POO, prototype et Javascript

Dans le billet précédent, on a défini des fonctions afin de réaliser l'animation d'un petit avion dans un canvas. Cette introduction à l'utilisation de Javascript permet de voir comment faire, mais si nous voulons avancer plus loin, il va falloir trouver une autre façon de faire que d'empiler des tonnes de fonctions qui intéragissent entre elles dans un bordel sans nom. Le mieux est de faire de la programmation orientée objet (= POO) afin de définir chaque entité comme un objet facilement manipulable et reproductible.

Programmation orienté objet

Je ne vais pas m'étendre sur le concept et rentrer dans les détails car il existe moultes sites qui traitent du sujet.

Nous pouvons résumer la POO à trois principes forts :

  • encapsulation : nous pouvons mettre ensemble des propriétés (attributs et méthodes) au sein d'un même type d'objet (une classe). Il suffira d'instancier ce type pour obtenir un objet qui contiendra les propriétés le définissant
  • héritage : si nous avons un type A, nous pouvons déclarer un type B qui hérite des propriétés de A et qui ajoute ses propres propriétés
  • polymorphisme : c'est le fait de pouvoir définir une même méthode pour différents types d'objet.

Avant de continuer dans ce billet, je vous invite à vous familiariser avec le vocabulaire lié à la POO et en comprendre les différentes notions (constructeurs, assesseurs, mutateurs, interface, etc ...)

Et Javascript dans tout ça ?

Javascript est tout à fait capable de faire les trois principes ci-dessus. Javascript étant un langage de script, il a ses propres particularités qui fait qu'on ne fait pas tout à fait la même chose en Javascript de ce qu'on aurait l'habitude de faire en C++ ou en Java. Pour résumer, il va falloir faire avec ses défauts et ses qualités.

Javascript est un langage à typage dynamique : cela signifie que lorsqu'on déclare une variable, son type est défini à son initialisation.

