Les classes en ES2015+
Par Christophe Porteneuve • Publié le 17 janv. 2022

Mise à jour le 28 juin 2022, 21:48

Bienvenue dans notre troisième article de la série JS idiomatique.

Si JS a toujours permis la Programmation Orientée Objets (POO), son recours initial à la POO prototypale a dérouté la majorité des gens. Depuis ES2015 toutefois, et de façon plus ou moins continue depuis, JS s'est doté de syntaxes plus traditionnelles et d'extensions régulières de fonctionnalités pour la définition et la gestion de classes et de leurs instances.

Tu connais déjà class, extends, constructor et super ? C'est ce que tu crois… et en prime, y'a plein de nouveaux trucs depuis. Voici un bon gros tour de piste, plein de détails croustillants.

Vous préférez une vidéo ?

Si tu es du genre à préférer regarder que lire pour apprendre, on a pensé à toi :

POO traditionnelle / classique vs. POO prototypale : un rappel

JavaScript et Java ont été conçus en même temps, pour sortir en même temps, dans le même produit (Netscape Navigator 2, fin 1995). Je vous racontais déjà les détails il y a… 10 ans #coupdevieux.

Les commerciaux de Sun ont fait pression pour que JS ne fasse pas d'ombre à Java, qui devait être marketé comme le langage « sérieux », « professionnel », etc. afin de détrôner C++. Du coup, JS n'avait absolument pas le droit de mettre en avant des fonctionnalités POO similaires à Java, et s'est donc vu interdire d'utiliser les mots-clés concernés, qui sont devenus mots réservés, interdits pour tout identifiant ou nom de propriété (ex. class, extends, interface, implements, private, public, final…). C'est la raison pour laquelle le DOM déclare des propriétés className (ce qu'on a retrouvé notamment dans React, au grand dam de beaucoup ; ES5 (2009) a toutefois permis d'utiliser des mots réservés comme noms de propriétés. On peut donc faire const obj = { class: 'Foo' } mais pas const class = 'Foo'.)

Seulement voilà, il était hors de question pour Brendan Eich, le créateur de JS, de pondre un langage pourri, sans fonctionnalités orientées objets. Il a donc été puiser dans tout le reste de la POO, au-delà de l'approche minimaliste dite « traditionnelle », en allant notamment du côté de la POO prototypale, inspirée de Self par exemple. On la trouve aujourd'hui dans divers langages, dont IO.

Dans cette approche, on n'a pas de classes à proprement parler, uniquement des objets. Et tout objet peut servir de prototype à d'autres. Un objet C peut avoir pour prototype un objet B, qui a lui-même pour prototype un objet A… Ça ressemble à une hiérarchie d'héritage de classes, mais sans les classes, et avec surtout la possibilité de modifier tant les relations de prototype que le contenu de ces objets, ce qui rend tout ça nettement plus dynamique !

En pratique, la POO prototypale peut simuler complètement la POO traditionnelle, mais peut aussi aller beaucoup plus loin (comme par exemple Ruby ou le boss originel de la POO : Smalltalk), en permettant un typage hautement dynamique, l'évolution à la volée des relations de prototypage ou l'extension des prototypes, les objets singletons, les eigenclasses, etc.

À quoi ça ressemble, en prototypal ?

Imaginons qu'on veuille faire une classe de base Shape dotée de quelques méthodes, puis une classe Square qui la spécialise. On va volontairement opter pour du code façon ES3 (pré-2009) et sans triturer la propriété alors pseudo-officielle __proto__. (Mais en ES5, on aurait pu passer par des descripteurs de propriétés et Object.create() afin de rendre tout ça plus clean). Ça donnerait ça :

// Classe + constructeur
function Shape(x, y) {
  this.moveTo(x, y)
  this.fillColor = 'white'
  this.strokeColor = 'black'
  this.strokeWidth = 1
}
// Méthodes d'instance
Shape.prototype.moveTo = function moveTo(x, y) {
  this.x = x
  this.y = y
}
Shape.prototype.moveBy = function moveBy(dx, dy) {
  this.x += dx
  this.y += dy
}
Shape.prototype.draw = function draw() {
  // Code de préparation du dessin, mais pas le dessin effectif,
  // car on ne sait pas encore de quelle forme il s'agit…
}
// Méthodes statiques
Shape.createRandomShape = function createRandomShape() {
  return new Shape(Math.random(), Math.random())
}

