함수형 인터페이스(Functional Interface)

람다식을 이해하기 전에 함수형 인터페이스에 대한 이해가 필요합니다. 함수형 인터페이스(Funcational Interface)란 하나의 추상 메서드를 갖는 인터페이스를 말합니다. 자바 8 이후에 추가되었으며, 이를 SAM 인터페이스(Single Abstract Method interface)라고도 부릅니다. 함수형 인터페이스의 대표적인 예로는 스레드에서 살펴봤던 Runnable 인터페이스를 꼽을 수 있습니다.

@FunctionalInterface
public interface Runnable {
    public abstract void run();
}

@FunctionalInterface

자바 8 이후부터는 함수형 인터페이스를 나타내는 @FunctionalInterface 내장 애노테이션을 사용할 수 있습니다. 이 애노테이션은 컴파일 타임에 해당 인터페이스가 함수형 인터페이스가 맞는지 검사할 때 유용하게 사용할 수 있습니다.

@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface FunctionalInterface {}

이 애노테이션은 함수형 인터페이스에 사용하거나 메타 애노테이션으로 사용할 수 있으며, @FunctionalInterface 애노테이션이 붙은 인터페이스가 함수형 인터페이스 조건을 만족하지 않으면 컴파일러가 에러를 내보냅니다.

@FunctionalInterface
// 에러: 잘못된 @FunctionalInterface 애노테이션. MyFunctionalInterface는 함수형 인터페이스가 아닙니다.
interface MyFunctionalInterface {
    // 추상 메서드는 하나만 존재할 수 있다.
	void foo();
	void bar();
}

함수형 인터페이스마다 반드시 붙여야 하는 것은 아니며, 이 애노테이션이 붙지 않아도 아래의 인터페이스는 여전히 함수형 인터페이스입니다.

interface MyFunctionalInterface {
    void foo();
}

함수형 인터페이스에는 하나의 추상 메서드 외에도 여러 개의 디폴트 메서드나 정적 메서드가 있을 수도 있습니다. 여전히 하나의 추상 메서드를 멤버로 갖고 있는 인터페이스이므로 함수형 인터페이스라고 말할 수 있습니다.

@FunctionalInterface
interface MyFunctionalInterface {
	void foo();
	default void bar() { /* ... */ }
	static void baz() { /* ... */ }
}

여기서 주의할 점은 예외적으로 java.lang.Object 클래스에 선언된 public 메서드를 오버라이딩하는 메서드는 인터페이스의 추상 메서드에 포함시키지 않습니다. 이런 메서드 선언은 인터페이스 내에 우리가 선언하지 않아도 암시적으로 이루어지며, 이 인터페이스를 구현하는 클래스는 이를 자동으로 구현해줍니다.

@FunctionalInterface
interface MyFunctionalInterface {
	void foo();
	
	@Override
	int hashCode(); // public native int hashCode()
	
	@Override
	boolean equals(Object obj); // public boolean equals(Object obj)
}

람다식(Lambda Expression)

람다식은 간단하게 말하면 이름이 없는 익명 메서드라고 생각할 수 있습니다. 좀 더 자세히 말하면 람다식은 함수형 인터페이스(functional interface)에 있는 추상 메서드를 구현하기 위해서 사용됩니다. 이 기능은 자바 8 이후부터 사용할 수 있습니다.

구문

람다식은 아래와 같이 정의할 수 있습니다. 여기서 화살표 연산자 또는 람다 연산자로 불리는 ->로 람다 매개변수와 몸체(body)를 구분합니다. 몸체에는 블록이나 표현식(expression)이 들어갈 수 있습니다.

(람다 매개변수) -> 몸체

람다 매개변수

람다 매개변수는 우리가 메서드를 선언할 때 살펴봤던 것처럼 각 매개변수는 콤마(,)로 구분합니다. 물론 각 매개변수의 타입이나 수는 함수형 인터페이스의 추상 메서드 선언에서 확인할 수 있는 것과 동일해야 합니다.

(int a, int b, int c) -> { /* ... */ }

