Advanced

Java SE Advanced Language Features

Prerequisites: have prior experience working with Java. This course is not intended for beginners.

Level: Advanced — prerequisites: experience working with Java


Table of Contents

  1. Course Overview
  2. Records
  1. Sealed Classes and Interfaces
  1. Pattern Matching
  1. Advanced Classes and Interfaces
  1. Advanced Generics
  1. Lambda Expressions and Method References
  1. Annotations
  1. Optional
  1. Try-with-resources and AutoCloseable

1. Course Overview

This course is aimed at Java developers who want to become true experts. It covers advanced features of the Java SE 17 language, including:

  • The records for building a domain model
  • sealed classes and interfaces
  • The pattern matching
  • Advanced generics, including wildcards
  • lambda expressions and method references
  • Personalized annotations
  • The class Optional
  • The try-with-resources and the AutoCloseable interface

Prerequisites: have prior experience working with Java. This course is not intended for beginners.


2. Records

2.1 Course Introduction

Java is evolving rapidly. Oracle releases a new minor release every six months and a new major Long-Term Support (LTS) release every two to three years. The features covered in this course are relevant for any version of Java, even the most recent ones.

Overview of topics covered in this course:

  • Records — concise immutable data classes
  • Sealed classes and interfaces — fine-grained control of class hierarchies
  • Pattern matching — data extraction with instanceof and switch
  • Advanced Generics — type parameters, wildcards, type erasure
  • Lambda expressions and method references — functional programming
  • Annotations — metadata and runtime processing
  • Optional — safe alternative to null references
  • Try-with-resources — automatic resource management

2.2 Immutable data objects

An immutable object is an object whose state cannot be changed after its construction. The state of an object corresponds to the data contained in its fields.

Examples of immutable objects in the Java Standard Library:

  • String
  • Wrapper classes for primitive types (Integer, Long, etc.)
  • BigInteger, BigDecimal
  • Classes in the java.time package

Advantages of immutability:

  1. Simplicity — the code is easier to reason about because the contents of an object do not change arbitrarily.
  2. No defensive copies — no need to copy objects to protect against changes.
  3. Thread-safety — immutable objects are automatically thread-safe and do not need synchronization. This is crucial in multi-threaded programming, where synchronization is difficult to implement correctly.
  4. HashMap keys / HashSet elements — immutable objects are perfect as keys because their hashCode never changes.

2.3 Immutable classes and records

To create an immutable class in Java, you must:

  1. Declare private final fields
  2. Write a constructor initializing all fields
  3. Provide getter methods for each field
  4. Declare class final (to prevent mutable subclasses)
  5. Generate equals, hashCode and toString

Here is an example of an immutable class ProductCls and its record equivalent ProductRec:

// Classe immuable — verbose (60+ lignes)
public final class ProductCls {

    private final long id;
    private final String name;
    private final String description;

    public ProductCls(long id, String name, String description) {
        this.id = id;
        this.name = name;
        this.description = description;
    }

    public long getId()          { return id; }
    public String getName()      { return name; }
    public String getDescription() { return description; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        ProductCls that = (ProductCls) o;
        return id == that.id
            && Objects.equals(name, that.name)
            && Objects.equals(description, that.description);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id, name, description);
    }

    @Override
    public String toString() {
        return "ProductCls{id=" + id + ", name='" + name + "', description='" + description + "'}";
    }
}
// Record équivalent — 1 seule ligne !
public record ProductRec(long id, String name, String description) {
}

The Java compiler automatically generates for a record:

  • private final fields for each component
  • A canonical constructor
  • accessor methods (with the same name as the component, e.g. id(), name(), description()) — no get prefix
  • The equals, hashCode and toString methods

2.4 Create a record

Syntax of a record:

public record NomDuRecord(TypeComposant1 composant1, TypeComposant2 composant2, ...) {
    // corps optionnel : méthodes supplémentaires, constructeurs, etc.
}

Example with an additional method:

public record Product(long id, String name, String description) {

    // On peut ajouter des méthodes comme dans une classe normale.
    public boolean hasDescription() {
        return description != null && !description.isBlank();
    }
}

What the compiler generates (approximation):

// Le compilateur traduit le record Product en une classe immuable similaire à :
public final class Product_ /* extends java.lang.Record */ {

    private final long id;
    private final String name;
    private final String description;

    public Product_(long id, String name, String description) {
        this.id = id;
        this.name = name;
        this.description = description;
    }

    // Accessor methods (nommées comme les composants, pas comme les getters JavaBeans)
    public long id()          { return id; }
    public String name()      { return name; }
    public String description() { return description; }

    public boolean hasDescription() {
        return description != null && !description.isBlank();
    }

    @Override
    public boolean equals(Object o) {
        return o instanceof Product_ other
            && this.id == other.id
            && Objects.equals(this.name, other.name)
            && Objects.equals(this.description, other.description);
    }

    @Override
    public int hashCode() { return Objects.hash(id, name, description); }

    @Override
    public String toString() {
        return "Product[id=" + id + ", name=" + name + ", description=" + description + "]";
    }
}

Using a record:

var product = new Product(100123L, "Apples", "Tasty red apples");
System.out.println(product.id());          // 100123
System.out.println(product.name());        // Apples
System.out.println(product.hasDescription()); // true
System.out.println(product);              // Product[id=100123, name=Apples, description=Tasty red apples]

2.5 Overloading accessor methods

It is possible to override automatically generated accessor methods. It is recommended to use the @Override annotation.

public record Customer(long id, String name, String email) {

    // Surcharge de l'accessor method pour 'name'
    @Override
    public String name() {
        return name != null && !name.isBlank() ? name : "anonymous";
    }
}

Warning: The automatically generated toString method uses field values ​​directly, not accessor methods. If you override an accessor method, toString will still display the original value of the field.

Problem with overloading: The generated equals method also uses the field values ​​directly. This can cause an inconsistency if you create a copy of an object using overloaded accessor methods, because the copies will not be equal to the original objects according to equals.

2.6 Automatically generated methods (equals, hashCode, toString)

In addition to accessor methods, the compiler automatically generates:

  • toString() — displays the values of all components (uses fields directly, not accessor methods)
  • equals(Object o) — compares records by comparing the values of all components
  • hashCode() — calculates a hashCode from all components

You can override these methods if necessary, for example to only compare objects by ID:

public record Customer(long id, String name, String email) {

    @Override
    public boolean equals(Object o) {
        return o instanceof Customer other && this.id == other.id;
    }

    @Override
    public int hashCode() {
        return Long.hashCode(id);
    }
}

Invariant rule: When two objects are considered equal by equals, their hashCode must be identical.

2.7 The canonical constructor

The canonical constructor of a record is the constructor whose parameter list exactly matches the component list. By default, it simply initializes fields with the received arguments.

It is often useful to implement your own canonical constructor for:

  1. Validate the arguments
  2. Make defensive copies of mutable objects
public record Order(long id, Customer customer, LocalDateTime dateTime, List<OrderLine> lines) {

    // Implémentation du canonical constructor
    public Order(long id, Customer customer, LocalDateTime dateTime, List<OrderLine> lines) {
        // Validation des arguments
        checkThat(customer != null, "customer must not be null");
        checkThat(dateTime != null, "dateTime must not be null");
        checkThat(lines != null && !lines.isEmpty(), "lines must not be null or empty");

        // Initialisation obligatoire de tous les champs
        this.id = id;
        this.customer = customer;
        this.dateTime = dateTime;
        this.lines = List.copyOf(lines); // Copie défensive immuable
    }
}

2.8 The compact constructor

The compact constructor is a more concise syntax for defining the canonical constructor. It is distinguished by the absence of the parameter list (because it is redundant with the record definition).

In a compact constructor, it is not necessary (and impossible) to directly assign values ​​to fields — the compiler automatically generates field initialization with argument values ​​after running the compact constructor.

To make a defensive copy, simply reassign the value of the argument:

public record Order(long id, Customer customer, LocalDateTime dateTime, List<OrderLine> lines) {

    // Compact constructor — pas de liste de paramètres, pas d'assignments aux champs
    public Order {
        checkThat(customer != null, "customer must not be null");
        checkThat(dateTime != null, "dateTime must not be null");
        checkThat(lines != null && !lines.isEmpty(), "lines must not be null or empty");

        // Réassignation de l'argument 'lines' pour que le champ soit initialisé avec la copie
        lines = List.copyOf(lines);
    }
}

