학습 배경

    private void assignBallSet(Ball[] tmpBalls) {
        // 3개의 숫자 리스트
        List<Integer> randomNumbers = RandomNumberGenerator.createRandomNumberBetweenOneAndNine();

        int madeBallCount = 0;
        int order = madeBallCount + 1;
        
        for (Integer randomNumber : randomNumbers) {
        	// Ball 객체 초기화
            Ball ball = new Ball(randomNumber, order);
            // tmpBalls 배열에 할당
            tmpBalls[madeBallCount] = ball;
            // tmpBalls 배열 할당 위한 인덱스 변수
            madeBallCount++;
        }
    }    

위의 코드는 숫자야구게임을 구현하는 프로젝트 내에서 세 개의 랜덤 숫자 리스트를 받아서 Ball 객체를 초기화하고 tmpBalls 배열에 할당하는 코드이다.

위의 코드로 작동은 했지만 람다식을 이용해서 같은 기능을 하는 코드를 구현하고 싶었다.

   private void assignBallSet(Ball[] tmpBalls) {
        // 3개의 숫자 리스트
        List<Integer> randomNumbers = RandomNumberGenerator.createRandomNumberBetweenOneAndNine();

        int madeBallCount = 0;
        int order = madeBallCount + 1;
        
        randomNumbers
            .forEach(randomNumber -> {
                Ball ball = new Ball(randomNumber, order);
                tmpBalls[madeBallCount] = ball;
                madeBallCount++;
            });
    }

충분히 그럴싸해보인다. 하지만 컴파일 에러가 발생한다.

Variable used in lambda expression should be final or effectively final

Variable used in lambda expression should be final or effectively final

이건 무슨 에러야? 왜 안되는거지? 느낌상 될 것 같은데? 😇

lambda 표현식 안에서 사용되는 변수는 final 변수 또는 effectively final 변수여야 합니다. 라는 뜻이였다.

final로 선언된 변수는 코드에서 변경할 수 없다. 물론, 참조형일 때는 내부적으로 담고 있는 컬렉션에 값의 변경은 가능하다. JavaScript의 const와 비슷한 역할을 한다.

final int a = 1;
a++; // -> 컴파일 에러

그렇다면 effectively final은 무엇일까?

변수가 선언되고 나서 사용되는 과정에서 변경되지 않는다면 effectively final 변수이다.

아래의 코드에서 num 변수는 effectively final 변수가 아니다.

int num = 10;
Runnable runnable = () -> {
    num++;
    System.out.println("number: " + num);
};
runnable.run();

아래의 코드에서는 num 변수는 effectively final이다

int num = 10;
Runnable runnable = () -> {
    System.out.println("number: " + num);
};
runnable.run();

💡 결국 람다식안에서는 람다식 밖에 변수를 변경하지 마세요. 라는 것이였다. 그래.. 알겠어.. 근데..왜??

스택과 힙

Java 람다의 작동 방식을 살펴보기 전 인스턴스 변수와 메서드 내의 지역 변수는 메서드 콜스택에 저장된다.

스택 영역은 힙 영역과 달리 서로 다른 스레드끼리 공유되지 않는다.

primitive type 변수는 메서드 내의 stack에서 사용되고 그 함수가 종료되고 콜스택에서 해당 메서드가 pop되면서 같이 소멸된다.

reference type 변수는 힙 영역에 할당되기 때문에 메서드가 끝나고 해당 메서드가 사용하고 있는 메모리 영역이 pop되어도 GC가 동작해서 해당 변수를 정리하기 전까지 사라지지 않는다.

참고로 클래스 변수(static)는 스레드끼리 공유 가능한 JVM의 Method Area에 저장된다.

람다를 보기 전 익명 내부 클래스를 살펴보자.

자바에서 람다가 사용 가능하기 전에 익명 내부 클래스가 람다와 비슷한 기능을 했었다고 한다. 또 다른 자료들을 찾아보면 람다를 설명하면서 익명 내부 클래스를 많이 언급을 한다. 그렇다면 익명 내부 클래스에서 로컬 변수를 변경할 수 있다면 어떨까? 정확히는 어떤 문제가 발생할까?

