Métaprogrammation et structures de données en JavaScript

Beaucoup de mes collègues le savent, je ne suis pas un grand fan de TypeScript. Même si je trouve qu’il y a beaucoup de choses intéressantes dans ce projet que j’aimerais bien voir arriver dans JavaScript (les annotations de type sur les variables et signatures de fonction par exemple), j’ai toujours trouvé que les bénéfices supposés ne valaient pas l’effort d’outillage que ça requiert.

En effet, TypeScript est fondamentalement un analyseur de structures de données statiques qui va prévenir le développeur qu’il est en train de faire une erreur de manipulation de ses structures. Super, mais franchement est-ce que j’ai vraiment besoin d’un outil supplémentaire pour faire ce que JavaScript peut faire nativement ? Comment ? Vous croyez que c’est compliqué et coûteux de faire de la vérification de structure de données à l’exécution en JavaScript ?

OK, là on compare des choux et des carottes. Mais vous savez quoi ? On va prendre ça comme prétexte pour faire un peu de métaprogrammation en JavaScript. 😁

Métaprogrammation !

Définir ce qu’est la métaprogrammation n’est jamais très simple, surtout avec des langages modernes comme JavaScript qui ont tendance à mélanger facilement la programmation de base et la métaprogrammation. Mais essayons quand même, on va dire que la métaprogrammation, c’est la programmation qui consiste à créer des programmes qui manipulent des programmes, possiblement eux-mêmes ! 🤪

Globalement, la métaprogrammation se divise en deux branches :

  • La programmation qui vise à créer, manipuler et exécuter un autre langage de programmation (C’est ce que fait un compilateur ou un transpileur, ainsi TypeScript ou Babel sont de parfaits exemples de ce genre de métaprogrammation appliqués à JavaScript).
  • La programmation qui vise à intercepter et interagir avec le fonctionnement même du langage qu’on utilise. On appelle ça parfois : métaprogrammation réflexive.

C’est ce dernier type de métaprogrammation qui va nous intéresser ici. Elle va nous permettre d’agir à trois niveaux différents :

  • L’introspection : on peut observer le langage et son fonctionnement à l’exécution
  • L’auto-modification : on peut changer la structure du programme pendant l’exécution
  • L’intercession : on peut redéfinir la sémantique et le comportement de certaines opérations du langage.

JavaScript est particulièrement bien outillé pour tout ça. Que ce soit via les méthodes statiques de Object ou Reflect, l’accès au prototype des objets, les Proxy… il y a de quoi faire.

Ceci étant dit, on va appliquer tout ça à la gestion de structure de données en JavaScript. Vous allez voir, c’est beaucoup moins compliqué que ça en a l’air.

Collections, Dictionnaire, Énumération, etc.

Quand on parle de structure de données, JavaScript est un langage extrêmement souple qui, pour certains, manque de rigueur et donc offre trop de risque d’erreur, en particulier dans les mains de développeurs peu expérimentés. Ce n’est pas complètement faux, mais comme on va le voir, c’est assez simple de “durcir” le comportement de JavaScript.

Il existe 3 grandes catégories de structure de données : les collections, les énumérations et les dictionnaires. Selon les langages, ces termes peuvent recouvrir des réalités très différentes. Aussi, pour la suite de cet article, nous allons arbitrairement définir ces termes de la façon suivante :

  • Une collection est une liste de valeurs. En JavaScript, on matérialisera ça sous la forme d’un objet de type Array, Set, WeakSet ou des tableaux typés pour les données binaires.
  • Une énumération est une liste d’identifiants (clés) dont la valeur réelle, souvent sans importance, doit être figée et garantie dans le temps. JavaScript n’offre pas une telle structure nativement, on va voir comment pallier ce manque.
  • Un dictionnaire est une liste de couples clé/valeur ou les valeurs peuvent être modifiées en fonction des besoins du programme. En JavaScript, on matérialisera ça sous la forme d’un objet de type Object, Map ou WeakMap.

