Classes, héritage, encapsulation vs Prototype en JS
Posons le décors, le JavaScript ne possède nativement : ni classe, ni héritage, ni encapsulation... et alors ? C'est probablement le langage le plus incompris, car je vous assure que JavaScript est un puissant langage orienté objet et qu'il permet :
- la création de nouveaux contextes d'exécution autonomes instanciés (new) avec un constructeur, des propriétés et des méthodes personnelles internes (this...) à partir d'un gabarit de construction (classe),
- de dupliquer les propriétés/méthodes dans un autre gabarit (héritage) pour ajouter ou modifier des comportements (polymorphisme) le tout depuis différents endroits dans le code (partielle et virtuelle),
- de limiter l'accès aux propriétés/méthodes au contexte d'exécution (encapsulation privée) ou les promouvoirs (encapsulation publique)...
avec effectivement d'autres mots clés et mécanismes que ceux dont vous avez l'habitude, et nottament grâce au Prototypage.
En ce qui concerne ceux qui ont déjà un bon pied dans le JavaScript, même si jusqu'à présent vous n'avez jamais réellement vu l'intérêt de créer des structures avancées (l'équivalent de classes) pour un développement côté client, il est indéniable que côté serveur (en Node.js par exemple) cela est nécessaire. Et si vous êtes plus d'un développeur Front-end sur vos sites : ça s'avère nécessaire également. Bien qu'en utilisant des bibliothèques JavaScript comme Prototype ou Mootools vous puissiez assez facilement créer des classes, je vais vous apprendre ici à comprendre la roue, vous en créer une sans l'utilisation du prototypage très simplement et cerise sur le gâteau : de jouer avec vos classes en les remplissant/appelant de la même manière qu'en jQuery !
Pour finir nous verrons en quoi le prototypage peut vous simplifier la vie et en quoi donc JavaScript n'a finalement besoin d'aucune classe, d'aucun héritage et d'aucune encapsulation.
Du JavaScript, JSON, jQuery à La RACHE
Étant pratiquant à vos heures de la méthodologie de La RACHE (Rapid Application Conception and Heuristic Extreme-programming) votre utilisation de JavaScript se limite principalement à trois cas d'utilisation, et cela vous suffit :
- JavaScript : Vous donnez un objet à manger à une fonction et celle-ci vous le ressort avec les modifications demandées.
- JSON : Vous créez des objets à la volée avec « {} » et vous les remplissez en fonction de vos besoins.
- jQuery : vous jonglez dans un DOM et faites des déplacements et duplication vraiment aisément.
// Créer des fonctions pour faire quelque chose à mes informations.
function checkAuth(account) {
if (account.email /* ... */ account.password) {
account.valid = true
}
return account;
}
// Regrouper mes informations.
var account = {
email: $("...").val("..."),
password: $("...").val("..."),
valid: false
}
// Réaliser les choses.
account = checkAuth(account);
/* ... */
L'avantage est que ça va vite à écrire : vous décidez sur le moment comment organiser votre objet et comment réaliser la fonctionnalité.
Les problèmes arrivent quand un script cumule plusieurs centaines de lignes (et plusieurs appels de fichier différents avec plusieurs centaines de lignes) basées sur une création de l'instant et qu'il doit être amélioré / débogué 6 mois plus tard par quelqu'un d'autre (et les yeux bandés !).
Vous ne disposez pas non plus de documentation car sincèrement, même-vous, vous vous demandez comment documenter ça...
Il est temps d'organiser votre code JavaScript avec un développement moins procédurale et plus orienté objet. Oui, mais s'organiser autour d'objet clairement défini sur lesquels : fautes de documentations, tout le monde pourra se référer au moins dans des fichiers par structure.
Héritage basé sur les classes (sans prototype)
Les classes en JavaScript : ça n'existe pas. À la place JavaScript a un mécanisme de prototypage qui est différent de celui des classes et donc des mécanismes que vous avez l'habitude de côtoyer dans vos langages objets. Laissons le prototypage un instant dans cette partie et revenons à nos classes. Une classe est un modèle qui, en l'instanciant (attacher « this » à un contexte d'exécution dédié), donne naissance à des objets formés selon la nomenclature de ladite classe. Mais puisque le JavaScript lui-même est déjà composé d'objet, à première vu on en a pas besoin. Oui mais l'avantage apporté par une classe est de générer facilement des objets complexes et tous formés de la même manière, de les étendres, etc. Si les classes n'existent pas nativement, rien ne nous empêche de simuler leur comportement.
Même objet, mais syntaxe et structure différente
L'élément clé new existe également en JavaScript. Mais comment va-t-il nous aider à instancier des classes pour créer des objets en JavaScript ?
Revenons aux sources un moment. Si je désire créer deux autres objets « account » en continuant mon exemple précédent, il est vrai que je n'ai pas besoin de passer par une classe après tout. Je peux écrire à la suite :
/* ... */
// SYNTAXE JSON : Création d'un Object littérale avec la syntaxe JSON (JavaScript Object Notation).
var bruno = { // Instanciation d'une variable "bruno" avec un nouvel élément de type Object selon la syntaxe JSON.
email: "bruno@email.ici", // Ajout, en tant que membre, de la propriété "email" (en même temps) avec un nouveau type primitif String.
password: "bépoB" // Ajout, en tant que membre, de la propriété "password" (en même temps) avec un nouveau type primitif String.
};
/* ... */
Ensuite, quelque part plus loin... peut-être dans un autre fichier... je peux également écrire de cette autre façon :
/* ... */
// SYNTAXE STANDARD : Création et assemblage d'Objets avec la syntaxe JavaScript standard.
var magalie = new Object(); // Instanciation d'une variable "magalie" avec un nouvel élément de type Object.
magalie.mail = new String("magalie@email.ici").toString(); // Ajout, en tant que membre, de la propriété "mail" avec un nouveau type Object converti en type primitif String.
magalie.pwd = new String("azertyM").toString(); // Ajout, en tant que membre, de la propriété "pwd" avec un nouveau type Object converti en type primitif String.
/* ... */
Et, arrivé au moment de vérifier mes « account »,...
/* ... */
bruno = checkAuth(bruno);
magalie = checkAuth(magalie);
/* ... */
... les problèmes commencent. Que c'est-il passé ? En fait j'ai bien créé deux « account » et mes deux syntaxes sont bonnes. Sauf que la fonction checkUser() s'attendait à pouvoir toucher à une propriété nommée valid qu'on a oublié de déclarer pour notre premier objet et qui était sensé faire un test sur les propriétés email et password qui n'existent pas dans notre second objet (en plus de valid) car on ne les a pas nommés ainsi...
Pour résoudre cela il nous faudrait :
- Être sûr que quoi qu'il arrive en créant un « user » les propriétés username, password et valid existent.
- Être sur que la fonction checkUser soit liée d'une manière ou d'une autre à l'objet « user » puis qu'elle fasse un traitement sur lui.
Bref : pour résoudre cela il nous faudrait une classe.
Des brouillons de classe
Ce qu'il nous faut, c'est un générateur d'objet déjà formé pour être sûr que, si j'utilise ce générateur pour créer mes objets : ils auront tous une structure identique. Ne sautons pas des étapes et faisons des tests de compréhension.
Afin d'éviter de se retrouver avec des objets tous formés différemments ; on décide de se référer à un modèle :
Code
// Création d'un brouillon de classe "Account" : un gabarit.
var account = {
email: "", // Ajout de la propriétés "email" à "account".
password: "" // Ajout de la propriétés "password" à "account".
};
Tests
var bruno, magalie;
// J'associe ma structure à la variable "bruno" et la remplie.
bruno = account;
bruno.email = "bruno@email.ici";
bruno.password = "bépoB";
console.log(bruno.email); // renvoi "bruno@email.ici".
console.log(bruno.password); // renvoi "bépoB".
// J'associe ma structure à la variable "magalie" et la remplie.
magalie = account;
magalie.email = "magalie@email.ici";
magalie.pwd = "azertyM"; // Bien que "pwd" n'existe pas dans mon gabarit, je peux quand même l'associer et oublier "password".
console.log(magalie.email); // renvoi "magalie@email.ici".
console.log(magalie.pwd); // renvoi "azertyM".
console.log(bruno.email); // renvoi "magalie@email.ici".
// Je constate que la ré-association à "magalie" (ligne 9, 10 et 11) a remplacée celle de "bruno" (ligne 2, 3 et 4).
On constate rapidement plusieurs problèmes :
- Même si j'ai créé mon gabarit : qui m'empêche de remplir par erreur une valeur inexistante ? C'est le cas ligne 11 des tests avec "pwd" et ça marche comme le prouve la ligne 13 des tests qui ne renvoi pas d'erreur.
- Le contexte d'exécution généré en créant un « Account » à la ligne 6 du code ne ce duplique pas lors de l'association à d'autre variable (ligne 2 et 9 des tests) ce qui a pour résultat de fournir les informations de "magalie" via "bruno" (ligne 16 des tests).
Bref, il va falloir faire mieux que ça !
Pour résoudre nos problèmes majeurs précédents, nous allons utiliser le mot clé function et créer une fonction ce qui va créer des contexte d'exécution différent pour nos deux « account » :
Code
// Création d'un brouillon de classe « Account » avec comme paramètres : "email" et "password".
var account = function (email, password) {
// Création d'un objet à l'intérieur de Account, sa portée est donc limitée à "Account".
var newAccount = {};
newAccount.email = email; // Ajout de la propriétés "email" à "newAccount" avec comme valeur le paramètre "email".
newAccount.password = password; // Ajout de la propriétés "password" à "account" avec comme valeur le paramètre "password".
// Notre fonction retourne un objet de nomenclature "Account".
return newAccount;
};
Tests
var bruno, magalie;
bruno = account("bruno@email.ici", "bépoB");
console.log(bruno.email); // renvoi "bruno@email.ici".
console.log(bruno.password); // renvoi "bépoB".
magalie = account("magalie@email.ici", "azertyM");
magalie.pwd = "azertyM";
console.log(magalie.email); // renvoi "magalie@email.ici".
console.log(magalie.pwd); // renvoi "azertyM".
console.log(bruno.email); // renvoi toujours "bruno@email.ici".
Bon, cette fois au moins, c'est fonctionnel :
- Même si l'on peut toujours inventer des propriétés (ligne 6 des tests), dans notre cas ce n'est plus génant car grâce aux paramètres imposées par la fonction (ligne 6 du code) on a au moins une certitude : les propriétés email et password sont remplies.
- On constate bien à la ligne 10 des tests que bruno.email n'a pas été écrasé par magalie.email (deux contextes distincts).
Une Classe en JavaScript
Effectivement, tout cela marche. Mais si on veut se rapprocher du comportement des classes dans les langages objets il va falloir faire mieux que ça. Pourquoi ? Par exemple : de quel type est notre objet dans le cas précédent ?
var
bruno,
account = function (email, password) {
var newAccount = {};
newAccount.email = email;
newAccount.password = password;
return newAccount;
};
bruno = account("bruno@email.ici", "bépoB");
console.log(bruno instanceof Object); // renvoi "true".
console.log(bruno instanceof account); // renvoi "false".
On s'aperçoit à la ligne 13 qu'il va être difficile de faire des objets distincts de cette façon, et je ne vous parle même pas d'héritage !
Instance, Constructeur, propriétés d'instance et propriétés d'objet
Voilà ce que l'on va permettre :
- Instanciation d'un contexte d'exécution avec une fonction grâce au mot-clé new. Une telle fonction en JavaScript est appelée « Constructeur ». Dans ce cas, la pratique veut que l'on commence le nom de la variable par une majuscule.
- Attribut d'instance attaché grâce au mot-clé this.
- Propriétés d'instances et propriétés statiques (utilisables sans instanciation).
Classe
// Création d'une classe "Account" avec comme paramètres : "email" et "password".
var Account = function (email, password) {
// Cette fois on attache les variables au contexte d'exécution avec comme base "this".
// En appelant cette fonction avec new,
// this correspond alors au contexte d'exécution d'une nouvelle instance d'Account.
this.email = email;
this.password = password;
// On attache une propriété à la variable "Account" (qui sera un objet, une fois la fonction instanciée).
// Si "Account.nbrOfAccount" n'existe pas, (premier passage), il vaut 1.
// Sinon il vaut lui-même plus 1.
Account.nbrOfAccount = (typeof Account.nbrOfAccount != 'undefined') ? Account.nbrOfAccount + 1 : 1;
// Il n'est pas nécessaire d'inclure de "return". Une fonction retourne son instance quand elle est appelée avec new.
};
Note : vous pouvez constater que la variable « Account » prend maintenant une majuscule. C'est une convention pour indiquer que cette fonction devra être instanciée avec le mot-clé « new ».
Tests
var bruno, magalie, badUseCase;
// Lecture de "Account.nbrOfAccount" sans instanciation.
console.log(Account.nbrOfAccount); // renvoi "undefined".
// Instanciation d'un premier "Account".
bruno = new Account("bruno@email.ici", "bépoB");
// Tentative de lecture d'une propriété d'instance depuis :
/* Une instance */ console.log(bruno.email); // renvoi "bruno@email.ici".
/* Un objet (classe) */ console.log(Account.email); // renvoi "undefined".
// Tentative de lecture d'une propriété d'objet (de classe) depuis :
/* Une instance */ console.log(bruno.nbrOfAccount); // renvoi "undefined".
/* Un objet (classe) */ console.log(Account.nbrOfAccount); // renvoi "1".
// Instanciation d'un deuxième "Account".
magalie = new Account("magalie@email.ici", "azertyB");
// Lecture de "Account.nbrOfAccount" après deux instanciations.
console.log(Account.nbrOfAccount); // renvoi 2.
// Création d'une variable dans le contexte d'exécution global.
global.email = "Rien ici"; // window.email est un équivalent dans les navigateurs.
console.log(global.email); // renvoi "Rien ici".
badUseCase = Account("badusecase@email.ici", "azertyB"); // On n'utilise pas le mot clé "new" pour instancier la classe.
// console.log(badUseCase.email); // error.
console.log(global.email); // renvoi "badusecase@email.ici".
Il y a deux problèmes à relever ici :
- On s'aperçoit à la ligne 4 des « Tests » que si aucune instance n'a été créée, la propriété nbrOfAccount de Account n'est jamais remplie. Elle est donc de type Undefined (au lieu de par exemple renvoyer 0).
- Le second problème vient de l'utilité de this. Si vous utilisez new lors de l'appel de Account (ligne 7 et 18 des « Tests »), cela permet à this d'être associé au contexte d'exécution de la fonction (nouvelle instance). Cependant, sans le mot-clé new, this reste associé au contexte d'exécution global à savoir la variable global (par exemple avec Node.js) et la variable window dans vos navigateurs (par exemple sous IE, Chrome, Firefox...).
Réglons ces problèmes avec le code ci-dessous :
var Account = function (email, password) {
// On vérifie bien que this est attaché au contexte d'exécution d'une nouvelle instance.
if (this instanceof Account) {
this.email = email;
this.password = password;
Account.nbrOfAccount = (typeof Account.nbrOfAccount != 'undefined') ? Account.nbrOfAccount + 1 : 1;
} else {
// On crache à la figure (mais gentiment) du développeur qui a fait une bétise :)
// L'erreur suivante s'affichera dans votre console ("La fonction 'Account' doit être instanciée avec le mot-clé 'this'.").
throw Error("'Account' function must be instantiated with the keyword 'this'.");
}
};
// On initialise la variable statique en dehors de la fonction pour qu'elle soit sur 0 initialement.
Account.nbrOfAccount = 0;
Malheureusement, l'utilisation de if (this instanceof Account) { empêche également à la fonction d'être utilisée par les fonctions « call(this) » ou apply(this) ». Et peut-être même que parfois il est intentionnel d'attacher le contexte à l'objet global. Dans la suite de cet article, nous n'allons pas utiliser ce contrôle (mais sachez qu'il existe).
Note : comment Account peut-être une fonction (ligne 1) et posséder une propriété nbrOfAccount (ligne 14). Et bien c'est tout simplement parce qu'une fonction est également un objet. Le type fonction (bien que renvoyé par typeof) n'existe pas. Une fonction n'est qu'un objet que l'on peut invoquer. Vous trouverez plus d'informations sur les 6 types en JavaScript dans mon billet précédent.
Accesseurs, encapsulation publique, encapsulation privée
Cela est fonctionnel, mais minimal. Comment pouvons-nous par exemple contrôller la façon dont une valeur doit être assignée ou renvoyée ? Dans notre exemple, nous aimerions pouvoir assigner un type String (et non un Object String) en valeur de l'attribut email et la renvoyer uniquement en minuscule par exemple. Nous allons utiliser pour cela des fonctions. Quand une fonction est une propriété d'un objet instancié par un constructeur, on l'appelle alors « Méthode », même en JavaScript.
Classe
var Account = function (email, password) {
/*** Variables conteneurs. ***/
this.email = email;
this.password = password;
/*** Variables statiques. ***/
Account.nbrOfAccount = (typeof Account.nbrOfAccount != 'undefined') ? Account.nbrOfAccount + 1 : 1;
/*****************************************************/
/*** Méthodes d'accession de retour - Getters. ***/
// J'associe à la propriété "getEmail" d'une instance d'"Account" une fonction.
// La différence entre le "this.email" et "this.getEmail()" est que
// "this.getEmail()" va renvoyer l'email en minuscule même si il a été passé avec des majuscules.
this.getEmail = function () {
return this.email.toLowerCase();
}
// J'associe à la propriété "getPassword" d'une instance d'"Account" une fonction.
// La différence entre le "this.password" et "this.getPassword()" est que
// "this.getPassword()" me permet de hasher le résultat avant de l'affficher.
this.getPassword = function (hashFunction) {
if (typeof hashFunction != 'function') { // Si le premier paramètre n'est pas une fonction.
return this.password; // On affiche le mot de passe normalement.
} else {
return hashFunction(this.password); // Sinon on l'affiche en fonction de ce qu'aura fait la fonction 'hashFunction'.
}
}
// Les Getters nous permettent donc d'afficher différemment la valeur stockée dans l'instance de l'objet.
// Mais la valeur reste conservée sans les modifications renvoyées.
/*****************************************************/
/*** Méthodes d'accession d'attribution - Setters. ***/
// J'associe à la propriété "setEmail" d'une instance d'"Account" une fonction.
// La différence entre le "this.email" et "this.setEmail()" est qu'avant d'attribuer la valeur à mon instance,
// "this.setEmail()" me permet de vérifier si c'est bien un email.
this.setEmail = function (value, isAnEmailFunction) {
value = (new String(value)).toString(); // On s'assure de travailler avec un type String.
if (typeof isAnEmailFunction != 'function') {
this.email = value;
} else {
this.email = isAnEmailFunction(value); // La valeur attribuée passe d'abord un test : est-elle un email valide ?
}
}
// J'associe à la propriété "setPassword" d'une instance d'"Account" une fonction.
// La différence entre le "this.password" et "this.setPassword()" est qu'avant d'attribuer la valeur à mon instance,
// "this.setPassword()" me permet de hasher la valeur.
this.setPassword = function (value, hashFunction) {
value = (new String(value)).toString();
if (typeof hashFunction != 'function') {
this.password = value;
} else {
this.password = hashFunction(value); // La valeur attribuée sera hashée comme 'hashFunction' l'aura décidée.
}
}
// Les Setters nous permettent donc avant stockage d'exercer un certain nombre de contrôles ou transformations à la valeur passée.
// La valeur reste alors conservée telle qu'elle a été modifiée et non telle qu'elle a été envoyée.
};
Account.nbrOfAccount = 0;
Note : this, à l'intérieur des méthodes, fait bien référence à une instance de « Account » car il fait référence au contexte d'exécution de l'instance. Comme expliqué plus tôt, il faudrait l'appeler avec le mot clé new pour que this fasse référence à la méthode en elle-même (ce qui ne sert à rien).
Petit scénario de test
// Ayons un peu d'imagination !
// "md5(value)" est une fonction de hash qui existe plus haut dans mon code.
// Elle hash la chaîne "value" en md5 pour empêcher de connaître le mot de passe tout en sachant vérifier si il est bon.
// "isEmail(value)" est une fonction qui existe plus haut.
// Elle vérifie que "value" soit un email valide. Si ce n'est pas le cas elle renvoi une erreur.
/* ... */
var bruno = new Account(); // Si les paramètres ne sont pas passés, "this.email" et "this.password" sont de type Undefined.
bruno.setEmail("COUCOU");
bruno.setPassword("bépoB");
console.log(bruno.email); // renvoi "COUCOU".
console.log(bruno.password); // renvoi "bépoB" (le mot de passe a été passé en clair).
console.log(bruno.getEmail()); // renvoi "coucou" (getEmail renvoi en minuscule).
console.log(bruno.getPassword(md5)); // renvoi "88255bfff0707a085e2f3faa5aa0d8cc".
var magalie = new Account();
// magalie.setEmail("COUCOU", isEmail); // error, car ce n'est pas un email.
magalie.setPassword("AzertyM", md5);
console.log( magalie.password ); // renvoi "237e4d6319a517c12bfbc8dc4c9c4fa4" (la valeur est transformée avant stockage).
console.log( magalie.getPassword() ); // renvoi "237e4d6319a517c12bfbc8dc4c9c4fa4".
console.log( magalie.getPassword(md5) ); // renvoi "eb20d719c7709e19a31936e36753244a" (le md5 de 237e4d6319a517c12bfbc8dc4c9c4fa4).
magalie.email = 17;
// console.log( magalie.getEmail() ); // error, on nous dit que "toLowerCase()" n'existe pas sur 17.
Les Getters et Setters vont nous être d'une grande utilité comme vous avez pu le constater cependant, tout ce travail est mis en l'air puisque l'on peut (les développeurs) passer directement par this.email par exemple (ligne 26 du scénario) et attribuer des valeurs sans vérification.
On va donc privatiser les propriétés comme suit :
var Account = function (email, password) {
var
publics = this, // On attache this à publique pour utiliser un mot avec du sens qu'en on associe les variables au contexte d'exécution.
privates = {}; // On créé un objet conteneur uniquement visible dans la fonction et ses sous fonctions. Pour rester cohérent, on l'appel privates.
/*** Variables privées. ***/
privates.email = email; // Cette propriété est attachée à privates, elle n'est donc pas accessible de l'extérieur.
privates.password = password;
/*** Variables statiques. ***/
Account.nbrOfAccount = (typeof Account.nbrOfAccount != 'undefined') ? Account.nbrOfAccount + 1 : 1;
/*** Méthodes publiques. ***/
publics.getEmail = function () { // Cette méthode est attachéé à publics, elle est donc accessible de l'extérieur.
return privates.email.toLowerCase(); // "privates.email" est accessible car une méthode fait partie du contexte d'exécution de son constructeur.
}
publics.setEmail = function (value, isAnEmailFunction) {
value = (new String(value)).toString();
if (typeof isAnEmailFunction != 'function') {
privates.email = value;
} else {
privates.email = isAnEmailFunction(value);
}
}
publics.getPassword = function (hashFunction) {
if (typeof hashFunction != 'function') {
return privates.password;
} else {
return hashFunction(privates.password);
}
}
publics.setPassword = function (value, hashFunction) {
value = (new String(value)).toString();
if (typeof hashFunction != 'function') {
privates.password = value;
} else {
privates.password = hashFunction(value);
}
}
};
Account.nbrOfAccount = 0;
En n'associant plus les propriétés email et password à this mais seulement à une simple variable interne (ligne 5), elles ne sont plus accessibles de l'extérieur.
var bruno = new Account();
bruno.setEmail("bruno.lesieur@gmail.com");
console.log(bruno.email); // renvoi "undefined".
console.log(bruno.getEmail()); // renvoi "bruno.lesieur@gmail.com".
bruno.email = 17;
console.log(bruno.getEmail()); // renvoi "bruno.lesieur@gmail.com".
console.log(Account.nbrOfAccount); // renvoi 1.
Note : le fait d'utiliser publics au lieu de this ou même d'attacher des variables en propriété à privates n'est absoluement pas obligatoire. Cela sert dans notre exemple à bien différencier ce qui est publique de ce qui est privé.
Un dernier problème reste à régler. Si je passe non pas par setEmail ou setPassword mais directement par mon constructeur, je ne pourrais pas par exemple vérifier que l'email est bon. Il suffit alors de ne plus associer les propriétés aux variables privées directement mais en passant par les Setters eux-mêmes dans le constructeur.
var Account = function (email, password) {
var
publics = this,
privates = {};
// Je n'assigne plus directement les propriétés sous peine de devoir réécrire les contrôles, ou ne pas en avoir du tout.
/*** Variables/Méthodes statiques. ***/
Account.nbrOfAccount = (typeof Account.nbrOfAccount != 'undefined') ? Account.nbrOfAccount + 1 : 1;
/*** Méthodes publiques. ***/
publics.getEmail = function () {
return privates.email.toLowerCase();
}
publics.setEmail = function (value, isAnEmailFunction) {
value = (new String(value)).toString();
if (typeof isAnEmailFunction != 'function') {
privates.email = value;
} else {
privates.email = isAnEmailFunction(value);
}
}
publics.getPassword = function (hashFunction) {
if (typeof hashFunction != 'function') {
return privates.password;
} else {
return hashFunction(privates.password);
}
}
publics.setPassword = function (value, hashFunction) {
value = (new String(value)).toString();
if (typeof hashFunction != 'function') {
privates.password = value;
} else {
privates.password = hashFunction(value);
}
}
/*** Constructeur ***/
// À la place je fais appel directement aux Setters dans ma classe.
// Je fais appel à eux après qu'ils aient été définis pour éviter les erreurs.
// Je m'aperçois que je ne peux pas passer de fonction md5 à "setPassword" de cette manière.
// Rien ne m'empêche de modifier les paramètres du constructeur pour permettre de le faire passer par là.
if (email) {
publics.setEmail(email); // Je vérifie que la construction est bonne pour le paramètre "email".
}
if (password) {
publics.setPassword(password); // Je vérifie que la construction est bonne pour le paramètre "password".
}
};
Account.nbrOfAccount = 0;
Getters et Setters rassemblés et chaînage à la jQuery
Utiliser un Getter et un Setter distinct est peut-être ce que vous avez toujours fait, mais pourquoi ne pas utiliser la même fonction pour attribuer une valeur ou la retourner ? Pure folie me dites-vous ? Avez-vous entendu parler d'une librairie se nommant jQuery ? Peut-être Prototype ou Mootools ? Bon, ça vous dit peut-être rien, mais c'est exactement ce qu'elles font.
Voici le principe des Getters et Setters tout en un, ce qui permet le chaînage.
- Si je dois manipuler la propriété privée "email", je le fais via la fonction publique "email()".
- Si je veux retourner la propriété "email", j'appel "email()" sans arguments.
- Si je veux attribuer une valeur à "email", j'appel "email(value)" avec pour "value" la valeur que je veux assigner.
- Quand j'attribue une valeur, au lieu que ma fonction ne retourne rien, elle retourne l'objet qui contient "email". Cela permet le chaînage.
var Account = function (email, password) {
var
publics = this,
privates = {};
/*** Variables/Méthodes statiques. ***/
Account.nbrOfAccount = (typeof Account.nbrOfAccount != 'undefined') ? Account.nbrOfAccount + 1 : 1;
/*** Méthodes publiques. ***/
// "getEmail" et "setEmail" deviennent une seule et même fonction, "email".
// Nous pouvons nous permettre de l'appeler comme cela, car "email" n'existe pas.
// Effectivement, maintenant que "email" est associée à "privates", ce nom est libre.
publics.email = function (value, isAnEmailFunction) {
// On vérifie que la fonction est utilisée comme un Getter.
// Pour en être sûr, il suffit de vérifier si la fonction n'a aucun paramètre.
// On vérifie donc que "value" a comme type Undefined.
if (typeof value == 'undefined') {
return privates.email.toLowerCase();
// Cependant, si une valeur est passée, c'est qu'on utilise la fonction comme un Setter.
// Dans ces cas-là, on y met la partie Setter.
} else {
value = (new String(value)).toString();
if (typeof isAnEmailFunction != 'function') {
privates.email = value;
} else {
privates.email = isAnEmailFunction(value);
}
return publics;
}
}
// Idem par ici.
publics.password = function (value, hashFunction) {
// Cependant, comme notre Getter peut prendre en premier paramètre une fonction,
// on vérifie que "value" ne soit pas de type Function (Object donc), car si c'est le cas,
// nous réclamons tout de même le Getter.
if (typeof value == 'undefined' || typeof value == 'function') {
// Nous passons donc "value" à "hashFunction" puisqu'en réalité lui n'a pas été demandé en mode "Getter".
hashFunction = value;
if (typeof hashFunction != 'function') {
return privates.password;
} else {
return hashFunction(privates.password);
}
} else {
value = (new String(value)).toString();
if (typeof hashFunction != 'function') {
privates.password = value;
} else {
privates.password = hashFunction(value);
}
return publics;
}
}
/*** Constructeur ***/
if (email) {
publics.email(email);
}
if (password) {
publics.password(password);
}
};
Account.nbrOfAccount = 0;
Avec un exemple d'utilisation du chainage :
console.log((new Account()).email("BRUNO@EMAIL.ICI").password("bépoB").email()); // renvoi "bruno@email.ici".
Note : pour faciliter la compréhension de la suite de notre article, nous allons retirer le support de "isAnEmailFunction" et de "hashFunction" dans nos exemples.
L'héritage de classe en JavaScript
On ne peut pas parler de classe sans parler d'héritage. Il n'existe pas plus que de réelle classe de quoi les faire hériter. Pourtant, le mécanisme de polymorphisme existe bien en JavaScript et nous allons reproduire ce que l'on peut appeler de l'héritage. L'idée c'est qu'une classe fille possède l'intégralité des méthodes publiques de sa classe mère en plus de ses propres méthodes. Voyons ensemble un moyen de faire de l'héritage de classe à partir du pattern de la partie précédente (et toujours sans prototypage).
Nous allons créer une classe « User ». Un user est un « Account » amélioré qui possède son propre nom et pourquoi pas diverses informations relatives à son état d'utilisateur (nom, prénom, date de naissance...). Nous allons juste ajouter une nouvelle propriété : le username.
Classe héritée
// Créons un objet "User" prennant en paramètre la même chose que "Account".
// Cependant, "username" est un paramètre en plus spécifique à "User".
var User = function (username, password, email /* , firstname, lastname, birthdate */) {
var
publics = this,
privates = {};
/****************/
/*** Héritage ***/
// Voici ce qui nous permet de faire de l'héritage, la méthode "call" (ou "apply") de tous les objets Function.
// Normalement, seul "Account" peut créer un "Account", c'est-à-dire un contexte d'exécution qu'il s'auto-associe.
// Cela lui permet de disposer de toutes les méthodes publiques qu'on lui a assigné.
// Mais la méthode "call" permet de lier le contexte d'exécution instancié de la fonction appelante (ici celui de "Account")
// au contexte d'exécution d'une autre fonction (ici "this", c'est-à-dire celui de "User").
// Le premier paramètre est donc "this" et les autres sont ceux qu'aurait normalement reçu le constructeur de "Account".
Account.call(this, email, password);
// L'héritage multiple est donc ici permis, nous pourrions associer et une classe "Account",
// et un classe "Person" pour former le contexte d'exécution rempli de la toute nouvelle classe "User".
// Ainsi User = Account + Person.
// Person.call(this, firstname, lastname, birthdate);
/*** Variables/Méthodes statiques. ***/
User.nbrOfUser = (typeof User.nbrOfUser != 'undefined') ? User.nbrOfUser + 1 : 1;
/*** Méthodes publiques. ***/
publics.username = function (value) {
if (typeof value == 'undefined') {
return privates.username;
} else {
privates.username = value;
return publics;
}
}
/*** Constructeur ***/
if (username) {
publics.username(username);
}
};
User.nbrOfUser = 0;
Tests
var bruno = (new Account())
.email("bruno@email.ici")
.password("bépoB");
var magalie = (new User())
.username("Magalie")
.password("AzertyM")
.email("magalie@email.ici");
console.log(bruno.email()); // renvoi "bruno@email.ici".
console.log(magalie.email()); // renvoi "magalie@email.ici".
// console.log(bruno.username()); // error
console.log(magalie.username()); // renvoi "Magalie".
console.log(Account.nbrOfAccount); // renvoi 2 (puisqu'un "User" est également un "Account").
console.log(User.nbrOfUser); // renvoi 1.
Vous pouvez trouver gênant que nbrOfAccount s'incrémente quand même lors de la création d'un nouveau « User ». Est-ce que vous vous rappelez de (this instanceof Account) sensé n'exécuter le code que si this s'attache à un contexte d'exécution émanent d'une fonction « Account » uniquement ? Et bien sachez que son défaut (ou son avanbtage, c'est au choix) est qu'il interdit l'exécution de code dans le contexte d'exécution global mais également dans les classes héritées.
Nous pouvons dès lors ajouter une variable statique à « Account » :
var Account = function (email, password) {
/* ... */
/*** Variables/Méthodes statiques. ***/
Account.nbrOfAccount = (typeof Account.nbrOfAccount != 'undefined') ? Account.nbrOfAccount + 1 : 1;
// Ajout d'une fonction pour ne compter que les objets non hérités.
Account.nbrOfRealAccount = (typeof Account.nbrOfRealAccount != 'undefined') ? ((this instanceof Account) ? Account.nbrOfRealAccount + 1 : Account.nbrOfRealAccount) : 1;
/* ... */
};
Account.nbrOfAccount = 0;
Account.nbrOfRealAccount = 0;
Héritage par délégation ou prototypal
La partie précédente, bien que fonctionnelle, ne va pas nous permettre d'aller plus loin dans notre exercice de simuler le comportement de classe en JavaScript. La réelle question c'est : est-ce réellement important ? Et si plutôt que de nous focaliser sur la façon de faire ressembler le système à celui des autres langages nous tirions plutôt partie des mécanismes en place pour faire mieux que ça ? Je vais vous présenter le prototypage en JavaScript.
Variable privées ou prototypes, il faut choisir
Actuellement, nous créons nos méthodes au sein du constructeur (faisant office de classe). Cela est possible mais il existe le mécanisme de prototypage qui nous permet de réaliser cela de manière plus performante. Cependant, il va y avoir des changements à opérer, notamment celui de perdre le bénéfice d'une vrai encapsulation privée.
Utilisons le prototypage avec notre classe constructeur « Account »
var Account = function (email, password) {
var
publics = this,
privates = {};
/*** Variables/Méthodes statiques. ***/
Account.nbrOfAccount = (typeof Account.nbrOfAccount != 'undefined') ? Account.nbrOfAccount + 1 : 1;
Account.nbrOfRealAccount = (typeof Account.nbrOfRealAccount != 'undefined') ? ((this instanceof Account) ? Account.nbrOfRealAccount + 1 : Account.nbrOfRealAccount) : 1;
// Nous sortons les méthodes publiques du corps du constructeur.
// Cela afin de les associer par la voix du prototype.
// Nous verrons plus loin en quoi cela est différent.
/*** Constructeur ***/
if (email) {
publics.email(email);
}
if (password) {
publics.password(password);
}
};
Account.nbrOfAccount = 0;
Account.nbrOfRealAccount = 0;
/*** Méthodes publiques. ***/
// Nous accrochons les méthodes au prototype de "Account".
// Et le premier constat est que la variable "privates" n'existe plus dans notre prototype.
// Nous pourrions la redéfinir dans la fonction elle-même...
Account.prototype.email = function (value) {
// Vérifions si "privates" est conservée dans un contexte d'exécution.
console.log(privates);
// Si "privates" n'existe pas on le créé, sinon on l'utilise.
var privates = privates || {};
if (typeof value == 'undefined') {
// Que nous renvoi "privates.email" ?
console.log(privates.email);
return privates.email.toLowerCase();
} else {
privates.email = (new String(value)).toString();
// Que nous renvoi "privates.email" ?
console.log(privates.email);
// Transformation de "publics" en "this" car publics n'existe plus.
return this;
}
}
Account.prototype.password = function (value) {
var privates = privates || {};
if (typeof value == 'undefined') {
return privates.password;
} else {
privates.password = (new String(value)).toString();
return this;
}
}
var bruno = (new Account())
.email("bruno@email.ici")
.password("bépoB");
console.log(bruno.email()); // error
En exécutant la ligne 66 tout ce passe bien. Quand la fonction email() est appelée avec un paramètre ligne 67, on passe dans le code ligne 34. La variable privates est alors undefinded ce qui est normal. Puis on passe par la ligne 37 qui nous créé notre variable privates et par la ligne 49 qui nous affiche bien notre valeur. Tout baigne ! Le problème viens quand on réclame cette valeur à la ligne 70. On pourrait s'attendre à ce que cette fois-ci la ligne 34 nous renvoi « { email: "bruno@email.ici" } » mais non, elle retourne de nouveau « undefined ». Ce qui fait qu'à la ligne 37 on recréé de nouveau la variable « privates », que la ligne 42 nous affiche « undefined » et qu'on nous crache à la figure ligne 44 car toLowerCase() n'est pas une méthode du type Undefined.
Une méthode attachée à un Objet par son prototype n'a :
- pas accès aux variables définis dans le constructeur,
- et n'a pas de contexte d'exécution personnel auquel se référer lui permettant de conserver des valeurs après la fin de l'exécution de la fonction.
Cette limitation est dû au fonctionnement même du prototypage. En réalité les méthodes attachées au prototype d'un objet n'appartiennent pas à l'instance de l'objet une fois celui-ci appelé avec new mais peuvent tout de même manipuler les propriétés et méthodes de l'instance qui sont attachées à this et sont elles-mêmes disponible via this. Cela signifie que les méthodes ne sont pas « dupliquées » à chaque instance mais seulement appelées par elle et référencées une unique fois dans le prototype.
- C'est un gain indéniable de performance et de place mémoire occupée par les objets.
- Cela permet de définir ou redéfinir des méthodes dans des fichiers séparés.
- Mais cela limite l'utilisation des variables privées.
De fausses variables privées
Une solution simple est donc d'attacher les variables anciennement privées à this de manière à les rendre disponibles à l'utilisation par les prototypes. Le problème est bien évidemment qu'elles pourront être lues ou assignées sans passer par leurs méthodes respectives. C'est un problème que nous allons limiter en indiquant qu'elles sont privées (bien qu'elles ne le soient pas). Il sera convenu avec vos développeurs qu'il ne faut jamais toucher à ces variables en dehors du constructeur et des prototypes.
var Account = function (email, password) {
// Voici notre fausse variable privée.
// Elle est bien accessible en passant par
// l'objet __private qui est accessible par une instance.
// Mais vous vous l'interdirez.
// Pour prévenir l'écrasement de this.__privates, on test d'abords son existence.
this.__privates = this.__privates || {};
/*** Constructeur ***/
if (email) {
this.email(email);
}
if (password) {
this.password(password);
}
/*** Variables/Méthodes statiques. ***/
Account.nbrOfAccount = (typeof Account.nbrOfAccount != 'undefined') ? Account.nbrOfAccount + 1 : 1;
Account.nbrOfRealAccount = (typeof Account.nbrOfRealAccount != 'undefined') ? ((this instanceof Account) ? Account.nbrOfRealAccount + 1 : Account.nbrOfRealAccount) : 1;
};
Account.nbrOfAccount = 0;
Account.nbrOfRealAccount = 0;
/*** Méthodes publiques. ***/
Account.prototype.email = function (value) {
var privates = this.__privates; // On crée un raccourci aux variables privées.
if (typeof value == 'undefined') {
return privates.email.toLowerCase();
} else {
privates.email = (new String(value)).toString();
return this;
}
}
Account.prototype.password = function (value) {
var privates = this.__privates; // On crée un raccourci aux variables privées.
if (typeof value == 'undefined') {
return privates.password;
} else {
privates.password = (new String(value)).toString();
return this;
}
}
Héritage de prototype
Occupons nous à présent du cas de « User ». Les constructeurs sont bien hérités avec notre appel à call mais quand est-il des prototypes ? Et bien pour simuler un héritage il va falloir « recopier » l'intégralité des prototypes de « Account » dans « User » en prenant soin de ne jamais écraser un prototype portant le même nom dans la classe héritière.
Héritage (multiple)
var User = function (username, password, email /* , firstname, lastname, birthdate */) {
this.__privates = this.__privates || {};
/*** Héritage ***/
// Ici on recopie l'intégralité des prototypes de "Account" dans "User".
// Il faut le faire avant l'appel de "Account.call()" sinon arrivé à "this.email()"
// dans le constructeur "Account" ça plantera car "User" n'aura pas encore le prototype de "email()".
// On parcourt tous les prototypes de "Account".
for (var p in Account.prototype) {
// On empèche d'écraser un prototype qu'on aurait volontairement surchargé pour le constructeur fils.
if (typeof User.prototype[p] != 'function') {
User.prototype[p] = Account.prototype[p];
}
}
Account.call(this, email, password);
/*********************/
/* Héritage multiple */
// Nous pourrions également hériter d'un constructeur "Person" ici et obtenir un objet contenant les prototypes des deux constructeurs "Account" et "Person".
/*
for (var p in Person.prototype) {
if (typeof User.prototype[p] != 'function') {
User.prototype[p] = Person.prototype[p];
}
}
Person.call(this, firstname, lastname, birthdate);
*/
/*** Variables/Méthodes statiques. ***/
User.nbrOfUser = (typeof User.nbrOfUser != 'undefined') ? User.nbrOfUser + 1 : 1;
/*** Constructeur ***/
if (username) {
this.username(username);
}
};
User.nbrOfUser = 0;
/*** Méthodes publiques. ***/
User.prototype.username = function (value) {
var privates = this.__privates;
if (typeof value == 'undefined') {
return privates.username;
} else {
privates.username = value;
return this;
}
}
Tests globaux
console.log(
(new Account()).email("bruno@email.ici").password("bépoB").email() // renvoi "bruno@email.ici".
);
var magalie = new User("Magalie", "AzertyM", "magalie@email.ici");
console.log(magalie.email()); // renvoi "magalie@email.ici".
console.log(magalie instanceof Account); // renvoi true.
console.log(magalie instanceof User); // renvoi true.
console.log(Account.nbrOfAccount); // renvoi 2.
console.log(Account.nbrOfRealAccount); // renvoi 1.
console.log(User.nbrOfUser); // renvoi 1.
Pour finir, je vous offre un moyen de complètement prototyper votre classe mais qui vous interdira de l'héritage multiple.
Code
/********************/
/** Account Object **/
/********************/
var Account = function (email, password) {
this.init(email, password);
/*** Variables/Méthodes statiques. ***/
Account.nbrOfAccount = (typeof Account.nbrOfAccount != 'undefined') ? Account.nbrOfAccount + 1 : 1;
Account.nbrOfRealAccount = (typeof Account.nbrOfRealAccount != 'undefined') ? ((this instanceof Account) ? Account.nbrOfRealAccount + 1 : Account.nbrOfRealAccount) : 1;
}
Account.nbrOfAccount = 0;
Account.nbrOfRealAccount = 0;
/*** Constructeur ***/
Account.prototype.init = function (email, password) {
this.__privates = this.__privates || {};
if (email) {
this.email(email);
}
if (password) {
this.password(password);
}
};
/*** Méthodes ***/
Account.prototype.email = function (value) {
var privates = this.__privates;
if (typeof value == 'undefined') {
return privates.email.toLowerCase();
} else {
privates.email = (new String(value)).toString();
return this;
}
}
Account.prototype.password = function (value) {
var privates = this.__privates;
if (typeof value == 'undefined') {
return privates.password;
} else {
privates.password = (new String(value)).toString();
return this;
}
}
/*****************/
/** User Object **/
/*****************/
var User = function (username, password, email) {
this.init(username, password, email);
// Variables/Méthodes statiques. //
User.nbrOfUser = (typeof User.nbrOfUser != 'undefined') ? User.nbrOfUser + 1 : 1;
};
User.nbrOfUser = 0;
/*** Passage de prototype ***/
User.prototype = new Account();
User.prototype.super = User.prototype.init;
/*** Constructeur ***/
User.prototype.init = function (username, password, email) {
this.super(email, password);
this.__privates = this.__privates || {};
if (username) {
this.username(username);
}
};
/*** Méthodes ***/
User.prototype.username = function (value) {
var privates = this.__privates;
if (typeof value == 'undefined') {
return privates.username;
} else {
privates.username = value;
return this;
}
}
Tests
console.log(
(new Account()).email("bruno@email.ici").password("bépoB").email() // renvoi "bruno@email.ici".
);
var magalie = new User("Magalie", "AzertyM", "magalie@email.ici");
console.log(magalie.email()); // renvoi "magalie@email.ici".
console.log(magalie instanceof Account); // renvoi true.
console.log(magalie instanceof User); // renvoi true.
console.log(Account.nbrOfAccount); // renvoi 2.
console.log(Account.nbrOfRealAccount); // renvoi 1.
console.log(User.nbrOfUser); // renvoi 1.
Pour aller plus loin
Je suis certain que ces patterns sont non exhaustifs et qu'il existe encore bien d'autres variantes pour structurer ses données dans sa partie modèle en JavaScript. Si cela vous a inspiré, que vous-même vous connaissez des astuces pour contourner certaines limitations que j'ai mentionnées ou pour tout autre remarque n'hésitez pas.
Pour finir, si des points vous ont semblé un peu vagues ou que vous voulez en savoir plus sur les Prototypes ainsi que tous les mécanismes qui ont permis de réaliser ces pseudo-classes, je vous invite à lire Voyage au coeur de JavaScript qui est un long article résumant de manière détaillée et concise les mécanismes mis en jeu dans JavaScript.
2 Commentaires
Choisir un Avatar depuis une url
Adresse : (64px x 64px)ou Changer la couleur de fond
et Choisir un Avatar dans la liste
Votre commentaire a été ajouter ci-dessous ! Si vous désirez le supprimer ultérieurement, servez vous du code suivant :
Les commentaires sont actuellement affiché du plus rescent au plus ancien. Vous pouvez inverser l'ordre en cliquant ci-dessous :
C'est bien l'impression que j'ai eu en découvrant ce blog et le site afférent: son auteur est fou! Et j'adore cette folie! Mais je suis loin d'être capable de comprendre ce qui sort de cet esprit échevelé. Pourtant, j'aimerais le suivre et là, je découvre qu'il n'y a pas de fil RSS visible pour retrouver les billets de ce blog: oubli, ou fait exprès?
Je trouve ce commentaire pertinent ?
Tu es un fou Bruno ! Bravo pour cet article très détaillé et passionnant. Il va falloir que j'y revienne plusieurs fois afin de bien comprendre comment cela fonctionne en profondeur mais ce que j'ai lut me plait beaucoup.
A quand un tutoriel pas à pas pour faire un échange client/serveur en Node.js partant de zéro (avec l'installation du serveur en amont) ?
A bientôt,
Pierre
Je trouve ce commentaire pertinent ?
Je trouve ce commentaire pertinent ?