이 장의 내용
- 람다란 무엇인가?
- 어디에, 어떻게 람다를 사용하는가?
- 실행 어라운드 패턴
- 함수형 인터페이스, 형식 추론
- 메서드 참조
- 람다 만들기 람다란 무엇인가?
3.1 람다란 무엇인가?
람다 표현식은 메서드로 전달할 수 있는 익명 함수를 단순화한 것이라고 할 수 있다.
- 익명
public String ummm...() {
return "메서드 이름을 뭐로 해야돼!!!";
}
보통의 메서드와 달리 이름이 없어 구현해야 할 코드에 대한 걱정거리가 줄어든다.
- 함수
@Service
@RequiredArgsConstructor
public class OrderService {
private final ProductRepository productRepository;
private final OrderRepository orderRepository;
private final StockRepository stockRepository;
public OrderResponse createOrder(OrderCreateServiceRequest request, LocalDateTime registeredDateTime) {
...
}
해당 코드는 OrderService라는 클래스에서는 주문을 생성하는 createOrder 메서드로 종속되어 있다고 생각할 때, 람다는 클래스와 종속되지 않아서 함수라고 부른다 정도 알고 넘어가자
람다도 메서드처럼 파라미터 리스트, 바디, 변환 형식, 가능한 예외 리스트를 포함한다. (뒤에서 설명)
- 전달
- 람다 표현식을 메서드 인수로 전달하거나 변수로 저장할 수 있다.
- 간결성
- 익명 클래스처럼 많은 자질구레한 코드를 구현할 필요가 없다.
람다 표현식
- 파라미터 리스트
- 파라미터 타입 생략 가능하다.
- 파라미터가 하나일때 () 생략 가능하다.
- 화살표
- 람다의 파라미터 리스트와 바디를 구분한다.
- 람다 바디
- 실행 내용이 단일 실행(한 줄)일때 {} 생략 가능하다.
- {}이 생략되면 return, ;(세미콜론) 생략하고, {}이 생략되지 않으면 return,;(세미콜론)를 적어줘야 한다.
표현식 스타일(expression style) 람다(기본 문법)
//(parameters) -> expression
(String s) -> "Iron Man"
블록 스타일(block style) 람다
//(parameters) -> {expression}
(Integer i) -> {return "Alan" + i;}
3.2 어디에, 어떻게 람다를 사용할까?
전달 - 람다 표현식을 메서드 인수로 전달하거나 변수로 저장할 수 있다...?
간결성 - 익명 클래스처럼 많은 자질구레한 코드를 구현할 필요가 없다...?
함수형 인터페이스
하나의 추상 메서드를 지정하는 인터페이스
@FunctionalInterface
public interface Predicate<T> {
boolean test(T t);
...
}
조심해야 되는 부분..!
@FunctionalInterface
public interface Adder {
int add(int a,int b);
}
public interface SmartAdder extends Adder{
int add(double a, double b);
}
smartAdder는 두 추상 add메서드(하나는 Adder에서 상속받음)를 포함하므로 함수형 인터페이스가 아니다.
두 그림의 차이는 @FunctionalInterface이 있고 없고 차이다.
어노테이션으로 컴파일 시점에 문제를 파악할 수 있기 때문에 어노테이션을 달아주는 것이 좋은거 같다.
//Multiple non-overriding abstract methods found in interface com.study.SmartAdder
물론 SmartAdder를 받는 파라미터에 람다식을 넣어주면 컴파일 시점에 알 수 있지만 조금 더 빠르게 파악하자는 마음으로 어노테이션을 달아주는게 개인적으로 좋아보인다.
자바 API의 함수형 인터페이스
Comparator, Runnable등 일반적으로 자주 쓰이는 형식의 메서드를 함수형 인터페이스로 미리 정의해 놓았다.
이번 예제를 보고 전달과, 간결성에 대한 장점을 생각해보자
@FunctionalInterface
public interface Runnable {
public abstract void run();
}
Thread클래스의 생성자로 Runnable을 받을 수 있다.
람다로 표현하면 생성자의 인자로 넘겨줄 수 있다.
Runnable r1 = () -> System.out.println("r1");
Thread t1 = new Thread(r1);
Thread t2 = new Thread(() -> System.out.println("r2"));
익명클래스로 작성한다면 밑에 코드와 같이 작성할 수 있다.
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("r3");
}
});
쉽게 생각하면 매개변수가 없고 반환타입이 void인 람다 표현식으로 함수형 인터페이스의 추상 메서드 구현을 직접 전달할 수 있으므로
Thread 입장에서 전체 표현식을 함수형 인터페이스의 인스턴스(Runnable)로 취급한다.
기술적으로 따지면 함수형 인터페이스를 구현한 클래스의 인스턴스
람다 표현식 != 익명 클래스
public static void main(String[] args) {
// Anonymous class
displayExpression(new Supplier<Double>() {
@Override
public Double get() {
return 50.0;
}
});
// Lambda expression
displayExpression(() -> 50.0);
}
private static void displayExpression(Supplier<Double> supplier) {
System.out.println(supplier);
System.out.println("Anonymous class: " + supplier.getClass().isAnonymousClass());
}
}
Runnable말고 매개변수가 없고 반환타입이 void인 함수형 인터페이스가 있다면..?
@FunctionalInterface
public interface MyFuncInterface {
public void test();
}
우리의 똑똑한 인텔리제이는 가르쳐주네요..!
하나의 람다 표현식을 다양한 함수형 인터페이스에 사용할 수 있다는 것을 알 수 있다.
Thread t1 = new Thread((Runnable) () -> System.out.println("Runnable"));
타입캐스팅을 해도 된다 이런말을 해주려고 예제를 준비하려다 (생략해도 된다. Thread 생성자에서 Runnable을 받는다고 되어있으니)
MyFuncInterface myFunc = () -> System.out.println("myFunc");
Thread t1 = new Thread((Runnable) myFunc);
매개변수 없고 void를 반환하는게 같으니까 type casting이 가능할까?
예외가 발생했다. 람다를 적용할 때 사용하는 함수형 인터페이스가 달라서 발생하는거 같다.
Exception in thread "main" java.lang.ClassCastException: class com.study.Main$$Lambda$14/0x0000000800064440 cannot be cast to class java.lang.Runnable (com.study.Main$$Lambda$14/0x0000000800064440 is in unnamed module of loader 'app'; java.lang.Runnable is in module java.base of loader 'bootstrap')
at com.study.Main.main(Main.java:8)
함수 디스크립터
함수 디스크립터(function descriptor)란 람다 표현식의 시그니처(siganture)를 서술하는 메서드이다.
- 시그니처란 함수의 설명이라고 이해하자
예를 들어, Runnable 인터페이스는 인수와 반환값이 없는(() -> void) 시그니처 라고 말할 수 있다.
Predicate<Apple> p =(Apple a) -> a.getWeight();
위의 예제의 시그니처는 (Apple) -> Integer이다. Predicate<Apple> -> boolean의 test 메서드와 시그니처와 일치하지 않는다.
확인해보면 똑똑한 인텔리제이가 가르쳐준다.
@FunctionalInterface
public interface Function<T, R> {
R apply(T t);
}
@FunctionalInterface
public interface ToIntFunction<T> {
int applyAsInt(T value);
}
위에 해당하는 함수형 인터페이스의 시그니쳐는 (T) -> Integer, (T) -> int 즉, (Apple) -> Integer과 같다.
try-with-resources
사용 후에 반납해주어야 하는 자원들은 Closable 인터페이스를 구현하고 있다.
package java.lang;
public interface AutoCloseable {
void close() throws Exception;
}
Java7 이전에는 close를 호출하기 위해서 try-catch-finally를 이용해서 처리를 했었다.
public static void main(String[] args) throws IOException {
FileReader fr = null;
BufferedReader br = null;
try {
fr = new FileReader("data.txt");
br = new BufferedReader(fr);
br.readLine();
} finally {
// close resources
if (fr != null) fr.close();
if (br != null) br.close();
}
}
문제점을 생각해 보면
- 자원 반납에 순서도 생각하므로 작업이 번거롭고 코드가 복잡하다.
- 실수로 자원을 반납하지 못하거나 에러로 인해서 자원을 반납하지 못할 수 있다.
- 에러 스택 트레이스가 누락되어 디버깅이 어렵다.
try-with-resources 구문이란?
Java7부터 자원을 자동으로 반납해주는 try-with-resources 문법이 추가 되었다.
AutoCloseable 인터페이스를 구현하고 있는 자원에 대해 try-with-resources를 적용 가능하도록 하였고, 이를 사용함으로써 코드가 유연해지고, 누락되는 에러없이 모든 에러를 잡을 수 있게 되었다.
public static void main(String[] args) throws IOException {
try (BufferedReader br = new BufferedReader(new FileReader("data.txt"))) {
br.readLine();
}
}
3.3 람다 활용 : 실행 어라운드 패턴
- 자원 처리 (예를 들면 데이터베이스의 파일 처리)에 사용하는 순환 패턴(recurrent pattern)은 자원을 열고, 처리한 다음에, 자원을 닫는 순서로 이루어진다.
- 설정(setup)과 정리(cleanup) 과정은 대부분 비슷하다.
- 아래 그림과 같은 형식의 코드를 실행 어라운드 패턴(execute around pattern)이라고 부른다.
1단계 : 동작 파라미터화를 기억하라
밑에 코드는 "data.txt"라는 파일에서 한 번에 한줄만 읽을 수 있다. 유연하게 설계 해보자
public String processFile() throws IOException {
try (BufferedReader br = new BufferedReader(new FileReader("data.txt"))) {
return br.readLine(); //실제 필요한 작업을 하는 행
}
}
기존의 설정, 정리 과정은 재사용하고 해당 메서드가 다른 동작(한 번에 두줄등)을 수행하도록 하기 위해서 processFile의 동작을 파라미터화 하자
2단계 : 함수형 인터페이스를 이용해서 동작 전달
람다를 넘겨주기 위해서는 BufferedReader를 인수로 받아서 String과 IOException을 throw하는 시그니처를 가진 함수형 인터페이스가 필요하다.
@FunctionalInterface
public interface BufferedReaderProcessor {
String process (BufferedReader br) throws IOException;
}
정의한 인터페이스를 processFile 메서드의 인수로 전달할 수 있다.
public String processFile(BufferedReaderProcessor p) throws IOException {
...
}
3단계 : 동작 실행
2단계를 통해서 BufferedReaderProcessor에 정의된 process 메서드의 시그니처 (BufferedReader -> String)와 일치하는 람다 표현식을 인자로 전달할 수 있게 되었다.
processFile 바디 내에서 BufferedReaderProcessor 객체의 process를 호출할 수 있어 동작이 가능하게 된다.
public String processFile(BufferedReaderProcessor p) throws IOException {
try (BufferedReader br = new BufferedReader(new FileReader("data.txt"))) {
return p.process(br); //BufferedReader 객체 처리
}
}
4단계 : 람다 전달
3단계에서 설명한 것 처럼 BufferedReaderProcessor에 정의된 process 메서드의 시그니처 (BufferedReader -> String)와 일치하는 람다 표현식을 processFile 메서드로 전달받아 유연하게 다양한 동작이 가능하게 되었다.
//한 행을 처리하는 코드
String oneLine = t.processFile((BufferedReader br)->
br.readLine());
//두 행을 처리하는 코드
String twoLine = t.processFile((BufferedReader br)->
br.readLine() + br.readLine());
3.4 함수형 인터페이스 사용
다양한 람다 표현식을 사용하면 공통의 함수 디스크립터를 기술하는 함수형 인터페이스 집합이 필요하다.
- 함수 디스크립터: 함수형 인터페이스의 추상 메서드 시그니처
java 8 라이브러리 설계자들은 java.util.function 패키지로 여러 가지 새로운 함수형 인터페이스를 제공한다.
함수형 인터페이스 | 메서드 | 설명 |
java.lang.Runnable | void run() | 매개변수도 없고, 반환값도 없다. |
Supplier<T> | T get() | 매개변수는 없고, 반환값만 있다. |
Consumer<T> | void accept(T t) | Supplier와 반대로 매개변수만 있고, 반환값이 없다. |
Function<T,R> | R apply(T t) | 일반적인 함수, 하나의 매개변수를 받아서 결과를 반환한다. |
Predicate<T> | boolean test(T t) | 조건식을 표현하는데 사용된다. 매개변수는 하나, 반환 타입은 boolean |
- 타입문자 T는 Type을 R은 Return Type을 의미
Predicate
@FunctionalInterface
public interface Predicate<T> {
...
}
public <T> List<T> filter(List<T> entity, Predicate<T> p){
List <T> results = new ArrayList<>();
for (T t : entity) {
if (p.test(t)){
results.add(t);
}
}
return results;
}
//(String) -> boolean
Predicate<String> nonEmptyStringPredicate = (String s) -> !s.isEmpty();
List<String> filter = t.filter(List.of("test1", "test2", "", " "), nonEmptyStringPredicate);
System.out.println(filter);
- T 형식의 객체를 사용하는 불리언 표현식이 필요할 때 직접 정의할 필요 없이 바로 사용이 가능하다.
함수형 인터페이스는 하나의 추상메서드만 있어야 되는데..? and, or, negate 라는 메서드는 어떻게 있는걸까?
Predicate 인터페이스
- 소스코드를 확인하면 default 가 선언되어 있는 것을 볼 수 있는데 default 가 선언된 인터페이스의 메서드를 default method라고 부른다.
디폴트 메서드란 무엇인가?
자바8에서 호환성을 유지하면서 API를 바꿀 수 있도록 추가한 새로운 기능이다.
이제 인터페이스는 자신을 구현하는 클래스에서 메서드를 구현하지 않을 수 있는 새로운 메서드 시그니처를 제공한다.
그럼 디폴트 메서드는 누가 구현할까?
인터페이스를 구현하는 클래스에서 구현하지 않은 메서드는 인터페이스 자체에서 기본으로 제공한다(그래서 디폴트 메서드라고 부른다). 인터페이스에 선언된 메서드는 클래스에서 구현하려할 때에는 반드시 오버라이드Override 되어야 하는 대상이다. 그러나 default 선언된 메서드는 구현하지 않아도 된다.
이와 같은 식으로 Predicate 인터페이스에는 and, or`과 `negate 메서드가 *디폴트 메서드*로 구현되어 있기 때문에 개발자가 별도로 그 메서드들을 구현하지 않아도 함수인터페이스를 사용하면서 사용할 수 있는 것이다.
함수형 인터페이스에 선언된 "디폴트 메서드"나 "정적 메서드"는 함수형 인터페이스의 메서드로 취급하지 않는것인가?
람다표현식으로 전달받은 동작 파라미터를 구현할 때는 함수형 인터페이스에 선언된 메서드(디폴트 메서드나 정적메스드가 아닌 유일한 메서드)를 이용한다고 보면 되겠다.
reference: https://gist.github.com/ihoneymon/3a98ea468766d6ff3678
Consumer
@FunctionalInterface
public interface Consumer<T> {
void accept(T t);
...
}
public <T> void forEach(List<T> list, Consumer<T> c){
for (T t : list){
c.accept(t);
}
}
// (Integer) -> void
forEach(
Arrays.asList(1,2,3,4,5),
(Integer i) -> System.out.println(i)
);
- T 형식의 객체를 인수로 받아서 반환값이 없는 어떤 동작을 수행하고 싶을 때 사용할 수 있다.
Function
@FunctionalInterface
public interface Function<T, R> {
R apply(T t);
...
}
public <T,R> List<R> map(List<T> list, Function<T, R> f){
List<R> result = new ArrayList<>();
for (T t : list){
result.add(f.apply(t));
}
return result;
}
map(
Arrays.asList("lambdas", "in", "action"),
(String s) -> s.length()
);
- T 형식의 객체를 인수로 받아서 R 객체를 반환할 때 사용할 수 있다.
- (위의 예제를 보면 String 타입을 받아서 문자열의 길이 즉, Integer 로 변환한다고 생각할 수 있다.)
기본형 특화
java의 모든 형식은 참조형(Reference type) or 기본형(Primitive type)에 해당한다.
자바 API의 함수형 인터페이스를 보면 제네릭 형식 T를 인수로 받는데 제네릭의 내부 구현 때문에 참조형만 사용할 수 있다.
- 기본형을 참조형으로 변환하는 박싱(boxing), 반대 동작인 언박싱(unboxing)의 작업이 생길 수 있다.
int insertNumber = 927;
List<Integer> list = new ArrayList<>();
list.add(insertNumber);
- 프로그래머가 편리하게 코드를 구현할 수 있도록 박싱, 언박싱이 자동으로 이루어지는 오토박싱(autoboxing) 기능도 제공한다.
이러한 변환 과정은 비용이 소모된다.
박싱한 값은 기본타입을 Wrapper Class객체로 감싸서 생성하기 때문에 힙영역에 생성되고 그 주소값을 참조하는, 참조타입 자료형 입니다.
매개변수를 사용할 때 래퍼클래스보단, 기본타입을 이용하자
- 문제를 해결하고자 자바 8에서는 기본형을 입출력으로 사용하는 상황에서 오토박싱 동작을 피할 수 있도록 특별한 버전의 함수형 인터페이스를 제공한다.
//제네릭X
@FunctionalInterface
public interface IntPredicate {
boolean test(int value);
...
}
IntPredicate evenNumbers = (int i) -> i % 2 == 0;
evenNumbers.test(1000); //박싱 없음
//제네릭O
@FunctionalInterface
public interface Predicate<T> {
boolean test(T t);
...
}
Predicate<Integer> oddNumbers = (Integer i) -> i % 2 == 0;
oddNumbers.test(1000); //박싱
- 일반적으로 특정 형식을 입력으로 받는 함수형 인터페이스의 이름 앞에는 DoublePredicate,IntConsumber처럼 형식명이 붙는다.
3.5 형식 검사, 형식 추론, 제약
- 람다가 사용되는 컨텍스트(context)를 이용해서 람다의 형식(type)을 추론할 수 있다.
- 어떤 컨텍스트에서 기대되는 람다 표현식의 형식을 대상 형식(target type)이라고 부른다.
List<Apple> heavierThan150g = filter(inventory, (Apple apple) -> apple.getWeight() > 150);
- 위 코드의 형식 확인 과정을 보여준다.
- filter 메서드의 선언을 확인한다.
- filter 메서드는 두 번째 파라미터로 Predicate<Apple> 형식(대상 형식)을 기대한다.
- Predicate<Apple>은 test라는 한 개의 추상 메서드를 정의하는 함수형 인터페이스다.
- test 메서드는 Apple을 받아 boolean을 반환하는 함수 디스크립터를 묘사한다.
- filter 메서드로 전달된 인수는 이와 같은 요구사항을 만족해야 한다.
- 람다 표현식이 예외를 던질 수 있다면 추상 메서드도 같이 예외를 던질 수 있도록 throws로 선언해야 한다.
밑에 코드를 컴파일할 수 없는 이유가 무엇일까?
Object o = () -> { System.out.println("Tricky example"); };
- 람다 표현식의 컨텍스트는 Object(대상 형식)인데, Object는 함수형 인터페이스가 아니라서 컴파일을 할 수 없다.
Object o = (Runnable)() -> {
System.out.println("Tricky example");
};
Runnable r = () -> {
System.out.println("Tricky example");
};
- 어떤 메서드의 시그니처가 사용되어야 하는지를 명시적으로 구분하도록 람다를 캐스트할 수 있다.
- 람다 표현식을 명시적으로 대상 형식을 제공하는 Runnable로 캐스팅 한다.
제네릭 타입은 어떻게 추론할까?
기본적으로 자바 컴파일러는 제네릭 타입을 명시하지 않으면 Obejct 타입으로 간주한다.
static <T> T test(T a1, T a2) {
return a2;
}
public static void main(String[] args) {
test("str1", new ArrayList<String>());
test("str1", "str2");
}
test 메서드는 리턴 타입이 매개변수의 타입과 같은 제네릭 형식 T로 선언되어 있다.
인텔리제이 변수 추출기능을 통해서 확인해보면
public static void main(String[] args) {
Serializable test1 = test("str1", new ArrayList<String>());
String test2 = test("str1", "str2"); //둘다 String
}
Serializable, String으로 변환이 되는데 String과 ArrayList가 모두 Serializable의 자식 클래스이기 때문이다.
static <T> T test(T a1, T a2, T a3) {
return a2;
}
Object str1 = test("str1", new ArrayList<String>(), new Apple(120, Color.RED));
- 제네릭 타입을 명시하지 않으면 Obejct 타입으로 간주하는 이유가 모든 클래스의 공통 부모 클래스이기 때문인거 같다.
다이아몬드 연산자
- Java 7에서도 다이아몬드 연산자(<>)로 컨텍스트에 따른 제네릭 형식을 추론할 수 있었다.
- 주어진 클래스 인스턴스 표현식을 두 개 이상의 다양한 컨텍스트에 사용할 수 있다.
- 이때 인스턴스 표현식의 형식 인수는 컨텍스트에 의해 추론된다.
List<String> listOfStrings = new ArrayList<>();
List<Integer> listOfIntegers = new ArrayList<>();
특별한 void 호환 규칙
- 람다의 바디에 일반 표현식이 있으면 void를 반환하는 함수 디스크립터와 호환된다.
- 파라미터 리스트도 호환되어야 한다.
// Predicate는 불리언 반환값을 갖는다.
Predicate<String> p = s -> list.add(s);
// Consumer는 void 반환값을 갖는다.
Consumer<String> b = s -> list.add(s);
- 할당문 컨텍스트, 메서드 호출 컨텍스트(파라미터, 반환값), 형변환(cast) 컨텍스트 등으로 람다 표현식의 형식을 추론할 수 있다.
형식 추론
- 자바 컴파일러는 람다 표현식이 사용된 컨텍스트(대상 형식)를 이용해서 람다 표현식과 관련된 함수형 인터페이스를 추론한다.
- 대상 형식을 이용해서 함수 디스크립터를 알 수 있으므로 컴파일러는 람다의 시그니처도 추론할 수 있다.
// Apple -> boolean
List<Apple> greenApples = filter(apples, apple -> Color.GREEN.equals(apple.getColor()));
- 결과적으로 컴파일러는 람다 표현식의 파라미터 형식에 접근할 수 있으므로 람다 문법에서 이를 생략할 수 있다.
Comparator<Apple> comparator1 = (Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());
Comparator<Apple> comparator2 = (a1, a2) -> a1.getWeight().compareTo(a2.getWeight());
- 즉, 자바 컴파일러는 다음처럼 람다 파라미터 형식을 추론할 수 있다.
지역 변수 사용
- 람다 표현식에서는 익명 함수가 하는 것처럼 자유 변수(free variable, 파라미터로 넘겨진 변수가 아닌 외부에서 정의된 변수)를 활용할 수 있다.
int portNumber = 1337;
Runnable r = () -> System.out.println(portNumber);
- 이와 같은 동작을 람다 캡처링(capturing lambda)이라고 부른다.
- 람다는 인스턴스 변수와 정적 변수를 자유롭게 캡처(자신의 바디에서 참조할 수 있도록)할 수 있다.
- 지역 변수는 명시적으로 final로 선언되어 있어야 하거나 실질적으로 final로 선언된 변수와 똑같이 사용되어야 한다.
람다가 사용하는 지역 변수에 final 이 없을때는 컴파일러가 알아채는데 이때, 컴파일러가 final 을 붙혀주기 때문에 해당 변수를 조작하려는 시도가 있으면 컴파일 에러를 띄운다. 이것을 effectively final 이라고 한다.
- 람다 표현식은 한 번만 할당할 수 있는 지역 변수를 캡처할 수 있다.
- 인스턴스 변수 캡처는 final 지역 변수 this를 캡처하는 것과 마찬가지다.
지역 변수의 제약
- 인스턴스 변수는 힙에 저장되는 반면 지역 변수는 스택에 위치한다.
- 람다에서 지역 변수에 바로 접근할 수 있다는 가정하에 람다가 스레드에서 실행된다면 변수를 할당한 스레드가 사라져서 변수 할당이 해제되었는데도 람다를 실행하는 스레드에서는 해당 변수에 접근하려 할 수 있다.
- 따라서 자바 구현에서는 원래 변수에 접근을 허용하는 것이 아니라 자유 지역 변수의 복사본을 제공한다.
- 따라서 복사본의 값이 바뀌지 않아야 하므로 지역 변수에는 한 번만 값을 할당해야 한다는 제약이 생긴 것이다.
- 또한 지역 변수의 제약 때문에 외부 변수를 변화시키는 일반적인 명령형 프로그래밍 패턴(병렬화를 방해하는 요소)에 제동을 걸 수 있다.
컴파일 에러 발생!!
public static void main(String[] args) throws IOException {
Test t = new Test();
Thread thread = new Thread(t.incrementer(3));
}
class Test {
public Runnable incrementer(int number) {
return () -> System.out.println(number++); //컴파일 에러
}
}
- number 는 지역 변수고 이 코드는 람다 표현식 안에서 number를 조작하는데, 람다가 number 값의 복사본을 만들려고 하기 때문이다. (쉽게 생각하면 컴파일러가 람다를 보고 final 키워드를 붙였는데 number값을 변경하려고 해서 에러가 발생한다고 생각하자)
이 때는 왜 컴피일이 되는걸까?
public class Test {
private int number;
// 생성자
public Test(int number){
this.number = number;
}
// setter
public void setNumber(int number) {
this.number = number;
}
// getter
public int getNumber() {
return number;
}
//람다
public Runnable incrementerWithSideEffect(Test number) {
number.setNumber(4);
return () -> System.out.println(number.getNumber());
}
//main
public static void main(String[] args) {
Test t = new Test(3);
new Thread(t.incrementerWithSideEffect(t)).start(); // 4출력
}
}
- 참조값이 final 이기 때문에 객체 안의 값은 변경할 수 있게 되지만 side effect를 발생시킬 수 있어 위험한 방법이다.
람다와 클로저의 차이점
람다와 클로저는 모두 익명의 특정 기능 블록입니다.
public class Test {
private int number = 3;
public void incrementerByClosure() { //클로저
Runnable runnable = () -> System.out.println(number); //4출력
Thread thread = new Thread(runnable);
thread.start();
number = 4;
}
public void incrementerByLambda(int number) { //람다
Runnable runnable = () -> System.out.println(number);
Thread thread = new Thread(runnable);
thread.start();
// number=4; 컴파일 에러 발생함
}
public static void main(String[] args) {
Test t = new Test();
t.incrementerByClosure();
t.incrementerByLambda(3);
}
}
- 차이점은 클로저는 외부 변수를 참조하고, 람다는 매개변수만 참조한다.
- 람다는 클로저를 포함하는 더 큰 개념이라고 볼 수 있다.
- 람다가 자신의 범위 밖에 있는 변수를 사용하면 그것은 람다인 동시에 클로저 이다.
클로저에서 3이 아니라 4가 출력되는 이유는 무엇일까?
로컬 변수와 다르게 멤버 변수는 힙에 저장되기 때문에 프로그램 종료시까지 살아있거나 가비지 컬렉션이 관리하기 때문에 값이 바뀔 수 있다고 생각이 들었다. 하지만 왜 최신값이 변경되는 것일까?
Heap 영역은 JVM이 관리하는 프로그램 상에서 데이터를 저장하기 위해 런타임 시 동적으로 할당하여 사용하는 영역이라서
런타임 시점에 최신값으로 초기화 되기 때문이라고 생각했는데...
여기서 메서드 호출 됬을 때.. 뭔가 다른게 있나 싶기도 하고 좀더 찾아봐야겠다..
3.6 메서드 참조(Method References)
Comparator<Apple> comparator = (Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());
apples.sort(comparator);
//java.util.Comparator.comparing
Function<Apple, Integer> function = (Apple a) -> a.getWeight();
//메서드 참조
Function<Apple, Integer> methodReferences = Apple::getWeight;
apples.sort(Comparator.comparing(a -> a.getWeight()));
//ArrayList(Class)
@Override
@SuppressWarnings("unchecked")
public void sort(Comparator<? super E> c) {
final int expectedModCount = modCount;
Arrays.sort((E[]) elementData, 0, size, c);
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
modCount++;
}
//Comparator(Interface)
// 내부적으로 전달 받은 Function으로 값을 추출하여 비교하는 Comparator를 반환해준다.
public static <T, U extends Comparable<? super U>> Comparator<T> comparing(
Function<? super T, ? extends U> keyExtractor)
{
Objects.requireNonNull(keyExtractor);
return (Comparator<T> & Serializable)
(c1, c2) -> keyExtractor.apply(c1).compareTo(keyExtractor.apply(c2));
}
//Comparable(Interface)
public int compareTo(T o);
- 메서드 참조는 특정 메서드만 호출하는 람다의 축약형이라고 생각할 수 있다.
- 메서드 참조를 이용하면 기존 메서드 구현으로 람다 표현식을 만들 수 있다.
- 이때 명시적으로 메서드명을 참조함으로써 가독성을 높일 수 있다.
구분자(::)를 붙여 메서드 참조를 활용할 수 있다.
람다 | 메서드 참조 단축 표현 |
(Apple apple) -> apple.getWeight() | Apple::getWeight |
(str, i) -> str.substring(i) | String::substring |
(String s) -> System.out.println(s) | System.out::println |
(String s) -> this.isValidName(s) | this::isValidName |
메서드 참조 만드는 방법
1. 정적 메서드 참조
람다: s -> Integer.parseInt(s)
메서드 참조: Integer::parseInt
2. 다양한 형식의 인스턴스 메서드 참조
람다: (String s) -> s.length()
메서드 참조: String::length
3. 기존 객체의 인스턴스 메서드 참조
public class Apple {
private Color color;
public Color getColor() {
return color;
}
...
}
람다: (Apple apple) -> apple.getColor()
메서드 참조: Apple::getColor
컴파일러는 람다 표현식의 형식을 검사하던 방식과 비슷한 과정으로 메서드 참조가 주어진 함수형 인터페이스와 호환되는지 확인한다.
- 콘텍스트의 형식과 일치해야 한다.
생성자 참조
- ClassName::new처럼 클래스명과 new 키워드를 이용해서 기존 생성자의 참조를 만들 수 있다.
- 인수가 없는 생성자, 즉 Supplier의 () -> Apple과 같은 시그니처를 갖는 생성자가 있다고 가정하자.
@FunctionalInterface
public interface Supplier<T> {
T get();
}
Supplier<Apple> c1 = () -> new Apple(); //default 생성자를 가진 Apple을 생성
Apple a1 = c1.get(); //Supplier의 get 메서드를 호출해서 Apple 객체 반환
(int)->Apple
IntFunction의 apply 메서드에 무게를 인자로 받아서 Apple객체 생성
public Apple(int weight){ //예제 Integer
this.weight = weight;
}
//오토박싱을 피하기 위해서
IntFunction<Apple> c2 = (int weight) -> new Apple(weight); //람다
IntFunction<Apple> c2 = Apple::new; //생성자 참조
Apple a2 = c2.apply(110);
BiFunction의 apply 메서드에 무게와 색상을 인자로 받아서 Apple객체 생성
public Apple(int weight, Color color) {
this.weight = weight;
this.color = color;
}
//(T,U) -> R
BiFunction<Integer, Color, Apple> c3 = Apple::new;
Apple a3 = c3.apply(150, Color.RED);
만약 생성자에 3개 이상 인자로 넣어줘야 되면 생성자 참조와 일치하는 시그니처를 갖는 함수형 인터페이스가 java API에는 없기 때문에 직접 만들어 줘야 한다.
@FunctionalInterface
public interface TriFunction<T, U, V, R> {
R apply(T t,U u,V v);
}
인스턴스화하지 않고 생성자의 접근하기
public class Study {
static Map<String, Function<Integer, Fruit>> map = new HashMap<>();
static {
map.put("apple", Apple::new);
map.put("orange",Orange::new);
// 등등
}
public static Fruit giveMeFruit(String fruit, Integer weight) {
return map.get(fruit.toLowerCase()) // map에서 Function<Integer, Fruit>를 얻었다.
.apply(weight); // Function의 apply 메서드에 정수 무게 파라미터를 제공해서 Fruit를 만들 수 있다.
}
public static void main(String[] args) {
Fruit apple = Study.giveMeFruit("apple", 150);
Fruit orange = Study.giveMeFruit("orange", 150);
}
}
3.8 람다 표현식을 조합할 수 있는 유용한 메서드
- Java 8 API의 몇몇 함수형 인터페이스는 다양한 유틸리티 메서드를 포함한다.
- 예를 들어, Comparator, Function, Predicate 같은 함수형 인터페이스는 람다 표현식을 조합할 수 있도록 유틸리티 메서드를 제공한다.
- 여러 개의 람다 표현식을 조합해서 복잡한 람다 표현식을 만들 수 있다.
- 디폴트 메서드(default method)가 이것을 가능하게 해준다.
Comparator 조합
Comparator.comparing을 이용해서 비교에 사용할 키를 추출하는 Function 기반의 Comparator를 반환할 수 있다.
Comparator<Apple> c = Comparator.comparing(Apple::getWeight);
역정렬
- 사과의 무게를 역정렬하고 싶다고 다른 Comparator 인스턴스를 만들 필요가 없다.
- 인터페이스 자체에서 주어진 비교자의 순서를 뒤바꾸는 reverse라는 디폴트 메서드를 제공하기 때문이다.
default Comparator<T> reversed() {
return Collections.reverseOrder(this);
}
inventory.sort(Comparator.comparing(Apple::getWeight).reversed())
Comparator 연결
- 무게가 같은 두 사과가 존재한다면 다른 정렬 조건이 필요할 수도 있다.
- thenComparing 메서드로 두 번째 비교자를 만들 수 있다.
- thenComparing은 (comparing 메서드처럼) 함수를 인수로 받아 첫 번째 비교자를 이용해서 두 객체가 같다고 판단되면 두 번째 비교자에 객체를 전달한다.
default <U extends Comparable<? super U>> Comparator<T> thenComparing(
Function<? super T, ? extends U> keyExtractor)
{
return thenComparing(comparing(keyExtractor));
}
inventory.sort(Comparator.comparing(
Apple::getWeight)
.reversed() //무게를 내림차순 정렬
.thenComparing(Apple::getCountry)); //두 사과가 같으면 국가별로 정렬
Predicate 조합
- Predicate 인터페이스는 복잡한 프레디케이트를 만들 수 있도록 negate, and, or 세 가지 메서드를 제공한다.
- 왼쪽 -> 오른쪽으로 연결되는데, 만약 a.or(b).and(c)는 (a||b) && c가 된다.
Predicate<Apple> redApple = (Apple a1) -> a1.getColor().equals(Color.RED);
//!redApple
Predicate<Apple> notRedApple = redApple.negate(); // 기존 프레디케이트 객체 redApple의 결과를 반전시킨 객체를 만든다.
//redApple && apple.getWeight() > 150
Predicate<Apple> redAndHeavyApple = redApple.and(apple -> apple.getWeight() > 150); // 두 프레디케이트를 연결해서 새로운 프레디케이트 객체를 만든다.
//(redApple && apple.getWeight() > 150) || greenApple
Predicate<Apple> redAndHeavyAppleOrGreen = redApple.and(apple -> apple.getWeight() > 150)
.or(apple -> GREEN.equals(apple.getColor())); // 프레디케이트 메서드를 연결해서 더 복잡한 프레디케이트 객체를 만든다.
Function 조합
- Function 인터페이스에서 제공하는 람다 표현식도 조합할 수 있다.
- Function 인터페이스는 Function 인스턴스를 반환하는 andThen, compose 두 가지 디폴트 메서드를 제공한다.
- andThen 메서드는 주어진 함수를 먼저 적용한 결과를 다른 함수의 입력으로 전달하는 함수를 반환한다.
Function<Integer, Integer> f = x -> x + 1;
Function<Integer, Integer> g = x -> x * 2;
// 수학으로는 write g(f(x)) 또는 (g ∘ f)(x)라고 표현
Function<Integer, Integer> h = f.andThen(g);
int result = h.apply(1); // 4출력
- compose 메서드는 인수로 주어진 함수를 먼저 실행한 다음에 그 결과를 외부 함수의 인수로 제공한다.
Function<Integer, Integer> f = x -> x + 1;
Function<Integer, Integer> g = x -> x * 2;
// 수학으로는 f(g(x)) 또는 (f ∘ g)(x)라고 표현
Function<Integer, Integer> h = f.compose(g);
int result = h.apply(1); // 3출력
쉽게 순서가 바뀐다고 생각해도 될거같다.
//서로 같다.
Function<Integer, Integer> h = f.compose(g);
Function<Integer, Integer> k = g.andThen(f);
문자열로 구성된 편지 내용을 변환하는 다양한 유틸리티 메서드가 있다고 가정하자
public class Letter {
public static String addHeader(String text) {
return "From Raoul, Mario and Alan: " + text;
}
public static String addFooter(String text) {
return text + " Kind regards";
}
public static String checkSpelling(String text) {
return text.replaceAll("labda", "lambda");
}
}
Function<String, String> addHeader = Letter::addHeader;
Function<String, String> transformationPipeline = addHeader.andThen(Letter::checkSpelling)
.andThen(Letter::addFooter);
- 여러 유틸리티 메서드를 조합해서 다양한 변환 파이프라인을 만들 수 있다.
'책 > 모던 자바 인 액션' 카테고리의 다른 글
8. 컬렉션 API 개선 (0) | 2023.07.28 |
---|---|
7. 병렬 데이터 처리와 성능 (0) | 2023.07.26 |
6. 스트림으로 데이터 수집 (0) | 2023.07.21 |
5. 스트림 활용 (0) | 2023.07.17 |
4. 스트림 소개 (0) | 2023.07.14 |