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 noreturnneeded for single expression).(a, b) -> a + b— parameter types omitted; the compiler gets them fromMathOp.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 -> -xworks becauseUnaryInt.apply(int)tells the compilerxisint. - Method references resolve by signature matching:
Math::absmaps toUnaryIntbecauseMath.abs(int)takes oneintand returnsint. - Return type is inferred per branch: every path through a block body must return the same type —
""ands.toUpperCase()are bothString. - 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:
- Static method:
Integer::parseIntmaps(String) -> Integerbecause the static method signature matches the SAM’s parameter and return types directly. - Instance method of an arbitrary object:
String::compareToIgnoreCasemaps(a, b) -> a.compareToIgnoreCase(b)— the first lambda parameter becomesthis. Similarly,Integer::toHexStringmaps(x) -> x.toHexString(). - Constructor:
ArrayList::newadapts to whatever constructor matches the SAM’s parameters.Supplier<List<String>>(no args) picksnew ArrayList<>(), whileFunction<Integer, List<String>>(one int arg) picksnew 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.