function Square(x, y, size) {
  Shape.call(this, x, y)
  this.size = size
}

inherit(Square, Shape)

Square.prototype.draw = function draw() {
  Shape.prototype.draw.call(this)
  // Code de dessin effectif basé sur x, y et size
}

// Et l'utilitaire qui tape (faute d'Object.create) :
function inherit(Child, Parent) {
  function Synth() {}
  Synth.prototype = Parent.prototype
  Child.prototype = new Synth()
  Child.prototype.constructor = Child
}

Ça pique les yeux hein ?! Hé oui… Pourtant, ça offre une puissance folle. Mais c'est hyper déroutant quand on vient d'une syntaxe plus classique et répandue.

Syntaxe de classes ES2015

ES2015 (longtemps appelé ES6) a fait deux constats majeurs :

  • La syntaxe liée au mode prototypal est un gros frein pour la plupart des gens, sans parler des concepts sous-jacents, qui semblent exotiques.
  • En prototypal comme en traditionnel, beaucoup de gens tombent dans divers pièges usuels de conception de leurs classes, surtout lorsqu'ils définissent des hiérarchies de classes.

Elle a donc sorti une nouvelle syntaxe de classes, qui était tout de suite beaucoup plus familière. Pour reprendre l'exemple précédent :

class Shape {
  constructor(x, y) {
    this.moveTo(x, y)
    this.fillColor = 'white'
    this.strokeColor = 'black'
    this.strokeWidth = 1
  }

  static createRandomShape() {
    return new Shape(Math.random(), Math.random())
  }

  moveTo(x, y) {
    this.x = x
    this.y = y
  }

  moveBy(dx, dy) {
    this.x += dx
    this.y += dy
  }

  draw() {
    // Code de préparation du dessin, mais pas le dessin effectif,
    // car on ne sait pas encore de quelle forme il s'agit…
  }
}

class Square extends Shape {
  constructor(x, y, size) {
    super(x, y)
    this.size = size
  }

  draw() {
    super.draw()
    // Code de dessin effectif basé sur x, y et size
  }
}

Aaaaah, ben voilà ! Tout de suite, on se sent à la maison !

Le balèze caché

Si la syntaxe semble familière, elle offre toutefois beaucoup plus de flexibilité que ce qu'on trouve dans la plupart des autres langages.

Pour commencer, la classe elle-même est une expression, pas une déclaration. On peut donc stocker la référence dans une variable, ou même la renvoyer à la volée. Du coup, elle peut être anonyme. Par exemple :

function wrapCounterWithClass(counterFx) {
  return class {
    getNextIndex() {
      return counterFx()
    }
  }
}

const WrappedCounter = wrapCounterWithClass(genNumbers)
// …
const counter = new WrappedCounter()
counter.getNextIndex() // etc.

C'est parce que sous le capot, une classe reste une fonction, la nouvelle syntaxe ne change rien à l'implémentation sous-jacente à base de fonctions et prototypes :

typeof class {} // => 'function'

Encore plus fort : la clause extends accepte elle aussi une expression, et pas un simple identifiant fixe. On peut donc étendre une classe fournie dynamiquement, voire construite à la volée par des appels de fonction :

function subClass(parentClass) {
  return class extends parentClass {
    // …
  }
}

function makeClassWithMixins(...mixins) {
  return class extends buildMixinPrototypeChain(mixins) {
    // … (la fonction 'buildMixinPrototypeChain' serait à implémenter
    // manuellement, JS n’en fournit pas une, hein.)
  }
}

Note au passage qu'une définition de classe étant une expression, elle n'est pas hoistée, contrairement aux déclarations de fonctions. Tout comme les deux autres mots-clés déclaratifs introduits par ES2015 (const et let), ce qu'elle déclare n'est donc accessible que dans la suite de notre code, pas avant :

new Person() // => ReferenceError

class Person {}

Pas que de la syntaxe, mais des protections en prime

Le recours à la syntaxe moderne pour définir des classes va bien au-delà du simple confort d'écriture : JS en a profité pour te protéger contre bon nombre de pièges usuels et « resserrer les boulons » au passage.

