2014년, Java SE 8에는 람다 표현식 개념이 도입되었습니다. Java SE 8이 출시되기 전을 기억하신다면 익명 클래스 개념을 기억하실 것입니다. 그리고 람다 표현식이 익명 클래스의 인스턴스를 작성하는 또 다른 간단한 방법이라고 들어보셨을 수도 있습니다.

만약 기억이 나지 않는다면 익명 클래스에 대해 들어보거나 읽은 적이 있을 것이고, 이 모호한 문법이 두렵게 느껴질 수도 있습니다.

좋은 소식은 람다 표현식을 작성하는 방법을 이해하기 위해 익명 클래스를 살펴볼 필요가 없다는 것입니다. 게다가 많은 경우 Java 언어에 람다가 추가되면서 더 이상 익명 클래스가 필요하지 않습니다.

람다 표현식을 작성하는 과정은 크게 세 단계로 나뉩니다:

  • 작성하려는 람다 표현식의 타입 식별별하기
  • 구현할 올바른 메서드 찾기
  • 메서드를 구현하기.

이것이 정말 전부입니다. 이 세 단계를 자세히 살펴보겠습니다.

 

람다 표현식의 타입 식별하기

모든 것은 자바 언어로 된 유형을 가지고 있으며, 이 유형은 컴파일 시 알 수 있습니다. 그래서 람다 식의 유형을 찾는 것은 항상 가능합니다. 변수의 유형, 필드의 유형, 메서드 매개 변수의 유형 또는 반환된 메서드의 유형일 수 있습니다.

람다 식의 유형에는 함수형 인터페이스여야 한다는 제한이 있습니다. 따라서 함수형 인터페이스를 구현하지 않는 익명 클래스는 람다 표현식으로 작성할 수 없습니다.

함수형 인터페이스가 무엇인지에 대한 완전한 정의는 조금 복잡합니다. 이 시점에서 알아야 할 것은 함수형 인터페이스는 abstract 메서드가 하나만 있는 인터페이스라는 점입니다.

Java SE 8부터는 인터페이스에 구체적인 메서드가 허용된다는 점에 유의해야 합니다. 이러한 메서드는 인스턴스 메서드일 수 있으며, 이 경우 default methods 라고 하며 정적 메서드가 될 수 있습니다. 이러한 메서드는 abstract methods 가 아니므로 포함되지 않습니다.

인터페이스에 @FunctionalInterface 어노테이션을 추가해야 작동하나요?

아니요. 이 어노테이션은 인터페이스가 실제로 작동하는지 확인하는 데 도움을 주기 위한 것입니다. 기능적 인터페이스가 아닌 유형에 이 어노테이션을 추가하면 컴파일러에서 오류가 발생합니다.

함수형 인터페이스 예시

JDK API에서 가져온 몇 가지 예제를 살펴보겠습니다. 소스 코드에서 주석을 제거했습니다.

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

Runnable 인터페이스는 추상 메서드가 하나만 있기 때문에 실제로 작동합니다. @FunctionalInterface 어노테이션이 도우미로 추가되었지만 필요하지 않습니다.

@FunctionalInterface
public interface Consumer<T> {
 
    void accept(T t);
 
    default Consumer<T> andThen(Consumer<? super T> after) {
        // the body of this method has been removed
    }
}

Consumer<T> 인터페이스도 기능적입니다. 추상적인 메서드 1개와 포함되지 않는 기본 구체적 메서드 1개가 있습니다. 다시 한 번 말하지만, @FunctionalInterface 어노테이션은 필요하지 않습니다.

@FunctionalInterface
public interface Predicate<T> {
 
    boolean test(T t);
 
    default Predicate<T> and(Predicate<? super T> other) {
        // the body of this method has been removed
    }
 
    default Predicate<T> negate() {
        // the body of this method has been removed
    }
 
    default Predicate<T> or(Predicate<? super T> other) {
        // the body of this method has been removed
    }
 
    static <T> Predicate<T> isEqual(Object targetRef) {
        // the body of this method has been removed
    }
 
    static <T> Predicate<T> not(Predicate<? super T> target) {
        // the body of this method has been removed
    }
}

Predicate<T> 인터페이스는 조금 더 복잡하지만 여전히 기능적인 인터페이스입니다:

  • 하나의 추상 메서드가 있습니다.
  • 포함하지 않는 세 가지 기본 메서드가 있습니다.
  • 두 가지 정적 메서드가 있으며 둘 다 포함되지 않습니다.

 

올바른 구현 방법 찾기

이 시점에서 작성해야 하는 람다 식의 유형을 확인했으며, 좋은 소식은 가장 어려운 부분만 완료했다는 것입니다. 나머지는 매우 기계적이고 쉽게 할 수 있다는 것입니다.

