glenn-syj / more-effective-java

이펙티브 자바를 읽으며 자바를 더 효율적으로 공부합니다
4 stars 5 forks source link

[MEJ-010] 익명 클래스와 람다의 스코프 차이에 대한 질문 #187

Closed yngbao97 closed 2 weeks ago

yngbao97 commented 1 month ago

Based on: chapter_07/item42_손영준_내부-클래스와-람다의-this-키워드.md by @glenn-syj


익명 클래스와 람다의 스코프 규칙에 대해 예시 코드와 함께 깔끔하게 정리해주셔서 어떤 차이가 있는지 잘 이해할 수 있었습니다. 다만, 탐구의 출발점으로 말씀해주신 왜 이러한 차이가 발생했는지에 대해 스스로 명확히 이해가 되지 않아서 질문드립니다!

검색을 통해 발견한 내용에서는(올바른 설명인지 모르겠으나..) 익명 클래스는 객체가 생성된 이후 생성되기 때문에 내부 스코프로 바인딩되고, 람다는 선언시점의 변수/메서드를 참조하는 렉시컬 스코프의 규칙을 갖기 때문에 외부 스코프로 바인딩 된다고 합니다.

람다 표현식은 코드 블록을 나중에 실행하기(later execution) 위한 목적이 컸다. 이는 지연 연산(lazy evaluation)으로도 불린다. 즉, 람다 표현식은 생성된 시점이 아니라 필요할 때 실행되도록 설계된다.

작성해주신 부분과 연결지어 생각했을 때, 람다 표현식은 생성 시점과 별개로 필요한 시점에 실행되기 위해 내부가 아닌 외부 스코프의 선언부를 미리 참조한다고 이해할 수 있을까요? 적절하게 이해한 것인지, 알맞은 표현인지 확신이 들지 않아 glenn-syj 님은 이 부분을 어떻게 이해하셨는지 조금 쉽게 설명해주시면 큰 도움이 될 것 같습니다..!

glenn-syj commented 1 month ago

람다가 렉시컬 스코핑(lexical scoping)을 따르는 것은 맞지만, 외부 스코프의 선언부를 미리 참조한다기보다는 선언된 시점의 스코프를 유지한다고 보는 편이 더 정확하다고 생각합니다. (미리 참조라는 말을 같은 의미로 쓰셨다면, 맞다고 생각합니다.) 여기에서 스코프를 유지한다는 말은 곧 변수의 참조와도 연관됩니다.

chatGPT가 생성한 코드를 조금 수정해, 간단한 코드로 설명을 이어나가겠습니다.

익명 클래스와 람다 표현식에서의 참조

// 오류가 있는 코드입니다.
// 현재 답변의 코드 대신 아래 답변의 코드를 확인해주세요!
public class SimpleAnonymousClass {
    public static void main(String[] args) {
        int number = 1;

        // 익명 클래스
        Runnable r = new Runnable() {
            int myNumber = number; // 외부 변수 값 복사

            @Override
            public void run() {
                System.out.println("익명: " + myNumber); // 복사된 값 사용
            }
        };

        Runnable lambda = () -> {
            System.out.println("람다: " + myNumber); // 렉시컬 스코프, 외부 변수 값 이용
        };

        number = 2; // 외부 변수 값 변경

        r.run(); // 출력: 1
        lambda.run(); // 출력: 람다: 10
    }
}

쉽게 말하자면 익명 클래스는 값의 복사가, 람다는 값의 참조가 이루어진다고 보아도 될 것 같습니다. 익명 클래스는 생성된 시점에서 외부 변수 값을 복사하며, 익명 클래스의 스코프는 내부의 스코프이기에 선언 시에 복사된 값을 이용합니다. 반면, 람다 표현식은 자신이 호출될 때 외부 변수에 대한 참조를 저장하고, 실행 시점에 그 값을 참조합니다.

람다 표현식이 선언 시점의 환경을 참조한다는 것은, 곧 선언된 시점의 스코프와 같은 환경을 의미한다고 생각하고 있습니다. 저는 선언 시점과 다르게 외부 변수 값이 변하더라도, 참조값을 이용해 같은 외부 환경을 이용한다는 의미로 받아들이는 중입니다!

yngbao97 commented 1 month ago

답변 감사합니다! 추가적으로 설명해주신 내용으로 익명클래스와 람다의 스코프 차이에 대해서 어느정도 이해가 된 것 같다고 생각했는데, 답변주신 예시 코드에서 오류가 나서 좀 수정해봐도 두 스코프 차이에 대해 설명하기 적절하게 완성이 안되네요ㅠ

