익명 클래스로 다양한 동작을 구현할 수 있었지만 코드가 깔끔하지는 않았다. 람다로 더 깔끔한 코드를 구현하고 전달하자.

함수형 인터페이스

Predicate<T>, Consumer<T>, Function<T, R>, Supplier<T>, UnaryOperator<T>, BinaryOperator<T> 등의 함수형 인터페이스가 있다.

함수 디스크립터

(Apple, Apple) -> int와 같은 람다 표현식의 시그니처를 함수 디스크립터라고 한다.

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

함수형 인터페이스의 사용

  1. Predicate java.util.Predicate<T> 인터페이스의 test 라는 추상 메서드는 제너릭 T를 받아 boolean을 반환한다.
    @FunctionalInterface
    public interface Predicate<T> { 
      boolean test(T t);
    }
    

    ```java public class PredicateExample { public static void main(String[] args) { Predicate nonEmptyStringPredicate = (String s) -> !s.isEmpty(); List filter = filter(List.of("Hello", "", "World", "!", ""), nonEmptyStringPredicate); System.out.println(filter.size()); }

private static List filter(List list, Predicate p){ List result = new ArrayList<>(); for (T s : list) { if(p.test(s)){ result.add(s); } } return result; } }


2. Consumer
`java.util.Consumer<T>` 인터페이스의 `accept` 라는 추상 메서드는 제너릭 T를 받아 void를 반환한다.
```java
@FunctionalInterface
public interface Consumer<T> {
  void accept(T t);
}
public class ConsumerExample {
    public static void main(String[] args) {
        List<String> strings = List.of("Hello", "World", "!");
        forEach(strings, (String s) -> System.out.println(s));
    }

    private static <T> void forEach(List<T> strings, Consumer<T> c) {
        for (T s : strings) {
            c.accept(s);
        }
    }
}
  1. Function java.util.Function<T, R> 인터페이스의 apply 라는 추상 메서드는 제너릭 T를 받아 제너릭 R을 반환한다. 입력을 출력으로 매핑하는 함수를 정의할 때 사용한다.
    public class FunctionExample {
     public static void main(String[] args) {
         List<Integer> l = map(Arrays.asList("lambdas", "in", "action"), (String s) -> s.length());
         System.out.println(l);
     }
    
     private static <T, R> List<R> map(List<T> list, Function<T, R> o) {
         List<R> result = new ArrayList<>();
         for (T t : list) {
             result.add(o.apply(t));
         }
         return result;
     }
    }
    

기본형 특화

위 세개의 함수형 인터페이스 Predicate, Consumer, Function은 기본형 특화 버전이 있다. 오토박싱의 과정은 비용이 소모되며 성능을 저하시킬 수 있다. 박싱한 값은 기본형을 감싸는 래퍼며 힙에 저장된다. 따라서 박싱한 값은 메모리를 더 소비하며 기본형을 가져올 때도 메모리를 탐색하는 과정을 거치게 된다. 아래 예제에서 IntPredicate는 박싱을 하지 않지만, Predicate<Integer>는 박싱을 한다. 이런식으로 특정 형식을 입력으로 받는 함수형 인터페이스는 IntPredicate, IntConsumer, IntFunction<R> 등이 앞에 타입이 붙는다.

public class App {
    public static void main(String[] args) {
        IntPredicate evenNumbers = (int i) -> i % 2 == 0;
        System.out.println(evenNumbers.test(1000));

        Predicate<Integer> oddNumbers = (Integer i) -> i % 2 != 0;
        System.out.println(oddNumbers.test(1000));
    }
}

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

자바 8의 함수형 인터페이스 종류

| Functional Interface | Function Descriptor | Examples | | ——————– | ——————- | ——– | | Predicate | T -> boolean | IntPredicate, LongPredicate, DoublePredicate | | Consumer | T -> void | IntConsumer, LongConsumer, DoubleConsumer | | Function<T, R> | T -> R | IntFunction, IntToDoubleFunction, IntToLongFunction, LongFunction, LongToDoubleFunction, LongToIntFunction, DoubleFunction, DoubleToIntFunction, DoubleToLongFunction, ToIntFunction, ToDoubleFunction, ToLongFunction | | Supplier | () -> T | BooleanSupplier, IntSupplier, LongSupplier, DoubleSupplier | | UnaryOperator | T -> T | IntUnaryOperator, LongUnaryOperator, DoubleUnaryOperator | | BinaryOperator | (T, T) -> T | IntBinaryOperator, LongBinaryOperator, DoubleBinaryOperator | | BiPredicate<L, R> | (T, U) -> boolean | - | | BiConsumer<T, U> | (T, U) -> void | ObjIntConsumer, ObjLongConsumer, ObjDoubleConsumer | | BiFunction<T, U, R> | (T, U) -> R | ToIntBiFunction<T, U>, ToLongBiFunction<T, U>, ToDoubleBiFunction<T, U> |

