Java Lambda (5) 변수 범위

🗓 ⏰ 소요시간 22 분

변수 범위

람다는 새로운 변수 범위를 생성하지 않습니다. 람다 내에서 변수 사용은 둘러싸고 있는 환경의 변수들을 참조합니다. thissuper 키워드도 마찬가지입니다. 따라서 복잡해지지 않습니다.

아래 예제에서 i 는 단순히 람다를 둘러싸고 있는 클래스의 필드를 가리키게 됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public static class Example {
int i = 5;

public Integer example() {
Supplier<Integer> function = () -> i * 2; // this.i * 2; 도 동일
return function.get();
}

public Integer anotherExample(int i) {
Supplier<Integer> function = () -> i * 2; // this.i 와 다름
return function.get();
}

public Integer yetAnotherExample() {
int i = 15;
Supplier<Integer> function = () -> i * 2;
return function.get();
}
}

결과를 출력해봅시다.

1
2
3
4
5
6
public static void main(String... args) {
Example scoping = new Example();
System.out.println("class scope = " + scoping.example()); // 10
System.out.println("method param scope = " + scoping.anotherExample(10)); // 20
System.out.println("method scope = " + scoping.yetAnotherExample()); // 30
}

유사 파이널 Effectively final

자바 7에서 익명 클래스의 인스턴스로 넘겨지는 모든 변수들은 final 이어야만 합니다. 익명 클래스의 인스턴스가 필요로 하는 변수 정보나 컨텍스트를 복사해서 넘겨주기 때문입니다. 이런 상황에서 변수가 변경되면 의도하지 않은 결과가 나올 수 있으므로 변경되지 않도록 final 로 선언되어야만 하고, 그렇지 않을 경우 컴파일 에러가 납니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// Java 7
// 필터 메소드는 List<Person> 를 돌면서 조건을 테스트한다.
private List<Person> filter(List<Person> people, Predicate<Person> predicate) {
ArrayList<Person> matches = new ArrayList<>();
for (Person person : people) {
if (predicate.test(person))
matches.add(person);
}
return matches;
}

// 은퇴나이를 기준으로 은퇴한 사람 리스트를 구한다.
public void findRetirees(List<Person> people) {
int retirementAge = 55; // final 필요
List<Person> retirees = filter(people, new Predicate<Person>() {
@Override
public boolean test(Person person) {
return person.getAge() >= retirementAge; // compile error
}
});
}

class Person {
private int age;

public int getAge() {
return age;
}
}

위 예제에서는 익명 인스턴스에서 외부의 retirementAge 를 참조할 때 컴파일 에러가 발생합니다. 이는 retirementAgefinal 이 아니기 때문에 나는 에러로 final 을 붙여주면 해결됩니다.

여기서 익명 클래스에 컨텍스트를 넘겨주는 것이 클로저입니다. 컴파일러는 이 필요한 정보를 복사해서 넘겨주는데 이를 Variable capture 라고 합니다.

자바 8에서는 유사 파이널 (effectively final) 이라는 개념을 도입해 해당 변수가 변경되지 않다고 컴파일러가 판단하면 해당 변수를 final 로 해석하게 됩니다. 따라서 자바 8 컴파일러로 변경한 후에는 final 키워드가 없어도 문제 없이 컴파일됩니다.

물론 여기서 변수를 초기화한 후 나중에 수정하는 경우라면 해당 변수를 유사 파이널로 볼 수 없습니다.

1
2
3
int retirementAge = 55;
// ...
retirementAge = 65; // 유사 파이널 아님!

이러한 유사 파이널은 람다에서도 그대로 적용됩니다.

1
List<Person> retirees = filter(people, person -> person.getAge() >= retirementAge);

파이널 우회

이렇게 강요되는 final 이 회피되는 경우가 있습니다. 만약 사람들의 전체 나이 합을 구한다고 합시다.

1
2
3
4
5
6
7
private static int sumAllAges(List<Person> people) {
int sum = 0;
for (Person person : people) {
sum += person.getAge();
}
return sum;
}

여기에서 반복 동작을 추상화해서 외부로 빼서, 나이 합을 구하는 로직을 람다로 받을 수 있게 변경할 수 있을 겁니다.

1
2
3
4
5
6
7
public final static Integer forEach(List<Person> people, Function<Integer, Integer> function) {
Integer result = null;
for (Person person : people) {
result = function.apply(person.getAge());
}
return result;
}

이렇게 만든 함수는 다음과 같이 사용할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
private static void badExample(List<Person> people) {
Function<Integer, Integer> sum = new Function<Integer, Integer>() {
private Integer sum = 0; // 합이 저장됨

@Override
public Integer apply(Integer amount) {
sum += amount;
return sum;
}
};

forEach(people, sum); // 결과는 정상적으로 출력될 것
}

문제는 이 합하는 연산 때문에 생깁니다. 합을 하기 위해서는 값을 지속적으로 저장할 곳이 필요합니다. 이 예제에서 sum 변수는 함수가 호출될 때마다 계속해서 재사용되고 변경됩니다. 동일한 인스턴스의 필드를 참조하고 있기 떄문입니다. 따라서 동작은 제대로 하지만 이는 람다로 변경할 수 없습니다. 람다 내부에는 누적해서 저장할 공간이 없기 때문입니다.

람다를 사용하면 외부에 있는 변수에 저장해야겠죠.

1
2
3
int sum = 0;
// Variable used in lambda expression should be final or effectively final
forEach(people, x -> sum += x);

그렇다면 위와 같은 에러를 만날 수 있습니다. 이 때 sum 은 람다 내부에서 변경되고 있기 때문에 유사 파이널이 아닙니다. 하지만 그렇다고 이 변수를 final 로 변경한다면 람다식 내부에서 변경(합)을 못하게 될 겁니다. 그렇다면 어떻게 해야 할까요?

1
2
final int sum = 0;
forEach(people, x -> sum += x); // Cannot assign a value to final variable 'sum'

이 문제를 해결할 방법은 바로 일반 자료형 타입 대신 객체나 배열을 사용하는 겁니다. 이는 참조변수이기 때문에 final 로 선언 시 참조는 변경되지 않고 해당 값은 변경할 수 있게 됩니다.

1
2
int[] sum = {0};
forEach(people, x -> sum[0] += x); // ok

하지만 배열 sum 에 대해서 다른 곳에서 변경할 수 있는 side effect 가 존재합니다. 유사 파이널 설명을 위해서 코드를 살펴봤지만, 이런 작업은 stream API 를 이용해서 하는 것이 더 효과적입니다. 다음과 같은 코드가 될 겁니다.

1
2
3
int sum = people.stream()
.map(person -> person.getAge())
.reduce(0, Integer::sum);

참고