It is Tuesday afternoon and you have just invented Pair<Order, String> so a one-arg Function can see both the order and the coupon. Or a tiny record whose only job is to unwrap into a Predicate test. The helper needed two arguments. Function, Predicate, and Consumer took one.
BiFunction, BiPredicate, and BiConsumer are those three shapes with a second argument. No pair type required.
Two arguments. No Pair type. The series hub owns the glossary; here we only care about apply / test / accept, the composition defaults, and the Map and Stream slots that already pass two values.
The pair you used to invent
A pre-lambda helper that needed an order and a rate usually grew a holder:
record OrderAndRate(Order order, BigDecimal rate) {}
Function<OrderAndRate, BigDecimal> payable = pair ->
pair.order().total().multiply(pair.rate());
BigDecimal due = payable.apply(new OrderAndRate(order, new BigDecimal("0.90")));
The holder exists so a one-arg Function has something to unwrap. The same intent with a BiFunction is two parameters and a JDK slot:
BiFunction<Order, BigDecimal, BigDecimal> payable =
(order, rate) -> order.total().multiply(rate);
BigDecimal due = payable.apply(order, new BigDecimal("0.90"));
Same result. The type is BiFunction<Order, BigDecimal, BigDecimal> whether you name it or let Map.merge / compute supply it.
Three contracts, same lab
The lab is still Order / LineItem / DiscountPolicy / PaymentGateway. DiscountPolicy stays a one-arg named seam. Two-arg work against an Order and a Map<String, Order> uses the package types.
@FunctionalInterface
public interface BiFunction<T, U, R> {
R apply(T t, U u);
default <V> BiFunction<T, U, V> andThen(Function<? super R, ? extends V> after) { … }
}
@FunctionalInterface
public interface BiPredicate<T, U> {
boolean test(T t, U u);
default BiPredicate<T, U> and(BiPredicate<? super T, ? super U> other) { … }
default BiPredicate<T, U> or(BiPredicate<? super T, ? super U> other) { … }
default BiPredicate<T, U> negate() { … }
}
@FunctionalInterface
public interface BiConsumer<T, U> {
void accept(T t, U u);
default BiConsumer<T, U> andThen(BiConsumer<? super T, ? super U> after) { … }
}
The methods you implement are apply, test, and accept. The rest are defaults. There is no BiSupplier and no compose on any of these three.
| Type | Method | Job |
|---|---|---|
BiFunction<T,U,R> | apply(t, u) | Two values in, a result out |
BiPredicate<T,U> | test(t, u) | Two values in, yes or no |
BiConsumer<T,U> | accept(t, u) | Two values in, a side effect |
Against the map of orders:
BiFunction<Order, BigDecimal, BigDecimal> payable =
(order, rate) -> order.total().multiply(rate);
BiPredicate<Order, BigDecimal> atLeast = (order, min) ->
order.total().compareTo(min) >= 0;
BiConsumer<String, Order> trail = (id, order) ->
audit.record(id, "PAID");
boolean billable = atLeast.test(order, new BigDecimal("10"));
byId.forEach(trail);
PaymentGateway.charge that needs an order and an amount is a BiConsumer adapter (gateway::charge) onto the named collaborator. It is not a replacement for that seam — same rule the Consumer post already made for one argument.
BinaryOperator is BiFunction<T,T,T>: same type twice in, same type out. BigDecimal::add in a merge slot is that restriction. This post does not retell operators.
Where the JDK already asks for two arguments
You do not invent a pair type for these slots. You fill them.
| API | Slot | Typical lambda |
|---|---|---|
Map.merge | BiFunction<? super V, ? super V, ? extends V> | BigDecimal::add |
Map.compute | BiFunction<? super K, ? super V, ? extends V> | (id, current) -> … |
Map.computeIfPresent | BiFunction<? super K, ? super V, ? extends V> | (id, order) -> refresh(order) |
Map.computeIfAbsent | Function<? super K, ? extends V> | this::loadFromStore — not Bi* |
Map.replaceAll | BiFunction<? super K, ? super V, ? extends V> | (id, order) -> deactivate(order) |
Map.forEach | BiConsumer<? super K, ? super V> | (id, order) -> log.info("{}", id) |
Stream.reduce (3-arg) | BiFunction accumulator, BinaryOperator combiner | (sum, order) -> sum.add(order.total()) |
computeIfAbsent is in the table because people reach for a BiFunction and the slot is a Function. List.replaceAll is a UnaryOperator; Map.replaceAll is the two-arg cousin.
Map<String, BigDecimal> totals = new HashMap<>();
for (Order order : orders) {
totals.merge(order.customerEmail(), order.total(), BigDecimal::add);
}
byId.computeIfAbsent(orderId, this::loadFromStore);
byId.replaceAll((id, order) ->
order.active() ? order : archived(order));
byId.forEach((id, order) -> log.info("{} {}", id, order.total()));
BigDecimal teamTotal = orders.stream()
.reduce(BigDecimal.ZERO,
(sum, order) -> sum.add(order.total()),
BigDecimal::add);
The three-arg reduce accumulator sees a partial result and an element (BiFunction<U, T, U>). The combiner merges two partials and is a BinaryOperator<U>. Sequential streams may never call the combiner; parallel ones will.
Composition on each type
BiFunction.andThen takes a unary Function, not another BiFunction. The two-arg work runs first; then after sees the result.
BiFunction<Order, BigDecimal, BigDecimal> payable =
(order, rate) -> order.total().multiply(rate);
Function<BigDecimal, String> asMoney = BigDecimal::toPlainString;
BiFunction<Order, BigDecimal, String> label = payable.andThen(asMoney);
String due = label.apply(order, new BigDecimal("0.90"));
A method reference has no andThen until you assign it. Assign, or cast — same rule as Consumer.andThen and Predicate.negate.
BiPredicate composes like Predicate: and / or short-circuit; negate flips. There is no BiPredicate.not and no isEqual.
BiPredicate<Order, String> sameCustomer =
(order, email) -> email.equals(order.customerEmail());
BiPredicate<Order, String> ready =
sameCustomer.and((order, email) -> order.active());
BiPredicate<Order, String> skip = ready.negate();
BiConsumer.andThen runs two effects on the same (t, u). There is no output to thread. If the first throws, the second does not run.
BiConsumer<String, Order> notify = (id, order) -> notifier.notifyPaid(order);
BiConsumer<String, Order> trail = (id, order) -> audit.record(id, "PAID");
byId.forEach(notify.andThen(trail));
Pitfalls
Map.merge and null. The value you pass in must be non-null or you get NullPointerException before the remapping function runs. If the key is absent (or mapped to null), merge puts that value and does not call the BiFunction. If the key is present, the function sees (oldValue, incoming). A null remapper return deletes the entry. That is a delete, not a “store null.”
compute vs computeIfAbsent. compute always calls the BiFunction with the key and the current value — null when the key is missing. A null return removes the mapping. computeIfAbsent takes a Function<K,V> and runs only when the key is absent or mapped to null; a null return means “do not insert.” Do not write (id, current) -> current == null ? load(id) : current on compute when computeIfAbsent is the slot.
Checked exceptions. apply / test / accept do not declare them. Wrap at the boundary. Empty catch inside a merge function is how production maps swallow failures — same wall as the one-arg family. The policy is Exceptions.
Named seam vs package type. A two-arg discount other people will implement still wants a verb (payable(Order, String coupon) on a type you own). BiFunction<Order, String, BigDecimal> compiles. Use it when merge / compute / forEach already declared the slot.
Cheat sheet
SAM BiFunction R apply(T t, U u)
BiPredicate boolean test(T t, U u)
BiConsumer void accept(T t, U u)
Defaults BiFunction.andThen(Function) two-arg first, then unary
BiPredicate and / or / negate short-circuit; no not()
BiConsumer.andThen both see the same (t, u)
JDK slots Map.merge / compute / computeIfPresent / replaceAll
Map.forEach Stream.reduce accumulator
computeIfAbsent Function, not BiFunction
BinaryOperator BiFunction<T,T,T> (operators post)
Do fill merge / forEach with Bi* name a seam when the verb is yours
Don't Pair just to call Function merge remapper that returns null by accident
compute when computeIfAbsent empty-catch inside apply / test / accept
Do:
- Fill
merge/compute/replaceAll/Map.forEachwith the Bi* type the slot already named. - Compose with
andThen/and/oron a typed variable, not on a bare method reference. - Use BinaryOperator when both arguments and the result are the same type.
Don’t:
- Invent
OrderCouponPairso a one-arg Function can unwrap two values. - Treat a null from
merge/computeas “store empty”; it deletes the entry. - Reach for
computewhen the work is “load if missing” — that iscomputeIfAbsent.
Wrap-up
BiFunction, BiPredicate, and BiConsumer are the two-argument forms of Function, Predicate, and Consumer. You meet them first on Map: merge, compute, replaceAll, forEach. A pair type was never the API. The next grid skips boxing on hot numeric paths.