Aller au contenu

Classes et objets

Java est d'abord un langage orienté objet, et Spring en exploite tous les recoins. Tu connais déjà les concepts : on se concentre sur les choix propres à Java, souvent différents de ceux du C++.

Dans ce chapitre

  • Classes, champs, constructeurs, static, et les 4 niveaux de visibilité
  • Getters et setters : la convention JavaBeans et pourquoi les frameworks en dépendent
  • Héritage simple, super, abstract, final, et toutes les méthodes sont virtuelles
  • Interfaces (les traits de Java), polymorphisme et instanceof avec motif
  • equals, hashCode et toString : le contrat à respecter
  • Composition plutôt qu'héritage, et les classes imbriquées

Une classe complète

Compte.java
public class Compte {
    private static int nbComptes = 0;        // static : partagé par toutes les instances

    private final String titulaire;          // final : fixé une fois, dans le constructeur
    private long soldeCentimes;              // valeur par défaut : 0

    public Compte(String titulaire, long soldeCentimes) {
        if (soldeCentimes < 0) {
            throw new IllegalArgumentException("solde négatif");
        }
        this.titulaire = titulaire;          // this.champ vs paramètre de même nom
        this.soldeCentimes = soldeCentimes;
        nbComptes++;
    }

    public Compte(String titulaire) {        // surcharge de constructeur
        this(titulaire, 0);                  // délègue à l'autre constructeur
    }

    public void deposer(long centimes) {
        soldeCentimes += centimes;           // this implicite
    }

    public String getTitulaire() { return titulaire; }
    public long getSoldeCentimes() { return soldeCentimes; }
    public static int getNbComptes() { return nbComptes; }

    public static void main(String[] args) {
        var a = new Compte("Ada", 10_000);
        var b = new Compte("Linus");
        b.deposer(2_500);
        System.out.println(b.getTitulaire() + " : " + b.getSoldeCentimes());  // affiche : Linus : 2500
        System.out.println(Compte.getNbComptes() + " comptes");              // affiche : 2 comptes
        System.out.println(a);                                               // affiche : Compte@<hash>
    }
}

Ce qui diffère de ce que tu connais :

  • Pas de liste d'initialisation comme en C++ : on affecte dans le corps. Un champ final doit être affecté exactement une fois par constructeur, le compilateur le vérifie.
  • Si tu n'écris aucun constructeur, Java en génère un sans paramètre. Dès que tu en écris un, il disparaît.
  • this est implicite ; on l'écrit surtout quand un paramètre masque un champ.
  • Les membres static appartiennent à la classe : c'est l'équivalent des fonctions associées de Rust (Compte::new()) et des membres static du C++. Les fabriques statiques (List.of, Integer.valueOf, Path.of) sont très courantes.
  • Pas de destructeur, pas de surcharge d'opérateurs, pas de const sur les méthodes.

Nouveau en Java 25 : du code avant this(...) ou super(...)

Jusqu'à Java 24, l'appel this(...) ou super(...) devait être la toute première instruction. Depuis Java 25, on peut placer avant lui des instructions qui n'utilisent pas l'objet en construction (valider ou préparer des arguments) :

Personne(String prenom, String nom) {
    String complet = prenom + " " + nom;   // autorisé : n'utilise pas this
    this(complet);
}

Visibilité

Mot-clé Visible depuis Proche de (Rust)
private la classe (et ses classes imbriquées) —
(rien) : package-private tout le paquet la visibilité par défaut d'un module
protected le paquet et les sous-classes —
public partout pub
Main.java:9: error: numero is not public in Compte; cannot be accessed from outside package
Main.java:10: error: code has private access in Compte

Piège : protected est plus large qu'en C++

En Java, protected inclut tout le paquet, pas seulement les sous-classes. Et l'absence de mot-clé ne veut pas dire private (comme dans une class C++) mais « visible dans le paquet ». En pratique : champs private, et le minimum de public.

Getters, setters et JavaBeans

Les champs sont private, on les expose par des méthodes nommées selon la convention JavaBeans :

  • getNom() pour lire la propriété nom, setNom(...) pour l'écrire ;
  • isActif() pour un boolean.

Ce n'est pas qu'une question de style : beaucoup de bibliothèques découvrent les propriétés d'un objet par réflexion en cherchant ces noms. Jackson (JSON) sérialise getNom() en "nom", Hibernate (base de données) et Spring (liaison de formulaires, configuration) s'appuient sur la même convention.

Sous le capot

L'API standard java.beans.Introspector applique exactement cette règle. Sur une classe avec getNom/setNom et isActif/setActif, elle trouve deux propriétés :

actif -> isActif / setActif
nom -> getNom / setNom

Tu verras comment écrire ce genre de code dans Annotations et réflexion.