람다 표현식은 이 함수형 인터페이스에서 유일한 추상 메서드의 구현입니다. 따라서 구현할 올바른 메서드를 찾는 것은 이 메서드를 찾기만 하면 됩니다.

잠시 시간을 내어 이전 단락의 세 가지 예에서 찾아보실 수 있습니다.

Runnable 인터페이스의 경우입니다:

public abstract void run();

Predicate 인터페이스의 경우입니다:

boolean test(T t);

그리고 Consumer<T> 인터페이스의 경우입니다:

void accept(T t);

 

람다 표현식으로 올바른 메서드 구현하기

Predicate<String>을 구현하는 첫 번째 람다 표현식 작성하기

이제 마지막 부분인 람다 자체를 작성해 보겠습니다. 여러분이 이해해야 할 것은 여러분이 작성하는 람다 표현식은 여러분이 찾은 추상 메서드의 구현이라는 것입니다. 람다 표현식 구문을 사용하면 코드에서 이 구현을 멋지게 인라인 처리할 수 있습니다.

이 구문은 세 가지 요소로 구성됩니다:

  • 매개변수 블록입니다;
  • 화살표를 묘사하는 작은 아스키 아트 조각인 ->입니다. Java는 빈약한 화살표(->)를 사용하고 살찐 화살표(=>)를 사용하지 않는다는 점에 유의하세요;
  • 메서드의 본문인 코드 블록입니다.

이에 대한 예를 살펴보겠습니다. 문자가 정확히 3개인 문자열에 대해 true를 반환하는 Predicate의 인스턴스가 필요하다고 가정해 보겠습니다.

  1. 람다 식의 타입은 Predicate입니다.
  2. 구현해야 하는 메서드는 boolean test(String s)입니다.

그런 다음 메서드의 서명을 간단히 복사/붙여넣기하는 매개변수 블록인 (String s)를 작성합니다.

그런 다음 ->라는 빈약한 화살표를 추가합니다.

그리고 메서드의 본문입니다. 결과는 다음과 같아야 합니다:

Predicate<String> predicate =
    (String s) -> {
        return s.length() == 3;
    };

구문 단순화하기

이 구문은 컴파일러가 많은 것을 추측할 수 있으므로 작성할 필요가 없으므로 단순화할 수 있습니다.

첫째, 컴파일러는 여러분이 Predicate 인터페이스의 추상 메서드를 구현하고 있다는 것을 알고 있으며, 이 메서드가 String을 인수로 받는다는 것을 알고 있습니다. 따라서 (String s)(s)로 단순화할 수 있습니다. 인자가 하나만 있는 경우에는 괄호를 제거하여 한 단계 더 나아갈 수도 있습니다. 그러면 인자 블록이 s가 됩니다. 인수가 두 개 이상이거나 인수가 없는 경우에는 괄호를 유지해야 합니다.

둘째, 메서드 본문에 코드가 한 줄만 있는 경우입니다. 이 경우 중괄호나 return 키워드가 필요하지 않습니다.

따라서 최종 구문은 실제로 다음과 같습니다:

Predicate<String> predicate = s -> s.length() == 3;

첫 번째 모범 사례는 람다를 짧게 작성하여 간단하고 가독성 있는 코드 한 줄로 만드는 것입니다.

Consumer<String> 구현하기

어느 순간 사람들은 지름길을 택하고 싶은 유혹을 받을 수 있습니다. 개발자가 “consumer가 object를 가져가도 아무것도 반환하지 않는다”고 말하는 것을 듣게 될 것입니다. 또는 “string에 정확히 세 개의 character가 있을 때 predicate는 True다.”입니다. 대부분의 경우 람다 표현식, 이 표현식이 구현하는 추상적인 메서드, 이 메서드를 보유하는 함수형 인터페이스 사이에 혼동이 발생합니다.

하지만 함수형 인터페이스와 그 추상적인 메서드, 그리고 이를 구현하는 람다 표현식은 서로 밀접하게 연결되어 있기 때문에 이러한 표현 방식은 실제로 완전히 합리적입니다. 따라서 모호함을 유발하지 않는 한 괜찮습니다.

String을 사용하여 System.out에 출력하는 람다를 작성해 보겠습니다. 구문은 다음과 같을 수 있습니다:

Consumer<String> print = s -> System.out.println(s);

여기서는 람다 식의 단순화된 버전을 직접 작성했습니다.

Runnable 구현하기

Runnable을 구현하면 void run()의 구현을 작성하는 것으로 밝혀졌습니다. 이 인자 블록은 비어 있으므로 괄호로 묶어서 작성해야 합니다. 기억하세요: 인수가 하나만 있는 경우에만 괄호를 생략할 수 있으며, 여기서는 0입니다.

이제 실행 중임을 알려주는 runnable을 작성해 보겠습니다:

Runnable runnable = () -> System.out.println("I am running");

 

