3.1 · Classes et encapsulation
Les diapositives de la séance 3
- écrire une
class: champs, constructeur, méthodes,this - utiliser les propriétés de constructeur
- contrôler la visibilité avec
public,private,protected, et garantir un invariant - écrire des accesseurs
getetset - distinguer un membre
staticd’un membre d’instance - choisir entre une classe et un objet littéral accompagné de fonctions
- ranger une classe dans son propre module
Les objets de données du chapitre 2.2 · Objets de données, les méthodes de tableaux du chapitre 2.3 · Tableaux et les modules du chapitre 1.3 · Modules.
La séance 3 est entièrement consacrée à la programmation orientée objet, en deux chapitres : celui-ci, sur les classes et l’encapsulation, puis 3.2 · Contrats, héritage et polymorphisme. C’est le dernier morceau du programme de la première évaluation, et presque tout ce qui suit s’appuie dessus : les erreurs du chapitre 5.3 sont des classes, un composant web du chapitre 7.3 aussi. Prenez le temps de taper les exemples plutôt que de les lire.
Le code complet du chapitre : télécharger app.ts. Exécutez-le avec npx tsx app.ts.
Des données au comportement
Au chapitre 2.2, un objet ne portait que des données, et le code qui les utilisait vivait à part, dans des fonctions :
interface Compte {
titulaire: string;
solde: number;
}
function deposer(compte: Compte, montant: number): void {
compte.solde += montant;
}Rien n’empêche alors n’importe quelle partie du programme d’écrire compte.solde = -500. Une classe réunit les données et le code qui a le droit de les modifier, et c’est ce qui permet de faire respecter des règles.
Les classes
Une classe est un moule à objets. Chaque objet fabriqué par ce moule est une instance.
class Personne {
nom: string; // champ (propriété d'instance)
age: number;
constructor(nom: string, age: number) { // appelé automatiquement par new
this.nom = nom; // this.nom = la propriété de l'objet en construction
this.age = age; // nom (sans this) = le paramètre du constructeur
}
saluer(): void { // méthode
console.log(`Bonjour, je m'appelle ${this.nom} et j'ai ${this.age} ans.`);
}
}
const alice = new Personne("Alice", 25);
alice.saluer(); // Bonjour, je m'appelle Alice et j'ai 25 ans.Trois mots-clés à retenir :
newréserve un objet vide et appelle le constructeur ;constructorinitialise les champs ;thisdésigne l’instance sur laquelle la méthode s’exécute.
Oublier this est l’erreur numéro un du chapitre. Dans une méthode, nom tout seul n’est pas le champ de l’objet : c’est une variable que TypeScript cherche ailleurs. Il vous répondra Cannot find name 'nom'. Did you mean the instance member 'this.nom'?. Écrivez toujours this.nom.
Raccourci : les propriétés de constructeur
Répéter trois fois le nom d’un champ (déclaration, paramètre, affectation) est fastidieux. TypeScript propose une écriture condensée : un modificateur devant un paramètre du constructeur déclare et initialise le champ.
class Personne {
constructor(public nom: string, public age: number) {}
// strictement équivalent à la version précédente
}Utilisez-la quand vous l’avez comprise ; la version longue reste parfaitement correcte.
L’encapsulation : public, private, protected
L’encapsulation consiste à décider ce qui est visible de l’extérieur. Par défaut, tout est public.
| Modificateur | Dans la classe | Dans les classes filles | De l’extérieur |
|---|---|---|---|
public (défaut) |
oui | oui | oui |
protected |
oui | oui | non |
private |
oui | non | non |
Les classes filles sont le sujet du chapitre 3.2 ; pour l’instant, retenez public et private.
Garantir un invariant
class CompteBancaire {
private solde: number = 0;
deposer(montant: number): void {
if (montant <= 0) return; // le contrôle est ici, impossible à contourner
this.solde += montant;
}
getSolde(): number {
return this.solde;
}
}
const c = new CompteBancaire();
c.deposer(100);
console.log(c.getSolde()); // 100
c.solde = 1000000; // ERREUR : Property 'solde' is privateUn invariant est une propriété qui doit rester vraie pendant toute la vie de l’objet : ici, « le solde n’a jamais reçu de dépôt négatif ». L’intérêt de private n’est pas la sécurité, c’est cette garantie : tant que solde est private, aucun code extérieur ne peut le rendre incohérent. Toutes les modifications passent par des méthodes que vous contrôlez.
Les accesseurs get et set
Un accesseur s’utilise comme une propriété, mais exécute du code :
class Temperature {
private celsius: number = 0;
get fahrenheit(): number {
return this.celsius * 9 / 5 + 32;
}
set fahrenheit(valeur: number) {
this.celsius = (valeur - 32) * 5 / 9;
}
}
const t = new Temperature();
t.fahrenheit = 100; // appelle le setter
console.log(t.fahrenheit); // 100 : appelle le getterUn get sans set donne une propriété en lecture seule, calculée à chaque accès. Un set est l’endroit naturel pour valider une valeur avant de l’accepter.
private n’est qu’une vérification du compilateur
private est une vérification du compilateur, pas du moteur JavaScript. Après transpilation, le champ existe comme n’importe quel autre et reste accessible depuis du JavaScript. Pour ce cours, private suffit : ce que l’on cherche, c’est empêcher les erreurs de conception, pas déjouer un attaquant.
JavaScript possède sa propre notation, #solde, qui rend le champ réellement inaccessible hors de la classe, y compris à l’exécution. Vous la croiserez dans du code récent ; elle s’utilise comme private, avec le # dans le nom : this.#solde.
Membres statiques
Un membre static appartient à la classe, pas aux instances : il existe en un seul exemplaire, partagé.
app.ts
class Numero {
static numeroStatique: number = 0; // partagée par TOUTES les instances
numero: number = 0; // propre à CHAQUE instance
constructor() {
Numero.numeroStatique++; // accès via le nom de la classe
this.numero++; // accès via this
}
}
const n1 = new Numero();
const n2 = new Numero();
const n3 = new Numero();
console.log(Numero.numeroStatique); // 3
console.log(n1.numero, n2.numero, n3.numero); // 1 1 1C’est ce qui permet de compter les instances, ou de stocker une constante de classe : static readonly TAUX_TVA = 0.2.
Classe ou objet littéral ?
Tout ne doit pas devenir une classe. Un objet littéral décrit par une interface, avec des fonctions à côté, reste souvent le bon choix.
| Situation | Choix |
|---|---|
| Des données qui circulent : une ligne de catalogue, une réponse d’API, une configuration | objet littéral + interface |
| Des données accompagnées de règles à faire respecter (un invariant) | classe avec des champs private |
| Plusieurs variantes d’un même comportement | classes + interface commune (chapitre 3.2) |
| Un calcul sans état : conversion, formatage | simple fonction |
| Une valeur partagée par toutes les instances | membre static |
Le critère décisif est la règle : dès qu’une donnée ne doit pas pouvoir prendre n’importe quelle valeur, une classe avec private la protège. Sans règle à défendre, un objet littéral suffit.
Un argument pratique penche aussi vers les objets littéraux : les données qui arrivent d’un fichier ou du réseau en JSON sont toujours des objets littéraux, jamais des instances. Vous le verrez au chapitre 5.1 · JSON.
Une classe par module
Une classe est une unité naturelle de découpage : la convention est de la ranger dans son propre fichier, nommé comme elle, et de l’exporter. C’est exactement le mécanisme du chapitre 1.3 · Modules.
src/scripts/CompteBancaire.ts
export class CompteBancaire {
private solde: number = 0;
deposer(montant: number): void {
if (montant <= 0) return;
this.solde += montant;
}
getSolde(): number {
return this.solde;
}
}src/scripts/app.ts
import { CompteBancaire } from "./CompteBancaire.ts";
const compte = new CompteBancaire();
compte.deposer(50);
console.log(compte.getSolde()); // 50Le fichier qui importe la classe ne voit que ce qu’elle rend public. Le module et private se complètent : le module choisit quelles classes sont visibles, private choisit quels membres.
Vous venez du C ?
| En C | En TypeScript |
|---|---|
une struct + des fonctions qui prennent struct Compte *c |
une classe : des champs et des méthodes |
compte_deposer(&c, 100); |
c.deposer(100); |
le pointeur c passé en paramètre |
this, fourni automatiquement |
| tout champ est modifiable par tous | private réserve un champ à la classe |
malloc puis initialisation à la main |
new appelle le constructor |
une variable static dans un fichier |
un membre static de la classe |
Différence essentielle : en C, les données (struct) et le code (fonctions) sont séparés, et rien n’empêche une fonction quelconque de modifier un champ. En TypeScript, une classe réunit les deux dans une même unité, et le langage garantit qu’une méthode s’exécute toujours sur un objet précis, accessible via this.
Exercices
Exercice 3.1 : D’une interface à une classe
essentiel
Voici le code écrit au chapitre précédent : des données décrites par une interface, et une fonction à côté.
interface Livre {
titre: string;
auteur: string;
pages: number;
isbn?: string;
}
function decrire(l: Livre): string {
const base = `${l.titre}, ${l.auteur} (${l.pages} p.)`;
return l.isbn !== undefined ? `${base} [ISBN: ${l.isbn}]` : base;
}- Remplacez l’interface et la fonction par une seule classe
Livre: les mêmes champs, un constructeur, et une méthodedecrire(): string. - Créez deux instances, l’une avec ISBN, l’autre sans, et affichez leur description.
- Réécrivez le constructeur avec des propriétés de constructeur (
public titre: string…).
Réussite : tsc ne signale aucune erreur, la version sans ISBN n’affiche jamais undefined, et vous savez dire ce qui a changé entre decrire(l) et l.decrire().
Version longue, puis version courte :
livre.ts
class Livre {
titre: string;
auteur: string;
pages: number;
isbn?: string;
constructor(titre: string, auteur: string, pages: number, isbn?: string) {
this.titre = titre;
this.auteur = auteur;
this.pages = pages;
this.isbn = isbn;
}
decrire(): string {
const base = `${this.titre}, ${this.auteur} (${this.pages} p.)`;
return this.isbn !== undefined ? `${base} [ISBN: ${this.isbn}]` : base;
}
}
const petitPrince = new Livre("Le Petit Prince", "Saint-Exupéry", 96, "978-2-07-040850-4");
const carnet = new Livre("Carnet de notes", "Anonyme", 120);
console.log(petitPrince.decrire());
// Le Petit Prince, Saint-Exupéry (96 p.) [ISBN: 978-2-07-040850-4]
console.log(carnet.decrire());
// Carnet de notes, Anonyme (120 p.)livre-court.ts
class Livre {
constructor(
public titre: string,
public auteur: string,
public pages: number,
public isbn?: string
) {}
decrire(): string {
const base = `${this.titre}, ${this.auteur} (${this.pages} p.)`;
return this.isbn !== undefined ? `${base} [ISBN: ${this.isbn}]` : base;
}
}
const dune = new Livre("Dune", "Herbert", 704);
console.log(dune.decrire()); // Dune, Herbert (704 p.)Le point clé : decrire(l) reçoit le livre en paramètre ; l.decrire() est appelée sur le livre, qui devient this. Le code de la méthode est identique à celui de la fonction, à un détail près : l. est devenu this.. C’est exactement ce que fait une classe : elle range la fonction avec les données, et lui fournit automatiquement l’objet sur lequel travailler.
Deux détails de syntaxe à noter. Un paramètre optionnel doit être en dernier : isbn?: string avant pages: number donnerait A required parameter cannot follow an optional parameter. Et dans la version longue, oublier un this. dans decrire donne Cannot find name 'titre'. Did you mean the instance member 'this.titre'? : le message vous donne lui-même la correction.
Exercice 3.2 : Un compteur qui ne déborde jamais
essentiel
Écrivez une classe Compteur dont la valeur reste toujours comprise entre 0 et un maximum fixé à la création.
- Le constructeur reçoit le maximum, qui ne pourra plus changer ensuite.
- La valeur, qui part de
0, est stockée dans un champprivate. incrementer()ajoute 1, sauf si le maximum est atteint ;decrementer()retire 1, sauf si la valeur est déjà0;reinitialiser()remet à0.getValeur(): numberpermet de lire la valeur.
Testez avec un maximum de 3 : incrémentez cinq fois, affichez ; décrémentez cinq fois, affichez. Essayez enfin d’écrire compteur.valeur = 50 et notez le message.
Réussite : les affichages donnent 3 puis 0, et il est impossible de sortir de l’intervalle depuis l’extérieur de la classe.
compteur.ts
class Compteur {
private valeur: number = 0;
constructor(private readonly max: number) {}
incrementer(): void {
if (this.valeur < this.max) {
this.valeur++;
}
}
decrementer(): void {
if (this.valeur > 0) {
this.valeur--;
}
}
reinitialiser(): void {
this.valeur = 0;
}
getValeur(): number {
return this.valeur;
}
}
const compteur = new Compteur(3);
for (let i = 0; i < 5; i++) {
compteur.incrementer();
}
console.log(compteur.getValeur()); // 3
for (let i = 0; i < 5; i++) {
compteur.decrementer();
}
console.log(compteur.getValeur()); // 0
// compteur.valeur = 50;
// ERREUR : Property 'valeur' is private and only accessible within class 'Compteur'.Le point clé : l’invariant « 0 <= valeur <= max » est vrai à la création, et chacune des trois méthodes qui modifient valeur le préserve. Comme aucune autre partie du programme ne peut toucher à valeur, l’invariant est vrai pour toujours. Pour vous en convaincre, il suffit de relire trois méthodes, pas tout le programme.
private readonly max combine deux protections : private empêche de le lire ou de l’écrire de l’extérieur, readonly empêche la classe elle-même de le modifier après le constructeur.
Le trou qui reste : new Compteur(-5) crée un compteur dont la valeur 0 dépasse déjà le maximum. Le constructeur devrait refuser un maximum négatif ; la bonne façon de refuser, en levant une exception, est le sujet du chapitre 5.3.
Exercice 3.3 : Un article avec get, set et une constante de classe
entraînement
Écrivez une classe Article :
- un champ
nom, public mais fixé à la création ; - un prix hors taxe stocké dans un champ privé
_prixHT, exposé par unget prixHTet unset prixHT; le setter ignore une valeur négative ; - une constante de classe
static readonly TAUX_TVA = 0.2; - un
get prixTTC, calculé à partir du prix hors taxe et arrondi au centime, sans setter ; - un constructeur
(nom, prixHT)qui refuse lui aussi un prix négatif, sans recopier le test du setter.
Vérifiez ensuite que article.prixHT = -5 ne change rien, que article.prixTTC = 10 est refusé par le compilateur, et que new Article("Stylo", -3) donne un prix de 0.
Réussite : les trois vérifications donnent le résultat attendu, et le test « prix négatif » n’est écrit qu’une seule fois.
Dans le constructeur, this.prixHT = prixHT passe par le setter.
article.ts
class Article {
static readonly TAUX_TVA: number = 0.2;
private _prixHT: number = 0;
constructor(public readonly nom: string, prixHT: number) {
this.prixHT = prixHT; // passe par le setter : la validation s'applique dès la création
}
get prixHT(): number {
return this._prixHT;
}
set prixHT(valeur: number) {
if (valeur >= 0) {
this._prixHT = valeur;
}
}
get prixTTC(): number {
return Math.round(this._prixHT * (1 + Article.TAUX_TVA) * 100) / 100;
}
}
const stylo = new Article("Stylo", 19.99);
console.log(stylo.prixHT, stylo.prixTTC); // 19.99 23.99
stylo.prixHT = -5; // ignoré par le setter
console.log(stylo.prixHT); // 19.99
const gratuit = new Article("Stylo", -3);
console.log(gratuit.prixHT); // 0
// stylo.prixTTC = 10;
// ERREUR : Cannot assign to 'prixTTC' because it is a read-only property.Le point clé : le constructeur passe par le setter au lieu d’écrire directement this._prixHT = prixHT. La règle « pas de prix négatif » n’existe donc qu’à un seul endroit, et elle s’applique aussi bien à la création qu’aux modifications suivantes. Si la règle change demain (un prix maximal, par exemple), une seule ligne est à modifier.
Pourquoi _prixHT et pas prixHT ? Un champ et un accesseur ne peuvent pas porter le même nom. Le souligné est une convention courante pour le champ caché derrière un accesseur.
Pourquoi = 0 sur _prixHT ? En mode strict, TypeScript exige que chaque champ soit initialisé, et il ne sait pas que le setter appelé dans le constructeur s’en charge. Sans cette valeur initiale : Property '_prixHT' has no initializer and is not definitely assigned in the constructor.
TAUX_TVA est static parce qu’il ne dépend d’aucun article : il n’existe qu’en un exemplaire, et s’écrit Article.TAUX_TVA, même à l’intérieur de la classe.
Exercice 3.4 : Un panier, entre tableaux et classes
entraînement transfert
Cet exercice réunit la séance 2 et la séance 3 : une classe qui protège un tableau d’objets, et des méthodes écrites avec filter et reduce.
interface ArticlePanier {
readonly nom: string;
readonly prix: number;
}Écrivez une classe Panier :
- un champ
private articles: ArticlePanier[], initialisé à[]; ajouter(article: ArticlePanier): void, qui refuse un prix négatif (elle ne fait alors rien) ;retirer(nom: string): boolean, qui retire tous les articles de ce nom et renvoietruesi au moins un a été retiré ;total(): number, la somme des prix, avecreduce;articlesAbordables(prixMax: number): ArticlePanier[], les articles dont le prix ne dépasse pasprixMax, avecfilter;nombreArticles(): number.
Puis répondez : si l’appelant fait un push dans le tableau renvoyé par articlesAbordables, le panier est-il modifié ? Et s’il essaie de changer le prix d’un des articles de ce tableau ?
Réussite : le total est correct, un article à prix négatif n’entre jamais dans le panier, et rien de ce que renvoie la classe ne permet de contourner cette règle.
panier.ts
interface ArticlePanier {
readonly nom: string;
readonly prix: number;
}
class Panier {
private articles: ArticlePanier[] = [];
ajouter(article: ArticlePanier): void {
if (article.prix < 0) return; // le prix négatif est simplement ignoré
this.articles.push(article);
}
retirer(nom: string): boolean {
const avant = this.articles.length;
this.articles = this.articles.filter((a) => a.nom !== nom);
return this.articles.length < avant;
}
total(): number {
return this.articles.reduce((somme, a) => somme + a.prix, 0);
}
articlesAbordables(prixMax: number): ArticlePanier[] {
return this.articles.filter((a) => a.prix <= prixMax);
}
nombreArticles(): number {
return this.articles.length;
}
}
const panier = new Panier();
panier.ajouter({ nom: "Clavier", prix: 45 });
panier.ajouter({ nom: "Souris", prix: 12.5 });
panier.ajouter({ nom: "Câble", prix: 8.9 });
panier.ajouter({ nom: "Article frauduleux", prix: -100 }); // refusé
console.log(panier.nombreArticles()); // 3
console.log(panier.total()); // 66.4
const abordables = panier.articlesAbordables(20);
console.log(abordables.map((a) => a.nom).join(", ")); // Souris, Câble
abordables.push({ nom: "Intrus", prix: -1 });
console.log(panier.nombreArticles()); // 3 : le panier n'a pas bougé
console.log(panier.retirer("Souris")); // true
console.log(panier.retirer("Souris")); // false : il n'y en a plus
console.log(panier.total()); // 53.9
// abordables[0].prix = -1;
// ERREUR : Cannot assign to 'prix' because it is a read-only property.Le point clé : private ne sert pas à cacher une donnée, il sert à garantir un invariant : « aucun article du panier n’a un prix négatif ». Cet invariant tient parce que rien de ce qui sort de la classe ne donne accès au tableau interne :
filterfabrique toujours un nouveau tableau : unpushsurabordablesne touche pasthis.articles;- les articles, eux, sont partagés entre les deux tableaux (la copie est superficielle, comme au chapitre 2.2). C’est
readonlydans l’interface qui empêche de changer leur prix par ce biais.
L’erreur classique est d’ajouter un accesseur naïf, qui ruine tout le travail :
getArticles(): ArticlePanier[] {
return this.articles; // renvoie LA référence : l'appelant peut tout modifier
}Un panier.getArticles().push({ nom: "Gratuit", prix: -50 }) contourne alors la vérification de ajouter. Renvoyez une copie, [...this.articles], ou un résultat de filter ou de map, qui en sont déjà.
Dernier piège : le 0 final de reduce n’est pas décoratif. Sans lui, total() sur un panier vide lève TypeError: Reduce of empty array with no initial value.
QCM
1. Pourquoi ce code ne compile-t-il pas ?
class Compte {
solde: number = 0;
crediter(montant: number): void {
solde += montant;
}
}A. solde devrait être private. B. Il manque un constructeur. C. Dans une méthode, le champ s’écrit this.solde. D. += est interdit sur un champ.
C. solde tout seul désigne une variable qui n’existe pas. Le compilateur le dit lui-même : Cannot find name 'solde'. Did you mean the instance member 'this.solde'?. Un champ initialisé à la déclaration n’a pas besoin de constructeur.
2. Que valent Numero.total et b.rang ?
class Numero {
static total: number = 0;
rang: number;
constructor() {
Numero.total++;
this.rang = Numero.total;
}
}
const a = new Numero();
const b = new Numero();
const c = new Numero();A. 3 et 3 B. 3 et 2 C. 1 et 2 D. 3 et 1
B. total est partagé par toute la classe : il vaut 3 après trois créations. rang est propre à chaque instance, et b l’a reçu quand total valait 2.
3. Un champ private est lu par du code JavaScript, après transpilation. Que se passe-t-il ?
A. Une erreur à l’exécution. B. La lecture fonctionne : private n’est vérifié qu’à la compilation. C. La valeur lue est undefined. D. Le champ a été supprimé par la transpilation.
B. Les types, et private avec eux, disparaissent à la transpilation. Seule la notation JavaScript #champ protège aussi à l’exécution.
À retenir
- Une
classréunit données et méthodes ;newfabrique une instance - Dans une méthode, un champ s’écrit toujours
this.champ privateprotège un invariant : toute modification passe par vos méthodes- Ne renvoyez jamais la référence d’un tableau
private: renvoyez une copie getsanssetdonne une propriété calculée en lecture seule ;setsert à validerstaticappartient à la classe, en un seul exemplaire- Pas de règle à défendre : un objet littéral et une
interfacesuffisent - Une classe par fichier, exportée, importée là où on s’en sert