TD-Modern — Modern Java: Streams, Records & Lambdas
S7 Inf A3 — Java for Graphical and Mobile Programming
Stéphane Derrode — Centrale Lyon
Part 1 — Java Fundamentals (Capstone)
Duration: 2h · No new project — refactor the code you already wrote
Objectives
This session is a refactoring lab: you will not build a new application. Instead, you revisit the classes from TD1–TD3 and the Polynomial bonus, and rewrite them with the modern Java features (Java 14–17) that make the code shorter, safer and closer to the Python idioms you already know.
By the end of this session you will be able to:
- Replace verbose immutable classes with
recordtypes — and recognise when a record is the wrong choice - Use the Streams API (
mapToDouble,sum,filter,count,max/min) as the Java counterpart of Python comprehensions andsum(...) - Return
Optional<T>instead ofnullto make "no result" explicit - Pass behaviour with lambdas and method references (
Car::getPower) - Replace
if/elseoperator dispatch with aswitchexpression - Combine all of the above in a streams-based Polynomial capstone
Before you start. Open your
td1Maven project (the one you kept growing through TD1–TD3, plus the Polynomial bonus). Everything below edits existing files or adds a handful of small ones in the same project. Java 17 is required —recordandswitchexpressions do not compile under Java 8.
Part 1 — Records for Immutable Data (30 min)
In Python you reach for a @dataclass (or a NamedTuple) when you just need to carry a few
values together. Java's answer since version 16 is the record: a one-line declaration
that generates a constructor, private final fields, accessors, equals, hashCode and
toString for you.
That single line is equivalent to ~40 lines of a classic class with getters and overrides.
1.1 Convert Monomial into a record
In the Polynomial bonus, Monomial stored an int degree (read-only) and a
double coefficient. A monomial is a pure value: two monomials with the same degree and
coefficient are the same monomial. That is exactly what records are for.
Create (or replace) src/main/java/com/s7infa3/poly/Monomial.java:
package com.s7infa3.poly;
public record Monomial(int degree, double coefficient) {
// Compact constructor — runs validation before the fields are assigned
public Monomial {
if (degree < 0)
throw new IllegalArgumentException("Negative degree: " + degree);
}
// A record may still have extra (non-accessor) methods
@Override
public String toString() {
if (coefficient == 0.0) return "";
if (degree == 0) return String.valueOf(coefficient);
if (degree == 1) return coefficient + " X";
return coefficient + " X^" + degree;
}
}
Notice what you get for free and no longer write by hand:
- the canonical constructor
Monomial(int degree, double coefficient) - the accessors — but note they are named
degree()andcoefficient(), notgetDegree()/getCoefficient() equals/hashCodeover both components- a default
toString(which we override here for the maths-friendly format)
The accessor name changes. A record accessor has the same name as the component:
m.degree()andm.coefficient(), with nogetprefix. Anywhere your old code calledm.getDegree(), change it tom.degree().
1.2 What we lost — and why that is good
The old Monomial had a mutating method addCoefficient(double value). A record's fields are
final: you cannot mutate them. Instead of changing a monomial in place, you create a new
one:
Monomial m = new Monomial(3, 3.4);
Monomial m2 = new Monomial(m.degree(), m.coefficient() + 4.7); // 8.1 X^3
This is the same shift you already know in Python between a mutable list and an immutable
tuple: immutable values are easier to reason about and safe to share.
1.3 A new value type: record CarSpec
Sometimes you want to talk about the kind of car (model + power) without all the moving
state of a real Car. That is a perfect immutable carrier.
Create src/main/java/com/s7infa3/CarSpec.java:
package com.s7infa3;
public record CarSpec(String model, int power) {
// Derived value: top speed for this spec, mirroring Car.maxSpeed()
public int maxSpeed() {
return 80 + power;
}
}
Build a real Car from a spec when you need behaviour:
CarSpec spec = new CarSpec("Clio", 90);
System.out.println(spec); // CarSpec[model=Clio, power=90]
System.out.println(spec.maxSpeed()); // 170
Car car = new Car(spec.model(), spec.power());
1.4 STOP — do not turn Car into a record
It is tempting to write record Car(String model, int power, boolean started, double speed).
Do not. Records are for immutable value carriers. Your Car has real mutable behaviour:
start()/stop()flipstartedaccelerate(delta)/brake(delta)changespeedspeedis capped bymaxSpeed()and floored at0
A record's components are final — you could never implement accelerate(), because you
cannot reassign speed. A Car changes over its lifetime; it is an entity with
identity and state, not a value. Keep Car as a normal class.
Use a record when… |
Keep a normal class when… |
|---|---|
| The object is an immutable bundle of values | The object has mutable internal state |
equals should compare all fields |
The object behaves / changes over time (start, accelerate) |
Examples: Monomial, CarSpec, a 2D Point |
Examples: Car, FleetManager, Garage |
Rule of thumb. If the object has a verb (
start,accelerate,register), it is an entity → normal class. If it is just a noun made of values (Monomial,CarSpec), it is a record.
Part 2 — Streams: Rewrite ShapeStats (30 min)
Recall ShapeStats from TD3 — a utility class full of explicit for loops that sum areas,
find the largest shape, count types. The Streams API expresses each of these as a single
pipeline, the way a Python comprehension or sum(...) would.
| Loop (TD3) | Stream (this lab) | Python analogue |
|---|---|---|
accumulate += |
.mapToDouble(...).sum() |
sum(s.area() for s in shapes) |
| track running max | .max(Comparator...) |
max(shapes, key=...) |
if (cond) n++ |
.filter(...).count() |
sum(1 for s in shapes if ...) |
2.1 Make "no result" honest with Optional
The old largest/smallest returned null for an empty list — and any caller that forgot to
check got a NullPointerException. The stream max/min operations return an
Optional<Shape> instead: a box that either contains a value or is explicitly empty. Make
that part of the method signature.
Replace src/main/java/com/s7infa3/shapes/ShapeStats.java:
package com.s7infa3.shapes;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
public class ShapeStats {
private ShapeStats() {} // utility class — no instances
public static double totalArea(List<Shape> shapes) {
// TODO: shapes.stream().mapToDouble(Shape::area).sum()
return 0;
}
public static double totalPerimeter(List<Shape> shapes) {
// TODO: same idea with Shape::perimeter
return 0;
}
public static Optional<Shape> largest(List<Shape> shapes) {
// TODO: shapes.stream().max(Comparator.comparingDouble(Shape::area))
return Optional.empty();
}
public static Optional<Shape> smallest(List<Shape> shapes) {
// TODO: same with .min(...)
return Optional.empty();
}
public static int countByType(List<Shape> shapes, Class<?> type) {
// TODO: filter with the method reference type::isInstance, then count
return 0;
}
// Bonus: group shapes by their simple class name → {"Circle": 2, "Rectangle": 1, ...}
public static Map<String, Long> countByClass(List<Shape> shapes) {
return shapes.stream()
.collect(Collectors.groupingBy(
s -> s.getClass().getSimpleName(),
Collectors.counting()));
}
}
Fill in each TODO. The four core bodies are one line each:
return shapes.stream().mapToDouble(Shape::area).sum();
return shapes.stream().mapToDouble(Shape::perimeter).sum();
return shapes.stream().max(Comparator.comparingDouble(Shape::area));
return shapes.stream().min(Comparator.comparingDouble(Shape::area));
return (int) shapes.stream().filter(type::isInstance).count();
Shape::areais a method reference — shorthand for the lambdas -> s.area().mapToDoubleproduces a primitiveDoubleStream, which is why.sum()exists and returns adouble.count()returns along, hence the(int)cast.
2.2 Test with Optional
Add to main() (reuse the List<Shape> shapes you built in TD3):
System.out.printf("Total area: %.2f%n", ShapeStats.totalArea(shapes));
System.out.printf("Total perimeter: %.2f%n", ShapeStats.totalPerimeter(shapes));
// Unwrap an Optional safely — never call .get() without checking
ShapeStats.largest(shapes)
.ifPresent(s -> System.out.println("Largest: " + s.describe()));
ShapeStats.smallest(shapes)
.ifPresent(s -> System.out.println("Smallest: " + s.describe()));
System.out.println("Circles: " + ShapeStats.countByType(shapes, Circle.class));
System.out.println("By class: " + ShapeStats.countByClass(shapes));
// The empty case is now explicit, not a crash:
List<Shape> none = new ArrayList<>();
System.out.println("Largest of empty: " + ShapeStats.largest(none)); // Optional.empty
Expected output (for the four TD3 shapes — circle r=3, rectangle 4×2, triangle 3-4-5, circle r=1.5):
Total area: 49.34
Total perimeter: 52.27
Largest: red Circle: area=28.27, perimeter=18.85
Smallest: green Triangle: area=6.00, perimeter=12.00
Circles: 2
By class: {Triangle=1, Rectangle=1, Circle=2}
Largest of empty: Optional.empty
Never call
.get()blindly.optional.get()on an emptyOptionalthrowsNoSuchElementException— exactly the crashOptionalwas meant to prevent. UseifPresent(...),orElse(default), ororElseThrow().
Part 3 — Streams & Method References in FleetManager (25 min)
FleetManager (TD2) wraps a Map<String, Car>. Its listAll() built a list of keys, sorted
it, then looped. Streams turn the whole thing into one readable pipeline, and let you add
collection-wide statistics for free.
3.1 Rewrite listAll()
Replace the body of listAll():
public void listAll() {
fleet.keySet().stream()
.sorted()
.forEach(owner -> System.out.println(owner + ": " + fleet.get(owner)));
}
sorted() uses the natural ordering of String (alphabetical) — no Collections.sort on a
temporary list needed.
3.2 Add stream-powered statistics
Add these methods to FleetManager. Each one streams over fleet.values() (the Car
objects) and uses method references to pull a property out of each car.
import java.util.Optional;
import java.util.OptionalDouble;
// Total horsepower across the whole fleet
public int totalPower() {
return fleet.values().stream()
.mapToInt(Car::getPower)
.sum();
}
// Average horsepower — empty when the fleet is empty
public OptionalDouble averagePower() {
return fleet.values().stream()
.mapToInt(Car::getPower)
.average();
}
// The most powerful car, or Optional.empty() if the fleet is empty
public Optional<Car> mostPowerful() {
return fleet.values().stream()
.max(Comparator.comparingInt(Car::getPower));
}
// How many cars are currently running (replaces the TD2 countStarted loop)
public long countStarted() {
return fleet.values().stream()
.filter(Car::isStarted)
.count();
}
Add the needed imports at the top of the file:
Car::getPowerandCar::isStartedare method references — the stream calls them on each car.filter(Car::isStarted)keeps only the cars whoseisStarted()returnstrue.
3.3 Test
FleetManager fm = new FleetManager();
fm.register("Alice", new Car("Clio", 90));
fm.register("Bob", new Car("Polo", 110));
fm.register("Charlie", new Car("Ferrari", 280));
fm.listAll();
// Alice: Car[Clio, 90hp, off, 0 km/h]
// Bob: Car[Polo, 110hp, off, 0 km/h]
// Charlie: Car[Ferrari, 280hp, off, 0 km/h]
System.out.println("Total power: " + fm.totalPower()); // 480
fm.averagePower().ifPresent(a -> System.out.printf("Avg power: %.1f%n", a)); // 160.0
fm.mostPowerful().ifPresent(c -> System.out.println("Strongest: " + c)); // Ferrari
fm.lookup("Bob").start();
fm.lookup("Charlie").start();
System.out.println("Running: " + fm.countStarted()); // 2
Expected output:
Alice: Car[Clio, 90hp, off, 0 km/h]
Bob: Car[Polo, 110hp, off, 0 km/h]
Charlie: Car[Ferrari, 280hp, off, 0 km/h]
Total power: 480
Avg power: 160.0
Strongest: Car[Ferrari, 280hp, off, 0 km/h]
Running: 2
Part 4 — The switch Expression for RPN (15 min)
In TD2 the RPN dispatch pushed a result inside each branch (an if/else chain, or a
switch statement). The modern, required form is a switch expression with arrow
labels: it returns a value, has no fall-through, and the compiler checks it is exhaustive.
4.1 Refactor the operator dispatch
Inside RPNCalculator.evaluate(...), after you pop the two operands, replace the dispatch
with a single switch expression that produces the result:
double b = stack.pop();
double a = stack.pop();
double result = switch (token) {
case "+" -> a + b;
case "-" -> a - b;
case "*" -> a * b;
case "%" -> a % b;
case "/" -> {
if (b == 0) throw new ArithmeticException("Division by zero");
yield a / b; // 'yield' returns the value of a block branch
}
default -> throw new IllegalArgumentException("Unknown operator: " + token);
};
stack.push(result);
Key points versus the old switch:
->instead of:— nobreak, no accidental fall-through.- The whole
switchis an expression: its value is assigned toresult. - A branch needing several statements uses a
{ ... }block andyieldto return its value. defaultmakes it exhaustive — an unknown token cannot slip through silently.
4.2 Test (unchanged behaviour)
RPNCalculator calc = new RPNCalculator();
System.out.println(calc.evaluate("3 4 +")); // 7.0
System.out.println(calc.evaluate("3 4 + 2 *")); // 14.0
System.out.println(calc.evaluate("5 1 2 + 4 * + 3 -")); // 14.0
System.out.println(calc.evaluate("10 2 /")); // 5.0
System.out.println(calc.evaluate("7 3 %")); // 1.0
try {
calc.evaluate("10 0 /");
} catch (ArithmeticException e) {
System.out.println("Caught: " + e.getMessage()); // Division by zero
}
Expected output:
Part 5 — Capstone: a Streams-based Polynomial (20 min)
Now combine everything. Build a small Polynomial backed by a List<Monomial> (your new
record) and implement its operations with streams instead of index loops.
Create src/main/java/com/s7infa3/poly/Polynomial.java:
package com.s7infa3.poly;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;
public class Polynomial {
private final List<Monomial> terms;
public Polynomial(List<Monomial> terms) {
// keep only non-zero terms; defensive copy so the caller can't mutate us
this.terms = terms.stream()
.filter(m -> m.coefficient() != 0.0)
.collect(Collectors.toList());
}
// evaluate P(x) — sum of coefficient * x^degree over all terms
public double evaluate(double x) {
// TODO: stream the terms, map each to coefficient * Math.pow(x, degree), sum
return 0;
}
// derivative: each a·x^n becomes (n*a)·x^(n-1); degree-0 terms vanish
public Polynomial derivative() {
List<Monomial> d = terms.stream()
.filter(m -> m.degree() >= 1)
.map(m -> new Monomial(m.degree() - 1,
m.degree() * m.coefficient()))
.collect(Collectors.toList());
return new Polynomial(d);
}
@Override
public String toString() {
if (terms.isEmpty()) return "0";
// highest degree first, joined with " + "
return terms.stream()
.sorted(Comparator.comparingInt(Monomial::degree).reversed())
.map(Monomial::toString)
.collect(Collectors.joining(" + "));
}
}
Implement the one TODO in evaluate:
public double evaluate(double x) {
return terms.stream()
.mapToDouble(m -> m.coefficient() * Math.pow(x, m.degree()))
.sum();
}
reduce/sumare Java'sfunctools.reduce.mapToDouble(...).sum()is a reduction: it folds the stream into onedouble. If you want to see the explicit form, the same result is...mapToDouble(...).reduce(0.0, Double::sum).
5.1 Test
import java.util.List;
import com.s7infa3.poly.*;
// P(x) = 11.4 - 1.45 x^2
Polynomial p = new Polynomial(List.of(
new Monomial(0, 11.4),
new Monomial(2, -1.45)
));
System.out.println(p); // -1.45 X^2 + 11.4
System.out.println(p.evaluate(1.0)); // 9.950000000000001 (floating-point!)
Polynomial dp = p.derivative();
System.out.println(dp); // -2.9 X
System.out.println(dp.evaluate(2.0)); // -5.8
// Zero polynomial: all-zero terms are filtered out
Polynomial zero = new Polynomial(List.of(new Monomial(0, 0.0)));
System.out.println(zero); // 0
Expected output:
Floating-point caveat: the trailing
…001is real — IEEE-754 sums are not exact (recall CM1's number rules). For a clean display, format it:System.out.printf("%.2f%n", p.evaluate(1.0))prints9.95.
Part 6 — Exploration (remaining time)
6.1 Add Polynomial add(Polynomial other). Concatenate the two term lists into one
stream, then collapse terms of equal degree with
Collectors.groupingBy(Monomial::degree, Collectors.summingDouble(Monomial::coefficient))
and rebuild a List<Monomial> from the resulting map.
6.2 Add List<CarSpec> specsAbove(int minPower) to FleetManager that streams the fleet,
filters by power, maps each Car to a new CarSpec(c.getModel(), c.getPower()), and collects
to a list. Note how a record makes a clean, immutable "view" of the data.
6.3 Rewrite FleetManager.saveToFile body so the line list is produced by a stream:
fleet.entrySet().stream().map(e -> e.getKey() + "," + e.getValue().getModel() + "," + e.getValue().getPower())
then write the collected lines. (Keep try-with-resources and the throws IOException.)
6.4 Records can override accessors and add static factory methods. Add a
static CarSpec of(Car c) factory to CarSpec returning new CarSpec(c.getModel(), c.getPower()).
Deliverable
No formal submission. Verify every "Expected output" block matches what your refactored code prints. The point of this lab is that behaviour is unchanged — only the code got shorter, safer and more idiomatic.
Keep this project: the same Car/FleetManager reappear in Part 2 (Swing UIs).
Common Errors
| Error | Cause | Fix |
|---|---|---|
cannot find symbol: method getDegree() |
Record accessor is degree(), not getDegree() |
Use the component name: m.degree(), m.coefficient() |
cannot assign a value to final variable |
Tried to mutate a record component (m.coefficient = ...) |
Records are immutable — build a new record instead |
NoSuchElementException from Optional.get() |
Called .get() on an empty Optional |
Use ifPresent, orElse, or orElseThrow |
incompatible types: long cannot be converted to int |
stream().count() returns long |
Cast with (int), or declare the method to return long |
error: not a statement near -> in switch |
Mixed case ... : and case ... -> styles |
Use arrow labels consistently; no break with -> |
missing return statement in a switch block |
A { ... } arrow branch never yields |
Add yield value; in every block branch |
int cannot be dereferenced on Car::getPower |
Used mapToDouble then expected int, or vice-versa |
mapToInt(Car::getPower) for int, mapToDouble(Shape::area) for double |
| Stream "does nothing" | Built a pipeline with no terminal operation | A stream is lazy — end it with sum, count, collect, forEach, max… |