728x90
반응형





본 글은 모던자바인액션 책을 참고하여 작성한 글입니다.
👉🏻이전글에 이어 본 글에서는 람다표현식이 함수형 인터페이스에서 어떻게 활용되는지 알아보고,
람다표현식의 참조 기법과 디폴트 메서드에 대해 알아보겠습니다.

1. 표준 함수형 인터페이스에서의 람다표현식 사용

표준 함수형 인터페이스

Java8부터는 표준 함수형 인터페이스를 제공합니다.
매번 함수형 인터페이스를 정의하여 사용하는 번거로운 작업을 최소화시키기 위해서죠.
아래 사진은 대표적인 표준 함수형 인터페이스를 나타냅니다.




대표 case

위 사진 속 함수형 인터페이스 중 세 가지 정도만 알아보겠습니다.

Predicate

Predicate 인터페이스는 test라는 추상메서드를 활용하여 제네릭 형식 T객체를 인수로 받아 boolean을 반환하는 함수형 인터페이스입니다.
주로 특정 조건에 관한 판단기준을 정의할 때 활용됩니다.

  • Predicate 예제
@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);



Consumer

Consumer 인터페이스는 accept 라는 추상메서드를 활용하여 제네릭 형식 T객체를 인수로 받아 void를 반환하는 함수형 인터페이스입니다.
특정 동작을 인수로 받아서 실행시키고 싶을 때 주로 활용됩니다.

  • 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);
    }
}
forEach( Arrays.asList(1, 2, 3, 4, 5), (Interger i) -> System.out.println(i) );



Function

Function 인터페이스는 apply 라는 추상메서드를 활용하여 제네릭 형식 T객체를 인수로 받아 제네릭 형식 R객체를 반환하는 함수형 인터페이스입니다.
특정 동작을 인수로 받아서 실행시킨 후에 반환해야 하는 값이 있을 때 주로 활용됩니다.

  • 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;
}

// [7, 2, 6]
List<Integer> l = map(
    Arrays.asList("lambdas", "in", "action"),
    (String s) -> s.length()
);




이 밖에도 ComparatorSupplier와 같은 함수형 인터페이스들이 있고,
Function 인터페이스에서 두 개의 인수를 받고싶다면, BiFunction 인터페이스를 활용할 수 있는 것처럼
다양한 표준 함수형 인터페이스들이 있습니다.


기본형 특화 모델

함수형 인터페이스는 제네릭 형식을 활용하고 있어서 기본형 타입을 사용하는 데 제약이 있습니다.
그래서 기본형 타입도 활용할 수 있는 기본형 특화 함수형 인터페이스가 있습니다.
해당 인터페이스는 제네릭 형식이 아닌 타입을 명시하고 있어서 기본형 타입을 활용할 수가 있습니다.

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




물론 위 코드와 같이 기본형 타입을 제네릭 활용 함수형 인터페이스의 추상 메서드의 인수로 줘도 실행이 됩니다.
왜냐하면 기본형 타입이 참조형 타입으로 변환되는 오토박싱기능이 제공되기 때문입니다.

  • 박싱 : 기본형을 참조형으로 변환하는 기능
  • 언박싱 : 참조형을 기본형으로 변환하는 기능
  • 오토박싱 : 박싱과 언박싱이 자동으로 이루어지는 기능




하지만 이러한 변환 과정은 메모리 공간을 추가로 소비하고, 기본형 타입을 가져오기 위해 메모리를 탐색하는 추가적인 과정이 생기기 때문에
비용이 소모됩니다. 그렇기 때문에 비용이 소모되는 오토박싱동작을 피할 수 있도록 기본형 특화 함수형 인터페이스가 제공됩니다.

  • 제네릭 활용 함수형 인터페이스
public interface Predicate<T> {
    boolean test(T t);
}
Predicate<Integer> oddNumbers = (Integer i) -> i % 2 != 0;
oddNumbers.test(100);



  • 기본형 특화 함수형 인터페이스
