☕ Java

Method References

Method references are a shorthand syntax for lambda expressions that simply call an existing method. Where a lambda expression is () -> SomeClass.someMethod() or x -> x.someMethod(), a method reference is SomeClass::someMethod or SomeClass::someMethod. The compiler translates a method reference into a functional interface implementation that calls the referenced method, producing code identical to the equivalent lambda expression — method references are purely syntactic sugar with no behavioral difference. Java defines four kinds: static method references (ClassName::staticMethod), unbound instance method references (ClassName::instanceMethod, where the first lambda argument provides the instance), bound instance method references (instance::instanceMethod, where a specific instance is captured), and constructor references (ClassName::new). Method references improve readability by eliminating lambda boilerplate when the lambda does nothing but forward to an existing method. This entry covers all four kinds with their exact lambda equivalents, the rules for matching method references to functional interface types, method references to overloaded methods, and how the compiler resolves ambiguity.

The Four Kinds of Method References

A static method reference ClassName::staticMethod refers to a static method of a class. The equivalent lambda takes the same parameters as the static method and passes them directly: Integer::parseInt is equivalent to (s) -> Integer.parseInt(s), Function<String, Integer>. When matched to a functional interface, the method reference's parameter list and return type must match the functional interface's abstract method signature. Multiple parameters are supported: Math::max as BinaryOperator<Integer> or IntBinaryOperator is equivalent to (a, b) -> Math.max(a, b). An unbound instance method reference ClassName::instanceMethod refers to an instance method where the instance will be provided as the first argument. The lambda equivalent has one extra parameter compared to the method's declared parameter list — the first parameter is the receiver instance: String::toUpperCase as Function<String, String> is equivalent to (s) -> s.toUpperCase(). String::startsWith as BiPredicate<String, String> is equivalent to (s, prefix) -> s.startsWith(prefix). The method is called on the first argument. A bound instance method reference instance::instanceMethod refers to an instance method where a specific instance is captured at reference creation time. Every call uses the same captured instance: System.out::println as Consumer<String> is equivalent to (s) -> System.out.println(s) where System.out is captured. myList::add as Consumer<String> is equivalent to (s) -> myList.add(s). Bound references capture the instance reference — if the instance is mutable, subsequent changes to it affect all calls through the bound reference. A constructor reference ClassName::new refers to a constructor. The lambda equivalent calls the constructor with the provided arguments and returns the new instance: ArrayList::new as Supplier<ArrayList<String>> is equivalent to () -> new ArrayList<>(); ArrayList::new as IntFunction<ArrayList<String>> is equivalent to (n) -> new ArrayList<>(n) (which constructor is invoked depends on the target functional interface's method signature). Constructor references for generic types require the target type to supply the type argument.
Java
// ── Kind 1: Static method reference ──────────────────────────────────
// Lambda:           (s)      -> Integer.parseInt(s)
// Method reference: Integer::parseInt
Function<String, Integer> parseInt = Integer::parseInt;
System.out.println(parseInt.apply("42"));   // 42

// Lambda:           (a, b) -> Math.max(a, b)
// Method reference: Math::max
IntBinaryOperator max = Math::max;
System.out.println(max.applyAsInt(3, 7));   // 7

// Lambda:           ()     -> System.currentTimeMillis()
// Method reference: System::currentTimeMillis
LongSupplier clock = System::currentTimeMillis;

// ── Kind 2: Unbound instance method reference ─────────────────────────
// Lambda:           (s)          -> s.toUpperCase()
// Method reference: String::toUpperCase
Function<String, String> upper = String::toUpperCase;
System.out.println(upper.apply("hello"));   // HELLO

// Lambda:           (s, prefix) -> s.startsWith(prefix)
// Method reference: String::startsWith
BiPredicate<String, String> startsWith = String::startsWith;
System.out.println(startsWith.test("Hello", "He"));  // true

// Lambda:           (list)      -> list.size()
// Method reference: List::size
ToIntFunction<List<?>> size = List::size;
System.out.println(size.applyAsInt(List.of(1, 2, 3)));  // 3

// ── Kind 3: Bound instance method reference ────────────────────────────
// Lambda:           (s) -> System.out.println(s)  (System.out captured)
// Method reference: System.out::println
Consumer<String> print = System.out::println;
print.accept("Bound reference");   // Bound reference

// Lambda:           (s) -> "prefix_".concat(s)  (literal captured)
String prefix = "Hello, ";
Function<String, String> greet = prefix::concat;
System.out.println(greet.apply("World"));   // Hello, World

// Lambda:           (n) -> myList.add(n)  (specific list captured)
List<String> myList = new ArrayList<>();
Consumer<String> addToList = myList::add;
addToList.accept("Alice");
addToList.accept("Bob");
System.out.println(myList);   // [Alice, Bob]