Le problème le plus souvent évoqué, c’est qu’une collection ou un dictionnaire en JavaScript n’offre aucune garantie sur sa forme : les clés et les valeurs peuvent changer arbitrairement sans contrainte. D’expérience, le vrai problème vient du fait que la plupart des développeurs JavaScript n’utilisent que les types Array et Object sans se poser de questions 😉.

On notera qu’une proposition est en cours d’étude autour de deux types immutables : Record et Tuple (à l’étape 2 du processus de standardisation ECMAScript du TC39 au moment où j’écris ces lignes) qui vont permettre de rendre une partie de cet article obsolète dans quelques années (normalement, à la fin de la lecture de cet article vous devriez être capable de créer des polyfills de ces futurs types).

Contraindre un dictionnaire

OK, attaquons par la question des dictionnaires en répondant à deux questions :

  1. Comment créer un dictionnaire dont les clés sont toujours les mêmes ?
  2. Comment créer un dictionnaire dont les valeurs ont un type contraint ?

On va commencer par répondre de manière naïve à la première question :

function Record(data) {
  return Object.seal(
    Object.assign(
      Object.create(null),
      data
    )
  )
}

On pourra alors créer une structure figée en faisant :

const point = Record({ x: 1, y: 2 })

Ainsi, on a la certitude absolue que notre point n’aura jamais que deux propriétés: x et y. (Et si vous memoïsez correctement la fonction Record, vous aurez en plus le bénéfice de pouvoir garantir que Record({a: 1}) === Record({a: 1}))

Alors qu’est-ce qu’on a fait dans notre fonction Record ? Tout d’abord on a créé un objet sans prototype (Object.create(null)). C’est un objet qui n’a pas de chaîne de prototype, ce qui veut dire que si vous utilisez l’opérateur in (ou la méthode Reflect.has()), vous avez la garantie qu’il n’ira pas chercher une clé dans la chaîne de prototype de l’objet. C’est très intéressant si vous voulez utiliser du duck typing pour vérifier la nature de vos objets.

Ensuite, nous avons assigné toutes les clés propres de data (et leur valeur) dans cet objet via Object.assign(). Enfin, nous avons “fermé” l’objet avec Object.seal(). C’est l’utilisation de cette méthode statique qui vous garantit que votre objet ne pourra jamais gagner ni perdre une clé. Attention, fermer un objet de cette manière est irréversible, c’est pour ça qu’on fait une copie de l’objet original afin d’éviter de l’altérer par inadvertance. Ceci dit, si on ne peut plus ajouter ou enlever de clé, on peut toujours modifier les valeurs des clés existantes. Si vous voulez complètement figer la structure et rendre la réassignation de valeur impossible, vous pouvez utiliser Object.freeze(). On va en reparler quand on abordera la question des énumérations.

OK, ça marche mais on peut faire mieux. En effet, l’utilisation d’un objet sans prototype comme conteneur, rend l’utilisation de l’opérateur instanceof inutile. Or, si on fige nos structures, cet opérateur sera beaucoup plus intéressant pour les identifier plutôt que de faire du duck typing.

On peut donc réécrire notre exemple comme suit :

class Dictionary {
  constructor(data) {
    Object.seal(Object.assign(this, data));
  }
}

Et on créera notre point de cette manière :

const point = new Dictionary({ x: 1, y: 2 })

À ce niveau, Dictionary reste une classe utilitaire puisque les structures de deux dictionnaires différents seront différentes. Ceci dit, puisque Dictionary est une classe, on va pouvoir en dériver des classes spécialisées qui, elles, garantiront la structure. Par exemple pour notre point :

class Point extends Dictionary {
  constructor(data) {
    super({
      x: Number(data.x) || 0,
      y: Number(data.y) || 0
    })
  }
}

Normalement, là, si vous avez un peu l’habitude des classes en JavaScript, à part l’utilisation de Object.seal() pour figer l’instance de classe, il n’y a rien qui devrait vous étonner.

