KakaoEnt-Study / Effective-Java-Study

카카오 엔터테인먼트 백엔드 개발자의 생존 전략
1 stars 0 forks source link

[item : 42] 익명클래스보다는 람다를 사용해라 #42

Open sean-k1 opened 2 years ago

sean-k1 commented 2 years ago

서론

함수타입을 표현 할때 추상 메서드를 하나만 담은 인터페이스를 사용했다. 이런 인터페이스의 인스턴스를 함수객체라고 부른다.

JDK 1.1 등장하면서 함수 객체를 만드는 주요 수단은 익명 클래스가 되었다.

제목에도 나와있듯이 익명클래스 -> 람다를 사용하라 하는 이유를 알아보자


본문

익명클래스의 최대 단점은 코드가 너무 길어져 자바에는 함수형 프로그래밍에 적합하지 않는다.

자바 8 에서는 추상 메서드 하나 짜리 인터페이스는 특별한 의미를 인정받아 특별한 대우를 받게 되었다.

추상 메서드를 하나 작성한 Comparator 코드

@FunctionalInterface
public interface Comparator<T> {
    int compare(T o1, T o2);
}

지금은 함수형 인터페이스라 부르는 이 이너테페이스들의 인스턴스를 람다식으로 사용해 만들 수 있게 된것이다.

결국 코드의 길이가 짧고 가독성을 높이기위해 가독성있는 함수객체를 만드는 것이다.

과거 익명클래스 코드

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

람다식 활용 코드

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

함수 객체를 람다식으로 넘기니 더 간결하게 표현되었다.

의문이 드는점은 타입을 명시를 안해줘도 되는것인가? 라는 고민이 들기시작한다.

Collections.sort 코드를 보면 List<T>의 T타입을 통해 컴파일러가 Comparator<? suepr T>를 집어넣어 추론한다.

따라서 List를 다음과 같이 선언하면 타입을 알지못해 오류가 발생한다.

        List words = new ArrayList<>(); // List<String> words로 변경해야 오류안남
        Collections.sort(words, (s1,s2) -> Integer.compare(s1.length(),s2.length()));

따라서 강조한 내용은 타입을 명시해야 코드가 더 명확할 때만 제외하고, 람다의 모든 매개변수 타입은 생략하자 이다.

여기에서더 람다식 코드를 줄일 수 있다. 바로 비교자 생성 메서드를 사용하면 더 간결하게 나온다.

비교자 생성 메서드를 활용한 코드

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

이것은 다음장에 더 자세히 나옵니다.

Item 34의 Operation 열거타입 예를 가져와 람다식으로 변경해보자.

Operation 열거타입 코드

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 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;
    }

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

더이상 apply를 추상메서드로 두지말고 람다식으로 코드가 간결해졌다.

근데 과연 코드가 짧다고 가독성이 좋아지는 것이 아니다.

그럼 언제 람다식을 표현해야할까?

람다로 대체할수 없는 것

람다 활용하지 못하는 익명클래스 예제

public abstract class AbstractClass {
    abstract int doSomething(int a, int b);

    public static void main(String[] args) {
            AbstractClass abstractClass2 = new AbstractClass() {
            @Override
            public int doSomething(int a, int b) {
                return a-b;
            }
        };
        int c =abstractClass2.doSomething(3,5);
        System.out.println("c = " + c);
        AbstractClass abstractClass = (a, b) -> a - b; // 해당 줄 오류 발생
        int b =abstractClass.doSomething(3,5);
        System.out.println("b = " + b);

    }
}

추상메서드 여러개 예제

만약 두개의 메서드가 있다고하면 다음과같이 선언해야될것이다.

람다식은 함수명을 따로 적지 않기때문에 두개인 경우 어느 함수의 람다식 인지 당연히 판단하지 못한다.

public interface AbstractClass {
    int doSomething(int a, int b);
    //int doSomething2(int a , int b ); 주석해제시 오류발생

    public static void main(String[] args) {
        AbstractClass abstractClass = (a, b) -> a + b; 

    }
}

결론


saintbeller96 commented 2 years ago

익명 클래스와 람다의 차이점

This

익명 클래스 내부에서 this 키워드는 익명 클래스의 인스턴스를 가르킨다.

하지만 람다 내부에서 this 키워드는 람다를 감싼 바깥 클래스의 인스턴스를 가르킨다.

public class Outer {

    public void thisAC(int a, int b) {
        new Comparator<>() {
            @Override
            public int compare(Object o1, Object o2) {
                System.out.println("익명 클래스의 this : " + this.getClass());
                return 0;
            }
        }.compare(a, b);
    }

    public void thisLambda(int a, int b) {
        IntBinaryOperator lambda = (int x, int y) -> {
            System.out.println("람다의 this: " + this.getClass());
            return Integer.compare(x, y);
        };
        lambda.applyAsInt(a, b);
    }

    public static void main(String[] args) {
        var outerClass = new Outer();
        outerClass.thisAC(1, 2);
        outerClass.thisLambda(1, 2);
    }
}
익명 클래스의 this : class com.kakaoent.item42.Outer$1
람다의 this: class com.kakaoent.item42.Outer

Variable Capture

익명 클래스 내부에서는 새로운 Scope가 생성된다.

