
람다 표현식은 함수를 하나의 식으로 표현한 것인데요. 메소드의 이름을 갖다버린 익명 함수(Anonymous Function)로 볼 수 있습니다.
이전 챕터 우리가 코드를 유연하고 간결하게 만들어보기 위하여 람다를 통해 코드를 변경시켜봤다. 어떤 특징을 가지고 있을까?
람다는 다음과 같이 이루어져 있다.
(Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());
람다 파라미터 화살표 바디
1. (parameter) -> expression;
2. (parameter) -> {statements;}
이전 챕터에서 봤었던 함수형 인터페이스를 사용했다. 바로 Predicate이다. 동작 파라미터화를 위해서 사용했던 이 친구가 함수형 인터페이스다.
함수형 인터페이스 : 하나의 추상 메서드를 지정하는 인터페이스
대표적인 예시로 Comparator, Runnable 이라는 친구들이 있다.
public interface Comparator<T>{
int compare(T o1, T o2);
}
public interface Runnable{
void run();
}
많은 디폴트 메서드드들이 있더라도 추상 메서드가 오직 하나면 함수형 인터페이스라고 한다!
책에 나온 문제인데 헷갈리는 것이 하나 있다.
public interface Adder{
int add(int a, int b);
}
public interface SmartAdder extends Adder{
int add(double a, double b);
}
정답부터 말하자면 Adder만 함수형 인터페이스이다. SmartAdder는 2 개의 추상 메서드를 가지고 있기 때문에 아닌 것이다.
그래서 핵심은! 함수형 인터페이스는 추상 메서드가 하나여야 한다!
대표적인 람다와 동작 파라미터화의 대표적인 예제이다. 크게 3가지 단계로 나뉜다고 한다.
흔히 알고 있는 DB가 파일을 처리하는 방식이라고 볼 수 있다.
예시로 다음과 같은 코드가 나와있다.
public static String processFile() throws IOException {
try (BufferedReader br = new BufferedReader(new FileReader("data.txt"))) {
return br.readLine();
}
}
위 코드는 파일의 한 행을 읽는 코드이다.
이제부터 위와 같은 코드를 점차 바꿔가보자.
본격적으로 어떻게 구성이 되는지 알아보자.
한 번에 두 행을 읽으려면 어떻게 해야 할까?
String result = processFile((BufferReader br) -> br.readLine() + br.readLine());
이런식으로 람다를 이용하여 동작 파라미터화를 활용하면 되지 않을까? 하는 아이디어로 시작해서 아래의 단계를 거쳐가면서 람다를 넘길 수 있도록 해볼 것이다.
@FunctionalInterface
public interface BufferedReaderProcessor {
String process(BufferedReader b) throws IOException;
}
public String processFile(BufferedReaderProcessor p) throws IOException{
}
이번엔 함수형 인터페이스라는 것을 통해 동작을 전달해보는 것이다.
이 과정은 BufferReader -> String과 IOException을 던질 수 있는 시그니처와 일치하는 함수형 인터페이스를 만드는 과정이다.
이렇게 만들었으니 이제 processFile 메서드의 인수로 전달이 가능해졌다.
여기서부터 이제 위에 이해가 되지 않은것이 하나씩 풀릴 것이다.
(BufferReader -> String) 시그니처와 일치하는 람다를 전달할 수 있다. 코드를 먼저 볼까?
public static String processFile(BufferedReaderProcessor p) throws IOException {
try (BufferedReader br = new BufferedReader(new FileReader(FILE))) {
return p.process(br);
}
}
이제 이것을 활용해서 람다로 표현해볼까?
String oneLine = processFile((BufferReader br) -> br.readLine());
String twoLines = processFile((BufferReader br) -> br.readLine() + br.readLine());
이제부터 이 코드를 보고 다시 한 번 위로 올라가서 아까부터 변화한 과정을 하나씩 보면 다음과 같을 거다.
이 과정들을 보면 고정적이었던 processFile이 동작 파라미터를 통해 유동적으로 바뀌었다는 것을 알 수 있었다.
즉 람다, 함수형 인터페이스를 통해 한 행만 읽었던 코드를 이제 유동적인 코드로 바꿀 수 있다는 것을 알 수 있었다.
함수형 인터페이스의 추상 메서드 시그니처를 함수 디스크립터라고 한다.
다양한 람다 표현식을 사용하려면 공통의 함수 디스크립터를 기술하는 함수형 인터페이스 집합이 필요하다! 자바 8 라이브러리에서는 java.util.Function 패키지에 여러 가지 새로운 함수형 인터페이스를 제공한다.
대표적으로는 Predicate, Consumer, Function 인터페이스 등등이 있다.
이런 형식으로 반환하여 주는 함수형 인터페이스이다. T : Type R: 해당 객체이다.
그런데 궁금한 점이 있다 위 대표적인 예시라고 했던 함수형 인터페이스들은 다 제네릭의 형태로 같은 것이 있는데 여기 T안에는 항상 참조형 타입이 들어가게 된다.
이것은 제네릭의 내부 구현 때문에 어쩔 수 없다고 한다. 그리고 이렇게 기본형을 참조형으로 변환하는 기능을 자바에서 제공하는데 이것을 박싱 그 반대 기능을 언 박싱이라고 한다.
그리고 프로그래머가 편리하게 코드를 구현할 수 있게 오토 박싱도 지원을 해준다고 한다.
그치만 이런 박싱 작업이 장점이 될 수 있긴 하지만 사실 단점도 분명하다.
박싱한 값은 기본형을 감싸는 래퍼이며 힙 메모리에 저장한다. 그래서 메모리를 더 소비하게 된다.
그래서 자바 8에서는 기본형을 입출력으로 사용하는 상황에서 오토박싱 동작을 피할 수 있도록 하는 함수형 인터페이스들이 존재한다고 한다.
ex) DoublePredicate, IntConsumer, LongBinaryOperator, IntFunction .....
타입의 형식들이 앞에 붙어있는 것을 통해 확인을 하며 오토 박싱을 하지 않을 수 있도록 한다.
List<Apple> heavierThan150g = filter(inventory, (Apple apple) -> apple.getWeight() > 150);
이 코드를 컴파일 할 때 형식 검사를 다음과 같은 단계를 거친다고 한다.
여기서 만약 예외를 던질 수 있다면 추상 메서드도 같은 예외를 던질 수 있도록 throws로 선언을 해줘야 한다고 한다!
이런 case도 있을 것이다. 예를 들자면
같은 형태의 람다 표현식의 시그니처가 있을 때 다른 함수형 인터페이스여도 동일하게 사용이 가능하다고 한다.
Callable<Integer> c = () -> 42;
PrivillegedAction<Integer> p = () -> 42;
위 코드는 유효한 코드이다.
위에서 말한 것처럼 동일한 람다 표현식을 사용하지만 서로 호환이 되는 것을 알 수 있다!
여기에서 말하고 싶은 것은 하나의 람다 표현식이 다양한 함수형 인터페이스에 사용할 수 있다는 것이다.
다이아몬드 연산자 <>
List< String > lists = new ArrayList<>() 이 코드에서 <> 가 비어있지만 사실 String이 들어간다는 것을 통해 콘텍스트에 따른 제네릭 형식을 추론할 수 있다는 것을 알 수 있다!
갑자기 위와 같이 형식 추론에 대해서 언급을 했다. 이제부터 얘기할 것이기 때문이다.
Comporator<Apple> c= (Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight()); // 형식 추론 x
Comporator<Apple> c= (a1, a2) -> a1.getWeight().compareTo(a2.getWeight()); //형식 추론
자바 컴파일러는 위와 같이 람다 표현식이 사용된 콘텍스트를 이용해서 함수형 인터페이스를 추론한다. 이 말은 즉, 어떤 형태인지 굳이 Apple을 넣지 않아도 앞의 콘텍스트를 통해서 알 수 있다는 말이다.
결론부터 말하자면 람다 표현식에서 사용하는 지역 변수는 final(한 번만 할당할 수 있는)을 붙인 것과 같은 변수만 사용할 수 있다.
왜냐하면 인스턴스 변수, 지역 변수의 태생이 다르기 때문이라고 한다. 스레드가 2개인 상황을 가정해서 보면 변수 할당을 담당한 1번 스레드, 람다를 담당한 2번 스레드가 있다고 했을 때
1번이 끝났는데 갑자기 2번에서 1번의 변수에 접근하려 하는 상황이 발생하기 때문이다. 그래서 자유 지역 변수(외부 지역 변수)의 복사본을 제공한다. 그래서 복사본의 값이 바뀌면 안되기 때문에 한 번만 할당해야 한다는 제약이 생긴 것이다.
inventory.sort(comparing(Apple::getWeight));
이런 구문을 봤을 거다. 여기서 ::이 바로 메서드 참조(람다의 축약형) 이다.
뭘 축약했냐?
(Apple a) -> apple.getWeight()
이 코드가 위의 메서드 참조를 활용해 축약이 되었다.
메서드 참조는 3가지 유형이 있다.
위 맥락이랑 비슷하다. 다음과 같은 것이다.
Function<Integer, Apple> c = (Apple a) -> new Apple(); // -> Apple::new 생성자 참조
Apple a = c.apply(110);
Function<Integer, Apple> c = Apple::new
Apple a = c.apply(110);
이런 형식처럼 사용하는 것이다.
결론적으로 람다의 표현식을 축약해서 더 분명한 코드를 만들 수 있었다.