OK, mais c’est pénible de devoir créer ses classes dérivées soi-même. On va se simplifier la vie en créant des classes à la volée, simplement en définissant la structure de données qu’on attend (un peu comme les interfaces de TypeScript en fait). Ce qu’on veut arriver à faire, c’est une simple factory comme ceci :

const Point = Dictionary.fromShape({
  x: Number,
  y: Number,
})
 
const point = new Point({ x: 1, y: 1 })

Notez que pour les besoins de l’article, je simplifie les structures à un seul type possible par clé, mais rien n’empêche de créer des unions de types si on le souhaite en utilisant un tableau de types plutôt qu’un type unique.

On va donc écrire la méthode statique Dictionary.fromShape :

class Dictionary {
  constructor(data) {
    Object.seal(Object.assign(this, data));
  }
 
  static fromShape(shape) {
    const closedShape = Record(shape)
 
    return class extends Dictionary {
      constructor(data) {
        super(Object.entries(closedShape).reduce(($, [key, type]) => ({
          ...$, [key]: VALUE(data[key]).to(type)
        }), {}))
      }
    }
  }
}

Plusieurs choses ici qu’il faut détailler. La première chose à faire c’est de faire une copie de notre paramètre shape (on recycle notre fonction Record ici, mais un simple Object.assign ou un peu de décomposition fonctionne aussi). C’est important pour garantir la stabilité de notre format. En effet, en JavaScript, les objets sont passés par référence et si shape venait à être modifié les nouvelles instances de notre classe changeraient de forme ! 😱

Ensuite, le reducer sur closedShape permet de filtrer les données entrantes, ce qui garantira que d’une part, seules les clés définies dans le shape initial apparaîtront dans notre dictionnaire et d’autre part, les données associées à la clé sont bien conformes au type attendu pour celles-ci.

La vérification de type en JavaScript, surtout en ce qui concerne les types primitifs est un peu délicate, c’est pour ça qu’on crée l’utilitaire VALUE qui contient toute la logique de vérification de type.

On va le voir dans un instant, cet utilitaire va nous permettre de définir notre politique de gestion des types. Il s’agit simplement d’une fonction qui va retourner un objet avec deux méthodes:

  • is(type) qui va vérifier si la valeur correspond à un type donné
  • to(type) qui va faire de la coercition de type pour garantir que la valeur sera bien du type donné (le cas échéant, elle enverra une TypeError si elle ne peut pas convertir la valeur).

Pour les besoins de cet article, je reste sur des fonctionnements simples où je me concentre sur les types primitifs, mais vous pouvez imaginer toute la logique de contrainte de type que vous voulez ici pour garantir la qualité des valeurs de vos dictionnaires.

function VALUE(value) {
  const current = Record({
    is(type) {
      return Boolean(
        // Ici on gère le cas des types qui sont une valeur constante
        // comme null, undefined ou n'importe quel valeur qui doit
        // toujours être constante
        (typeof type !== 'function' && value === type) ||

        // NaN n'est jamais NaN (NaN === NaN // false)
        // mais on s'assure que c'est quand même le cas
        (Number.isNaN(type) && Number.isNaN(value)) ||
 
        // instanceof est idéal pour tester les types mutables dérivés de Object
        (value instanceof type) ||
 
        // Par contre, instanceof ne fonctionne pas avec les types primitifs
        (type === String && typeof value === 'string') ||
        (type === Symbol && typeof value === 'symbol') ||
        (type === BigInt && typeof value === 'bigint') ||
        (type === Boolean && typeof value === 'boolean') ||

        // On s'assure que NaN ne sera pas considéré comme un nombre valide
        (type === Number && value === +value) ||

        // Ici on vérifie que les objets créés avec Object.create(null) sont
        // identifiés comme tel car instanceof échouera avec ce genre d'objet
        // particulier
        (
          type === Object &&
          Object.prototype.toString.call(value) === '[object Object]'
        )
      )
    },
 
    to(type) {
      if (current.is(type)) { return value }
 
      if (
        type === Number
        && value !== '' // On ne veut pas convertir '' en 0
        && !Number.isNaN(Number(value)) // On ne veut pas convertir en NaN
      ) {
        return Number(value)
      }
 
      if (type === Boolean) {
        return Boolean(value)
      }
 
      if (
        type === BigInt
        && (current.is(Number) || current.is(String))
      ) {
        try {
          return BigInt(value)
        } catch {
          // Pas de gestion d’erreur ici, on va juste créer
          // une exception plus explicite ci-après.
        }
      }
 
      throw new TypeError(`Cannot convert ${
        value?.constructor?.name
      } to ${
        typeof type === 'function' ? type.name : String(type)
      }`)
    },
  })
 
  return current
}

