Introduction
In the last episode, we mastered Supplier, the source that generates values from nothing.
Now, let's turn to UnaryOperator, a specialization of Function built for one very specific job: transforming a value into another value of the exact same type.
If Predicate decides, Function transforms, Consumer acts, and Supplier provides, then UnaryOperator refines. It takes something, and gives back more of the same kind, changed.
What Is UnaryOperator?
Here's the definition:
@FunctionalInterface
public interface UnaryOperator<T> extends Function<T, T> {
static <T> UnaryOperator<T> identity() {
return t -> t;
}
}
Input: an object of type T.
Output: another object, also of type T.
Purpose: express a transformation that never changes the type, only the value.
Why Use UnaryOperator?
You could always write this with a plain Function<T, T>:
Function<String, String> trim = String::trim;
That works, but it doesn't say anything extra about the shape of the transformation, a reader still has to check both generic parameters to confirm they match. UnaryOperator states the contract directly:
UnaryOperator<String> trim = String::trim;
This one-type guarantee matters most where the JDK itself expects it, like List.replaceAll.
Practical Examples
1. In-Place List Transformation
List<String> names = new ArrayList<>(List.of("filipe", "andré", "borba"));
UnaryOperator<String> capitalize = s -> s.substring(0, 1).toUpperCase() + s.substring(1);
names.replaceAll(capitalize);
// [Filipe, André, Borba]
2. Simple Same-Type Math
UnaryOperator<Integer> increment = n -> n + 1;
System.out.println(increment.apply(41)); // 42
3. Composition (Inherited from Function)
UnaryOperator<String> trim = String::trim;
UnaryOperator<String> upper = String::toUpperCase;
Function<String, String> pipeline = trim.andThen(upper);
System.out.println(pipeline.apply(" borba ")); // "BORBA"
4. Iterative Generation with Stream.iterate
UnaryOperator<Integer> doubleIt = n -> n * 2;
List<Integer> powersOfTwo = Stream.iterate(1, doubleIt)
.limit(5)
.toList();
System.out.println(powersOfTwo); // [1, 2, 4, 8, 16]
Real-World Patterns
-
In-Place Normalization: Trimming, upper/lower-casing, or sanitizing collections via
replaceAll. -
State Transitions: Modeling a value moving to the next state of the same type, e.g. an
Orderwith an updated status. -
Iterative Algorithms: Feeding
Stream.iterateor recursive-style computations that keep producing the same type. - Configuration Overrides: Applying a same-type patch or decorator to a config or builder object.
Best Practices
- Prefer Immutability: Return a new instance instead of mutating the input, especially for domain objects.
-
Reserve It for Same-Type Transforms: If input and output types differ, use
Function<T, R>instead, don't force the fit. -
Name for the Operation:
increment,trim,capitalizecommunicate intent better thanop.
Common Pitfalls
- Silent Mutation: Since T equals T, it's tempting to mutate the argument directly instead of returning a new value, breaking purity.
-
Forcing the Fit: Wrapping a
Function<T, R>asUnaryOperator<T>by ignoring a type mismatch defeats its purpose. -
Overusing in Long Compositions: A long chain of
andThencalls can become as unreadable as the imperative code it replaced.
Functional Analogy
Think of UnaryOperator as a polishing machine:
- Raw material T goes in.
- The machine reshapes it.
- What comes out is still T, just refined.
Conclusion
UnaryOperator is Function's disciplined sibling; it exists to make same-type transformations explicit and safe. Wherever a value needs to become a better version of itself, in place, in a pipeline, or across iterations, UnaryOperator is the right tool.
What's Next
In the next episode, we'll meet BinaryOperator, a step further: instead of transforming one value, it combines two values of the same type into one. Get ready to reduce.
Top comments (0)