Structures de données transitoires
Raisonnement
Si un arbre tombe dans la forêt, fait-il du bruit ?
Si une fonction pure modifie des données locales pour produire une valeur de retour immuable, est-ce acceptable ?
C'est une question intéressante. Les structures de données Clojure utilisent la mutation à chaque appel, par exemple `assoc`, créant un ou plusieurs tableaux et les modifiant avant de les renvoyer pour une utilisation immuable ultérieure. La raison est simple : la performance. Il est impossible d'atteindre la même vitesse avec uniquement des fonctions pures et des données immuables. Une fois construites et partagées, l'immuabilité et la persistance sont essentielles à la robustesse des programmes. En interne, Clojure modifie de petits tableaux nouvellement alloués qui constituent les nouds internes de ses structures de données. Ces tableaux restent invisibles pour le reste du code.
On rencontre un scénario similaire, à un niveau supérieur, lorsqu'on souhaite initialiser ou transformer une grande structure de données persistante en plusieurs étapes, dont aucune ne sera visible par le code autre que celui de la construction/transformation. La difficulté réside ici dans le fait que la source de la transformation sera une structure de données persistante existante, et que le résultat de la fonction sera partagé. Copier vers une structure de données mutable traditionnelle, puis revenir à la structure initiale, implique une complexité temporelle de O(n), et le code interne est un véritable casse-tête impératif, bien différent du reste du code Clojure. De plus, aucune protection n'est prévue contre le partage ou l'aliasage accidentel de la structure de données mutable, notamment si des fonctions auxiliaires sont nécessaires. En bref, il serait regrettable de devoir s'éloigner du modèle Clojure pour optimiser un code de ce type. Les structures de données transitoires constituent une solution à ce problème d'optimisation : elles s'intègrent au modèle Clojure et offrent les mêmes garanties de sécurité des processus léger.
Fonctionnement
Les structures de données transitoires sont toujours créées à partir d'une structure de données Clojure persistante existante. Depuis Clojure 1.1.0, les vecteurs, les tables de hachage et les ensembles de hachage sont pris en charge. Notez que toutes les structures de données Clojure ne prennent pas en charge cette fonctionnalité, mais la plupart le font. Les listes ne le font pas, car cela n'apporte aucun avantage.
Vous obtenez une «copie» transitoire d'une structure de données en appelant `transient`. Cela crée une nouvelle structure de données transitoire qui est une copie de la source et possède les mêmes caractéristiques de performance. En fait, il s'agit presque toujours de la structure de données source, ce qui met en évidence la première caractéristique des structures transitoires : leur création est en O(1). Elles partagent la même structure que leur source, tout comme les copies persistantes.
La seconde caractéristique des structures transitoires est que leur création ne modifie pas la source et que la source ne peut pas être modifiée par l'utilisation de la structure transitoire. Vos données sources restent immuables et persistantes comme toujours.
Les objets transitoires prennent en charge l'interface en lecture seule de la source ; autrement dit, vous pouvez appeler `nth`, `get`, `count` et `fn` sur un vecteur transitoire, comme sur un vecteur persistant.
En revanche, les objets transitoires ne prennent pas en charge l'interface persistante de la structure de données source. Les opérations `assoc`, `conj`, etc. lèveront des exceptions, car les objets transitoires ne sont pas persistants. Vous ne risquez donc pas de transférer accidentellement un objet transitoire dans un contexte nécessitant un objet persistant.
Les objets transitoires prennent en charge un ensemble parallèle d'opérations de « modification », dont les noms sont similaires et suivis d'un point d'exclamation (`!`) : `assoc!`, `conj!`, etc. Ces opérations effectuent les mêmes actions que leurs homologues persistants, à la différence que leurs valeurs de retour sont elles-mêmes transitoires. Notez en particulier que les objets transitoires ne sont pas conçus pour être utilisés directement. Vous devez capturer et utiliser la valeur de retour lors de l'appel suivant. De cette manière, ils prennent en charge la même structure de code que le code persistant fonctionnel qu'ils remplacent. Comme le montrera l'exemple, cela vous permettra d'améliorer facilement les performances d'un morceau de code sans modification structurelle.
Une fois vos résultats obtenus, vous pouvez créer une structure de données persistante en appelant `persistent!`. sur le transitoire. Cette opération est également en O(1). Après l'appel à `persistent!`, le transitoire ne doit plus être utilisé et toutes les opérations lèveront des exceptions. Ceci sera également valable pour tous les alias que vous auriez pu créer.
Exemple
Voici un exemple très classique : du code construisant un vecteur à retourner, toutes les modifications étant locales à la fonction. Remarquez que la version utilisant `transient` a exactement la même structure, à ceci près :
- Appel de `transient` sur le vecteur source
- Utilisation de `conj!` au lieu de `conj`
- Appel de `persistent!` au retour
- (defn vrange [n]
- (loop [i 0 v []]
- (if (< i n)
- (recur (inc i) (conj v i))
- v)))
-
- (defn vrange2 [n]
- (loop [i 0 v (transient [])]
- (if (< i n)
- (recur (inc i) (conj! v i))
- (persistent! v))))
-
- ;; Comparaison des performances (Java 1.8, Clojure 1.7)
- (def v (vrange 1000000)) ;; 73.7 ms
- (def v2 (vrange2 1000000)) ;; 19.7 ms
Ah oui, les transitoires sont rapides !
Utilisation concurrente
L'utilisation des objets transitoires est simple, mais elle présente une autre contrainte importante : l'isolation des processus légers. Chaque résultat d'une opération transitoire partageant une structure (mutable) avec le précédent, une erreur se produit si plusieurs processus légers manipulent un même objet transitoire simultanément. L'utilisation d'une instance transitoire particulière doit être contrôlée soit en l'utilisant dans un contexte mono-processus léger, soit dans un cadre d'application imposant cette isolation.
Dans Clojure 1.6 et versions antérieures, toute utilisation (lecture ou écriture) d'un objet transitoire par un processus léger autre que celui l'ayant créé provoquait une exception. Cette vérification a été supprimée dans la version 1.7 afin de permettre une utilisation plus flexible dans des cadres d'applications tels que les blocs core.async go qui imposent la contrainte mono-processus léger par d'autres moyens.
Résumé
Les transitoires offrent une optimisation haute performance du code de construction de structures de données fonctionnelles compatible avec les structures de données Clojure et garantissant une sécurité essentielle.
- Utilisation à chemin unique
- Création en O(1) à partir de structures de données persistantes
- Partage la structure avec la source persistante
- Création en O(1) d'une structure de données persistante à la fin de la session d'édition
- Structure de code identique à la version fonctionnelle
- Récupération de la valeur de retour pour l'appel suivant
- Évite les modifications directes
- Non persistants : impossible de conserver les valeurs intermédiaires ou les alias
- Inutilisables après le retour d'une version persistante
- Rapides
Les vecteurs persistants transitoires, les tables de hachage et les ensembles de hachage ont été ajoutés dans Clojure 1.1.