다인자·기본형 인터페이스
다인자 함수와 기본형 특화 인터페이스를 비교하고 박싱 비용을 줄인 통계 파이프라인을 만듭니다.
두 입력이 필요한 함수에는 BiFunction, BiConsumer, BiPredicate, BinaryOperator가 있습니다.
세 입력 이상은 JDK가 일반 표준 인터페이스를 제공하지 않으므로 작은 사용자 정의 TriFunction을 정의하거나 여러 값을 record 하나로 묶습니다.
인자 수만 보고 선택하지 말고 세 값이 하나의 명령이나 문맥을 이루는지 먼저 판단합니다.
제네릭 함수에 int를 전달하면 Integer 박싱이 발생합니다.
대부분의 업무 코드에서는 가독성과 객체 비용의 균형이 제네릭 타입 쪽에 있을 수 있습니다.
그러나 대량 숫자 스트림과 성능 핵심 반복문에서는 IntPredicate, IntUnaryOperator, IntBinaryOperator, ToIntFunction 같은 기본형 특화가 할당과 언박싱을 줄입니다.
추측이 아니라 프로파일러와 벤치마크로 병목을 확인합니다.
박싱 비용
다음 코드는 모든 int를 Integer로 박싱한 목록에 담고 Function 호출마다 언박싱과 박싱이 생길 수 있습니다.
JIT가 일부 할당을 제거할 수도 있지만 제네릭 인수는 null인 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));
}
}기본형 특화 이름은 함수 시그니처를 압축합니다.
IntFunction<R>은 int를 받아 R을 반환하고, ToIntFunction<T>는 T를 받아 int를 반환합니다.
IntUnaryOperator는 int에서 int, IntBinaryOperator는 두 int에서 int입니다.
이름 앞의 To는 결과 기본형, 이름 앞의 Int는 입력 기본형을 뜻하는 경우가 많습니다.
BiFunction<T, U, R>은 두 입력 타입이 다를 수 있습니다.
BinaryOperator<T>는 두 입력과 결과가 모두 T로 같습니다.
축약에서 두 입력과 결과가 같은 타입이면 BinaryOperator가 그 형태를 드러냅니다. 결합 법칙과 필요한 항등원은 사용하는 축약 연산의 계약이며 이 인터페이스 타입 자체가 강제하지는 않습니다.
단순히 매개변수가 둘이라는 이유로 연산자를 선택하지 않습니다.
다인자·기본형 계약
아래 예제는 BiFunction으로 가격과 할인 정책을 결합하고 ToIntFunction으로 record에서 기본형 가격을 추출합니다.
IntPredicate와 IntUnaryOperator는 박싱되지 않은 정수 단계에서 동작합니다.
BiConsumer는 상품과 최종 가격을 받고, 바깥의 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> record = (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)) {
record.accept(item, finalPrice);
}
}
System.out.println(audit);
}
}discounted는 Integer 제네릭 경계를 사용해 결과에서 언박싱합니다.
금액 도메인은 오버플로와 반올림 규칙이 중요하므로 실제 서비스에서는 최소 화폐 단위로 나타낸 long이나 BigDecimal 값 객체를 검토합니다.
기본형 특화는 빠른 계산 도구이지 금액 모델의 정확성을 자동으로 보장하지 않습니다.
BiConsumer의 두 입력이 자주 함께 이동한다면 PricedItem record로 묶어 Consumer<PricedItem>을 쓰는 편이 확장에 유리합니다.
매개변수가 늘어날 때마다 TriConsumer, QuadConsumer를 추가하면 호출 순서 실수가 커집니다.
TriFunction의 적용 범위
JDK에 TriFunction이 없으므로 다음처럼 직접 정의할 수 있습니다.
andThen은 세 입력 함수 결과를 다음 Function에 전달합니다.
세 값이 배송 견적의 독립 입력이라는 계약이 분명할 때는 적합하지만, 값이 계속 늘면 요청 record가 더 읽기 좋습니다.
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));
}
}예제의 quote는 입력 세 개와 계산한 수수료를 한 번에 record로 묶어 관계를 보존합니다.
그다음 andThen은 완성된 Quote만 받아 표시 문자열로 바꿉니다.
그래도 타입이 같거나 비슷한 여러 인자는 위치 교환 실수를 컴파일 단계에서 잡기 어렵습니다.
입력이 여러 함수 사이를 이동한다면 ShippingRequest record 하나를 받는 설계가 더 안전합니다.
기본형 인터페이스 선택
ToIntFunction은 null 결과를 표현하지 못합니다.
값 부재가 정상이라면 OptionalInt나 별도 Result를 사용해야 합니다.
Integer를 쓴 뒤 null을 센티널로 활용하는 것은 언박싱 지점에서 NullPointerException을 만들 수 있습니다.
기본형 반환은 정상 반환 시 값 하나를 준다는 형태를 표현하지만, 센티널 사용이나 예외까지 막지는 않습니다. 부재를 결과로 표현하려면 Function<T, OptionalInt>처럼 다른 반환 계약이 필요합니다.
IntStream은 map, 필터, sum, summaryStatistics를 기본형으로 수행합니다.
객체 스트림에서 map(Item::price)를 쓰면 Stream<Integer>가 되지만 mapToInt(Item::price)는 IntStream입니다.
계산을 마친 뒤 박싱된 컬렉션이 실제로 필요할 때만 boxed()합니다.
연습 문제
원시 센서 int 배열에 보정 함수와 유효 범위 조건식을 적용한 뒤 개수, 합계, 최소, 최대를 계산합니다.
중간 Integer 목록을 만들지 않고 오버플로 가능성을 줄이기 위해 합계는 long으로 누적합니다.
값이 하나도 통과하지 않는 경우도 표현합니다.
해답 보기
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));
}
}보정 후 통과한 값으로 만드는 통계
| 원문 main의 입력 | 보정 후 선택값 | 반환 |
|---|---|---|
| [-5, 10, 20, 150, 30] | [12, 22, 32] | count=3, sum=66, minimum=12, maximum=32 |
| [-9, 500] | 없음 | 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 연산이므로 임의 입력의 보정 오버플로를 막지는 않습니다.
병렬 집계가 필요하다면 결합 가능한 누산기와 동일성을 설계하거나 IntSummaryStatistics 수집기를 사용합니다.