Séance 3

Programmation orientée objet

Rémi Sharrock

TypeScript 101, Télécom Paris

Échauffement

Trois questions sur la séance 2, tirées des QCM du cours.

Question 1 (chapitre 2.1)

Que fait saluer ?

const saluer = (nom: string) => {
  return () => console.log(`Bonjour, ${nom}!`);
};
  • A. Elle affiche un message de salutation
  • B. Elle renvoie le message sous forme de chaîne
  • C. Elle renvoie une fonction qui prend un nom en paramètre
  • D. Elle renvoie une fonction qui affiche un message de salutation

D. saluer("Alice") n’affiche rien : elle fabrique une fonction (une fermeture).

Question 2 (chapitre 2.2)

Que vaut a.prix à la fin ?

const a = { titre: "Dune", prix: 10 };
const b = a;
b.prix = 20;
  • A. 10
  • B. 20
  • C. undefined
  • D. Une erreur de compilation, car a est const

B. b = a ne copie rien : a et b désignent le même objet.

Question 3 (chapitre 2.3)

Que vaut [3, 1, 4, 1, 5].reduce((acc, x) => acc + x, 10) ?

  • A. 14
  • B. 24
  • C. 10
  • D. Une erreur

B. L’accumulateur part de 10, puis 10 + 3 + 1 + 4 + 1 + 5 = 24.

Objectifs

  • Écrire une class : champs, constructeur, méthodes, this
  • Protéger un invariant avec private, get et set
  • Choisir entre une classe et un objet littéral
  • Imposer un contrat avec implements, réutiliser avec extends
  • Comprendre le polymorphisme et les classes abstract

Deux chapitres : 3.1 Classes et encapsulation, 3.2 Contrats, héritage et polymorphisme.

Classes et encapsulation

Des données au comportement

En séance 2, un objet ne portait que des données, et le code vivait à part :

interface Compte { titulaire: string; solde: number; }

function deposer(compte: Compte, montant: number): void {
  compte.solde += montant;
}

Rien n’empêche d’écrire compte.solde = -500 n’importe où.

Une classe réunit les données et le code qui a le droit de les modifier. C’est ce qui permet de faire respecter des règles.

Un moule à objets

class Personne {
  nom: string;
  age: number;
  constructor(nom: string, age: number) {
    this.nom = nom;   // this.nom : la propriété de l'objet en construction
    this.age = age;   // age      : le paramètre du constructeur
  }
  saluer(): void {
    console.log(`Je m'appelle ${this.nom}.`);
  }
}
const alice = new Personne("Alice", 25);
alice.saluer();   // Je m'appelle Alice.

Trois mots-clés

  • 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

Dans une méthode, nom tout seul n’est pas le champ de l’objet. Le compilateur répond : Cannot find name 'nom'. Did you mean the instance member 'this.nom'?

Écrivez toujours this.nom.

Propriétés de constructeur

Un modificateur devant un paramètre déclare et initialise le champ :

class Personne {
  constructor(public nom: string, public age: number) {}
}

Strictement équivalent à la version longue.

Utilisez ce raccourci quand vous l’avez compris. La version longue reste parfaitement correcte.

public, private, protected

Décider ce qui est visible de l’extérieur. Par défaut, tout est public.

Modificateur Dans la classe Classes filles Extérieur
public (défaut) oui oui oui
protected oui oui non
private oui non non

Garantir un invariant

class CompteBancaire {
  private solde: number = 0;

  deposer(montant: number): void {
    if (montant <= 0) return;   // contrôle impossible à contourner
    this.solde += montant;
  }

  getSolde(): number { return this.solde; }
}
const c = new CompteBancaire();
c.solde = 1000000;   // ERREUR : Property 'solde' is private

Un invariant reste vrai pendant toute la vie de l’objet. Pour le vérifier, il suffit de relire les méthodes de la classe, pas tout le programme.

Accesseurs get et set

Ils s’utilisent comme des propriétés, mais exécutent du code.

class Temperature {
  private celsius: number = 0;

