Modern Java in Action - CH3 람다 표현식

AIR·2024년 4월 11일
0

Modern Java in Action

목록 보기
3/5

동작 파라미터화를 이용해 변화하는 요구사항에 효과적으로 대응하는 코드를 구현할 수 있고 익명 클래스로 그에 맞는 다양한 동작을 구현할 수 있지만 코드가 깔끔하지 않았다. 자바 8의 람다 표현식은 익명 클래스처럼 이름이 없는 함수면서 메서드를 인수로 전달할 수 있다.

람다란?

람다의 특징은 다음과 같다.

  • 익명: 이름이 없어 구현해야 할 코드에 대한 걱정거리가 줄어든다.
  • 함수: 메서드처럼 특정 클래스에 종속되지 않는다.
  • 전달: 메서드 인수로 전달하거나 변수로 저장할 수 있다.
  • 간결성: 익명 클래스처럼 자질구레한 코드를 구현할 필요가 없다.

람다 표현식은 파라미터, 화살표, 바디로 이루어진다.

(parameters) -> expression  //표현식 스타일
(parameters) -> { statements; }  //블록 스타일

유효한 람다 표현식

(String s) -> s.length()  //String 형식의 파라미터, int 반환
(int x, int y) -> {
	System.out.println(x + y);  //void 리턴
}
() -> "Raoul"
() -> {return "Mario";}

유효하지 않은 람다 표현식

(integer i) -> return "Alan" + i;  //return은 흐름 제어문으로 {}를 포함해야 한다.
(String s) -> {"Iron Man";}  //"Iron Man"은 statement가 아니라 expression이다.

함수형 인터페이스

다음의 Predicate<T>가 함수형 인터페이스인데 오직 하나의 추상 메서드만 지정하기 때문이다.

public interface Predicate<T> {
	boolean test(T t);
}

자바 API의 함수형 인터페이스로는 Comparator, Runnable 등이 있다.

public interface Comparator<T> {
	int compare(T o1, T o2);
}

public interface Runnable {
	void run();
}

람다 표현식으로 함수형 인터페이스의 추상 메서드 구현을 직접 전달할 수 있으므로 전체 표현식을 함수형 인터페이스의 인스턴스로 취급한다.

public static void process(Runnable r) {
    r.run();
}

Runnable r1 = () -> System.out.println("Hello world");
Runnable r2 = new Runnable() {
    public void run() {
        System.out.println("Hello world");
    }
};

//셋 다 동일한 출력
process(r1);
process(r2);
process(() -> System.out.println("Hello world"));

함수 디스크립터

함수형 인터페이스의 추상 메서드 시그니처는 람다 표현식의 시그니처를 가리킨다. 람다 표현식의 시그니처를 서술하는 메서드를 함수 디스크립터(function descriptor)라고 부른다.

() -> void 표기는 파라미터 리스트가 없으며 void를 반환하는 함수를 의미한다.
(apple, Apple) -> int 는 두 개의 Apple을 인수로 받아 int를 반환하는 함수를 가리킨다.

유효한 람다 표현식

@FunctionalInterface
public interface Runnable {
    public abstract void run();
}

public void execute(Runnable r) {
	r.run();
}
excute(() -> {});

람다 표현식 () -> {}의 시그니처는 () -> void이며 Runnable의 추상 메서드 run의 시그니처와 일치한다.

@FunctionalInterface
public interface Callable<V> {
    V call() throws Exception;
}

public Callable<String> fetch() {
	return () -> "Tricky example ;-)";
}

fetch 메서드의 반환 형식은 Callable<String>이고 메서드의 시그니처는 () -> String이 된다.
() -> "Tricky example ;-)"는 () -> String 시그니처이므로 유효하다.

유효하지 않은 람다 표현식

Predicate<Apple> p = (Apple a) -> a.getWeight();

람다 표현식의 시그니처는 (Apple) -> Integer이므로 Predicate<Apple>의 test 메서드의 시그니처 (Apple) -> boolean와 일치하지 않는다.

@FunctionalInterface

@FunctionalInterface는 함수형 인터페이스임을 가르키는 어노테이션이다. 함수형 어노테이션이 아니면 컴파일 에러가 발생한다.

실행 어라운드 패턴

자원 처리에 사용하는 순환 패턴(recurrent pattern)은 자원을 열고, 처리한 다음 자원을 닫는 순서로 이루어진다. 실제 자원을 처리하는 코드를 설정과 정리 두 과정이 둘러싸는 형태를 갖는데 이를 실행 어라운드 패턴(executre around pattern)이라고 부른다.

