[Java] 익명 객체 / 람다식에서 지역 변수 변경이 안되는 이유

hwhyeons·2023년 1월 16일
0


스트림을 이용해서 배열이나 컬렉션을 다루면 코드도 짧고 가독성이 좋아져서

자주 사용하다보니, 배열을 다루면서 인덱스를 사용하는 동시에 배열의

값도 이용하고 싶은 상황이 생겼다.

for문이라면

int[] arr = {1,2,3,4,5};
for (int i = 0; i<arr.length; i++) {
	System.out.println(i+"번째 인덱스의 값 : "+arr[i]);
}

이렇게 코드를 구현하면 인덱스와 값을 동시에 이용할 수 있다.



이와 같은 행위를 스트림으로 구현 할려면,

AtomicInteger 같은 Atomic Type을 이용하거나,

IntStream을 만들어서 인덱스처럼 활용하는 방법이 있다.




하지만, 조금 더 while문처럼 Stream을 작성한다면
int i = 0;
int[] arr = {1,2,3,4,5};
Arrays.stream(arr).forEach(val -> {
    System.out.println(i+"번 인덱스의 값 : "+arr[i]);
    i++;
});

이렇게 작성할 수도 있으나.. 이건 컴파일 할 수 없는 코드다.

왜냐면 i라는 지역변수를 람다식 내부에서 값을 변경하고 있기 때문이다.

이때, 인텔리제이에서 제공하는 Comment를 보면

  • 변수가 final이거나 effectively final여야 한다
  • Atomic으로 바꿔야한다

등으로 나오게 된다.


왜 final 또는 effectively final이어야 할까??

람다식의 경우, 람다식 외부에 있는 지역변수를 가져올 때

그 지역변수를 복사해서 가져온다 (람다 캡처링)

int i = 0;
int[] arr = {1,2,3,4,5};
Arrays.stream(arr).forEach(val -> {
    System.out.println(i+"번 인덱스의 값 : "+arr[i]);
    i++;
});

이 코드에서, forEach의 람다식 내부에서 i는 바깥에 있는 i가
아니라 복사된 i가 되기 때문에, 아무리 forEach를 하면서 i++를 하더라도
결국 계속 0이다.

물론 위 코드는 컴파일 오류가 발생하기 때문에 컴파일 되지 않는다.

단, 이건 i가 지역변수이기 때문에 발생하는 오류다.

i를 그대로 참조하는게 아니라 i를 복사하는 이유도 지역변수이기 때문이다.


지역변수는 스택메모리에 저장되는데, 스택 메모리의 경우

멀티 스레드 환경에서 스레드끼리 서로 공유하지 않는 영역이기 때문에

위와 같은 코드를 허용하게 되면 문제가 발생할 수 있다.

람다식을 서로 다른 스레드에서 사용하게 되면 문제가 발생하기 때문이다.


이러한 문제는 스트림이라서 발생한다기보다는 익명 객체이기 때문인데,
    int a = 10;
    Predicate<Integer> test = new Predicate<Integer>() {
        @Override
        public boolean test(Integer integer) {
            a = 30;
            return false;
        }
    };

이 코드 역시 컴파일 오류가 발생한다.

사실, 이러한 기본 타입의 지역변수가 아니라 실제 값이 heap에 저장되는

참조타입의 지역 변수의 경우 내부 값을 변경하면서 조작 가능하다.

    final int[] a = {10};
    Thread thread = new Thread() {
        @Override
        public void run(){
            for (int i = 0; i < 5; i++) {
                a[0] = a[0]+10;
                System.out.println(a[0]);
            }
        }
    };
    thread.start();

이 부분을 메인 메소드에 넣고 실행시, 의도한대로
20 30 40 50 60으로 출력이 된다.

왜냐면, 배열의 시작 주소는 스택메모리에 저장되지만,

배열 원소들의 실제 값은 힙 메모리에 저장 되기 때문에 저렇게

건드려도 상관이 없다.


예를 들어 안드로이드 스튜디오에서 버튼같은 컴포넌트에 액션을 추가할 때,

기본타입 지역변수에 접근할 때 컴파일 오류가 발생하면서

안드로이드 스튜디오 자동으로

int a = 30

같은 형태를

final int[]a = {0};
a[0] = 30;

이렇게 바꾸도록 추천?해줬던 것이 기억이 났다.

다시 들어가서 테스트해보니,

이런식으로 나오고, 추천하는대로 바꿔보면

        final int[] a = {30};
        button.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                a[0] += 10;
            }
        });

이렇게 자동으로 바뀐다.

(안드로이드 스튜디오뿐만 아니라 Java Swing에서 액션을 추가할 때도
익명 객체를 사용할 수 있다)


하지만 아무리 실제 값이 heap영역에 들어간다 한들,

만약 저기서

a = new int[3];

처럼 a의 주소를 바꿔버리는 코드가 들어오는 순간

익명 객체 내부의 a에 바로 빨간 밑줄이 생기는걸 볼 수 있다.



사실, 안드로이드 스튜디오에서 위에 말한 것처럼

final로 바꿔서 사용하라고 추천한건지, 이 방법이 안전한건지는

아직 정확히 이해하지 못했다.

위 방식이 안드로이드 구조상 저렇게 해도 문제가 없기 때문에 IDE에서

저렇게 바꾸라는건지, 아니면 원래 저렇게 해도 상관 없는지는

아직 잘 모르겠다.

바깥 지역변수를 람다식 내부에서 참조하게 되면 멀티스레드에서

동시성 문제가 발생할 수 있는데 말이다..




추가로, 정적 변수(static) 역시 스택에 생성되는게 아니므로 당연히

람다식 내부에서 참조해도 문제가 없다.

또한, 지역변수더라도 effectively final한 형태라면
(즉, final 키워드가 없어도 final처럼 참조, 값이 바뀌지 않고 작동하는 변수)

컴파일 오류가 일어나지 않는다.

예를 들어,

int a = 30;
Thread thread = new Thread() {
    @Override
    public void run(){
        System.out.println(a);
    }
};
thread.start();

이렇게만 하고 저 코드 뒤쪽에서 a를 건드리지 않는다면

컴파일이 잘 된다.

또한, 앞 뒤가 중요한게 아니라,

    int a = 30;
    a = 10;
    Thread thread = new Thread() {
        @Override
        public void run(){
            System.out.println(a); // 오류
        }
    };
    thread.start();

이렇게 재할당이 일어난 경우에도 컴파일이 되지 않는다.

0개의 댓글