Les différents types peuvent être primitifs comme :

  • les numériques (int, float, double, char),
  • les chaines de caractères (string entre ' ou ")
  • les booleans (true, false)
>>> var mavariable = 5;
>>> typeof(mavariable);
"number" /* mavariable est de type numérique */

>>> var monstring = "foo";
>>> typeof(monstring);
"string" /* monstring est de type string */

>>> var monboolean = true;
>>> typeof(monboolean);
"boolean" /* ma variable est un type boolean */

Les numériques sont par défaut des décimaux. Javascript peut aussi définir des entiers que nous représentons soit sous forme octale (base 8 commençant par O...) soit sous forme hexadecimale (base 16 commençant par Ox...).

Warning

Il faut bien faire gaffe que si nous écrivons 0344 nous obtenons en réalité l'entier 228 car 0344 est en base 8 (forme octale) à cause du 0 qui est devant.

Nous avons deux autres types particuliers :

  • null qui est une constante du langage qui bien différent des autres types
  • undefined qui est le type d'une variable qui a été ni déclarée, ni affectée
>>> var a = null;
>>> typeof(null);
"object" /* a est un type null */

>>> typeof(unevariablenondefinie);
"undefined" /* unevariablenondefinie est ... non définie (logique non) */

Warning

Une variable undefined n'est pas null et null n'est pas 0.

Javascript est un langage à typage faible : cela veut dire que nous pouvons facilement mélanger des chaines de caractères avec d'autres types. Ce qui peut sembler être une force peut vite poser problème.

>>> var a = "mon string" + 2;
"mon string2"

>>> var a = "mon string" * 2;
NaN /* Not a Number */

>>> typeof("monstring"*2);
"Number"  /* ?????? Très bizarre mais ça n'a pas l'air de lui poser un soucis moral */

Enfin, nous avons aussi deux autres types identifiables : les fonctions et les objets

>>> var madate = new Date();
>>> typeof(madate)
"object" /* ma variable est un objet */

>>> mafonction = function() {}
>>> typeof(mafonction);
"function" /* ma variable est une fonction */

Dernier point fort et qui a tout son importance, c'est qu'un objet javascript peut être modifié à tout moment. Nous pouvons librement modifier ses attributs et ses méthodes après son instanciation (comme en Python par exemple)

>>> var madate = new Date();
>>> madate.functionperso = fonction(){ return "foo"; }
>>> madate.functionperso();
"foo"

Dans cet exemple, nous avons défini une nouvelle méthode functionperso pour notre objet madate de type Date.

Les classes en Javascript

En Javascript toute fonction peut être définie comme le constructeur d'une classe portant le même nom. C'est le constructeur qui permettra de définir ses propriétés (attributs et méthodes) grâce au mot-clef this. Pour faire simple, notre fonction ainsi détaillée sera notre classe. Il suffira d'utiliser new pour obtenir une instance de cette classe (qui aura l'allure d'une fonction).

Nous allons définir le constructeur d'une classe nommée ClasseA de la manière suivante

var ClasseA = function(a,b) {
    this.attrA = a;
    this.attrB = b;
}

Pour créer une nouvelle instance, nous allons faire

>>> var instance = new ClasseA(2,3);
>>> instance;
ClasseA { attrA=2, attrB=3}
>>> instance.attrA;
2
>>> instance.attrB;
3

Première remarque, nos attributs sont publics et sont donc accessibles aussi bien en lecture qu'en écriture depuis l'extérieur de notre classe.

>>> var instance = new ClasseA(2,3);
>>> instance.attrA;
2
>>> instance.attrA = 4; /* attrA étant public, attrA sera bien modifié */
>>> instance.attrA;
4

Toute variable associé à this dans le constructeur sera un attribut public. Aussi si vous désirez que cet attribut devienne privé, il faudra simplement remplacer this par var.

/* Si on remplace ... */
var ClasseA = function(a,b) {
    this.attrA = a;  /* attrA est public car accessible de l'extérieur */
    this.attrB = b;  /* attrB est public car accessible de l'extérieur */
}

/* ... par */
var ClasseA = function(a,b) {
    var attrA = a;  /* attrA devient accessible qu'à l'ensemble des propriétés du constructeur ClasseA */
    var attrB = b;  /* attrB devient accessible qu'à l'ensemble des propriétés du constructeur ClasseB */
}

La portée d'une variable Javascript déclarée avec le mot clef var est limitée à la fonction/objet qui l'encadre. Du coup, tout ce qui se trouve dans cette fonction/objet aura accès directement à cette variable. Par contre, de l'extérieur, elle n'existera pas. Je ne vais pas entrer dans le détail des portées en Javascript. Cette notion, à elle-seule mériterait un article complet, tellement c'est simple et vicieux à la fois.

Cas particulier : une variable qui n'a jamais été déclarée avec le mot clef var appartient d'office à l'instance window de notre page Web et vu que toutes les fonctions déclarées dans une page Web sont dans l'objet window, elles ont accès à cette variable.

>>> mavar = "foo";
>>> window.mavar;
"foo"

Maintenant si vous avez bien suivi les exemples du début, une fonction (ou méthode) n'est identifiée que par une variable. Ajouter des méthodes à notre classe sera aussi simple que d'ajouter une propriété dont la valeur est une fonction.

var ClasseA = function(a,b) {
    this.attrA = a; /* premier nombre */
    this.attrB = b; /* deuxième nombre */

    /**
     * Méthode a + b
     * @return nombre
     */
    this.somme = function() {
        return (this.attrA + this.attrB);
    }

    /**
     * Méthode a - b
     * @return nombre
     */
    this.diff = function() {
        return (this.attrA - this.attrB);
    }
}

Si nous voulions que l'attribut attrA et attrB soient privés au lieu d'être publics, nous aurions écrit la classe de cette façon.

var ClasseA = function(a,b) {
    var attrA = a; /* premier nombre */
    var attrB = b; /* deuxième nombre */

    /**
     * Méthode a + b
     * @return nombre
     */
    this.somme = function() {
        return ( attrA + attrB);
    }

    /**
     * Méthode a - b
     * @return nombre
     */
    this.diff = function() {
        return ( attrA - attrB);
    }
}

Pourquoi ne plus utiliser this dans les méthodes ? Car les attributs attrA et attrB n'ont été déclarés et initialisés que pour la fonction ClasseA. Toute méthode qui sera déclarée dans le constructeur aura accès à ces variables.

Si on instancie un nouvelle objet, nous aurons aucun doute sur le résultat

>>> var moninstance = new ClasseA(3,2);
>>> moninstance.somme();
5
>>> moninstance.diff();
1

Ce code fonctionne bien entendu que les attributs soient déclarés privés ou publics. A savoir aussi qu'en Javascript la notion de variable protégée n'existe pas. Du coup, si nous désirons faire de l'héritage, seules les variables publiques peuvent être utilisées. Pour le reste des découvertes, nous resterons sur la version de classe qui possèdent les attributs publiques.

Notion d'héritage

Reprenons le cas de la ClasseA avec des attributs publics

var ClasseA = function(a,b) {
    this.attrA = a;
    this.attrB = b;

    this.somme = function() {
        return (this.attrA + this.attrB);
    }

    this.diff = function() {
        return (this.attrA - this.attrB);
    }
}

Vu que les attributs et les méthodes sont de simples propriétés d'un objet, il est facile de pouvoir faire de l'héritage. Il suffit d'appeler la classe mère au sein du constructeur de la classe fille pour que cette dernière reprenne l'ensemble des propriétés de la classe mère. Nous faisons ceci grâce à la méthode call.

var ClasseB = function(a,b) {
    ClasseA.call(this,a,b); /* Appel du constructeur de la ClasseA */

    this.diff = function() { /* Nous redéfinissons diff */
        return 0;
    }

    this.multiplie = function() { /* Nous ajoutons une méthode */
        return (this.attrA * this.attrB);
    }
}

Et à l'usage

>>> b = new ClasseB(3,5)
Object { attrA=3, attrB=5, somme=function(), more...}
>>> b.multiplie()
15
>>> b.diff();
0
>>> b.somme();
8

On voit bien qu'on est dans un semblant d'héritage puisque les attributs de A sont présents, la méthode somme est toujours disponible mais diff est changée et nous avons ajouté une méthode multiplie.

En réalité, nous ne faisons pas vraiment de l'héritage mais de la copie des propriétés de la classe mère et là, on met le doigt sur le problème de cette façon de faire de la POO depuis le début de ce billet.

Lorsque dans un langage permettant la POO comme le C++, nous définissons une méthode, le bout de code correspondant à la méthode de la classe ne sera disponible qu'une fois en mémoire. Si j'ai une méthode somme définie dans une ClasseA et qu'on instancie deux fois cette ClasseA, nos deux instances utiliseront bien la même méthode en mémoire (avec des contextes d'execution différents bien entendu). En Javascript, cette méthode est considérée comme une simple propriété du coup, elle est redéfinie à chaque instance. De même pour l'héritage : si j'instancie une ClasseB, vu qu'il y a un call sur la ClasseA, l'ensemble des propriétés de la ClasseA seront redéfinies pour la ClasseB (ou recopiées selon votre façon de voir).

