Pour les Jedis, JavaScript, épisode III : La revanche des Prototypes (partie 1)

Cet article fait partie de la série “Pour les Jedis, JavaScript” d’articles consacrés à JavaScript. Si ce n’est pas déjà fait, veuillez lire les épisodes précédents.
  1. Pour les Jedis, JavaScript, épisode I : Au cœur des Fonctions (1 et 2)
  2. Pour les Jedis, JavaScript, épisode II : L’attaque des Closures (1 et 2)
  3. Pour les Jedis, JavaScript, épisode III : La revanche des Prototypes (1 et 2)

Nous voilà arrivés à la dernière mission vers notre quête : devenir un Jedi JavaScript confirmé ne craignant pas le côté obscure de la force. Pour rappel, durant les deux premiers épisodes de cette série d’articles consacrés à l’apprentissage du langage JavaScript, dans le but de faire de vous des Jedis, nous avons abordé le côté fonctionnel du langage (1 et 2) ainsi que la notion de fermeture (1 et 2) (ou Closure en anglais) qui permet de rendre ces fonctions encore plus polyvalentes et très utiles.

Dans ce dernier épisode nous allons nous attaquer à un dernier pilier du langage JavaScript que sont les Prototypes. Probablement que certains d’entre vous sont déjà familiers avec cette notion de prototypes pensent qu’elle est étroitement liée à la programmation orienté objet. Mais à vrai dire, c’est encore une des nombreuses qualités des fonctions. Oui, j’ai bien dit “fonctions” !

Les prototypes sont utilisés pour définir des attributs et des fonctions qui seront appliqués automatiquement à un objet au moment de sa création.

JavaScript a emprunté les prototypes au langage Self, un langage orienté objet et dialecte de Smalltalk. Les prototypes sont utilisés pour définir des attributs et des fonctions qui seront appliqués automatiquement à un objet au moment de sa création. Une fois définit, le prototype servira de modèle, pour les objets créés. Vous l’aurez sûrement compris donc, les prototypes jouent le même rôle que les classes dans les langages à base de classes. C’est pour cela qu’en JavaScript, les prototypes sont souvent utilisés par les développeurs pour faire de la programmation orienté objet en mimant la syntaxe des “classes”.

Les objets

En JavaScript, toutes les fonctions possèdent une propriété prototype dont la valeur est un objet vide. Cette propriété n’est exploitée que lorsqu’une fonction est invoquée en tant que constructeur, avec le mot-clé new, ce que nous avons déjà détaillé lors du premier épisode. Invoquer une fonction en tant que constructeur revient donc à créer une nouvelle instance.

Tentons d’expliquer ce mécanisme d’instanciation pour mieux comprendre le rôle des prototypes.

Instanciation d’objet

En JavaScript, la façon la plus simple pour instancier un objet est comme ceci :

Cette instruction crée un objet vide prêt à l’emploi, et nous pouvons lui définir des propriétés comme ceci :

Je pense que je ne vous apprend rien jusque là. Par contre, ceux venons d’un langages orienté objet (au sens strict du terme) préféreront sûrement avoir un peu plus d’encapsulation et plus de structuration ; c’est-à-dire avoir un sorte de constructeur, une fonction donc, dont le rôle est d’initialiser un objet dans un état connu. Ensuite, utiliser des méthodes (Getter et Setter) pour modifier cet état, au lieu de modifier les attributs directement, ce qui risque d’introduire des erreurs et rend la maintenabilité du code un peu plus complexe. Il est donc préférable d’avoir un mécanisme pour consolider les attributs et méthodes des différents objets instanciés, à un seul endroit. JavaScript propose en effet ce genre de mécanisme, mais il est un peu différents des autres langages.

A l’instar de Java, JavaScript utilise le mot-clé new pour instancier de nouveaux objets à travers l’invocation de leurs constructeurs, mais il n’y a pas de définition de classes à proprement parlé en JavaScript. Au lieu, le mot-clé new, une fois appliqué à une fonction, déclenche la création d’un nouvel objet et à ce moment là, les prototypes entrent en jeu.

Prenons l’exemple suivant :

Analysons ce simple exemple : nous avons définit une fonction Jedi, qui ne fait rien et que nous avons invoqué de deux manières : en tant que fonction et en tant que constructeur. Après la création de la fonction, nous avons ajouté une méthode useForce() au prototype de cette fonction.

Dans un premier temps, la fonction a été invoquée normalement et son résultat a été mémorisé dans la variable anakin. Étant donné que la fonction Jedi ne retourne rien, la variable anakin a bien la valeur undefined ; et sans surprise, anakin ne possède pas de méthode useForce(). Par contre, en invoquant la fonction Jedi en tant que constructeur avec l’opérateur new, le résultat est tout autre. Il se trouve que cette fois-ci un nouvel objet a été créé et positionné comme étant le contexte d’exécution de la fonction, et le résultat retourné par le constructeur est la référence vers cet objet. L’inspection de l’instance luke nous prouve bien que cette instance de Jedi et l’objet luke possèdent bien la méthode useForce(), récupérée depuis son prototype.

Ce premier exemple démontre que le rôle des prototypes est bien de servir de modèle aux objets instanciés. Juste le fait d’attacher la méthode useForce() au prototype de Jedi l’a rendu disponible dans l’instance créée.

De manière générale, voici un schéma illustrant les liens entre une instance, son constructeur et le prototype :

