RizTech Academy logo
RizTech Academy
Streams and Functional JavaLesson 1 of 530 min

Lambdas and functional interfaces

Every time this course has used Comparator.comparing(Sub::name) or computeIfAbsent(k, key -> new ArrayList<>()), it has been writing a small function and passing it to a method. This lesson explains what those actually are, because they underpin the whole of the rest of this module.

The one idea: in Java, a function you pass around is an object implementing an interface with a single method. Lambdas are shorthand for writing that object.

What a lambda replaces

Before Java 8, passing behaviour meant an anonymous class:

Comparator<String> byLength = new Comparator<String>() {
    @Override
    public int compare(String a, String b) {
        return Integer.compare(a.length(), b.length());
    }
};

Five lines of ceremony around one expression. The lambda:

Comparator<String> byLength = (a, b) -> Integer.compare(a.length(), b.length());

They compile to different things — a lambda does not create a new class file — but for your purposes they mean the same and the lambda is what you write.

Functional interfaces

A lambda can be assigned to any interface with exactly one abstract method. That is a functional interface, and it can be one of your own:

@FunctionalInterface
interface Discount {
    long applyTo(long paise);
}

Discount student = paise -> paise * 90 / 100;
System.out.println(student.applyTo(8_235));

The @FunctionalInterface annotation is optional and worth adding: it makes the compiler reject a second abstract method, so nobody breaks every lambda in the codebase by adding one. Default and static methods do not count.

Most of the time you will not declare your own. java.util.function has the shapes already, and using the standard ones means your method works with every library that expects them.

The ones you need to know

Interface Method Shape Example
Predicate<T> test(T) T to boolean s -> s.length() > 5
Function<T,R> apply(T) T to R String::length
Supplier<T> get() nothing to T () -> "Namaste"
Consumer<T> accept(T) T to nothing s -> System.out.println(s)
UnaryOperator<T> apply(T) T to T s -> s.toUpperCase()
BiFunction<T,U,R> apply(T,U) two in, R out (s, n) -> s.repeat(n)
BinaryOperator<T> apply(T,T) two T, one T Integer::sum
BiConsumer<T,U> accept(T,U) two in, nothing out (k, v) -> print(k, v)
Runnable run() nothing to nothing () -> doIt()

And the primitive versions that avoid boxing — IntPredicate, ToIntFunction<T>, IntUnaryOperator, ToLongFunction<T> and friends. You will meet ToLongFunction the moment you total money in paise.

Learn to read the shape, not the name. "Takes one thing, returns a boolean" is a Predicate. "Takes one thing, returns another" is a Function. That is most of the package.

Syntax variations

(String s) -> s.length()      // explicit type, rarely needed
(s) -> s.length()             // inferred
s -> s.length()               // one parameter, no brackets
(a, b) -> a + b               // two parameters, brackets required
() -> "Namaste"               // no parameters
s -> { int n = s.length(); return n * 2; }   // block body needs return

A block body needs return; an expression body does not. Mixing them up produces missing return statement or incompatible types: bad return type in lambda expression.

Composing them

The functional interfaces come with combinators, which is where they become genuinely pleasant:

Predicate<String> isLong = s -> s.length() > 5;
Predicate<String> startsWithA = s -> s.startsWith("A");

isLong.negate().test("Amit");            // true
startsWithA.and(isLong).test("Amit");    // false
startsWithA.or(isLong).test("Amit");     // true

Function<String, Integer> length = String::length;
Function<String, Integer> doubled = length.andThen(n -> n * 2);
doubled.apply("Priya");                  // 10
Combinator On Does
and / or / negate Predicate Boolean logic
andThen Function, Consumer Run this, then that
compose Function Run that, then this
Predicate.not(p) static Same as negate, reads better in a stream

Predicate.not(String::isBlank) in a filter is much clearer than s -> !s.isBlank(), and you will use it constantly.

Method references

When a lambda does nothing but call an existing method, name the method instead. There are four kinds:

Function<String, Integer> parse = Integer::parseInt;   // static method
Function<String, String> upper = String::toUpperCase;  // instance method of the parameter
Function<String, String> greet = prefix::concat;       // instance method of a specific object
Supplier<ArrayList<String>> maker = ArrayList::new;    // constructor

The second is the one that confuses people. String::toUpperCase takes no argument as a method, yet it becomes a Function<String, String> — because the receiver becomes the parameter. s -> s.toUpperCase() and String::toUpperCase are the same thing.