Si cette méthode est lourde et qu'on instancie mille fois notre classe ou une de ses filles, on va avoir mille versions de la même méthode et se retrouver avec une empreinte mémoire énorme. Nous perdons complétement l'intérêt de la POO d'un point de vue éxectution. Javascript va plutôt se baser sur un type d'objet particulier nommés prototype.

Conclusion : nous avons fait de la merde, nous allons faire autrement.

Dernier point avant de tout reprendre du début

Avant de voir cette notion de prototype, nous allons voir un petit piège. Considérons le code suivant où plutôt que de définir nos méthodes à l'intérieur de notre classe, on le fait après, à l'extérieur : rappelez vous, Javascript permet de redéfinir les propriétés de ses objets.

var ClasseA = function(a,b) {
    /* attributs */
    this.attrA = a;
    this.attrB = b;
}

/* méthodes déclarées à l'extérieur */
ClasseA.somme = function() {
    return (this.attrA + this.attrB);
}

ClasseA.diff = function() {
    return (this.attrA - this.attrB);
}

Exécutons notre appel précédent

>>> var moninstance = new ClasseA(3,2);
>>> moninstance.somme();
TypeError: moninstance.somme is not a function

Nous avons une erreur et c'est normal. Le new ClasseA ne s'applique qu'à ce qui est définie dans le constructeur ClasseA. Les méthodes somme et diff ne sont pas accessibles dans notre objet moninstance car elles n'y sont pas déclarées. Elles ne sont pas des méthodes d'instances mais des méthodes de classes. Nous pouvons appeler ça aussi des méthodes statiques. La seule façon d'y accéder serait de faire :