컴파일러가 추상 메서드 선언에서 타입을 추론할 수 있기 때문에 람다 매개변수의 타입을 아래와 같이 생략할 수도 있습니다. 이 방식은 타입을 명시적으로 선언하는 방식과 함께 섞어서 사용할 수 없음에 주의합시다.

(a, b, c) -> { /* ... */ } // OK
(int a, int b, int c) -> { /* ... */ } // OK
(a, int b, c) -> { /* ... */ } // 두 방식을 섞어서 사용할 수는 없다.

매개변수가 하나라면 다음과 같이 괄호를 생략할 수도 있습니다.

() -> { /* ... */ }

람다 몸체

람다 몸체에는 표현식이나 블록이 들어갈 수 있습니다. 함수형 인터페이스의 추상 메서드 반환형에 따라 몸체에서 해당 타입의 값을 반환해야 합니다.

// 반환형이 void인 경우
() -> {}
() -> System.out.println("blah");
() -> { System.out.println("blah"); }

// 반환형이 int인 경우
() -> 42;
() -> { return 42; }

// 반환형이 void이거나 그 외의 타입(int, double, ...)인 경우
// 아래와 같이 정상적으로 종료되지 않는 경우에는 값을 반환해야 할 때도 에러가 발생하지 않는다.
() -> { while(true); }
() -> { throw new RuntimeException(); }

람다 살펴보기

지역 변수로의 접근 제한

이전에 살펴봤던 로컬 클래스(local class)와 마찬가지로 람다식 안에서 자신을 둘러싸는 클래스의 멤버나 메서드의 지역 변수에도 접근할 수 있습니다. 하지만 접근할 때는 해당 변수가 final로 선언이 되었거나, 사실상 final(effectively final)이어야 합니다. 이유에 별다른 관심이 없는 분들은 이를 지켜야 할 규칙이라고 생각하고 다음으로 넘어가셔도 아무런 지장이 없습니다.

class Foo {
    public void doSomething() {
        int localVar = 10;
        // 에러: 람다식에 사용된 변수는 final 또는 사실상 final(effectively final)이어야 합니다.
        Runnable r = () -> System.out.println(localVar);
        localVar = 20;
        // ...
    }
}

이유를 알아보기 위해서는 잠시 람다식을 조금은 파고들 필요가 있습니다. 람다식은 최적화를 위해서 내부 클래스, 메서드 핸들, 다이내믹 프록시 등 상황에 맞는 다양한 전략을 사용하여 람다식을 구현하는 인스턴스를 만들며, 더 나아가서 지연 초기화나 캐싱 메커니즘 등을 통해 성능 개선을 도모합니다. 이런 전략 중에서 람다식의 인스턴스는 대부분 내부 클래스를 통해서 만들어집니다. 향후에는 구현이 어떻게 변할지는 모르겠지만 지금 시점에서는 그렇게 틀린 말도 아닙니다. 설명의 용이함을 위해서 내부 클래스 전략을 통해 이해하는 것이 가장 간단합니다. 바로 예시를 살펴봅시다.

class Foo {
	public void doSomething() {
		int localVar = 10;
		// 설명은 이렇게 했지만 람다식과 내부 클래스는 동일하지 않다는 사실에 주의하자!
		Runnable r = new AnonymousClass$1(localVar);
		// ...
	}
}

final class AnonymousClass$1 implements Runnable {
	private final int localVar;
	// 람다식 내에서 접근한 외부 변수들은 생성자로 값이 넘어가서 복사된다. 
	AnonymousClass$1(int localVar) {
		this.localVar = localVar;
	}

	@Override
	public void run() {
		System.out.println(localVar);
	}
}

컴파일러가 생성할 코드를 대략적으로 살펴보면 위와 같을 것입니다. 람다식 내부에서 접근한 지역 변수의 값은 생성자로 넘어가서 복사됩니다. 즉, 해당 지역 변수의 사본(copy)을 만드는 셈입니다. 다시 말해서, 원본 변수(localVar)의 값이 변경되어도 사본의 값은 변경되지 않기 때문에 개발자 입장에서는 상당히 혼란스러울 수 있습니다. 따라서 원본 변수의 값이 변경되지 않는 것이 좋습니다.

