JavaScript, les bons éléments, façon 2020 : les classes, ou comment faire des objets structurés

Rebienvenue dans un nouvel article de blog. Aujourd’hui, on va parler d’une des plus grosses nouveautés de ces dernières années dans le JavaScript.

Et pourtant, celle-ci ne révolutionne rien ! Hormis une (énorme) subtilité, tout ce qu’on va voir était possible avant en JavaScript, sans avoir besoin de n’importe quelle structure.

…Attendez, ne partez pas ! Les classes font en fait office de sucre syntaxique, c’est à dire qu’elles rendent plus joli et plus clair des parties de code.

Alors, à quoi servent les classes ? A construire des objets qui se ressemblent : mêmes propriétés, même méthodes. Normalement, si vous avez déjà touché un langage datant d’autant moins mi-90, vous savez ce que c’est (surtout que bon, les objets, en JavaScript, c’est pas comme si on en voyait partout).

Attention, cet article contient beaucoup de code et d’exemples parfois complexes. En effet, les classes sont mieux à illustrer avec un comportement qui a du sens !

Construire des objets avant les classes

Les bases

Vraie question : Comment fait on, en JavaScript, pour construire des objets via un constructeur (sous-entendu, qui passera par l’utilisation de l’opérateur new) ?

On utilise des simples fonctions. On en a déjà un peu parlé lors de précédents articles, il y a une convention pour dire que les fonctions dont le nom commence par une majuscule construit des objets.

Petite illustration bienvenue :

// Le constructeur
function User(name, address) {
  this.name = name;
  this.address = address;
}

// Définition des méthodes
// Ne faites JAMAIS comme ça aujourd'hui, utilisez
// Object.defineProperty !
User.prototype.sayName = function () {
  console.log('Salut! Je suis ' + this.name + ' et j\'habite au ' + this.address + '.');
};

var paul = new User('Paul', '5 rue de la Boustifaille');

paul.sayName(); // -> Salut! Je suis Paul et j'habite au 5 rue de la Boustifaille.

Lorsqu’on utilise l’opérateur new, la fonction juxtaposée est appelée dans un contexte particulier.

Avant d’effectuer l’appel, un nouvel objet est créé. Le prototype de cet objet est défini à {constructeur}.prototype. La fonction constructeur est ensuite appelée avec pour valeur de this l’objet nouvellement créé.

Si la fonction constructrice renvoie un objet, alors cet objet est retourné par l’opérateur new. Si elle renvoie autre chose (une primitive, ou undefined), l’objet passé à l’appel en tant que this est renvoyé.

C’est l’équivalent du code suivant (ES5+ seulement) :

function makeNew(construct_fn, args) {
  var new_object = {};
  new_object.__proto__ = construct_fn.prototype;
  
  var return_val = construct_fn.apply(new_object, args);

  if (typeof return_val === 'object') {
    return return_val;
  }
  return new_object;
}

var paul = makeNew(User, ['Paul', '5 rue de la Boustifaille']);

Propriétés statiques, getters, setters

Définir une propriété/méthode statique en JavaScript se résume à ajouter une propriété/méthode sur le constructeur de votre objet. Attention cependant, contrairement à beaucoup de langages, les propriétés statiques ne sont pas disponibles sur les instances en JavaScript.

// On crée une propriété "default_address" sur le constructeur
User.default_address = '0 New York City';

// Méthode statique
User.changeDefaultAddress = function (new_address) {
  // this vaut User dans ce contexte
  this.default_address = new_address;
};

User.default_address; // '0 New York City'
// Les propriétés statiques n'arrivent pas sur les instances,
// car elles héritent de User.prototype, pas de User
paul.default_address; // undefined

Depuis ES5 (2009), vous pouvez définir des getters et des setters sur vos objets simplement, mais c’est un peu plus compliqué pour les objets construits. Il faut appeler manuellement Object.defineProperty dans le constructeur sur votre instance.

// Définition d'un getter pour les instances
Object.defineProperty(User.prototype, 'summary', {
  get: function () {
    return this.name + ' vit au ' + this.address + '.';
  },
});

paul.summary; // 'Paul vit au 5 rue de la Boustifaille.'