Validation examples with compact constructor:

public record Customer(long id, String name, String email) {

    public Customer {
        checkThat(name != null && !name.isBlank(), "name must not be null or blank");
        checkThat(email != null && !email.isBlank(), "email must not be null or blank");
    }
}
public record Product(long id, String name, String description) {

    public Product {
        checkThat(name != null && !name.isBlank(), "name must not be null or blank");
        // description peut être null ou vide
    }
}

Best practice: Prefer the compact constructor syntax when you want to implement the canonical constructor of a record. It’s more concise and more readable.

2.9 Additional constructors

It is possible to add additional constructors to a record. Special rule: The first statement of an additional constructor must be a call to this(...) (call to another constructor of the record).

This rule ensures that the canonical constructor is always called first, which ensures that all final fields are correctly initialized.

public record Product(long id, String name, String description) {

    // Compact constructor pour validation
    public Product {
        checkThat(name != null && !name.isBlank(), "name must not be null or blank");
    }

    // Constructeur supplémentaire sans description
    public Product(long id, String name) {
        this(id, name, null); // OBLIGATOIRE : appel au canonical constructor en premier
    }
}

Consequence: In an additional constructor, it is impossible to directly assign values ​​to final fields because they have already been initialized by the canonical constructor.

2.10 Record class hierarchy

  • Records are implicitly final — cannot subclass them.
  • Records cannot extend other classes (except java.lang.Record implicitly).
  • Records can implement interfaces (eg: Comparable).
  • The common superclass of all records is java.lang.Record, itself a subclass of java.lang.Object.
  • Class java.lang.Record redeclares equals, hashCode and toString as abstract methods — the compiler generates the implementations.

2.11 Practical use cases for records

Appropriate use cases for records:

Use casesExplanation
Domain objectsProducts, Customers, Orders, etc. in non-data-access layers
Value ObjectsRepresentation of values ​​with strong business significance
Data Transfer Objects (DTO)Data transport between layers
Query resultsResults returned from database

NOT appropriate use cases:

Use casesReason
JPA EntitiesJPA requires mutable classes, a constructor without arguments, and non-final fields
JavaBeansA record’s accessor methods do not follow the JavaBeans convention (getId vs id)

Example with specific domain types (Value Objects):

Problem with a “stringly typed” record:

// Problème : facile de confondre l'ordre des arguments
public record Customer1(long id, String firstName, String familyName, String nickName, String email) {

    private static final Pattern EMAIL_PATTERN = Pattern.compile(".+@.+\\..+");

    public Customer1 {
        checkThat(firstName != null && !firstName.isBlank(), "firstName must not be null or blank");
        checkThat(familyName != null && !familyName.isBlank(), "familyName must not be null or blank");
        checkThat(nickName != null && !nickName.isBlank(), "nickName must not be null or blank");
        checkThat(email != null && !email.isBlank(), "email must not be null or blank");
        checkThat(EMAIL_PATTERN.matcher(email).matches(), "not a valid email address: " + email);
    }
}

Solution with domain-specific types:

// Solution : types spécifiques pour chaque concept
public record FullName(String firstName, String familyName) { ... }
public record NickName(String value) { ... }
public record EmailAddress(String value) {
    private static final Pattern EMAIL_PATTERN = Pattern.compile(".+@.+\\..+");
    public EmailAddress {
        checkThat(value != null && !value.isBlank(), "email must not be null or blank");
        checkThat(EMAIL_PATTERN.matcher(value).matches(), "not a valid email address: " + value);
    }
}

// Le record Customer2 délègue la validation à chaque type spécifique
public record Customer2(long id, FullName fullName, NickName nickName, EmailAddress email) {
    public Customer2 {
        checkThat(fullName != null, "fullName must not be null");
        checkThat(nickName != null, "nickName must not be null");
        checkThat(email != null, "email must not be null");
    }
}

2.12 The Builder Pattern with records

When a record has many components, the Builder Pattern improves readability by explicitly naming each value.

public record Order(long id, Customer customer, LocalDateTime dateTime, List<OrderLine> lines) {

    // Classe Builder statique imbriquée
    public static class Builder {
        private long id;
        private Customer customer;
        private LocalDateTime dateTime;
        private final List<OrderLine> lines = new ArrayList<>();

        // Méthodes "fluent" retournant this pour le chaînage
        public Builder withId(long id) {
            this.id = id;
            return this;
        }

        public Builder forCustomer(Customer customer) {
            this.customer = customer;
            return this;
        }

        public Builder atDateTime(LocalDateTime dateTime) {
            this.dateTime = dateTime;
            return this;
        }

        public Builder addLine(OrderLine line) {
            this.lines.add(line);
            return this;
        }

        public Order build() {
            return new Order(id, customer, dateTime, lines);
        }
    }
}

Using the Builder:

var order = new Order.Builder()
    .withId(200201L)
    .forCustomer(customer)
    .atDateTime(LocalDateTime.now())
    .addLine(new SaleOrderLine(product, 6, new BigDecimal("5.94")))
    .addLine(new DiscountOrderLine("WELCOME", new BigDecimal("2.50")))
    .build();

2.13 Wither Methods for records

Since records are immutable, their fields cannot be modified. To obtain a modified version of a record, we create a copy with a different value for the desired field. wither methods make this operation easier.

public record OrderLine(Product product, int quantity, BigDecimal amount) {

    // Wither method pour la quantité
    public OrderLine withQuantity(int newQuantity) {
        return new OrderLine(product, newQuantity, amount);
    }

    // Wither method pour le prix
    public OrderLine withAmount(BigDecimal newAmount) {
        return new OrderLine(product, quantity, newAmount);
    }
}

Usage:

var originalLine = new OrderLine(apples, 3, new BigDecimal("5.94"));

// Création d'une copie avec une nouvelle quantité et un nouveau prix
var modifiedLine = originalLine
    .withQuantity(5)
    .withAmount(new BigDecimal("9.90"));

2.14 Records module summary

  • Records are a concise syntax for defining immutable data classes.
  • Components of a record have auto-generated accessor methods with the same name (no get prefix).
  • The compiler automatically generates the canonical constructor, equals, hashCode and toString.
  • The compact constructor is the preferred way to implement the canonical constructor.
  • Additional constructors must call another constructor first.
  • Records are implicitly final, cannot extend other classes, but can implement interfaces.
  • Records are great for domain objects, value objects and DTOs, but not for JPA entities.

3. Sealed Classes and Interfaces

3.1 Controlling class hierarchies

By default in Java, classes and interfaces can be extended or implemented by any accessible class. sealed classes and interfaces allow fine-grained control over who can extend or implement a type.

Before Java 17, options were limited:

  • Make class final (no subclass possible)
  • Make constructor package-private (subclasses only in same package)
  • For interfaces: no control possible

With sealed classes: you can specify exactly which classes are allowed to extend.

3.2 Rules for sealed classes and interfaces

Syntax of a sealed class:

public sealed class Shape permits Circle, Rectangle, Triangle {
    // ...
}

Rules for subclasses:

  • Must be direct subclasses (no intermediate level in the hierarchy)
  • Must have one of the following three modifiers:
  • final — cannot be subclassed further
  • sealed — itself a sealed class with its own permits clause
  • non-sealed — open for extension according to normal Java rules

Co-tenancy rule:

  • If a sealed class and all its subclasses are defined in the same source file, the permits clause is optional (can be omitted).

Rules for modules/packages:

  • With the Java Module System: the sealed class and its subclasses must be in the same module.
  • Without modules: the sealed class and its subclasses must be in the same package.

Sealed interfaces — same rules as sealed classes:

public sealed interface OrderLine permits SaleOrderLine, DiscountOrderLine {
}

3.3 Sealed Classes and Interfaces in practice

Sealed classes are not a commonly used feature. They are useful in specific cases:

  1. Modeling the grammar of a language or protocol — a fixed set of message types
  2. Contract guarantees — prevent subinterfaces from breaking guarantees (e.g. immutability in eclipse-collections)
  3. Algebraic Data Types — see next section

3.4 Algebraic Data Types with sealed interfaces and records

An Algebraic Data Type (ADT) represents a fixed and limited set of value types. This is a “type-level” version of an enum.

  • An enum models a fixed set of values (instances)
  • A sealed interface + records models a fixed set of types of values

Example: Order template with two types of order lines:

// Sealed interface définissant les types autorisés
public sealed interface OrderLine permits SaleOrderLine, DiscountOrderLine {
}
// Ligne de vente normale
public record SaleOrderLine(Product product, int quantity, BigDecimal amount) implements OrderLine {

    public SaleOrderLine {
        checkThat(product != null, "product must not be null");
        checkThat(quantity > 0, "quantity must be greater than zero");
        checkThat(amount.compareTo(BigDecimal.ZERO) > 0, "amount must be greater than zero");
    }
}
// Ligne de remise
public record DiscountOrderLine(String discountCode, BigDecimal amount) implements OrderLine {

    public DiscountOrderLine {
        checkThat(discountCode != null && !discountCode.isBlank(), "discountCode must not be null or blank");
        checkThat(amount.compareTo(BigDecimal.ZERO) > 0, "amount must be greater than zero");
    }
}

Use in business logic:

public class OrderService {

    public BigDecimal calculateTotalAmount(Order order) {
        var total = BigDecimal.ZERO;

        for (OrderLine line : order.lines()) {
            if (line instanceof SaleOrderLine) {
                SaleOrderLine sale = (SaleOrderLine) line; // typecast nécessaire (ancien style)
                total = total.add(sale.amount());
            } else if (line instanceof DiscountOrderLine) {
                DiscountOrderLine discount = (DiscountOrderLine) line;
                total = total.subtract(discount.amount());
            }
        }

        return total;
    }
}

Use with main method:

public class SealedExample01 {
    public static void main(String[] args) {
        var customer = new Customer(500567L, "Joe Smith", "joe.smith@example.com");
        var product = new Product(100123L, "Apples", "Tasty red apples");

        var order = new Order(200201L, customer, LocalDateTime.now(), List.of(
                new SaleOrderLine(product, 6, new BigDecimal("5.94")),
                new DiscountOrderLine("WELCOME", new BigDecimal("2.50"))));

        var orderService = new OrderService();
        var total = orderService.calculateTotalAmount(order);
        System.out.printf("Total amount: %s%n", total);
    }
}

3.5 Sealed Classes module summary

  • Sealed classes and interfaces provide precise control over class hierarchies.
  • The permits clause is required (unless all subclasses are in the same source file).
  • Subclasses must be direct and have the final, sealed or non-sealed modifier.
  • Useful in specific cases: protocol modeling, contract guarantees, algebraic data types.
  • Sealed interfaces combined with records allow defining ADTs — a “type-level” version of enums.

4. Pattern Matching

4.1 Introduction to pattern matching

pattern matching helps to work with data structures, in particular to extract specific elements. It improves the readability and conciseness of the code.

Three forms of pattern matching in Java 17:

  1. Pattern matching for instanceof
  2. Pattern matching for switch (statements and expressions)
  3. Record patterns — extracting data from record structures

4.2 Pattern Matching for instanceof

Before pattern matching (classic style):

// Ancien style — typecast explicite nécessaire
public BigDecimal calculateTotalAmount(Order order) {
    var total = BigDecimal.ZERO;

    for (OrderLine line : order.lines()) {
        if (line instanceof SaleOrderLine) {
            SaleOrderLine sale = (SaleOrderLine) line; // typecast redondant
            total = total.add(sale.amount());
        } else if (line instanceof DiscountOrderLine) {
            DiscountOrderLine discount = (DiscountOrderLine) line; // typecast redondant
            total = total.subtract(discount.amount());
        }
    }

    return total;
}

With pattern matching for instanceof:

// Nouveau style — pattern matching, plus de typecast
public BigDecimal calculateTotalAmount(Order order) {
    var total = BigDecimal.ZERO;

    for (OrderLine line : order.lines()) {
        // Le type pattern "SaleOrderLine sale" déclare et initialise la variable 'sale'
        if (line instanceof SaleOrderLine sale) {
            total = total.add(sale.amount());
        } else if (line instanceof DiscountOrderLine discount) {
            total = total.subtract(discount.amount());
        }
    }

    return total;
}

The type pattern SaleOrderLine sale does two things at once:

  1. Checks if the value is an instance of the required type
  2. Declares a new variable of this type, accessible if the test is true

4.3 Scope of pattern matching variables

The scope of a pattern matching variable is determined by the places in the code where the expression instanceof is true.

Example with scope after an if:

public BigDecimal calculateTotalDiscount(Order order) {
    var total = BigDecimal.ZERO;

    for (OrderLine line : order.lines()) {
        // Si la ligne n'est PAS un DiscountOrderLine, on saute cette itération
        if (!(line instanceof DiscountOrderLine discount)) {
            continue;
        }
        // Ici, on sait que 'line' est un DiscountOrderLine, donc 'discount' est dans la portée
        total = total.add(discount.amount());
    }

    return total;
}

4.4 Pattern Matching for switch

Pattern matching also works with switch statements and expressions. This allows replacing strings of if/else if with a more readable switch.

With switch statement:

public BigDecimal calculateTotalAmount(Order order) {
    var total = BigDecimal.ZERO;

    for (OrderLine line : order.lines()) {
        switch (line) {
            case SaleOrderLine sale -> total = total.add(sale.amount());
            case DiscountOrderLine discount -> total = total.subtract(discount.amount());
        }
    }

    return total;
}

With switch expression (best option) — exhaustiveness checking:

public BigDecimal calculateTotalAmount2(Order order) {
    var total = BigDecimal.ZERO;

    for (OrderLine line : order.lines()) {
        // Switch expression avec exhaustiveness checking automatique
        // Grâce à la sealed interface OrderLine, le compilateur vérifie que tous les types sont couverts
        var netAmount = switch (line) {
            case SaleOrderLine sale -> sale.amount();
            case DiscountOrderLine discount -> discount.amount().negate();
        };

        total = total.add(netAmount);
    }

    return total;
}

Advantage of the switch expression: exhaustiveness checking — if we add a new OrderLine type to the sealed interface, the code will no longer compile, forcing us to deal with the new case.

4.5 When clauses in patterns

You can add a when clause to a pattern to add additional conditions.

public String formatOrderLines(Order order) {
    var sb = new StringBuilder();

    for (OrderLine line : order.lines()) {
        var text = switch (line) {
            // Cas spécial : quantité == 1 (plus spécifique, doit venir en premier)
            case SaleOrderLine sale when sale.quantity() == 1 ->
                    String.format("%-20s %8s%n", sale.product().name(), sale.amount());
            // Cas général pour les autres SaleOrderLines
            case SaleOrderLine sale ->
                    String.format("%-14s (%3d) %8s%n", sale.product().name(), sale.quantity(), sale.amount());
            // DiscountOrderLine
            case DiscountOrderLine discount ->
                    String.format("Discount %-8s    %8s%n", discount.discountCode(), discount.amount().negate());
        };

        sb.append(text);
    }

    return sb.toString();
}

Important: The order of the boxes matters — the first box that matches is applied. More specific boxes (with when) must come before more general boxes.

4.6 Record Patterns

record patterns allow you to directly extract the components of a record into a pattern. They can be used with instanceof and switch.

With standard pattern matching:

var netAmount = switch (line) {
    case SaleOrderLine sale -> sale.amount();
    case DiscountOrderLine discount -> discount.amount().negate();
};

With record patterns — direct component extraction:

var netAmount = switch (line) {
    // Record pattern : déclare les variables product, quantity, amount directement
    case SaleOrderLine(var product, var quantity, var amount) -> amount;
    // Record pattern pour DiscountOrderLine
    case DiscountOrderLine(var discountCode, var amount) -> amount.negate();
};

Nested record patterns — the real power:

public String formatShippingAddress(Customer customer) {
    return switch (customer) {
        // Extraction imbriquée : les composants de Customer ET d'Address en une seule ligne
        case Customer(var id, var name, var email, Address(var street, var houseNumber, var city, var country)) ->
                String.format("%s%n%s %s%n%s%n%s%n", name, street, houseNumber, city, country);
    };
}

4.7 Pattern Matching module summary

  • Pattern matching with instanceof avoids unnecessary typecasts.
  • The scope of pattern matching variables is determined by where the expression is true.
  • Pattern matching in switch statements and expressions advantageously replaces if/else if strings.
  • switch expressions provide completeness checking — all cases must be covered.
  • when clauses allow you to add additional conditions to a case.
  • record patterns allow you to extract the components of a record directly into a pattern.
  • Nested record patterns allow you to extract data from a complex record structure in a concise manner.

5. Advanced Classes and Interfaces

