[아이템 42] 익명 클래스보다는 람다를 사용하라

2023. 5. 12. 11:05Java/Effective Java

람다란?

  • 자바 8에서 추상 메서드 하나만 가지고 있는 인터페이스를 함수형 인터페이스라고 부른다.
  • 이 인터페이스들의 인스턴스를 람다식(lambda expression, 혹은 짧게 람다)를 사용해 만들 수 있다.

 

 

예) 문자열 길이순으로 정렬

정렬을 위한 비교 함수로 익명 클래스 사용

  • 익명 클래스의 인스턴스를 함수 객체로 사용 - 낡은 기법
  • Comparator 인터페이스가 정렬을 담당하는 추상 전략을 뜻하며, 구체적인 전략을 익명 클래스로 구현
Collections.sort(words, new Comparator<String>() {
	public int compare(String s1, String s2) {
		return Integer.compare(s1.length(), s2.length());
	}
});

 

람다식을 함수 객체로 사용

  • 람다, 매개변수(s1, s2), 반환값의 타입은 각각 (Comparator<String>), String, int 지만 코드에서는 언급이 없다. 컴파일러가 문맥을 살펴보고 타입을 추론할 것이다. ⇒ 못할 경우 직접 명시
  • 타입을 명시해야하는 코드가 더 명확할 때만 제외하고는, 람다의모든 매개변수 타입은 생략하자.
Collections.sort(words, (s1, s2) -> Integer.compare(s1.length(), s2.length()));

// 비교자 생성 메서드를 사용하여 더 간결하게 변경
Collections.sort(words, comparingInt(String::length));

// List 인터페이스에 추가된 sort 메서드 사용하여 더 간결하게 변경
words.sort(comparingInt(String::length));

 

 

예) 함수 객체를 실용적 사용

상수별 클래스 몸체와 데이터를 사용한 열거 타입

  • 상수별 클래스 몸체를 구현하는 방식보다는 열거 타입에 인스턴스 필드를 두는 편이 낫다고 했었음
  • 열거 타입에 apply 라는 추상 메서드를 선언하고 각 상수별 클래스 몸체, 즉 각 상수에서 자신에게 맞게 재정의 하는 방법 사용 ⇒ 상수별 메서드 구현
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;
    }

    public abstract double apply(double x, double y);
}

 

함수 객체(람다)를 인스턴스 플드에 저장해 상수별 동작을 구현한 열거 타입

  • 각 열거 타입 상수의 동작을 람다로 구현해 생성자에 넘기고, 생성자는 이 람다를 인스턴스 필드로 저장해둔다.
public enum OperationWithLambda {
    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; // 람다를 저장하는 인스턴스 필드

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

    public double apply(double x, double y) {
        return op.applyAsDouble(x, y);
    }
}

 

※ 람다 기반으로 변경하면 상수별 클래스 몸체는 더이상 사용할 필요가 없는가?

  • 람다는 이름이 없고 문서화도 못한다. 따라서 코드 자체로 동작이 명확히 설명되지 않거나 코드 줄 수가 많아지면 람다를 쓰지 말아야 한다.
  • 람다 표현식이 3줄 이상이 되면 가독성이 떨어지기 때문에 안쓰거나 리펙토링
  • 상수별 동작을 단 몇줄로 구현하기 어렵거나, 인스턴스 필드나 메서드를 사용해야만 하는 상황이라면 상수별 클래스 몸체를 사용해야 한다.

 

 

람다로 대체 할 수 없는 곳

  • 추상 클래스의 인스턴스를 만들 때 람다를 쓸수 없으니, 익명 클래스를 써야 한다. => 람다는 함수형 인터페이스(추상 메서드 하나만 가지고 있는 인터페이스)에서만 쓰인다.
  • 추상 메서드가 여러 개인 인터페이스의 인스턴스를 만들 때도 익명 클래스를 쓸 수 있다.
  • 람다는 자신을 참조할 수 없다. ⇒ 함수 객체가 자신을 참조해야 한다면 반드시 익명클래스를 써야한다.
    • 람다에서의 this 키워드는 바깥 인스턴스를 가리킨다.
    • 익명 클래스에서의 this는 익명 클래스의 인스턴스 자신을 가리킨다.

 

예) 추상 클래스의 인스턴스를 만들 때 람다를 쓸수 없으니, 익명 클래스를 써야 한다.

  • 익명 클래스(Anonymous Class)는 추상 클래스나 인터페이스를 상속받아 즉석에서 구현하여 객체를 생성하는 방법입니다. 익명 클래스는 클래스의 선언과 객체의 생성을 동시에 하기 때문에 간단하게 구현할 수 있으며, 일회성으로 사용할 수 있는 객체를 쉽게 만들 수 있습니다.