>>> ClasseA.somme();
NaN

Le this à l'intérieur de chaque méthode ne correspondrait qu'à la méthode elle-même. Nous sommes bien face à une méthode statique.

Warning

Toute méthode d'instance doit être définie dans le constructeur via l'opérateur this. Dans le cas contraire, ça devient une méthode statique (ou méthode de classe) et l'opérateur this est sans effet.

Les prototypes

Un prototype est une sorte de "patron" qu'un objet va pouvoir utiliser. C'est la façon de dire à Javascript "attention, si tu créés plusieurs instances du même type d'objet (classe), les méthodes ne sont pas à redéfinir car elles existent dans un patron de référence". Ne pas confondre ça avec des Interfaces, car contrairement à ces dernières, nous pouvons définir la méthode complétement au sein de notre prototype, là où avec les interfaces, les méthodes ne sont pas définies. (pour rappel, une interface ne contient que des méthodes abstraites).

Notre exemple précédent

/* constructeur de ClasseA */
var ClasseA = function (a,b) {
    /* attributs */
    this.attrA = a;
    this.attrB = b;

    /* methodes */
    this.somme = function() {
        return (this.attrA + this.attrB);
    }

    this.diff = function() {
        return (this.attrA - this.attrB);
    }
}

deviendra donc

/* constructeur de ClasseA */
var ClasseA = function (a,b) {
    /* attributs */
    this.attrA = a;
    this.attrB = b;
}

/* methodes de ClasseA */
ClasseA.prototype.somme = function() {
        return (this.attrA + this.attrB);
}

ClasseA.prototype.diff = function() {
        return (this.attrA - this.attrB);
}

Qu'avons nous fait de différent ? La super classe Object dont hérite tous les objets en Javascript possède comme propriété un objet prototype. Une fonction (Function) hérite d'Object et donc de sa propriété prototype aussi. Chaque objet aura donc un objet prototype faisant partie de ses propriétés. Quand Javascript cherchera la propriété d'un objet, il ira d'abord la chercher dans l'objet lui-même (via this) et s'il ne la trouve pas, ira la chercher dans le prototype. Si ce prototype n'est pas défini, Javascript ira chercher le prototype de l'objet parent, jusqu'à remonter au prototype de la super classe Object.

>>> var instance = new ClasseA(2,3);
>>> var instance2 = new ClasseA(5,2);
>>> instance.somme();
5
>>> instance2.somme()
7

On retrouve le même comportement que tout à l'heure, sauf que dans ce cas, la méthode somme est issue d'un unique et même "patron". Nous avons en mémoire quelque chose qui se rapproche plus d'une classe classique et nous pouvons désormais créer moultes objets ClasseA avec une empreinte mémoire et un temps d'éxecution plus faible qu'avant.

Héritage en Javascript

Comme on l'a vu avec l'explication du prototype, quand nous appellons la propriété d'un objet, Javascript va chercher si l'objet contient cette méthode. Si ce n'est pas le cas, Javascript va vérifier si elle est disponible dans le prototype du type correspondant (sa classe), et si ce n'est pas le cas, Javascript va remonter progressivement jusqu'au prototype de la super classe Object. C'est une mécanique d'héritage de prototype.

Nous allons créer un objet ClasseB qui va hériter des propriétés de ClasseA, il redéfinira la méthode diff, et ajoutera la méthode multiplie

/* ClasseB hérite de ClasseA */
var ClasseB = function (a,b) {
    ClasseA.call(this,a,b); /* Appel du constructeur de la classe Mère */
}
ClasseB.prototype = new ClasseA();