Pour un setter, c’est pareil, mais avec set au lieu de get. On peut définir un setter et un getter pour la même propriété, tant qu’on les définit en même temps.

Object.defineProperty(User.prototype, 'lowername', {
  set: function (val) {
    this.name = val.slice(0, 1).toUpperCase() + val.slice(1);
  },
  get: function () {
    return this.name.toLowerCase();
  },
});

paul.lowername = 'heather';
paul.lowername; // 'heather'
paul.name; // 'Heather'

Faire de l’héritage

C’est là que ça devient plus compliqué avec les objets ECMAScript classique. Comment hériter d’un constructeur ?

Il y a plusieurs actions à faire : Faire hériter les instances de {classe_originale}.prototype, et faire hériter le constructeur de {classe_originale}. Ensuite, pendant le "nouveau" constructeur, il faut appeler le constructeur parent avant de commencer à écrire le reste du code. Il faut bien passer les bons arguments au constructeur d’origine…

On va essayer de "simplifier" en faisant une fonction qui crée la classe enfante automatiquement.

function makeHeritage(origin, construct) {
  // Créé un "super" constructeur
  // pour englober l'appel au constructeur parent
  var new_constructor = function () {
    // Récupère tous les arguments passés sous forme de tableau
    var args = Array.prototype.slice.call(arguments);

    // Appelle le constructeur parent
    origin.apply(this, args);

    // Appelle le constructeur défini pour la classe héritée
    construct.apply(this, args);
  };

  // Définit notre origine comme prototype du nouveau constructeur
  Object.setPrototypeOf(new_constructor, origin);

  // Changement de la prop name de la fonction pour l'affichage en console
  Object.defineProperty(new_constructor, 'name', { value: construct.name });

  // Récupère les méthodes de l'origin dans le nouveau constructeur
  new_constructor.prototype = Object.create(origin.prototype);

  // Permet d'accéder à super dans les méthodes de sous-classe
  Object.defineProperty(new_constructor.prototype, '__super__', { value: origin.prototype });

  return new_constructor;
}

var SuperUser = makeHeritage(User, function SuperUser(name, address, rights) {
  this.rights = rights;
});

Object.defineProperty(SuperUser.prototype, 'changeRights', {
  value: function (rights) {
    this.rights = rights;
  },
});

var jimmy = new SuperUser('Jimmy', '99 Montée', 1)
jimmy.sayName(); // 'Salut! Je suis Jimmy et j'habite au 99 Montée.'
jimmy.rights; // 1
jimmy.changeRights(2);

Vous le voyez bien, ce n’est pas optimal. Réalisable, mais pas parfait.

Ce que vous voyez ici fonctionne toujours aujourd’hui en JS, tant que les paramètres origin et construct de makeHeritage sont des fonctions (et non pas des classes).

Introduction aux classes

Bienvenue dans le JavaScript post-2015. Bienvenue dans le JavaScript qui va vous faire aimer la programmation orienté objet, tout du moins autant qu’en Java, Python, Ruby, C#, C++, et bien d’autres.

Voici, rapidement, un résumé des classes JavaScript :

  • Sucre syntaxique (ce n’est qu’une autre façon d’écrire ce qu’on a vu précédemment)
  • Héritage simple
  • Définition avec class, pas de préfixe pour les méthodes ou les attributs
  • Pas de modificateur de visibilité (jusqu’en late-2020, on y reviendra)
  • Définition de méthodes statiques avec static
  • Une instance est un objet classique, on peut y ajouter/supprimer des propriétés à la volée, même hors de la classe, sans restriction
  • Instantiation avec new uniquement (c’est l’unique différence avec la méthode précédente, instancier sans new lance une exception)
  • Le constructeur est déterminé avec le "mot-clé" constructor (ce n’est pas un mot clé réservé, mais comme vous ne pouvez pas nommer une fonction constructor, c’est pratique)

Reproduire notre exemple initial

Vous allez voir qu’avec les classes, tout va rapidement se simplifier !

Reproduisons d’abord notre "classe" User de précédemment.

class User {
  constructor(name, address) {
    this.name = name;
    this.address = address;
  }

  sayName() {
    console.log('Salut! Je suis ' + this.name + ' et j\'habite au ' + this.address + '.');
  }
}

