You need to uppercase every SKU in place, or fold line-item prices into one BigDecimal. A Function that turns Order into String is too open — the work never changes type. Tuesday afternoon, the slot already knows: same type in, same type out.
An operator is a Function (or BiFunction) with the types locked to T. The series hub owns the glossary; here we only care about UnaryOperator, BinaryOperator, identity / minBy / maxBy, and the JDK slots that already demand this tightness.
The in-place map that was already an operator
Normalizing SKUs with a throwaway mapper type is ceremony. List.replaceAll already named the slot:
List<String> skus = new ArrayList<>(
order.items().stream().map(LineItem::sku).toList());
skus.replaceAll(String::toUpperCase);
replaceAll takes a UnaryOperator<E>. The list stays a list of String. Streams map can do the same job into a new list; replaceAll mutates this one.
Folding prices is the two-argument cousin:
BinaryOperator<BigDecimal> sum = BigDecimal::add;
BigDecimal total = order.items().stream()
.map(item -> item.unitPrice().multiply(BigDecimal.valueOf(item.quantity())))
.reduce(BigDecimal.ZERO, sum);
Two BigDecimals in, one BigDecimal out. That is BinaryOperator, not a pair type you invent.
The contract
@FunctionalInterface
public interface UnaryOperator<T> extends Function<T, T> {
static <T> UnaryOperator<T> identity() { … }
}
@FunctionalInterface
public interface BinaryOperator<T> extends BiFunction<T, T, T> {
static <T> BinaryOperator<T> minBy(Comparator<? super T> comparator) { … }
static <T> BinaryOperator<T> maxBy(Comparator<? super T> comparator) { … }
}
UnaryOperator is a Function<T,T>. It inherits apply, andThen, and compose. BinaryOperator is a BiFunction<T,T,T> — apply(t, u) and andThen come from there. The extras are static factories that keep the return type an operator, not a raw Function.
| Method | Type | Job |
|---|---|---|
apply(t) | Unary | Map T to T |
identity() | Unary static | Return the argument unchanged, as a UnaryOperator |
apply(t, u) | Binary | Combine two Ts into one T |
minBy(cmp) / maxBy(cmp) | Binary static | Pick the lesser / greater argument by a Comparator |
UnaryOperator.identity() is the one replaceAll will accept. Function.identity() returns a Function, and a Function is not a UnaryOperator — inheritance only goes one way.
Against the checkout lab:
UnaryOperator<String> skuUpper = String::toUpperCase;
List<LineItem> copy = new ArrayList<>(order.items());
copy.replaceAll(item -> new LineItem(
skuUpper.apply(item.sku()),
item.quantity(),
item.unitPrice()));
BinaryOperator<Order> largerTotal =
BinaryOperator.maxBy(Comparator.comparing(Order::total));
Order pick = largerTotal.apply(guest, member);
DiscountPolicy stays a named seam — Order in, BigDecimal out — so not an operator. PaymentGateway.charge is a side effect, not a combine.
Where the JDK already asks for an operator
You do not invent a SkuNormalizer for these slots. You fill them.
| API | Slot | Typical lambda |
|---|---|---|
List.replaceAll | UnaryOperator<E> | String::toUpperCase |
Stream.reduce(op) | BinaryOperator<T> | BigDecimal::add |
Stream.reduce(identity, op) | BinaryOperator<T> | BigDecimal::add |
Collectors.reducing | BinaryOperator<T> (and a mapper, in the three-arg form) | BigDecimal::add |
Stream.reduce of a whole Order | BinaryOperator<Order> via minBy / maxBy | BinaryOperator.maxBy(comparing(Order::total)) |
reduce and reducing are the everyday Streams combine. Empty stream, no identity: you get Optional. With identity, you get the identity itself:
Optional<BigDecimal> maybe = prices.stream().reduce(BigDecimal::add);
BigDecimal total = prices.stream().reduce(BigDecimal.ZERO, BigDecimal::add);
Optional<Order> largest = orders.stream()
.reduce(BinaryOperator.maxBy(Comparator.comparing(Order::total)));
The three-arg Collectors.reducing maps first, then combines — mapper is still a Function; the op is the operator:
BigDecimal merchandise = order.items().stream()
.collect(Collectors.reducing(
BigDecimal.ZERO,
item -> item.unitPrice().multiply(BigDecimal.valueOf(item.quantity())),
BigDecimal::add));
A UnaryOperator also fills any Function<T,T> slot (Stream.map of SKUs). A Function<T,T> does not fill a UnaryOperator slot. Prefer the operator when the API declared one.
Operator vs Function
Use the tighter type when the types really are the same.
| Reach for | When |
|---|---|
Function<T,R> | Input and output differ — Order::id, Order::customerEmail |
UnaryOperator<T> | T in, T out — replaceAll, normalize, identity in that slot |
BiFunction<T,U,R> | Two arguments, types may differ — next post |
BinaryOperator<T> | Two Ts in, one T out — reduce, minBy / maxBy, BigDecimal::add |
andThen on a UnaryOperator returns a Function, not a UnaryOperator. Chain two operators you already named by writing the body (or a lambda) when the slot still wants an operator:
UnaryOperator<String> trim = String::strip;
UnaryOperator<String> upper = String::toUpperCase;
Function<String, String> both = trim.andThen(upper); // widened
skus.replaceAll(sku -> upper.apply(trim.apply(sku)));
The operator is the default when the JDK slot already declared it. Function remains the default when R is a different type, including a domain seam like DiscountPolicy.
Pitfalls
Function.identity() in replaceAll. It does not compile. The slot wants UnaryOperator. Use UnaryOperator.identity(), or x -> x.
Immutable lists. replaceAll mutates. List.of throws UnsupportedOperationException. Copy into an ArrayList first, including when the list came from order.items().
Non-associative reduce. BigDecimal::add is fine. (a, b) -> a.subtract(b) is not a parallel-safe combiner. Sequential accident, parallel bug. Streams already teach this; the type does not save you.
Nulls in minBy / maxBy. The comparator sees both arguments. A null Order or a null total is an NPE, not an empty Optional.
Mutating the argument. item -> { item.setSku(item.sku().toUpperCase()); return item; } is an in-place rewrite wearing an operator. With records you return a new LineItem. That is the honest writing.
Checked exceptions. apply does not declare them. Same boundary rule as Function — wrap or preprocess; do not empty-catch inside the operator.
Cheat sheet
Unary Function<T,T> apply(t) identity() → UnaryOperator
Binary BiFunction<T,T,T> apply(t, u) minBy / maxBy(Comparator)
JDK slots List.replaceAll UnaryOperator
Stream.reduce BinaryOperator
Collectors.reducing BinaryOperator (+ mapper)
Do String::toUpperCase in replaceAll
BigDecimal::add / maxBy(comparing(Order::total)) in reduce
Don't Function.identity() in replaceAll
Function<T,T> variable passed into a UnaryOperator slot
subtract as a parallel combiner
Do:
- Fill
replaceAll/reduce/reducingwith an operator (or a method reference that matches it). - Use
UnaryOperator.identity()when the slot requires an operator and you have nothing to change. - Use
minBy/maxBywhen “pick one of these twoTs” is the combine.
Don’t:
- Pass a
Function<T,T>intoreplaceAlland expect it to adapt. - Call
replaceAllon an immutable list. - Pretend
DiscountPolicyis an operator —Orderin,BigDecimalout is a Function.
Wrap-up
UnaryOperator<T> is Function<T,T>. BinaryOperator<T> is BiFunction<T,T,T>. Reach for them when the types really are the same: replaceAll, reduce, Collectors.reducing, minBy / maxBy. Use Function when R is a different type. Two arguments that are not the same T — or a value plus a key — are the next family.