N'écris pas de setter par réflexe : un objet sans setters est plus simple à raisonner (voir Mémoire et références). Pour de simples porteurs de données, Java moderne a mieux : les record (accesseurs nom() sans préfixe get), au chapitre suivant.

Paquets et import

import java.util.List;                // une classe
import java.util.*;                   // toutes les classes du paquet (pas les sous-paquets)
import static java.lang.Math.max;     // un membre statique : max(a, b) sans préfixe
import module java.base;              // Java 25 : tout un module d'un coup

java.lang (String, Math, Integer, System...) et le paquet courant sont toujours visibles. Sans import, on peut utiliser le nom complet : java.time.LocalDate.of(2026, 10, 21). Il n'existe pas d'alias (use ... as en Rust, import ... as en Python) : en cas de conflit de noms, l'un des deux s'écrit en entier.

Héritage

Animaux.java
import java.util.List;

public class Animaux {
    static abstract class Animal {                       // abstract : pas instanciable
        private final String nom;

        protected Animal(String nom) { this.nom = nom; }

        public String nom() { return nom; }
        public abstract String cri();                    // à fournir par les sous-classes
        public abstract int pattes();

        public String decrire() {                        // appelle des méthodes redéfinies
            return nom + " (" + pattes() + " pattes) fait " + cri();
        }
    }

    static class Chien extends Animal {
        Chien(String nom) { super(nom); }                // appel du constructeur parent

        @Override public String cri() { return "Wouf"; }
        @Override public int pattes() { return 4; }

        void rapporter() { System.out.println(nom() + " rapporte la balle"); }
    }

    static class Chiot extends Chien {
        Chiot(String nom) { super(nom); }

        @Override
        public String cri() { return super.cri().toLowerCase() + " !"; }   // version du parent
    }

    static final class Oiseau extends Animal {           // final : pas de sous-classe
        Oiseau(String nom) { super(nom); }
        @Override public String cri() { return "Cui"; }
        @Override public int pattes() { return 2; }
    }

    public static void main(String[] args) {
        List<Animal> zoo = List.of(new Chien("Rex"), new Chiot("Pixel"), new Oiseau("Tweety"));
        for (Animal a : zoo) {
            System.out.println(a.decrire());
        }
    }
}
// affiche :
// Rex (4 pattes) fait Wouf
// Pixel (4 pattes) fait wouf !
// Tweety (2 pattes) fait Cui
  • Héritage simple : un seul extends (class C extends A, B ne compile pas). Toute classe hérite au final de Object.
  • Un constructeur commence toujours par appeler celui du parent : explicitement avec super(...), sinon Java insère super(), ce qui échoue si le parent n'a pas de constructeur sans paramètre.
  • abstract : new Animal("x") donne error: Animal is abstract; cannot be instantiated.
  • final sur une classe interdit l'héritage (cannot inherit from final) ; sur une méthode, la redéfinition.

Toutes les méthodes sont virtuelles

Contrairement au C++, pas besoin de virtual : tout appel de méthode d'instance est résolu selon le type réel de l'objet. Dans decrire(), cri() appelle la version de Chiot pour Pixel. Seules les méthodes static, private et final échappent à cette règle.

@Override est facultatif mais indispensable en pratique : si tu te trompes de signature, le compilateur te prévient au lieu de créer silencieusement une nouvelle méthode.

@Override public String tostring() { ... }
error: method does not override or implement a method from a supertype

Piège : appeler une méthode redéfinissable dans un constructeur

Le constructeur parent s'exécute avant l'initialisation des champs de la sous-classe, mais l'appel virtuel atteint déjà la sous-classe :

static class Base {
    Base() { System.out.println("Base voit : " + description()); }
    String description() { return "base"; }
}

static class Derivee extends Base {
    private String nom = "dérivée";
    private int taille = 42;
    @Override String description() { return nom + " / " + taille; }
}

new Derivee();   // affiche : Base voit : null / 0

En C++, le même code afficherait base (pendant la construction de la base, l'objet est encore une Base). En Java, la méthode de Derivee s'exécute sur des champs pas encore initialisés. Règle : un constructeur n'appelle que des méthodes private, static ou final.

Interfaces

Une interface est un contrat : des méthodes abstraites, éventuellement des méthodes default (avec implémentation) et static. C'est l'équivalent le plus proche des traits de Rust ; une classe peut en implémenter autant qu'elle veut.

interface Nomme {
    String nom();                                   // abstraite (et implicitement public)

    default String presentation() {                 // méthode fournie, redéfinissable
        return "Je m'appelle " + nom();
    }

    static Nomme anonyme() {                        // fabrique statique
        return () -> "Anonyme";                     // lambda : voir le chapitre streams
    }
}

interface Bruyant {
    String cri();
}

static abstract class Animal implements Nomme, Bruyant { ... }   // plusieurs interfaces

zoo.get(1).presentation();         // "Je m'appelle Pixel"
Nomme.anonyme().presentation();    // "Je m'appelle Anonyme"

Différences avec les traits Rust :

  • On déclare les interfaces à la définition de la classe : impossible d'implémenter ton interface pour String après coup (pas d'impl MonTrait for String).
  • L'appel passe toujours par une table virtuelle, comme un dyn Trait ; il n'y a pas de monomorphisation.
  • Les méthodes d'interface sont public : les implémenter sans public donne attempting to assign weaker access privileges; was public.