참고

Function Descriptor Functional Interface
T -> boolean Predicate<T>
T -> void Consumer<T>
T -> R Function<T, R>
(int, int) -> int IntBinaryOperator
() -> T Supplier<T>
(T, U) -> R BiFunction<T, U, R>

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

형식 검사

어떤 콘텍스트에서 기대되는 람다 표현식의 형식을 대상 형식이라고 한다. 아래 코드를 통해 람다 표현식을 사용하면 실제 어떤 일이 일어나는지 보여준다.

List<Apple> heavierThan150g = filter(inventory, (Apple a) -> a.getWeight() > 150);
  1. filter 메서드의 선언 invetory, (Apple a) -> a.getWeight() > 150을 확인
  2. filter 메서드는 (Apple a) -> a.getWeight() > 150에 대해 Apple을 받아서 boolean을 반환하는 Predicate<Apple>형식으로 대체됨
  3. Predicate<Apple>test라는 추상 메서드 정의하는 함수형 인터페이스
  4. test() 메서드는 Apple을 받아서 boolean을 반환하는 함수 디스크립터를 묘사, boolean test(Apple a)로 Apple을 인수로 받아 boolean을 반환하는 test 메서드로 대체됨

형식 검사2

() -> void 형식의 함수 디스크립터를 갖는 Runnable로 대상 형식을 바꿔서 문제를 해결할 수 있다.

// ERROR > Target type of a lambda conversion must be an interface
Object obj = () -> { System.out.println("Hello"); };

Runnable obj = () -> { System.out.println("Hello"); };
Object obj = (Runnable) () -> { System.out.println("Hello"); };

형식 추론

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

지역 변수 사용, 람다 캡처링

람다 표현식에서는 익명 함수가 하는 것처럼 외부에 정의된 변수(자유변수)를 활용할 수 있고, 이를 람다 캡처링이라고 합니다. 람다 표현식에서 사용될 자유변수는 명시적으로 final이거나 final처럼 작동해야 합니다. 자바 8에서는 effectively final이라는 개념을 도입해 해당 변수가 변경되지 않았다고 컴파일러가 판단하면, 해당 변수를 final로 해석한다.

public class App {
    public static void main(String[] args) {
        int portNumber = 1337;
        Runnable r = () -> System.out.println(portNumber);
        portNumber = 1000; // ERROR
        r.run();
    }
}

왜? 람다는 지역변수에 제약을 걸었을까?

인스턴스 변수와 지역변수는 다른 메모리에 저장됩니다. 인스턴스 변수는 힙에 저장되고, 지역변수는 스택에 저장됩니다. 람다에서 지역변수에 바로 접근이 가능하며 둘이 각각 스레드에서 실행되고 있단 가정하에 변수를 할당한 스레드가 사라져서 할당이 해제가 되어도 람다를 실행하는 스레드에선 해당 변수에 접근하려고 할 수 있기 때문입니다. 중요한 점은, 지역 변수가 스택 메모리에 할당되며 스레드가 종료되면 해당 스택 메모리 영역도 해제된다는 것 그러나 람다 표현식에서 사용하는 지역 변수는 내부적으로 람다 객체에 복사되어 저장됩니다. 따라서, 변수를 할당한 스레드가 종료되어도 람다 표현식은 여전히 그 변수의 복사본에 접근할 수 있습니다.

람다와 클로저 관련한 아주 좋은 글

메서드 참조

메서드 참조는 람다 표현식을 더 간결하게 만들 수 있는 방법입니다.

//람다 미사용
inventory.sort((Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight()));
//람다 사용
inventory.sort(comparing((Apple::getWeight)));
명령어 메서드 참조 대신 함수 표현
(Apple apple) -> apple.getWeight() Apple::getWeight
() -> Thread.currentThread()::dumpStack
(str, i) -> str.substring(i) String::substrㅍㅍing
(String s) -> System.out.println(s) System.out.println
-> this.isValidName(s) this::isValidName

댓글남기기