public interface IntPredicate {
    boolean test(int i);
}
IntPredicate evenNumbers = (int i) -> i % 2 == 0;
evenNumbers.test(1000);



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

  • 람다 예제 : (List<String> list) -> list.isEmpty(), 함수형 인터페이스 : Predicate<List<String>>
  • 람다 예제 : () -> new Apple(10), 함수형 인터페이스 : Supplier<Apple>
  • 람다 예제 : (Apple a) -> System.out.println(a.getWeight()), 함수형 인터페이스 : Consumer<Apple>
  • 람다 예제 : (String s) -> s.length(), 함수형 인터페이스 : Function<String, Integer> or ToIntFunction<String>
  • 람다 예제 : (int a, int b) -> a * b, 함수형 인터페이스 : IntBinaryOperator
  • 람다 예제 : (Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight()), 함수형 인터페이스 : Comparator<Apple> or BiFunction<Apple, Apple, Integer> or ToIntBiFunction<Apple, Apple>



2. 람다 표현식 컴파일 과정

람다 표현식의 컴파일 과정을 통해 람다 표현식이 어떻게 함수형 인터페이스에 매핑이 되는지 알 수 있습니다.

  • 대상 형식 : 어떤 컨텍스트(ex. 람다 표현식이 파라미터로 입력되는 메서드 등)에서 기대되는 람다 표현식의 형식



형식 검사

람다 표현식은 형식 검사라는 컴파일 과정을 거칩니다.
특정 메서드에 람다표현식을 입력했을 때, 해당 메서드에서 파라미터로 선언한 함수형 인터페이스와 람다표현식이 호환되는지 확인하는 과정이라고 볼 수 있습니다. 예제를 보며 형식 검사가 어떻게 이루어지는지 확인해보겠습니다.



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



  • 형식 검사 과정
  1. filter 메서드의 선언을 확인합니다.
  2. filter 메서드는 두 번째 파라미터로 Predicate<Apple> 형식(대상 형식)을 기대합니다.
  3. Predicate<Apple>은 test라는 한 개의 추상 메서드를 정의하는 함수형 인터페이스입니다.
  4. test 메서드는 Apple을 받아 boolean을 반환하는 함수 디스크립터를 묘사합니다.
  5. filter 메서드로 전달된 인수는 이와 같은 요구사항을 만족해야 합니다.




컴파일러가 위 형식 검사 과정 중 예외를 던지지 않는다면, 형식 검사를 마치고 다음 단계로 넘어가게 됩니다.

형식 추론

형식 추론을 통해 우리는 람다표현식에서의 코드를 더욱 단순화할 수 있습니다.
형식 검사 과정을 통해 우리는 파라미터로 입력된 람다표현식과 관련된 함수형 인터페이스를 추론할 수 있고,
해당 함수디스크립터를 알 수 있으므로 컴파일러가 람다의 시그니처도 추론할 수 있습니다.

즉, 람다 표현식에서 파라미터 형식을 명시하지 않아도 컴파일러가 해당 형식을 추론하여 컴파일을 진행할 수 있습니다.

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

위 코드의 두 표현은 같은 표현입니다.

제약

람다 표현식을 사용하는 데 있어서 변수 사용에 제약이 있습니다.

지역변수의 제약

  • 자유변수 : 람다의 파라미터로 넘겨진 변수가 아닌 외부에서 정의된 변수
  • 람다캡처링 : 람다에서 자유변수를 활용하는 것

람다에서는 인스턴스 변수와 정적 변수를 자유롭게 캡처하여 사용할 수 있지만, 해당 변수는 명시적으로 final로 선언되어야 하거나 실직적으로 final처럼 사용되어야 합니다. 아래 예제는 컴파일 과정에서 에러를 반환하는 코드입니다.

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

portNumber 자유변수의 람다 캡처링 및 활용 후, 해당 변수에 값을 새로 할당하고 있습니다. final처럼 사용해야하는 규칙을 위반했으므로 에러를 반환하게 됩니다.

제약이 필요한 이유