Tout d'abord, puisqu'un corps de classe ne pouvait pas exister dans du code pré-ES2015, on n'a pas à maintenir la compatibilité ascendante. Du coup, le code d'une classe ES2015 est automatiquement en mode strict, ce qui nous permet certains blindages, le plus important ici étant sans doute un meilleur comportement de this en cas de référence pas immédiatement appelée sur nos méthodes (il sera à undefined au lieu de l'objet global).

Il est par ailleurs encore trop fréquent de voir des classes instanciées en oubliant l'opérateur new, ce qui appelle le constructeur comme une bête fonction. Avec du code à l'ancienne, il fallait blinder contre ça manuellement, pour éviter de polluer à tort l'objet global et renvoyer par défaut undefined au lieu d'une nouvelle instance :

// ⚠ À l'ancienne, sans blindage
function Person(first, last) {
  this.first = first
  this.last = last
}

const alice = Person('Alice', 'Avril')
alice // => undefined 😱
first // => 'Alice'   😱
last // => 'Avril'   😱

// Avec un blindage explicite, mais manuel
function Person(first, last) {
  if (!(this instanceof Person)) {
    throw new TypeError(
      "Class constructor Person cannot be invoked without 'new'"
    )
  }
  this.first = first
  this.last = last
}

const bob = Person('Bob', 'Sponge')
// => TypeError !

Les fonctions JS ont un marqueur interne (ce que la spec appelle un internal slot) appelé [[IsClassConstructor]], qui est à true pour celles représentant des classes définies par class. L'opération [[Call]] du moteur JS, qui implémente l'appel classique d'une fonction (par opposition à [[Construct]], qui s'occupe des appels via new), vérifie ce marqueur et, s'il est actif, lève une TypeError. On est donc blindés d'office contre un oubli du new.

Un dernier souci classique survient lorsqu'une classe en spécialise une autre (par héritage) mais oublie d'appeler le constructeur parent dans le sien, ou l'appelle après avoir initialisé des champs :

class Person {
  constructor(first, last) {
    this.first = first
    this.last = last
  }
}

// Cas 1 : j'oublie d'appeler la construction parente
class Geek extends Person {
  constructor(first, last, nick) {
    this.nick = nick
  }
}

// Cas 2 : je l'appelle, mais après avoir initialisé des trucs sur `this`
class Geek extends Person {
  constructor(first, last, nick) {
    this.nick = nick
    super(first, last)
  }
}

JS n'appelle pas automagiquement le constructeur parent dans le constructeur fils, ne serait-ce que parce qu'il n'a pas forcément une signature identique au nôtre, et parce qu'on peut vouloir procéder au pré-traitement des arguments avant d'en transmettre quelques-uns. Il nous appartient de l'appeler explicitement.

Ne pas appeler son constructeur parent au sein du nôtre, c'est un peu comme monter les murs sans avoir coulé les fondations !

Le risque avec le 2e cas de figure, c'est que la construction de la classe parent évolue ultérieurement pour initialiser elle aussi les champs qu'on a initialisés dans le constructeur fils, écrasant discrètement nos propres initialisations, réalisées trop tôt. Par exemple, si plus tard le code de Person devenait ça :

class Person {
  constructor(first, last) {
    this.first = first
    this.last = last
    this.nick = null
  }
}

…on se retrouverait subitement avec des instances de Geek dont le champ nick est null, alors qu'il semble bien initialisé dans le constructeur (et que ça marchait avant, alors que le code de Geek n'a pas changé d'un iota). C'est difficile à déboguer.

Pour toutes ces raisons, les constructeurs ES2015 exigent, au sein d'une classe en spécialisant une autre, qu'on appelle le constructeur parent, et qui plus est avant toute manipulation de this. À défaut, instancier notre classe lèvera une ReferenceError au message explicite :

new Geek('Thomas', 'Anderson')
// => ReferenceError: Must call super constructor in derived class
// before accessing 'this' or returning from derived constructor

On est ainsi protégés contre ce type de risque.

Pour finir, lorsqu'on définissait nos classes à l'ancienne, le simple ajout au prototype créait des propriétés énumérables, ce qui pourrissait les for…in notamment :