V1 : 동작 파라미터화 기억

public String processFile() throws IOException {
    //자바 7에 추가된 try-with-resources 구문
    try (BufferedReader br = new BufferedReader(new FileReader("data.txt"))) {
        return br.readLine();
    }
}

현재 processFile 메서드는 파일에서 한 번에 한 줄만 읽을 수 있다. 기존의 설정, 정리 과정은 재사용하고 메서드만 다른 동작을 수행하도록 한다. processFile 메서드가 BufferdReader를 이용해서 다른 동작을 수행할 수 있도록 processFile 메서드로 동작을 전달해야 한다.

processFile 메서드가 한 번에 두 행을 읽게 하려면 다음과 같이 고쳐야 한다.

String result = processFile((BufferedReader br) -> br.readLine() + br.readLine());

V2 : 함수형 인터페이스를 이용해서 동작 전달

함수형 인터페이스 자리에 람다를 사용할 수 있으므로 BufferedReader -> String과 IOException을 던질수 있는 시그니처와 일치하는 함수형 인터페이스를 만든다.

@FunctionalInterface
public interface BufferedReaderProcessor {
    String process(BufferedReader reader) throws IOException;
}

정의한 인터페이스를 메서드의 인수로 전달한다.

public static String processFile(BufferedReaderProcessor p) throws IOException {...}

V3 : 동작 실행

람다 표현식으로 함수형 인터페이스의 추상 메서드 구현을 직접 전달하며 전달된 코드는 함수형 인터페이스의 인스턴스로 전달된 코드와 같은 방식으로 처리한다. 따라서 processFile 바디 내에서 BufferedReaderProcessor 객체의 process를 호출할 수 있다.

public static String processFile(BufferedReaderProcessor p) throws IOException {
    try (BufferedReader br = new BufferedReader(new FileReader("data.txt"))) {
        return p.process(br);
    }
}

V4 : 람다 전달

이제 람다를 이용해서 다양한 동작을 processFile 메서드로 전달할 수 있다.

String oneLine = processFile((BufferedReader br) -> br.readLine());
String twoLine = processFile((BufferedReader br) -> br.readLine() + br.readLine());

Predicate

Predicate<T> 인터페이스는 test라는 추상 메서드를 정의하며 제네릭 형식 T의 객체를 인수로 받아 불리언을 반환한다.

@FunctionalInterface
public interface Predicate<T> {
    boolean test(T t);
}

Predicate<String> predicate = s -> !s.isEmpty();

Consumer

Consumer<T> 인터페이스는 제네릭 형식 T의 객체를 받아서 void를 반환하는 accept라는 추상 메서드를 정의한다.

@FunctionalInterface
public interface Consumer<T> {
    void accept(T t);
}

Consumer<String> consumer = s -> System.out.println(s)

Function

Function<T, R> 인터페이스는 제네릭 형식 T를 인수로 받아서 제네릭 형식 R을 반환하는 추상 메서드 apply를 정의한다.

@FunctionalInterface
public interface Function<T, R> {
    R apply(T t);
}

Function<String, Integer> function = s -> s.length();

기본형 특화

자바의 모든 형식은 참조형(reference type) 아니면 기본형(primitive type)에 해당한다. 하지만 제네릭 파라미터에는 참조형만 사용할 수 있다. 자바에는 변환 기능을 제공하는데 기본형을 참조형을 변환하는 기능을 박싱(boxing)이라 하고 참조형은 기본형으로 반환하는 기능은 언박싱(unboxing)이라고 한다. 자바는 박싱과 언박싱이 자동으로 이루어지는 오토박싱(autoboxing)이라는 기능도 제공하는데 다음은 int가 Integer로 박싱되는 코드이다.

//오토박싱 int -> Integer
List<Integer> list = new ArrayList<>();
for (int i = 300; i < 400; i++) {
	list.add(i);
}

하지만 이런 변환 과정은 비용이 소모된다. 박싱한 값은 기본형을 감싸는 래퍼며 힙에 저장된다. 따라서 박싱한 값은 메모리를 더 소비하여 기본형을 가져올 때도 메모리를 탐색하는 과정이 필요하다.

자바 8에서는 기본형을 입출력으로 사용하는 상황에서 오토박싱 동작을 피할 수 있도록 특별한 버전의 함수형 인터페이스를 제공한다.

@FunctionalInterface
public interface IntPredicate {
    boolean test(int value);
}

IntPredicate evenNumbers = (int i) -> i % 2 == 0;
System.out.println(evenNumbers.test(1000));  //true(박싱 없음)