내부적으로 인스턴스 변수와 지역 변수가 있는데, 인스턴스 변수는 힙에 저장되는 반면 지역 변수는 스택에 위치합니다.
그리고 이 때, 람다에서 지역 변수에 직접 접근할 수 있다고 가정한다면(원래는 접근에 제약을 걸어둠), 람다가 다른 스레드에서 실행되고 지역 변수를 선언한 스레드가 사라졌을 때 람다를 실행하는 다른 스레드에서 지역 변수에 다시 접근하려고 할 때 에러가 발생할 여지가 있습니다.




그렇기 때문에 람다에서 지역변수 직접 접근에 제약을 걸었고, 람다에서 지역 변수를 사용해야 한다면 람다캡처링 과정을 통해 해당 지역 변수의 복사본을 사용하게 됩니다. 즉, 해당 지역변수의 값은 람다에서 활용하기 때문에 바뀌면 안되고, final로 선언하거나 final처럼 값이 한 번만 할당될 수 있도록 사용해야 하는 것입니다.



3. 메서드 참조

메서드 참조란?

메서드 참조는 기존에 정의된 메서드를 람다처럼 전달할 수 있는 기법입니다.
메서드명 앞에 구분자 ::를 붙이는 방식으로 활용할 수 있습니다.
Apple::getWeight
위 메서드 참조는 람다 표현식 (Apple a) -> a.getWeight()를 축약한 표현입니다.



  • 기존코드
inventory.sort(Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());
  • 메서드 참조 활용 (java.util.)
inventory.sort(comparing(Apple::getWeight));

이렇게 메서드 참조를 활용하면 코드의 가독성을 높일 수 있습니다.



메서드 참조 생성 case

메서드 참조를 활용하는 case는 대표적으로 아래 3가지 case가 있습니다.

정적 메서드 참조

정적 메서드 참조는 말그대로 정적메서드를 참조하는 case입니다.
Integer의 정적메서드인 parseInt는 아래와 같이 표현할 수 있습니다.
Integer::parseInt

다양한 형식의 인스턴스 메서드 참조

정적 메서드가 아닌 특정 형식의 인스턴스 메서드를 참조하는 case입니다.
String의 인스턴스 메서드인 length는 아래와 같이 표현할 수 있습니다.
String::length

  • 활용 예제
(String s) -> s.toUpperCase();
String::toUpperCase




위 람다표현식을 아래 메서드 참조 표현으로 바꿔서 활용할 수 있습니다.

기존 객체의 인스턴스 메서드 참조

정적 메서드가 아니고 람다 파라미터도 아닌 지역 변수의 인스턴스 메서드를 참조하는 case입니다.
Transaction을 할당받은 람다의 외부객체 expensiveTransaction의 인스턴스 메서드인 getValue는 아래와 같이 표현할 수 있습니다.
expensiveTransaction::getValue

  • 활용 예제
() -> expensiveTransaction.getValue();
expensiveTransaction::getValue

위 람다표현식을 아래 메서드 참조 표현으로 바꿔서 활용할 수 있습니다.

메서드 참조 예제

메서드 참조의 예시를 더 알아보겠습니다.
아래 예제 코드에서 sort 메서드는 인수로 Comparator를 기대하고 있고, 함수 디스크립터로 (T, T) -> int를 갖습니다.

List<String> str = Arrays.asList("a", "b", "A", "B");
str.sort((s1, s2) -> s1.compareToIgnoreCase(s2));




아래와 같이 메서드 참조 표현을 활용하면 위 코드와 같이 Comparator를 새로 구현하지 않고 기존 메서드를 참조시킬 수 있습니다.

List<String> str = Arrays.asList("a", "b", "A", "B");
str.sort(String::compareToIgnoreCase);

String의 인스턴스 메서드 compareToIgnoreCase는 시그니처로 (T, T) -> int를 가지기 때문에 인수로 기대하는 Comparator와 호환됩니다.

4. 생성자 참조