5.1 Nested Types

A nested type is a type (class, interface, record or enum) defined inside another type or inside a method.

Categories of nested types:

TypeDescription
Static Nested ClassNon-static class inside another class
Inner ClassNon-static class (associated with an instance of the enclosing class)
Nested Interface/Record/EnumImplicitly static
Local TypeDefined inside a method or block
Anonymous ClassIn-place implementation of an interface or extension of a class

5.2 Static Nested Classes

A static nested class is a class defined inside another class with the static modifier.

  • It has access to static members (even private) of its enclosing class
  • She does NOT have access to instance members of the enclosing class
  • Enclosing class can access private members of nested class
public class Enclosing {

    private static int number = 23;
    private static LocalDate date = LocalDate.of(2023, 1, 1);
    private String name = "Joe Smith"; // membre d'instance

    private static void printNumber() { System.out.println(number); }
    private static void printDate()   { System.out.println(date); }
    public void printName()           { System.out.println(name); }

    static class Nested {
        // Variable qui "shadow" la variable 'date' de la classe englobante
        private LocalDate date = LocalDate.of(2011, 8, 9);

        private void run() {
            // Accès aux membres statiques de la classe englobante (même privés)
            System.out.println(number);
            printNumber();

            // ERREUR : impossible d'accéder aux membres d'instance
            // System.out.println(name);

            // 'date' réfère à la variable de la nested class (shadowing)
            System.out.println(date);

            // Pour accéder à la variable 'date' de la classe englobante :
            System.out.println(Enclosing.date);
        }

        // Méthode qui "shadow" printDate() de la classe englobante
        private void printDate() { System.out.println(date); }
    }
}

Creating an instance:

// Sans import
var nested = new Enclosing.Nested();

// Avec import (import com.example.Enclosing.Nested)
var nested = new Nested();

Typical use case: The Builder Pattern defined as a static class nested in the Order record.

5.3 Inner Classes

An inner class is a non-static class defined inside another class. An inner class instance exists in the context of an instance of its enclosing class — it has a hidden reference to that instance.

public class Enclosing {

    private String name = "Joe Smith";

    public void createInner() {
        // Dans une méthode d'instance, 'new Inner()' est équivalent à 'this.new Inner()'
        var inner = new Inner();
    }

    class Inner {
        private String name = "Susan Jones";

        void run() {
            // 'name' réfère à la variable de l'inner class (shadowing)
            System.out.println(name);          // Susan Jones
            // Pour accéder au membre de la classe englobante :
            System.out.println(Enclosing.this.name); // Joe Smith
        }
    }
}

Creating an instance from outside:

Enclosing enclosing = new Enclosing();
// Syntaxe spéciale : appel de new sur l'instance de la classe englobante
Enclosing.Inner inner = enclosing.new Inner();

5.4 Nested Interfaces, Records and Enums

Important rule: Nested interfaces, records and enums are implicitly static, whether or not you write the static keyword. There is no “inner interface”, “inner record” or “inner enum”.

Additionally, any type defined inside an interface is implicitly static — including classes.

5.5 Local Types

A local type is a type defined inside a method or block. It only exists in the scope where it is defined.

public void someMethod(int x, int y) {
    int number = 42;

    class Local {
        private void run() {
            // Accès aux paramètres et variables locales de la méthode englobante
            // Ces variables doivent être effectively final
            System.out.println(x);
            System.out.println(y);
            System.out.println(number);
        }
    }

    var local = new Local();
    local.run();
    
    // number++; // ERREUR : rendrait 'number' non-effectively-final
}

Rule: Local variables and enclosing method parameters accessed by a local type must be effectively final (behavior as if final, even without the explicit keyword).

5.6 Anonymous Classes

An anonymous class implements an interface or extends a class directly at the point of use, without a name.

var names = new ArrayList<>(List.of("Joe", "Susan", "John", "Louise"));

// Implémentation anonyme de Comparator<String>
names.sort(new Comparator<String>() {
    @Override
    public int compare(String first, String second) {
        return Integer.compare(first.length(), second.length());
    }
});

// Équivalent avec une lambda expression (beaucoup plus concis)
names.sort((first, second) -> Integer.compare(first.length(), second.length()));

// Encore mieux avec une method reference
names.sort(Comparator.comparingInt(String::length));

System.out.println(names); // [Joe, John, Susan, Louise]

The same rules as for local types apply: variables in the enclosing method must be effectively final.

5.7 Default, private and static methods in interfaces

Historical issue: If you add a method to an interface, all classes that implement it must implement the new method — binary mismatch.

Solution: Default methods — allow you to add methods with a default implementation in an interface without forcing implementers to implement them.

public interface Collection<E> {
    // Méthode abstraite normale
    boolean add(E element);
    
    // Default method — implémentation fournie dans l'interface
    default void forEach(Consumer<? super E> action) {
        for (E e : this) {
            action.accept(e);
        }
    }
}

Default methods rules:

  • If two interfaces provide default methods with the same signature, the class implementing both interfaces must override the method.
  • A class can always override a default method.

Private methods (Java 9+) — allow you to share code between several default methods in the same interface:

public interface MyInterface {
    default void publicMethod1() { privateHelper(); }
    default void publicMethod2() { privateHelper(); }
    
    private void privateHelper() {
        // Code commun
    }
}

Static methods in interfaces — interface-related utility methods:

public interface Validator<T> {
    boolean validate(T value);
    
    static <T> Validator<T> of(Predicate<T> predicate) {
        return predicate::test;
    }
}

5.8 Static Initializer Blocks

A static initializer block allows code to be executed during class initialization (only once). Useful when initializing a static member requires more than a simple expression — especially for handling checked exceptions.

public class AdvancedClassesExample05 {

    private static final Properties CONFIGURATION = new Properties();

    // Static initializer block
    static {
        // Cas d'usage typique : gérer les checked exceptions lors de l'initialisation
        try {
            CONFIGURATION.load(
                AdvancedClassesExample05.class.getResourceAsStream("/example.properties"));
        } catch (IOException e) {
            throw new RuntimeException("Error while loading configuration", e);
        }
    }

    public static void main(String[] args) {
        System.out.println(CONFIGURATION.getProperty("message"));
    }
}

Note: The static initializer block cannot throw checked exceptions — they must be caught and wrapped in a RuntimeException.

5.9 Instance Initializer Blocks

An instance initializer block is executed each time a new instance is created, before the constructor. It’s a block between { } directly in the class, without a keyword.

public class AdvancedClassesExample06 {

    private final byte[] randomBytes;

    // Instance initializer block
    {
        randomBytes = new byte[16];
        new Random().nextBytes(randomBytes);
    }

    public static void main(String[] args) {
        var obj = new AdvancedClassesExample06();
        for (byte b : obj.randomBytes) {
            System.out.printf("%02X ", b);
        }
        System.out.println();
    }
}

Note: In practice, instance initializer blocks are rarely used. Constructors are usually enough.

5.10 Advanced Classes module summary

  • Static nested class: packaged in another class, access to static members only.
  • Inner class: associated with an instance of the enclosing class, access to instance members.
  • Nested interfaces, records, enums: implicitly static.
  • Local types: defined in a method, access to effectively final variables of the method.
  • Anonymous classes: in-place implementation of an interface or class (often replaced by lambdas).
  • Default methods: allow the evolution of interfaces without breaking backward compatibility.
  • Static initializer blocks: initialization of static members with complex logic.
  • Instance initializer blocks: little used in practice (preferable constructors).

6. Advanced Generics

6.1 Overview of advanced generics

This module covers generics well beyond their use for collections:

  • Define your own generic types and methods
  • Bounded type parameters
  • Raw types (to be avoided)
  • Invariance of generics
  • Wildcards — often misunderstood feature
  • Type erasure — how the compiler handles generics
  • Heap pollution and its causes
  • Generics and arrays — incompatibility
  • Generics and varargs

6.2 Defining generic types

Example: generic binary tree

// Interface générique avec type parameter <T>
public interface TreeNode<T> {
    T getValue();
    TreeNode<T> getLeft();
    TreeNode<T> getRight();
}
// Classe générique : nœud interne (a deux enfants)
public class InnerNode<T> implements TreeNode<T> {

    private final TreeNode<T> left, right;

    public InnerNode(TreeNode<T> left, TreeNode<T> right) {
        this.left = left;
        this.right = right;
    }

    @Override
    public T getValue() { return null; }