Une interface ne peut pas avoir d'état (champs d'instance). Pour partager du code et de l'état, il faut une classe abstraite. Spring utilise massivement les interfaces : tu injectes un TodoRepository (interface), Spring fournit l'implémentation.

Polymorphisme et instanceof

Une variable de type Animal peut référencer un Chien. Pour accéder à ce qui est propre au Chien, on teste le type réel. Depuis Java 16, instanceof déclare directement une variable :

Object o = zoo.get(1);
if (o instanceof Chien c) {          // test + cast + variable c, en une fois
    c.rapporter();                   // affiche : Pixel rapporte la balle
}

Chien ch = (Chien) a;                // cast « à l'ancienne » : vérifié à l'exécution,
                                     // ClassCastException si a n'est pas un Chien

Une chaîne de if (x instanceof A) ... else if (x instanceof B) est souvent le signe qu'une méthode redéfinie ou un switch sur un type scellé (voir Records, enums, sealed) serait plus propre.

Les méthodes héritées d'Object

Toute classe hérite de Object trois méthodes que tu redéfiniras souvent :

Méthode Par défaut Équivalent Rust
toString() Compte@1b6d3586 (nom + hash) Debug / Display
equals(Object) identité (==) PartialEq
hashCode() dérivé de l'identité Hash

Le contrat : si a.equals(b), alors a.hashCode() == b.hashCode(). equals doit aussi être réflexif, symétrique, transitif, et renvoyer false pour null.

Egalite.java (extrait)
static final class Point {
    private final int x, y;
    Point(int x, int y) { this.x = x; this.y = y; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Point p)) return false;   // gère aussi o == null
        return x == p.x && y == p.y;
    }

    @Override
    public int hashCode() {
        return Objects.hash(x, y);                   // combine les champs
    }

    @Override
    public String toString() {
        return "Point(" + x + ", " + y + ")";
    }
}

Piège : equals sans hashCode

Avec une classe qui redéfinit equals mais oublie hashCode :

Set<PointMoitie> s = new HashSet<>();
s.add(new PointMoitie(1, 2));
new PointMoitie(1, 2).equals(new PointMoitie(1, 2));   // true
s.contains(new PointMoitie(1, 2));                    // false (!)

HashSet et HashMap cherchent d'abord par hashCode : deux objets « égaux » avec des hash différents ne se trouvent jamais. Même problème si tu modifies un objet après l'avoir mis dans un HashSet : son hash change, il devient introuvable. D'où l'intérêt des clés immuables.

Ne les écris pas à la main : l'IDE les génère, et les record les fournissent automatiquement.

Composition plutôt qu'héritage

L'héritage expose la sous-classe aux détails internes du parent. Exemple classique : compter les ajouts dans un HashSet.

Composition.java (extrait)
static class CompteurHerite<E> extends HashSet<E> {
    int ajouts = 0;
    @Override public boolean add(E e) { ajouts++; return super.add(e); }
    @Override public boolean addAll(Collection<? extends E> c) { ajouts += c.size(); return super.addAll(c); }
}

static class CompteurCompose<E> {                // contient un Set au lieu d'en être un
    private final Set<E> interne = new HashSet<>();
    int ajouts = 0;
    boolean add(E e) { ajouts++; return interne.add(e); }
    boolean addAll(Collection<? extends E> c) { ajouts += c.size(); return interne.addAll(c); }
}

// après addAll(List.of("a", "b", "c")) sur chacun :
// CompteurHerite : 6 ajouts (!)   CompteurCompose : 3 ajouts

HashSet.addAll appelle add en interne, que la sous-classe a redéfini : chaque élément est compté deux fois. Ce genre de couplage invisible est la raison pour laquelle Rust n'a pas d'héritage du tout. Bonne pratique : hériter seulement d'une classe conçue pour (souvent abstract), sinon contenir l'objet et lui déléguer. L'injection de dépendances de Spring, c'est précisément de la composition.

Classes imbriquées

public class Externe {
    private final String nom = "externe";