int localVar = 10;
Runnable r = () -> System.out.println(localVar); // 사본을 지님
localVar = 20; // 원본 변수 변경

메모리 구조에 기본적인 배경지식이 있다면 다음과 같이 이해해도 무방합니다. JLS에서 확인할 수 있듯이, 스레드 간에 공유할 수 있는 메모리를 공유 메모리 혹은 힙 메모리라고 부르는데, 모든 인스턴스 필드나 정적 필드, 배열 요소는 바로 이 힙 메모리에 저장됩니다. 하지만 지역 변수나 메서드의 매개변수, catch절에서 볼 수 있는 예외 변수는 스레드 간에 공유할 수 없습니다. 여기서 핵심은 스택에 저장되는 지역 변수는 멀티스레드 환경에서 스레드 간에 공유될 수 없다는 사실입니다. 하지만 람다식은 자신이 선언된 스레드에서 다른 스레드로 넘어갈 수 있으며, 넘겨받은 스레드에 의해서 지역 변수에 접근하여 변경을 시도할 수 있다는 문제점이 있습니다. 따라서 지역 변수의 변경을 차단하면 멀티스레드 환경에서 일어날 수 있는 동시성 문제를 예방할 수 있습니다.

익명 내부 클래스와 비교하기

위에서 잠시 살펴봤지만 여기서 조금만 다시 살펴보도록 합시다. 새로운 스레드를 생성하기 위해서 Runnable 인터페이스를 구현하는 익명 내부 클래스를 만들기 위해 아래와 같이 코드를 작성해야 했습니다.

new Thread(new Runnable() {
    @Override
    public void run() {
        // ...
    }
}).start();

하지만 코드를 보면 다소 장황합니다. 함수형 인터페이스인 Runnable은 추상 메서드가 하나만 있기 때문에 어떤 메서드를 구현할 것인지는 명확하기 때문입니다. 람다식을 사용하면 아래와 같이 코드를 간결하고 읽기 쉽도록 만들 수 있습니다.

new Thread(() -> { /* ... */ }).start();

물론 람다식이 단순하게 함수형 인터페이스를 구현하는 익명 내부 클래스를 편하게 만들기 위해서 자바에서 제공하는 구문만은 아님에 주의합시다.

예시 살펴보기

JLS에서 람다식 예시를 가져와 봤는데, 아래의 예시를 하나하나씩 살펴보도록 하겠습니다.

() -> {}                // 매개변수 없음. 반환값이 없다.
() -> 42                // 매개변수 없음. 몸체에 표현식이 왔다.
() -> null              // 매개변수 없음. 몸체에 표현식이 왔다.
() -> { return 42; }    // 매개변수 없음. 몸체에 return문이 달린 블록이 왔다.
() -> { System.gc(); }  // 매개변수 없음. 몸체에 반환값이 없는 블록이 왔다.

() -> {                 // 몸체에 return문이 달린 복잡한 블록이 왔다.
  if (true) return 12;
  else {
    int result = 15;
    for (int i = 1; i < 10; i++)
      result *= i;
    return result;
  }
}                          

(int x) -> x+1              // 타입을 선언한 단일 매개변수
(int x) -> { return x+1; }  // 타입을 선언한 단일 매개변수
(x) -> x+1                  // 타입 추론을 통해 단일 매개변수를 선언할 때는
x -> x+1                    // 괄호를 생략할 수도 있다
int x -> x+1                // 컴파일 에러. 타입 선언 시에는 괄호 생략 불가.

(String s) -> s.length()      // 타입을 선언한 단일 매개변수
(Thread t) -> { t.start(); }  // 타입을 선언한 단일 매개변수
s -> s.length()               // 타입 추론 방식의 단일 매개변수
t -> { t.start(); }           // 타입 추론 방식의 단일 매개변수

(int x, int y) -> x+y  // 타입을 선언한 여러 개의 매개변수
(x, y) -> x+y          // 타입 추론 방식의 여러 개의 매개변수
(x, int y) -> x+y    // 컴파일 에러. 타입 추론과 타입 선언을 혼용할 수 없음.
(x, final y) -> x+y  // 컴파일 에러. 타입 추론을 사용할 때는 제어자 사용 불가.