La fonction to(type) permet de mettre en place des règles de coercition de type, libre à vous d’être hyper strict en vous arrêtant à la première ligne et de ne faire aucune coercition, ou d’être ultra permissif en essayant de tout convertir en tout (un bon exercice si le cœur vous en dit).

Bien, à partir d’ici, on arrive à produire très facilement des dictionnaires à la forme figée et identifiée dont on contrôle la qualité des valeurs initiales :

const Point = Dictionary.fromShape({
  x: Number,
  y: Number,
})
 
const Vector = Dictionary.fromShape({
  origin: Point,
  angle: Number,
  speed: Number,
})
 
const p = new Point({ x: 1, y: 0 });
const v = new Vector({ origin: p, angle: '180', speed: false, pitch: 270 })
 
console.log(p instanceof Dictionary) // true
console.log(p instanceof Point) // true
console.log(p instanceof Vector) // false
console.log(p) // { x: 1, y: 0 }
console.log(v instanceof Dictionary) // true
console.log(v instanceof Point) // false
console.log(v instanceof Vector) // true
console.log(v) // { origin: { x:1, y: 0 }, angle: 180, speed: 0 }

Dernier point à régler, la modification des valeurs de nos dictionnaires. En effet, si au moment de l’instanciation on valide les valeurs initiales, une fois l’instance créée on peut assigner n’importe quelle valeur à nos clés ! 😱

Pour régler ce dernier problème on va légèrement modifier notre factory Dictionary.fromShape() :

class Dictionary {
  constructor(data) {
    Object.seal(Object.assign(this, data));
  }
 
  static fromShape(shape) {
    const closedShape = Record(shape)
 
    return class extends Dictionary {
      constructor(data) {
        super(Object.entries(closedShape).reduce(($, [key, type]) => ({
          ...$, [key]: VALUE(data[key]).to(type)
        }), {}))
 
        return new Proxy(this, {
          set(obj, prop, value) {
            obj[prop] = VALUE(value).to(closedShape[prop])
            return true
          }
        })
      }
    }
  }
}

Et voilà, le constructeur de notre classe va envelopper l’instance dans un Proxy qui passera toutes les valeurs entrantes dans notre utilitaire de gestion des types et boum… Toutes les valeurs de nos dictionnaires auront toujours le bon type quitte à convertir les valeurs selon les règles de conversion de type qu’on a décidé de mettre en place. 🤘

En moins d’une centaine de lignes de code on a réglé 80% des problèmes de structure de données incohérentes en JavaScript ! Sauf que… Ben oui, ça marche bien, si on s’en sert, 🤔 et c’est là que j’adorerais voir les annotations de type arriver en JavaScript pour que le moteur JS puisse vérifier lui-même à l’exécution que les valeurs passées à une fonction ou assignées à une variable soient du bon type. 🙄

Avouez tout de même que, pour le même résultat, ça :

function foo(string: myString) {
  console.log(myString)
}

C’est beaucoup plus élégant que ça :

function foo(myString) {
  if (typeof myString !== 'string') {
    throw new TypeError('A String is expected')
  }
 
  console.log(myString)
}

Créer des énumérations

OK, parlons maintenant des énumérations. Une énumération est un ensemble de clés dont la valeur de chacune est unique et figée.

Comme on le disait on peut obtenir quelque chose d’approchant en changeant notre fonction Record pour qu’elle utilise Object.freeze au lieu de Object.seal :

function Enum(data) {
  return Object.freeze(
    Object.assign(
      Object.create(null),
      data
    )
  )
}

C’est pas mal mais pas satisfaisant. En effet, dans ce cas, on est obligé d’associer manuellement une valeur à chaque clé. On va donc modifier un peu cette fonction pour pouvoir créer une énumération sur la base d’une liste de clés si on se fiche de la nature des valeurs associées :

function Enum(keys) {
  if (Array.isArray(keys) || keys instanceof Set) {
    keys = Object.fromEntries([...keys].map(key => [key, Symbol(key)]))
  }
 
  return Object.freeze(
    Object.assign(
      Object.create(null),
      keys
    )
  )
}

De cette manière on peut définir une énumération où chaque clé sera associée à un Symbol absolument unique (on l’annote quand même avec le nom de la clé pour faciliter le debug mais même si on crée deux énumérations avec la même clé on a la garantie absolue que la valeur sera différente.)

Avec cette fonction on peut créer deux types d’énumération :

// La valeur n'a pas d'importance mais
// LEVEL.FOO === LEVEL.FOO
// doit être la seule vérité vraie.
const LEVEL = Enum([
  'BEGINNER',
  'INTERMEDIATE',
  'ADVANCED',
])
 
// La valeur est importante et doit
// être préservée à tout prix.
const COLOR = Enum({
  WHITE: 0xFFFFFF,
  BLACK: 0x000000,
  RED:   0xFF0000,
  PINK:  0xFFCCCC,
})

Il n’y a pas grand-chose de plus à dire sur les énumérations, mais si vous voulez aller plus loin, ça peut être intéressant d’essayer de mémoïser Enum pour que des énumérations de la même forme soient réellement le même objet (et donc avec les mêmes symboles le cas échéant).

La question des collections

Les collections de valeurs en JavaScript sont souvent matérialisées sous la forme d’Array (ou de Set si on veut garantir l’unicité des valeurs). Ici nous allons formaliser tout ça pour leur donner les mêmes contraintes que nos dictionnaires. On va tout de même faire une nuance entre deux types de collections :

  • D’une part, les collections ouvertes qui ont une taille illimitée mais qui ne peuvent contenir que certains types ;
  • D’autre part, les tuples qui sont des collections à la taille figée et où chaque emplacement est d’un type particulier.

Quoi qu’il en soit, en JavaScript, un Array est en fait un cas particulier de Object où les clés sont numériques et qui implémente l’interface d’itération de JavaScript. Ce qu’on va faire va donc beaucoup ressembler à ce que nous avons déjà fait pour les dictionnaires. Cependant, l’API des Array est assez vaste et selon ce qu’on voudra faire, il faudra peut-être à l’occasion surcharger cette API.

Les collections ouvertes

Ici c’est assez facile, on va juste s’assurer que toutes les valeurs poussées dans la collection sont bien du type qu’on a choisi :

class Collection extends Array {
  static withType(type) {
    return class TypedCollection extends Collection {
      constructor(...args) {
        super()
        this.push(...args.map((value) => VALUE(value).to(type)))
 
        return new Proxy(this, {
          set(obj, prop, value) {
            obj[prop] = prop === 'length'
              ? value
              : VALUE(value).to(type)
 
            return true
          }
        })
      }
    }
  }
}
 
const ArrayOfString = Collection.withType(String)
const aos = new ArrayOfString('foo', 'bar')
console.log(aos) // ['foo', 'bar']
aos.push('baz')  // Will extend the Array
console.log(aos) // ['foo', 'bar', 'baz']
aos.push(42)     // Will throw a TypeError, thanks to Proxy trap

Si vous voulez créer des collections sans doublons, vous pouvez facilement faire la même chose en héritant de Map au lieu de Array.

On notera que dans le cas où on souhaiterait contraindre des listes de nombres particuliers (signé ou non, à 8, 16, 32 ou 64 bits), JavaScript fournit déjà un ensemble de types dédiés, les tableaux typés : Int8Array, Uint8Array, Uint8ClampedArray, Int16Array, Uint16Array, Int32Array, Uint32Array, Float32Array, Float64Array, BigInt64Array, BigUint64Array. (Ces types sont fondamentalement pensés pour traiter les données binaires mais peuvent s’utiliser pour manipuler n’importe quelle liste de nombres fortement typés)

Les tuples

Les tuples ressemblent beaucoup aux dictionnaires en ce qu’ils ont une taille figée. La principale différence c’est la définition de la forme (un tableau de type) et le support de l’API de Array (ou Map selon vos besoins) :

class Tuple extends Array {
  constructor(...args) {
    super()
    this.push(...args)
 
    Object.seal(this)
  }
 
  static fromShape(shape) {
    const types = [...shape]
 
    return class extends Tuple {
      constructor(...args) {
        super(...types.map((type, index) => VALUE(args[index]).to(type)))
 
        return new Proxy(this, {
          set(obj, prop, value) {
            obj[prop] = prop === 'length'
              ? value
              : VALUE(value).to(types[prop])
 
            return true
          }
        })
      }
    }
  }
}

Normalement, à ce stade, ce code doit vous sembler bien familier. 😉

Maintenant qu’on en est là, on peut commencer à faire assez facilement des choses qui ressemblent furieusement à du TypeScript (mais qui seront évaluées à l’exécution) :

// Monetary est toujours un entier.
// Ce qui est une bonne pratique quand
// on manipule des données monétaires
class Monetary extends Number {
  constructor(cents) {
    super(Math.floor(cents))
  }
 
  // Dans la vraie vie, on aurait une API
  // pour gérer les valeurs monétaires
}
 
const CURRENCY = Enum([
  'EUR', 'USD', 'JPY'
])
 
const Price = Tuple.fromShape([Monetary, Symbol])
 
const Prices = Collection.withType(Price)
 
const Product = Dictionary.fromShape({
  name: String,
  price: Prices,
})
 
const PS5 = new Product({
  name: 'PlayStation 5',
  price: new Prices(
    new Price(new Monetary(411_65), CURRENCY.EUR),
    new Price(new Monetary(498_99), CURRENCY.USD),
    new Price(new Monetary(54949_28), CURRENCY.JPY),
  )
})

Conclusion : Apprenez à maîtriser la métaprogrammation

Nous y voilà. Sans trop d’efforts, nous avons durci la gestion de nos structures de données, mais ne nous y trompons pas, on peut aller beaucoup plus loin, que ce soit dans la gestion et le contrôle des types aussi bien que dans le sucre syntaxique qui permet de simplifier leur usage (en mémoïsant les structures, en simplifiant l’usage… Dieu qu’il est moche cet opérateur new, etc.).

Alors, faut-il jeter TypeScript avec l’eau du bain… Bien sûr que non ! Faire de l’analyse de code statique a ses mérites, ne serait-ce que pour prévenir les erreurs les plus simples et optimiser les performances du code final en se permettant d’omettre raisonnablement un certain nombre de vérifications inutiles à l’exécution. Et soyons clairs, j’adore le fait que l’interpréteur TypeScript de mon VSCode me permette de bénéficier d’une aide à la complétion au top quand je code en JavaScript !

Pour autant, comme on l’a vu, JavaScript seul permet d’être extrêmement rigoureux, bien plus que ce que permet un usage naïf de TypeScript puisqu’il permet d’intercepter les erreurs exactement là où elles apparaissent à l’exécution, ce qui permet d’une part d’accéder à une stack trace qui facilite le débogage et d’autre part de mettre en place des mécanismes de récupération sur erreur qu’on a tendance à négliger avec TypeScript.

Maîtriser la métaprogrammation en JavaScript (ou tout autre langage) vous permet d’être plus malin et d’aller au-delà des limites réelles ou imaginaires des outils que vous utilisez. Avant de vous précipiter sur le dernier outil à la mode, essayez toujours de voir jusqu’où vous pouvez repousser les limites des langages que vous utilisez.

Laisser un commentaire

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.

%d blogueurs aiment cette page :