// ⚠ À l'ancienne, PaBô™
function Character(first, last) {
  this.first = first
  this.last = last
}
Character.prototype.greet = function greet(whom) {
  return 'Bonjour ' + whom + ", je m'appelle " + this.first
}

const jamie = new Character('Nomi', 'Marks')
for (var prop in jamie) {
  console.log(prop)
}
// first, last, greet 😱

Il aurait fallu, à partir d'ES5, passer par des descripteurs de propriétés pour définir les méthodes comme non-énumérables :

// ⚠ À l'ancienne, PaBô™
function Character(first, last) {
  this.first = first
  this.last = last
}
Object.defineProperty(Character.prototype, 'greet', {
  // Bon, là, elle sera au passage non-supprimable et
  // non-réaffectable, mais c'est plutôt pas mal.
  value: function greet(whom) {
    return 'Bonjour ' + whom + ", je m'appelle " + this.first
  },
})

const jamie = new Character('Nomi', 'Marks')
for (var prop in jamie) {
  console.log(prop)
}
// first, last

Bonne nouvelle donc : les méthodes d'instance définies dans un corps de classe ES2015 sont automatiquement non-énumérables. Parce que bon, quand même.

Interopérabilité avec l'ancien monde

Encore une fois, sous le capot, les classes restent implémentées de la même façon : ce sont des fonctions, avec leurs prototypes. Le savoir-faire historique reste donc valable, et l'interopérabilité est totale entre les classes définies à l'ancienne et celles utilisant la nouvelle syntaxe.

En particulier, elles peuvent hériter l'une de l'autre sans souci (dans un sens comme dans l'autre).

Et après ES2015 ?

La plupart des gens s'arrêtent aux syntaxes vues jusqu'à présent, introduites en ES2015, ce qui pour résumer comprend :

  • class
  • extends
  • constructor
  • super
  • static pour les méthodes

Mais le boulot a continué depuis, et même s'il a connu beaucoup d'allers-retours, impasses, refontes et détours, ES2022 apporte enfin l'aboutissement de nombreux travaux. Ils sont d'ores et déjà pris en charge nativement dans la quasi-totalité des navigateurs evergreens (Safari traîne un peu sur certains points) et depuis Node 16 (et pour les autres, il y a Babel).

Faisons un petit passage en revue.

Initialiseurs de champs

Popularisés notamment par les tutos React, la syntaxe d'initialiseurs de champs d'instance vise à éviter de devoir écrire un constructeur qui se contente de ces initialisations. Imaginons qu'on ait la classe existante suivante :

class Crypto {
  cipher() {
    /* … */
  }
  decipher() {
    /* … */
  }
}

Supposons qu'on souhaite initialiser un buffer interne à la construction, dont la nouvelle implémentation de nos méthodes a besoin. Avant ES2022, il fallait créer un constructeur pour ça :

// Pré-ES2022, on devait créer le constructeur
class Crypto {
  constructor() {
    this.buffer = new Uint8Array(256)
  }

  cipher() {
    /* … */
  }
  decipher() {
    /* … */
  }
}

On peut désormais initialiser directement depuis le corps de classe :

class Crypto {
  buffer = new Uint8Array(256)

  cipher() {
    /* … */
  }
  decipher() {
    /* … */
  }
}

Dans React, les composants à base de classe (qui ont largement cédé la place aux fonctions composants, éliminant ce sujet complètement) utilisaient beaucoup ça :

class OldSchoolComponent extends React.Component {
  state = { visible: this.props.initialVisible, collapsed: true }

  // …

  render() {
    /* … */
  }
}

Remarquez que du coup, dans l'expression d'initialisation, this fait référence à l'instance fraîchement créée. C'est véritablement l'équivalent de ce même code, préfixé par this., à l'intérieur du constructeur. Un truc de ce genre-là :

// "Desugaring" de l'initialisateur de champ d'instance vu juste avant
class OldSchoolComponent extends React.Component {
  constructor(...args) {
    super(...args)
    this.state = { visible: this.props.initialVisible, collapsed: true }
  }

  // …
}

Il est aussi possible, désormais d'initialiser des champs statiques, et pas seulement d'instance. Ça évite comme toujours de devoir attendre la fin du corps de classe pour les coller derrière, par exemple :

class OldSchoolComponent extends React.Component {
  static defaultProps = {
    initialVisible: false,
  }

  static propTypes = {
    initialVisible: bool.isRequired,
    items: arrayOf(ItemPropType).isRequired,
  }

  state = { visible: this.props.initialVisible, collapsed: true }

  // …

  render() {
    /* … */
  }
}

Membres réellement privés

Dès la sortie de la syntaxe de classes avec ES2015, des voix se sont élevées pour se plaindre de l'absence des qualificateurs classiques private, protected et public.

Tout d'abord, il faut bien comprendre que, dans la majorité des langages, ces qualificateurs sont plus « pour info » que pour la sécurité. Ils sont rarement inviolables. Par exemple :

  • En Java, l'API de réflexivité permet de lire un champ privé (ou d'appeler une méthode privée), sauf exigence contraire d'un security manager actif (ex. obj.class.getDeclaredField('secret').get(obj)).
  • En C#, l'API de réflexivité permet aussi ça (avec GetField et BindingFlags notamment).
  • En PHP, idem (via ReflectionProperty).
  • En Python, le côté privé se résume à une convention de nommage (préfixe __) qui n'empêche pas l'accès externe, même si celui-ci doit être préfixé.
  • En Ruby, la méthode .send permet de lire ou d'appeler des membres privés d'un objet.
  • etc.