    @Override
    public TreeNode<T> getLeft() { return left; }

    @Override
    public TreeNode<T> getRight() { return right; }

    @Override
    public String toString() { return String.format("{%s, %s}", left, right); }
}
// Classe générique : nœud feuille (pas d'enfants, a une valeur)
public class LeafNode<T> implements TreeNode<T> {

    private final T value;

    public LeafNode(T value) { this.value = value; }

    @Override
    public T getValue() { return value; }

    @Override
    public TreeNode<T> getLeft()  { return null; }

    @Override
    public TreeNode<T> getRight() { return null; }

    @Override
    public String toString() { return String.format("[%s]", value); }
}

Use with Integer:

// On "instancie" le type générique TreeNode<T> avec l'argument de type Integer
var two   = new LeafNode<Integer>(2);
var three = new LeafNode<Integer>(3);
var five  = new LeafNode<Integer>(5);
var tree  = new InnerNode<>(new LeafNode<>(2), new InnerNode<>(three, five));
System.out.println(tree); // {{[2], {[3], [5]}}}

6.3 Generic terminology

TermDefinitionExample
Generic typeType with type parametersTreeNode<T>, List<E>
Type parameterPlaceholder for a concrete type<T>, <E>
Parameterized typeGeneric type with concrete type argumentsTreeNode<Integer>, List<String>
Argument typeConcrete type passed during instantiationInteger, String
Instantiating a generic typeCreate a parameterized type by providing type argumentsnew LeafNode<Integer>(5)

Value/type analogy:

  • Method parameter ↔ Type parameter
  • Method argument ↔ Argument type
  • Method call ↔ Instantiation of a generic type

6.4 Defining generic methods

Methods (not just types) can have type parameters.

public record Pair<T, U>(T first, U second) {

    // Generic static factory method avec type parameters <V> et <W>
    public static <V, W> Pair<V, W> of(V first, W second) {
        return new Pair<>(first, second);
    }
}

Call with explicit arguments type:

Pair<String, Integer> pair = Pair.<String, Integer>of("hello", 42);

Call with inference type (recommended):

var pair = Pair.of("hello", 42); // types inférés automatiquement

6.5 Bounded Type Parameters

bounded type parameters restrict the types that can be used as type arguments.

Upper bound (extends):

// T doit implémenter Comparable<T>
public interface TreeNode<T extends Comparable<T>> {
    T getValue();
    TreeNode<T> getLeft();
    TreeNode<T> getRight();
}
// MaxValueInnerNode : T doit être borné car on utilise compareTo()
public class MaxValueInnerNode<T extends Comparable<T>> implements TreeNode<T> {

    private final TreeNode<T> left, right;

    public MaxValueInnerNode(TreeNode<T> left, TreeNode<T> right) {
        this.left = left;
        this.right = right;
    }

    @Override
    public T getValue() {
        T leftValue = left.getValue();
        T rightValue = right.getValue();
        // compareTo() est disponible car T extends Comparable<T>
        var result = leftValue.compareTo(rightValue);
        return result >= 0 ? leftValue : rightValue;
    }
    // ...
}

6.6 Multiple Type Parameter Bounds

A parameter type can have several limits separated by &:

public interface HasId   { long id(); }
public interface HasName { String name(); }

// Product implémente les deux interfaces
public record Product(long id, String name, String description)
        implements HasId, HasName {
    // Les accessor methods générées servent d'implémentation des interfaces
}
// Méthode générique avec plusieurs bounds : T doit implémenter HasId ET HasName
public static <T extends HasId & HasName> List<String> sortByIdAndExtractNames(List<T> items) {
    return items.stream()
        .sorted(Comparator.comparingLong(HasId::id))
        .map(HasName::name)
        .toList();
}

6.7 Raw Types

A raw type is a generic type used without type arguments. Absolutely avoid.

// Raw type — aucune type safety
List list = new ArrayList();
list.add("hello");
list.add(42);           // mélange de types — problème !
String s = (String) list.get(1); // ClassCastException à l'exécution !

Raw types only exist for backward compatibility with Java code prior to Java SE 5 (2004). Never use raw types in new code.

6.8 Generics and inheritance — Invariance

Generics are invariant: if Dog is a subtype of Animal, this does not mean that List<Dog> is a subtype of List<Animal>.

interface Animal {}
record Dog(String name) implements Animal {}
record Cat(String name) implements Animal {}

List<Dog> dogs = new ArrayList<>();
dogs.add(new Dog("Daisy"));

// ERREUR de compilation : List<Dog> n'est pas un sous-type de List<Animal>
// List<Animal> animals = dogs; // incompatible types

// Si cela avait été autorisé, on pourrait ajouter un Chat dans une liste de Chiens :
// animals.add(new Cat("Luna")); // évidemment faux !

Why invariance is necessary: To guarantee type-safety at compile time. If generics were covariant, we could add objects of the wrong type to a collection.

6.9 Wildcards

wildcards are used to designate a family of types:

WildcardMeaning
? (unbounded)All types
? extends T (upper bounded)T and all its subtypes
? super T (lower bounded)T and all its supertypes

A wildcard parameterized type is a parameterized type where a wildcard is used as an argument type:

List<? extends Animal> animals = dogs; // OK : Dog extends Animal
// animals.add(new Cat("Luna")); // ERREUR : type concret inconnu
// animals.add(new Dog("Max"));  // ERREUR aussi !

Important nuance: List<? extends Animal> is not “a list that can contain any animal”. It’s “a list of objects of a particular but unknown type that extends Animal”. We cannot therefore add anything because the exact type is unknown to the compiler.

6.10 Wildcard Capture

When we assign a value to a parameterized wildcard type variable, the compiler “captures” the concrete type:

List<? extends Animal> animals = dogs; // wildcard capture : ? est capturé comme Dog

// On peut lire des Animal depuis cette liste
Animal a = animals.get(0); // OK

// Mais pas écrire (le type exact est inconnu)
// animals.add(new Dog("Max")); // ERREUR

6.11 Use of wildcards in practice

Wildcards are useful in method signatures to avoid unnecessary restrictions.

Example: Collections.copy method

// Signature avec wildcards — flexible
public static <T> void copy(List<? super T> dest, List<? extends T> src)
  • List<? super T> for the destination — we can add elements of type T
  • List<? extends T> for source — we can read elements of type T

PECS (Producer Extends, Consumer Super) rule:

  • A list that produces (provides) values of type T → ? extends T
  • A list that consumes (receives) values of type T → ? super T

6.12 Wildcards in method signatures

Example: Stream.flatMap method

// Signature complexe de flatMap
<R> Stream<R> flatMap(Function<? super T, ? extends Stream<? extends R>> mapper)
  • T — type of input stream elements (Stream type parameter)
  • R — type of output stream elements (flatMap type parameter)
  • ? super T — the mapper can accept T or a super-type of T
  • ? extendsStream<? extends R> — the mapper returns a Stream of R or a subtype of R

6.13 Type Erasure

type erasure is the process by which the compiler processes generics. Generics are a compile-time only feature.

What the erasure type does:

  1. Replaces type parameters with Object (or their left bound if bounded)
  2. Remove type arguments in parameterized types
  3. Automatically inserts typecasts where needed

Before erasure type (Java source):

public interface TreeNode<T> {
    T getValue();
    TreeNode<T> getLeft();
    TreeNode<T> getRight();
}

public class LeafNode<T> implements TreeNode<T> {
    private final T value;
    public T getValue() { return value; }
    // ...
}

After erasure type (equivalent bytecode):

public interface TreeNode {
    Object getValue();
    TreeNode getLeft();
    TreeNode getRight();
}

public class LeafNode implements TreeNode {
    private final Object value;
    public Object getValue() { return value; }
    // ...
}

6.14 Limitations due to Erasure Type

LimitationReason
No primitive types like type arguments (List<int>)Type erasure replaces T with Object, and primitives are not objects
Unable to do new T()No information about T at runtime
Cannot use instanceof with a non-reifiable type (x instanceof List<String>)Type argument removed at runtime
Unable to create an array of parameterized type (new T[n], new List<String>[n])Incompatibility between covariant arrays and invariant generics

Workaround for new T() — use the Reflection API:

public <T> T createInstance(Class<T> clazz) throws Exception {
    return clazz.getDeclaredConstructor().newInstance();
}

6.15 Heap Pollution

heap pollution occurs when a variable of a parameterized type references an object of the wrong type. Main causes:

  1. Mixture of raw types and parameterized types
  2. Unchecked casts