정적 메서드 참조를 만드는 것처럼 new 키워드를 활용하여 생성자 참조를 만들 수 있습니다.
ClassName::new

아래 예제는 () -> T 시그니처를 갖는 Supplier를 활용한 생성자 참조 예제입니다.

  • 기존 코드
Supplier<Apple> c1 = () -> new Apple();
Apple a1 = c1.get();
  • 생성자 참조 활용
Supplier<Apple> c1 = Apple::new; // Apple() 생성자 참조
Apple a1 = c1.get();




아래 예제는 (T) -> T 시그니처를 갖는 Function을 활용한 생성자 참조 예제입니다.

  • 기존 코드
Function<Integer, Apple> c2 = (weight) -> new Apple(weight);
Apple a2 = c2.apply(110);
  • 생성자 참조 활용
Function<Integer, Apple> c2 = Apple::new; // Apple(Integer weight) 생성자 참조
Apple a2 = c2.apply(110);




최종 예제로 아래와 같이 사용할 수 있습니다.

List<Integer> weights = Arrays.asList(7, 3, 4, 10);
List<Apple> apples = map(weights, Apple::new);
public List<Apple> map(List<Integer> list, Function<Integer, Apple> f) {
    List<Apple> result = new ArrayList<>();
    for (Integer i : list) {
        result.add(f.apply(i));
    }
    return result;
}



5. 람다/메서드참조 활용 예제

사과 리스트를 다양한 정렬 기법으로 정렬하는 예제로 람다활용과 메서드참조를 최종적으로 정리해보겠습니다.

코드 전달

정렬 메서드 sort에 정렬기법을 아래 코드와 같이 전달합니다.
동작 파라미터화된 sort메서드는 파라미터로 Comparator를 기대하고 있기 때문에 Comparator를 재정의한 AppleComparator를 인수로 전달할 수 있습니다.

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



익명 클래스 사용

코드를 구현하지 않고 직접 파라미터에 구현코드를 전달하는 방식인 익명 클래스 활용 방식을 이용할 수 있습니다.

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



람다 표현식 사용

지금까지 우리는 람다표현식이 어떻게 함수형 인터페이스에 매핑되는지 알아봤습니다.

람다 표현식을 활용하여 익명 클래스를 사용한 장황한 코드를 간단하게 정리할 수 있습니다.
람다표현식은 함수형 인터페이스를 기대하는 어느 메서드에서나 사용될 수 있습니다.

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




자바 컴파일러는 사용된 콘텍스트를 통해 람다의 파라미터 형식도 추론할 수 있습니다. 그리고 아래와 같이 더 간단하게 정리할 수 있습니다.

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




ComparatorFunction을 인수로 받아 Comparator 객체로 반환하는 comparing이라는 정적 메서드(디폴트 메서드라고도 불림)를 포함합니다. 디폴트 메서드는 아래에서 짚고 넘어가겠습니다.

Comparator<Apple> c = Comparator.comparing((Apple a) -> a.getWeight());
위 표현을 활용하여 아래와 같이 코드를 더 간소화 시킬 수 있습니다.


import static java.util.Comparator.comparing;
inventory.sort(comparing(apple -> apple.getWeight()));



메서드 참조 사용

메서드 참조를 사용하면 람다 표현식을 더 깔끔하게 전달할 수 있습니다.
(apple) -> apple.getWeight(); 람다 표현식은 Apple::getWeight로 표현할 수 있습니다.

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



6. 람다 표현식 조합을 위한 디폴트 메서드

함수형 인터페이스의 정적메서드(디폴트 메서드)를 활용하면 여러개의 람다 표현식을 조합하여 복잡한 람다 표현식을 만들 수 있습니다.
해당 메서드의 예제로 위에서 언급한 Comparator.comparing메서드가 있습니다.

디폴트 메서드란?

디폴트 메서드는 인터페이스를 구현한 클래스에서 구현하지 않아도 되는 메서드를 말합니다.
디폴트 메서드는 아래 코드와 같이 활용됩니다. 아래 코드는 실제 Comparator 선언문의 일부를 가져온 코드입니다.