En somme, ces qualificateurs sont des indices, pas des blindages de sécurité. Ils nous disent « attention, ça c'est ma tambouille interne, touches-y à tes risques et périls, ça peut péter et je peux tout virer dans ma prochaine release si j'ai envie ».

JS, lui, s'exécute dans des environnements où notre code n'est pas tout seul (une page web avec, souvent, moult scripts tiers parfois très douteux), et l'impératif de sécurité est donc très fort.

On pouvait certes réaliser ce genre de trucs avec des cerceaux en flammes à base de WeakMap et de déclarations locales au module, mais c'était contortionniste à l'usage.

ES2022 apporte donc une syntaxe permettant de donner un caractère véritablement privé à nos membres (champs et méthodes, d'instance ou statiques). Par « privé », il faut ici entendre « inaccessibles hors du code de la classe concernée ». Seul le code déclaré au sein du corps de classe d'origine pourra y accéder.

En revanche, une instance A peut très bien accéder aux membres privés d'une instance B de la même classe : c'est le même code source, ça n'aurait pas de sens de l'empêcher d'accéder aux données d'une autre instance, puisqu'ayant la main sur le code source de la classe, il pourrait implémenter aisément son propre contournement.

Les membres privés préfixent leur identifiant par le caractère dièse (#), tant à la déclaration qu'à l'indexation, ce qui a le mérite de signaler immédiatement, à la consultation / l'appel, qu'on travaille avec un membre privé. Pour les champs, il est impératifs qu'ils soient déclarés avant d'être utilisés (pas de création à la volée comme pour les champs publics classiques), généralement en début de corps de classe.

class PasswordManager {
  static #STORAGE_KEY = Symbol('pm:storage-key')
  #key

  constructor(key) {
    this.#key = key
  }

  addIdentity(identity) {
    const store = this.#open()
    store.add(identity)
    store.persist()
  }

  #open() {
    return CryptoStorage.get(PasswordManager.#STORAGE_KEY).open(this.#key)
  }
}

Ce caractère ne fait pas partie du nom de la propriété ou méthode, mais celle-ci n'est pas exploitable sans.

Les membres privés ne figurent par ailleurs nulle part dans les mécanismes de listage de membres (ex. Object.keys(), Object.getOwnPropertyNames(), etc.) et ne sont pas indexables dynamiquement (avec [] ou ?.[]).

En savoir plus chez Axel ou dans le MDN.

Blocs d'initialisation statiques

Les instances ont le constructeur pour s'initialiser, mais les classes ? Parfois, définir une classe suppose d'en initialiser les champs statiques d'après un contexte externe, ce qui nécessite du code.

Il était naturellement possible de faire ça après le corps de classe, dans la suite du module qui la définit. Mais c'est dommage. On avait les initialiseurs de champs statiques (qui prenaient une simple expression) et les méthodes statiques : on a désormais les blocs d'initialisation statiques.

Ça permet notamment d'initialiser de multiples champs statiques via une même opération, ou simplement d'initialiser des champs statiques en se servant de membres statiques privés (inaccessibles depuis du code hors du corps de la classe).

Dans un bloc d'initialisation statique comme dans une expression d'initialisation de champ statique, this référence la classe elle-même.

Pour voler un exemple à Axel Rauschmayer :

class Translator {
  static translations = {
    yes: 'ja',
    no: 'nein',
    maybe: 'vielleicht',
  }
  static englishWords = []
  static germanWords = []
  static {
    // Et hop ! On peut initialiser les deux champs grâce à la même
    // opération, plutôt que de la faire deux fois, ce qui peut être
    // inutilement long / lourd voire limité par contrainte.
    for (const [english, german] of Object.entries(this.translations)) {
      this.englishWords.push(english)
      this.germanWords.push(german)
    }
  }
}

Note qu'il est possible d'avoir plusieurs blocs d'initialisation statiques au fil du corps de la classe. Ils sont exécutés, avec les autres initialiseurs statiques, de haut en bas.

En savoir plus chez Axel ou dans le MDN.

Et l'avenir ?

Ouh là, plein de choses. On ne va pas s'apesantir dessus, d'autant que beaucoup de trucs n'en sont qu'au stade 2 voire 1 du processus de normalisation, et n'ont parfois pas été représentés en comité de standardisation depuis des années.

Mais y'a des trucs cool :

Décorateurs

Les habitués de TypeScript les connaissent bien, et de nombreux frameworks se basent fortement dessus (Angular, Nest…). Ils auraient dû sortir il y a longtemps, mais ont dû repenser toute leur spécification à la dernière minute quand quelqu'un a levé un loup jusque-là passé inaperçu.

Ils sont encore au stade 2 et ont été traités pour la dernière fois en décembre 2021, donc tout récemment. On est sûrs que ça va finir par aboutir, mais ça prend du temps.

Les décorateurs, similaires aux annotations Java (mais plus puissants) et aux attributs PHP par exemple, sont une manière confortable de faire de l'Aspect-Oriented Programming (ou AOP), en fournissant in-situ des métadonnées pour nos classes et méthodes.

Le langage fournit la mécanique des décorateurs, mais c'est l'écosystème qui développe les décorateurs spécifiques dont on a besoin. De nombreux modules npm fournissent des décorateurs métiers (dont le célèbre @autobind), et on en retrouve, comme je le disais, dans de nombreux frameworks :

// Angular 2+
import { Component, OnInit } from '@angular/core'

@Component({
  selector: 'app-product-alerts',
  templateUrl: './product-alerts.component.html',
  styleUrls: ['./product-alerts.component.css'],
})
export class ProductAlertsComponent implements OnInit {
  // …
}

// Nest.js
import { Controller, Get } from '@nestjs/common'

@Controller('cats')
export class CatsController {
  @Get()
  findAll(): string {
    return 'This action returns all cats'
  }
}

Les trucs nettement moins sûrs…

On pourra peut-être prochainement combiner champs privés et déstructuration, faire des déclarations privées accessibles depuis une portée plus large que le corps de classe, accéder plus simplement aux membres statiques depuis les instances, appeler facilement des méthodes génériques sur des objets précis ou encore améliorer la définition des accesseurs.

Les fans d'interfaces semi-implémentées, mixins et autres modules façon Ruby bavaient depuis longtemps sur les first-class protocols, mais ça végète depuis mi-2018 😩

Ça vous a plu ? Ne manquez pas la suite !

Pour être sûr·e de ne rater aucun de nos tutos et articles, le mieux est encore de vous abonner à notre newsletter et à notre chaîne YouTube. Vous pouvez aussi nous suivre sur Twitter.

Et bien entendu, n'hésitez pas à jeter un œil à nos formations ! Si explorer l'intégralité des recoins du langage vous intéresse, la ES Total notamment est faite pour vous !

Découvrez nos cours vidéo ! 🖥

Nos cours vidéo sont un complément idéal et bon marché à nos articles techniques et formations présentielles. Autour de Git, de JavaScript et d’autres sujets, retrouvez des contenus de très grande qualité à des prix abordables, spécialement conçus pour lever vos plus gros points de blocage.