    static class Config { int port = 8080; }        // static nested : simple rangement

    class Interne {                                 // inner : liée à une instance d'Externe
        String qui() { return "dans " + nom; }      // accède aux champs de l'externe
    }
}

var conf = new Externe.Config();
var in = new Externe().new Interne();               // syntaxe rare : il faut une instance

mots.sort(new Comparator<String>() {                // classe anonyme
    @Override
    public int compare(String a, String b) {
        return Integer.compare(a.length(), b.length());
    }
});
  • Static nested : une classe rangée dans une autre, sans lien avec ses instances. C'est le bon choix par défaut.
  • Inner (sans static) : chaque instance garde une référence cachée vers l'objet englobant. Pratique, mais source de fuites mémoire si l'objet interne survit longtemps.
  • Anonyme : une classe sans nom définie à la volée. Avant Java 8, c'était la seule façon de passer du comportement ; pour une interface à une seule méthode, une lambda la remplace ((a, b) -> Integer.compare(a.length(), b.length()), voir Lambdas et streams).

Exercices

1. Écris une classe immuable Personne(nom, age) avec equals, hashCode et toString. Vérifie qu'un HashSet ne garde qu'un exemplaire de deux personnes égales.

Solution
Personne.java
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;

public final class Personne {
    private final String nom;
    private final int age;

    public Personne(String nom, int age) {
        this.nom = Objects.requireNonNull(nom);
        this.age = age;
    }

    @Override
    public boolean equals(Object o) {
        return o instanceof Personne p && age == p.age && nom.equals(p.nom);
    }

    @Override
    public int hashCode() {
        return Objects.hash(nom, age);
    }

    @Override
    public String toString() {
        return nom + " (" + age + " ans)";
    }

    public static void main(String[] args) {
        Set<Personne> gens = new HashSet<>();
        gens.add(new Personne("Ada", 36));
        gens.add(new Personne("Ada", 36));
        gens.add(new Personne("Linus", 28));
        System.out.println(gens.size());                               // affiche : 2
        System.out.println(gens.contains(new Personne("Linus", 28)));  // affiche : true
        System.out.println(new Personne("Ada", 36));                   // affiche : Ada (36 ans)
    }
}

La classe est final : une sous-classe qui ajouterait un champ casserait la symétrie d'equals. Au chapitre suivant, record Personne(String nom, int age) {} fera tout cela en une ligne.

2. Écris une interface Taxable avec une méthode abstraite double prixHT(), une méthode default tauxTva() qui renvoie 0.20 et une méthode default prixTTC(). Implémente-la dans Gadget (taux normal) et Livre (taux de 5,5 %).

Solution
Taxes.java
import java.util.List;

public class Taxes {
    interface Taxable {
        double prixHT();

        default double tauxTva() {
            return 0.20;
        }

        default double prixTTC() {
            return Math.round(prixHT() * (1 + tauxTva()) * 100) / 100.0;
        }
    }

    static class Gadget implements Taxable {
        private final double prix;
        Gadget(double prix) { this.prix = prix; }
        @Override public double prixHT() { return prix; }        // public obligatoire
    }

    static class Livre implements Taxable {
        private final double prix;
        Livre(double prix) { this.prix = prix; }
        @Override public double prixHT() { return prix; }
        @Override public double tauxTva() { return 0.055; }      // redéfinit le default
    }

    public static void main(String[] args) {
        List<Taxable> panier = List.of(new Gadget(10), new Livre(20));
        for (Taxable t : panier) {
            System.out.println(t.prixHT() + " HT -> " + t.prixTTC() + " TTC");
        }
    }
}
// affiche :
// 10.0 HT -> 12.0 TTC
// 20.0 HT -> 21.1 TTC

prixTTC() appelle tauxTva() de façon virtuelle : la version de Livre est utilisée, comme une méthode fournie d'un trait Rust qui appelle une méthode redéfinie. (Pour de vrais montants, on utiliserait BigDecimal.)

À retenir

  • Champs private, constructeurs qui délèguent avec this(...), membres static pour ce qui appartient à la classe.
  • Quatre visibilités : private, package-private (par défaut), protected (paquet + sous-classes), public.
  • Getters et setters suivent la convention JavaBeans (getX, isX, setX) parce que les frameworks les trouvent par réflexion.
  • Héritage simple, toutes les méthodes sont virtuelles ; mets toujours @Override, et n'appelle pas de méthode redéfinissable dans un constructeur.
  • Les interfaces sont les traits de Java (méthodes default et static comprises) ; une classe en implémente plusieurs.
  • equals et hashCode se redéfinissent ensemble, sinon HashMap et HashSet dysfonctionnent.
  • Préfère la composition à l'héritage, et les classes imbriquées static aux classes internes.