ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • CHAP3. 람다 표현식
    book/모던 자바 인 액션 2020. 7. 28. 21:00

    1. 람다란 무엇인가?

    람다 표현식 : 메소드로 전달할 수 있는 익명 함수를 단순화한 것

     

    특징

    1. 익명 : 보통 메소드와 달리 이름이 없어 익명이라 표현한다.

    2. 함수 : 람다는 메소드처럼 특정 클래스에 종속되지 않아 함수라고 부른다. 하지만 메소드처럼 파라미터 리스트, 바디, 반환 형식, 가능한 예외 리스트를 포함한다.

    3. 전달 : 람다 표현식을 메소드 인수로 전달하거나 변수로 저장할 수 있다.

    4. 간결성 : 익명 클래스처럼 많은 자질구레한 코드를 구현할 필요가 없다.

    람다는 자바 8 이전의 자바로 할 수 없었던 일을 제공하는 것은 아니고 동작 파라미터를 이용할 때 익명 클래스 등 판에 박힌 코드를 구현할 필요가 없이 더 쉽게 구현할 수 있다.

    - 파라미터 리스트 : Comparator의 compare 모드 파라미터 ( 사과 두 개 )

    - 화살표 : 람다의 파라미터 리스트와 바디를 구분

    - 람다 바디  : 람다의 반환값에 해당하는 표현식

     

    람다 표현식의 5가지 예

    //String 형식의 파라미터 하나를 가지고 int를 반환
    //람다 표현식에는 return이 함축되어 있으므로 return을 명시적으로 사용하지 않아도 됨.
    (String s) -> s.length()
    
    //Apple 형식의 파라미너 하나를 가지고 boolean을 반환
    (Apple a) -> a.getWeight() > 150
    
    //int 형식의 파라미터 두 개를 가지며 리턴값이 없다. ( void 리턴 )
    //람다는 여러행의 문장을 포함할 수 있다.
    (int x, int y) -> {
    	System.out.println("Result : ");
        System.out.println(x + y);
    }
    
    //파라미터가 없으며 int 42를 반환한다.
    () -> 42
    
    //Apple 형식의 파라미터 두 개를 가지고 int를 반환한다.
    (Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight())

     

    표현식 스타일 람다 : (parameter) -> expression

    ex) () -> "Raoul"

    블록 스타일 람다 : (parameter) -> { statements; }

    ex) (Integer i) -> { return "Alan" + i; }

     

     

    2. 어디에, 어떻게 람다를 사용할까?

    람다는 함수형 인터페이스라는 문맥에서 람다 표현식을 사용할 수 있다.

     

    2.1 함수형 인터페이스

    함수형 인터페이스는 정확히 하나의 추상 메소드를 지정하는 인터페이스이다.

    public interface Predicate<T> {
    	boolean test(T t);
    }
    
    
    public interface Comparator<T> {
    	int compare(T o1, T o2);
    }
    
    
    public interface Runnable{
    	void run();
    }

    위의 인터페이스들이 함수형 인터페이스의 예이다.

     

    인터페이스는 디폴트 메소드, 즉 인터페이스의 메소드를 구현하지 않은 클래스를 고려해서 기본 구현을 제공하는 바디를 포함하는 메소드를 포함할 수 있다.

    많은 디폴트 메소드가 있더라도 추상 메소드가 오직 하나면 함수형 인터페이스이다.

     

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

    //람다 사용
    Runnable r1 = () -> System.out.println("Hello World 1");
    
    //익명 클래스 사용
    Runnable r2 = new Runnable() {
    	public void run() {
        	System.out.println("Hello World 2");
        }
    };
    
    public static void process(Runnable r) {
    	r.run();
    }
    
    
    process(r1);
    process(r2);
    //직접 전달된 람다 표현식
    process(() -> System.out.println("Hello World 3"));

     

     

    2.2 함수 디스크립터

    함수형 인터페이스의 추상 메소드 시그니처는 람다 표현식의 시그니처를 가리킨다.

    람다 표현식의 시그니처를 서술하는 메소드함수 디스크립터라고 부른다.

     

    예를 들어 Runnable 인터페이스의 유일한 추상 메소드 run은 인수와 반환값이 없으므로

    Runnable 인터페이스는 "인수와 반환값이 없는 시그니처"라고 할 수 있다.

     

    @FunctionalInterface란?

    @FunctionalInterface는 함수형 인터페이스임을 가리키는 어노테이션이다.

    @FunctionalInterface로 인터페이스를 선언했지만 실제로 함수형 인터페이스가 아니라면 컴파일 과정에서 에러가 발생한다.

     

     

    3. 람다 활용 : 실행 어라운드 패턴

    자원 처리 ( BufferedReader ) 처럼 실제 자원을 처리하는 코드를 설정과 정리 두 과정이 둘러싸는 형태를 실행 어라운드 패턴이라고 한다.

     

    3.1 1단계 : 동작 파라미터화를 기억하라

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

    현재 코드는 파일에서 한 번에 한 줄만 읽을 수 있다.

    한번에 두 줄을 읽거나 가장 자주 사용되는 단어를 반환하려면 어떻게 해야 할까?

     

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

     

    3.2 2단계 : 함수형 인터페이스를 이용해서 동작 전달

    @FunctionalInterface
    public interface BufferedReaderProcessor {
    	String process(BufferedReader b) throws IOException;
    }
    
    public String processFile(BufferedReaderProcessor p) throws IOException {
    	...
    }

     

    3.3 3단계 : 동작 실행

    이제 BufferedReaderProcessor에 정의된 process 메소드의 시그니처 ( BufferedReader -> String )와 일치하는 람다를 전달할 수 있다.

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

     

    3.4 4단계 : 람다 전달

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

     

     

    4. 함수형 인터페이스 사용

    자바 8에서 java.util.function 패키지로 여러 가지 새로운 함수형 인터페이스를 제공한다.

     

    4.1 Predicate

    test라는 추상 메소드를 정의하며 test는 제네릭 형식 T의 객체를 인수로 받아 불리언을 반환한다.

    String 객체를 인수로 받아 람다를 정의하는 예제

    @FunctionalInterface
    public interface Predicate<T> {
        boolean test(T t);
    }
    
    public <T> List<T> filter(List<T> list, Predicate<T> p) {
    	List<T> results = new ArrayList<>();
    	for (T t: list) {
    		if(p.test(t)) {
    			results.add(t);
    		}
    	}
    	return results;
    }
    
    Predicate<String> nonEmptyStringPredicate = (String s) -> !s.isEmpty();
    List<String> nonEmpty = filter(listOfStrings, nonEmptyStringPredicate);

     

    4.2 Consumer

    제네릭 형식 T 객체를 받아서 void를 반환하는 accept라는 추상 메소드를 정의한다.

    T형식의 객체를 인수로 받아서 어떤 동작을 수행하고 싶을 때 Consumer 인터페이스를 사용할 수 있다.

    forEach와 람다를 이용해서 리스트의 모든 항목을 출력하는 예제

    @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);
    	}
    }
    
    forEach(
        Arrays.asList(1,2,3,4,5),
        //Consumer의 accept 메소드를 구현하는 람다
        (Integer i) -> System.out.println(i)
    );

     

    4.3 Function

    제네릭 형식 T를 인수로 받아서 제네릭 형식 R 객체를 반환하는 추상 메소드 apply를 정의한다.

    입력을 출력으로 매핑하는 람다를 정의할 때 활용할 수 있다.

    String 리스트를 인수로 받아 각 String의 길이를 포함하는 Integer 리스트로 변환하는 map 메소드를 정의하는 예제

    @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;
    }
    
    List<Integer> l = map(
    		Arrays.asList("lambdas", "in", "action"),
    		(String s) -> s.length()
    );

     

     

    5. 형식 검사, 형식 추론, 제약

    람다 표현식 자체에는 람다가 어떤 함수형 인터페이스를 구현하는지의 정보가 포함되어 있지 않다.

    따라서 람다 표현식을 더 제대로 이해하려면 람다의 실제 형식을 파악해야한다.

     

    5.1 형식 검사

    람다가 사용되는 콘텍스트를 이용해서 람다의 형식을 추론할 수 있다.

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

    위 코드의 형식 확인 과정은 아래와 같다.

    1. filter 메소드의 선언을 확인한다.

    2. filter 메소드는 두 번째 파라미터로 Predicate<Apple> 형식 ( 대상 형식 )을 기대한다.

    3. Predicate<Apple> 은 teset라는 한 개의 추상 메소드를 정의하는 함수형 인터페이스이다.

    4. test 메소드는 Apple을 받아 boolean을 반환하는 함수 디스크립터를 묘사한다.

    5. 함수 디스크립터는 Apple -> boolean  이므로 람다의 시그니처와 일치한다.

     

    5.2 같은 람다, 다른 함수형 인터페이스

    대상 형식이라는 특징 때문에 같은 람다 표현식이라더라도 호환되는 추상 메소드를 가진 다른 함수형 인터페이스로 사용될 수 있다.

    Comparator<Apple> c1 = (Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());
    ToIntBiFunction<Apple, Apple> c2 = (Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());
    BiFunction<Apple, Apple, Integer> c3 = (Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());

     

    5.3 형식 추론

    자바 컴파일러는 람다 표현식이 사용된 콘텍스트 ( 대상 형식 )를 이용해서 람다 표현식과 관련된 함수형 인터페이스를 추론한다.

    결과적으로 컴파일러는 람다 표현식의 파라미터 형식에 접근할 수 있으므로 람다 문법에서 이를 생략할 수 있다.

    Comparator<Apple> c1 = (a1, a2) -> a1.getWeight().compareTo(a2.getWeight());

    위를 보면 a1와 a2의 타입이 생략된 것을 볼 수 있다.

    //형식을 추론하지 않음
    Comparator<Apple> c = (Apple a1, Apple a2) => a1.getWeight().compareTo(a2.getWeight());
    
    //형식을 추론함
    Comparator<Apple> c = (a1, a2) => a1.getWeight().compareTo(a2.getWeight());

    위와 같이 형식을 배제하면 가독성을 향상시킬 수 있지만 무조건 배제한다고해서 좋은 것은 아님!!

     

    5.4 지역 변수 사용

    람다 표현식은 파라미터로 넘겨진 변수가 아닌 외부에서 정의된 변수, 즉 자유변수를 활용할 수 있다.

    이와 같은 동작을 람다 캡처링이라고 부른다.

    다음은 portNumber 변수를 캡처하는 람다 예제이다.

    int portNumber = 1337;
    Runnable r = () -> System.out.println(portNumber);

    위처럼 하기 위해서는 지역 변수는 명시적으로 final이 선언되어 있어야 하거나 실질적으로 final로 선언된 변수와 똑같이 사용되어야 한다.

    즉, 람다 표현식은 한 번만 할당할 수 있는 지역 변수를 갭처할 수 있다.

    다음은 잘못된 예제이다.

    int portNumber = 1337;
    Runnable r = () -> System.out.println(portNumber);
    int portNumber = 31337;

     

    왜 지역 변수에 이런 제약이 필요할까?

    인스턴스 변수는 힙에 저장되는 반면 지역 변수는 스택에 위치한다.

    람다에서 지역 변수에 바로 접근할 수 있다는 가정하에 람다가 스레드에서 실행된다면 지역 변수를 할당한 스레드가 사라져서 변수 할당이 해제되었는데도 람다를 실행하는 스레드에서는 해당 지역 변수에 접근하려 할 수 있다.

    따라서 자바 구현에서는 원래 변수에 접근을 허용하는 것이 아니라 자유 지역 변수의 복사본을 제공한다.

    따라서 복사본의 값이 바뀌지 않아야 하므로 지역 변수에는 한 번만 값을 할당해야 한다는 제약이 생긴 것이다.

     

    6. 메소드 참조

    메소드 참조는 특정 람다 표현식을 축약한 것이라고 생각하면 된다.

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

    람다 표현식보다 메소드 참조를 사용하는 것이 더 가독성이 좋으며 자연스러울 수 있다.

    메소드 참조를 새로운 기능이 아니라 하나의 메소드를 참조하는 람다를 편리하게 표현할 수 있는 문법으로 간주할 수 있다.

    기존 코드

    inventory.sort((Apple a1, Apple a2) ->
    				a1.getWeight().compareTo(a2.getWeight()));

     

    메소드 참조와 java.util.Comparator.comparing 을 활용한 코드

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

     

    메소드 참조를 만드는 방법

    1. 정적 메소드 참조

    Integer의 parseInt 메소드는 Integer::parseInt로 표현할 수 있다.

    2. 다양한 형식의 인스턴스 메소드 참조

    String 의 length 메소드는 String::length 로 표현할 수 있다.

    3. 기존 객체의 인스턴스 메소드 참조

    예를 들어 Transaction 객체를 할당받은 expensiveTransaction 지역 변수가 있고 Transaction 객체에는 getValue 메소드가 있다면 이를 expensiveTransaction::getValue라고 표현할 수 있다.

     

    컴파일러는 람다 표현식의 형식을 검사하던 방식과 비슷한 과정으로 메소드 참조가 주어진 함수형 인터페이스와 호환하는지 확인한다.

    즉, 메소드 참조는 콘텍스트 형식과 일치해야 한다.

     

    6.2 생성자 참조

    ClassName::new 처럼 클래스 명과 new 키워드를 이용해서 기존 생성자의 참조를 만들 수 있다.

    //인수가 없는 생성자, 즉 () -> Apple과 같은 시그니처
    Supplier<Apple> c1 = Apple::new;
    Supplier<Apple> c1 = () -> new Apple();
    
    //Apple(Integer weight)라는 시그니처를 갖는 생성자는 Function 인터페이스의 시그니처와 같다
    Function<Integer, Apple> c2 = Apple::new;
    Function<Integer, Apple> c2 = (weight) -> new Apple(weight);

     

     

    7. 람다, 메소드 참조 활용하기

     

    7.1 1단계 : 코드 전달

    //sort 메소드의 시그니처
    void sort(Comparator<? super E> c)
    
    public class AppleComparator implements Comparator<Apple> {
    	@Override
    	public int compare(Apple o1, Apple o2) {
    		return o1.getWeight().compareTo(o2.getWeight());
    	}
    } 
    inventory.sort(new AppleComparator());

     

    7.2 2단계 : 익명 클래스 사용

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

     

    7.3 3단계 : 람다 표현식 사용

    inventory.sort((Comparator<Apple>) (a1, a2) -> a1.getWeight().compareTo(a2.getWeight()));

     

    7.4 4단계 : 메소드 참조 사용

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

     

     

    8. 람다 표현식을 조합할 수 있는 유용한 메소드

    람다 표현식을 조합할 수 있는 유용한 메소드는 바로 디폴트 메소드(추상 메소드가 아니므로 디폴트 메소드가 여러 개 있더라도 함수형 인터페이스의 정의를 벗어나지 않음)이다.

     

    8.1 Comparator 조합

    //사과 무게순으로 정렬
    Comparator<Apple> c = Comparator.comparing(Apple::getWeight);
    
    //역순 정렬
    inventory.sort(comparing(Apple::getWeight).reversed());
    
    //무게가 같은 경우 비교자 추가
    inventory.sort(comparing(Apple::getWeight)
    		.reversed()
            .thenComparing(Apple::getCountry));

     

    8.2 Predicate 조합

    //특정 Predicate를 반전시킬 때 사용하는 메소드 negate()
    Predicate<Apple> notRedApple = redApple.negate();
    
    //and 메소드를 이용해 빨간색이면서 무거운 사과를 선택
    Predicate<Apple> redAndHeavyApple = 
    		redApple.and(apple -> apple.getWeight() > 150);
            
    //or 메소드 추가
    Predicate<Apple> redAndHeavyApple = redApple
    				.and(apple -> apple.getWeight() > 150)
            			.or(apple -> GREEN.equals(a.getColor()));
    

     

    8.3 Function 조합

    //andThen 메소드는 주어진 함수를 먼저 적용한 결과를 다른 함수의 입력으로 전달하는 함수
    Function<Integer, Integer> f = x -> x + 1;
    Function<Integer, Integer> g = x -> x * 2;
    Function<Integer, Integer> h = f.andThen(g); //수학적으론 g(f(x))
    int result = h.apply(1); // 4를 반환 
    
    //compose 메소드는 인수로 주어진 함수를 먼저 실행한 다음에 그 결과를 외부 함수의 인수로 제공
    Function<Integer, Integer> f = x -> x + 1;
    Function<Integer, Integer> g = x -> x * 2;
    Function<Integer, Integer> h = f.compose(g); // 수학적으론 f(g(x))
    int result = h.apply(1); // 3을 반환
Designed by Tistory.