Predicate<Integer> oddNumbers = (Integer i) -> i % 2 != 0;
System.out.println(oddNumbers.test(1000));  //false(int -> Integer 박싱)

일반적으로 특정 형식을 입력으로 받는 함수형 인터페이스의 이름 앞에는 DoublePredicate, IntConsumer, LongBinaryOperator 처럼 형식명이 붙는다. Function 인터페이스는 ToIntFunction<T>, IntToDoubleFunction 등의 다양한 출력 형식 파라미터를 제공한다.

람다와 함수형 인터페이스 예제

//불리언 표현
Predicate<List<String>> predicate = (List<String> list) -> list.isEmpty();
//객체 생성
Supplier<Apple> supplier = () -> new Apple();
//객체에서 소비
Consumer<Apple> consumer1 = (Apple a) -> {
    System.out.println(a.getWeight());
};
//객체에서 선택/추출
ToIntFunction<String> toIntFunction = (String s) -> s.length();
//두 값 조합
IntBinaryOperator intBinaryOperator = (int a, int b) -> a * b;
//두 객체 비교
Comparator<Apple> comparator = (Apple a1, Apple a2) -> a2.getWeight() - a1.getWeight();

형식 검사

람다가 사용되는 콘텍스트(context)를 이용해서 람다의 형식(type)을 추론할 수 있다. 이때 기대되는 람다 표현식의 형식을 대상 형식(target type)이라고 한다.

List<Apple> heavierThan150g = filter(inventory, apple -> apple.getWeight() > 150);

위 코드의 형식 확인 과정은 다음과 같다.

  1. filter 메서드의 선언을 확인한다. filter(List<T> list, Predicate<T> predicate)
  2. filter 메서드는 두 번째 파라미터로 Predicate<Apple> 형식(대상 형식)을 기대한다.
  3. Predicate<Apple>은 test라는 하나의 추상 메서드를 정의하는 함수형 인터페이스이다.
  4. test 메서드는 Apple을 받아 boolean을 반환하는 함수 디스크립터를 묘사한다. (Apple -> boolean)
  5. 람다의 시그니처와 일치하므로 코드 형식 검사가 성공적으로 완료된다.

특별한 void 호환 규칙

람다의 바디에 일반 표현식이 있으면 void를 반환하는 함수 디스크립터와 호환된다. List의 add 메서드는 Consumber 콘텍스트(T -> void)가 기대하는 void 대신 boolean을 반환하지만 유효한 코드이다.

//Predicate는 불리언 반환값을 갖는다.
Predicate<String> p = s -> list.add(s);
//Consumer는 void 반환값을 갖는다.
Consumber<String> b = s -> list.add(s);

형식 검사 문제. 컴파일할 수 없는 이유는?

Object o = () -> { System.out.println("Hello world"); };

람다 표현식의 콘텍스트는 Object(대상 형식)이다. 하지만 Object는 함수형 인터페이스가 아니다. 따라서 () -> void 형식의 함수 디스크립터를 갖는 Runnable로 대상 형식을 바꿔야 한다.

Runnable r = () -> System.out.println("Hello world");
Object o = (Runnable) () -> { System.out.println("Hello world"); };  //캐스팅

함수형 인터페이스 오버로딩

public void execute(Runnable r) {
	r.run();
}

public void execute(Action<T> a) {
	a.act();
}

execute((Action) () -> {});  //캐스트를 하여 호출 대상 명확하게 지정

형식 추론

대상 형식을 이용해서 함수 디스크립터를 알 수 있으므로 컴파일러는 람다의 시그니처도 추론할 수 있다.

Comparator<Apple> comparator1 = (Apple a1, Apple a2) -> a2.getWeight() - a1.getWeight();
//형식 추론
Comparator<Apple> comparator2 = (a1, a2) -> a2.getWeight() - a1.getWeight();

람다 캡쳐링

람다 표현식에서는 익명 함수가 하는 것 처럼 자유 변수를 활용할 수 있다. 이를 람다 캡쳐링이라고 한다. 하지만 이때 사용하는 지역 변수는 final로 선언되거나 final로 선언된 변수와 똑같이 사용되어야 한다.

int portNumber = 1337;
Runnable r = () -> System.out.println(portNumber);  //portNumber가 뒤에서 재할당되므로 에러
portNumber = 31337;

메서드 참조

메서드 참조를 이용하면 기존의 메서드 정의를 재활용해서 람다처럼 전달할 수 있다.

inventory.sort((Apple a1, Apple a2) -> a1.getWeight() - a2.getWeight());
inventory.sort(Comparator.comparing(Apple::getWeight));  //메서드 참조