Comme vous pouvez le voir, pour définir une méthode, cela se passe avec la syntaxe raccourcie également disponible pour les objets standard. Il suffit de préciser le nom, les paramètres, puis d’ouvrir le corps avec les accolades. Simple et efficace.

Vous pouvez également (depuis mi 2019) déclarer les champs directement dans le corps de la classe, ce qui peut être utile pour les champs avec une valeur par défaut.

class User {
  role = 'user'; // le littéral 'user' sera ré-évalué à chaque instanciation
  name; // à titre indicatif, ne sert à rien
  address;

  constructor(name, address) {
    this.name = name;
    this.address = address;
  }

  // ...
}

const paul = new User('Paul', '5 rue de la Boustifaille');
paul.role; // 'user'

C’est aussi simple que ça. Bien plus propre, non ? 🙂

Méthodes et propriétés statiques

Pour les propriétés et méthodes statiques, c’est tout aussi simple. Rajoutez simplement le modificateur static devant le nom de propriété / méthode.

class User {
  static default_address = '0 New York City';

  static changeDefaultAddress(new_address) {
    // this vaut la classe, pas une instance dans ce contexte
    this.default_address = new_address;
  }

  // ...
}

Attention cependant, pour les propriétés, ce modificateur est très récent. Il n’est même pas encore supporté par Safari 14, le dernier navigateur d’Apple à ce jour (12/11/2020).

Pour ajouter une propriété statique… ajoutez simplement la propriété sur votre classe ! Cela va à l’encontre du principe d’une classe de tout faire en un seul bloc, mais c’est mieux supporté pour l’instant.

class User {
  static changeDefaultAddress(new_address) {
    // this vaut la classe, pas une instance dans ce contexte
    this.default_address = new_address;
  }

  // ...
}

User.default_address = '0 New York City';

Setters et getters

C’est tout aussi simple de définir des getters/setters que de définir des méthodes statiques ! Rajoutez simplement get ou set devant le nom d’une méthode pour la transformer en propriété calculée.

class User {
  get summary() {
    return this.name + ' vit au ' + this.address + '.';
  }

  get lowername() {
    return this.name.toLowerCase();
  }

  set lowername(val) {
    this.name = val.slice(0, 1).toUpperCase() + val.slice(1);
  }

  // ...
}

Hériter d’une classe ou d’une fonction

Vous vous souvenez comment on a galéré tout à l’heure pour hériter tout simplement de notre classe User pour faire un SuperUser ? Je vais simplement donner un exemple pour vous montrer à quel point ça change ici.

// Dans le extends, n'importe quel constructeur peut être spécifié,
// même une fonction classique, une autre classe ou un objet natif !
class SuperUser extends User {
  constructor(name, address, rights) {
    // Tout commence forcément avec un appel à super() 
    // pour initialiser la classe parente
    super(name, address);
    this.rights = rights;
  }

  changeRights(rights) {
    this.rights = rights;
  }

  // On peut surcharger la classe parente et appeler ses méthodes
  sayName() {
    return super.sayName() + ` Mes droits sont définis sur ${this.rights}.`;
  }
}

const jimmy = new SuperUser('Jimmy', '99 Montée', 1)
jimmy.sayName(); // 'Salut! Je suis Jimmy et j'habite au 99 Montée. Mes droits sont définis sur 1.'
jimmy.changeRights(2); 
SuperUser.default_address; // '0 New York City' ; Elle hérite même des props statiques

Une dernière chose à savoir : Les classes sont constantes. Lorsque vous définissez une classe, le nom devient réservé, comme si vous aviez réservé ce nom avec const, c’est à dire au niveau du bloc.

Usages avancés

Hériter de plusieurs sources

C’est quelque chose qu’on pouvait techniquement déjà faire, mais ça relève plus du hack qu’autre chose. Comme dit précédemment, ce n’est pas dans la volonté de l’ECMA.

Le principe en JavaScript reste de créer une "classe source" et de copier toutes les propriétés des classes "parentes" à utiliser.

Une chose à savoir c’est que après extends, n’importe quelle expression peut être utilisée : appel de fonction, variable utilisateur, etc. On peut donc utiliser cette permissivité pour créer des classes héritant de plusieurs sources.

