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 get et set
  • distinguer un membre static d’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 :

  • new réserve un objet vide et appelle le constructeur ;
  • constructor initialise les champs ;
  • this dé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 private

Un 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 getter

Un 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 1

C’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());   // 50

Le 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;
}
  1. Remplacez l’interface et la fonction par une seule classe Livre : les mêmes champs, un constructeur, et une méthode decrire(): string.
  2. Créez deux instances, l’une avec ISBN, l’autre sans, et affichez leur description.
  3. 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.

  1. Le constructeur reçoit le maximum, qui ne pourra plus changer ensuite.
  2. La valeur, qui part de 0, est stockée dans un champ private.
  3. incrementer() ajoute 1, sauf si le maximum est atteint ; decrementer() retire 1, sauf si la valeur est déjà 0 ; reinitialiser() remet à 0.
  4. getValeur(): number permet 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 :

  1. un champ nom, public mais fixé à la création ;
  2. un prix hors taxe stocké dans un champ privé _prixHT, exposé par un get prixHT et un set prixHT ; le setter ignore une valeur négative ;
  3. une constante de classe static readonly TAUX_TVA = 0.2 ;
  4. un get prixTTC, calculé à partir du prix hors taxe et arrondi au centime, sans setter ;
  5. 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 :

  1. un champ private articles: ArticlePanier[], initialisé à [] ;
  2. ajouter(article: ArticlePanier): void, qui refuse un prix négatif (elle ne fait alors rien) ;
  3. retirer(nom: string): boolean, qui retire tous les articles de ce nom et renvoie true si au moins un a été retiré ;
  4. total(): number, la somme des prix, avec reduce ;
  5. articlesAbordables(prixMax: number): ArticlePanier[], les articles dont le prix ne dépasse pas prixMax, avec filter ;
  6. 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 :

  • filter fabrique toujours un nouveau tableau : un push sur abordables ne touche pas this.articles ;
  • les articles, eux, sont partagés entre les deux tableaux (la copie est superficielle, comme au chapitre 2.2). C’est readonly dans 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 class réunit données et méthodes ; new fabrique une instance
  • Dans une méthode, un champ s’écrit toujours this.champ
  • private protège un invariant : toute modification passe par vos méthodes
  • Ne renvoyez jamais la référence d’un tableau private : renvoyez une copie
  • get sans set donne une propriété calculée en lecture seule ; set sert à valider
  • static appartient à la classe, en un seul exemplaire
  • Pas de règle à défendre : un objet littéral et une interface suffisent
  • Une classe par fichier, exportée, importée là où on s’en sert