List<Dog> dogs = new ArrayList<>();
List rawList = dogs;            // assignation raw type — unchecked warning
List<Cat> cats = rawList;       // heap pollution : une liste de Chats contient des Chiens !

// Tout semble OK jusqu'ici...
System.out.println(cats);       // imprime [Dog[name=Daisy]] — bizarre mais pas d'erreur

Cat cat = cats.get(0);          // ClassCastException à l'exécution !

Solution: Never use raw types.

6.16 Generics and Arrays

Arrays and generics do not mix well in Java:

  1. Arrays are covariantDog[] is a subtype of Animal[]
  2. Generics are invariantList<Dog> is not a subtype of List<Animal>

This inconsistency is causing problems:

// Arrays covariants — dangereux !
Animal[] animals = new Dog[3]; // OK car arrays covariants
animals[0] = new Cat("Luna");  // ArrayStoreException à l'EXÉCUTION (pas à la compilation)

// Generics invariants — sûr à la compilation
// List<Animal> animalList = new ArrayList<Dog>(); // ERREUR DE COMPILATION (safe)

Creation of arrays of generic types — not allowed:

Pair<Integer, String>[] pairs; // déclaration OK
// pairs = new Pair<Integer, String>[10]; // ERREUR : generic array creation

6.17 Generics and Variable Arguments (varargs)

varargs are syntactic sugar for arrays. Combining generics and varargs can create heap pollution.

public static void printLines(String... lines) {
    // 'lines' est traité comme un String[] en interne
    for (String line : lines) {
        System.out.println(line);
    }
}

// Appel avec n'importe quel nombre d'arguments
printLines("hello", "world", "foo");

Problem with generics and varargs:

// Méthode générique avec varargs de non-reifiable type
@SafeVarargs // Annotation nécessaire pour supprimer le warning
public static <T, U> void putAll(Map<T, U> map, Pair<T, U>... pairs) {
    for (var pair : pairs) {
        map.put(pair.first(), pair.second());
    }
}

The @SafeVarargs annotation can be used to indicate that the method does not do anything unsafe with the varargs parameter. It removes the compiler warning.

6.18 Advanced Generics module summary

  • Generic type: type with type parameters; Parameterized type: generic type with concrete arguments type
  • bounded type parameters (extends) restrict the allowed types
  • Raw types: never use in new code
  • Generics are invariantList<Dog> is not a List<Animal>
  • wildcards (?, ? extends T, ? super T) allow more flexibility in method signatures
  • type erasure removes type information at compile time — this imposes limitations
  • arrays are covariant, generics invariant — fundamental incompatibility
  • @SafeVarargs allows you to suppress warnings for generic methods with varargs

7. Lambda Expressions and Method References

7.1 Reminder of lambda expressions

A lambda expression is an anonymous method that is defined in place and usually passed to another method.

Comparison anonymous class vs lambda expression:

var names = new ArrayList<>(List.of("Joe", "Susan", "John", "Louise"));

// Anonymous class — verbeux (6 lignes pour 1 ligne de logique)
names.sort(new Comparator<String>() {
    @Override
    public int compare(String first, String second) {
        return Integer.compare(first.length(), second.length());
    }
});

// Lambda expression — concis (1 ligne)
names.sort((first, second) -> Integer.compare(first.length(), second.length()));

System.out.println(names); // [Joe, John, Susan, Louise]

Syntax of lambda expressions:

// Avec types explicites
(String first, String second) -> Integer.compare(first.length(), second.length())

// Avec inférence de types
(first, second) -> Integer.compare(first.length(), second.length())

// Corps en bloc (plusieurs instructions)
(first, second) -> {
    int diff = first.length() - second.length();
    return Integer.compare(diff, 0);
}

// Avec un seul paramètre (parenthèses optionnelles)
name -> name.toUpperCase()

7.2 Functional Interfaces

A functional interface is an interface with exactly one abstract method.

  • Lambda expressions implement functional interfaces.
  • The equals(Object) method inherited from Object does not count as an abstract method.
  • The @FunctionalInterface annotation allows the compiler to verify that an interface is indeed functional.
@FunctionalInterface
public interface MyTransformer<T, R> {
    R transform(T input);
    // On ne peut pas ajouter d'autres méthodes abstraites
}

Examples of standard functional interfaces:

  • Comparator<T> — a single abstract compare method
  • Runnable — a single abstract run method
  • Callable<V> — a single abstract call method

7.3 Standard Functional Interfaces (java.util.function)

The java.util.function package contains commonly used functional interfaces:

InterfaceSignatureDescription
Function<T, R>R apply(T t)Function with an input and an output
Consumer<T>void accept(T t)Function with an input, without output
Beg<T>T get()Function without input, with an output
Runnablevoid run()Function without input or output (in java.lang)
BiFunction<T, U, R>R apply(T t, U u)Function with two inputs and one output
BiConsumer<T, U>void accept(T t, U u)Function with two inputs, no output
Predicate<T>boolean test(T t)Function returning a boolean
BiPredicate<T, U>boolean test(T t, U u)Predicate with two inputs
UnaryOperator<T>T apply(T t)Specialization of Function<T, T>
BinaryOperator<T>T apply(T t1, T t2)Specialization of BiFunction<T, T, T>

Specialized versions for primitive types:

  • IntFunction<R>, LongFunction<R>, DoubleFunction<R>
  • IntConsumer, LongConsumer, DoubleConsumer
  • IntSupplier, LongSupplier, DoubleSupplier
  • IntPredicate, LongPredicate, DoublePredicate

7.4 Capturing local variables in lambdas

Lambda expressions can access local variables of their enclosing method, but those variables must be final or effectively final.

var names = List.of("Joe Smith", "Susan Miller", "Will Johnson");

// Pour-loop normal — le compteur peut être modifié
var count = 0;
for (String name : names) {
    System.out.println(++count + ": " + name);
}

// ERREUR : la lambda ne peut pas capturer une variable modifiée
// names.forEach(name -> System.out.println(++count + ": " + name));
// Erreur : local variables referenced from a lambda expression must be final or effectively final

Reason: Unlike objects, local variables only live on the stack. If the lambda survives the method (e.g. passed to another thread), the local variable no longer exists. Java copies the value into the lambda, but only if it is final (consistent copy).

7.5 Functional programming with lambda expressions

Principle: no side effects in lambda expressions

A side effect is any modification of state external to the lambda.

var names = List.of("Joe Smith", "Susan Miller", "Will Johnson");

// ANTIPATTERNE : effet de bord — modification de result2 depuis la lambda
var result2 = new ArrayList<String>();
names.forEach(name -> result2.add(name.toUpperCase())); // effet de bord !

// BONNE PRATIQUE : style fonctionnel avec stream — pas d'effet de bord
var result3 = names.stream()
    .map(name -> name.toUpperCase())
    .toList();

// Encore mieux avec une method reference
var result4 = names.stream()
    .map(String::toUpperCase)
    .toList();

7.6 Handling Checked Exceptions in lambdas

Checked exceptions can be difficult to handle in lambdas because standard functional interfaces (Consumer, Function, etc.) do not declare a throws clause.

var names = List.of("Joe Smith", "Susan Miller", "Will Johnson");

try (FileWriter out = new FileWriter("names.txt", StandardCharsets.UTF_8)) {
    // For-loop simple — IOException gérée naturellement
    for (String name : names) {
        out.write(name + "\n");
    }

    // Avec forEach + lambda — forcé de catcher l'exception à l'intérieur
    names.forEach(name -> {
        try {
            out.write(name + "\n");
        } catch (IOException e) {
            throw new RuntimeException(e); // doit encapsuler l'exception
        }
    });
} catch (IOException | RuntimeException e) {
    System.err.println("An error occurred: " + e.getMessage());
}

Tip: When working with operations that throw checked exceptions, a classic for is often clearer than a lambda.

7.7 Method References

A method reference implements a functional interface by pointing to an existing method. Syntax: ClassName::methodName or instance::methodName.

Four types of method references:

TypeSyntaxExample
Static methodClassName::staticMethodLambdasExample06::isExpensive
Instance method on a particular objectobject::instanceMethodSystem.out::println
Instance method on typeClassName::instanceMethodProduct::price, BigDecimal::add
BuilderClassName::newTreeSet::new
var products = List.of(
    new Product(100123L, "Apples",  new BigDecimal("2.25")),
    new Product(100578L, "Oranges", new BigDecimal("4.09")),
    new Product(100393L, "Cheese",  new BigDecimal("6.99")),
    new Product(100346L, "Bread",   new BigDecimal("3.49")));