/* repointage du constructeur de ClasseB qui était devenu celui de ClasseA par l'instruction précédente */
ClasseB.prototype.constructor = ClasseB;

/* methode diff surchargée */
ClasseB.prototype.diff = function() {
    if (this.attrA < this.attrB) {
        return (this.attrB - this.attrA);
    } else {
        return (this.attrA - this.attrB);
    }
}

/* méthode multiplie déclarée */
ClasseB.prototype.multiplie = function() {
        return (this.attrA * this.attrB);
}

Ce qui donne à l'usage

>>> obj = new ClasseB(3,6)
Object { attrA=3, attrB=6, constructor=function(), more...}
>>> obj.diff();
3
>>> obj.multiplie();
18
>>> obj.somme();
9

Nous avons bien le résultat attendu. La méthode diff a bien été redéfinie et la méthode multiplie ajoutée. Notre fille hérite bien des propriétés de sa classe mère.

Facile .. oui mais !

Je vais quand même montrer qu'il peut y avoir des couacs liés au langage Javascript dans le cas de l'utilisation des prototypes. Pour cet exemple tordu, je vais rependre le billet trouvé ici et qui parle d'une erreur de conception trouvée sur ce billet là.

Le code d'origine est le suivant (version francisée de l'exemple trouvé dans les billets cités ci-dessus)

/**
 * Classe Animal
 */
var Animal = function(nom){  /* Constructeur de la classe Animaux */
        this.nom=nom; /* On définit son nom */
        this.enfants=[]; /* On définit un tableau contenant ses enfants */
}
Animal.prototype.nouveauBebe=function(){ /* permet d'ajouter un bébé */
        var bebe=new Animal("Bebe "+this.nom); /* nouveau bébé */
        this.enfants.push(bebe); /* qu'on ajoute aux enfants */
}
Animal.prototype.toString=function(){
        return '[Animal "'+this.nom+'"]'; /* affichage du nom */
}

/**
 * Classe Chat
 */
var Chat = function(nom){ /* Constructeur de la classe Chat */
    this.nom=nom; /* On définit son nom */
}
Chat.prototype = new Animal();
Chat.prototype.constructor = Chat;

Maintenant que nos classes sont déclarées, nous allons les utiliser

>>> var monanimal = new Animal("Papa");
>>> var monchat = new Chat("Felix");
>>> monanimal.toString();
"[Animal "Papa"]"
>>> monchat.toString();
"[Chat "Felix"]"
>>> monchat.nouveauBebe();
>>> monchat.enfants.length;
1

Tout va bien, mais si on ajoute

>>> var monchat2 = new Chat("Ronron");
>>> monchat2.toString();
"[Chat "Ronron"]"
>>> monchat2.nouveauBebe();
>>> monchat2.enfants.length; /* pas cool */
2
>>> monchat2.enfants[0] /* Celui là appartient à Felix, pas à Ronron */
[Animal "Bebe Felix"] { nom="Bebe Felix", enfants=[0], nouveauBebe=function(), more...}
>>> monchat2.enfants[1] /* Celui là a été ajouté alors qu'il aurait du être tout seul */
[Animal "Bebe Ronron"] { nom="Bebe Ronron", enfants=[0], nouveauBebe=function(), more...}
>>> monchat.enfants.length; /* En ajoutant un enfant à monchat2, monchat se le voit attribuer aussi */
2

Nous avons une erreur : la propriété enfants à l'air d'être commune à mes deux objets filles que sont monchat et monchat2. Normalement, monchat2 n'aurait du avoir qu'un seul enfant à l'ajout d'un enfant. Or, il avait déjà en mémoire l'enfant de monchat

Pourquoi cela ?

Nous avions dit à l'introduction des prototypes que cela agissait comme une sorte de "patron" commun à l'ensemble des objets instanciés.

Reprenons l'exemple, on commmentant le prototype.constructor

/**
 * Classe Chat
 */
var Chat = function(nom){ /* Constructeur de la classe Chat */
    this.nom=nom; /* On définit son nom */
}
Chat.prototype = new Animal();
/* Chat.prototype.constructor = Chat; */