Nous avons vu que l’opérateur new créé un nouvel objet qui sert par la suite de contexte d’exécution de la fonction (le constructeur), et nous avons également vu qu’il était possible d’attacher des propriétés au prototype de cette fonction. Puisque la fonction possède un contexte, nous pouvons également attacher des propriétés au constructeur directement via le paramètre this. Étudions ce cas :

Comme pour l’exemple précédent, en (1) nous avons attaché une méthode useForce() au prototype du constructeur. De plus, nous avons ajouté une méthode portant le même nom au sein du constructeur. Le deux méthodes retournent un résultat différent pour que nous puissions savoir laquelle a été appelée.

En (2), après avoir créé une instance luke, et invoqué la méthode useForce(), nous constatons que c’est bien la méthode définie dans le constructeur qui a été invoquée. L’ordre d’initialisation des propriétés est donc très important et suit la logique suivante :

  1. Les propriétés sont attachées à l’instance de l’objet depuis le prototype ;
  2. Les propriétés sont ajoutées à l’instance de l’objet depuis le constructeur.

Autrement dit, les propriétés ajoutées dans le constructeur passent toujours avant celles attachées au prototype. La raison est que le this, autrement dit le contexte, dans le constructeur représente l’instance elle-même.

Étudions un autre cas afin de mieux comprendre le lien entre les prototypes et les instances d’objets. Prenons le code de l’exemple précédent et modifions le un peu pour avoir ceci :

Dans ce code, nous avons échangé l’ordre de création de l’instance (1) et celui d’attachement de la méthode useForce() au prototype (2). Pourtant, si nous invoquons cette méthode, elle est bien présente, et son résultat et celui attendu. Comment se fait-il ?

Nous aurions pu penser que les propriétés attachées au prototype sont simplement copiées vers l’objet au moment de sa création, et qu’ensuite tout changement effectué sur le prototype après la construction de l’objet ne serait pas reporté sur l’instance créée ? En réalité les propriétés du prototype ne sont pas du toutes copiées, c’est plutôt le prototype lui-même qui est attaché à l’objet construit.

En JavaScript, chaque objet possède une propriété appelée constructor qui référence le constructeur qui a été invoqué pour créer l’objet en question. Comme le prototype est une propriété du constructeur, chaque objet sait donc comment accéder à son prototype. Vérifions cela avec cet exemple :

Nous avons bien accès au constructeur de l’instance luke ainsi qu’à son prototype et donc à toutes les propriétés attachées au prototype ; Ce qui permet d’expliquer pourquoi tout changement effectué sur le prototype après la création de l’objet est automatiquement présent sur ce dernier.

Je vous laisse imaginer l’étendu de ce que nous offre ce genre de fonctionnalités. Nous pouvons faire des librairies que les utilisateurs peuvent enrichir, même après que tous les objets ont été instanciés.

Maintenant que la notion de prototype n’a plus de secret pour vous, nous allons introduire une autre notion intimement liée aux prototypes : la chaîne des prototypes.

La chaîne des prototypes et l’héritage

Afin d’expliquer ce qu’est la chaîne des prototypes, prenons l’exemple suivant :

Le prototype d’une fonction étant un simple objet, il existe plusieurs façon de lui attacher des propriétés. Dans l’exemple précédent, nous avons défini une fonction Force et une fonction Jedi, et puisqu’un Jedi est le seul individu apte à maîtriser la Force et l’utilise pour faire du bien, nous allons faire en sorte que le Jedi hérite des attributs de la Force. Nous faisons cela en copiant la méthode useForce() depuis le prototype de Force vers une méthode useForce() dans le prototype de Jedi.

En testant la présence de la méthode useForce(), ainsi que la nature du type de l’instance luke, nous réalisons que luke a la faculté maintenant d’utiliser la Force, mais il ne l’incarne pas, il n’est pas la Force ! Si nous souhaitons qu’il devienne la Force, nous devrions copier toutes les propriétés de Force vers le prototype de Jedi, une par une ! Sinon, plus simplement, il suffit que Jedi hérite les propriétés de Force. Jusque là je ne vous apprend rien. Cependant, en JavaScript, nous allons nous baser sur ce que l’on appelle la chaîne des prototypes pour bénéficier de l’héritage entre les objets.

La “bonne” façon de réaliser cette chaîne est de créer une instance d’un objet et l’utiliser en tant prototype d’un autre objet :

Maintenant, luke incarne la Force ! Voici ce que cela donne sous forme de schéma :

A titre d’exemple, voici la “mauvaise” façon de réaliser une chaîne des prototypes…

Vous allez me dire, après tout, les deux prototypes sont des objets, pourquoi ne pas faire une simple affectation ? La raison est simple : les objets sont copiés par référence en JavaScript, au moment de l’affectation, les deux prototypes référencent le même objet en mémoire. Modifier le prototype de Jedi revient à modifier celui de Force, ce qui peut conduire à des effets indésirables :

Vous l’aurez compris donc, cette pratique est à proscrire !

Après avoir étudié les prototypes et exploré la flexibilité et la puissance offertes par les prototypes, ainsi que la chaîne des prototypes, passons maintenant à la suite de l’épisode et tentons de mettre en pratique cette fonctionnalité pour implémenter des “classes” (comme dans les autres langages du type C++ et Java).

Que le JS soit avec vous…


Avez-vous aimé cet article ? Soutenez-moi avec 50 claps et Suivez moi sur Medium et Twitter pour plus de contenu sur JavaScript et le Web 🎉