// Référence à méthode statique
var expensiveProducts = products.stream()
    .filter(LambdasExample06::isExpensive) // méthode statique
    .toList();

// Référence à méthode d'instance d'un objet particulier
expensiveProducts.forEach(System.out::println); // System.out est l'objet

// Référence à méthode d'instance sur le type
var totalPrice = products.stream()
    .map(Product::price)                  // instance method sur Product
    .reduce(BigDecimal.ZERO, BigDecimal::add); // instance method sur BigDecimal

// Référence à constructeur
var productNames = products.stream()
    .map(Product::name)
    .collect(Collectors.toCollection(TreeSet::new)); // constructeur de TreeSet

static boolean isExpensive(Product product) {
    return product.price().compareTo(new BigDecimal("4.00")) >= 0;
}

7.8 Lambda Expressions module summary

  • A lambda expression is an anonymous method passed to another method.
  • Lambdas implement functional interfaces (interfaces with a single abstract method).
  • The @FunctionalInterface annotation makes the compiler check that an interface is functional.
  • The java.util.function package contains the standard functional interfaces: Function, Consumer, Supplier, Predicate, BiFunction, etc.
  • Local variables captured by a lambda must be effectively final.
  • Lambdas should not have side effects — principle of functional programming.
  • method references (::) are concise syntax for lambdas that just call an existing method. There are four types.

8. Annotations

8.1 Annotation use cases

Annotations add metadata to Java code. They do not produce executable code on their own, but can be used in three categories:

  1. Additional compiler information: @Override, @Deprecated, @SuppressWarnings, @SafeVarargs, @FunctionalInterface

  2. Processing at compilation (annotation processors):

  • Lombok — boilerplate code generation
  • Immutables — generation of immutable classes
  • MapStruct — generation of mappers
  1. Runtime processing (Reflection API):
  • Spring Framework — dependency injection, etc.
  • Jakarta EE — annotations for EJBs, JPA, etc.
  • Jackson — JSON serialization/deserialization
  • JUnit — test definition and control

8.2 Declaration Annotations and Type Annotations

Declaration annotation — applies to a declaration (field, class, method, etc.):

// @NonNull appliqué à la déclaration de la variable
@NonNull String name;

Type annotation — applies to the type itself (Java 8+):

// @NonNull appliqué au type String (enrichissement du type system)
@NonNull String name; // signifie : le type est "NonNull String"

Type annotations are much less common and are used to enrich the type system (eg: null-safety checker).

8.3 Define custom annotation

Annotation definition syntax:

// Définition : similaire à une interface, mais avec @interface
public @interface Command {

    // Éléments de l'annotation (ressemblent à des méthodes)
    String value();                  // obligatoire (pas de default)
    String description() default ""; // optionnel (a une valeur default)
    int order() default 0;           // optionnel
}

Using annotation:

@Command(value = "login", description = "Log in to the system", order = 1)
public class LoginCommand implements CommandExecutor {
    // ...
}

// Si l'annotation n'a qu'un élément 'value', on peut l'omettre
@Command("logout")
public class LogoutCommand implements CommandExecutor {
    // ...
}

Repeatable annotation:

// Annotation conteneur (nécessaire pour @Repeatable)
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface Commands {
    Command[] value();
}

// Annotation principale avec @Repeatable
@Repeatable(Commands.class)
public @interface Command {
    String value();
    String description() default "";
    int order() default 0;
}

// Utilisation
@Command(value = "show", order = 1)
@Command(value = "list", order = 2)
public class ShowInventoryCommand {
    // ...
}

8.4 Use meta-annotations

Meta-annotations are annotations applied over other annotations. They are in the java.lang.annotation package.

Meta-annotationDescriptionValues ​​
@TargetSpecifies where the annotation can be usedTYPE, METHOD, FIELD, CONSTRUCTOR, PARAMETER, LOCAL_VARIABLE, ANNOTATION_TYPE, PACKAGE, MODULE
@RetentionDetermines when annotation is availableSOURCE (compiler only), CLASS (bytecode), RUNTIME (reflection)
@DocumentedIncluded in the Javadoc
@InheritedSubclasses inherit theannotation —

Full example with meta-annotations:

@Target(ElementType.TYPE)        // uniquement sur les classes/interfaces
@Retention(RetentionPolicy.RUNTIME) // disponible à l'exécution via réflexion
@Documented
@Inherited
@Repeatable(Commands.class)
public @interface Command {

    String value();
    String description() default "";
    int order() default 0;
}

8.5 Inspect annotations at runtime (Reflection API)

public class AnnotationsExample01 {

    public static void main(String[] args) throws IOException, ClassNotFoundException {
        // 1. Trouver toutes les classes dans un package
        Set<String> classNames = ClassUtils.findClassNames(
            "com.pluralsight.advancedjava.examples.example01.commands");

        // 2. Filtrer celles qui ont l'annotation @Command
        List<Class<?>> commandClasses = new ArrayList<>();
        for (String className : classNames) {
            Class<?> cls = Class.forName(className);
            Command annotation = cls.getAnnotation(Command.class);
            if (annotation != null) {
                System.out.printf("Found command: %s, order: %d%n",
                    annotation.value(), annotation.order());
                commandClasses.add(cls);
            }
        }

        // 3. Trier par l'élément 'order' de l'annotation
        commandClasses.sort(Comparator.comparingInt(
            cls -> cls.getAnnotation(Command.class).order()));

        // 4. Utilisation des commandes
        var sessionState = new SessionState();
        var scanner = new Scanner(System.in);

        while (!sessionState.isFinished()) {
            // Afficher le menu à partir des annotations
            for (Class<?> cls : commandClasses) {
                Command annotation = cls.getAnnotation(Command.class);
                System.out.printf("%-12s - %s%n",
                    annotation.value(), annotation.description());
            }

            // Lire la commande de l'utilisateur et l'exécuter
            var userInput = UserInput.readUserInput(scanner);
            for (Class<?> cls : commandClasses) {
                Command annotation = cls.getAnnotation(Command.class);
                if (annotation.value().equals(userInput.command())) {
                    var instance = (CommandExecutor) ClassUtils.newInstance(cls);
                    instance.execute(sessionState, userInput);
                    break;
                }
            }
        }
    }
}

8.6 Summary of the Annotations module

  • Annotations add metadata to Java code — they do not produce executable code.
  • Three usage categories: compiler information, compile-time processing, run-time processing.
  • Define an annotation: @interface syntax, elements like methods, default values.
  • Meta-annotations (@Target, @Retention, @Documented, @Inherited) define the behavior of an annotation.
  • Access to annotations at runtime via the Reflection API (Class.getAnnotation()).
  • Annotations annotated with @Repeatable can be applied multiple times to the same element.

9. Optional

9.1 Optional as an alternative to null

The class java.util.Optional<T> is a container that can either contain a value or be empty. It makes it explicit that a value may be absent.

Problem with null:

// On ne sait pas si null peut être retourné sans lire la documentation ou l'implémentation
public Customer findCustomer(long id) { ... }

With Optional:

// Le type de retour rend explicite que la valeur peut être absente
public Optional<Customer> findCustomer(long id) { ... }

Tony Hoare, the inventor of the null reference, called this idea his “billion-dollar mistake” because of the countless errors, vulnerabilities, and crashes it caused.

9.2 Using Optional in practice

Create an Optional:

// Optional avec une valeur
Optional<Product> opt1 = Optional.of(product);           // NullPointerException si null

// Optional depuis une valeur nullable
Optional<Product> opt2 = Optional.ofNullable(nullableProduct); // vide si null

// Optional vide
Optional<Product> opt3 = Optional.empty();

Check and access the value:

Optional<Product> optional = findProductById(269912L);

// isPresent() / isEmpty()
if (optional.isPresent()) {
    Product product = optional.get(); // NoSuchElementException si vide !
    System.out.println("Found: " + product);
} else {
    System.out.println("Not found");
}

// orElse() — valeur de remplacement
Product p1 = optional.orElse(new Product(0L, "Default", null));

// orElseGet() — valeur calculée uniquement si nécessaire (Supplier)
Product p2 = optional.orElseGet(() -> productRepository.getDefault());