// ── Kind 4: Constructor reference ─────────────────────────────────────
// Lambda:           ()  -> new ArrayList<>()
// Method reference: ArrayList::new
Supplier<ArrayList<String>> listFactory = ArrayList::new;
ArrayList<String> newList = listFactory.get();   // new empty ArrayList

// Lambda:           (n) -> new ArrayList<>(n)
// Method reference: ArrayList::new  (different constructor selected by target type)
IntFunction<ArrayList<String>> sizedFactory = ArrayList::new;
ArrayList<String> sized = sizedFactory.apply(100);  // pre-sized ArrayList

// Lambda:           (s) -> new StringBuilder(s)
// Method reference: StringBuilder::new
Function<String, StringBuilder> sbFactory = StringBuilder::new;
StringBuilder sb = sbFactory.apply("initial");

Matching Method References to Functional Interfaces

The compiler matches a method reference to a functional interface by examining the target type — the functional interface type expected in the context where the method reference appears. The matching rules differ by kind. For static method references: the method's parameter list and return type must match the functional interface's abstract method exactly. For unbound instance references: the first lambda parameter provides the receiver, so the functional interface must have one more parameter than the method — the first parameter's type must be the class containing the method (or a supertype), and the remaining parameters match the method's parameters. For bound instance references: the functional interface's parameter list must match the method's parameter list exactly (no extra first parameter for the receiver — it is captured). For constructor references: the functional interface's return type must be the constructed type, and its parameters must match the constructor's parameters. Overloaded methods require careful attention. When a class has multiple overloads of a method with different signatures, the compiler uses the target functional interface type to select the correct overload. PrintStream::println is ambiguous without a target type — there are overloads for String, int, long, double, Object, etc. Assigning PrintStream::println to Consumer<String> selects the println(String) overload; assigning to Consumer<Integer> selects println(int) (which gets autoboxed) or println(Object). If the overload resolution is ambiguous, a compilation error occurs and an explicit lambda with a cast must be used. Generic method references require the compiler to infer the type arguments. Collections::sort as Consumer<List<Comparable>> selects void sort(List<T> list) with T inferred from the Consumer's element type. When the inference is ambiguous or unavailable, an explicit lambda with a cast or explicit type witness is needed. Method references to methods that throw checked exceptions cannot be assigned to functional interfaces whose abstract method does not declare the same checked exceptions. A reference to Files::readString (throws IOException) cannot be assigned to Function<Path, String> directly — it can only be assigned to a functional interface that declares throws IOException. The common workaround: catch the IOException inside a wrapping lambda, or use a custom functional interface that declares the exception.
Java
// ── Unbound reference: first param is the receiver ───────────────────
// String::compareToIgnoreCase requires BiFunction or Comparator:
// Lambda: (s1, s2) -> s1.compareToIgnoreCase(s2)
Comparator<String> cmp  = String::compareToIgnoreCase;
BiFunction<String, String, Integer> bf = String::compareToIgnoreCase;
System.out.println(cmp.compare("Apple", "apple"));   // 0

// ── Overload resolution via target type ───────────────────────────────
// PrintStream has many println overloads:
Consumer<String>  printStr = System.out::println;   // println(String) selected
Consumer<Integer> printInt = System.out::println;   // println(int) via unboxing? No:
                                                    // println(Object) selected (Integer is Object)

// Explicitly select println(int) via IntConsumer:
IntConsumer printPrimInt = System.out::println;     // println(int) selected

// ── Ambiguous: explicit lambda required ───────────────────────────────
// If two overloads match equally, use a lambda with explicit cast:
// someMethod has overloads: void foo(String s) and void foo(Object o)
Consumer<String> c = SomeClass::foo;   // may be ambiguous if both match Consumer<String>
// Resolution: (String s) -> SomeClass.foo(s) selects the String overload explicitly

// ── Constructor reference: type inference ────────────────────────────
// ArrayList has: ArrayList(), ArrayList(int), ArrayList(Collection<?>)
Supplier<ArrayList<String>>          noArg  = ArrayList::new;   // ArrayList()
IntFunction<ArrayList<String>>       intArg = ArrayList::new;   // ArrayList(int)
Function<Collection<?>, ArrayList<?>> colArg = ArrayList::new;  // ArrayList(Collection)

// Streams.toCollection uses Supplier<C>:
TreeSet<String> ts = Stream.of("b","a","c")
    .collect(Collectors.toCollection(TreeSet::new));  // TreeSet() selected
System.out.println(ts);   // [a, b, c]

// ── Checked exceptions: method refs to throwing methods ───────────────
// Files.readString(Path) throws IOException — cannot use as Function<Path, String>:
// Function<Path, String> reader = Files::readString;  // COMPILE ERROR

// Workaround 1: wrap in lambda with try-catch
Function<Path, String> reader = path -> {
    try { return Files.readString(path); }
    catch (IOException e) { throw new UncheckedIOException(e); }
};

// Workaround 2: define a throwing functional interface:
@FunctionalInterface
interface ThrowingFunction<T, R> {
    R apply(T t) throws Exception;
}
ThrowingFunction<Path, String> throwingReader = Files::readString;  // compiles fine

