BiFunction과 기본형 함수형 인터페이스
인수가 둘인 BiFunction·BiConsumer·BiPredicate와, 박싱 없이 int·long·double을 다루는 IntPredicate·ToIntFunction 같은 기본형 특화 인터페이스를 언제 어떻게 쓰는지 예제로 익힙니다.
앞 글에서 본 표준 인터페이스는 모두 값을 하나만 받았습니다. 하지만 가격과 할인율로 판매가를 구하거나, 상품과 최종 가격을 함께 기록하는 일처럼 입력이 둘인 함수도 흔합니다. 또 앞 글의 판독값 예제는 UnaryOperator<Integer>와 Predicate<Integer>를 쓰는 바람에 정수가 int와 Integer 사이를 계속 오갔습니다. 이 글에서는 이 두 가지를 차례로 다룹니다. 먼저 인수가 둘인 Bi 인터페이스와 인수가 셋 이상일 때의 선택지를 보고, 이어서 박싱 없이 int, long, double을 다루는 기본형 특화 인터페이스를 살펴봅니다.
인수가 둘인 함수: Bi 인터페이스
이름 앞의 Bi는 인수가 둘이라는 뜻입니다. 앞 글의 기본 모양 가운데 입력이 있는 Function, Consumer, Predicate에 각각 Bi 버전이 있고, 같은 타입의 두 값을 합치는 BinaryOperator도 이 무리에 속합니다. 입력이 없는 Supplier에는 Bi 버전이 없습니다.
| 인터페이스 | 추상 메서드 | 받는 값 → 돌려주는 값 |
|---|---|---|
BiFunction<T, U, R> | R apply(T t, U u) | T, U → R |
BiConsumer<T, U> | void accept(T t, U u) | T, U → 없음 |
BiPredicate<T, U> | boolean test(T t, U u) | T, U → boolean |
BinaryOperator<T> | T apply(T t1, T t2) | T, T → T |
키와 값을 함께 다루는 Map의 메서드들이 Bi 인터페이스를 많이 받습니다. 다음 예제는 재고표에서 수량이 0인 상품을 10개로 채우고, 5개 미만인 상품에는 표시를 붙여 출력합니다.
import java.util.Map;
import java.util.TreeMap;
import java.util.function.BiConsumer;
import java.util.function.BiFunction;
import java.util.function.BiPredicate;
public final class BiInterfaces {
public static void main(String[] args) {
Map<String, Integer> stock = new TreeMap<>(Map.of("pen", 3, "note", 0, "cup", 12));
BiFunction<String, Integer, Integer> restock = (name, count) -> count == 0 ? 10 : count;
BiPredicate<String, Integer> low = (name, count) -> count < 5;
BiConsumer<String, Integer> print = (name, count) ->
System.out.println(name + "=" + count + (low.test(name, count) ? " (부족)" : ""));
stock.replaceAll(restock);
stock.forEach(print);
}
}print는 항목을 찍을 때마다 low로 수량을 검사해 부족한 상품에만 표시를 붙입니다. replaceAll이 먼저 실행되므로 출력되는 수량은 채운 뒤의 값입니다.
cup=12
note=10
pen=3 (부족)Map.replaceAll은 키와 기존 값을 받아 새 값을 돌려주는 BiFunction을 받으므로, 수량이 0이던 note만 10으로 바뀝니다. Map.forEach는 키와 값을 받는 BiConsumer를 항목마다 한 번씩 호출하고, 그 안에서 BiPredicate인 low가 수량이 3인 pen에만 (부족)을 붙입니다. TreeMap은 키를 정렬해 보관하므로 출력은 cup, note, pen 순서입니다.
BiFunction<Integer, Integer, Integer>와 BinaryOperator<Integer>는 받는 값과 돌려주는 값이 똑같지만 전하는 뜻이 다릅니다. BinaryOperator는 합계, 최댓값, 두 목록 합치기처럼 '같은 종류의 두 값을 같은 종류의 값 하나로 합친다'는 뜻이고, 스트림의 reduce 같은 API가 이 모양을 요구합니다. 반면 가격과 할인율을 받아 판매가를 구하는 함수는 두 int의 역할이 서로 다릅니다. 이런 함수를 BinaryOperator로 선언해도 컴파일은 되지만, 읽는 사람은 두 값을 합치는 연산으로 오해하고 reduce에 넘겨도 되는 함수라고 생각할 수 있습니다. 그러니 매개변수가 둘이고 타입이 같다는 이유만으로 BinaryOperator를 고르지는 않습니다.
인수가 셋 이상이라면
java.util.function에는 인수가 셋 이상인 인터페이스가 없습니다. 이때 선택지는 두 가지입니다. 필요한 함수형 인터페이스를 직접 정의하거나, 여러 값을 레코드 하나로 묶어 인수를 하나로 만드는 것입니다.
직접 정의하는 쪽부터 보겠습니다. 다음 TriFunction은 배송 거리(km), 무게(kg), 특급 여부를 받아 배송비를 계산합니다. Function의 andThen을 본떠, 결과를 다음 함수에 넘기는 default 메서드도 붙였습니다.
import java.util.Objects;
import java.util.function.Function;
public final class TriFunctionComposition {
@FunctionalInterface
interface TriFunction<A, B, C, R> {
R apply(A first, B second, C third);
default <V> TriFunction<A, B, C, V> andThen(Function<? super R, ? extends V> next) {
Objects.requireNonNull(next);
return (first, second, third) -> next.apply(apply(first, second, third));
}
}
record Quote(int distanceKm, int weightKg, boolean express, int fee) {
}
public static void main(String[] args) {
TriFunction<Integer, Integer, Boolean, Integer> fee = (distance, weight, express) -> {
int base = 3000 + distance * 20 + weight * 500;
return express ? base * 2 : base;
};
TriFunction<Integer, Integer, Boolean, Quote> quote = (distance, weight, express) ->
new Quote(distance, weight, express, fee.apply(distance, weight, express));
TriFunction<Integer, Integer, Boolean, String> summary = quote.andThen(value ->
value.distanceKm() + "km/" + value.weightKg() + "kg=" + value.fee());
System.out.println(fee.apply(10, 2, false));
System.out.println(quote.apply(12, 3, true));
System.out.println(summary.apply(5, 1, false));
}
}세 줄은 차례로 fee, quote, summary에 서로 다른 배송 조건을 넣은 결과입니다. quote의 결과는 레코드라서 구성 요소 이름과 함께 찍힙니다.
4200
Quote[distanceKm=12, weightKg=3, express=true, fee=9480]
5km/1kg=3600첫 줄은 기본 요금 3000에 거리 10 × 20과 무게 2 × 500을 더한 4200입니다. 둘째 줄의 quote는 같은 방식으로 3000 + 12 × 20 + 3 × 500 = 4740을 구하고, 특급이므로 두 배인 9480을 입력 세 개와 함께 Quote 레코드에 담습니다. 셋째 줄의 summary는 quote.andThen(...)으로 만든 함수입니다. 이때 andThen에 넘긴 함수는 배송 입력 세 개를 다시 받지 않고, quote가 완성한 Quote 하나만 받아 문자열로 바꿉니다. 그래서 5km, 1kg의 요금 3600이 담긴 5km/1kg=3600이 나옵니다.
이 방식은 잘 동작하지만, 타입이 같은 인수가 나란히 있으면 순서를 바꿔 넘기는 실수를 컴파일러가 잡지 못합니다. fee.apply(2, 10, false)처럼 거리와 무게를 바꿔 넘겨도 오류 없이 4200이 아닌 8040이 나옵니다. 인수가 늘 때마다 TriConsumer, QuadFunction을 만들어 가면 이런 실수가 끼어들 자리도 함께 늘어납니다.
그래서 여러 값이 하나의 요청이나 문맥을 이룬다면 레코드로 묶는 편이 낫습니다. 아래는 같은 계산을 ShippingRequest 레코드 하나를 받는 Function으로 바꾼 것입니다.
import java.util.function.Function;
public final class ShippingRequestVersion {
record ShippingRequest(int distanceKm, int weightKg, boolean express) {
}
public static void main(String[] args) {
Function<ShippingRequest, Integer> fee = request -> {
int base = 3000 + request.distanceKm() * 20 + request.weightKg() * 500;
return request.express() ? base * 2 : base;
};
Function<ShippingRequest, String> summary = request ->
request.distanceKm() + "km/" + request.weightKg() + "kg=" + fee.apply(request);
System.out.println(fee.apply(new ShippingRequest(10, 2, false)));
System.out.println(summary.apply(new ShippingRequest(5, 1, false)));
}
}입력값이 같으므로 앞 예제의 첫 줄, 셋째 줄과 같은 결과가 나와야 합니다. 달라진 것은 함수가 값을 받는 방식뿐입니다.
4200
5km/1kg=3600이제 함수는 request.distanceKm()처럼 이름으로 값을 꺼내므로, 함수 안에서 순서를 헷갈릴 일이 없습니다. 레코드의 생성자도 인수를 위치로 받으니 new ShippingRequest(2, 10, false)처럼 잘못 만들 수는 있습니다. 하지만 그런 실수가 생길 수 있는 곳이 레코드를 만드는 한 곳으로 줄어들고, 값이 여러 함수 사이를 옮겨 다닐수록 이 차이가 커집니다. 앞 예제의 quote가 입력 세 개와 계산한 요금을 Quote 레코드 하나로 묶은 것도 같은 생각입니다. 반대로 세 값이 서로 독립된 입력이고 한두 곳에서만 쓰인다면 TriFunction을 정의해도 괜찮습니다.
더 깊이: andThen 안의 apply는 누구의 apply인가
TriFunction의 andThen은 람다 (first, second, third) -> next.apply(apply(first, second, third))를 돌려줍니다. 이 람다 안의 apply(...)는 지금 만들어지는 람다 자신의 메서드가 아니라, andThen을 호출받은 원래 TriFunction의 apply입니다. 람다 본문에서 이름과 this의 뜻은 람다를 둘러싼 코드에서와 똑같기 때문입니다. default 메서드 andThen 안에서 이름만 쓴 apply는 this.apply이고, 여기서 this는 andThen을 호출받은 객체입니다.
같은 코드를 익명 클래스로 옮기면 뜻이 바뀝니다. 익명 클래스는 새 클래스여서, 그 안에서 이름만 쓴 apply를 컴파일러가 먼저 익명 클래스 자신의 메서드에서 찾기 때문입니다.
import java.util.function.Function;
public final class AnonymousAndThen {
@FunctionalInterface
interface TriFunction<A, B, C, R> {
R apply(A first, B second, C third);
default <V> TriFunction<A, B, C, V> andThen(Function<? super R, ? extends V> next) {
return new TriFunction<>() {
@Override
public V apply(A first, B second, C third) {
return next.apply(apply(first, second, third));
}
};
}
}
public static void main(String[] args) {
TriFunction<Integer, Integer, Integer, Integer> sum = (a, b, c) -> a + b + c;
System.out.println(sum.andThen(total -> "total=" + total).apply(1, 2, 3));
}
}AnonymousAndThen.java:12: error: incompatible types: V cannot be converted to CAP#1
return next.apply(apply(first, second, third));
^
where V,R,A,B,C are type-variables:
V extends Object declared in method <V>andThen(Function<? super R,? extends V>)
R extends Object declared in interface TriFunction
A extends Object declared in interface TriFunction
B extends Object declared in interface TriFunction
C extends Object declared in interface TriFunction
where CAP#1 is a fresh type-variable:
CAP#1 extends Object super: R from capture of ? super R
Note: Some messages have been simplified; recompile with -Xdiags:verbose to get full output
1 error안쪽 apply(first, second, third)가 익명 클래스 자신의 apply를 가리키므로 그 결과 타입은 R이 아니라 V입니다. 그런데 next는 ? super R을 받으니 타입이 맞지 않아 컴파일 오류가 납니다. 메시지의 CAP#1은 컴파일러가 ? super R 자리에 붙인 임시 타입 이름입니다. 여기서는 V와 R이 서로 다른 타입 변수라서 컴파일러가 실수를 잡아 주었습니다. 하지만 입력과 결과 타입이 모두 같은 TriOperator<T> 같은 인터페이스였다면 그대로 컴파일되고, 실행하면 apply가 자기 자신을 끝없이 호출하다 StackOverflowError가 납니다. 익명 클래스에서 바깥 객체의 메서드를 부르려면 TriFunction.this.apply(first, second, third)처럼 바깥 타입 이름을 붙여야 합니다.
제네릭 함수에 int를 넘기면 생기는 일
제네릭 인터페이스의 타입 인수에는 참조형만 쓸 수 있으므로, 정수를 다루는 함수는 Function<Integer, Integer>처럼 래퍼 클래스로 선언해야 합니다. 그러면 int 값이 함수에 들어갈 때 Integer 객체로 박싱되고, 계산할 때 다시 언박싱됩니다. 다음 코드로 확인해 보겠습니다.
import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;
public final class BoxedNumericPipeline {
static List<Integer> apply(List<Integer> values, Function<Integer, Integer> operator) {
List<Integer> result = new ArrayList<>();
for (Integer value : values) {
result.add(operator.apply(value));
}
return result;
}
public static void main(String[] args) {
System.out.println(apply(List.of(1, 2, 3), value -> value * 2));
}
}출력은 각 원소를 두 배로 만든 목록입니다. 결과만 봐서는 그 사이에 값이 객체로 바뀌었다가 다시 돌아오는 과정이 드러나지 않습니다.
[2, 4, 6]이 짧은 코드에서도 변환이 여러 번 일어납니다. List.of(1, 2, 3)의 세 정수는 목록에 들어가면서 Integer로 박싱됩니다. 람다 value -> value * 2는 호출될 때마다 value를 int로 언박싱해 곱하고, 그 결과를 반환 타입 Integer에 맞추려고 다시 박싱합니다. 오토박싱은 Integer.valueOf를 호출하는데, -128부터 127까지는 미리 만들어 둔 객체를 돌려주지만 그 밖의 값은 새 객체를 만들 수 있습니다(래퍼 클래스와 박싱·언박싱 참고). 원소가 수백만 개라면 그만큼 객체가 생겼다가 버려질 수 있습니다. JIT 컴파일러가 일부 할당을 없애 주기도 하지만, 언제나 그렇다고 기대할 수는 없습니다.
같은 일을 기본형 특화 인터페이스로 쓰면 다음과 같습니다. IntUnaryOperator는 int를 받아 int를 돌려주는 함수이고, 목록 대신 int 배열을 썼습니다.
import java.util.Arrays;
import java.util.function.IntUnaryOperator;
public final class PrimitiveNumericPipeline {
static int[] apply(int[] values, IntUnaryOperator operator) {
int[] result = new int[values.length];
for (int i = 0; i < values.length; i++) {
result[i] = operator.applyAsInt(values[i]);
}
return result;
}
public static void main(String[] args) {
System.out.println(Arrays.toString(apply(new int[] { 1, 2, 3 }, value -> value * 2)));
}
}Arrays.toString은 int 배열을 앞의 목록과 같은 모양의 문자열로 바꿔 줍니다. 그래서 두 프로그램의 출력은 글자까지 같습니다.
[2, 4, 6]출력은 같지만 이번에는 박싱이 한 번도 일어나지 않습니다. 배열의 원소도, applyAsInt의 매개변수와 반환값도 모두 int이기 때문입니다.
박싱은 성능만의 문제가 아닙니다. Integer는 객체이므로 null일 수 있고, 제네릭 함수는 null을 그대로 받아들였다가 언박싱하는 순간 실패합니다.
import java.util.function.Function;
public final class BoxedNull {
public static void main(String[] args) {
Function<Integer, Integer> doubler = value -> value * 2;
System.out.println(doubler.apply(21));
System.out.println(doubler.apply(null));
}
}42
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "java.lang.Integer.intValue()" because "<parameter1>" is null
at BoxedNull.lambda$main$0(BoxedNull.java:5)
at BoxedNull.main(BoxedNull.java:7)value * 2를 계산하려면 value.intValue()로 언박싱해야 하는데, value가 null이라 NullPointerException이 났습니다. 메시지에 매개변수 이름 대신 <parameter1>이 보이는 것은 기본 옵션으로 컴파일하면 지역 변수 이름이 클래스 파일에 남지 않기 때문이고, javac -g로 컴파일하면 "value"로 나옵니다. 기본형 특화 인터페이스를 쓰면 이 문제가 컴파일 시점으로 옮겨집니다.
import java.util.function.IntUnaryOperator;
public final class PrimitiveNull {
public static void main(String[] args) {
IntUnaryOperator doubler = value -> value * 2;
System.out.println(doubler.applyAsInt(null));
}
}PrimitiveNull.java:6: error: incompatible types: <null> cannot be converted to int
System.out.println(doubler.applyAsInt(null));
^
Note: Some messages have been simplified; recompile with -Xdiags:verbose to get full output
1 errorint 매개변수에는 null을 넘길 방법이 없으므로, 값이 없을 수 있다는 상황 자체가 생기지 않습니다.
그렇다고 모든 함수를 기본형 특화로 바꿀 필요는 없습니다. 일반적인 업무 코드에서는 박싱 비용보다 코드를 읽기 쉽게 유지하는 쪽이 더 중요한 경우가 많습니다. 대량의 숫자를 반복해서 계산하는 코드처럼 비용이 실제로 쌓이는 곳에서, 프로파일러나 JMH 같은 벤치마크 도구로 병목을 확인한 뒤에 바꾸는 것이 순서입니다.
기본형 특화 인터페이스의 이름 읽기
기본형 특화 인터페이스는 int, long, double 세 타입에 대해서만 있습니다. byte, short, char, float용은 없으므로 int나 double로 넓혀서 다루고, boolean에는 값을 공급하는 BooleanSupplier 하나만 있습니다. 개수는 많아 보이지만 이름을 짓는 규칙이 일정해서, 규칙만 알면 이름에서 모양을 읽어 낼 수 있습니다. 아래 표는 int용 인터페이스를 정리한 것이고, Int를 Long이나 Double로 바꾼 인터페이스도 같은 방식으로 있습니다.
| 인터페이스 | 추상 메서드 | 받는 값 → 돌려주는 값 |
|---|---|---|
IntFunction<R> | R apply(int value) | int → R |
ToIntFunction<T> | int applyAsInt(T value) | T → int |
IntUnaryOperator | int applyAsInt(int operand) | int → int |
IntBinaryOperator | int applyAsInt(int left, int right) | int, int → int |
IntPredicate | boolean test(int value) | int → boolean |
IntConsumer | void accept(int value) | int → 없음 |
IntSupplier | int getAsInt() | 없음 → int |
ObjIntConsumer<T> | void accept(T t, int value) | T, int → 없음 |
ToIntBiFunction<T, U> | int applyAsInt(T t, U u) | T, U → int |
IntToLongFunction | long applyAsLong(int value) | int → long |
IntToDoubleFunction | double applyAsDouble(int value) | int → double |
규칙은 네 가지입니다. 첫째, 이름 앞에 붙은 기본형은 받는 값의 타입입니다. IntPredicate는 int를 받는 Predicate이고, IntFunction<R>은 int를 받아 R을 돌려줍니다. 둘째, ToInt는 돌려주는 값이 int라는 뜻입니다. 그래서 ToIntFunction<T>는 T를 받아 int를 돌려줍니다. 셋째, 두 규칙을 합치면 IntToLongFunction처럼 int를 받아 long을 돌려주는 이름이 됩니다. 넷째, Obj는 그 자리는 기본형으로 바꾸지 않고 객체로 둔다는 표시입니다. ObjIntConsumer<T>는 첫 인수가 T, 둘째 인수가 int인 BiConsumer라고 읽으면 되고, 인수마다 접두어가 붙어 있으니 이름에 Bi가 없어도 인수는 둘입니다. 이 규칙은 JDK 문서의 java.util.function 패키지 설명에 그대로 나와 있습니다.
메서드 이름도 함께 바뀝니다. 기본형을 돌려주는 메서드는 applyAsInt, applyAsLong, getAsInt, getAsBoolean처럼 As 뒤에 결과 타입이 붙습니다. 반면 결과 타입이 처음부터 정해진 test와 accept는 이름이 그대로이고, 객체를 돌려주는 IntFunction도 그대로 apply입니다. 그래서 ToIntFunction 변수에 apply를 호출하면 그런 메서드가 없다는 컴파일 오류가 납니다.
박싱을 줄인 가격 계산
이제 Bi 인터페이스와 기본형 특화 인터페이스를 함께 써 보겠습니다. 다음 프로그램은 상품마다 10% 할인한 판매가에 배송비 5를 더하고, 최종 가격이 예산 100 이하인 상품만 기록합니다. BiFunction인 discounted가 가격과 할인율로 판매가를 구하고, ToIntFunction인 priceOf가 레코드에서 int 가격을 꺼냅니다. IntPredicate와 IntUnaryOperator는 int만 다루고, BiConsumer인 logSale은 상품과 최종 가격을 받아 바깥의 audit 목록에 문자열을 추가합니다.
import java.util.ArrayList;
import java.util.List;
import java.util.function.BiConsumer;
import java.util.function.BiFunction;
import java.util.function.IntPredicate;
import java.util.function.IntUnaryOperator;
import java.util.function.ToIntFunction;
public final class BiAndPrimitiveInterfaces {
record Item(String name, int price) {
}
public static void main(String[] args) {
BiFunction<Integer, Integer, Integer> discounted = (price, percent) ->
price - price * percent / 100;
ToIntFunction<Item> priceOf = Item::price;
IntPredicate withinBudget = price -> price <= 100;
IntUnaryOperator addShipping = price -> price + 5;
List<String> audit = new ArrayList<>();
BiConsumer<Item, Integer> logSale = (item, finalPrice) ->
audit.add(item.name() + "=" + finalPrice);
for (Item item : List.of(new Item("Book", 80), new Item("Desk", 220))) {
int sale = discounted.apply(priceOf.applyAsInt(item), 10);
int finalPrice = addShipping.applyAsInt(sale);
if (withinBudget.test(finalPrice)) {
logSale.accept(item, finalPrice);
}
}
System.out.println(audit);
}
}할인 금액은 정수 나눗셈으로 계산하므로 소수점 아래가 버려집니다. 출력에는 예산 안에 들어와 audit에 기록된 상품만 나옵니다.
[Book=77]두 상품이 반복문을 지나는 과정은 다음과 같습니다.
| 상품 | 가격 | 할인 뒤 | 배송비 포함 | 예산 100 이하인가 | 기록 |
|---|---|---|---|---|---|
| Book | 80 | 72 | 77 | 예 | Book=77 |
| Desk | 220 | 198 | 203 | 아니요 | 없음 |
할인 계산 price - price * percent / 100은 정수 나눗셈이라 소수점 아래를 버립니다. 가격 80과 220은 10%가 8과 22로 딱 떨어지지만, 가격이 99라면 990 / 100이 9.9가 아니라 9가 되어 할인 뒤 90, 배송비를 더해 95가 됩니다. 실제 금액 계산에서는 이런 반올림 규칙과 오버플로를 따로 정해야 하므로, 원이나 센트 같은 최소 화폐 단위의 long이나 BigDecimal을 쓰는 값 객체를 검토합니다. 기본형 특화는 계산을 빠르게 해 줄 뿐, 금액 계산이 정확하다는 것까지 보장해 주지는 않습니다.
이 프로그램에도 박싱이 아직 두 군데 남아 있습니다. discounted는 BiFunction<Integer, Integer, Integer>라서 int 가격과 할인율 10이 들어갈 때 박싱되고, 결과를 int sale에 담을 때 언박싱됩니다. BiConsumer<Item, Integer>인 logSale에도 finalPrice가 박싱되어 들어갑니다. 이 두 선언을 기본형 특화 인터페이스로 바꾸면 박싱이 모두 사라집니다.
IntBinaryOperator discounted = (price, percent) -> price - price * percent / 100;
ObjIntConsumer<Item> logSale = (item, finalPrice) ->
audit.add(item.name() + "=" + finalPrice);IntBinaryOperator의 메서드는 applyAsInt이므로 반복문 안의 호출도 discounted.applyAsInt(priceOf.applyAsInt(item), 10)으로 바꿔야 합니다. ObjIntConsumer의 메서드는 그대로 accept라서 logSale.accept(item, finalPrice)는 고칠 필요가 없고, 실행 결과도 똑같이 [Book=77]입니다.
다만 int 두 개를 받아 int를 돌려주는 기본형 인터페이스는 IntBinaryOperator 하나뿐이어서, 앞에서 말한 '역할이 다른 두 값' 문제를 피할 수 없습니다. 이럴 때는 discounted, price, percent 같은 이름으로 역할을 드러내고, 이 함수를 reduce처럼 값을 합치는 곳에 넘기지 않도록 주의합니다. 또 BiConsumer의 두 입력처럼 상품과 최종 가격이 늘 함께 다닌다면, PricedItem 같은 레코드로 묶어 Consumer<PricedItem>을 쓰는 편이 나중에 값을 추가하기 쉽습니다.
기본형 특화로 나타낼 수 없는 것: 값이 없음
ToIntFunction의 applyAsInt는 반드시 int를 돌려줘야 하므로 '결과가 없음'을 나타낼 수 없습니다. 그렇다고 Function<T, Integer>로 바꾸고 null을 '없음'의 뜻으로 쓰면, 앞에서 본 것처럼 누군가 그 값을 int로 받는 순간 NullPointerException이 납니다. -1 같은 특별한 값을 정해 쓰는 방법도 있지만, 호출하는 쪽이 그 약속을 잊으면 -1이 정상 값처럼 계산에 섞여 들어갑니다. 값이 없는 것이 정상적인 결과라면 Function<T, OptionalInt>처럼 '값이 있을 수도 없을 수도 있다'는 사실을 타입으로 드러내는 편이 안전합니다. OptionalInt는 int 하나를 담거나 비어 있는 객체이고, Optional과 함께 33장에서 자세히 다룹니다.
기본형 특화 인터페이스와 IntStream
32장에서 배울 기본형 스트림 IntStream은 이 글의 인터페이스를 매개변수로 받습니다. filter는 IntPredicate를, map은 IntUnaryOperator를, 객체로 바꾸는 mapToObj는 IntFunction을 받습니다. 객체 스트림에서 map(Item::price)를 쓰면 Stream<Integer>가 되지만, ToIntFunction을 받는 mapToInt(Item::price)를 쓰면 박싱 없는 IntStream이 됩니다. 계산을 모두 마친 뒤 정말로 Integer 컬렉션이 필요할 때만 boxed()로 다시 박싱합니다. 자세한 내용은 최종 연산과 기본형 스트림에서 다룹니다.
정리
- 인수가 둘인 함수에는
BiFunction,BiConsumer,BiPredicate를 씁니다.BinaryOperator는 같은 종류의 두 값을 하나로 합치는 연산에만 씁니다. - 인수가 셋 이상인 표준 인터페이스는 없습니다. 직접
TriFunction을 정의할 수 있지만, 값들이 함께 다닌다면 레코드로 묶는 편이 순서 실수를 줄입니다. - 제네릭 함수형 인터페이스로 정수를 다루면 박싱과 언박싱이 일어나고
null이 끼어들 수 있습니다.int,long,double용 기본형 특화 인터페이스는 둘 다 막아 주지만, 비용이 실제로 문제 되는 곳에서 측정한 뒤 바꿉니다. - 이름 앞의 기본형은 받는 값,
ToXxx는 돌려주는 값,Obj는 객체로 두는 자리입니다. 기본형을 돌려주는 메서드는applyAsInt,getAsInt처럼 이름이 바뀝니다. - 기본형 반환은 '값 없음'을 나타낼 수 없으므로, 값이 없는 것이 정상이라면
OptionalInt같은 타입을 돌려줍니다.
연습 문제
문제 1: 알맞은 인터페이스 고르기
다음 함수에 맞는 표준 함수형 인터페이스를 고르세요. 박싱이 일어나지 않는 쪽을 고릅니다.
- 상품(
Product)과 수량(int)을 받아 장바구니에 담는 동작 - 문자열을 받아 그 길이를 돌려주는 함수
- 주문 번호(
long)를 받아 주문(Order) 객체를 찾아 주는 함수 - 두 문자열의 길이가 같은지 판단하는 조건
- 정수 두 개 가운데 큰 값을 돌려주는 함수
해설 보기
ObjIntConsumer<Product>입니다. 돌려주는 값이 없으니Consumer이고, 첫 인수는 객체 그대로(Obj), 둘째 인수는int입니다.ToIntFunction<String>입니다. 결과가int이므로ToInt가 붙고,String::length를 그대로 넘길 수 있습니다.LongFunction<Order>입니다. 받는 값이long이고 돌려주는 값은 객체이므로Long접두어만 붙습니다.BiPredicate<String, String>입니다. 두 입력이 모두 객체이고 결과는 이미 기본형boolean이므로 박싱이 일어나지 않습니다.IntBinaryOperator입니다. 같은 종류의 두int를 하나로 합치는 연산이어서 이름의 뜻과도 맞고,Math::max를 그대로 넘길 수 있습니다.
문제 2: 기본형으로 센서 통계 내기
센서 값이 담긴 int 배열에 보정 함수(IntUnaryOperator)와 범위 조건(IntPredicate)을 적용한 뒤, 조건을 통과한 값의 개수, 합계, 최솟값, 최댓값을 구하세요. 중간에 Integer 목록을 만들지 말고, 합계는 오버플로 가능성을 줄이기 위해 long으로 누적합니다. 통과한 값이 하나도 없는 경우도 결과로 나타낼 수 있어야 합니다. 보정은 2를 더하는 것, 범위는 0 이상 100 이하로 하고 { -5, 10, 20, 150, 30 }과 { -9, 500 }으로 확인하세요.
해설 보기
import java.util.Optional;
import java.util.function.IntPredicate;
import java.util.function.IntUnaryOperator;
public final class PrimitiveStatisticsSolution {
record Statistics(int count, long sum, int minimum, int maximum) {
}
static Optional<Statistics> summarize(
int[] rawValues,
IntUnaryOperator calibration,
IntPredicate accepted) {
int count = 0;
long sum = 0;
int minimum = Integer.MAX_VALUE;
int maximum = Integer.MIN_VALUE;
for (int raw : rawValues) {
int value = calibration.applyAsInt(raw);
if (!accepted.test(value)) {
continue;
}
count += 1;
sum += value;
minimum = Math.min(minimum, value);
maximum = Math.max(maximum, value);
}
return count == 0
? Optional.empty()
: Optional.of(new Statistics(count, sum, minimum, maximum));
}
public static void main(String[] args) {
int[] raw = { -5, 10, 20, 150, 30 };
IntUnaryOperator calibrate = value -> value + 2;
IntPredicate accepted = value -> value >= 0 && value <= 100;
System.out.println(summarize(raw, calibrate, accepted));
System.out.println(summarize(new int[] { -9, 500 }, calibrate, accepted));
}
}실행 결과:
Optional[Statistics[count=3, sum=66, minimum=12, maximum=32]]
Optional.empty보정 후 통과한 값으로 만드는 통계
다섯 원시 값에 +2를 적용하고 0..100 통과한 값 12·22·32를 누산해 count=3/sum=66을 만듭니다. 변환·선택·통계 상태를 실제 값으로 추적할 수 있습니다.
보정 후 통과한 값으로 만드는 통계
보정 · 선택 · 통계
보정 후 통과한 값으로 만드는 통계
각 원시 값에 2를 더한 뒤 0..100을 통과한 값만 집계합니다. 통과한 값이 없으면 Optional.empty입니다.
[-5, 10, 20, 150, 30]
- 보정 후 선택값
- [12, 22, 32]
- 반환
- count=3, sum=66, minimum=12, maximum=32
[-9, 500]
- 보정 후 선택값
- 없음
- 반환
- Optional.empty
통계가 없으면 최소·최대의 초기 센티널도 반환하지 않습니다. long 누적과 별개로, 보정 value+2는 int 연산이므로 임의 입력의 보정 오버플로를 막지는 않습니다.
12 + 22 + 32 = 66이며 최소 12, 최대 32입니다. 합계는 long이지만 보정 자체는 int 연산입니다.
이 예의 조건
- for의 교육용 중간값이며 새 배치 API 실행이 아닙니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 원 다섯 값에 각각 2를 더한 뒤 0..100 범위의 12·22·32를 모아 count 3·sum 66·min 12·max 32를 얻는다. 통과한 값이 없으면 count 0인 별도 분기에서 Optional.empty를 반환한다. 화살표는 교육용 중간값 이동과 최종 반환 분기이며 원 for 루프를 새 배치 API로 구현했다는 의미가 아니다. long 합계가 앞의 int 보정 오버플로를 막지는 않는다. 모바일도 같은 6개 노드와 5개 관계, 경계 조건을 유지한다. |
| 9 | 통과한 값이 있는가? |
| 구분 | 내용 |
|---|---|
| 설명 | 원 다섯 값에 각각 2를 더한 뒤 0..100 범위의 12·22·32를 모아 count 3·sum 66·min 12·max 32를 얻는다. 통과한 값이 없으면 count 0인 별도 분기에서 Optional.empty를 반환한다. 화살표는 교육용 중간값 이동과 최종 반환 분기이며 원 for 루프를 새 배치 API로 구현했다는 의미가 아니다. long 합계가 앞의 int 보정 오버플로를 막지는 않는다. 모바일도 같은 6개 노드와 5개 관계, 경계 조건을 유지한다. |
| 전체 읽기 정보 | 보정 후 통과한 값으로 만드는 통계 보정 후 통과한 값으로 만드는 통계 보정 후 통과한 값으로 만드는 통계 보정 · 선택 · 통계 보정 후 통과한 값으로 만드는 통계 각 원시 값에 2를 더한 뒤 0..100을 통과한 값만 집계합니다. 통과한 값이 없으면 Optional.empty입니다. [-5, 10, 20, 150, 30] 보정 후 선택값 [12, 22, 32] 반환 count=3, sum=66, minimum=12, maximum=32 [-9, 500] 보정 후 선택값 없음 반환 Optional.empty 통계가 없으면 최소·최대의 초기 센티널도 반환하지 않습니다. long 누적과 별개로, 보정 value+2는 int 연산이므로 임의 입력의 보정 오버플로를 막지는 않습니다. 12 + 22 + 32 = 66이며 최소 12, 최대 32입니다. 합계는 long이지만 보정 자체는 int 연산입니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 보정 -5,10,20,150,30 각각에 2를 더합니다. |
|
|
| 범위 선택 0..100에 해당하는 값만 통과합니다. |
|
|
| 통계 반환 통과 값으로 Statistics를 만듭니다. |
|
|
보정 후 통과한 값으로 만드는 통계
다섯 원시 값에 +2를 적용하고 0..100 통과한 값 12·22·32를 누산해 count=3/sum=66을 만듭니다. 변환·선택·통계 상태를 실제 값으로 추적할 수 있습니다.
보정 후 통과한 값으로 만드는 통계
보정 · 선택 · 통계
보정 후 통과한 값으로 만드는 통계
각 원시 값에 2를 더한 뒤 0..100을 통과한 값만 집계합니다. 통과한 값이 없으면 Optional.empty입니다.
[-5, 10, 20, 150, 30]
- 보정 후 선택값
- [12, 22, 32]
- 반환
- count=3, sum=66, minimum=12, maximum=32
[-9, 500]
- 보정 후 선택값
- 없음
- 반환
- Optional.empty
통계가 없으면 최소·최대의 초기 센티널도 반환하지 않습니다. long 누적과 별개로, 보정 value+2는 int 연산이므로 임의 입력의 보정 오버플로를 막지는 않습니다.
12 + 22 + 32 = 66이며 최소 12, 최대 32입니다. 합계는 long이지만 보정 자체는 int 연산입니다.
이 예의 조건
- for의 교육용 중간값이며 새 배치 API 실행이 아닙니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 원 다섯 값에 각각 2를 더한 뒤 0..100 범위의 12·22·32를 모아 count 3·sum 66·min 12·max 32를 얻는다. 통과한 값이 없으면 count 0인 별도 분기에서 Optional.empty를 반환한다. 화살표는 교육용 중간값 이동과 최종 반환 분기이며 원 for 루프를 새 배치 API로 구현했다는 의미가 아니다. long 합계가 앞의 int 보정 오버플로를 막지는 않는다. 모바일도 같은 6개 노드와 5개 관계, 경계 조건을 유지한다. |
| 9 | 통과한 값이 있는가? |
| 구분 | 내용 |
|---|---|
| 설명 | 원 다섯 값에 각각 2를 더한 뒤 0..100 범위의 12·22·32를 모아 count 3·sum 66·min 12·max 32를 얻는다. 통과한 값이 없으면 count 0인 별도 분기에서 Optional.empty를 반환한다. 화살표는 교육용 중간값 이동과 최종 반환 분기이며 원 for 루프를 새 배치 API로 구현했다는 의미가 아니다. long 합계가 앞의 int 보정 오버플로를 막지는 않는다. 모바일도 같은 6개 노드와 5개 관계, 경계 조건을 유지한다. |
| 전체 읽기 정보 | 보정 후 통과한 값으로 만드는 통계 보정 후 통과한 값으로 만드는 통계 보정 후 통과한 값으로 만드는 통계 보정 · 선택 · 통계 보정 후 통과한 값으로 만드는 통계 각 원시 값에 2를 더한 뒤 0..100을 통과한 값만 집계합니다. 통과한 값이 없으면 Optional.empty입니다. [-5, 10, 20, 150, 30] 보정 후 선택값 [12, 22, 32] 반환 count=3, sum=66, minimum=12, maximum=32 [-9, 500] 보정 후 선택값 없음 반환 Optional.empty 통계가 없으면 최소·최대의 초기 센티널도 반환하지 않습니다. long 누적과 별개로, 보정 value+2는 int 연산이므로 임의 입력의 보정 오버플로를 막지는 않습니다. 12 + 22 + 32 = 66이며 최소 12, 최대 32입니다. 합계는 long이지만 보정 자체는 int 연산입니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 보정 -5,10,20,150,30 각각에 2를 더합니다. |
|
|
| 범위 선택 0..100에 해당하는 값만 통과합니다. |
|
|
| 통계 반환 통과 값으로 Statistics를 만듭니다. |
|
|
첫 배열의 다섯 값에 2를 더하면 -3, 12, 22, 152, 32가 되고, 이 가운데 범위를 통과하는 값은 12, 22, 32입니다. 그래서 개수 3, 합계 12 + 22 + 32 = 66, 최솟값 12, 최댓값 32가 나옵니다. 둘째 배열은 보정하면 -7과 502가 되어 하나도 통과하지 못하므로 count가 0으로 남고, 메서드는 Optional.empty()를 돌려줍니다. 반환 타입 Optional<Statistics>는 통계가 있을 수도 없을 수도 있다는 사실을 타입으로 드러냅니다. 보정과 범위 검사, 누적이 모두 int와 long으로 이루어지므로 중간에 박싱은 일어나지 않습니다.
이 반복문은 한 스레드에서 값을 차례로 누적합니다. 여러 스레드로 나눠 집계하려면 부분 결과끼리 합치는 방법과 아무 값도 없을 때의 시작값을 함께 설계해야 하는데, JDK의 IntSummaryStatistics가 이런 집계를 이미 제공합니다.
문제 3: 통계 결과 따라가기
문제 2의 해답에 { -2, 98, 99 }를 넣으면 보정값, 통과한 값, 그리고 count, sum, minimum, maximum이 어떻게 되는지 구하세요. 또 { -9, 500 }처럼 통과한 값이 없을 때 minimum과 maximum의 초깃값을 그대로 돌려주지 않고 Optional.empty()를 돌려주는 이유를 설명하세요. 마지막으로, 합계를 long으로 누적하면 보정 value + 2에서 생기는 오버플로도 막을 수 있는지 답하세요.
해설 보기
Optional[Statistics[count=2, sum=100, minimum=0, maximum=100]]보정값은 0, 100, 101입니다. 범위의 양 끝인 0과 100은 조건 value >= 0 && value <= 100을 통과하지만 101은 빠지므로, 개수 2, 합계 100, 최솟값 0, 최댓값 100이 됩니다.
통과한 값이 없으면 최솟값과 최댓값이라는 것이 존재하지 않습니다. minimum의 초깃값 Integer.MAX_VALUE와 maximum의 초깃값 Integer.MIN_VALUE는 첫 비교를 위해 넣어 둔 값일 뿐 통계값이 아닌데, 그대로 돌려주면 호출하는 쪽이 진짜 값으로 오해할 수 있습니다. 실제로 JDK의 IntSummaryStatistics는 값이 하나도 없을 때 이 초깃값을 그대로 보여 줍니다. 32장에서 배울 IntStream으로 둘째 배열을 집계하면 다음과 같이 출력됩니다.
IntStream.of(-9, 500)
.map(calibrate)
.filter(value -> value >= 0 && value <= 100)
.summaryStatistics()IntSummaryStatistics{count=0, sum=0, min=2147483647, average=0.000000, max=-2147483648}그래서 IntSummaryStatistics를 쓸 때는 getCount()가 0인지 먼저 확인해야 합니다. 해답의 Optional.empty()는 이 확인을 잊을 수 없게 타입으로 강제하는 셈입니다.
마지막 질문의 답은 '막지 못한다'입니다. long은 통과한 값을 더하는 합계의 타입일 뿐이고, 그 앞의 보정은 여전히 int 연산입니다. 예를 들어 calibrate.applyAsInt(Integer.MAX_VALUE)는 2147483647 + 2가 int 범위를 넘어 -2147483647이 되고, 이 값은 범위 검사에서 조용히 걸러질 뿐 아무 오류도 나지 않습니다. 보정에서도 오버플로를 잡으려면 Math.addExact(value, 2)처럼 오버플로가 나면 예외를 던지는 메서드를 쓰거나, 보정을 long으로 계산해야 합니다.
다음 글
31장의 제네릭 filter와 map 직접 만들기에서는 이번 장에서 익힌 Predicate와 Function을 매개변수로 받는 컬렉션 유틸리티를 직접 만듭니다. 반복문에서 '고르기'와 '바꾸기'를 함수로 떼어 내 어떤 타입의 목록에도 쓸 수 있게 일반화하면서, 함수형 파이프라인의 기초를 다집니다.