// orElseThrow() — exception si vide
Product p3 = optional.orElseThrow(() -> new EntityNotFoundException("Product not found"));

Implementation of a method returning Optional:

// Méthode qui retourne null (style ancien)
private static Product getProductById(long productId) {
    for (Product product : Product.PRODUCTS) {
        if (product.id() == productId) return product;
    }
    return null;
}

// Méthode qui retourne Optional (style recommandé)
private static Optional<Product> findProductById(long productId) {
    for (Product product : Product.PRODUCTS) {
        if (product.id() == productId) return Optional.of(product);
    }
    return Optional.empty();
}

// Version concise avec Stream
private static Optional<Product> findProductById(long productId) {
    return Product.PRODUCTS.stream()
        .filter(product -> product.id() == productId)
        .findFirst(); // findFirst() retourne déjà un Optional
}

9.3 Functional programming with Optional

Optional<Product> optional = findProductById(269912L);

// ifPresent — Consumer appelé si la valeur est présente
optional.ifPresent(product -> System.out.println("Found: " + product));

// ifPresentOrElse — Consumer OU Runnable
optional.ifPresentOrElse(
    product -> System.out.println("Found: " + product),
    () -> System.out.println("Not found"));

// map — transforme la valeur si présente
String text = optional
    .map(Product::name)    // Optional<String>
    .orElse("Not found");

// filter — filtre la valeur selon un prédicat
Set<Long> discountedIds = Set.of(269912L, 404019L, 837481L);
String result = optional
    .filter(product -> discountedIds.contains(product.id()))
    .map(product -> "Discounted: " + product.name())
    .orElse("No discount");

// flatMap + Optional.stream() — filtrer les Optional vides dans un Stream
Set<Long> productIds = Set.of(485845L, 236839L, 100178L);
List<Product> products = productIds.stream()
    .map(OptionalExample02::findProductById) // Stream<Optional<Product>>
    .flatMap(Optional::stream)               // aplati en Stream<Product> (vides filtrés)
    .toList();

9.4 Optional module summary

  • Optional<T> is an immutable class primarily serving as a return type for methods.
  • Makes it explicit that a value may be absent, helping to avoid NullPointerException.
  • Creation methods: Optional.of(), Optional.ofNullable(), Optional.empty()
  • Access methods: get() (dangerous), isPresent(), isEmpty(), orElse(), orElseGet(), orElseThrow()
  • Functional methods: ifPresent(), ifPresentOrElse(), filter(), map(), flatMap()
  • Avoid: using Optional as a field type in serializable classes.

10. Try with resources and AutoCloseable

10.1 Resource and exception management

A resource is any object that must be closed after use (files, network connections, JDBC connections, etc.). Proper management of resources requires ensuring that they are always closed, even in the event of an exception.

Example without try-with-resources (verbose and fragile code):

public static void removeEmptyLines(String inputFileName, String outputFileName)
        throws IOException {

    BufferedReader in = null;
    BufferedWriter out = null;

    try {
        in = new BufferedReader(new FileReader(inputFileName, StandardCharsets.UTF_8));
        out = new BufferedWriter(new FileWriter(outputFileName, StandardCharsets.UTF_8));

        String line;
        while ((line = in.readLine()) != null) {
            if (!line.trim().isEmpty()) {
                out.write(line);
                out.write(System.lineSeparator());
            }
        }
    } finally {
        // Doit fermer 'out' en premier, puis 'in', même si la fermeture lance une exception
        if (out != null) {
            try { out.close(); }
            catch (IOException e) { System.err.println("Error closing output: " + e.getMessage()); }
        }
        if (in != null) {
            try { in.close(); }
            catch (IOException e) { System.err.println("Error closing input: " + e.getMessage()); }
        }
    }
}

10.2 Try-with-resources syntax

With try-with-resources (much simpler):

public static void removeEmptyLines(String inputFileName, String outputFileName)
        throws IOException {

    try (BufferedReader in = new BufferedReader(
                new FileReader(inputFileName, StandardCharsets.UTF_8));
         BufferedWriter out = new BufferedWriter(
                new FileWriter(outputFileName, StandardCharsets.UTF_8))) {

        String line;
        while ((line = in.readLine()) != null) {
            if (!line.trim().isEmpty()) {
                out.write(line);
                out.write(System.lineSeparator());
            }
        }
    }
    // 'in' et 'out' sont automatiquement fermés, même si une exception se produit
}

What a resource is: Any object of type that implements java.lang.AutoCloseable.

Important points about syntax:

  • We can use var for resource variables
  • We can use an already initialized resource (the variable must be effectively final)
  • The catch block and finally block are optional
  • Multiple resources can be declared, separated by ;

10.3 How try-with-resources works

Rule 1: Initialization and shutdown order

// Les ressources sont initialisées dans l'ordre déclaré
// et fermées dans l'ordre INVERSE
try (var r1 = new ExampleResource("1"); var r2 = new ExampleResource("2")) {
    System.out.println("Inside the try-block");
}
// Output:
// ExampleResource 1 initialized
// ExampleResource 2 initialized
// Inside the try-block
// ExampleResource 2 closed    <- inversé
// ExampleResource 1 closed    <- inversé

Rule 2: Closure in case of exception

// Même si une exception est lancée dans le try-block, les ressources sont fermées
try (var r1 = new ExampleResource("1"); var r2 = new ExampleResource("2")) {
    System.out.println("Inside the try-block");
    throw new RuntimeException("Something went wrong");
}
// Output:
// ExampleResource 1 initialized
// ExampleResource 2 initialized
// Inside the try-block
// ExampleResource 2 closed
// ExampleResource 1 closed
// Exception in thread "main" java.lang.RuntimeException: Something went wrong

Rule 3: Suppressed exceptions

If closing a resource also throws an exception, it is deleted (attached to the main exception via getSuppressed()).

10.4 Implement AutoCloseable in practice

Example: TempDirectory class which automatically deletes a temporary directory:

public class TempDirectory implements AutoCloseable {

    private final Path directory;

    public TempDirectory(String prefix) throws IOException {
        // Crée un répertoire temporaire avec un nom aléatoire
        this.directory = Files.createTempDirectory(prefix);
    }

    public Path getDirectory() {
        return directory;
    }

    @Override
    public void close() throws IOException {
        // Supprime récursivement le répertoire et tout son contenu
        Files.walkFileTree(directory, new SimpleFileVisitor<>() {
            @Override
            public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
                    throws IOException {
                Files.delete(file);
                return FileVisitResult.CONTINUE;
            }

            @Override
            public FileVisitResult postVisitDirectory(Path dir, IOException exc)
                    throws IOException {
                if (exc == null) {
                    Files.delete(dir);
                    return FileVisitResult.CONTINUE;
                } else {
                    throw exc;
                }
            }
        });
    }
}
try (var tempDir = new TempDirectory("myapp-")) {
    Path dir = tempDir.getDirectory();
    // Travailler avec le répertoire temporaire...
    Files.writeString(dir.resolve("temp.txt"), "Hello!");
    // ...
} // tempDir.close() est appelé automatiquement ici
// Le répertoire temporaire et tout son contenu sont supprimés

10.5 Module and course summary

try-with-resources module summary:

  • A resource is any object implementing java.lang.AutoCloseable
  • Try-with-resources ensures that resources are closed automatically
  • Resources are initialized in the declared order and closed in the reverse order
  • Resources are closed even if an exception occurs in the try-block
  • Exceptions thrown when closing are suppressed (accessible via getSuppressed())
  • To implement AutoCloseable, implement the close() method which releases the resources

General course summary:

ModuleKey concept
RecordsConcise syntax for immutable classes; canonical/compact constructor; wither methods
Sealed classesControl of class hierarchies; algebraic data types
Pattern matchinginstanceof and switch concise; nested record patterns
Advanced ClassesStatic nested classes; inner classes; anonymousclasses; default methods
Advanced GenericsType parameters; invariance; wildcards; erasure type
Lambda ExpressionsAnonymous methods; functional interfaces; functional programming
NotesMetadata; meta-annotations; Reflection API
OptionalSafe alternative to null; functional programming
Try-with-resourcesAutomatic resource management; AutoCloseable


Search Terms

java · se · language · features · backend · architecture · full-stack · web · classes · interfaces · records · pattern · generics · matching · methods · optional · sealed · type · types · annotations · expressions · functional · lambda · generic

Interested in this course?

Contact us to book it or get a custom training plan for your team.