Effective Java를 읽고 정리한 정리본입니다.

📌 Item 42 : 익명 클래스보다는 람다를 사용하라

🫧 함수 객체

예전에는 자바에서 함수 타입을 표현할 때 추상 메서드를 하나만 담은 인터페이스 (드물게는 추상 클래스)를 사용했다.

이런 인터페이스의 인스턴스를 함수 객체라고 하며, 특정 함수의 동작을 나타내는데 사용한다.

JDK 1.1 등장 이후 함수 객체는 주로 익명 클래스를 통해 만들었다. 다음 예시가 바로 익명 클래스로 함수 객체를 만든 코드 예이다.

Collections.sort(words, new Comparator<String>() {
  public int compare(String s1, String s2) {
    return Integer.compare(s1.length(), s2.length());
  }
})

이 코드에서 Comparator 인터페이스가 정렬을 담당하는 추상 전략을 뜻하며, 문자열을 길이에 따라 정렬한다는 의미의 코드를 익명 클래스로 구현했다.

그러나 익명 클래스 방식은 코드가 너무 길기 때문에 자바는 위 코드 예시와 같은 함수형 프로그래밍에 적합하지 않았다.

🫧 람다식

그러나 자바 8로 접어들며 람다식이 등장했다.

익명 클래스에서 람다식으로, 기존에 추상 메서드 하나짜리 인터페이스 (이하 함수형 인터페이스)의 구현 방식이 바뀌었다.

람다는 함수나 익명 클래스와 개념은 비슷하지만 코드는 훨씬 간결하다.

다음은 익명 클래스를 사용한 앞의 코드를 람다 방식으로 바꾼 모습이다.

Collections.sort(words, (s1, s2) -> Integer.compare(s1.length(), s2.length()));

아래는 Collections.sort() 메서드의 구현 형태이다.

public static <T> void sort(List<T> list, Comparator<? super T> c) {
        list.sort(c);
    }

List 형태의 words를 입력 받아 비교 메서드인 Comparator을 인자로 넘기는 방식의 코드이다.

여기서 람다, 매개변수 (s1, s2), 반환값의 타입은 각각 Comparator, String, int지만, 코드에서는 언급이 없다.

이는 컴파일러가 자동 추론을 해 주기 때문에 따로 지정할 필요가 없는 것이다.

제네릭의 로 타입을 쓰지 말라고 한 것, 제네릭을 쓰라고 한 것, 제네릭 메서드를 쓰라고 한 부분 모두 이러한 컴파일러의 자동 추론을 사용하기 위함이다.

추가로, 람다 자리에 비교자 생성 메서드를 사용하면 이 코드를 더욱 간결하게 만들 수 있다.

Collections.sort(words, comparingInt(String::length));

더 나아가 자바 8 때 List 인터페이스에 추가된 sort 메서드를 이용하면 더욱 짧게 축약이 가능하다.

words.sort(comparingInt(String::length));

✨ 동작을 람다로 구현한 예시

public enum Operation {
  PLUS("+") {
    public double apply(double x, double y) { return x + y; }
  },
  MINUS("-") {
    public double apply(double x, double y) { return x - y; }
  },
  TIMES("*") {
    public double apply(double x, double y) { return x * y; }
  },
  DIVIDE("/") {
    public double apply(double x, double y) { return x / y; }
  };

  private final String symbol;

  Operation(String symbol) { this.symbol = symbol; }

  @Override public String toString() { return symbol; }
  public abstract double apply(double x, double y);
}

위 코드는 apply 메서드의 동작을 상수마다 다르게 구현한 것이다.

람다를 이용하면 열거 타입의 인스턴스 필드를 이용하는 방식으로 상수별 다르게 동작하는 코드를 쉽게 구현이 가능하다.

코드는 아래와 같다.

public enum Operation {
  PLUS ("+", (x, y) -> x + y),
  MINUS ("-", (x, y) -> x - y),
  TIMES ("*", (x, y) -> x * y),
  DIVIDE ("/", (x, y) -> x / y);

  private final String symbol;
  private final DoubleBinaryOperator op;

  Operation(String symbol, DoubleBinaryOperator op) {
    this.symbol = symbol;
    this.op = op;
  }

  @Override public String toString() { return symbol; }
  public double apply (double x, double y) {
    return op.applyAsDouble(x, y);
  }
}

단순히 각 열거 타입 상수의 동작을 람다로 구현해 생성자에 넘기고, 생성자는 이 람다를 인스턴스 필드로 저장해둔다.

그런 다음 apply 메서드에서 필드에 저장된 람다를 호출하기만 하면 된다.

🫧 람다식 단점

이렇게 간단한 람다에도 단점은 존재한다.

✨ 1. 메서드나 클래스와 달리, 람다는 이름이 없고 문서화도 못한다.

코드 자체로 동작이 명확히 설명되지 않거나 코드 줄 수가 많아지면 람다를 쓰지 말아야 한다.

한 줄일 때가 가장 좋으며, 세 줄을 넘어가면 가독성이 나빠지니 람다를 더 간단히 줄여보거나 쓰지 않는 쪽으로 리팩터링하는 것이 좋다.

✨ 2. 열거 타입 생성자 안의 람다는 열거 타입의 인스턴스 멤버에 접근이 불가능하다.

자바는 컴파일 후 런타임 방식을 채택하고 있다. 컴파일 시 람다는 타입을 추론해야 하는데, 열거 타입의 인스턴스는 런타임에 만들어지기 때문에 접근이 불가능한 것이다.

그래서 열거 타입 생성자에 넘겨지는 인수들의 타입도 컴파일 타임에 추론해야 한다.

따라 상수별 동작을 단 몇 줄로 구현하기 어렵거나, 인스턴스 필드나 메서드를 사용해야만 하는 상황이라면 상수별 클래스 몸체 (수정 전 코드) 를 사용하는 것이 좋다.

✨ 3. 추상 클래스의 인스턴스를 만들 때 람다를 쓸 수 없다.

비슷하게 추상 메서드가 여러 개인 인터페이스의 인스턴스를 만들 때도 익명 클래스를 써야 한다.

✨ 4. 자신을 참조할 수 없다.

람다에서의 this 키워드는 바깥 인스턴스를 가리킨다.

반면 익명 클래스에서의 this는 익명 클래스의 인스턴스 자신을 가리킨다.

따라 함수 객체가 자신을 참조해야 한다면 반드시 익명 클래스를 써야 한다.

✨ 5. 직렬화가 불가능하다.

람다도 익명 클래스처럼 직렬화 형태가 구현별로 (가령 가상머신별로) 다를 수 있다.

따라서 익명 클래스와 마찬가지로 람다를 직렬화하는 일은 극히 삼가야 한다.

만일 직렬화해야만 하는 함수 객체가 있다면(ex, Comparator) private 정적 중첩 클래스의 인스턴스를 사용하자.