A ce moment précis, le prototype du Chat devient celui d'un objet Animal et cette opération n'est faite qu'une seule fois durant toute l'éxecution du script.

>>> Chat.prototype
[Chat "undefined"] { enfants=[0], toString=function(), nouveauBebe=function()}

Le prototype de Chat contient comme attribut la liste enfants et nom n'est pas défini (undefined). Le constructeur de A n'initialise nom qu'à la création de l'objet mais une fois qu'on indique que la classe Chat aura comme prototype un objet de type Animal le constructeur de Chat devient celui de l'objet Animal, ce qui nous arrange pas.

Pour changer cela, nous faisons en sorte que le prototype.constructor de Chat devienne la fonction Chat (au lieu de l'objet Animal). Toutefois, dans ce constructeur, nous ne faisons que traiter l'attribut nom. Du coup, enfants existe bien pour un objet Chat mais fait parti de son prototype et non de son constructeur.

Aussi, lorsque nous instancions un Chat et lui ajoutons un enfant

>>> var monchat = new Chat("Felix");
>>> monchat.nouveauBebe();
  • nom sera initialisé dans le constructeur de l'objet monchat de type Chat et deviendra "Felix"
  • enfants sera augmenté d'une référence. Mais enfants n'appartient pas à l'objet monchat mais au prototype de Chat

Lorsqu'on va instancier un autre Chat et lui ajouter un enfant

>>> var monchat2 = new Chat("Ronron");
>>> monchat2.nouveauBebe();
  • nom sera initialisé dans le constructeur de l'objet monchat2 de type Chat et eviendra "Ronron"
  • enfants sera augmenté d'une référence. Mais enfants n'appartient pas à l'objet monchat mais au prototype de Chat qui contient déjà une référence.

Voilà, j'espère que vous avez compris ! Nous allons bien avoir deux enfants alors que pour chaque objet, un seul a été ajouté. Durant la déclaration de Chat, nous lui avons dit que son prototype allait hériter des propriétés de Animal et ça, nous le faisons QU'UNE SEULE FOIS durant toute l'éxecution du script au moment de la déclaration du prototype (nous sommes dans un langage de script ou chaque ligne est interprétée où elle est lue).

Le paradigme prototype en Javascript a certains inconvénients que seule la rigueur permettra de combler. En effet, pour que notre classe Chat soit déclarée correctement, il aurait suffit de remplacer

/**
 * Classe Chat
 */
var Chat = function(nom){ /* Constructeur de la classe Chat */
    this.nom=nom; /* On définit son nom */
}
Chat.prototype = new Animal();
Chat.prototype.constructor = Chat;

par le code suivant

/**
 * Classe Chat
 */
var Chat = function(nom){ /* Constructeur de la classe Chat */
    Animal.call(this,nom); /* Appel du constructeur de la classe A */
}
Chat.prototype = new Animal();
Chat.prototype.constructor = Chat;

Dans ce cas, vu que le constructeur va redéfinir nom et enfants (trouvés dans le constructeur de la classe Animal), Javascript au moment de chercher l'attribut enfants, ira le chercher dans le constructeur alors qu'avant le correctif, il allait le chercher dans le prototype Chat. Javascript n'est pas en tord : ici le problème, c'est l'interface chaise-clavier (nous).

Conclusion de ce (très/trop) long billet

Vous savez maintenant créer des classes en Javascript :

  • soit en définissant une classe par son constructeur : rédaction simple, mais duplication systématique des méthodes au sein des objets instanciés.
  • soit en définissant une classe à l'aide d'un prototype : rédaction compliquée, erreur de syntaxe à prévoir, demande plus de rigueur, mais empreinte mémoire plus réduite.

Javascript est un langage à part entière. Avant de le critiquer, il faut s'y intéresser et comprendre sa façon de fonctionner car il permet énormément de choses.

Maintenant que nous avons toutes ces informations sur la POO, nous allons pouvoir refaire notre exemple de canvas précédent mais avec un peu plus de structure. Cela fera l'objet d'un prochain billet.

Commentaires