function mix(...mixins) {
  class Mixin {}

  // On ajoute toutes les méthodes et accesseurs
  // des mixins à la classe Mixin.
  for (const mixin of mixins) {
    // Copie les props et méthodes statiques
    copyProperties(Mixin, mixin);
    // Props et méthodes des instances
    copyProperties(Mixin.prototype, mixin.prototype);
  }
  
  return Mixin;
}

function copyProperties(target, source) {
  for (const key of Reflect.ownKeys(source)) {
    if (key !== 'constructor' && key !== 'prototype' && key !== 'name') {
      Object.defineProperty(
        target,
        key, 
        Object.getOwnPropertyDescriptor(source, key)
      );
    }
  }
}

// Superclasse étendue depuis User et Serializable
class SerializableUser extends mix(User, Serializable) {}

Il faut néanmoins faire attention à l’ordre des paramètres (si deux méthodes/props ont le même nom, elles se feront remplacer par celui traité en dernier) et qu’aucun constructeur ne soit requis pour les classes étendues.

Modificateur public/privé

Depuis la superbe année 2020, il est possible dans la plupart des navigateurs de définir une propriété comme privée.

Attention, c’est pas du petit privé genre c’est pas énumérable : Une prop/méthode déclarée comme privée ne sera jamais accessible en dehors, que ce soit par d’autres classes, d’autres objets ou par un script externe. Ce sera même une erreur de syntaxe en dehors des classes.

class User {
  #rights; // rights est une propriété privée désormais

  showRights() {
    // Uniquement valable dans une classe
    return this.#rights;
  }
}

const u = new User();
u.#rights; // invalide, erreur de syntaxe

Un modificateur privé est donc un préfixe # devant un nom de propriété/méthode de classe.

Hériter d’objets natifs

Je l’ai rapidement évoqué, oui, il est possible d’étendre des objets natifs !

Il est par exemple possible d’étendre l’objet Array pour en faire un tableau versionné. Avec les modificateurs privés, c’est encore plus simple de faire un vrai objet "fermé".

C’est un peu long et c’est cadeau :

class VersionnedArray extends Array {
  #history = [];
  #head = 0;

  /** Save made changes into history. */
  commit() {
    this.#history = this.#history.slice(0, this.#head);
    this.#history.push(this.slice());
    this.#head = this.#history.length;
  }

  /** Remove current history */
  clearHistory() {
    this.#history = [];
    this.#head = 0;
  }

  /** Go back to last chosen commit, and delete history after this point. */
  rebase() {
    if (!this.#head) {
      // No history
      return;
    }
    
    // Go to last selected commit (head is always selected + 1)
    this.checkout(this.#head - 1);
    // Head has been incremented, remove after last selected commit
    this.#history = this.#history.slice(0, this.#head - 1);
  }

  /** Save current state and go back to previous commit. */
  revert() {
    this.commit();
    this.checkout(this.#head - 2);
  }

  /** Go to commit {position} in history. Changes made since last commit will be lost. */
  checkout(position) {
    if (position < 0) {
      position += this.#history.length;
    }

    if (position < 0 || position > this.#history.length) {
      // Invalid history position, do nothing
      return;
    }
    // Get the item and keep history
    const item = this.#history[position];
    this.splice(0, this.length, ...item);
    this.#head = position + 1;
  }

  /** Get data corresponding to {position} in history. */
  peek(position) {
    if (position < 0) {
      position += this.#history.length;
    }

    if (position < 0 || position > this.#history.length) {
      // Invalid history position, do nothing
      return [];
    }

    return [...this.#history[position]];
  }

  /** Number of saved items in history. */
  get historyLength() {
    return this.#history.length;
  }
}

Mieux encore : ce cas étant 100% prévu par le standard, les méthodes de Array renvoyant des nouveaux tableaux (comme .map et .slice) renverront des VersionnedArray !

La fin

On a fait le (grand) tour des classes JavaScript, j’espère que vous avez apprécié. La première partie était longue, mais c’est intéressant de savoir comment JavaScript fonctionne vraiment, non ?

Allez, on se revoit la prochaine fois pour parler des nouvelles primitives de JavaScript, BigInt, Symbol et les object proxies !

Article précédent : fetch • Bientôt

Laisser un commentaire