// 추상 클래스
abstract class Animal {
    public abstract void makeSound();
}

// Animal 클래스를 상속받는 클래스
class Dog extends Animal {
    public void makeSound() {
        System.out.println("멍멍!");
    }
}

public class Main {
    public static void main(String[] args) {
        // Dog 클래스를 이용하여 객체를 생성하여 사용
        Animal animal = new Dog();
        animal.makeSound();

        // 익명 클래스를 이용하여 객체를 생성하여 사용
        Animal animal2 = new Animal() {
            public void makeSound() {
                System.out.println("야옹!");
            }
        };
        animal2.makeSound();
    }
}

 

 

람다 사용 시 주의 사항

  • 람다도 익명 클래스처럼 직렬화 형태가 구현별로(가령 가상머신별로) 다를 수 있다.
  • 람다를 직렬화하는 일은 극히 삼가해야 한다.(익명 클래스의 인스턴스도 마찬가지)
  • 직렬화해야만 하는 함수 객체가 있다면(가령 Comparator 처럼) private 정적 중첩 클래스의 인스턴스를 사용하자

 

 

궁금한점

열거 타입 생성자에 넘겨지는 인수들의 타입도 컴파일 타임에 추론된다. 따라서 열거 타입 생성자 안의 람다는 열거 타입의 인스턴스 멤버에 접근할 수 없다(인스턴스는 런타임에 만들어지기 때문이다).

 

1. 열거 타입 생성자에 넘겨지는 인수들의 타입도 컴파일 타임에 추론된다

  • 열거 타입 생성자에 넘겨지는 인수들의 타입이 컴파일 타임에 추론된다는 것은, 컴파일러가 해당 생성자를 호출할 때 인수들의 타입을 자동으로 추론하여 결정한다는 의미
  • MyEnum 타입을 생성할 때 **T**의 구체적인 타입을 지정해주지 않으면 컴파일러는 MyEnum 생성자를 호출할 때 인수로 전달된 값의 타입을 이용하여 **T**의 구체적인 타입을 추론
  • 예를 들어, **MyEnum.VALUE_1**을 생성하면, **"value1"**이 MyEnum 생성자의 인수로 전달되며, 컴파일러는 **"value1"**의 타입이 **String**임을 추론하여 **T**를 **String**으로 결정합니다. 이렇게 추론된 T 타입은 나중에 getValue() 메소드에서 사용됩니다.
  • 따라서, 열거 타입 생성자에 넘겨지는 인수들의 타입이 컴파일 타임에 추론된다는 것은, 제네릭 타입의 열거 타입을 생성할 때 생성자 인수의 타입도 자동으로 추론된다는 것을 의미합니다.
public enum MyEnum<T> {
    VALUE_1("value1"),
    VALUE_2("value2"),
    VALUE_3("value3");

    private final T value;

    MyEnum(T value) {
        this.value = value;
    }

    public T getValue() {
        return value;
    }
}

 

2. 열거 타입 생성자 안의 람다는 열거 타입의 인스턴스 멤버에 접근할 수 없다

  • 열거 타입 생성자에서 람다를 사용하면 해당 람다에서 참조하는 인스턴스 멤버는 해당 열거 타입의 인스턴스가 생성된 이후에 초기화되기 때문에, 람다를 사용하여 초기화하는 인스턴스 멤버를 참조할 수 없습니다.
  • 람다는 런타임에 인스턴스화가 된다. 이때 람다는 자신의 바깥쪽 스코프를 캡처하여 그 스코프 내의 변수나 상수를 참조할 수 있습니다. 그러나 열거 타입의 생성자 안에서 람다를 사용하는 경우, 람다는 열거 타입의 인스턴스 멤버가 아닌, 생성자 스코프 내의 변수나 상수를 참조하게 됩니다. 따라서 람다에서 this 키워드를 사용하면 컴파일 에러가 발생합니다. this 키워드는 람다의 바깥쪽 스코프인 생성자 스코프를 참조하기 때문입니다. 그리고 람다는 열거 타입의 인스턴스 멤버에 접근할 수 없는 것입니다.
public enum MyEnum {
    ITEM1("Item 1", () -> {
        // 람다 안에서 인스턴스 멤버 접근 시도 => 컴파일 에러
        // 람다가 열거 타입의 인스턴스 멤버에 접근할 수 없기 때문
        System.out.println("MyEnum instance member: " + this.name());
    }),
    ITEM2("Item 2", () -> {});

    private String name;
    private Runnable action;

    MyEnum(String name, Runnable action) {
        this.name = name;
        this.action = action;
    }

    public void doAction() {
        action.run();
    }
}