  get fahrenheit(): number { return this.celsius * 9 / 5 + 32; }
  set fahrenheit(v: number) { this.celsius = (v - 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 : propriété calculée, en lecture seule
  • un set : l’endroit naturel pour valider

private n’est pas une protection

Une vérification du compilateur, rien de plus

Après transpilation, le champ existe comme n’importe quel autre et reste accessible depuis du JavaScript.

Ce que l’on cherche, c’est empêcher les erreurs de conception, pas déjouer un attaquant.

La notation JavaScript #solde protège aussi à l’exécution. Vous la croiserez dans du code récent.

Membres static

Ils appartiennent à la classe, pas aux instances : un seul exemplaire, partagé.

class Numero {
  static numeroStatique: number = 0;   // partagée par TOUTES les instances
  numero: number = 0;                  // propre à CHAQUE instance

  constructor() {
    Numero.numeroStatique++;   // via le nom de la classe
    this.numero++;             // via this
  }
}

Trois new Numero() plus tard : Numero.numeroStatique vaut 3, chaque numero vaut 1.

Usage typique : une constante de classe, static readonly TAUX_TVA = 0.2.

Classe ou objet littéral ?

Situation Choix
Des données qui circulent (catalogue, réponse d’API) objet littéral + interface
Des données avec des règles à faire respecter classe + private
Plusieurs variantes d’un comportement classes + interface commune
Un calcul sans état simple fonction

Le critère décisif est la règle : sans invariant à défendre, un objet littéral suffit. Et le JSON reçu du réseau n’est jamais une instance.

Une classe par module

src/scripts/CompteBancaire.ts
export class CompteBancaire {
  private solde: number = 0;
  // ...
}
src/scripts/app.ts
import { CompteBancaire } from "./CompteBancaire.ts";

const compte = new CompteBancaire();

Le module choisit quelles classes sont visibles, private choisit quels membres.

Contrats, héritage, polymorphisme

implements

Une interface décrit ce qu’une classe doit savoir faire. Aucun code : un cahier des charges.

interface Electrical { getVoltage(): number; }
interface Good { getPrice(): number; }

class TV implements Electrical, Good {
  constructor(private price: number, private voltage: number) {}
  getVoltage(): number { return this.voltage; }
  getPrice(): number { return this.price; }
}
class Salad implements Good {          // une salade n'est pas un appareil
  constructor(private price: number) {}
  getPrice(): number { return this.price; }
}

Typer par l’interface

const appareils: Electrical[] = [];
appareils.push(new TV(1399, 220));
// appareils.push(new Salad(2.3));   // ERREUR : Salad n'est pas Electrical

appareils.forEach((e: Electrical) => console.log(e.getVoltage()));

Ce tableau ne sait rien des classes concrètes. Il sait seulement que chaque élément répond à getVoltage(). On ajoutera demain un Refrigerateur sans toucher une ligne de ce code.

extends et super

L’héritage exprime une relation « est un ».

class Person {
  constructor(public name: string, public age: number) {}
  logPerson(): void { console.log(`Name: ${this.name}, Age: ${this.age}`); }
}

class User extends Person {
  occupation: string;
  constructor(name: string, age: number, occupation: string) {
    super(name, age);              // constructeur de Person
    this.occupation = occupation;  // toujours APRÈS super()
  }
}

new User("Max", 30, "Dev").logPerson() fonctionne : la méthode est héritée.

L’ordre de super

super(...) doit être appelé avant tout accès à this.

this.occupation = occupation;
super(name, age);
// ERREUR : 'super' must be called before accessing 'this'
//          in the constructor of a derived class

La raison est logique : tant que le constructeur de la mère n’a pas tourné, l’objet n’est pas initialisé.

Redéfinir une méthode

class Admin extends Person {
  constructor(name: string, age: number, public role: string) {
    super(name, age);
  }

  logPerson(): void {                  // remplace la version de Person
    super.logPerson();                 // ... en réutilisant celle-ci
    console.log(`Role: ${this.role}`);
  }
}
  • même nom, même signature : la version de la fille remplace celle de la mère
  • super.logPerson() appelle la version de la mère

Le polymorphisme

« Plusieurs formes » : on manipule des objets de classes différentes à travers un type commun, et chacun exécute sa propre version d’une méthode.

const persons: Person[] = [
  new User("Max", 30, "Frontend Developer"),
  new Admin("Max", 30, "Administrator"),
];

persons.forEach((p: Person) => p.logPerson());

Le type de la variable décide de ce qu’on a le droit d’appeler ; l’objet réel décide de quelle version s’exécute.

Le bénéfice réel

Aucun if, aucun switch sur le type.

Ajouter un cas se fait en ajoutant une classe, jamais en modifiant le code qui parcourt le tableau.

Quand on a réellement besoin du type concret, instanceof le donne :

const usersOnly = persons.filter((p) => p instanceof User);   // User[]

Les classes abstraites

Entre l’interface et la classe : du code partagé et des méthodes que les filles devront écrire. On ne peut pas l’instancier.

abstract class Person implements PersonInterface {
  constructor(public name: string, public age: number) {}

  abstract logPerson(): void;   // pas de corps : chaque fille DOIT l'écrire
}

// const p = new Person("X", 20);
// ERREUR : Cannot create an instance of an abstract class

Interface, abstract ou class ?

Besoin Outil
Un contrat, sans aucun code interface
Du code partagé entre classes proches abstract class
Créer des objets class

Une classe n’hérite que d’une seule classe, mais implémente autant d’interfaces qu’elle veut.

Bonne pratique : typez les tableaux et les paramètres par l’interface, pas par la classe abstraite.

Composition plutôt qu’héritage

class Vector {
  constructor(public start: Point, public end: Point) {}   // un Vector A deux Point
}

Un vecteur n’est pas un point : il en contient deux.

Posez-vous la question à voix haute : « est-ce que X est un Y ? ». Si la phrase sonne faux, composez.

Se méfier de as

as ne convertit rien et ne vérifie rien : il fait taire le compilateur.

const quelquun: Person = new Admin("Max", 30, "Administrator");
const u = quelquun as User;                 // accepté : TypeScript vous croit
console.log(u.occupation.toUpperCase());    // plantage à l'exécution

instanceof et typeof vérifient réellement :

if (quelquun instanceof User) {
  console.log(quelquun.occupation);   // ici, c'est sûr
}

Où vous retrouverez tout ça

Chapitre La POO dedans
5.1 JSON une instance transformée en JSON perd ses méthodes
5.2 Generics une classe générique
5.3 Exceptions extends Error, instanceof
7.2 Formulaires une méthode comme écouteur, .bind(this)
7.3 Web components extends HTMLElement

Chacun de ces chapitres contient un encadré « Rappel POO » qui renvoie ici.

À vous : par où commencer

Parcours essentiel, dans l’ordre

  • 3.1 D’une interface à une classe
  • 3.2 Un compteur qui ne déborde jamais
  • 3.5 Deux formes, un même contrat

Ensuite

  • 3.3 Un article avec get, set et une constante de classe (entraînement)
  • 3.4 Un panier, entre tableaux et classes (entraînement, transfert)
  • 3.6 Une médiathèque (entraînement, transfert)
  • 3.7 Véhicules, classe abstraite et polymorphisme (entraînement)
  • 3.8 Composition (approfondissement)

L’évaluation 1 porte sur les séances 1 à 3. Sa page, sur le site, donne la grille des compétences attendues.