Programmation orientée objet · C2 Encapsulation · Chapitre 2 · 5 h
Membres de classe et surcharge
Attributs et méthodes statiques, constantes, surcharge de méthodes et de constructeurs, variable de classe contre variable d'instance, passage par valeur et par référence.
Le tout premier programme Java qu'un étudiant écrit contient un mot qu'il ne peut pas encore comprendre :
public static void main(String[] args) { ... }Pourquoi static ? Parce que la machine virtuelle doit appeler cette méthode avant qu'aucun
objet n'existe — elle n'a aucune instance sur laquelle l'invoquer. C'est exactement ce que
static signifie : appartenir à la classe, et non à un objet.
Ce chapitre traite de ce qui appartient à la classe, puis de la possibilité de donner plusieurs formes à une même opération. Et il referme le point qui coince du chapitre 2, en le posant frontalement : combien d'exemplaires de cette variable existe-t-il ?
Une case pour la classe
public class Compte { private static int nombreDeComptes = 0; // UNE case, pour toute la classe private double solde; // une case PAR objet public Compte(double depot) { this.solde = depot; nombreDeComptes++; } public static int getNombreDeComptes() { return nombreDeComptes; }}L'animation du chapitre 2 l'a montré pas à pas : solde existe en autant d'exemplaires qu'il y
a d'objets, nombreDeComptes en un seul, créé au chargement de la classe et vivant jusqu'à la
fin du programme.
Trois conséquences suivent, et la troisième est le piège.
On accède à un membre statique par la classe : Compte.getNombreDeComptes(). Java tolère
monCompte.getNombreDeComptes(), ce qui est légal et trompeur — cela donne l'impression d'une
donnée de l'objet. Il faut prendre l'habitude d'écrire le nom de la classe.
Une méthode statique n'a pas de this. Elle ne s'exécute sur aucun objet, donc elle ne peut
pas lire un attribut d'instance. C'est l'erreur du premier main : appeler une méthode
d'instance depuis main sans avoir créé d'objet donne non-static variable cannot be referenced from a static context. Le remède est toujours le même — créer un objet, et travailler dessus.
L'inverse est permis : une méthode d'instance peut lire un membre statique, puisqu'il existe toujours.
Quand employer static ? Trois cas légitimes, et un seul contre-emploi.
Les constantes, écrites static final et en majuscules : public static final double TVA = 0.2;. Une par classe suffit, elles ne changent pas, et les dupliquer par objet serait absurde.
Les compteurs et registres partagés, comme ci-dessus.
Les méthodes utilitaires sans état : Math.sqrt, Integer.parseInt. Elles ne dépendent
d'aucun objet, donc elles n'en demandent pas.
Le contre-emploi est de rendre statique par commodité, pour éviter d'avoir à créer un objet. On obtient alors un état global modifiable de partout — exactement le défaut du chapitre 1, sous un autre nom.
Constantes
public static final int CAPACITE_MAX = 100;Trois mots, trois effets. static : une seule case. final : la valeur ne peut plus changer
après initialisation. Le nom en majuscules est une convention, pas une règle du langage — mais
elle est universellement suivie.
L'attention porte sur un point : final interdit de réaffecter la référence, pas de
modifier l'objet qu'elle désigne.
public static final List<String> NOMS = new ArrayList<>();NOMS = new ArrayList<>(); /* refusé */NOMS.add("Ana"); /* AUTORISÉ */C'est la même distinction qu'au chapitre 2 entre la référence et l'objet, et c'est ce qui rend
final insuffisant à lui seul pour garantir l'immuabilité.
Surcharge
Surcharger une méthode, c'est déclarer plusieurs méthodes de même nom avec des signatures différentes — nombre, types ou ordre des paramètres.
public void afficher(String texte) { ... }public void afficher(String texte, int fois) { ... }public void afficher(double valeur) { ... }Le compilateur choisit d'après les types des arguments de l'appel. Deux règles à connaître.
Le type de retour ne fait pas partie de la signature. Deux méthodes ne différant que par
leur retour ne compilent pas : int lire() et String lire() sont un conflit.
Le choix est fait à la COMPILATION, sur les types déclarés. Retenez cette phrase : elle sera le contraste central du chapitre 6, où la redéfinition, elle, se résout à l'exécution sur le type réel. Surcharge et redéfinition portent des noms voisins et fonctionnent de façon opposée.
La surcharge de constructeurs est le cas le plus fréquent, et elle se combine avec le this
du chapitre 2 :
public Compte(String titulaire, double depot) { this.titulaire = titulaire; this.solde = depot;}public Compte(String titulaire) { this(titulaire, 0); /* enchaîne sur l'autre constructeur */}Écrire this(...) plutôt que dupliquer l'initialisation garantit qu'il n'existe qu'un seul
endroit où l'invariant est établi — c'est le chapitre 3 appliqué aux constructeurs.
Quiz · 1 question
Une classe déclare afficher(double) et afficher(int). On appelle afficher(5). Quelle méthode est choisie, et quand ?
- afficher(int), choisie à l'EXÉCUTION d'après la valeur réelle de l'argument — à l'exécution
- afficher(int), choisie à la COMPILATION d'après le type déclaré de l'argument : 5 est un littéral entier, et le compilateur préfère toujours la correspondance exacte à une conversion — à la compilation
- Le code ne compile pas : deux méthodes de même nom sont ambiguës — ambigu
Réponse : La résolution de surcharge est entièrement l'affaire du COMPILATEUR : il regarde les types déclarés des arguments et choisit la méthode la plus spécifique qui accepte l'appel, en préférant une correspondance exacte à une conversion élargissante. Le littéral 5 est un int, donc afficher(int) l'emporte ; afficher(5.0) choisirait l'autre, et si afficher(int) n'existait pas, le 5 serait converti en double. Deux méthodes de même nom ne sont ambiguës que si aucune n'est plus spécifique que l'autre. RETENEZ CETTE PHRASE : le choix se fait à la compilation, sur les types déclarés. Le chapitre 6 présentera la REDÉFINITION, qui porte un nom voisin et fonctionne à l'inverse — résolution à l'exécution, sur le type réel. Confondre les deux est l'erreur la plus fréquente du bloc III.
Passage de paramètres en Java
C'est la question d'examen classique du chapitre, et la réponse tient en une phrase que presque personne n'énonce correctement.
Java passe toujours par valeur. Il n'existe aucun passage par référence, contrairement à ce qu'on lit souvent. Ce qui trompe, c'est que la valeur d'une variable objet est une référence — et copier une référence donne un second nom pour le même objet.
D'où deux comportements qu'il faut savoir distinguer.
static void modifier(Compte c) { c.deposer(100); /* VISIBLE par l'appelant : même objet */} static void remplacer(Compte c) { c = new Compte(0); /* INVISIBLE : on change une copie locale */}modifier agit sur l'objet désigné, et l'appelant le voit. remplacer réaffecte le
paramètre, qui est une variable locale à la méthode : l'appelant garde sa référence
d'origine.
C'est exactement le chapitre 7 du cours de programmation : passer un pointeur permet de modifier ce qu'il désigne, mais réaffecter le pointeur lui-même n'a aucun effet chez l'appelant. La seule différence est que Java écrit tout cela sans étoile ni esperluette.
Conséquence directe : on ne peut pas écrire echanger(a, b) en Java. La méthode échangerait
ses deux copies de références, et l'appelant ne verrait rien. Il faut passer par un objet
conteneur, un tableau, ou rendre un résultat.
Les types primitifs — int, double, boolean, char — ne sont pas des objets : leur
valeur est copiée directement, et aucune modification n'est jamais visible de l'appelant.
Quiz · 1 question
Une méthode reçoit un Compte en paramètre. Dans un cas elle appelle c.deposer(100), dans l'autre elle fait c = new Compte(0). Que voit l'appelant ?
- Les deux modifications, puisque les objets sont passés par référence en Java — les deux
- Seulement le dépôt : Java passe la référence PAR VALEUR, donc modifier l'objet désigné est visible, mais réaffecter le paramètre ne change qu'une variable locale à la méthode — seulement le dépôt
- Aucune des deux : les paramètres sont toujours des copies complètes de l'objet — aucune
Réponse : Java passe TOUJOURS par valeur — il n'existe aucun passage par référence, malgré ce qu'on lit souvent. Ce qui trompe est que la valeur d'une variable objet EST une référence : la copier donne un second nom pour le même objet, donc c.deposer(100) agit bien sur l'objet de l'appelant. En revanche, c = new Compte(0) réaffecte le PARAMÈTRE, qui est une variable locale à la méthode : l'appelant conserve sa référence d'origine et ne voit rien. C'est mot pour mot le chapitre 7 du cours de programmation — passer un pointeur permet de modifier ce qu'il désigne, réaffecter le pointeur n'a aucun effet chez l'appelant — mais sans étoile ni esperluette. Conséquence pratique : on ne peut pas écrire echanger(a, b) en Java. Et les types primitifs, qui ne sont pas des objets, sont copiés directement : aucune modification n'est jamais visible.
À vous
L'exercice est en trois volets, et le troisième est celui qu'il faut faire jusqu'au bout.
Le compteur statique d'abord : plusieurs objets, une seule case, et la vérification qu'un compteur d'instance à la place donnerait toujours 1.
Puis la résolution de surcharge : plusieurs méthodes de même nom, et un journal indiquant laquelle a été choisie pour chaque appel. Vous chercherez le cas où la conversion l'emporte, et celui où l'appel devient ambigu.
Enfin le passage de paramètres, avec les trois cas côte à côte — modifier l'objet, réaffecter le
paramètre, et un type primitif — puis la tentative d'écrire echanger, qui échoue, et les deux
contournements possibles.
Exercice de code
Comptez les instances par un membre de classe, résolvez une surcharge, puis constatez l'échec de echanger.
Point de départ
// ── 1. Une case pour la classe ────────────────────────────────────────────
class Compte {
static nombreDeComptes = 0; // UNE case, pour toute la classe
#solde; // une case PAR objet
constructor(titulaire, depot) {
this.titulaire = titulaire;
this.#solde = depot;
// ← à écrire : incrémenter le compteur DE CLASSE
}
deposer(m) { this.#solde += m; }
getSolde() { return this.#solde; }
static getNombreDeComptes() { return Compte.nombreDeComptes; }
}
// ── 2. Résolution de surcharge ────────────────────────────────────────────
// JavaScript n'a pas de surcharge : on la SIMULE en inspectant les types,
// ce qui rend visible ce que le compilateur Java fait à notre place.
function afficher(...args) {
const signature = args.map((a) =>
typeof a === "number" ? (Number.isInteger(a) ? "int" : "double") : typeof a
).join(", ");
const CANDIDATES = {
"int": (n) => "afficher(int) -> " + n,
"double": (x) => "afficher(double) -> " + x.toFixed(2),
"string": (s) => "afficher(String) -> " + s,
"string, int": (s, n) => "afficher(String, int) -> " + s.repeat(n),
};
// ← à écrire : choisir la candidate exacte ; si aucune, tenter une
// conversion élargissante int -> double, comme le fait Java.
return "aucune surcharge applicable pour (" + signature + ")";
}
// ── 3. Passage de paramètres ──────────────────────────────────────────────
function modifier(c) { c.deposer(100); } // agit sur l'objet désigné
function remplacer(c) { c = new Compte("X", 0); } // réaffecte une locale
function incrementer(n) { n = n + 1; } // type primitif
function echanger(a, b) {
const t = a; a = b; b = t; // échange les COPIES de références
}
// ── À VOUS ────────────────────────────────────────────────────────────────
// 1. Incrémentez le compteur de classe, créez trois comptes, et vérifiez
// qu'un compteur d'instance à la place vaudrait toujours 1.
// 2. Complétez la résolution de surcharge, y compris la conversion.
// 3. Lancez les trois cas de passage, puis echanger. Pourquoi échoue-t-il,
// et par quoi le remplacer ?
const a = new Compte("Ana", 100);
const b = new Compte("Bo", 50);
console.log(" comptes créés :", Compte.getNombreDeComptes());
Solution
class Compte {
static nombreDeComptes = 0;
#solde;
compteurPropre = 0; // pour la comparaison du point 1
constructor(titulaire, depot) {
this.titulaire = titulaire;
this.#solde = depot;
Compte.nombreDeComptes++; // par la CLASSE : plus clair que « nombre++ »
this.compteurPropre++; // une case par objet : vaudra toujours 1
}
deposer(m) { this.#solde += m; }
getSolde() { return this.#solde; }
toString() { return this.titulaire + " : " + this.getSolde(); }
static getNombreDeComptes() { return Compte.nombreDeComptes; }
}
console.log("— 1. variable de classe contre variable d'instance —");
const a = new Compte("Ana", 100);
const b = new Compte("Bo", 50);
const c = new Compte("Cy", 20);
console.log(" Compte.nombreDeComptes = " + Compte.getNombreDeComptes() + " (une seule case)");
console.log(" a.compteurPropre = " + a.compteurPropre +
", b = " + b.compteurPropre + ", c = " + c.compteurPropre +
" (une case chacun : toujours 1)");
console.log("");
console.log("— 2. résolution de surcharge —");
function afficher(...args) {
const signature = args.map((x) =>
typeof x === "number" ? (Number.isInteger(x) ? "int" : "double") : typeof x
).join(", ");
const CANDIDATES = {
"int": (n) => "afficher(int) -> " + n,
"double": (x) => "afficher(double) -> " + x.toFixed(2),
"string": (s) => "afficher(String) -> " + s,
"string, int": (s, n) => "afficher(String, int) -> " + s.repeat(n),
};
// 1) Correspondance EXACTE : toujours préférée.
if (CANDIDATES[signature]) return CANDIDATES[signature](...args);
// 2) À défaut, conversion élargissante int -> double, comme en Java.
const elargie = signature.replace(/\bint\b/g, "double");
if (CANDIDATES[elargie]) return CANDIDATES[elargie](...args) + " (int élargi en double)";
return "aucune surcharge applicable pour (" + signature + ")";
}
for (const appel of [[5], [5.5], ["ab"], ["ab", 3], [true]]) {
console.log(" afficher(" + appel.map((x) => JSON.stringify(x)).join(", ") + ")".padEnd(12) +
" -> " + afficher(...appel));
}
console.log(" Le littéral 5 est un int : la correspondance exacte l'emporte.");
console.log(" Si afficher(int) n'existait pas, 5 serait élargi en double.");
console.log(" Tout cela se décide À LA COMPILATION, sur les types DÉCLARÉS.");
console.log("");
console.log("— 3. passage de paramètres —");
function modifier(x) { x.deposer(100); }
function remplacer(x) { x = new Compte("Remplacé", 0); }
function incrementer(n) { n = n + 1; }
function echanger(x, y) { const t = x; x = y; y = t; }
console.log(" avant : " + a.toString() + " | " + b.toString());
modifier(a);
console.log(" après modifier : " + a.toString() + " (le dépôt est VISIBLE)");
remplacer(a);
console.log(" après remplacer : " + a.toString() + " (rien n'a changé)");
let n = 10;
incrementer(n);
console.log(" après incrementer(n) : n = " + n + " (primitif : copie directe)");
let x = a, y = b;
echanger(x, y);
console.log(" après echanger : x = " + x.toString() + ", y = " + y.toString());
console.log(" Rien n'a bougé : la méthode a échangé ses deux COPIES de");
console.log(" références, et l'appelant garde les siennes. On ne peut pas");
console.log(" écrire echanger en Java — il faut un conteneur, un tableau,");
console.log(" ou rendre un résultat :");
[x, y] = [y, x]; // l'appelant fait l'échange lui-même
console.log(" échange chez l'appelant : x = " + x.toString() + ", y = " + y.toString());
En travaux pratiques
Travaux pratiques 4 · 3 h
Ce qui appartient à la classe
Distinguer par l'expérience ce qui appartient à l'objet de ce qui appartient à la classe, et voir le compilateur choisir entre plusieurs méthodes de même nom.
Avant de commencer
- Les TP 2 et 3
Énoncé
- Compter les instances — Ajoutez à Document un compteur du nombre d'objets créés. Créez-en cinq et affichez le compteur, depuis un objet puis depuis la classe.
- L'erreur symétrique — Tentez d'accéder à un champ d'instance depuis une méthode statique. Lisez le message et expliquez-le en une phrase avec le mot this.
- La constante — Ajoutez une durée d'emprunt maximale, partagée et non modifiable. Testez ce que le compilateur refuse.
- La fabrique statique — Ajoutez une méthode statique qui construit un Document à partir d'une ligne de fichier, et qui refuse une ligne mal formée. Comparez avec un constructeur.
- Surcharger — Écrivez trois méthodes chercher : par titre, par titre et auteur, par année. Appelez les trois et vérifiez laquelle est choisie.
- L'ambiguïté — Écrivez deux surcharges, l'une prenant un entier long, l'autre un flottant double. Appelez avec un entier ordinaire, puis avec null sur deux surcharges d'objets. Notez ce qui compile et ce qui ne compile pas. Indice : La résolution se fait à la COMPILATION, sur les types déclarés.
- Le piège du nombre variable d'arguments — Ajoutez une surcharge à nombre variable d'arguments à côté d'une surcharge à un argument. Appelez avec un seul argument et déterminez laquelle est retenue.
- Au fil rouge — Ajoutez à Mediatheque un identifiant unique généré automatiquement pour chaque document. Vérifiez qu'il reste unique après création de mille documents.
C'est réussi quand
- Votre compteur donne 5 sans qu'aucun objet ne le stocke
- Vous expliquez l'erreur de la méthode statique en parlant de this
- Vous prédisez correctement quelle surcharge est appelée dans les trois cas
Correction
public class Document {
private static int nombreCreés = 0; /* UNE seule copie */
private final String titre; /* une copie par objet */
public Document(String titre) {
this.titre = titre;
nombreCreés++; /* partagé */
}
public static int getNombreCreés() { return nombreCreés; }
}
Document.getNombreCreés() → 5 /* la bonne façon */
unDocument.getNombreCreés() → 5 /* compile, mais TROMPEUR */Un champ statique existe une fois, quel que soit le nombre d'objets — y compris zéro. L'appeler sur une instance compile et laisse croire que la valeur dépend de l'objet : on écrit toujours Document.getNombreCreés(). À noter pour plus tard : ce compteur n'est pas sûr en présence de plusieurs fils d'exécution, exactement comme le compteur du TP 5 de Systèmes.
public static void afficher() {
System.out.println(titre); /* ERREUR : non-static variable titre
cannot be referenced from a static
context */
}Une méthode statique n'a pas de this : elle n'est appelée sur AUCUN objet, donc la question « le titre de qui ? » n'a pas de réponse. Cette seule phrase explique l'erreur, et aussi pourquoi main est statique — au démarrage, aucun objet n'existe encore.
public static Optional<Document> depuisLigne(String ligne) {
String[] champs = ligne.split(";");
if (champs.length != 3) return Optional.empty(); /* refus PROPRE */
try {
return Optional.of(new Document(champs[0], champs[1],
Integer.parseInt(champs[2])));
} catch (NumberFormatException e) {
return Optional.empty();
}
}Trois choses qu'un constructeur ne peut pas faire : porter un nom explicite, ne RIEN renvoyer en cas d'échec, et rendre un objet déjà existant plutôt qu'un neuf. Un constructeur doit soit réussir soit lever une exception — il ne peut pas renvoyer « rien ». C'est pourquoi les bibliothèques modernes exposent des fabriques nommées : Optional.of, List.of, Integer.valueOf.
void afficher(long x) { System.out.println("long"); }
void afficher(double x) { System.out.println("double"); }
afficher(5); → « long » /* int s'élargit d'abord en long */
void traiter(String s) { }
void traiter(Object o) { }
traiter(null); → « String » /* le type le plus SPÉCIFIQUE gagne */
void f(Integer i) { }
void f(String s) { }
f(null); → ERREUR : reference to f is ambiguousLe compilateur choisit selon les types DÉCLARÉS, à la compilation, en préférant la conversion la moins large puis la surcharge la plus spécifique. Quand deux candidats sont incomparables, il refuse. C'est la différence fondamentale avec la redéfinition du TP 6, qui se résout à l'EXÉCUTION sur le type réel — confondre les deux est le troisième point qui coince du cours.
void chercher(String titre) { System.out.println("un"); }
void chercher(String... criteres) { System.out.println("plusieurs"); }
chercher("Dune"); → « un »
/* la forme à nombre variable est le DERNIER recours :
le compilateur essaie d'abord sans conversion,
puis avec élargissement, puis avec autoboxing,
et seulement ensuite avec varargs */Cet ordre en trois phases explique la plupart des surprises de surcharge. En pratique : évitez de surcharger une méthode à nombre variable d'arguments, et surtout évitez de surcharger sur des types que l'autoboxing peut relier — int et Integer dans la même famille de surcharges produit du code que personne ne sait lire.
Ce que la suite en fait
Le bloc III ouvre le cœur du cours. L'héritage du chapitre 5 permettra à une classe d'en réutiliser une autre, et posera la question qui coince : quand a-t-on le droit d'hériter ?
Le chapitre 6, lui, reprendra directement une phrase de ce chapitre-ci : la surcharge se résout à la compilation, sur le type déclaré. La redéfinition fera exactement l'inverse, et tout le polymorphisme tient dans cette opposition.
À retenir
Flashcards · 5 cartes
- Que signifie static, et pourquoi le main l'est-il ?
- Appartenir à la CLASSE et non à un objet : une seule case, créée au chargement de la classe, quel que soit le nombre d'instances. Le main est static parce que la machine virtuelle doit l'appeler AVANT qu'aucun objet n'existe — elle n'a aucune instance sur laquelle l'invoquer. Conséquence : une méthode statique n'a pas de this et ne peut pas lire un attribut d'instance, d'où l'erreur « non-static variable cannot be referenced from a static context » du premier programme.
- Quels sont les trois emplois légitimes de static, et le contre-emploi ?
- 1) Les CONSTANTES, en static final et en majuscules. 2) Les COMPTEURS et registres partagés. 3) Les MÉTHODES UTILITAIRES sans état (Math.sqrt, Integer.parseInt), qui ne dépendent d'aucun objet. Contre-emploi : rendre statique PAR COMMODITÉ, pour éviter de créer un objet — on obtient un état global modifiable de partout, c'est-à-dire le défaut du procédural sous un autre nom.
- Que garantit final sur une référence, et que ne garantit-il pas ?
- Il interdit de RÉAFFECTER la référence, pas de MODIFIER l'objet qu'elle désigne : sur une liste déclarée static final, l'affectation d'une nouvelle liste est refusée mais add() est autorisé. C'est la même distinction qu'entre la référence et l'objet, et c'est pourquoi final ne suffit pas à garantir l'immuabilité.
- Comment la surcharge est-elle résolue, et quelles règles la gouvernent ?
- À LA COMPILATION, d'après les TYPES DÉCLARÉS des arguments : le compilateur choisit la méthode la plus spécifique, en préférant une correspondance exacte à une conversion élargissante. Le TYPE DE RETOUR ne fait pas partie de la signature — deux méthodes n'en différant que par là ne compilent pas. Retenir la phrase « à la compilation, sur le type déclaré » : le chapitre 6 présentera la redéfinition, qui fait exactement l'inverse.
- Comment Java passe-t-il les paramètres, et quelle en est la conséquence ?
- TOUJOURS PAR VALEUR — il n'existe aucun passage par référence. Ce qui trompe : la valeur d'une variable objet EST une référence, donc la copier donne un second nom pour le même objet. Résultat : modifier l'objet désigné (c.deposer(100)) est visible par l'appelant ; réaffecter le paramètre (c = new Compte(0)) ne change qu'une variable locale. C'est le chapitre 7 du cours de programmation, sans étoile ni esperluette. Conséquence : on ne peut pas écrire echanger(a, b) en Java. Les types primitifs, eux, sont copiés directement.