익명 클래스에서 number 를 참조하기에는 참조값이 실질적으로 final이어야 해서 number=2로 수정할 수 없고, lambda에서 myNumber 변수를 참조하는 부분에서도 컴파일 에러가 생기는데 람다 출력이 10인 것도 이해가 잘 안됐습니다,,! 혹시 코드를 옮기는 과정에서 직관적인 예시를 위해 생략된 부분이 있었을까요? 코드 한번만 다시 확인해주시면 감사하겠습니다,,!

glenn-syj commented 1 month ago

제가 코드를 업데이트하는 걸 잊었군요! 이해에 혼란을 드려 죄송합니다.

package java_test;

public class SimpleAnonymousClass {

    static class CustomInteger {

        int value;

        CustomInteger() {

        }

        CustomInteger(int value) {
            this.value = value;
        }
    }

    public static void main(String[] args) {

        final CustomInteger number = new CustomInteger(1);

        // 익명 클래스
        Runnable r = new Runnable() {
            int intVal = number.value; // 외부 변수 값 복사

            @Override
            public void run() {
                System.out.println("익명: " + intVal); // 복사된 값 사용
            }
        };

        Runnable lambda = () -> {
            int intVal = number.value;
            System.out.println("람다: " + intVal); // 렉시컬 스코프, 외부 변수 값 이용
        };

        number.value = 10;// 외부 변수 값 변경

        r.run(); // 출력: "익명: 1"
        lambda.run(); // 출력: "람다: 10"
    }
}

이 코드를 돌려보시면 제대로 된 결과가 나올 거예요!

특히 익명 클래스와 람다 식 모두에서 int intVal로 값을 복사했음에도, 출력 시 "Runnable 구현체가 생성될 때"의 값을 이용했느냐 혹은 외부 변수의 변화된 값을 이용하느냐에 초점을 맞추면 더 이해하기가 쉬울 겁니다!

더 궁금하신 부분이 있으면 언제든 알려주세요!

yngbao97 commented 1 month ago

final 멤버변수를 클래스로 구현하니 코드 수정에서 어려움을 겪었던 부분이 해결됐네요!👍🏻 코드까지 잘 수정해주셔서 감사합니다. 이해에 정말 도움이 됐어요!

추가적으로 lambda.run(); 코드를 number.value = 10; 이전에 하나 더 복사해서 실행해보니 람다는 호출 시점에 참조값을 이용한다는 부분이 더 잘 와닿았습니다!

lambda.run(); // 출력: "람다: 1"

number.value = 10;// 외부 변수 값 변경

r.run(); // 출력: "익명: 1"
lambda.run(); // 출력: "람다: 10"

감사합니다!

glenn-syj commented 1 month ago

추가적으로, 위 코드에서 익명 클래스 및 람다 초기화에 해당하는 바이트코드는 아래와 같습니다.

익명 클래스

LINENUMBER 23 L1
    NEW java_test/SimpleAnonymousClass$1
    DUP
    ALOAD 1
    INVOKESPECIAL java_test/SimpleAnonymousClass$1.<init>(Ljava_test/SimpleAnonymousClass$CustomInteger;)V
    ASTORE 2

람다 초기화

LINENUMBER 32 L2
    ALOAD 1
    INVOKEDYNAMIC run(Ljava_test/SimpleAnonymousClass$CustomInteger;)Ljava/lang/Runnable; [
      // handle kind 0x6 : INVOKESTATIC
      java/lang/invoke/LambdaMetafactory.metafactory(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
      // arguments:
      ()V, 
      // handle kind 0x6 : INVOKESTATIC
      java_test/SimpleAnonymousClass.lambda$0(Ljava_test/SimpleAnonymousClass$CustomInteger;)V, 
      ()V
    ]

어렵게 느껴질 수도 있지만, 사실 INVOKESPECIALINVOKEDYNAMIC의 차이를 살펴본다면 왜 익명 클래스와 람다가 다른 스코프를 갖는지 이해하는 데 도움이 될 겁니다. INVOKESPECIAL이 컴파일 타임에 정적으로 메서드나 생성자를 호출한다면, INVOKEDYNAMIC은 메서드 핸들이라는 걸 통해서, 런타임에 동적으로 호출할 수 있도록 해준다네요!