1. The concept of function composition
A bit of theory (but not too dry)
In mathematics, function composition is when the result of one function becomes the input to another. If we have functions f and g, the composition g(f(x)) means: first apply f to x, then feed the result into g.
In programming, the idea is the same: we want to assemble complex transformations from simple ones, instead of writing one giant function for everything. This makes the code flexible, reusable, and readable.
Imagine a pastry assembly line: first the dough (f), then the cream (g), then the sprinkles (h). The whole process is h(g(f(ingredients))).
Why is composition important?
- Composition lets you build a program out of small function “blocks”.
- It’s easier to reuse: a single “block” can be plugged into different places without duplication.
- Flexibility: to change one stage, replace the corresponding function — the rest stays untouched.
- Readability and testability: small functions are easier to read, verify, and maintain.
2. Methods compose and andThen in the Function interface
Function interface: a quick refresher
@FunctionalInterface
public interface Function<T, R> {
R apply(T t);
// Methods for composition:
default <V> Function<V, R> compose(Function<? super V, ? extends T> before)
default <V> Function<T, V> andThen(Function<? super R, ? extends V> after)
}
- compose: first the function you pass to compose runs, and then the current one.
- andThen: first the current function runs, and then the one passed to andThen.
Visual diagram
// Suppose there are two functions:
Function<String, Integer> parse = s -> Integer.parseInt(s);
Function<Integer, Integer> square = x -> x * x;
// compose: square.compose(parse) == x -> square.apply(parse.apply(x))
"5" --parse--> 5 --square--> 25
// andThen: parse.andThen(square) == x -> square.apply(parse.apply(x))
"5" --parse--> 5 --square--> 25
// But order matters if the types differ!
Example: convert a string to a number, then square it
import java.util.function.Function;
public class ComposeAndThenDemo {
public static void main(String[] args) {
// Function: converts a string to a number
Function<String, Integer> parse = s -> Integer.parseInt(s);
// Function: squares a number
Function<Integer, Integer> square = x -> x * x;
// Combine: parse first, then square
Function<String, Integer> parseThenSquare = parse.andThen(square);
System.out.println(parseThenSquare.apply("7")); // 49
// What if we swap them?
// square.compose(parse) — the same result (for these functions)
Function<String, Integer> squareOfParsed = square.compose(parse);
System.out.println(squareOfParsed.apply("8")); // 64
}
}
When does order matter?
If the function types don’t match, order becomes critical. For example:
Function<String, String> addPrefix = s -> "User: " + s;
Function<String, Integer> length = s -> s.length();
Function<String, Integer> composed = addPrefix.andThen(length);
System.out.println(composed.apply("Alice")); // "User: Alice" -> 11
// But this way:
// length.andThen(addPrefix) — compilation error!
// length returns Integer, but addPrefix accepts String.
Table: difference between compose and andThen
|
|
|
|
|---|---|---|---|
|
|
|
|
3. Composing predicates and other interfaces
Predicate<T>: and, or, negate
The functional interface Predicate<T> is a function that returns boolean. To combine predicates there are special methods:
- and: logical AND (&&)
- or: logical OR (||)
- negate: logical NOT (!)
Example: complex filter conditions
Suppose we have a user class:
public class User {
String name;
int age;
public User(String name, int age) {
this.name = name;
this.age = age;
}
}
Now create several predicates:
import java.util.function.Predicate;
Predicate<User> isAdult = user -> user.age >= 18;
Predicate<User> nameStartsWithA = user -> user.name.startsWith("A");
// Combine: adult and name starting with "A"
Predicate<User> adultAndA = isAdult.and(nameStartsWithA);
// Adult or name starting with "A"
Predicate<User> adultOrA = isAdult.or(nameStartsWithA);
// Not an adult
Predicate<User> notAdult = isAdult.negate();
Now we can use these predicates in filtering, for example with the Stream API:
import java.util.List;
import java.util.stream.Collectors;
List<User> users = List.of(
new User("Alice", 20),
new User("Bob", 17),
new User("Anna", 15),
new User("Mike", 22)
);
List<User> filtered = users.stream()
.filter(adultAndA)
.collect(Collectors.toList());
// Only Alice will end up in filtered (adult and name starting with "A")
Composing Consumer, Function, Supplier
- Consumer<T>: the andThen method — lets you perform two operations in sequence.
- Function<T, R>: composition was covered above.
- Supplier<T>: not composed directly, but can be used inside other functions.
Example: Consumer<T>.andThen
import java.util.function.Consumer;
Consumer<String> print = s -> System.out.println("Received: " + s);
Consumer<String> printUpper = s -> System.out.println("Uppercase: " + s.toUpperCase());
Consumer<String> combined = print.andThen(printUpper);
combined.accept("hello");
// Output:
// Received: hello
// Uppercase: HELLO
4. Practice: chains of transformations and filtering
Task 1: Build a Function transformation chain
Suppose our application stores users as strings "Name,Age", for example "Alice,20". We need to:
- Convert the string to a User object
- Get the age
- Check whether the user is an adult
import java.util.function.Function;
import java.util.function.Predicate;
Function<String, User> stringToUser = str -> {
String[] parts = str.split(",");
return new User(parts[0], Integer.parseInt(parts[1]));
};
Function<User, Integer> getAge = user -> user.age;
Predicate<Integer> isAdultAge = age -> age >= 18;
// Combine: string -> User -> age -> predicate
Function<String, Integer> stringToAge = stringToUser.andThen(getAge);
String input = "Bob,19";
int age = stringToAge.apply(input);
System.out.println("Age: " + age); // 19
System.out.println("Adult? " + isAdultAge.test(age)); // true
Task 2: Combine multiple Predicate for filtering
Suppose we need to select users older than 18 whose names start with "A" or "M".
Predicate<User> isAdult = user -> user.age > 18;
Predicate<User> nameStartsWithA = user -> user.name.startsWith("A");
Predicate<User> nameStartsWithM = user -> user.name.startsWith("M");
Predicate<User> filter = isAdult.and(nameStartsWithA.or(nameStartsWithM));
List<User> filtered = users.stream()
.filter(filter)
.collect(Collectors.toList());
Task 3: Multi-stage Function transformation
Goal: take a string, trim whitespace, convert to upper case, add the prefix "USER: ".
Function<String, String> trim = String::trim;
Function<String, String> toUpper = String::toUpperCase;
Function<String, String> addPrefix = s -> "USER: " + s;
// Build the chain
Function<String, String> pipeline = trim.andThen(toUpper).andThen(addPrefix);
System.out.println(pipeline.apply(" john ")); // USER: VASYA
5. Common mistakes when composing functions
Error #1: compose/andThen order is mixed up.
Beginners often confuse what runs first and what runs second. Remember: f.compose(g) — first g, then f; f.andThen(g) — first f, then g.
Error #2: Type mismatch.
If the result type of one function doesn’t match the parameter type of another, the compiler won’t let you compose them. For example, you can’t do Function<Integer, String>.andThen(Function<Double, Boolean>).
Error #3: Overly complex chains.
Sometimes you want to cram all business logic into a single chain and get “spaghetti”. Break it up into small functions and give them clear names.
Error #4: Side effects inside functions.
It’s better to keep functions and predicates “pure” (without side effects), otherwise composition becomes risky and unpredictable.
GO TO FULL VERSION