람다표현식 내부에서 변수가 어떻게 작동하는지 살펴보자.
목차
1.1 람다 표현식의 유효 범위
1.2 바깥쪽 유효 범위에 속한 변수 접근하기
2. 고차함수
2.1 함수를 반환하는 메서드
2.2 함수를 수정하는 메서드
2.3 Comparator 인터페이스의 메서드
3 지역 내부 클래스
3.1 지역 클래스
3.2 익명 클래스
1.1 람다 표현식의 유효 범위
람다 표현식의 구현부는 유효 범위가 중첩 블록과 같다.
int first = 0;
Comparator<String> comparator = (first, second) -> first.length() - second.length();
// 오류 - first를 이미 정의 했다.
람다 표현식의 this는 같은 유효 범위 규칙의 영향을 받는다. 따라서 this 키워드는 람다 객체를 생성하는 메서드의 this 파라미터를 의미한다.
public class Application {
public void doWork() {
Runnable runner = () -> {
System.out.println(this.toString());
};
}
}
표현식 this.toString()은 Runnable 인스턴스가 아니라 Application객체의 toString 메서드를 호출한다.
1.2 바깥쪽 유효 범위에 속한 변수 접근하기
종종 람다 표현식에서 자신을 감싼 메서드나 클래스에 속한 변수에 접근하고 싶을 때가 있다.
public static void repeatMessgage(String text, int count) {
Runnable r = () -> {
for(int i = 0; i < count; i++) {
System.out.println(text);
}
};
new Thread(r).start();
}
람다 표현식을 3가지로 구성된다.
- 코드블록
- 파라미터
- 자유 변수들의 값(자유 변수는 파라미터 변수도 아니고 코드 내부에서 선언한 변수도 아니 것을 말함.)
위의 예는 람다 표현식이 text와 count라는 자유 변수 2개를 사용했다. 이를 가리켜 람다 표현식이 이 값들을 캡처했다고 말한다.
자유 변수의 값을 사용하는 코드 블록을 클로저(closure)라 한다. 자바에서는 람다 표현식이 클로저다.
람다 표현식은 자신을 감싸는 유효 범위에 속한 변수의 값을 캡처할 수 있다. 제약이 하나 있는데 람다 표현식에서는 값이 변하지 않는 변수만 참조할 수 있다. 이런 이유 때문에 람다 표현식은 변수가 아니라 값을 캡처한다고 말하기도 한다.
public static void capture() {
for(int i = 0; i < 10; i++) {
new Thread(() -> System.out.println(i)).start();
// 오류 - i를 캡처할 수 없다.
}
}
다시 설명하면 람다 표현식은 자신을 감싸고 있는 유효 범위에 속한 사실상 최종(effectively final)인 지역 변수에만 접근할 수 있다.
향상된 for 루프의 변수는 유효 범위가 단일 반복(single iteration)이므로 사실상 최종이다. 따라서 다음 코드는 정상작동한다.
public void advencedFor() {
for(String arg : new String[] {"jwa", "seung", "ui"}) {
new Thread(() -> System.out.println(arg)).start();
}
}
사실상 final 규칙 때문에 람다 표현식은 캡처한 변수를 어는 것도 변경할 수 없다.
public static void repeatMessage(String text, int count, int threads) {
Runnable r = () -> {
while(count > 0) {
count--; // 오류 - 캡처한 변수를 변경할 수 없다.
System.out.println(text);
}
};
for(int i = 0; i < threads; i++) new Thread(r).start();
}
2. 고차함수
함수형 프로그래밍에서는 함수를 인자와 반환값으로 사용할 수 있다.
함수를 처리하거나 반환하는 함수를 고차 함수라고 한다. 자바는 함수형 언어는 아니지만 원칙은 같다. 여기서는 고차 함수의 몇 가지 예를 살펴보고 Comparator 인터페이스의 고차 함수를 조사해보자.
2.1 함수를 반환하는 메서드
어떨 때는 배열을 오름차순으로 정렬하고, 어떨 때는 내림차순으로 정렬하게 할 수 있는 메서드를 정의해보자.
public class HighOrderFunction {
public static void main(String[] args) {
String[] words = { "Mary", "had", "a", "little", "lamb" };
Arrays.sort(words, compareInDirection(-1));
System.out.println(Arrays.toString(words));
}
public static Comparator<String> compareInDirection(int direction) {
return (x, y) -> direction * x.compareTo(y);
}
}
compareInDirection(1) 호출은 오름차순 compareInDirection(-1)은 내림차순 비교자를 돌려준다.
웬만하면 주저하지 말고 함수(기술적으로는 함수형 인터페이스를 구현한 클래스의 인스턴스)를 만들어내는 메서드를 작성하자. 이런 메서드는 함수형 인터페이스를 받는 메서드에 전달할 사용자 정의 함수를 만드는 데 유용하다.
2.2 함수를 수정하는 메서드
앞서 본 메서드는 오름차순과 내림차순 문자열 비교자를 돌려준다. 비교자를 역으로 만들면 일반화 할 수 있다.
public static Comparator<String> reverse(Comparator<String> comp) {
return (x, y) -> -comp.compare(x, y);
}
이 메서드는 함수에도 사용할 수 있다. 함수를 인자로 받아 수정된 함수를 반환한다. 대소문자를 구분하지 않는 내림차순 비교자를 얻으려면 다음과 같이 호출한다.
reverse(String::compareToIgnoreCase)
Comparator 인터페이스에는 바로 이 방법으로 주어진 비교자의 역을 만들어내는 기본 메서드 reversed가 있다.
완성된 소스코드
public class HighOrderFunction {
public static void main(String[] args) {
String[] words = { "Mary", "had", "a", "little", "lamb" };
Arrays.sort(words, compareInDirection(-1));
System.out.println(Arrays.toString(words));
Arrays.sort(words, reverse(String::compareToIgnoreCase));
System.out.println(Arrays.toString(words));
}
public static Comparator<String> compareInDirection(int direction){
return (x, y) -> direction * x.compareTo(y);
}
public static Comparator<String> reverse(Comparator<String> comp) {
return (x, y) -> -comp.compare(x, y);
}
}
2.3 Comparator 인터페이스의 메서드
Comparator 인터페이스에는 비교자를 만들어내는 유용한 고차 함수가 정적 메서들 많이 정의되어 있다. comparing 메서드는 T 타입으로 String처럼 비교 가능한 타입으로 매핑하는 ‘키 추출’ 함수를 받는다. 비교 대상 객체에 키 추출 함수를 적용한 다음 반환받은 키를 비교한다.
Person 객체의 배열을 이름으로 정렬하는 예
public class Person {
private String first;
private String middle;
private String last;
public Person(String first, String middle, String last) {
this.first = first;
this.middle = middle;
this.last = last;
}
public Person(String first, String last) {
this.first = first;
this.last = last;
}
public String getFirstName() {
return first;
}
public String getMiddleName() {
return middle;
}
public String getLastName() {
return last;
}
public String getName() {
if (middle == null) {
return first + " " + last;
}
else {
return first + " " + middle + " " + last;
}
}
public String toString() {
return getName();
}
}
Person[] people = {
new Person("George", "Washington"),
new Person("William", "Henry", "Harrison"),
new Person("John", "Quincy", "Adams"),
new Person("James", "Monroe"),
...
}
Arrays.sort(people, Comparator.comparing(Person::getName));
thenComparing 메서드로 비교자를 연결할 때 배교 대상이 같으면 추가로 비교 가능하다.
Arrays.sort(people, Comparator.comparing(Person::getLastName)
.thenComparing(Person::getFirstName));
이름의 길이 순서로 정렬하기
Arrays.sort(people, Comparator.comparing(Person::getName,
(s, t) -> s.length() - t.length()));
comparing, thenComparing 메서드 둘 다 int, long, double 값으 박싱을 피하는 변형도 있다.
Arrays.sort(people, Comparator.comparingInt(p -> p.getName().length()));
키 함수가 null을 반환할 가능성이 있으면 nullsFirst, nullsLast 어뎁터를 사용한다. 정적 메서드 nullsFirst, nullsLast는 기본 비교자를 인자로 받아서, null값을 만나도 예외를 던지지 않고 null 값을 정상 값보다 작은 값과 큰 값으로 취급되도록 비교자를 수정한다.
naturalOrder 메서드는 Comparable을 구현한 모든 클래스로부터 비교자를 생성한다. 다음 호출은 null을 반환할 가능성이 있는 중간 이름으로 정렬한다.
naturalOrder : 각 클래스에 자연스로운 순서를 말하며, naturalOrder가 돌려주는 비교자로 정렬하면 자연 순서로 정렬된 배열 또는 컬렉션을 얻는다. 역은 reverseOrder이다.
// middel name이 null인 객체를 naturalOrder로 정렬함
Arrays.sort(people, Comparator.comparing(Person::getMiddleName, Comparator.nullsFirst(Comparator.naturalOrder())));
3. 지역 내부 클래스
람다 표현하기 이전에 자바에는 함수형이든 아니든, 한 인터페이스를 구현하는 클래스를 간결하게 정의하는 매커니즘이 있었다. 함수형 인터페이스는 람다 표현식을 사용해야 겠지만, 때로는 함수형이 아닌 인터페이스로 간결하게 구현하고 싶을 수도 있다. 그리고 레거시 코드에서도 이런 전통적인 구조를 접하게 될 것이다.
3.1 지역 클래스
메서드 안에 클래스를 정의할 수 있고 이를 지역클래스(로컬 클래스)라고 한다. 보통을 전술적으로 사용한다. 어떤 클래스가 인터페이스 하나를 구현하고, 메서드를 호출하는 쪽이 클래스에는 관심이 없고 인터페이스에만 관심이 있을 때 이렇게 한다.
public class LocalClassDemo {
private static Random generator = new Random();
public static IntSequence randomInts(int low, int high) {
class RandomSequence implements IntSequence {
@Override
public int next() {
return low + generator.nextInt(high - low -1);
}
@Override
public boolean hasNext() {
return true;
}
}
return new RandomSequence();
}
public static void main(String[] args) {
IntSequence dieTosses = randomInts(1, 6);
for (int i = 0; i < 10; i++) System.out.println(dieTosses.next());
}
}
지역 클래스는 메서드 바깥에서 접근할 수 없으므로 public이나 private, protected로 선언할 수 없다.
클래스를 지역 클래스로 만들면 두 가지 이점이 있다.
- 클래스의 이름이 메서드의 유효 범위 안으로 숨는다.
- 람다 표현식의 변수와 마찬가지로 지역 클래스의 메서드에서 지역 클래스를 감싸고 있는 유효 범위에 속한 변수에 접근할 수 있다.
- next 메서드가 low, high, generator 변수를 캡쳐한다.
3.2 익명 클래스
앞에서 RandomSequence라는 이름의 반환 값을 생성할 목적으로 딱 한 번 사용했다. 이럴 때는 클래스를 익명으로 만들 수 있다.
public class AnonymousClassDemo {
private static Random generator = new Random();
public static IntSequence randomInts(int low, int high) {
return new IntSequence() {
@Override
public boolean hasNext() {
return true;
}
@Override
public int next() {
return low + generator.nextInt(high - low + 1);
}
};
}
public static void main(String[] args) {
IntSequence dieTosses = randomInts(1, 6);
for (int i = 0; i < 10; i++) System.out.println(dieTosses.next());
}
}
늘 그렇듯이 new 표현식에서 ()는 생성 인자를 나타낸다. 따라서 익명 클래스의 기본 생성자가 호출된다.
자바에 람다 표현식이 생기기 전에는 runnable, comparator, functional object를 제공하는 가장 결한 문법이었다. 레거시 코드에서 종종 보게 될 것이다.