기존의 람다가 메서드를 어떻게 호출해야 하는지 설명을 하는 것보다 메서드명을 직접 참조함으로써 가독성을 높일 수 있다.

  1. 정적 메서드 참조
    • (String s) -> Integer.parseInt(s)을 Integer::parseInt로 표현
    • (args) -> ClassName.staticMethod(args)
    • ClassName::staticMethod
  2. 다양한 형식의 인스턴스 메서드 참조
    • (String s) -> s.length을 String::length로 표현
    • (arg0, rest) -> arg0.instanceMethod(rest)
    • ClassName::instanceMethod
  3. 기존 객체의 인스턴스 메서드 참조
    • () -> expensiveTransaction.getValue()을 expensiveTransaction::getValue로 표현
    • (args) -> expr.instanceMethod(args)
    • expr::instanceMethod

세 번째 유형은 다음과 같이 비공개 헬퍼 메서드를 정의한 상황에서 유용하게 활용할 수 있다.

private boolean isValidName(String s) {
	return Character.isUpperCase(s.charAt(0));
}
//Predicate<String>의 인스턴스로 취급하여 전달
List<Word> filter = filter(words, this::isValidName);

생성자 참조

ClassName::new처럼 기존 생성자의 참조를 만들 수 있다. 이것은 정적 메서드의 참조를 만드는 방법과 비슷하다.

//동일한 두코드
Function<Integer, Apple> c = Apple::new;
Function<Integer, Apple> c = (weight) -> new Apple(weight);

Apple a = c.apply(110);  //Function의 apply 메서드에 무게를 인수로 호출

V1 : 코드 전달

List의 sort 메서드에 정렬 전략을 전달한다. sort 메서드는 다음과 같은 시그니처를 갖는다.

void sort(Comparator<? super E> c)

객체 안에 동작을 포함시키는 방식으로 다양한 전략을 전달할 수 있다.

public class AppleComparator implements Comparator<Apple> {
    public int compare(Apple o1, Apple o2) {
        return o1.getWeight() - o2.getWeight();
    }
}
inventory.sort(new AppleComparator());

V2 : 익명 클래스 사용

inventory.sort(new Comparator<Apple>() {
    public int compare(Apple a1, Apple a2) {
        return a1.getWeight() - a2.getWeight();
    }
});

V3 : 람다 표현식 사용

inventory.sort((Apple a1, Apple a2) -> a1.getWeight() - a2.getWeight());
inventory.sort((a1, a2) -> a1.getWeight() - a2.getWeight());
//Comparator는 Comparable 키를 추출해서 객체를 만든다.
//람다 표현식은 사과를 비교하는 데 사용할 키를 어떻게 추출할 것인지 지정하는 한 개의 인수만 포함한다.
Comparator<Apple> c = comparing(a -> a.getWeight());
inventory.sort(comparing(apple -> apple.getWeight()));

V4 : 메서드 참조 사용

inventory.sort(comparing(Apple::getWeight));

Comparator 조합

Comparator<Apple> c = Comparator.comparing(Apple::getWeight);

역정렬

사과의 무게를 내림차순으로 정렬하고 싶다면 주어진 비교자의 순서를 뒤바꾸는 reversed라는 디폴트 메서드를 이용한다.

inventory.sort(comparing(Apple::getWeight).reversed());

Comparator 연결

inventory.sort(comparing(Apple::getWeight)
        .reversed()  //무게를 내림차순으로 정렬
        .thenComparing(Apple::getColor));  //두 사과의 무게가 같다면 색깔별로 정렬

Predicate 조합

Predicate 인터페이스는 복잡한 프레디케이트를 만들 수 있도록 negate, and, or 세 가지 메서드를 제공한다.

Predicate<Apple> redApple = (Apple a) -> a.getColor().equals(RED);
Predicate<Apple> notRedApple = redApple.negate();  //프레디케이트 반전
Predicate<Apple> redAndHeavyOrGreenApple = redApple  //and, or 메서드
        .and(apple -> apple.getWeight() > 150)
        .or(apple -> apple.getColor().equals(GREEN));

Function 조합

Function 인터페이스는 Function 인스턴스를 반환하는 andThen, compose 두 가지 디폴트 메서드를 제공한다.

Function<Integer, Integer> f = x -> x + 1;
Function<Integer, Integer> g = x -> x * 2;
Function<Integer, Integer> h = f.andThen(g); 
Function<Integer, Integer> h2 = f.compose(g);  //f(g(x)) 합성 함수
int result = h.apply(1);  //4 반환
int result2 = h.apply(1);  //3 반환
profile
백엔드

0개의 댓글