아래의 샘플 코드를 살펴보자.

  1. InneClassTest.methodA()가 호출된다.
  2. 콜스택에 methodA()에 대한 스택 프레임이 생성된다.
  3. methodA()에서 사용하는 int a = 1; 의 코드가 methodA()의 스택프레임에 저장된다.
  4. HelloWorld 클래스를 초기화하고 그 참조값을 리턴한다.
    • HelloWorld.greet() 내부에서 로컬 변수인 a의 값을 변경하고 있다.
  5. methodA()가 종료된다.
  6. methodA()의 스택 프레임 pop되었다.
    • 프레임 내부에 저장되어 있던 a = 1 이라는 데이터도 사라졌다.
  7. 하지만 HelloWolrd 클래스는 힙에 아직 존재하고, 그 이후에 진행될 코드에 따라 소멸 시기가 정해진다.

문제는 HelloWorld.greet()이 내부적으로 가지고 있고 변경하려던 원본 a는 프레임에서 pop 되었기 때문에 이미 존재하지 않게 되고, 근데 누군가 HelloWorld.greet()을 호출해서 a의 값을 변경하려고 한다면? 존재하지 않는 값을 변경하는 것이니 말이 안되는 코드가 되어버린다.

// 주의! 이 코드는 컴파일 되지 않습니다.
public class InnerClassTest {
    interface HelloWorld {
        void greet();
    }

    public static HelloWorld methodA() {
        int a = 1;
        
        HelloWorld helloWorld = new HelloWorld() {
            @Override
            public void greet() {
            	a++;
            }
        };

        return helloWorld;
    }

    public static void methodB(HelloWorld helloWorld) {
        helloWorld.greet();
    }

    public static void main(String[] args) {
        methodB(methodA());
    }
}

람다도 메서드 밖으로 전달이 가능하지 않나?

아래의 코드를 보면 익명 내부 클래스 처럼 람다식을 외부로 전달할 수 있고, 이 람다식이 언제 실행될지 알 수가 없다.

로컬 변수가 사라지기 전에 람다가 호출되리라고 보장할 수가 없다.

그렇기 때문에 람다식 내부에서는 로컬 변수를 변경하지 못하게 막는 것이 당연하다고 생각된다.

// 주의! 이 코드는 컴파일 되지 않습니다.
import java.util.function.Function;

public class LambdaTest {
    public static Function<String, Integer> methodA() {
        int a = 1;

        Function<String, Integer> function = (string) -> a++;
        return function;
    }

    public static void methodB(Function<String, Integer> function) {
        function.apply("");
    }

    public static void main(String[] args) {
        methodB(methodA());
    }
}

람다식을 쓸 때 내부적으로 동작하는 방식

람다식을 사용하면 익명 내부 클래스가 생성되는 것처럼 동작할 것 같지만 사실은 그렇지 않다.

람다식을 사용하면 bytecode 에서 invokedynamic opcode가 실행된다.

그리고 아래와 같은 과정이 일어난다.

Invokedynamic(indy) call

invokedynamic 은 조금 더 들여다볼 필요가 있다.

람다 캡쳐링

내부적으로 어떻게 동작하는지는 아직 확실히 모르겠지만 자바는 람다식이 로컬 변수에 접근 시 해당 람다가 해당 로컬 변수들을 사용하게 하기 위해 값들을 복사한다.

아래의 자바코드는 그 아래의 바이트코드로 컴파일 된다.

특이한 점은 람다식에 로컬변수를 사용할 수록 바이트 코드가 조금씩 변경되는데 그 부분은 <-------- ** 표시를 해놨다.

변하는 코드를 보아서는 람다를 이용한 객체 생성 당시 전달해줄 것 같다는 추측은 들지만 정확히 바이트코드 상에 그 동작이 나타나는지는 확인이 필요하다.

    public static Function<String, Integer> methodA() {
        int a = 1;
        int b = 2;

        Function<String, Integer> function = (string) -> a + b;
        return function;
    }
 public static java.util.function.Function<java.lang.String, java.lang.Integer> methodA();
    descriptor: ()Ljava/util/function/Function;
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=3, args_size=0
         0: iconst_1
         1: istore_0
         2: iconst_2
         3: istore_1
         4: iload_0        <--------  ** local variable 에서 스택으로 굳이 옮길 필요가 없는데, 옮기고 있다. 이 값을 invokedynamic이 어떻게 처리하는지는 아직 모르겠다.
         5: iload_1        <--------  **
         6: invokedynamic #2,  0              // InvokeDynamic #0:apply:(II)Ljava/util/function/Function; <--------  ** (II)가 로컬변수 사용개수 만큼 늘어난다.
        11: astore_2
        12: aload_2
        13: areturn

reference

0개의 댓글

Powered by GraphCDN, the GraphQL CDN