Use a method reference when the lambda adds nothing. s -> s.length() becomes String::length. But s -> s.length() > 5 stays a lambda, because it does more than call a method.

Effectively final

A lambda can read local variables from the enclosing method, but only if they never change:

int limit = 5;
Predicate<String> under = s -> s.length() < limit;

Add limit = 6; anywhere in the method and the lambda stops compiling: local variables referenced from a lambda expression must be final or effectively final.

The reason: the lambda may outlive the method — stored in a field, passed to another thread, run later — and Java captures the value, not the variable. Allowing it to change would mean two versions of the truth.

Fields are different. A lambda can read and write instance fields freely, because it captures this, not a copy.

The workaround people find, and should not use:

int[] counter = {0};
list.forEach(s -> counter[0]++);

That compiles — the array reference is effectively final even though its contents change — and it works. It is also a sign you are using a functional construct to do an imperative job. Count with stream().count() or a collector instead.

this inside a lambda

In an anonymous class, this refers to the anonymous instance. In a lambda, it refers to the enclosing object. The lambda is not a new scope in that sense — it does not have its own this, and cannot shadow a variable from the enclosing method either.

That is nearly always what you want, and it is one more reason to prefer lambdas to anonymous classes.

What lambdas are not for

  • Long bodies. A lambda over about three lines should be a method, with a name that says what it does. Then pass a method reference.
  • Side effects inside a stream. Adding to a list from inside map or filter works and is a bad idea for reasons the next lesson covers.
  • Replacing every loop. A for loop with an index, an early break, or two things happening at once is usually clearer as a loop. The final lesson of this module is explicit about when not to convert.
  • Checked exceptions. None of the standard functional interfaces declare them, so a lambda that throws IOException will not compile against Function. Module 7 comes back to this; it is a genuine rough edge.

Check your work

What makes an interface functional? Exactly one abstract method. Default and static methods do not count. @FunctionalInterface asks the compiler to enforce it.

Which interface takes one value and returns a boolean? Predicate<T>, with test.

What is the difference between s -> s.toUpperCase() and String::toUpperCase? Nothing — the receiver of an unbound instance method reference becomes the parameter.

Why must a captured local variable be effectively final? The lambda may outlive the method, so Java captures the value rather than the variable. A changing variable would mean two versions of the truth.

Why is capturing a one-element array to count things a smell? It works because the array reference never changes, but it is imperative state inside a functional construct. Use count() or a collector.

What does this refer to inside a lambda? The enclosing object — unlike an anonymous class, where it refers to the anonymous instance.

Name a case where a lambda is the wrong choice. A body longer than about three lines; a loop needing an index or an early break; anything throwing a checked exception.

Practice 2, the four method references. Integer::parseInt is a static method. String::toUpperCase is an unbound instance method — the receiver becomes the parameter. prefix::concat is bound to one specific object. ArrayList::new is a constructor reference and fits Supplier. If you could not make the second one type-check, the clue is that it is a Function<String, String> and not a Supplier<String>.

Practice 4, the effectively final error.

error: local variables referenced from a lambda expression must be final or effectively final

Moving the variable into a field makes it compile, because the lambda then captures this and reads the field each time it runs — which also means the behaviour changes if the field changes later. That difference is the whole reason for the rule.

Practice

  1. Convert three anonymous classes. Write a Comparator, a Runnable and a Predicate as anonymous classes, then rewrite each as a lambda. Count the lines.

  2. Use all four kinds of method reference. A static one, an unbound instance one, a bound one and a constructor one. Assign each to the right functional interface.

  3. Compose predicates. Build isLong, startsWithA and combinations with and, or and negate. Then rewrite one filter using Predicate.not(...) and decide which reads better.

  4. Break effectively final. Capture a local variable in a lambda, then assign to it afterwards. Read the error. Then make it a field and confirm it compiles — and work out what changed about when the value is read.

  5. Write your own functional interface. Discount with long applyTo(long paise). Implement student, festival and none as lambdas, put them in a Map<String, Discount> and apply one by name. Then add a second abstract method and read what @FunctionalInterface says.

  6. Harder — a validation pipeline. Write Predicate<Sub> rules for a valid subscriber: non-blank name, six-digit pincode, tiffins between 0 and 62. Put them in a List<Predicate<Sub>> with a message each, and write a method that returns every rule a given subscriber fails. Then combine them all with reduce(Predicate::and) into a single predicate and note what you lose — you get one yes-or-no answer and no longer know which rule failed. That trade comes back in the capstone.

Next: streams, which are these functions applied to a whole collection at once.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship