lundi 5 septembre 2016

La vie privée et cachée des membres en Javascript (ES6)

Il est étonnant, depuis la release d'ES6, de ne pas avoir vu évoluer les techniques permettant de gérer correctement les membres privés des objets en Javascript en attendant le support natif du mot clé "private" (à l'heure actuelle, ce mot clé est réservé, mais pas utilisé).

En effet, aucune technique connue ne satisfait toutes ces caractéristiques :
  1. interdire rigoureusement l'accès aux membres privés
  2. les méthodes accédant aux membres privés doivent être des méthodes de classe
  3. l'instance obtenue doit bien être du type souhaité
  4. les membres privés doivent être directement associés à leur instance
Afin de combler ce manque, cet article propose donc une technique qui couvre toutes ces exigences et qui me paraît l'une des plus élégantes que petits et grands pourront à loisir adapter pour leurs propres projets.


Mais tout d'abord, faisons un tour d'horizon des techniques classiques. On va modéliser un objet simple, une voiture, avec 2 propriétés, le type de voiture qui est public et le kilométrage qui est privé, et 2 méthodes l'une pour augmenter le kilométrage (conduire), l'autre pour l'obtenir.


Privativité basée sur des conventions de nommage



Dans cet exemple, on convient d'une règle de nommage, par exemple préfixer les noms avec "_", pour indiquer qu'un membre est privé :
// privacy based on naming convention
export default class Car {

    constructor(type) {
        this._mileage = 0;
        this.type = type;
    }

    drive(km) {
        if (typeof km == 'number' && km >= 0) {
            this._mileage += km;
        } else {
            throw new Error('drive() only accepts numbers >= 0');
        }
    }

    mileage() {
        return this._mileage;
    }

}
Simple et sans bavure ? Pas tout à fait car une fois une instance obtenue, il est possible d'accéder à la propriété "privée" en dépit de la convention de nommage (ce n'est qu'une convention, cela n'empêche rien) myCar._mileage = 'bad data';

C'est une pratique assez courante, mais elle se base sur la seule discipline du programmeur.

Conclusion : [VIOLATION DE LA REGLE #1]

Privativité construite par fermeture



La seule technique éprouvée qui permette en Javascript de créer de la privativité est de recourir à une fermeture (closure) ; celle-ci permet de créer un contexte d'accessibilité réduit à la fonction qui le définit, incluant les fonctions définies à l'intérieur de ce contexte et uniquement celles-là.
// closure-based privacy
export default class Car {

    constructor(type) {
        let mileage = 0;
        this.type = type;

        this.drive = function(km) {
            if (typeof km == 'number' && km >= 0) {
                mileage += km;
            } else {
                throw new Error('drive() only accepts numbers >= 0');
            }
        }

        this.mileage = function () {
            return mileage;
        }
    }

}
Question accessibilité des données, c'est parfait : il n'y a aucun moyen à partir d'une instance d'obtenir directement un accès à la variable "mileage", tout simplement parce qu'elle ne fait pas partie de l'objet, mais du contexte du constructeur qui subsiste tant que l'objet existe car les 2 méthodes "drive" et "mileage" y font référence. Aucune autre fonction ne peut y accéder.

L'inconvénient de cette technique est que les méthodes sont créées pour chaque instance créée. S'il y a de très nombreuses méthodes et de très nombreuses instances, cela finira par prendre beaucoup de mémoire et deviendra peu efficace.

Conclusion : [VIOLATION DE LA REGLE #2]

Privativité construite par fermeture sans copie des méthodes



Evitons de copier les méthodes dans chaque instance en ayant recours à une classe intermédiaire :
// closure-based privacy without copying methods
export default class Car {

    constructor(type) {
        let mileage = 0;

        class TheCar {
            constructor(type) {
                this.type = type;
            }

            drive(km) {
                if (typeof km == 'number' && km >= 0) {
                    mileage += km;
                } else {
                    throw new Error('drive() only accepts numbers >= 0');
                }
            }

            mileage() {
                return mileage;
            }
        }

        return new TheCar(type);
    }

}
Ici, on détourne le constructeur pour fournir un autre objet : le code perd en clarté, mais on a bien une réelle privativité sans avoir copié les méthodes dans chaque instance.

L'inconvénient est qu'une instance perd sa classe d'origine : myCar instanceof Car donne false. Je ne crois pas qu'on puisse bricoler un changement des variables internes (constructor, prototype) de l'objet car instanceof examine justement le constructeur de la chaîne des prototypes et si celui-ci était changé on n'aurait plus de visibilité des méthodes.

Conclusion : [VIOLATION DE LA REGLE #3]

Privativité obtenue par la gestion des références faibles



Cette technique combine les mêmes avantages que la précédente mais sans perdre la référence à la classe d'origine : elle utilise un objet WeakMap (invisible du code client) qui associe à chaque instance le kilométrage.

Pour ceux qui ne connaissent pas les références faibles, il s'agit d'un mécanisme permettant d'associer des données à une clé, tant que la clé est par ailleurs accessible par une référence forte (c'est à dire accessible d'une manière ou d'une autre par la chaîne des références connues du programme, en prenant soin d'éradiquer correctement les références circulaires, mais cela est pris en charge par le moteur Javascript) ; en clair, dès qu'un objet est éligible pour le ramasse-miette, les données liées par référence faible le sont aussi (à moins qu'elles ne soient elles-mêmes liées par une référence forte).
// privacy based on weak maps
const MILEAGES = new WeakMap();

export default class Car {

    constructor(type) {
        MILEAGES.set(this, 0);
        this.type = type;
    }

    drive(km) {
        if (typeof km == 'number' && km >= 0) {
            let mileage = MILEAGES.get(this);
            mileage += km;
            MILEAGES.set(this, mileage);
        } else {
            throw new Error('drive() only accepts numbers >= 0');
        }
    }

    mileage() {
        return MILEAGES.get(this);
    }

}
Cette technique est facilement extensible si plusieurs données privées doivent être gérées. Elle reste également facilement lisible. Chaque accès à une propriété "privée" requiert une lecture du WeakMap ; à la longue ça peut pénaliser les temps de réponse.

Techniquement, les données privées ne font pas partie de l'instance, elles sont gérées "à côté".

Conclusion : [VIOLATION DE LA REGLE #4]

Privativité basée sur les symboles



On peut recourir aux symboles, nouveaux dans ES6, qui permettent de dénaturer le nom d'une propriété :
// privacy based on ES6 Symbols
const MILEAGE = Symbol('mileage');

export default class Car {

    constructor(type) {
        this[MILEAGE] = 0;
        this.type = type;
    }

    drive(km) {
        if (typeof km == 'number' && km >= 0) {
            this[MILEAGE] += km;
        } else {
            throw new Error('drive() only accepts numbers >= 0');
        }
    }

    mileage() {
        return this[MILEAGE];
    }

}
L'effet est un peu meilleur que la convention de nommage (premier exemple), sauf que la propriété est moins visible. Mais elle n'en reste pas moins accessible par Object.getOwnPropertySymbols(myCar). S'il y a plusieurs symboles, ce sera plus compliqué de savoir à quoi chacun peut servir, mais les valeurs n'en seront pas moins directement accessibles.

Conclusion : [VIOLATION DE LA REGLE #1]

Réelle privativité



Pour finir, toujours en utilisant les dernières fonctionnalités ES6 (Proxy, Reflect et Symbol), on peut élégamment améliorer la précédente technique en interdisant l'accès aux symboles.

Je baptise cette technique réelle privativité, car contrairement aux exemples précedents, toutes les règles #1 #2 #3 et #4 sont satisfaites. La propriété que l'on souhaite rendre privée est bien attachée à son instance, mais je ne transige pas sur les autres aspects : les méthodes sont bien des méthodes de classe (du prototype et non pas de l'instance), et on ne perd pas la référence à la classe qui a construit l'instance.
const handler = {
    ownKeys(target) {
        return Reflect
            .ownKeys(target)
            .filter(key => (typeof key !== 'symbol'));
    }
}

const MILEAGE = Symbol('mileage');

export default class Car {

    constructor(type) {
        this[MILEAGE] = 0;
        this.type = type;
        return new Proxy(this, handler);
    }

    drive(km) {
        if (typeof km == 'number' && km >= 0) {
            this[MILEAGE] += km;
        } else {
            throw new Error('drive() only accepts numbers >= 0');
        }
    }

    mileage() {
        return this[MILEAGE];
    }

}
Ce qui est intéressant :
  • c'est qu'il n'est pas obligatoire de filtrer tous les symboles, on peut se contenter de ceux qu'on veut garder privés
  • et que contrairement aux apparences, la classe proxy ne masque pas l'appartenance de l'instance à la classe cible : myCar instanceof Car donne true

Crash test



On n'est pas obligé de croire cela sur parole, il faut encore :
  • vérifier ce que donne instanceof,
  • vérifier l'accès aux propriétés par Reflect.ownKeys(),
  • vérifier l'accès aux propriétés par for ... in loop et Object.getOwnPropertySymbols()
  • vérifier la forme sérialisée en JSON
Pour exécuter mes classes et vérifier les caractéristiques des instances obtenues, j'ai utilisé le code ci-dessous.
import util from 'util';

function crashTest(car, carClass, kind) {
    console.log(`\n==== ${kind} ====`);
    // on roule un peu...
    car.drive(30);
    car.drive(12);
    console.log(`J'ai roulé ${car.mileage()}km en ${car.type}`);

    // avons-nous bien une instance de la classe désirée ?
    console.log(`La classe ${carClass.name} ${car instanceof carClass ? "est bien":"n'est pas"} une voiture`);

    // les propriétés sont elles accessibles par introspection avec Reflect ?
    let keys = [];
    for (let key of Reflect.ownKeys(car)) {
        keys.push(key);
    }
    console.log(`Membres recherchés par introspection : ${util.inspect(keys)}`);

    // les propriétés sont elles accessibles par introspection ?
    let members = [];
    for (let key in car) {
        members.push({[key] : car[key]});
    }
    for (let key of Object.getOwnPropertySymbols(car)) {
        members.push({[key.toString()] : car[key]});
    }
    console.log(`Les propriétés de ${car.type} sont : ${util.inspect(members)}`);

    // que donne l'instance en JSON ?'
    console.log(`${car.type} en JSON : ${util.inspect(car)}`);

}

import Car from './car';
crashTest(new Car('Peugeot 205'), Car, 'Réelle privativité');

Et la sortie concernant le dernier exemple est :
==== Réelle privativité ====
J'ai roulé 42km en Peugeot 205
La classe Car est bien une voiture
Membres recherchés par introspection : [ 'type' ]
Les propriétés de Peugeot 205 sont : [ { type: 'Peugeot 205' } ]
Peugeot 205 en JSON : Car { type: 'Peugeot 205' }
Pour les autres exemples, vous obtiendrez :
==== Privativité basée sur des conventions de nommage ====
J'ai roulé 42km en Peugeot 205
La classe Car est bien une voiture
Membres recherchés par introspection : [ '_mileage', 'type' ]
Les propriétés de Peugeot 205 sont : [ { _mileage: 42 }, { type: 'Peugeot 205' } ]
Peugeot 205 en JSON : Car { _mileage: 42, type: 'Peugeot 205' }

==== Privativité basée sur une fermeture ====
J'ai roulé 42km en Peugeot 205
La classe Car est bien une voiture
Membres recherchés par introspection : [ 'type', 'drive', 'mileage' ]
Les propriétés de Peugeot 205 sont : [ { type: 'Peugeot 205' },
  { drive: [Function] },
  { mileage: [Function] } ]
Peugeot 205 en JSON : Car { type: 'Peugeot 205', drive: [Function], mileage: [Function] }

==== Privativité basée sur une fermeture sans copie des méthodes ====
J'ai roulé 42km en Peugeot 205
La classe Car n'est pas une voiture
Membres recherchés par introspection : [ 'type' ]
Les propriétés de Peugeot 205 sont : [ { type: 'Peugeot 205' } ]
Peugeot 205 en JSON : TheCar { type: 'Peugeot 205' }

==== Privativité basée sur les références faibles ====
J'ai roulé 42km en Peugeot 205
La classe Car est bien une voiture
Membres recherchés par introspection : [ 'type' ]
Les propriétés de Peugeot 205 sont : [ { type: 'Peugeot 205' } ]
Peugeot 205 en JSON : Car { type: 'Peugeot 205' }

==== Privativité basée sur les symboles ====
J'ai roulé 42km en Peugeot 205
La classe Car est bien une voiture
Membres recherchés par introspection : [ 'type', Symbol(mileage) ]
Les propriétés de Peugeot 205 sont : [ { type: 'Peugeot 205' }, { 'Symbol(mileage)': 42 } ]
Peugeot 205 en JSON : Car { type: 'Peugeot 205' }
Comme dit précédemment, celles qui donnent un résultat correct ne sont pas totalement satisfaisantes sur les autres critères.

Pour exécuter ces exemples dans NodeJS, il vous faut le fichier .babelrc :
{
  "presets": ["es2015"]
}
Et pour installer le tout :
npm install --save-dev babel-cli babel-preset-es2015
Puis complétez la section "scripts" dans package.json :
    "start": "babel-node crashTest.js"
Et enfin pour lancer : npm start

Privativité et/and privacy



"Privativité" n'a pas l'air d'être un mot du dictionnaire, mais bon, franciser "privacy" de la sorte me semble approprié : j'en ai déjà croisé sur internet.




Semantic Mismatch
ERR-19 : IllegalArgumentException
ARC must have a single CORDE
-- In "Avoir plusieurs cordes à son arc"
-- See log for details



Aucun commentaire:

Enregistrer un commentaire