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의 인스턴스가 필요하다고 가정해 보겠습니다.
- 람다 식의 타입은
Predicate입니다. - 구현해야 하는 메서드는
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 변수라고 합니다. 이것은 매우 유용한 기능입니다.
람다 직렬화
람다 표현식은 직렬화할 수 있도록 만들어졌습니다.
람다 표현식을 직렬화하는 이유는 무엇인가요? 람다 표현식은 필드에 저장될 수 있으며, 이 필드는 생성자나 설정자 메서드를 통해 액세스할 수 있습니다. 그러면 런타임 시 객체 상태에 람다 표현식이 있어도 이를 인지하지 못할 수 있습니다.
따라서 기존 직렬화 가능한 클래스와의 하위 호환성을 유지하기 위해 람다 표현식을 직렬화할 수 있습니다.