Java SE 8에 람다 표현식이 도입되면서 JDK API가 대대적으로 재작성되었습니다. 제네릭을 도입한 JDK 5보다 람다를 도입한 JDK 8에서 더 많은 클래스가 업데이트되었습니다.
함수형 인터페이스라는 매우 간단한 정의 덕분에 기존의 많은 인터페이스가 수정할 필요 없이 functional 인터페이스가 되었습니다. 기존 코드도 마찬가지입니다. 애플리케이션에 Java SE 8 이전에 작성된 인터페이스가 있는 경우, 손대지 않고도 람다로 구현할 수 있는 기능적 인터페이스가 될 수 있습니다.
java.util.function 패키지 알아보기
JDK 8에는 애플리케이션에서 사용할 수 있는 함수형 인터페이스가 포함된 새로운 패키지인 java.util.function도 도입되었습니다. 이러한 함수형 인터페이스는 JDK API, 특히 컬렉션 프레임워크와 스트림 API에서 많이 사용됩니다. 이 패키지는 java.base 모듈에 있습니다.
40개가 조금 넘는 인터페이스를 가진 이 패키지는 처음에는 조금 무섭게 보일 수 있습니다. 하지만 네 가지 주요 인터페이스를 중심으로 구성되어 있습니다. 이를 이해하면 다른 모든 인터페이스를 이해하는 열쇠를 얻을 수 있습니다.
Supplier<T>를 사용하여 객체 생성 또는 제공
Supplier<T> 인터페이스 구현하기
첫 번째 인터페이스는 Supplier<T> 인터페이스입니다. 간단히 말해 supplier는 인수를 받지 않고 객체를 반환합니다.
supplier 인터페이스를 구현하는 람다는 인수를 받지 않고 객체를 반환합니다. 바로 가기를 만들어두면 혼란스럽지 않다면 더 쉽게 기억할 수 있습니다.
이 인터페이스는 정말 간단합니다. 기본 메서드나 정적 메서드가 없고 평범한 get() 메서드만 있습니다. 이 인터페이스는 다음과 같습니다:
@FunctionalInterface
public interface Supplier<T> {
T get();
}다음 람다는 이 인터페이스를 구현한 것입니다:
Supplier<String> supplier = () -> "Hello Duke!";`이 람다 표현식은 단순히 Hello Duke! 문자열을 반환합니다. 호출될 때마다 새 객체를 반환하는 supplier를 작성할 수도 있습니다:
Random random = new Random(314L);
Supplier<Integer> newRandom = () -> random.nextInt(10);
for (int index = 0; index < 5; index++) {
System.out.println(newRandom.get() + " ");
}이 supplier의 get() 메서드를 호출하면 random.nextInt()가 호출되어 임의의 정수를 생성합니다. 이 랜덤 제너레이터의 시드는 314L 값으로 고정되어 있으므로 다음과 같은 랜덤 정수가 생성됩니다:
1
5
3
0
2이 람다는 둘러싸는 범위에서 변수를 캡처하고 있다는 점에 유의하세요: random으로 이 변수를 effectively final 로 만듭니다.
supplier<T> 사용
이전 예제에서 newRandom 공급자를 사용하여 난수를 생성한 방법을 참고하세요:
for (int index = 0; index < 5; index++) {
System.out.println(newRandom.get() + " ");
}Supplier 인터페이스의 get() 메서드를 호출하면 람다가 호출됩니다.
특화된 Supplier 사용
람다 표현식은 애플리케이션에서 데이터를 처리하는 데 사용됩니다. 따라서 람다 표현식을 얼마나 빨리 실행할 수 있는지는 JDK에서 매우 중요합니다. 실제 애플리케이션에서 상당한 최적화를 의미할 수 있으므로 절약할 수 있는 모든 CPU 사이클을 절약해야 합니다.
이 원칙에 따라 JDK API는 Supplier<T> 인터페이스의 특수하고 최적화된 버전도 제공합니다.
두 번째 예제에서는 Integer 타입을 제공하는데, 여기서 Random.nextInt() 메서드가 int를 반환하는 것을 보셨을 것입니다. 따라서 여러분이 작성한 코드에서는 두 가지 일이 내부에서 일어나고 있습니다:
Random.nextInt()가 반환하는int는 오토박싱 메커니즘에 의해 먼저Integer로 박싱됩니다;- 이
Integer는 자동 언박싱 메커니즘에 의해nextRandom변수에 할당될 때 언박싱됩니다.
오토박싱은 int 값을 Integer 객체에 직접 할당할 수 있는 메커니즘입니다:
int i = 12;
Integer integer = i;내부적으로는 해당 값을 감싸는 객체가 만들어집니다.
자동 언박싱은 그 반대의 작업을 수행합니다. Integer 내에서 값을 래핑 해제하여 int 값에 Integer를 할당할 수 있습니다:
Integer integer = Integer.valueOf(12);
int i = integer;이 박싱/언박싱은 무료로 제공되지 않습니다. 대부분의 경우 이 비용은 데이터베이스나 원격 서비스에서 데이터를 가져오는 것과 같이 애플리케이션이 수행하는 다른 작업에 비해 적은 비용입니다. 그러나 경우에 따라서는 이 비용을 감당할 수 없어 지불을 피해야 할 수도 있습니다.
좋은 소식은 JDK가 IntSupplier 인터페이스를 통해 해결책을 제공한다는 것입니다. 이 인터페이스는 다음과 같습니다:
@FunctionalInterface
public interface IntSupplier {
int getAsInt();
}동일한 코드를 사용하여 이 인터페이스를 구현할 수 있습니다:
Random random = new Random(314L);
IntSupplier newRandom = () -> random.nextInt();애플리케이션 코드의 유일한 수정 사항은 get() 대신 getAsInt()를 호출해야 한다는 점입니다:
for (int i = 0; i < 5; i++) {
int nextRandom = newRandom.getAsInt();
System.out.println("next random = " + nextRandom);
}이 코드를 실행한 결과는 동일하지만 이번에는 박싱/언박싱이 발생하지 않았으며 이 코드가 이전 코드보다 성능이 더 우수합니다.
JDK는 애플리케이션에서 불필요한 박싱/언박싱을 방지하기 위해 이러한 특화 supplier 4가지를 제공합니다: IntSupplier, BooleanSupplier, LongSupplier 및 DoubleSupplier.
원시 타입을 처리하기 위한 이러한 특수 버전의 함수형 인터페이스가 더 많이 보일 것입니다. 추상 메서드에 대한 간단한 명명 규칙이 있습니다: 기본 추상 메서드의 이름([
get()](공급자의 경우 https://docs.oracle.com/en/java/javase/22/docs/api/java.base/java/util/function/Supplier.html#get())을 취하고 여기에 반환되는 유형을 추가합니다. 따라서 공급자 인터페이스의 경우 다음과 같습니다:getAsBoolean(),getAsInt(),getAsLong(),getAsDouble().
Consumer<T>로 객체 소비하기
Consumers 구현 및 사용
두 번째 인터페이스는 Consumer<T> 인터페이스입니다. consumer는 supplier와 반대로 인수를 받고 아무 것도 반환하지 않습니다.
이 인터페이스는 조금 더 복잡합니다. 이 튜토리얼의 뒷부분에서 다룰 기본 메서드가 있습니다. 여기서는 추상적인 메서드에 집중해 보겠습니다:
@FunctionalInterface
public interface Consumer<T> {
void accept(T t);
// default methods removed
}이미 consumers를 구현하셨습니다:
Consumer<String> printer = s -> System.out.println(s);이 consumers로 이전 예제를 업데이트할 수 있습니다:
for (int i = 0; i < 5; i++) {
int nextRandom = newRandom.getAsInt();
printer.accept("next random = " + nextRandom);
}특화된 Consumers 사용
정수를 인쇄해야 한다고 가정해 봅시다. 그러면 다음과 같은 consumers를 작성할 수 있습니다:
Consumer<Integer> printer = i -> System.out.println(i);`그러면 supplier 예시와 동일한 자동 박싱 문제가 발생할 수 있습니다. 이 박싱/언박싱이 애플리케이션에서 성능 측면에서 적합한가요?
그렇지 않더라도 걱정하지 마세요. JDK는 세 가지 특화된 consumers로 여러분을 지원하고 있습니다: IntConsumer, LongConsumer, DoubleConsumer. 이 세 소비자의 추상 메서드는 공급자와 동일한 규칙을 따르며, 반환되는 타입은 항상 void이므로 모두 accept로 명명됩니다.
BiConsumer로 두 요소 사용하기
JDK는 하나가 아닌 두 개의 인수를 취하는 Consumer<T> 인터페이스의 또 다른 변형을 추가하는데, 이 인터페이스는 아주 자연스럽게 BiConsumer<T, U>라고 불립니다. 이 인터페이스는 다음과 같습니다:
@FunctionalInterface
public interface BiConsumer<T, U> {
void accept(T t, U u);
// default methods removed
}다음은 biconsumer의 예입니다:
BiConsumer<Random, Integer> randomNumberPrinter =
(random, number) -> {
for (int i = 0; i < number; i++) {
System.out.println("next random = " + random.nextInt());
}
};이 biconsumer를 사용하여 이전 예제를 다르게 작성할 수 있습니다:
randomNumberPrinter.accept(new Random(314L), 5));원시 유형을 처리하기 위한 BiConsumer<T, U> 인터페이스의 세 가지 특수 버전이 있습니다: ObjIntConsumer<T>, ObjLongConsumer<T> 및 ObjDoubleConsumer<T>.
Consumer를 Iterable로 전달하기
컬렉션 프레임워크의 인터페이스에 몇 가지 중요한 메서드가 추가되었으며, 이 튜토리얼의 다른 부분에서 다룹니다. 그 중 하나는 Consumer<T>를 인수로 사용하는 매우 유용한 메서드인 Iterable.forEach() 메서드입니다. 다음은 어디에서나 볼 수 있는 간단한 예제입니다:
List<String> strings = ...; // really any list of any kind of objects
Consumer<String> printer = s -> System.out.println(s);
strings.forEach(printer);이 마지막 코드 줄은 목록의 모든 객체에 consumer를 적용합니다. 여기서는 단순히 콘솔에 하나씩 인쇄합니다. 뒷부분에서 이 consumer를 작성하는 또 다른 방법을 살펴보겠습니다.
이 forEach() 메서드는 Iterable의 모든 요소에 대한 내부 반복에 액세스하여 각 요소에 필요한 작업을 전달하는 방법을 노출합니다. 이는 매우 강력한 방법이며 코드의 가독성도 높여줍니다.
Predicate<T> 로 객체 테스트하기
Predicates 구현 및 사용하기
세 번째 인터페이스는 Predicate<T> 인터페이스입니다. predicate는 객체를 테스트하는 데 사용됩니다. 이 인터페이스는 나중에 살펴볼 주제인 스트림 API에서 스트림을 필터링하는 데 사용됩니다.
이 인터페이스의 추상 메서드는 객체를 받아 부울 값을 반환합니다. 이 인터페이스는 Consumer<T>보다 조금 더 복잡합니다. 기본 메서드와 정적 메서드가 정의되어 있는데, 나중에 살펴볼 것입니다. 추상 메서드에 집중해 보겠습니다:
@FunctionalInterface
public interface Predicate<T> {
boolean test(T t);
// default and static methods removed
}이전 파트에서 Predicate<String>의 예시를 이미 보셨을 것입니다:
Predicate<String> length3 = s -> s.length() == 3;주어진 문자열을 테스트하려면 Predicate 인터페이스의 test() 메서드를 호출하기만 하면 됩니다:
String word = ...; // any word
boolean isOfLength3 = length3.test(word);
System.out.prinln("Is of length 3? " + isOfLength3);특화된 Predicates 사용
정수 값을 테스트해야 한다고 가정해 봅시다. 다음과 같은 predicate를 작성할 수 있습니다:
Predicate<Integer> isGreaterThan10 = i -> i > 10;consumers, supplier, 이 predicate도 마찬가지입니다. 이 술어가 인수로 취하는 것은 Integer 클래스의 인스턴스에 대한 참조이므로 이 값을 10과 비교하기 전에 이 객체가 자동으로 언박싱됩니다. 매우 편리하지만 오버헤드가 발생합니다.
JDK가 제공하는 솔루션은 suppliers, consumers와 동일합니다: 특화된 predicates. Predicate<String>와 함께 세 가지 특수 인터페이스가 있습니다: IntPredicate, LongPredicate, DoublePredicate가 있습니다. 이 추상 메서드들은 모두 명명 규칙을 따릅니다. 모두 부울을 반환하므로 test()로 명명하고 인터페이스에 해당하는 인수를 받습니다.
따라서 이전 예제를 다음과 같이 작성할 수 있습니다:
IntPredicate isGreaterThan10 = i -> i > 10;람다의 구문 자체는 동일하며, i가 이제 Integer 대신 int 유형이라는 점만 다를 뿐임을 알 수 있습니다.
BiPredicate를 사용하여 두 요소 테스트하기
Consumer<T>에서 보았던 관례에 따라 JDK는 하나가 아닌 두 개의 요소를 테스트하는 BiPredicate<T, U> 인터페이스도 추가합니다. 인터페이스는 다음과 같습니다:
@FunctionalInterface
public interface BiPredicate<T, U> {
boolean test(T t, U u);
// default methods removed
}다음은 이러한 bipredicate의 예입니다:
Predicate<String, Integer> isOfLength = (word, length) -> word.length() == length;이 bipredicate를 다음 패턴과 함께 사용할 수 있습니다.:
String word = ...; // really any word will do!
int length = 3;
boolean isWordOfLength3 = isOfLength.test(word, length);There is no specialized version of BiPredicate<T, U> to handle primitive types.
컬렉션에 Predicate 전달하기
컬렉션 프레임워크에 추가된 메서드 중 하나인 removeIf() 메서드는 같은 predicate를 사용합니다. 이 메서드는 이 predicate를 사용하여 컬렉션의 각 요소를 테스트합니다. 테스트 결과가 true이면 이 요소는 컬렉션에서 제거됩니다.
다음 예제에서 이 패턴이 실제로 작동하는 모습을 확인할 수 있습니다:
List<String> immutableStrings =
List.of("one", "two", "three", "four", "five");
List<String> strings = new ArrayList<>(immutableStrings);
Predicate<String> isEvenLength = s -> s.length() % 2 == 0;
strings.removeIf(isEvenLength);
System.out.println("strings = " + strings);이 코드를 실행하면 다음과 같은 결과가 생성됩니다:
strings = [one, two, three]이 예제에서 몇 가지 주목할 만한 사항이 있습니다:
- 보시다시피,
removeIf()를 호출하면 이 컬렉션이 변경됩니다. - 따라서
List.of()팩토리 메서드에서 생성되는 것과 같은 불변 컬렉션에서removeIf()를 호출하면 안 됩니다. 이렇게 하면 불변 컬렉션에서 요소를 제거할 수 없기 때문에 예외가 발생합니다. Arrays.asList()는 배열처럼 동작하는 컬렉션을 생성합니다. 기존 요소를 변경할 수는 있지만 이 팩토리 메서드가 반환하는 목록에서 요소를 추가하거나 제거할 수는 없습니다. 따라서 이 목록에서removeIf()를 호출해도 작동하지 않습니다.
Function<T, R>으로 객체를 다른 객체에 매핑하기
Functions 구현 및 사용하기
네 번째 인터페이스는 Function<T, R> 인터페이스입니다. 함수의 추상 메서드는 T 타입의 객체를 받아 해당 객체를 다른 타입의 R로 변환하여 반환합니다. 이 인터페이스에는 기본 메서드와 정적 메서드도 있습니다.
@FunctionalInterface
public interface Function<T, R> {
R apply(T t);
// default and static methods removed
}Functions는 스트림 API에서 객체를 다른 객체에 매핑하는 데 사용되며, 이 주제는 나중에 다룰 예정입니다. predicate는 boolean을 반환하는 특수한 유형의 함수로 볼 수 있습니다.
특화된 Functions 사용
다음은 문자열을 받아 해당 문자열의 길이를 반환하는 함수의 예입니다.
Function<String, Integer> toLength = s -> s.length();
String word = ...; // any kind of word will do
int length = toLength.apply(word);여기서도 박싱과 언박싱 작업이 작동하는 것을 확인할 수 있습니다. 먼저, length() 메서드는 int를 반환합니다. 이 함수는 Integer를 반환하므로 이 int는 박스 처리됩니다. 그러나 결과는 int 타입의 변수 length에 할당되므로 Integer의 박스가 해제되어 이 변수에 저장됩니다.
애플리케이션에서 성능이 문제가 되지 않는다면 이 박싱과 언박싱은 큰 문제가 되지 않습니다. 만약 그렇다면 피하고 싶을 것입니다.
JDK에는 Function<T, R> 인터페이스의 특수 버전이 포함된 솔루션이 있습니다. 이 인터페이스 세트는 입력 인자의 유형과 반환되는 유형 모두에 대해 특수 함수가 정의되어 있기 때문에 Supplier, Consumer<T> 또는 Predicate 범주보다 더 복잡합니다.
입력 인자와 출력은 모두 네 가지 유형을 가질 수 있습니다:
- 매개변수화된 타입
T; int;long;double.
API 디자인에 미묘한 차이가 있기 때문에 여기서 멈추지 않습니다. 특별한 인터페이스가 있습니다: Function<T, R>를 확장하는 UnaryOperator<T>라는 특별한 인터페이스가 있습니다. 이 단항 연산자 개념은 주어진 타입의 인수를 받아 같은 타입의 결과를 반환하는 함수의 이름을 지정하는 데 사용됩니다. 단항 연산자는 여러분이 예상하는 것과 같습니다. 제곱근, 모든 삼각함수 연산자, 로그, 지수 등 모든 고전 수학 연산자는 UnaryOperator<T>로 모델링할 수 있습니다.
다음은 java.util.function 패키지에서 찾을 수 있는 16가지 특수 함수 유형입니다.
이러한 인터페이스의 모든 추상 메서드는 동일한 규칙을 따르며, 해당 함수의 반환된 타입의 이름을 따서 명명됩니다. 다음은 그 이름입니다:
- 일반 타입
T를 반환하는 함수의 경우apply() - 원시 타입
int를 반환하는 경우applyAsInt() long의 경우applyAsLong()double의 경우applyAsDouble()
단항 연산자를 목록에 전달하기
UnaryOperator<T>로 목록의 요소를 변환할 수 있습니다. 왜 기본 Function이 아닌 UnaryOperator<T>를 사용하는지 궁금할 수 있습니다. 사실 대답은 아주 간단합니다. 일단 선언되면 목록의 유형을 변경할 수 없습니다. 따라서 적용하는 함수는 목록의 요소는 변경할 수 있지만 유형은 변경할 수 없습니다.
이 단항 연산자를 취하는 메서드는 replaceAll() 메서드로 전달합니다. 다음은 예제입니다:
List<String> strings = Arrays.asList("one", "two", "three");
UnaryOperator<String> toUpperCase = word -> word.toUpperCase();
strings.replaceAll(toUpperCase);
System.out.println(strings);이 코드를 실행하면 다음이 표시됩니다:
[ONE, TWO, THREE]이번에는 Arrays.asList() 패턴으로 만든 목록을 사용했다는 점에 유의하세요. 실제로 이 코드는 이 목록에 요소를 추가하거나 제거할 필요 없이 각 요소를 하나씩 수정하기만 하면 되므로 이 특정 목록에서 가능합니다.
BiFunction으로 두 요소 매핑하기
consumer 및 predicate와 관련하여 함수에는 두 개의 인수를 받는 버전인 바이함수도 있습니다. 인터페이스는 BiFunction<T, U, R>이며, 여기서 T와 U는 인자이고 R은 반환되는 타입입니다. 다음은 인터페이스입니다:
@FunctionalInterface
public interface BiFunction<T, U, R> {
R apply(T t, U u);
// default methods removed
}람다 표현식을 사용하여 이항 함수를 만들 수 있습니다:
BiFunction<String, String, Integer> findWordInSentence =
(word, sentence) -> sentence.indexOf(word);UnaryOperator<T> 인터페이스에는 두 개의 인자를 가진 형제 인터페이스도 있습니다: BinaryOperator<T>는 BiFunction<T, U, R>을 확장한 것입니다. 예상대로 네 가지 기본 산술 연산은 BinaryOperator로 모델링할 수 있습니다.
가능한 모든 특수 버전의 bifunction 하위 집합이 JDK에 추가되었습니다:
IntBinaryOperator,LongBinaryOperatorandDoubleBinaryOperator;ToIntBiFunction<T>,ToLongBiFunction<T>andToDoubleBiFunction<T>.
Functional 인터페이스의 네 가지 범주 마무리하기
컬렉션 프레임워크 또는 스트림 API에서 사용할 모든 람다 표현식은 이 패키지의 인터페이스 중 하나를 구현하기 때문에 java.util.function 패키지는 이제 Java의 핵심입니다.
보시다시피 이 패키지에는 많은 인터페이스가 포함되어 있으며, 이를 찾는 방법은 까다로울 수 있습니다.
첫째: 기억해야 할 것은 인터페이스에는 4가지 범주가 있다는 것입니다:
- suppliers: 인수를 받지 않고, 무언가를 반환합니다.
- consumers: 인수를 받고, 아무것도 반환하지 않습니다.
- predicates: 인수를 받고, 부울을 반환합니다.
- functions : 인수를 받고, 무언가를 반환합니다.
둘째: 일부 인터페이스에는 인수가 하나가 아닌 두 개를 받는 버전이 있습니
- the biconsumers
- the bipredicates
- the bifunctions
셋째: 일부 인터페이스에는 박싱 및 언박싱을 방지하기 위해 추가된 특수 버전이 있습니다. 모두 나열하기에는 너무 많습니다. 인터페이스의 이름은 해당 유형의 이름을 따서 지었습니다. 예를 들어 IntPredicate 또는 ToLongFunction<T>에서와 같이 반환하는 타입의 이름을 따서 명명합니다. 둘의 이름을 따서 명명할 수도 있습니다: IntToDoubleFunction.
마지막: 모든 타입이 동일한 경우를 위한 Function<T, R>과 BiFunction<T, U, R>의 확장 버전인 UnaryOperator<T>와 BinaryOperator<T>가 있으며, 기본 타입에 특화된 버전이 추가되었습니다.