@FunctionalInterface
public interface Comparator<T> {

    // 유일 추상 메서드
    int compare(T o1, T o2);

    // 최상위 Object 클래스의 추상메서드 (유일 추상 메서드의 함수형 인터페이스 규칙을 위반하지 않음)
    boolean equals(Object obj);

    // 디폴트 메서드
    default Comparator<T> reversed() {
        return Collections.reverseOrder(this);
    }

}




디폴트 메서드는 선언문 앞에 java8에서 새로 추가된 default 키워드가 붙은 메서드입니다.

등장배경

java8 등장이전에는 인터페이스에 메서드가 추가될 때마다 해당 인터페이스를 구현한 클래스에서 해당 메서드를 모두 구현해야 하는 불편함이 있었습니다.
이러한 불편함을 보완하기 위해 java8에서는 구현 클래스에서 구현하지 않아도 되는 디폴트 메서드를 만들어 인터페이스를 쉽게 바꿀 수 있도록 하였습니다.

하지만 java의 클래스는 여러 인터페이스를 구현할 수 있고, 여러 인터페이스에 다중 디폴트 메서드가 있다면 다중 상속을 어느정도 허용한다는 의미라고 볼 수도 있습니다. 원래는 인터페이스에서 메서드를 구현한 일이 없기 때문에 어떤 메서드가 어떤 인터페이스의 메서드인지 알 필요가 없어서 다중상속 문제는 java에서 논외의 문제였는데, 추상메서드가 등장하고 인터페이스에서 메서드가 구현될 수 있게되어 다중상속의 대표적인 문제인 다이아몬드 상속 문제가 발생할 수 있습니다. 해당 문제를 보완하는 방법은 다음 글에서 다뤄보도록 하겠습니다.

  • 다이아몬드 상속 문제 : 동일한 메서드 시그니처(함수명 동일, 파라미터 동일, 반환 타입 동일)의 메서드를 가지는 여러 인터페이스를 상속 받은 클래스에서 어떤 인터페이스의 메서드를 실행해야할 지 결정하지 못하는 문제

람다 표현식 조합 예제

  • Comparator.comparing

comparing 메서드를 활용하면 Comparator 함수형 인터페이스를 람다 표현식으로 일일히 작성하는 것이 아닌
비교할 키를 추출하는 Fucntion을 인수로 받아서 Comparator를 반환할 수 있습니다.

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



  • Comparator.reverse, Comparator.thenComparing

reverse 메서드를 활용하면, 주어진 비교자의 순서를 뒤 바꾸는 역정렬을 수행할 수 있고
thenComparing 메서드를 활용하면, 두 번째 비교자를 추가할 수 있습니다.

아래 코드는 첫 번째 비교자인 무게를 기준으로 사과를 내림차순하고, 동일 무게의 사과일 때는 원산지(Country)순으로 오름차순 정렬하는 코드입니다.

inventory.sort(comparing(Apple::getWeight)
            .reverse()
            .thenComparing(Apple::getCountry));



  • Predicate.negate, Predicate.and, Predicate.or

negate 메서드를 활용하면, 기존 Predicate 객체의 결과를 반전시키는 결과를 반환합니다.
and 메서드를 활용하면, and 조건으로 여러 Predicate를 조합할 수 있고,
or 메서드를 활용하면, or 조건으로 여러 Predicate를 조합할 수 있습니다.


Predicate<Apple> notRedApple = redApple.negate(); // redApple 프레디케이트의 결과를 반전 시킴

Predicate<Apple> redAndHeavyApple = redApple.and(apple -> apple.getWeight() > 150); // 빨갛고 무게가 150 초과하는 사과

Predicate<Apple> redAndHeavyAppleOrGreen = redApple.and(apple -> apple.getWeight() > 150)
                                                    .or(apple -> GREEN.equals(apple.getColor())); // 빨갛고 무게가 150을 초과하거나 초록색인 사과



Reference

반응형

+ Recent posts