Skip to content

TD-Modern — Modern Java: Streams, Records & Lambdas

← Part 1 Overview

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 record types — 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 and sum(...)
  • Return Optional<T> instead of null to make "no result" explicit
  • Pass behaviour with lambdas and method references (Car::getPower)
  • Replace if/else operator dispatch with a switch expression
  • Combine all of the above in a streams-based Polynomial capstone

Before you start. Open your td1 Maven 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 — record and switch expressions 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.

public record Point(int x, int y) {}

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() and coefficient(), not getDegree()/getCoefficient()
  • equals / hashCode over 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() and m.coefficient(), with no get prefix. Anywhere your old code called m.getDegree(), change it to m.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() flip started
  • accelerate(delta) / brake(delta) change speed
  • speed is capped by maxSpeed() and floored at 0

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::area is a method reference — shorthand for the lambda s -> s.area().
  • mapToDouble produces a primitive DoubleStream, which is why .sum() exists and returns a double.
  • count() returns a long, 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 empty Optional throws NoSuchElementException — exactly the crash Optional was meant to prevent. Use ifPresent(...), orElse(default), or orElseThrow().


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:

import java.util.Comparator;
import java.util.Optional;
import java.util.OptionalDouble;
  • Car::getPower and Car::isStarted are method references — the stream calls them on each car.
  • filter(Car::isStarted) keeps only the cars whose isStarted() returns true.

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 :no break, no accidental fall-through.
  • The whole switch is an expression: its value is assigned to result.
  • A branch needing several statements uses a { ... } block and yield to return its value.
  • default makes 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:

7.0
14.0
14.0
5.0
1.0
Caught: Division by zero

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 / sum are Java's functools.reduce. mapToDouble(...).sum() is a reduction: it folds the stream into one double. 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:

-1.45 X^2 + 11.4
9.950000000000001
-2.9 X
-5.8
0

Floating-point caveat: the trailing …001 is 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)) prints 9.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