이번에는 람다 몸체에 관련된 예시를 살펴봅시다. 사실상 final(effectively final)은 중첩 클래스 편에서 살펴봤지만, 기억이 가물가물하신 분들을 위해서 다시 한번 살펴보도록 하겠습니다.

사실상 final(effectively final)

말 그대로 사실상 final입니다. final로 선언되지는 않았지만 초기화된 후에도 값이 변경되지 않는 변수나 매개변수는 사실상 final(effectively final)이라고 할 수 있습니다.

class Foo { 
	public void doSomething() {
		int x = 10; // effectively final
		class Bar {
			public void doSomething() {
				System.out.println(x);
			}
		}
		// ...
	}
}

하지만 아래와 같은 경우는 사실상 final이라고 할 수 없습니다. 초기화된 후에 값이 변경되었기 때문입니다.

class Foo { 
	public void doSomething() {
		int x = 10;
		// ...
		x = 20; // 중간에 값이 한 번 변경됨
		class Bar {
			public void doSomething() {
				// 지역 변수 x는 final이나 effectively final이어야 에러가 발생하지 않는다.
				System.out.println(x);
			}
		}
		// ...
	}
}
void m1(int x) {
    int y = 1;
    foo(() -> x+y);
    // 에러 없음. x와 y는 둘 다 사실상 final이다.
}

void m2(int x) {
    int y;
    y = 1;
    foo(() -> x+y);
    // 에러 없음. x와 y는 둘 다 사실상 final이다.
}

void m3(int x) {
    int y;
    if (...) y = 1;
    foo(() -> x+y);
    // 컴파일 에러. y는 사실상 final이지만, 확실하게 할당되지 않는다. 조건문이 거짓이면 할당되지 않을 수도 있기 때문이다.
}

void m4(int x) {
    int y;
    if (...) y = 1; else y = 2;
    foo(() -> x+y);
    // 에러 없음: x와 y는 둘 다 사실상 final이다.
}

이어서 예시를 살펴봅시다.

void m5(int x) {
    int y;
    if (...) y = 1;
    y = 2;
    foo(() -> x+y);
    // 컴파일 에러: y는 사실상 final이 아니다.
}

void m6(int x) {
    foo(() -> x+1);
    x++;
    // 컴파일 에러. x는 사실상 final이 아니다.
}

void m7(int x) {
    foo(() -> x=1);
    // 컴파일 에러. x는 사실상 final이 아니다.
}

void m8() {
    int y;
    foo(() -> y=1);
    // 컴파일 에러. 람다식으로 들어가기 전에 y는 확실하게 할당되지 않았다.
}

void m9(String[] arr) {
    for (String s : arr) {
        foo(() -> s);
        // 에러 없음: s는 사실상 final이다.
        // (각 반복에서 s는 새로운 변수이기 때문이다. 즉, s는 재사용 되지 않는다. 이해가 되지 않는다면 m10()을 확인하자.)
    }
}

void m10(String[] arr) {  
    for (int i = 0; i < arr.length; i++) {  
        String s = arr[i];  
        foo(() -> s);
        // 에러 없음: s는 사실상 final이다.
    }  
}

void m11(String[] arr) {
    for (int i = 0; i < arr.length; i++) {
        foo(() -> arr[i]);
        // 컴파일 에러. i는 사실상 final이 아니다.
        // (매 반복마다 변수 i의 값이 변하기 때문에 사실상 final이 아니다.)
    }
}

함수형 인터페이스

직접 함수형 인터페이스를 선언할 수도 있지만 자주 사용되는 형태의 함수형 인터페이스는 자바에서 이미 제공하고 있습니다. 하나하나씩 살펴보도록 합시다.

Function

이름에서 알 수 있듯이 하나의 값을 인수로 전달하면 어떤 값을 반환할 때는 이 함수형 인터페이스를 사용할 수 있습니다. 아래에서 볼 수 있듯이, T는 인수의 타입, R는 반환형을 말합니다.

