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
instanceofavec motif equals,hashCodeettoString: le contrat à respecter- Composition plutôt qu'héritage, et les classes imbriquées
Une classe complète¶
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
finaldoit ê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.
thisest implicite ; on l'écrit surtout quand un paramètre masque un champ.- Les membres
staticappartiennent à la classe : c'est l'équivalent des fonctions associées de Rust (Compte::new()) et des membresstaticdu 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
constsur 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) :
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 unboolean.
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 :
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¶
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, Bne compile pas). Toute classe hérite au final deObject. - Un constructeur commence toujours par appeler celui du parent : explicitement avec
super(...), sinon Java insèresuper(), ce qui échoue si le parent n'a pas de constructeur sans paramètre. abstract:new Animal("x")donneerror: Animal is abstract; cannot be instantiated.finalsur 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
Stringaprè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 sanspublicdonneattempting 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.
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.
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
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
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 avecthis(...), membresstaticpour 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
defaultetstaticcomprises) ; une classe en implémente plusieurs. equalsethashCodese redéfinissent ensemble, sinonHashMapetHashSetdysfonctionnent.- Préfère la composition à l'héritage, et les classes imbriquées
staticaux classes internes.