Java 8 Features
Default & static methods in interfaces, functional interfaces, lambda expressions, method references, and the Streams API for declarative data processing.
Assumes you know
After this you should be able to
- Use a functional interface
- Judge when a lambda improves clarity
Self-contained study resource. Section I is a hand-copy cheat sheet; Section II is the full explanation; Section III has runnable code for every concept; Section IV is a hands-on lab.
+ tag = my own enrichment / correction beyond the core material. Everything else is the backbone.
I. Notebook Summary (copy by hand) #
Terse, keyword-first, monospace blocks. Fully derivable from Section II.
JAVA 8 INTERFACE CHANGES
========================
Before J8 : iface = only public ABSTRACT methods, no body
add method -> ALL implementers break (must update)
J8+ : + static methods (have body)
+ default methods (have body)
=> add methods WITHOUT breaking old code (backward compat)
STATIC METHOD in iface
- belongs to IFACE itself, not instance
- call: InterfaceName.method()
- NOT inherited, NOT overridable
- use: utility/helper, no instance data
- cannot touch instance vars/methods
DEFAULT METHOD in iface (keyword: default)
- has body; INHERITED by impl classes
- impl class MAY override
- purpose: evolve iface safely
doodle: box "interface" with 3 slots: [abstract |open]
[default |filled, copyable] [static |locked to box]
STATIC vs DEFAULT (interface methods)
+-----------+----------------+------------------+
| | STATIC | DEFAULT |
+-----------+----------------+------------------+
| has body | yes | yes |
| called on | Iface.m() | instance.m() |
| inherited | NO | YES |
| override | NO | YES (optional) |
| use for | helpers/util | safe evolution |
+-----------+----------------+------------------+
FUNCTIONAL INTERFACE (FI)
= exactly ONE abstract method (SAM = Single Abstract Method)
+ any # of static + default + Object methods (don't count)
@FunctionalInterface = COMPILE-TIME check only (optional) if >1 abstract -> compile error
reason to exist -> enable LAMBDAS / functional programming
LAMBDA = short anon block: (params) -> body
= an INSTANCE of a functional interface
takes params, returns value
forms: () -> expr (no args) x -> x*2 (1 arg, parens optional)(a,b) -> a+b (2 args)(x) -> { ...; return v; } (block body needs return)
[+] method ref = lambda shorthand: Class::method , obj::method , Class::new
doodle: lambda = arrow gun (params)-->[body] loaded into FI socket BUILT-IN FUNCTIONAL INTERFACES (java.util.function)
+-------------+---------------------+-----------+-----------+
| Iface | abstract method | in -> out | example |
+-------------+---------------------+-----------+-----------+
| Supplier<T>| T get() | () -> T | ()->rand |
| Consumer<T>| void accept(T) | T -> () | print |
| BiConsumer | void accept(T,U) | T,U-> () | put(k,v) |
| Function<T,R>| R apply(T) | T -> R | len(s) |
| BiFunction | R apply(T,U) | T,U-> R | a+b |
| Predicate<T>| boolean test(T) | T -> bool| x>0 |
+-------------+---------------------+-----------+-----------+
mnemonic: SUPPLY-out, CONSUME-in, FUNCTION-transform, PREDICATE-judge
[+] also: UnaryOperator<T>(Function T->T), BinaryOperator<T>(BiFunction T,T->T)
[+] default combos: Predicate.and/or/negate ; Function.andThen/compose
doodle: Supplier=tap(->water) Consumer=drain(water->) Predicate=gate(?)
IMPERATIVE vs DECLARATIVE
+--------------+--------------------+--------------------+
| | IMPERATIVE | DECLARATIVE |
+--------------+--------------------+--------------------+
| you write | HOW (steps) | WHAT (intent) |
| state | you manage (vars) | library manages |
| flow control | you (loops/if) | hidden |
| ex | for+if+counter | filter.limit.collect|
| analogy | recipe steps | order the dish |
+--------------+--------------------+--------------------+
STREAM API
Stream = sequence of elements from a SOURCE (not a data store!)
pipeline = SOURCE -> intermediate ops* -> terminal op
[+] does NOT mutate source; single-use; can be sequential/parallel
PIPELINE SHAPE:
collection.stream() // source
.filter(..) .map(..) // intermediate (lazy, return Stream)
.sorted() .distinct()
.collect(..) / forEach // terminal (eager, ends stream)
doodle: balls ->[filter red]->[map melt->triangle]->[filter big]->[reduce sum]
client ->[pipe: worker|worker|worker] ->result
STREAM CREATION
list.stream() // from Collection
Arrays.stream(arr) // from array
Stream.of(a,b,c) // from values
Stream.iterate(0,n->n+1) // infinite -> need limit
Stream.generate(Math::random) // infinite -> need limit
IntStream.range(1,5) // 1,2,3,4 (rangeClosed = incl end)
str.chars() // IntStream of chars
INTERMEDIATE OPS (lazy, return Stream)
filter(pred) keep matching
map(fn) transform each (mapToInt/Long/Double for primitives)
sorted()/(cmp) order
limit(n) first n skip(n) drop first n
distinct() dedupe (uses equals/hashCode)
peek(consumer) side-effect only (debug/log) -- DON'T mutate
TERMINAL OPS (eager, end stream)
forEach , collect(Collectors..) , count , reduce
anyMatch/allMatch/noneMatch , findFirst/findAny (Optional)
min/max(cmp) , toList() [J16+] , toArray , sum/average (primitive)
RECOMMENDED OP ORDER (perf)
filter -> map -> distinct -> sorted -> limit/skip
(cut elements early; sort/limit on smallest set)
rule: cheap+shrinking ops FIRST, expensive (sort) LAST
LAZY EVALUATION
intermediate ops do NOTHING until a TERMINAL op runs
then elements flow ONE-AT-A-TIME through whole chain (vertical)
=> short-circuit: limit/findFirst stop early
doodle: faucet OFF until terminal turns it ON; each drop runs full pipe
TERMINAL = ONE SHOT
after terminal, stream is CONSUMED / dead
reuse -> IllegalStateException ("stream has already been operated upon")
[+] fix: create a fresh stream each time (or use a Supplier<Stream>)
GOTCHAS (memory hooks)
- lambda captures vars that are final / effectively final ONLY [+]
- static iface method NOT inherited (Iface.m only) [+]
- default method DIAMOND: 2 ifaces same default -> MUST override [+]
- peek = look, don't touch ; forEach order undefined if parallel [+]
- findFirst vs findAny: findAny faster in parallel [+]
- distinct/sorted are STATEFUL (buffer elements) [+]
II. Detailed Study Notes #
2. Static methods in interfaces
3. Default methods in interfaces
4. Functional interfaces &@FunctionalInterface
5. Lambda expressions
6. Method references [+]
7. The six built-in functional interfaces
8. Imperative vs declarative
9. Stream API & the pipeline
10. Creating streams
11. Intermediate operations
12. Recommended operation order
13. Lazy evaluation
14. Terminal operations & single-use rule
1. Interface evolution: why default & static methods exist #
Before Java 8 an interface was a pure contract; after Java 8 it can also ship behavior, so you can grow an interface without breaking everyone who implements it.
Originally an interface could only declare public abstract
methods — names and signatures, no bodies. Every implementing class had to supply the body. That created a brittle situation: the moment you added a new method to a published interface, every
existing class that implemented it stopped compiling until its author added the new method. For a widely-used interface (think List, implemented by hundreds of classes across the world), that is a non-starter.
Java 8 fixed this with two new kinds of interface members that carry their own implementation: static methods
and default methods. Because they have bodies, adding one does not force implementers to write anything — old code keeps compiling and running. +
The flagship real example: Java 8 added default Stream<E> stream()
and default void forEach(...)
to the Collection
/Iterable
interfaces. Every collection in existence instantly gained .stream()
without a single library breaking. That is the whole feature in one sentence.
→ Code: III · Interface with static & default methods
2. Static methods in an interface #
A static interface method belongs to the interface type itself — like a namespaced utility function — and is never inherited by implementers.
A static method in an interface is conceptually identical to a static method on a class: it is tied to the type, not to any object. You call it as InterfaceName.staticMethod(). Its natural job is a helper / factory / utility
that logically belongs with the interface but does not need any particular instance's data — for example a factory that builds a default implementation, or a comparator builder.
- Not inherited: implementing classes do not receive static interface methods. You cannot call them through an instance or through the class that implements the interface — only through the interface name.
- Not overridable: since they are not inherited, there is nothing to override. A same-named static method in an implementing class is a completely separate, unrelated method.
- No instance access: like any static context, it cannot read instance variables or call instance methods of implementers.
myImpl.staticMethod()
or ImplClass.staticMethod()
to work — it does not compile. Always go through the interface: Iface.staticMethod().
→ Code: III · Interface with static & default methods
3. Default methods in an interface #
A default method is an interface method with a body, marked by the default
keyword, that implementers inherit for free and may override.
Where a static method stays attached to the interface, a default method is genuinely inherited by every implementing class. If a class says nothing, it gets the default behavior automatically; if it wants different behavior, it overrides the method just like any inherited method. This is what makes safe evolution possible: ship a sensible default body, and existing classes silently pick it up.
List.sort, Map.getOrDefault
patterns). Cons / limits:
default methods cannot hold state (no instance fields in interfaces); overusing them turns interfaces into half-baked abstract classes and can muddy the "interface = pure contract" mental model.
InterfaceName.super.method().
→ Code: III · Interface with static & default methods | III · Diamond resolution
4.
Functional interface &@FunctionalInterface
#
A functional interface has exactly one abstract method (SAM) — that single method is what a lambda implements.
"Functional interface" (FI) is a category, not a keyword: any
interface with exactly one abstract method qualifies, regardless of annotation. The single abstract method is often called the SAM
(Single Abstract Method). The interface may additionally have any number of static
and default
methods — those have bodies, so they are not abstract and do not break the "one abstract method" rule. +
Methods that merely redeclare public
methods of java.lang.Object
(like equals, toString
) also do not count toward the limit.
The @FunctionalInterface
annotation is optional documentation + a compile-time guard. It does not make
an interface functional; it asks the compiler to verify the interface really has exactly one abstract method, and to fail the build with a clear error if someone later adds a second one. Think of it as a unit test baked into the type.
@FunctionalInterface
to use a lambda." False — the annotation is optional; Runnable, Comparator, etc. work as lambda targets either way. The annotation just protects the interface from accidentally gaining a second abstract method.
→ Code: III · Custom functional interface
5. Lambda expressions #
A lambda is a compact anonymous function — (params) ->body
— that supplies the body for a functional interface's single abstract method.
Before lambdas, passing behavior meant writing a verbose anonymous inner class. A lambda is the same idea distilled to parameters, an arrow, and a body. The compiler matches it against the expected functional interface and treats the lambda as an instance of that interface.
Syntax forms#
| Shape | Example | Meaning |
|---|---|---|
| No params |
() ->42
| Supplier-style, returns 42 |
| One param |
x ->x * 2
| parens optional for single param |
| Typed params |
(int x) ->x * 2
| explicit type (rarely needed) |
| Multiple |
(a, b) ->a + b
| parens required |
| Block body |
x ->{ log(x); return x*2; }
|
braces ⇒ explicit return
|
this
scope — this
inside a lambda refers to the enclosing object, unlike an anonymous inner class.
this
= enclosing instance (anon class's this
= the anon object); lambdas compile to invokedynamic
rather than a separate .class
file, so they are lighter. +
→ Code: III · Lambda forms
6. + Method references (lambda shorthand) #
+
When a lambda does nothing but call one existing method, a method reference (::
) says the same thing more cleanly.
This is not in the core list but it is inseparable from lambdas in practice and on interviews, so it's worth knowing. There are four flavors:
| Kind | Syntax | Equivalent lambda |
|---|---|---|
| Static method |
Integer::parseInt
|
s ->Integer.parseInt(s)
|
| Instance, specific object |
System.out::println
|
x ->System.out.println(x)
|
| Instance, arbitrary object |
String::toUpperCase
|
s ->s.toUpperCase()
|
| Constructor |
ArrayList::new
|
() ->new ArrayList<>()
|
.map(String::toUpperCase)
reads like English. They are verified to be 100% standard and behaviour-equivalent to the lambdas above.
→ Code: III · Method references
7. The six most-used built-in functional interfaces #
Java ships ready-made functional interfaces in java.util.function
so you rarely write your own; learn the shape of each (inputs → output).
| Interface | Abstract method | Signature | One-liner |
|---|---|---|---|
Supplier<T>
|
T get()
| () → T | Produces a value, takes nothing |
Consumer<T>
|
void accept(T)
| T → () | Takes a value, returns nothing (side-effect) |
BiConsumer<T,U>
|
void accept(T,U)
| T,U → () | Two inputs, no output (e.g. map put) |
Function<T,R>
|
R apply(T)
| T → R | Transforms input to output |
BiFunction<T,U,R>
|
R apply(T,U)
| T,U → R | Two inputs, one output |
Predicate<T>
|
boolean test(T)
| T → boolean | Tests a condition (yes/no) |
+ A few close cousins worth knowing because they appear constantly:
-
UnaryOperator<T>— aFunction<T,T>where input and output share a type (e.g.x ->x+1). -
BinaryOperator<T>— aBiFunction<T,T,T>; this is whatreduceuses (e.g.(a,b) ->a+b). -
Primitive specializations like
IntSupplier,ToIntFunction,IntPredicateavoid boxing for performance.
+ Several of these carry useful default combinators:
-
Predicate:.and(),.or(),.negate()to compose conditions. -
Function:.andThen(g)(this then g) and.compose(g)(g then this). -
Consumer:.andThen()to chain side-effects.
Supplier; logging → Consumer; length-of-string → Function; is-even → Predicate; sum-two → BinaryOperator
/BiFunction.
→ Code: III · All six built-ins
8. Imperative vs declarative #
Imperative code spells out how step by step and manages its own state; declarative code states what you want and lets the library handle the how.
Suppose you want the first five people over 18. The imperative
way: write a for
loop, an if
age check, a result list, a counter, and a break. You own every step and every variable. The declarative
way: people.stream().filter(p ->p.age >18).limit(5).collect(toList()). You named the intent — filter, limit, collect — and the Stream API decided how to iterate, where to stop, and how to accumulate.
.parallelStream()
). Costs: a debugger steps through it less obviously, and naïve heavy chains can be slower than a hand-tuned loop. For hot, simple loops a plain for
can still win.
→ Code: III · Imperative vs declarative side-by-side
9. Stream API & the pipeline #
A Stream is a sequence of elements from a source that you process through a pipeline of operations — it is a view over data, not a new data structure.
The Stream API lets you filter, transform, and aggregate collection data with readable method calls instead of hand-written loops and conditionals. Two definitions to lock in: a stream is a sequence of elements coming from a source (a collection, array, file, generator…); a pipeline is the chain of operations that runs on that stream to produce a result.
A pipeline always has three parts:
-
Source
— e.g.
list.stream(). -
Zero or more intermediate operations
—
filter,map,sorted… each returns a new stream, so they chain. -
Exactly one terminal operation
—
collect,forEach,count… it triggers execution and yields the final result.
+
Key properties that distinguish streams from collections: a stream does not store elements
(it pulls them from the source on demand), it does not modify the source, it is traversable once, and it can run sequentially or in parallel. A Collection
is about storing
data; a Stream
is about computing over
it.
→ Code: III · Full pipeline example
10. Creating streams #
Streams can come from collections, arrays, explicit values, infinite generators, or numeric ranges.
| Source | How |
|---|---|
| Collection |
list.stream()
/ set.stream()
|
| Array |
Arrays.stream(arr)
|
| Explicit values |
Stream.of("a","b","c")
|
| Infinite (function) |
Stream.iterate(0, n ->n+1)
— needs limit
|
| Infinite (supplier) |
Stream.generate(Math::random)
— needs limit
|
| Numeric range + |
IntStream.range(1,5)
→ 1,2,3,4; rangeClosed(1,5)
→ 1..5
|
| Chars of a string + |
"abc".chars()
→ IntStream
|
Stream.iterate
and Stream.generate
are infinite. Without a short-circuiting op like limit(n)
a terminal operation will run forever.
→ Code: III · Stream creation
11. Intermediate operations #
Intermediate ops are lazy transformations that each return a new stream, so they chain into a pipeline; nothing runs until a terminal op arrives.
Each intermediate operation is "a worker on the conveyor": it takes the stream, does its job, and hands back a new stream for the next worker.
| Op | Does | Note |
|---|---|---|
filter(pred)
|
keep elements matching a Predicate
| shrinks the stream |
map(fn)
|
transform each element via a Function
|
1-to-1; +
use flatMap
to flatten nested streams
|
sorted()
/ sorted(cmp)
| order naturally or by comparator | + stateful — buffers all elements |
limit(n)
|
keep first n
| short-circuiting |
skip(n)
+
|
drop first n
|
complements limit
|
distinct()
| remove duplicates |
uses equals
/hashCode; stateful
|
peek(consumer)
| run an action per element as it passes | does not change elements; for logging/debug only |
peek
is for observation, not transformation. Mutating elements inside peek
is a code smell, and because of laziness peek
only fires for elements the terminal op actually pulls — so a peek
with no terminal prints nothing.
→ Code: III · Each intermediate op
12. Recommended operation order (performance) #
Order ops to shrink the stream as early as possible: filter → map → distinct → sorted → limit/skip.
- Filter first — cuts the element count so every later op processes fewer items.
- Map after filter — only transform the elements you're keeping.
- Distinct after map — dedupe on the transformed values.
- Sort after distinct — sorting is expensive (O(n log n) and stateful); do it on the smallest possible set.
- Limit / skip last — control output size once everything else is done.
sorted()
before filter()
sorts elements you're about to throw away — wasted work.
13. Lazy evaluation #
Intermediate operations do nothing until a terminal operation runs; then elements flow one at a time through the entire chain.
This is the most important mental model in the Stream API. When you write .filter(...).map(...), no filtering or mapping happens yet. You have only described a plan. The work starts the instant a terminal operation (collect, forEach, count
…) is invoked. At that point the stream pulls each element through the whole pipeline vertically
— element 1 goes through filter, then map, then the terminal; then element 2; and so on — rather than running filter over the entire list, then map over the entire list.
limit, findFirst, and anyMatch
can stop early. Stream.iterate(1, n->n+1).filter(odd).limit(3)
works on an infinite
source because it stops the moment it has 3 results.
→ Code: III · Laziness & short-circuit
14. Terminal operations & the single-use rule #
A terminal operation consumes the stream, produces the final result, and leaves the stream dead — reusing it throws IllegalStateException.
Terminal operations are what actually return something other than a stream — a list, a number, a boolean, an Optional, or a side-effect. After a terminal op runs, the stream is consumed: you cannot add more operations or call another terminal on the same stream object. Doing so throws IllegalStateException: stream has already been operated upon or closed.
| Category | Examples | Returns |
|---|---|---|
| Collect / reduce |
collect(Collectors.toList()), reduce(0,Integer::sum)
| aggregate / single value |
| Iterate |
forEach, forEachOrdered
| void (side-effect) |
| Count / match |
count, anyMatch, allMatch, noneMatch
| long / boolean |
| Find |
findFirst, findAny
|
Optional<T>
|
| Min / max |
min(cmp), max(cmp)
|
Optional<T>
|
| To array + |
toArray(), toList()
(Java 16+)
| array / immutable list |
Supplier <Stream<T>>
and call .get()
for a fresh stream.
findAny
can return any matching element (faster, no ordering guarantee) while findFirst
must respect encounter order.
→ Code: III · Terminal ops & reuse error
III. Code Samples (runnable, standard library only) #
Target: Java 17 (LTS). Single-file programs — compile with javac File.java
and run with java File. Java 11+ lets you skip compilation: java File.java.
Interface with static & default methods (↔ §1–3) #
Greeter.javajava Greeter.javainterface Greeter { // abstract: implementers MUST provide this String name(); // default: inherited + overridable, has a body default String greet() { return "Hello, " + name() + "!"; // can call abstract method } // static: belongs to the interface, NOT inherited static Greeter of(String n) { // factory helper return () -> n; // lambda implements name() } } public class GreeterDemo { public static void main(String[] a) { Greeter g = Greeter.of("Abhishek"); // static via Iface name System.out.println(g.greet()); // uses default method // override the default inline Greeter shout = new Greeter() { public String name() { return "World"; } public String greet() { return name().toUpperCase() + "!!"; } }; System.out.println(shout.greet()); } }$ java GreeterDemo.javaExpected output Hello, Abhishek! WORLD!!
Greeter.of(...)
only works through the interface name — g.of(...)
would not compile, proving static interface methods aren't inherited. The default greet()
is inherited automatically and freely overridden in the second instance.
Diamond resolution for default methods (↔ §3) #
Diamond.javajava Diamond.javainterface A { default String hi() { return "A"; } } interface B { default String hi() { return "B"; } } // A and B both define hi() -> compiler forces an override public class Diamond implements A, B { @Override public String hi() { return A.super.hi() + B.super.hi(); // explicit delegation } public static void main(String[] x) { System.out.println(new Diamond().hi()); } }$ java Diamond.javaExpected output AB
A.super.hi()
reaches the specific interface's default.
Custom functional interface (↔ §4) #
CalcDemo.javajava CalcDemo.java@FunctionalInterface // compile-time guard: exactly 1 abstract method interface Calc { int op(int a, int b); // the single abstract method (SAM) default Calc thenNegate() { return (a,b) -> -op(a,b); } // allowed static Calc add() { return (a,b) -> a + b; } // allowed } public class CalcDemo { public static void main(String[] x) { Calc sum = (a,b) -> a + b; // lambda fills the SAM Calc mul = (a,b) -> a * b; System.out.println(sum.op(3,4)); // 7 System.out.println(mul.op(3,4)); // 12 System.out.println(sum.thenNegate().op(3,4)); // -7 } }$ java CalcDemo.javaExpected output 7 12 -7
int op2(int a)
and the build fails: "Calc is not a functional interface… multiple non-overriding abstract methods."
That is @FunctionalInterface
doing its job.
Lambda forms (↔ §5) #
Lambdas.javajava Lambdas.javaimport java.util.function.*; public class Lambdas { public static void main(String[] x) { Supplier<String>noArg = () -> "hi"; // no params Function<Integer,Integer>one = n -> n * 2; // 1 param, no parens BiFunction<Integer,Integer,Integer>two = (a,b) -> a + b; Function<Integer,Integer>block = n -> { // block body int r = n * n; return r + 1; // braces require return }; int factor = 10; // effectively final -> capturable Function<Integer,Integer>cap = n -> n * factor; // factor = 11; // <-- uncommenting this breaks the capture (compile error) System.out.println(noArg.get()); System.out.println(one.apply(5)); System.out.println(two.apply(3,4)); System.out.println(block.apply(4)); System.out.println(cap.apply(3)); } }$ java Lambdas.javaExpected output hi 10 7 17 30
factor
must be effectively final because the lambda may outlive the method's stack frame; Java captures a copy of the value, so allowing reassignment would create confusing inconsistency.
Method references (↔ §6) + #
MethodRefs.javajava MethodRefs.javaimport java.util.*; import java.util.function.*; public class MethodRefs { public static void main(String[] x) { Function<String,Integer>parse = Integer::parseInt; // static Consumer<String>print = System.out::println; // bound instance Function<String,String>upper = String::toUpperCase; // unbound instance Supplier<List<String>>make = ArrayList::new; // constructor print.accept("value = " + parse.apply("41")); print.accept(upper.apply("stream")); List<String>list = make.get(); list.add("created via ArrayList::new"); print.accept(list.toString()); } }$ java MethodRefs.javaExpected output value = 41 STREAM [created via ArrayList::new]
All six built-in functional interfaces (↔ §7) #
BuiltIns.javajava BuiltIns.javaimport java.util.function.*; import java.util.*; public class BuiltIns { public static void main(String[] x) { Supplier<String>sup = () -> "produced"; // () -> T Consumer<String>con = s -> System.out.println("got: " +s); // T -> () BiConsumer<String,Integer>bicon = (k,v) -> System.out.println(k+"=" +v); Function<String,Integer>fn = s -> s.length(); // T -> R BiFunction<Integer,Integer,Integer>bifn = (a,b) -> a+b; // T,U -> R Predicate<Integer>pred = n -> n >0; // T -> boolean con.accept(sup.get()); bicon.accept("age", 30); System.out.println("len = " + fn.apply("hello")); System.out.println("sum = " + bifn.apply(4,6)); System.out.println("5>0 ? " + pred.test(5)); // [+] default combinators Predicate<Integer>big = n -> n >100; System.out.println("pos AND big (50): " + pred.and(big).test(50)); Function<Integer,Integer>plus1 = n -> n+1, times2 = n -> n*2; System.out.println("andThen (3): " + plus1.andThen(times2).apply(3)); // (3+1)*2 } }$ java BuiltIns.javaExpected output got: produced age=30 len = 5 sum = 10 5>0 ? true pos AND big (50): false andThen (3): 8
Imperative vs declarative side-by-side (↔ §8) #
FindPeople.javajava FindPeople.javaimport java.util.*; import java.util.stream.*; public class FindPeople { record Person(String name, int age) {} // Java 16+ record public static void main(String[] x) { List<Person>people = List.of(new Person("Amy",15), new Person("Ben",22), new Person("Cara",19), new Person("Dan",40), new Person("Eve",17), new Person("Fay",30), new Person("Gus",25)); // ---- IMPERATIVE: you manage list, counter, flow ---- List<String>imp = new ArrayList<>(); int count = 0; for (Person p : people) { if (p.age() >18) { imp.add(p.name()); if (++count == 3) break; } } // ---- DECLARATIVE: state intent, library handles it ---- List<String>dec = people.stream().filter(p -> p.age() >18).limit(3).map(Person::name).collect(Collectors.toList()); System.out.println("imperative: " + imp); System.out.println("declarative: " + dec); } }$ java FindPeople.javaExpected output imperative: [Ben, Cara, Dan] declarative: [Ben, Cara, Dan]
limit(3)
short-circuits exactly like the imperative break
— thanks to lazy evaluation it never inspects Fay or Gus.
Full pipeline example (↔ §9) #
Pipeline.javajava Pipeline.javaimport java.util.*; import java.util.stream.*; public class Pipeline { public static void main(String[] x) { List<String>words = List.of("apple","banana","avocado","cherry","apricot"); String result = words.stream() // SOURCE .filter(w -> w.startsWith("a")) // intermediate .map(String::toUpperCase) // intermediate .sorted() // intermediate .collect(Collectors.joining(", ")); // TERMINAL System.out.println(result); } }$ java Pipeline.javaExpected output APPLE, APRICOT, AVOCADO
Stream creation (↔ §10) #
Create.javajava Create.javaimport java.util.*; import java.util.stream.*; public class Create { public static void main(String[] x) { Stream.of("a","b","c").forEach(System.out::print); // abc System.out.println(); int[] arr = {1,2,3}; System.out.println(Arrays.stream(arr).sum()); // 6 // infinite source + limit (REQUIRED or it runs forever) List<Integer>firstFive = Stream.iterate(0, n -> n + 2).limit(5).toList(); System.out.println(firstFive); // [0,2,4,6,8] System.out.println(IntStream.rangeClosed(1,5).sum()); // 15 System.out.println("abc".chars().count()); // 3 } }$ java Create.javaExpected output abc 6 [0, 2, 4, 6, 8] 15 3
Each intermediate op (↔ §11) #
Intermediate.javajava Intermediate.javaimport java.util.*; import java.util.stream.*; public class Intermediate { public static void main(String[] x) { List<Integer>n = List.of(5,3,5,8,1,3,9,2); System.out.println("filter even: " + n.stream().filter(i -> i%2 ==0).toList()); System.out.println("map *10: " + n.stream().map(i -> i*10).toList()); System.out.println("sorted: " + n.stream().sorted().toList()); System.out.println("distinct: " + n.stream().distinct().toList()); System.out.println("limit 3: " + n.stream().limit(3).toList()); System.out.println("skip 3: " + n.stream().skip(3).toList()); // peek: side-effect only, does NOT alter elements List<Integer>out = n.stream().filter(i -> i >3).peek(i -> System.out.println(" passed: " + i)).map(i -> i + 100).toList(); System.out.println("after peek: " + out); } }$ java Intermediate.javaExpected output filter even: [8, 2] map *10: [50, 30, 50, 80, 10, 30, 90, 20] sorted: [1, 2, 3, 3, 5, 5, 8, 9] distinct: [5, 3, 8, 1, 9, 2] limit 3: [5, 3, 5] skip 3: [8, 1, 3, 9, 2] passed: 5 passed: 5 passed: 8 passed: 9 after peek: [105, 105, 108, 109]
filter → peek → map
before the next starts — that vertical flow is why the "passed:" lines appear as elements stream past, not all at once up front.
Laziness & short-circuit (↔ §13) #
Lazy.javajava Lazy.javaimport java.util.stream.*; public class Lazy { public static void main(String[] x) { // 1) No terminal op -> nothing runs, peek never fires Stream.of(1,2,3).peek(i -> System.out.println("seen " +i)); System.out.println("(nothing printed above this line)"); // 2) Infinite stream made finite by short-circuiting limit String r = Stream.iterate(1, i -> i + 1).peek(i -> System.out.println("checking " +i)).filter(i -> i % 3 == 0).limit(2) // stops after 2 matches .map(String::valueOf).collect(Collectors.joining(",")); System.out.println("result = " + r); } }$ java Lazy.javaExpected output (nothing printed above this line) checking 1 checking 2 checking 3 checking 4 checking 5 checking 6 result = 3,6
limit(2)
is full and short-circuits — so 7, 8, … are never generated. Part 1 proves intermediate ops without a terminal do literally nothing.
Terminal ops & the reuse error (↔ §14) #
Terminal.javajava Terminal.javaimport java.util.*; import java.util.stream.*; import java.util.function.*; public class Terminal { public static void main(String[] x) { List<Integer>n = List.of(4,8,15,16,23,42); System.out.println("count >10: " + n.stream().filter(i -> i >10).count()); System.out.println("sum: " + n.stream().reduce(0, Integer::sum)); System.out.println("anyMatch >40: " + n.stream().anyMatch(i -> i >40)); Optional<Integer>first = n.stream().filter(i -> i >10).findFirst(); System.out.println("findFirst >10: " + first.get()); // THE single-use trap Stream<Integer>s = n.stream(); s.forEach(i -> {}); // 1st terminal: consumes the stream try { s.count(); // 2nd terminal on same stream -> boom } catch (IllegalStateException e) { System.out.println("reuse error: " + e.getMessage()); } // [+] fix: a Supplier hands you a FRESH stream each time Supplier<Stream<Integer>>fresh = n::stream; System.out.println("reuse via supplier: " + fresh.get().count() + " then " + fresh.get().max(Integer::compare).get()); } }$ java Terminal.javaExpected output count >10: 4 sum: 108 anyMatch >40: true findFirst >10: 15 reuse error: stream has already been operated upon or closed reuse via supplier: 6 then 42
IV. Hands-On Lab (practice) #
terminalbash# 1. Check you have Java 11+ (17 LTS recommended) java -version # expect: openjdk version "17.x" or higher # 2. Make a working folder mkdir java8-lab && cd java8-lab # 3. Single-file run (Java 11+): no separate compile step java Exercise1.java # 3b. Classic two-step (any version): javac Exercise1.java # produces Exercise1.class java Exercise1 # run (no .java extension)
Each exercise is one self-contained .java
file with a public class
matching the filename. Predict the output before
running.
Default + static interface methods#
Goal:
Build an interface Shape
with an abstract area(), a default describe(), and a static
factory for a unit square.
-
Declare
interface Shape { double area(); }. -
Add
default String describe()returning"area=" + area(). -
Add
static Shape unitSquare()returning a lambda whose area is 1. -
In
main, create a circle (radius 2) as a lambda and print both shapes'describe().
Shape.unitSquare().describe()
print? Could you call circle.unitSquare()
through the instance? (Try it — note the compile error.)
Solution
Exercise1.javajava Exercise1.javainterface Shape { double area(); // abstract SAM default String describe() { return "area=" + area(); } static Shape unitSquare() { return () -> 1.0; } // factory } public class Exercise1 { public static void main(String[] a) { double r = 2; Shape circle = () -> Math.PI * r * r; // captures r (effectively final) System.out.println(circle.describe()); System.out.println(Shape.unitSquare().describe()); // static via Iface name // circle.unitSquare(); // would NOT compile: static not inherited } }Expected output area=12.566370614359172 area=1.0
Pick the right built-in functional interface#
Goal:
Wire up one each of Supplier, Predicate, Function, and Consumer
to process a single number.
-
Supplier<Integer>that returns 7. -
Predicate<Integer>that tests "is odd". -
Function<Integer,String>that maps a number to"odd"/"even"using the predicate. -
Consumer<String>that prints the label. Chain them inmain.
get
/test
/apply
/accept
). Mixing them up is the classic beginner slip.
Solution
Exercise2.javajava Exercise2.javaimport java.util.function.*; public class Exercise2 { public static void main(String[] a) { Supplier<Integer>src = () -> 7; Predicate<Integer>odd = n -> n % 2 != 0; Function<Integer,String>label = n -> odd.test(n) ? "odd": "even"; Consumer<String>out = System.out::println; int v = src.get(); // 7 out.accept("value " + v + " is " + label.apply(v)); } }Expected output value 7 is odd
Build a stream pipeline#
Goal: From a list of words, get the distinct lengths of words longer than 3 chars, sorted descending.
-
Start with
List.of("sky","tree","ocean","sun","river","tree"). -
filterlength >3,mapto length,distinct,sortedreversed. -
collectto a list and print.
Solution
Exercise3.javajava Exercise3.javaimport java.util.*; import java.util.stream.*; public class Exercise3 { public static void main(String[] a) { List<String>w = List.of("sky","tree","ocean","sun","river","tree"); List<Integer>lengths = w.stream().filter(s -> s.length() >3) // shrink first .map(String::length) // 4,5,5,4 .distinct() // 4,5 .sorted(Comparator.reverseOrder()) // 5,4 .collect(Collectors.toList()); System.out.println(lengths); } }Expected output [5, 4]
Prove lazy evaluation & short-circuiting#
Goal:
Use peek
to show that only the elements needed by findFirst
are processed.
-
Stream
1..1_000_000viaIntStream.rangeClosedboxed. -
peekto print each element examined. -
filterfor the first multiple of 7 that is also >10. -
findFirstand print the result.
Solution
Exercise4.javajava Exercise4.javaimport java.util.stream.*; public class Exercise4 { public static void main(String[] a) { int result = IntStream.rangeClosed(1, 1_000_000).boxed().peek(i -> System.out.println("examined " + i)).filter(i -> i >10 && i % 7 == 0).findFirst().get(); System.out.println("first = " + result); } }Expected output examined 1 examined 2 examined 3 examined 4 examined 5 examined 6 examined 7 examined 8 examined 9 examined 10 examined 11 examined 12 examined 13 examined 14 first = 14
Only 14 elements are examined out of a million — findFirst
short-circuits the instant the filter matches 14 (first int >10 divisible by 7).
Reproduce the single-use exception, then fix it#
Goal:
Trigger IllegalStateException
by reusing a stream, catch it, then refactor to a reusable design.
-
Assign
words.stream()to a variable, runcount(), then tryforEachon the same variable. - Catch the exception and print its message.
-
Refactor: wrap stream creation in a
Supplier <Stream<String>>and run two terminals successfully.
Supplier
version avoid it? (Hint: each .get()
returns a brand-new stream.)
Solution
Exercise5.javajava Exercise5.javaimport java.util.*; import java.util.stream.*; import java.util.function.*; public class Exercise5 { public static void main(String[] a) { List<String>words = List.of("a","bb","ccc"); Stream<String>s = words.stream(); System.out.println("count = " + s.count()); // consumes s try { s.forEach(System.out::println); // reuse -> throws } catch (IllegalStateException e) { System.out.println("caught: " + e.getMessage()); } // FIX: a fresh stream per use Supplier<Stream<String>>src = words::stream; System.out.println("len sum = " + src.get().mapToInt(String::length).sum()); System.out.println("longest = " + src.get().max(Comparator.comparingInt(String::length)).get()); } }Expected output count = 3 caught: stream has already been operated upon or closed len sum = 6 longest = ccc
Imperative → declarative refactor with grouping#
Goal: Convert a loop that groups names by first letter into a one-line declarative pipeline, and confirm both produce the same map.
-
Names:
List.of("Amy","Ann","Bob","Cara","Carl","Bea"). -
Imperative:
loop, build a
Map <Character,List<String>>withcomputeIfAbsent. -
Declarative:
reproduce it with
Collectors.groupingBy. - Print both maps and verify they're equal.
Solution
Exercise6.javajava Exercise6.javaimport java.util.*; import java.util.stream.*; public class Exercise6 { public static void main(String[] a) { List<String>names = List.of("Amy","Ann","Bob","Cara","Carl","Bea"); // imperative Map<Character,List<String>>imp = new TreeMap<>(); for (String n : names) imp.computeIfAbsent(n.charAt(0), k -> new ArrayList<>()).add(n); // declarative — one call replaces the whole loop Map<Character,List<String>>dec = names.stream().collect(Collectors.groupingBy(n -> n.charAt(0), TreeMap::new, Collectors.toList())); System.out.println("imperative: " + imp); System.out.println("declarative: " + dec); System.out.println("equal? " + imp.equals(dec)); } }Expected output imperative: {A=[Amy, Ann], B=[Bob, Bea], C=[Cara, Carl]} declarative: {A=[Amy, Ann], B=[Bob, Bea], C=[Cara, Carl]} equal? true
groupingBy
with a map-factory + downstream collector is the declarative equivalent of computeIfAbsent
in a loop. TreeMap::new
keeps keys sorted so the two maps match exactly.
-
Interfaces:
I can say, without looking, why default methods exist (backward-compatible evolution) and why
Iface.staticMethod()is the only way to call a static interface method. -
Functional interfaces:
I can name the SAM rule and explain what
@FunctionalInterfacedoes (and doesn't) do. - Lambdas: I can write all five syntax forms and explain the effectively-final capture rule.
- Built-ins: Given a use-case, I instantly pick Supplier / Consumer / Function / Predicate (+ Bi-/Unary-/Binary- variants) and call the right method (get/accept/apply/test).
- Streams: I can identify source / intermediate / terminal in any pipeline and order ops for performance (filter→map→distinct→sorted→limit).
-
Laziness:
I can predict that intermediate ops don't run without a terminal, and that
limit/findFirstshort-circuit infinite or huge sources. -
Single-use:
I can reproduce the
IllegalStateExceptionon purpose and fix it with aSupplier<Stream>. -
Stretch:
I refactored Exercise 6 from a loop to
groupingBywith no peeking at the solution.
★ Key Takeaways #
- Default & static interface methods exist for one reason: evolve interfaces without breaking implementers. Default = inherited & overridable; static = interface-only, not inherited.
-
A functional interface has exactly one abstract method.
@FunctionalInterfaceis an optional compile-time guard, not a requirement. -
A lambda is an instance of a functional interface
—
(params) ->body— and captures only effectively-final locals. Method references are the cleaner shorthand. + - Memorize the six built-ins by shape: Supplier (()→T), Consumer (T→()), BiConsumer (T,U→()), Function (T→R), BiFunction (T,U→R), Predicate (T→boolean).
- Declarative beats imperative for readability and parallelism: say what, not how.
- A stream is a lazy pipeline, not a data store: source → intermediate ops (lazy, return streams) → one terminal op (eager, ends the stream).
- Order ops to shrink early: filter → map → distinct → sorted → limit/skip.
-
Lazy evaluation enables short-circuiting:
nothing runs until the terminal op;
limit/findFirst/anyMatchcan stop early, even over infinite sources. -
Streams are single-use:
a second terminal op throws
IllegalStateException; recreate the stream (aSupplier<Stream>helps). +
End of notes · Java 8 interfaces, functional interfaces, lambdas & the Stream API. + marks my own additions and corrections.
Choose your route#
Java 8 turns behavior into values. Build a readable refactor in Learn deeply, or rehearse when a lambda clarifies the code and when it hides intent.
Shared core#
A functional interface is a contract with one abstract operation. Lambdas implement that contract; method references make an existing operation readable. The choice is about expressing intent, not removing every loop.
Learn deeply: refactor with evidence#
Refactor a filtering loop into a named predicate, then compare the two versions with a test and keep the clearer one.
Prepare for interviews: explain the choice#
Explain lambda capture, readability, and one case where a loop is the better answer. Handle a follow-up about checked exceptions.