@FunctionalInterface
public interface Function<T, R> {
    R apply(T t);

    default <V> Function<V, R> compose(Function<? super V, ? extends T> before) {
        Objects.requireNonNull(before);
        return (V v) -> apply(before.apply(v));
    }

    default <V> Function<T, V> andThen(Function<? super R, ? extends V> after) {
        Objects.requireNonNull(after);
        return (T t) -> after.apply(apply(t));
    }

    static <T> Function<T, T> identity() {
        return t -> t;
    }
}

그 외에도 compose, andThen, identity가 보입니다. 아래에서 확인할 수 있듯이 compose() 메서드는 합성함수를 만들 때 사용할 수 있으며, f.compose(g)와 g.andThen(f)는 동일합니다.

Function<Integer, Integer> f = x -> x * 3;
Function<Integer, Integer> g = x -> x + 5;

System.out.println("f(3): " + f.apply(3)); // 9
System.out.println("g(3): " + g.apply(3)); // 8

Function<Integer, Integer> fg = f.compose(g); // f(g(x))
System.out.println("f(g(3)): " + fg.apply(3)); // 24

fg = g.andThen(f); // f(g(x))
System.out.println("f(g(3)): " + fg.apply(3)); // 24

identity() 메서드는 말 그대로 항등함수로, 넘긴 인수를 그대로 반환하는 메서드입니다.

Function<Integer, Integer> f = Function.identity(); // f(x) = x  
Function<Integer, Integer> g = x -> x; // g(x) = x  
  
System.out.println(f.apply(5)); // 5  
System.out.println(g.apply(5)); // 5

여기서 제네릭의 타입 인수에는 기본 타입이 올 수 없으므로, 인수나 반환형에 기본 타입을 사용하고 싶은 경우에 사용할 수 있는 함수형 인터페이스는 따로 제공하고 있습니다. 제공하는 인터페이스를 살펴보면 IntFunction, DoubleFunction, IntToLongFunction 등 다양한 인터페이스를 확인할 수 있으며 더 자세한 내용은 이곳에서 확인하실 수 있습니다.

@FunctionalInterface
public interface IntToLongFunction {
    long applyAsLong(int value);
}

한 개의 인수가 아닌 두 개의 인수를 받고 싶을 때는 BiFunction 같이 접두사 Bi를 붙이면 됩니다.

@FunctionalInterface
public interface BiFunction<T, U, R> {
    R apply(T t, U u);
    // ...
}

Supplier

이름에서 알 수 있듯이 Supplier는 무언가를 공급, 즉 어떤 결과를 반환할 때 사용할 수 있는 함수형 인터페이스입니다.

@FunctionalInterface  
public interface Supplier<T> {
    T get();  
}

여기서 제네릭의 타입 인수에는 기본 타입이 올 수 없으므로 기본 타입의 값을 반환하려고 할 때 사용할 수 있는 함수형 인터페이스는 따로 제공하고 있습니다. 필요하다면 DoubleSupplier, IntSupplier, LongSupplier, BooleanSupplier를 사용할 수 있습니다.

@FunctionalInterface  
public interface IntSupplier {
    int getAsInt();  
}

Consumer

Supplier와는 반대로 무언가를 소비, 즉 어떤 인수(argument)를 받고서 아무런 결과도 반환하지 않을 때는 이 함수형 인터페이스를 사용할 수 있습니다.

@FunctionalInterface
public interface Consumer<T> {
    void accept(T t);

    default Consumer<T> andThen(Consumer<? super T> after) {
        Objects.requireNonNull(after);
        return (T t) -> { accept(t); after.accept(t); };
    }
}

여기서 디폴트 메서드인 andThen()는 아래와 같이 두 개 이상의 Consumer를 서로 연결시킬 때 사용할 수 있습니다. 코드에서 보는 바와 같이 first가 먼저 실행되고 이어서 second가 실행됩니다.

Consumer<String> first = x -> { /* ... */ };  
Consumer<String> second = y -> { /* ... */ };  
Consumer<String> result = first.andThen(second);

Supplier와 마찬가지로 기본 타입에 대해서는 별도의 함수형 인터페이스를 제공하고 있습니다. DoubleConsumer, LongConsumer, IntConsumer 등 다양한 인터페이스를 확인할 수 있으며 더 자세한 내용은 이곳에서 확인할 수 있습니다. 그리고 Function과 마찬가지로 앞에 접두사 Bi를 붙이면 두 개의 인수를 받을 수 있습니다.

@FunctionalInterface
public interface BiConsumer<T, U> {
    void accept(T t, U u);

    default BiConsumer<T, U> andThen(BiConsumer<? super T, ? super U> after) {
        Objects.requireNonNull(after);

        return (l, r) -> {
            accept(l, r);
            after.accept(l, r);
        };
    }
}

Predicate

자바에서 술어(predicate)는 인수와 관련된 어떤 조건을 검사하여 해당 조건을 만족하면 참을 반환하고 아니면 거짓을 반환하는 메서드를 말합니다. 아래 선언에서 볼 수 있듯이, 술어를 논리적으로 연결하거나 부정하는 등의 메서드를 확인할 수 있습니다.

@FunctionalInterface
public interface Predicate<T> {
    boolean test(T t);

    default Predicate<T> and(Predicate<? super T> other) {
        Objects.requireNonNull(other);
        return (t) -> test(t) && other.test(t);
    }

    default Predicate<T> negate() {
        return (t) -> !test(t);
    }

    default Predicate<T> or(Predicate<? super T> other) {
        Objects.requireNonNull(other);
        return (t) -> test(t) || other.test(t);
    }

    static <T> Predicate<T> isEqual(Object targetRef) {
        return (null == targetRef)
                ? Objects::isNull
                : object -> targetRef.equals(object);
    }

    @SuppressWarnings("unchecked")
    static <T> Predicate<T> not(Predicate<? super T> target) {
        Objects.requireNonNull(target);
        return (Predicate<T>)target.negate();
    }
}

아래 예시에서 보여준 기능 말고도 and(), or()을 통해 두 개 이상의 술어를 서로 논리적으로 연결할 수도 있습니다. 위에 정의된 디폴트 메서드를 살펴보면 이해가 갈 것입니다.

Predicate<String> isEmptyString = string -> string.length() == 0;  
  
System.out.println(isEmptyString.test("ABC")); // false  
System.out.println(isEmptyString.test("")); // true  
  
Predicate<String> isNotEmptyString = isEmptyString.negate();  
  
System.out.println(isNotEmptyString.test("ABC")); // true  
System.out.println(isNotEmptyString.test("")); // false

Predicate도 기본 타입에 대해서는 DoublePredicate, LongPredicate, IntPredicate와 같이 별도의 함수형 인터페이스를 제공하고 있습니다. BiPredicate처럼 앞에 접두사 Bi를 붙이면 인수 두 개를 받을 수도 있습니다.

@FunctionalInterface  
public interface BiPredicate<T, U> {  
    boolean test(T t, U u);
    // ...
}

메서드 참조(Method Reference)

메서드 참조는 말 그대로 기존 메서드에 대한 참조를 말하는 것인데, 함수형 인터페이스를 구현하는 클래스나 람다식 대신에 메서드 참조를 사용할 수 있습니다.

람다식을 이용한 예시 살펴보기

메서드 참조를 살펴보기 전에 사람의 이름을 가나다순으로 정렬한 뒤 출력하는 예시를 간단히 살펴보도록 하겠습니다. 

public class MethodReferenceExample {
    public static void main(String[] args) {
        List<String> names = new ArrayList<>();
        names.add("홍길동");
        names.add("홍길순");
        names.add("김철수");
        names.add("김영희");

        names.sort((n1, n2) -> n1.compareTo(n2));
        names.forEach(n -> System.out.println(n));
    }
}

위의 코드에서 람다식 부분을 살펴보면 기존의 메서드를 그대로 호출하고 있는 것을 볼 수 있습니다.

names.sort((n1, n2) -> n1.compareTo(n2));
names.forEach(n -> System.out.println(n));

이렇게 람다 본체가 어떤 메서드 하나를 호출하는 것에 그친다면 메서드 참조를 통해서 아래와 같이 축약할 수 있습니다. 다소 장황했던 표현이 축약되면서 간결해지고 가독성이 한결 높아진 것을 볼 수 있습니다. 위의 코드는 아래의 코드와 의미상으로 동일합니다.

names.sort(String::compareTo);  
names.forEach(System.out::println);

사용 방법

메서드 참조는 아래와 같이 클래스명이나 객체명을 메서드명과 구분하기 위해서 메서드명 앞에 :: 연산자를 사용합니다.

Person::compareByAge
MethodReferencesExamples::appendStrings

메서드 참조에는 다음과 같이 4가지 종류가 있는데, 하나하나씩 차례대로 살펴보도록 하겠습니다.

정적 메서드 참조

클래스에 있는 정적 메서드에 대한 메서드 참조를 만들 수 있습니다. 예를 들어서, Integer의 parseInt() 메서드는 Integer::parseInt로 나타낼 수 있습니다.

// ... = (String n) -> Integer.parseInt(n)와 동일
Function<String, Integer> stringToInt = Integer::parseInt;
System.out.println(stringToInt.apply("3") + 12);

이어서 설명할 메서드 참조 유형들도 마찬가지지만, 여기서 주의할 점이 하나 있습니다. 당연할지도 모르겠지만 메서드 참조는 함수형 인터페이스의 추상 메서드 시그니처와 호환이 되어야 합니다. 위 예시의 경우에는 메서드 참조 Integer::parseInt가 문자열 타입(String)의 인수를 넘겨받고, 정수 타입(Integer)을 반환해야 합니다.

Function<String, Number> stringToInt = Integer::parseInt;

물론 위와 같이 수정해도 에러는 발생하지 않습니다. Integer.parseInt() 메서드의 반환형은 정수 타입(Integer)인데 이는 Number의 자식 타입이므로 호환이 되기 때문입니다.

특정 객체의 인스턴스 참조

특정 객체의 인스턴스 메서드에 대한 메서드 참조를 만들 수도 있습니다.

public class MethodReferenceExample {
    private void display(String str) {
        System.out.println(str);
    }

    public static void main(String[] args) {
        MethodReferenceExample ex = new MethodReferenceExample();
        // ... = (String str) -> ex.display(str)와 동일
        Consumer<String> printString = ex::display;

        printString.accept("안녕하세요!");
    }
}

특정 타입의 인스턴스 메서드 참조

여기서 특정 타입은 String, Integer 등과 같은 클래스 타입을 말합니다. 아래의 예시에서 String.compareTo() 메서드는 정적 메서드가 아닌 인스턴스 메서드입니다.

public class MethodReferenceExample {
    public static void main(String[] args) {
        String[] names = {"Liam", "Noah", "Oliver", "Elijah", "William", "James", "Benjamin"};

        // Arrays.sort(names, (String s1, String s2) -> s1.compareTo(s2))와 동일
        Arrays.sort(names, String::compareTo);
        for (String name : names) {
            System.out.println(name);
        }
    }
}

생성자 참조

생성자 참조는 조금 특이합니다. 클래스명과 new라는 이름을 사용해서 해당 클래스의 생성자를 참조할 수 있습니다.

class Person {
    private String name;

    public Person(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }
    // ...
}

interface PersonFactory {
    Person getPerson(String name);
}

public class MethodReferenceExample {
    public static void main(String[] args) {
	    // ... = (String name) -> new Person(name)와 동일
        PersonFactory personFactory = Person::new;
        Person person = personFactory.getPerson("홍길동");

        System.out.println("person.getName() = " + person.getName());
    }
}

'프로그래밍 관련 > 자바' 카테고리의 다른 글

27편. 컬렉션(Collections)  (1) 2022.04.26
35편. 스트림(Streams) (1)  (0) 2022.04.04
34편. 애노테이션(Annotation)  (0) 2022.02.18
26편. 제네릭(Generic)  (0) 2022.02.06
25편. 중첩 클래스(Nested Class)  (1) 2022.02.06