람다표현식
이번에는 람다 표현식을 어떻게 만드는지, 어떻게 사용하는지, 어떻게 코드를 간결하게 만들 수 있는지 설명하는 장입니다. 또한 자바 8 API에 추가된 중요한 인터페이스와 형식 추론 등의 기능도 확인하며, 마지막으로 람다 표현식과 함께 위력을 발휘하는 새로운 기능인 메서드 참조를 설명하겠습니다.
람다란 무엇인가?
람다 표현식은 메서드로 전달할 수 있는 익명 함수를 단순화한 것이라고 할 수 있습니다. 람다 표현식에는 이름은 없지만, 파라미터 리스트, 바디, 반환 형식, 발생할 수 있는 예외 리스트는 가질 수 있습니다.
*람다의 특징
- 익명
- 보통의 메서드와 달리 이름이 없어 익명이라 표현합니다. 구현해야 할 코드에 대한 걱정거리가 줄어든다.
- 함수
- 람다는 메서드처럼 특정 클래스에 종속되지 않으므로 함수라고 부릅니다. 하지만 메서드처럼 파라미터 리스트, 바디, 반환 형식, 가능한 예외 리스트를 포함한다.
- 전달
- 람다 표현식을 메서드 인수로 전달하거나 변수로 저장할 수 있다.
- 간결성
- 익명 클래스처럼 많은 자질구레한 코드를 구현할 필요가 없다.
예시)
Comparator<Car> byPrice = new Comparator<Car>() {
@Override
public int compare(Car o1, Car o2) {
return o1.getPrice() - o2.getPrice();
}
};
기존에 많이 사용하는 Comparator를 사용하여 가격별로 오름차순을 할 수 있게 익명 클래스를 구현했습니다.
람다표현식을 사용하면 코드를 더 간결하게 만들 수 있습니다.
Comparator<Car> byPrice = (o1, o2) -> o1.getPrice() - o2.getPrice();
람다표현식을 사용하면 이처럼 compare 메서드의 바디를 직접 전달하는 것처럼 코드를 작성할 수 있습니다.
위 코드에서 확인할 수 있듯이 람다 파라미터, 화살표, 바디 로 이루어져 있습니다.
람다의 표현식은 2가지 스타일이 있습니다.
- 표현식 스타일 (expression style) - 람다라고 알려진 람다의 기본 문법
- (parameters) → expression
- 블록 스타일 (block - style)
- (parameters) → { statements; }
자바에서 람다 표현식을 작성할 때 유의해야 할 사항은 뭘까요?
- 매개변수의 타입을 추론할 수 있는 경우에는 타입을 생략할 수 있습니다.
- 매개변수가 하나인 경우에는 괄호 () 를 생략할 수 있습니다.
- 함수의 몸체가 하나의 명령문으로만 이루어진 경우에는 중괄호를 생략할 수 있습니다. (이때 세미콜론은 붙이지 않음)
- 함수의 몸체가 하나의 return 문으로만 이루어진 경우에는 중괄호를 생략할 수 없습니다.
- return 문 대신 표현식을 사용할 수 있으며, 이때 반환값은 표현식의 결과값이 됩니다. (이때 세미콜론은 붙이지 않음)
다양한 람다 예제
사용 사례 | 람다 예제 |
불리언 표현식 | (List<String> list) → list.isEmpty( ); |
객체 생성 | ( ) → new Car( ) |
객체에서 소비 | (Car car) → { System.out.println(car.getPrice()); } |
객체에서 선택/추출 | (Car car1, Car car2) → car1.getPrice().compareTo(car2.getPrice()) |
두 값을 조합 | (int a, int b) → a * b |
두 값을 비교 | (Car car1, Car car2) → car1.getPrice().compareTo(car2.getPrice()) |
어디에, 어떻게 람다를 사용할까?
그러면 람다 표현식을 배웠는데 정확히 어디에 사용할 수 있을까요?
—> 함수형 인터페이스라는 문맥에서 람다 표현식을 사용할 수 있습니다.
함수형 인터페이스라는 말이 새로 등장을 했는데 함수형 인터페이스가 무엇인지 알아보러 갑시다!
함수형 인터페이스
전에 만들었던 Predicate <T> 인터페이스로 필터 메서드를 파라미터화 할 수 있었음을 기억하나요?
Predicate <T>가 바로 함수형 인터페이스입니다. 함수형 인터페이스는 오직 하나의 추상 메서드만 지정하기 때문입니다. Comparator, Runnable 등도 함수형 인터페이스 입니다.
??? Comparator에는 안에는 다른 메서드도 많은데 왜 함수형 인터페이스 인가요?
—> 인터페이스는 *디폴트 메서드를 포함할 수 있습니다. 많은 디폴트 메서드가 있더라도 추상 메서드가 오직 하나면 함수형 인터페이스입니다.
추가로 Object 클래스 메서드는 디폴트 메서드 취급을 합니다!! equals, hashCode 등등
*디폴트 메서드 : 인터페이스의 메서드를 구현하지 않은 클래스를 고려해서 기본 구현을 제공하는 바디를 포함하는 메서드
함수형 인터페이스로 뭘 할 수 있을까요?
→ 람다 표현식으로 함수형 인터페이스의 추상 메서드 구현을 직접 전달할 수 있으므로 전체 표현식을 함수형 인터페이스의 인스턴스 취급 (기술적으로 따지면 함수형 인터페이스를 구현한 클래스의 인스턴스) 할 수 있습니다.
함수 디스크립터
함수형 인터페이스의 추상 메서드 시그니처는 람다 표현식의 시그니처를 가리킵니다. 람다 표현식의 시그니처를 서술하는 메서드를 함수 디스크립터라고 부릅니다.
예를 들어 Runnable 인터페이스의 유일한 추상 메서드 run은 인수와 반환값(void)이 없으므로 Runnable 인터페이스는 인수와 반환값이 없는 시그니처로 생각할 수 있습니다.
( ) → void 표기는 파라미터 리스트가 없으며 void를 반환하는 함수를 의미합니다. 앞서 설명한 Runnable이 이에 해당합니다.
*예제)
public void process(Runnable r){
r.run();
}
process(() -> System.out.println("Lamda is good"));
//process(() -> { System.out.println("Lamda is good")); };
() -> System.out.println("Lamda is good") 은 인수가 없으며 void를 반환하는 람다 표현식이다. 이는 Runnable 인터페이스의 run 메서드 시그니처와 같습니다. 직접 들어가서 확인해 보면 알 수 있습니다.
그런데 여기서 @FunctionalInterface가 있습니다. 여기서 @FunctionalInterface는 무엇일까요?
--> 이 어노테이션은 바로 함수형 인터페이스임을 가리키는 어노테이션입니다. 해당 어노테이션을 사용하게 되면 함수형 인터페이스가 아니게 되면 컴파일러가 에러를 발생시킵니다.
람다 활용 : 실행 어라운드 패턴
람다와 동작 파라미터화로 유연하고 간결한 코드를 구현하는 데 도움을 주는 실용적인 예제를 살펴봅시다.
자원 처리에 사용하는 순환 패턴은 자원을 열고, 처리한 다음에, 자원을 닫는 순서로 이루어집니다.
실제 자원을 처리하는 코드를 설정과 정리 두 과정이 둘러싸는 형태를 갖습니다.
public String processFile() throws IOException{
try(BufferedReader br = new BufferedReader(new FileReader("data.txt"))){
return br.readLine();
}
}
동작 파라미터화를 떠올린다면 다양하게 바뀌는 요구사항에 전략적으로 대응을 할 수 있었습니다. 작업 A, 작업 B만 다르고 나머지 부분은 같습니다. 그러면 반복되는 코드를 추상화하여 다른 동작을 수행할 수 있도록 만들어 봅시다.
작업을 제외한 설정, 정리 과정은 재사용하고 processFile을 동작 파라미터화하여 다른 동작을 수행할 수 있도록 만들면 됩니다.
BufferedReader를 인수로 받아 String을 반환하고 IOExeption을 던질 수 있는 형태의 시그니처와 일치하는 함수형 인터페이스를 만들어야 합니다.
- BufferedReader → String thorws IOExeption
@FunctionalInterface
public interface BufferedReaderProcessor {
String process(BufferedReader b) throws IOException;
}
인터페이스를 BufferedReaderProcessor 라고 정의를 하였고, 정의한 인터페이스를 processFile 메서드의 인수로 전달할 수 있게 되었습니다.
public String processFile(BufferedReaderProcessor p) throws IOException{
try(BufferedReader br = new BufferedReader(new FileReader("data.txt"))){
return p.process(br);
}
}
이렇게 해서 BufferedReaderProcessor에 정의된 process 메서드의 시그니처 (BufferedReader → String) 과 일치하는 람다를 전달할 수 있습니다.
String oneLine = processFile((BufferedReader br) -> br.readLine());
//또는
String twoLine = processFile(br -> br.readLine() + br.readLine());
만약에 단순 일회성이 아닌 사용하는 곳이 많아진다면 람다를 사용하는 게 아닌 BufferedReaderProceesor를 직접 구현한 클래스를 만들어서 사용하는 것이 좋습니다.
함수형 인터페이스 사용
다시 복습하면, 함수형 인터페이스는 오직 하나의 추상 메서드를 지정한다고 설명드렸습니다.
함수형 인터페이스의 추상 메서드는 람다 표현식의 시그니처를 묘사합니다. 함수형 인터페이스의 추상 메서드 시그니처를 함수 디스크립터라고 합니다. 다양한 람다 표현식을 사용하려면 공통의 함수 디스크럽터를 기술하는 함수형 인터페이스 집합이 필요합니다.
이미 자바 API는 다양한 함수형 인터페이스를 포함하고 있습니다.
ex) Predicate, Comparable, Runnable, Callable...
*기본형 특화
자바의 모든 형식은 참조형 아니면 기본형에 해당합니다. 하지만 제네릭 파라미터에는 참조형만 사용할 수 있습니다. 제네릭의 내부 구현 때문에 어쩔 수 없는 일입니다. 자바에서는 기본형을 참조형으로 변환하는 기능을 제공하는데 이 기능을 박싱(boxing) 이라고 하고, 참조형에서 기본형에서 변환하는 기능을 언박싱(unboxing)이라고 이라고 합니다. 또한 프로그래머가 편리하게 코드를 구현할 수 있도록 해당 기능이 자동으로 이루어지는데 그 기능을 오토박싱 이라고 합니다.
ex) ArrayList<Integer> list 에 int 자료형을 추가할 때 list.add(10), list,add(20) . . .
하지만 이런 변환 과정은 비용이 소모가 됩니다. 박싱한 값은 기본형을 감싸는 래퍼며 힙에 저장됩니다. 따라서 박싱한 값은 메모리를 더 소비하며 기본형을 가져올 때도 메모리를 탐색하는 과정이 필요합니다.
그래서 자바 8에서는 기본형을 입출력으로 사용하는 상황에서 오토박싱 동작을 피할 수 있도록 특별한 버전의 함수형 인터페이스를 제공해줍니다. ex) DoublePredicate, IntConsumer, LongBinaryOperatior, ToIntFunction, IntToDoubleFunction
*예외, 람다, 함수형 인터페이스의 관계
→ 함수형 인터페이스는 확인된 예외를 던지는 동작을 허용하지 않습니다. 즉, 예외를 던지는 람다표현식을 만들려면 확인된 예외를 선언하는 함수형 인터페이스를 직접 정의하거나 람다를 try/catch 블록으로 감싸야합니다.
위에 BufferedReaderProcessor 인터페이스와 달리 Function<T, R> 형식의 함수형 인터페이스를 기대하는 API를 사용하고 있으며 직접 함수형 인터페이스를 만들기 어려운 상황에 다음처럼 명시적으로 확인된 예외를 잡을 수 있습니다.
Function<BufferedReader, String> f = (BufferedReader b) -> {
try {
return b.readLine()
} catch (IOException e) {
throw new RuntimeException(e);
}
};
형식 검사
람다가 사용하는 *콘텍스트를 이용해서 람다의 형식을 추론할 수 있습니다. 어떤 콘텍스트에서 기대되는 람다 표현식의 형식을 대상 형식(target type) 이라고 부릅니다. 람다 표현식을 사용할 때 실제 어떤 일이 일어나는지 보여주는 예제를 보여드리겠습니다.
*콘텍스트 : 람다가 전달될 메서드 파라미터나 람다가 할당되는 변수 등
public static <T> List<T> filter(List<T> list, Predicate<T> p) {
List<T> result = new ArrayList<>();
for (T e : list) {
if (p.test(e)) {
result.add(e);
}
}
return result;
}
List<Car> carGV70 = filter(carList, car -> car.getModel() == Model.GV70);
- 람다가 사용된 콘텍스트는 뭐지? filter 메서드의 선언을 확인한다.
- filter 메서드는 두 번째 파라미터로 Predicate<Car> 형식( 대상 형식 ) 을 기대한다.
- Predicate<Car>는 test라는 한 개의 추상 메서드를 정의하는 함수형 인터페이스다.
- test 메서드는 Car를 받아 boolean을 반환하는 함수 디스크립터를 묘사한다.
- filter 메서드로 전달된 인수는 이와 같은 요구사항을 만족해야 한다.
- Car → boolean 이므로 람다의 시그니처와 일치한다!
같은 람다, 다른 함수형 인터페이스
대상 형식이라는 특징 때문에 같은 람다 표현식이더라도 호환되는 추상 메서드를 가진 다른 함수형 인터페이스로 사용될 수 있습니다.
예를 들어 Callabe과 PrivilegedAction 인터페이스는 인수를 받지 않고 제네릭 형식 T를 반환하는 함수를 정의합니다. 따라서 아래와 같은 코드는 모두 유효합니다.
Callable<Integer> c = () -> 42;
PrivilegedAction<Integer> p = () -> 42;
Callable<String> c = () -> "abc";
PrivilegedAction<String> p = () -> "abc";
할당문 콘텍스트, 메서드 호출 콘텍스트(파라미터, 반환값), 형변환 콘텍스트 등으로 람다 표현식의 형식을 추론할 수 있습니다.
public static void example(Callable c) throws Exception {
c.call();
}
public static void example(PrivilegedAction p){
p.run();
}
example(() -> 10);
만약 위와 같은 코드가 있으면 제대로 수행을 할까요?
→ 정답은 아닙니다. 왜냐하면 Callable과 PrivilegedAction의 함수 디스크립터가 같으므로 example메서드가 누구를 가리키는지 명확하지 않습니다.
example((PrivilegedAction) () -> 10);
위와 같이 캐스팅을 하면 누구를 호출할 것인지가 명확해집니다.
형식 추론
코드를 좀 더 단순하게 만들 수 있는 방법이 있습니다. 자바 컴파일러는 람다 표현식이 사용된 콘텍스트(대상 형식)을 이용해서 람다 표현식과 관련된 함수형 인터페이스를 추론합니다. 즉, 대상 형식을 이용해서 함수 디스크립터를 알 수 있으므로 컴파일러는 람다의 시그니처도 추론할 수 있습니다.
결과적으로 컴파일러는 람다 표현식의 파라미터 형식에 접근할 수 있으므로 람다 문법에서 이를 생략할 수 있습니다.
Comparator<Car> byPrice = (Car c1, Car c2) -> c1.getPrice() - c2.getPrice();
Comparator<Car> byPrice = (c1, c2) -> c1.getPrice() - c2.getPrice()
근데 꼭 반드시 생략하는 게 좋다고는 할 수 없습니다! 프로그래머가 판단하기에 상황에 따라서 어떻게 쓰면 더 좋은지 결정해야 합니다.
지역 변수 사용
지금까지는 람다 표현식은 인수를 자신의 바디 안에서만 사용했습니다. 하지만 람다 표현식에서는 익명 함수가 하는 것처럼 자유 변수(파라미터로 넘겨진 변수가 아닌 외부에서 정의된 변수)를 활용할 수 있다. 이와 같은 동작을 람다 캡쳐링이라고 부릅니다.
Integer a = 100;
Callable<Integer> c = () -> a;
하지만 제약 조건이 있습니다.
람다는 인스턴스 변수와 정적 변수를 자유롭게 캡쳐(자신의 바디에서 참조할 수 있도록)할 수 있습니다. 하지만 그러려면 지역변수는 명시적으로 final 선언이 되어있거나, 실질적으로 final처럼 사용을 해야 합니다. 즉 람다 표현식은 한 번만 할당할 수 있는 지역 변수를 캡쳐할 수 있습니다. ( 인스턴스 변수 캡처는 final 지역변수 this를 캡쳐하는 것과 마찬가지)
Integer a = 100;
Callable<Integer> c = () -> a; //에러 a의 값이 변함
a++;
*지역 변수의 제약
왜 지역 변수에 이런 제약이 필요로 할까요?
→ 우선 인스턴스 변수(힙 영역)와 지역 변수(스택 영역)는 메모리에 저장되는 위치부터 다릅니다.
람다에서 지역 변수에 바로 접근할 수 있다는 가정하에 람다가 스레드에서 실행이 되면 변수를 할당한 스레드가 사라져서 변수 할당이 해제되었는데도 람다가 실행하는 스레드에서는 해당 변수에 접근하려 할 수 있습니다. 따라서 자바 구현에서는 원래 변수에 접근을 허용하는 것이 아니라 자유 지역 변수의 복사본을 제공합니다.
따라서 복사본의 값이 바뀌지 않아야 하므로 지역 변수에는 한 번만 값을 할당해야 한다는 제약이 생겼습니다.
메서드 참조
메서드 참조를 이용하면 기존의 메서드 정의를 재활용해서 람다처럼 전달할 수 있습니다. 때로는 람다 표현식 보다 메서드 참조를 사용하는 것이 더 가독성이 좋으며 자연스러울 수 있습니다.
carList.sort((o1, o2) -> o1.getPrice() - o2.getPrice());
carList.sort(comparing(Car::getPrice));
왜 메서드 참조가 중요할까요?
→ 메서드 참조는 특정 메서드만을 호출하는 람다의 축약형이라고 생각할 수 있습니다. 메서드를 어떻게 호출해야 하는지 설명을 참조하기보다는 메서드명을 직접 참조하는 것이 편리합니다. 명시적으로 메서드를 참조함으로써 가독성을 높일 수 있습니다.
*메서드 참조를 만드는 방법
- 메서드 참조는 세 가지 유형으로 구분할 수 있습니다.
- 정적 메서드 참조
- Integer의 parseInt메서드는 Integer::parseInt로 표현할 수 있습니다.
- 다양한 형식의 인스턴스 메서드 참조
- String의 length 메서드는 String::length로 표현할 수 있습니다.
- 기존 객체 인스턴스 메서드 참조
- 예를 들어 Transcation 객체를 할당받은 expensiveTransaction 지역 변수가 있고, Transaction 객체에는 getValue메서드가 있다면, 이를 expensiveTransaction::getValue 라고 표현할 수 있습니다.
- 비공개 메서드를 정의한 상황에서 유용하게 활용이 가능 람다
List<String> str = Array.asList("a", "b", "A", "B");
str.sort((s1, s2) -> s1.comparToIgnoreCase(s2));
//메서드 참조 사용
List<String> str = Array.asList("a", "b", "A", "B");
str.sort(String::compareToIgnoreCase);
컴파일러는 람다 표현식의 형식을 검사하던 방식과 비슷한 과정으로 메서드 참조가 주어진 함수형 인터페이스와 호환하는지 확인한다. 즉, 메서드 참조는 콘텍스트의 형식과 일치해야 합니다.
생성자 참조
ClassName::new 처럼 클래스명과 new 키워드를 이용해서 기존 생성자의 참조를 만들 수 있습니다. 정적 메서드의 참조를 만드는 방법과 비슷한데. 예를 들어 Supplier의 () → Car 와 같은 시그니처를 갖는 생성자가 있다고 가정해봅시다.
Supplier<Car> c1 = Car::new;
Car c2 = c1.get(); //Supplier의 get 메서드를 호출해서 새로운 Car객체를 만들 수 있다.
//아래와 같다.
Supplier<Car> c1 = () -> new Car();
Car c2 = c1.get();
인스턴스화 하지 않고도 생성자에 접근할 수 있는 기능을 다양한 상황에 응용할 수 있습니다. 예를 들어 Map으로 생성자와 문자열 값을 관련시킬 수 있다. String과 Integer가 주어졌을 때 다양한 가격을 만드는 giveMeCar라는 메서드를 만들 수 있습니다.
static Map<String, Function<Integer, Car>> map = new HashMap<>();
static {
map.put("gv60", Car::new);
map.put("k3", Car::new);
}
public static Car giveMeCar(String car,Integer price){
return map.get(car).apply(price);
}
람다 표현식을 조합할 수 있는 유용한 메서드
자바 8 API 의 몇몇 함수형 인터페이스는 다양한 유틸리티 메서드를 포함합니다. 예를 들어 Comparator, Function, Predicate 같은 함수형 인터페이스는 람다 표현식을 조합할 수 있도록 유틸리티 메서드를 제공합니다. 간단하게 여러 개의 람다 표현식을 조합해서 복잡한 람다 표현식을 만들 수 있습니다.
여기서 이 유틸리티 메서드가 디폴트 메서드입니다. 자세한 건 추후에 나오지만 간단하게 Comparator만 알아봅시다.
*Comparator 조합
이전에도 보았듯이, 정적 메서드 Comparator.comparing을 이용해서 비교에 사용할 키를 추출하는 Function 기반의 Comparator를 반환할 수 있습니다.
Comparator<Car> c = Comparator.comparing(Car::getPrice);
만약에 가격을 내림차순으로 만들자 하면 어떻게 해야 할까요? 또 다른 Comparator인스턴스를 만들 필요가 없습니다. 인스턴스 자체에서 주어진 비교자의 순서를 뒤바꾸는 reverse라는 디폴트 메서드를 제공하기 때문입니다.
Comparator<Car> c = Comparator.comparing(Car::getPrice).reversed();
carList.sort(c);
하지만 가격이 같은 차량이 존재해야 한다면 어떻게 해야할까요?
이럴 때는 thenComparing 메서드로 두 번째 비교자를 만들 수 있습니다. thenComparing은 (comparing메서드 처럼) 함수를 인자로 받아 첫 번째 비교자를 이용해서 두 객체가 같다고 판단되면 두 번째 비교자에 객체를 전달합니다.
carList.sort(c.thenComparing(Car::getModel)); //두 차의 가격이 같으면 모델명으로 정렬
마무리
- 람다 표현식은 익명 함수의 일종이다. 이름은 없지만 파라미터 리스트, 바디, 반환 형식을 가지며 예외를 던질 수 있습니다.
- 람다 표현식을 통해 간결한 코드를 구현할 수 있습니다.
- 함수형 인터페이스란 단 하나의 추상 메서드만을 정의하는 인터페이스입니다.
- 람다 표현식 전체가 함수형 인터페이스의 인스턴스로 취급됩니다.
- 자바 8은 오토박싱 동작을 피할 수 잇는 기본형 특화 인터페이스도 제공합니다.
- 람다 표현식의 기대 형식을 대상 형식이라고 합니다.
- 메서드 참조를 이용하면 기존의 메서드 구현을 재사용하고 직접 전달할 수 있습니다.
'모던 자바 인 액션 스터디' 카테고리의 다른 글
모던 자바 인 액션 - 6장 스트림으로 데이터 수집 (0) | 2022.05.24 |
---|---|
모던 자바 인 액션 - 5장 스트림 활용 (0) | 2022.05.16 |
모던 자바 인 액션 - 4장 스트림 소개 (0) | 2022.05.09 |
모던 자바 인 액션 - 2장 동작 파라미터화 코드 전달하기 (0) | 2022.04.25 |