따라서 익명 클래스 외부에 선언된 변수와 동일한 이름을 가진 변수를 익명 클래스 내부에서 새롭게 선언한다면, 외부에서 선언한 변수는 덮어씌워진다(Shadowing).

int outer = 0;
new Comparator<>() {
    @Override
    public int compare(Object o1, Object o2) {
        int outer = 1; //익명 클래스 내부에서 새로운 Scope 생성
        System.out.println(outer) // 1
        return 0;
    }
};

하지만 람다 내부의 Scope는 람다를 감싸고 있는 Scope와 동일하다.

int outer = 0;
IntBinaryOperator lambda = (int x, int y) -> {
    int outer = 1; // 바깥 Scope를 그대로 사용하기 때문에 변수명이 동일하다는 컴파일 에러 발생
    return Integer.compare(x, y);
};

익명 클래스와 람다에서 사용하는 지역 변수는 왜 final 혹은 effective final이어야 할까?

익명 클래스나 람다 내부에서 참조하는 지역 변수를 변경하려고 하면 컴파일 에러가 발생한다.

image

컴파일 에러를 보면, 람다식 내부에서 사용하는 변수는 반드시 final이거나 effective final(final로 선언하지 않았지만 변경되지 않는 변수)이어야 한다고 나온다.

간단한 예시를 통해 왜 람다식 내부에서 사용하는 변수는 final 혹은 effective final이어야 하는지 알아보자.

다음은 String 타입 메시지를 처리하는 AsyncMessageProcessor 클래스이다.

이 클래스는 메시지를 처리할 때 메시지와 람다식을 함께 전달받는다.

그리고 새로운 워커 쓰레드를 생성해 전달받은 람다식을 실행시킨다.

public class AsyncMessageProcessor {

    public void asyncProcess(String message, Consumer<String> consumer/**람다식**/) {
        ExecutorService executorService = Executors.newSingleThreadExecutor();//새로운 쓰레드 생성
        executorService.submit(() -> {
            System.out.println("람다식이 실행되는 쓰레드 -> " + Thread.currentThread().getName());
            consumer.accept(message);//람다식 실행
            /**
            실제로 실행되는 내용
            System.out.println(message.toUpperCase());
            **/
        });
    }

    public static void main(String[] args) {
        var messageProcessor = new AsyncMessageProcessor();
        System.out.println("람다를 전달하는 쓰레드 -> " + Thread.currentThread().getName());
        messageProcessor.asyncProcess("hello", s -> {
            System.out.println(s.toUpperCase());
        });
    }
}

main 메서드에서 실행한 결과를 보면 람다식을 전달하는 쓰레드와, 람다식을 실행하는 쓰레드가 서로 다른 것을 확인할 수 있다.

람다를 전달하는 쓰레드 -> main
람다가 실행되는 쓰레드 -> pool-1-thread-1
HELLO

이제 이 코드를 살짝 바꿔보자.

메시지가 잘 처리되었는지 확인하기 위해 boolean 타입의 isProcessed라는 지역 변수를 선언하고, 전달하는 람다식이 실행될 때 true로 변경되게끔 작성해 보자.

public class AsyncMessageProcessor {

    public void asyncProcess(String message, Consumer<String> consumer) {
        ExecutorService executorService = Executors.newSingleThreadExecutor();//새로운 쓰레드 생성
        executorService.submit(() -> {
            System.out.println("람다가 실행되는 쓰레드 -> " + Thread.currentThread().getName());
            consumer.accept(message);//람다 실행
            /**
            실제로 실행될 내용
            System.out.println(message.toUpperCase());
            isProcessd = true;
            **/
        });
    }

    public static void main(String[] args) {
        var messageProcessor = new AsyncMessageProcessor();
        boolean isProcessed = false;
        System.out.println("람다를 전달하는 쓰레드 -> " + Thread.currentThread().getName());
        messageProcessor.asyncProcess("hello", s -> {
            System.out.println(s.toUpperCase());
            isProcessed = true; <--- 컴파일 에러가 나지 않는다면 무슨 문제가 발생할까?
        });
    }
}

만약 이 코드에서 컴파일 에러가 발생하지 않는다고 가정하면 어떤 문제가 발생할까?

지역 변수 isProcessedmain 쓰레드스택에 저장되어 있다.

하지만 람다식이 실행되는 쓰레드는 pool-1-thread-1이다(이 쓰레드를 워커 쓰레드라고 하자).

스택은 쓰레드 별로 할당되기 때문에, main 쓰레드의 스택에 저장된 지역 변수를 워커 쓰레드에서는 참조할 수 없다.

이런 문제를 해결하기 위해 자바는 지역변수의 값을 복사해 람다식 내부에서 참조할 수 있게끔 전달한다.

이 시점에서 main 쓰레드의 스택에 있는 isProcessed가 변경되거나 워커 쓰레드의 람다식 내부에서 isProcessed의 값이 변경되는 경우, 복사된 변수값과 실제 변수값이 일치하지 않는 문제가 발생한다.

이런 문제를 피하기 위해 람다식 내부에서 참조하는 지역변수는 반드시 final이거나 effective final이어야 한다.

반면 인스턴스 변수힙 영역에 할당되기 때문에 다른 쓰레드에서 실행되는 람다의 내부에서도 인스턴스 변수는 자유롭게 변경할 수 있다.

하지만 이런 경우엔 반드시 동기화 작업을 해 주어야 한다.