Lambda Expressions and Method References in Java

Part 15 of 19 in Functional Java Unleashed

Lambda expressions (introduced in Java 8) let you write anonymous functions inline, and the :: method-reference operator lets you point at an existing method without wrapping it in boilerplate. Under the hood both rely on functional interfaces — single-method types like java.util.function.Function<T,R> or any custom SAM type — and a mechanism called target typing.

This post walks through six small patterns: basic lambda syntax, the rules the compiler uses to infer types, four forms of method reference (::), and how overload resolution picks between functional interface variants.

The code

The full file demonstrates all patterns in order. Each section is clearly labeled; read ahead for what to look for, then watch it run.

import java.util.*;
import java.util.function.*;
import java.util.stream.*;

@FunctionalInterface
interface MathOp {
    int apply(int a, int b);
}

@FunctionalInterface
interface UnaryInt {
    int apply(int x);
}

@FunctionalInterface
interface ToStr<T> {
    String toStr(T value);
}

public class LambdaMethods {

    // 1. BASIC LAMBDA SYNTAX
    public static void demoBasicLambdas() {
        MathOp addExplicit = (int a, int b) -> a + b;
        MathOp addInferred = (a, b) -> a + b;       // types omitted
        UnaryInt square    = x -> x * x;             // no parens for 1 param

        MathOp max = (a, b) -> {                    // braces for multi-statement
            if (a > b) return a;
            else         return b;
        };

        int factor = 10;
        UnaryInt multiplier = x -> x * factor;      // captures effectively final var
    }

    // 2. TYPE INFERENCE RULES
    public static void demoTypeInference() {
        UnaryInt negate = x -> -x;                  // param type: inferred from SAM
        UnaryInt abs    = Math::abs;                // parameter types match via ::

        ToStr<String> upperSafe = s -> {            // return type: inferred per branch
            if (s == null) return "";
            return s.toUpperCase();                 // all branches must agree
        };

        Consumer<String> printer = s -> System.out.print(s);  // target typing
    }

    // 3. STATIC METHOD REFERENCE (::Class::method)
    public static void demoStaticMethodRef() {
        Function<String, Integer> parseIntViaRef = Integer::parseInt;
        UnaryInt absRef                          = Math::abs;
        Function<Long, String> longToStr         = Long::toBinaryString;
    }

    // 4. INSTANCE METHOD OF AN ARBITRARY OBJECT (::Class::instanceMethod)
    public static void demoInstanceMethodRef() {
        List<String> names2 = new ArrayList<>();
        names2.sort(String::compareToIgnoreCase);   // (a,b) -> a.compareTo(b)
        ToStr<Integer> toHexRef = Integer::toHexString;  // x -> x.toHexString()
    }

    // 5. CONSTRUCTOR REFERENCE (::new)
    public static void demoConstructorRef() {
        Supplier<List<String>> listFactory  = ArrayList::new;
        Function<Integer, List<String>> capList  = ArrayList::new;  // with capacity
        ToStr<byte[]> bytesToStr               = String::new;
        IntFunction<String[]> stringArrayFactory = String[]::new;
    }

    // 6. OVERLOAD RESOLUTION WITH LAMBDAS
    public static void demoOverloadResolution() {
        // x -> x + 1 has a return value -> picks UnaryOperator, not Consumer
        printLambda(x -> x + 1);
    }

    private static void printLambda(Consumer<Integer> c) { /* ... */ }
    private static void printLambda(UnaryOperator<Integer> u) { /* ... */ }

    public static void main(String[] args) {
        demoBasicLambdas();
        demoTypeInference();
        demoStaticMethodRef();
        demoInstanceMethodRef();
        demoConstructorRef();
        demoOverloadResolution();
    }
}

Running it

The program walks through every pattern and prints its results:

=== 1. Basic Lambda Syntax ===

addExplicit(3, 4)   = 7
addInferred(10, 20) = 30
square(7)           = 49
max(5, 9)           = 9
multiplier(42)      = 420

=== 2. Type Inference Rules ===

negate(-5)          = 5
abs(-5)             = 5
upperSafe("hello")    = HELLO
upperSafe(null)         = 
Target-typed: works

=== 3. Static Method Reference ===

parseIntViaRef.apply("42")  = 42
Math::abs applied(-7)      = 7
Long::toBinaryString(10L) = 1010

=== 4. Instance Method Reference (arbitrary object) ===

Sorted (lambda):          [hello, Java, WORLD]
Sorted (method ref):      [hello, Java, WORLD]
toHex(255)              = ff
toHexRef(255)           = ff

=== 5. Constructor Reference ===

listFactory.get()     = [a, b, c]
capList.apply(100).size() = 0
bytesToStr.toStr(bytes)   = "Hello"
stringArrayFactory(3).length = 3

=== 6. Overload Resolution with Lambdas ===

UnaryOperator result: 6
unaryOp.apply(5)      = 6

All examples completed successfully.


How each section works

Lambda syntax — three shortcuts

The first section shows the four forms a lambda takes:

  • (int a, int b) -> a + b — fully explicit types and braces (though no return needed for single expression).
  • (a, b) -> a + b — parameter types omitted; the compiler gets them from MathOp.apply(int,int).
  • x -> x * x — parentheses dropped because there’s exactly one parameter.
  • (a, b) -> { if ... return a; } — braces required when the body has more than one statement.

A lambda can also capture effectively-final locals (factor = 10) just like an anonymous class would.

Type inference — four rules

The compiler infers types from three sources: assignment type, method parameter type, and return type. The second section demonstrates:

  • Parameter types come from the SAM: UnaryInt negate = x -> -x works because UnaryInt.apply(int) tells the compiler x is int.
  • Method references resolve by signature matching: Math::abs maps to UnaryInt because Math.abs(int) takes one int and returns int.
  • Return type is inferred per branch: every path through a block body must return the same type — "" and s.toUpperCase() are both String.
  • Target typing flows from the context: a lambda passed as an argument or assigned to a variable gets its functional interface type from that context. Standing alone, it has none.

Method references — three forms

The :: operator is syntactic sugar for a lambda. There are three cases:

  1. Static method: Integer::parseInt maps (String) -> Integer because the static method signature matches the SAM’s parameter and return types directly.
  2. Instance method of an arbitrary object: String::compareToIgnoreCase maps (a, b) -> a.compareToIgnoreCase(b) — the first lambda parameter becomes this. Similarly, Integer::toHexString maps (x) -> x.toHexString().
  3. Constructor: ArrayList::new adapts to whatever constructor matches the SAM’s parameters. Supplier<List<String>> (no args) picks new ArrayList<>(), while Function<Integer, List<String>> (one int arg) picks new ArrayList<>(int).

Overload resolution

When you pass a lambda to an overloaded method, the compiler tries each overload’s target type. A lambda that returns a value (x -> x + 1) is compatible with UnaryOperator<Integer> but not with Consumer<Integer> (void return). The output shows only the UnaryOperator branch firing — Consumer was silently eliminated by overload resolution.

Takeaway

A lambda expression is just target-typed anonymous code: parameter types, return type, and functional interface flow from context, not from the lambda itself. Method references (::) are a concise way to say “wrap this existing method in the right functional interface” — the compiler figures out the mapping automatically based on signatures.