// ── Method references in sorted, map, filter ─────────────────────────
List<String> names = List.of("Charlie", "Alice", "Bob", "Dave");

// sorted with Comparator method reference:
List<String> sorted = names.stream()
    .sorted(String::compareToIgnoreCase)   // Comparator<String> from unbound ref
    .collect(Collectors.toList());
System.out.println(sorted);   // [Alice, Bob, Charlie, Dave]

// map with constructor reference:
List<StringBuilder> builders = names.stream()
    .map(StringBuilder::new)   // Function<String, StringBuilder> — constructor(String)
    .collect(Collectors.toList());

// filter with method reference (predicate):
List<String> nonEmpty = List.of("Alice", "", "Bob", "", "Carol").stream()
    .filter(Predicate.not(String::isEmpty))  // negate the isEmpty unbound ref
    .collect(Collectors.toList());
System.out.println(nonEmpty);   // [Alice, Bob, Carol]

Related Topics in Java 8 Features

Intermediate Operations
Intermediate operations are Stream API methods that transform one stream into another stream, enabling pipeline construction through method chaining. Every intermediate operation is lazy — it does not process any elements when called; it only builds a description of the computation to be performed. The actual processing occurs only when a terminal operation triggers stream traversal, at which point all intermediate operations execute in a single pass over the data, interleaved element by element rather than stage by stage. This laziness and fusion model is the defining architectural feature of the Streams API, distinguishing it from eager collection-transformation approaches. Intermediate operations fall into categories: filtering (filter, distinct), transformation (map and its primitive variants, flatMap), ordering (sorted), size-limiting (limit, skip), peeking (peek), and the Java 9 additions for prefix-based selection (takeWhile, dropWhile). This entry covers the laziness model and why it matters, every intermediate operation with its exact semantics and performance characteristics, stateless versus stateful intermediate operations, short-circuiting operations and how they interact with infinite streams, the map/flatMap distinction, and operation ordering and its performance implications.
Terminal Operations
Terminal operations are Stream API methods that trigger the actual traversal and processing of a stream pipeline, producing a non-stream result — a value, a collection, a side effect, or nothing (void). A stream can have exactly one terminal operation; once invoked, the stream is consumed and cannot be reused (calling any further operation on it throws IllegalStateException). Terminal operations fall into categories: reduction (reduce, collect), matching (anyMatch, allMatch, noneMatch), finding (findFirst, findAny), counting (count), iteration with side effects (forEach, forEachOrdered), and array/collection materialization (toArray, toList). Some terminal operations are short-circuiting (anyMatch, findFirst) and can terminate before examining the entire stream; others (forEach, count in most cases, collect) must examine every element. This entry covers every terminal operation in depth, the reduce() method and its three overloads with the combiner function's role in parallel execution, the Collectors framework as the primary mechanism for complex terminal reduction, short-circuiting semantics and their interaction with infinite streams, the single-use constraint and its rationale, and the choice between equivalent terminal operations for performance and clarity.
Optional
Optional<T>, introduced in Java 8, is a container object that may or may not hold a non-null value, designed to make the possible absence of a value explicit in a method's type signature rather than implicit through a nullable return type. Optional.of(value) creates a present Optional and throws NullPointerException if value is null; Optional.empty() creates an absent Optional; Optional.ofNullable(value) creates either a present or empty Optional depending on whether value is null. The design intent, stated explicitly in the Javadoc, is as a return type for methods where the absence of a result is a valid and expected outcome, communicating that absence through the type system instead of through null, with the goal of reducing NullPointerException at the API boundary. Optional is explicitly not intended for use as a field type, a method parameter type, or inside collections, and the JDK team has stated that misuse in these contexts works against its design intent. This entry covers the complete Optional API including creation, value extraction, conditional execution, transformation and chaining, the primitive specializations OptionalInt/OptionalLong/OptionalDouble, and the established conventions for where Optional should and should not be used.
Default Methods
Default methods, introduced in Java 8, are methods declared in an interface with a body, marked with the default keyword, providing a concrete implementation that implementing classes inherit automatically without being forced to override it. Before Java 8, interfaces could declare only abstract methods, meaning adding any new method to an interface would break every existing implementation — a problem that became critical when the Streams API needed to add methods like forEach(), stream(), and removeIf() to the existing Collection-family interfaces without breaking the entire ecosystem of third-party collection implementations. Default methods solved this by allowing interface evolution: new methods can be added to an interface with a default implementation, and existing implementing classes continue to compile and work without modification, inheriting the default behavior until they choose to override it. This entry covers the design motivation and interface evolution problem default methods solve, the complete syntax and inheritance rules, the diamond problem and Java's resolution rules for conflicting default methods from multiple interfaces, the explicit disambiguation syntax using InterfaceName.super.method(), the interaction between default methods and abstract classes, and the static method companion feature introduced alongside default methods.