람다 표현식 호출하기

이전 Predicate 예제로 돌아가서 이 predicate가 메서드에 정의되어 있다고 가정해 보겠습니다. 주어진 문자열이 실제로 길이 3인지 테스트하기 위해 어떻게 사용할 수 있을까요?

람다를 작성하는 데 사용한 구문과 상관없이 이 람다는 Predicate 인터페이스의 인스턴스라는 점을 염두에 두어야 합니다. 이 인터페이스는 String을 받아 [boolean]을 반환하는 test()라는 메서드를 정의합니다.

이를 메서드로 작성해 보겠습니다:

List<String> retainStringsOfLength3(List<String> strings) {
 
    Predicate<String> predicate = s -> s.length() == 3;
    List<String> stringsOfLength3 = new ArrayList<>();
    for (String s: strings) {
        if (predicate.test(s)) {
            stringsOfLength3.add(s);
        }
    }
    return stringsOfLength3;
}

이전 예제에서와 마찬가지로 predicate를 어떻게 정의했는지 주목하세요. Predicate 인터페이스는 이 메서드 boolean test(String)를 정의하므로 Predicate 타입의 변수를 통해 Predicate에 정의된 메서드를 호출하는 것은 완전히 합법적입니다. 이 predicate 변수는 메서드를 정의하는 것처럼 보이지 않기 때문에 처음에는 혼란스러워 보일 수 있습니다.

이 튜토리얼의 뒷부분에서 이 코드를 작성하는 훨씬 더 좋은 방법이 있으니 참고해 주세요.

따라서 람다를 작성할 때마다 이 람다가 구현하는 인터페이스에 정의된 모든 메서드를 호출할 수 있습니다. 추상 메서드를 호출하면 이 람다가 해당 메서드의 구현이므로 람다 자체의 코드가 호출됩니다. 기본 메서드를 호출하면 인터페이스에 작성된 코드가 호출됩니다. 람다가 기본 메서드를 재정의할 수 있는 방법은 없습니다.

 

로컬 Value 캡처

익숙해지면 람다를 작성하는 것이 매우 자연스러워집니다. 람다는 컬렉션 프레임워크, 스트림 API 및 JDK의 다른 많은 곳에 매우 잘 통합되어 있습니다. Java SE 8부터는 람다를 어디에나 사용할 수 있습니다.

람다 사용에는 제약이 있으며 컴파일 시 오류가 발생할 수 있으므로 이를 이해해야 합니다.

다음 코드를 살펴보겠습니다:

int calculateTotalPrice(List<Product> products) {
 
    int totalPrice = 0;
    Consumer<Product> consumer =
        product -> totalPrice += product.getPrice();
    for (Product product: products) {
        consumer.accept(product);
    }
}

이 코드가 보기에는 좋아 보일지라도 컴파일을 시도하면 이 Consumer 구현에서 totalPrice를 사용할 때 다음과 같은 오류가 발생합니다:

람다 식에 사용되는 변수는 final이거나 사실상 final이어야 합니다.

그 이유는 다음과 같습니다: 람다는 외부에 정의된 변수를 수정할 수 없습니다. 람다는 변수가 final, 즉 불변인 경우에만 읽을 수 있습니다. 변수에 접근하는 이 과정을 capturing 이라고 하는데, 람다는 변수를 capturing 할 수 없고 값만 capturing 할 수 있습니다. final 변수는 사실 값입니다.

오류 메시지에서 변수가 _final_이 될 수 있다고 알려주는데, 이는 Java 언어의 고전적인 개념입니다. 또한 변수가 effectively final 일 수 있음을 알려줍니다. 이 개념은 Java SE 8에서 도입되었으며, 사용자가 명시적으로 변수를 final로 선언하지 않더라도 컴파일러가 이를 대신 수행할 수 있습니다. 이 변수가 람다에서 읽혀지고 사용자가 이를 수정하지 않으면 컴파일러가 final 선언을 멋지게 추가합니다. 물론 이 작업은 컴파일된 코드에서 수행되며 컴파일러는 소스 코드를 수정하지 않습니다. 이러한 변수를 final 이라고 하지 않고 effectively final 변수라고 합니다. 이것은 매우 유용한 기능입니다.

 

람다 직렬화

람다 표현식은 직렬화할 수 있도록 만들어졌습니다.

람다 표현식을 직렬화하는 이유는 무엇인가요? 람다 표현식은 필드에 저장될 수 있으며, 이 필드는 생성자나 설정자 메서드를 통해 액세스할 수 있습니다. 그러면 런타임 시 객체 상태에 람다 표현식이 있어도 이를 인지하지 못할 수 있습니다.

따라서 기존 직렬화 가능한 클래스와의 하위 호환성을 유지하기 위해 람다 표현식을 직렬화할 수 있습니다.

More Learning