Types de données : deftype, defrecord et reify
Motivation
Clojure est conçu selon un modèle d'abstractions. Il existe des abstractions pour les séquences, les collections, la capacité d'appel,... De plus, Clojure fournit de nombreuses implémentations de ces abstractions. Les abstractions sont spécifiées par des interfaces hôtes, et les implémentations par des classes hôtes. Bien que cela ait suffi pour initialiser le langage, Clojure manquait alors de mécanismes d'abstraction et d'implémentation de bas niveau similaires. Les protocoles et les types de données ajoutent des mécanismes puissants et flexibles pour l'abstraction et la définition des structures de données, sans compromis sur les fonctionnalités de la plateforme hôte.
Principes de base
Les fonctionnalités de type de données - `deftype`, `defrecord` et `reify` - fournissent le mécanisme permettant de définir des implémentations d'abstractions et, dans le cas de `reify`, des instances de ces implémentations. Les abstractions elles-mêmes sont définies par des protocoles ou des interfaces. Un type de données fournit un type hôte (nommé pour `deftype` et `defrecord`, anonyme pour `reify`), doté d'une structure (champs explicites pour `deftype` et `defrecord`, fermeture implicite pour `reify`) et des implémentations optionnelles de méthodes d'abstraction. Ils permettent d'accéder, de manière relativement simple, aux mécanismes de représentation primitive et de polymorphisme les plus performants de l'hôte. Il est important de noter qu'il ne s'agit pas de simples constructions encapsulant l'hôte. Ils ne prennent en charge qu'un sous-ensemble restreint des fonctionnalités de l'hôte, souvent avec un dynamisme supérieur à celui de l'hôte lui-même. L'objectif est que, sauf en cas d'interopérabilité nécessitant de dépasser leur portée, il ne soit pas nécessaire de quitter Clojure pour bénéficier des structures de données les plus performantes disponibles sur la plateforme.
deftype et defrecord
deftype et defrecord génèrent dynamiquement du bytecode compilé pour une classe nommée, avec un ensemble de champs spécifiés et, en option, des méthodes pour un ou plusieurs protocoles et/ou interfaces. Ils conviennent au développement dynamique et interactif, ne nécessitent pas de compilation AOT et peuvent être réévalués au cours d'une même session. Similaires à defstruct pour la génération de structures de données avec des champs nommés, ils diffèrent de defstruct par les points suivants :
- Ils génèrent une classe unique, dont les champs correspondent aux noms donnés.
- La classe résultante possède un type correct, contrairement aux conventions d'encodage des types pour les structures dans les métadonnées.
- Puisqu'ils génèrent une classe nommée, celle-ci possède un constructeur accessible.
- Les champs peuvent avoir des annotations de type et peuvent être primitifs.
- Notez qu'actuellement, une annotation de type non primitif ne sera pas utilisée pour contraindre le type du champ ni l'argument du constructeur, mais sera utilisée pour optimiser son utilisation dans les méthodes de la classe.
- La contrainte du type du champ et de l'argument du constructeur est prévue.
- Un deftype/defrecord peut implémenter un ou plusieurs protocoles et/ou interfaces.
- Un deftype/defrecord peut être écrit avec une syntaxe de lecture spéciale : `#my.thing[1 2 3]`, où :
- chaque élément sous forme de vecteur est passé au constructeur du deftype/defrecord sans être évalué;
- le nom du deftype/defrecord doit être entièrement qualifié ;
- disponible uniquement dans les versions de Clojure supérieures à 1.3.
- Lorsqu'un deftype/defrecord Foo est défini, une fonction correspondante ->Foo est définie et transmet ses arguments au constructeur (versions 1.3 et ultérieures uniquement).
Les classes `deftype` et `defrecord` diffèrent comme suit :
- `deftype` ne fournit aucune fonctionnalité non spécifiée par l'utilisateur, hormis un constructeur.
- `defrecord` fournit une implémentation complète d'une table de hachage persistante, incluant :
- l'égalité basée sur les valeurs et le hachage par code de hachage;
- la prise en charge des métadonnées;
- la prise en charge de l'associativité;
- des accesseurs par mots-clés pour les champs;
- ...
- des champs extensibles (vous pouvez associer des clés non fournies avec la définition de `defrecord`);
- `deftype` prend en charge les champs mutables, contrairement à `defrecord`.
- `defrecord` prend en charge une forme de lecture supplémentaire : `#my.record{:a 1, :b 2}`, prenant une map qui initialise un `defrecord` selon les critères suivants :
- le nom du `defrecord` doit être complet;
- les éléments de la map ne sont pas évalués;
- les champs existants du `defrecord` conservent les valeurs indexées;
- les champs du `defrecord` sans valeur indexée dans la map littérale sont initialisés à `nil`;
- des valeurs indexées supplémentaires sont autorisées et ajoutées au `defrecord`.
- Disponible uniquement dans les versions de Clojure supérieures à 1.3.
- Lorsqu'un enregistrement Bar est défini, une fonction correspondante map->Bar est définie qui prend une carte et initialise une nouvelle instance d'enregistrement avec son contenu (versions 1.3 et ultérieures uniquement).
Pourquoi utiliser à la fois `deftype` et `defrecord` ?
Dans la plupart des programmes orientés objet, les classes se répartissent en deux catégories distinctes : celles qui sont des artefacts du domaine d'implémentation/de programmation, comme les classes `String` ou `Collections`, ou les types référence de Clojure ; et celles qui représentent des informations du domaine applicatif, comme `Employee`, `PurchaseOrder`, etc. L'utilisation de classes pour les informations du domaine applicatif a toujours eu pour inconvénient de masquer ces informations derrière des micro-langages spécifiques à chaque classe. Par exemple, même la méthode apparemment anodine `employee.getName()` est une interface personnalisée pour accéder aux données. Stocker des informations dans de telles classes pose problème, un peu comme si chaque livre était écrit dans une langue différente. On ne peut plus adopter une approche générique du traitement de l'information. Il en résulte une explosion de spécificités inutiles et une pénurie de réutilisation.
C'est pourquoi Clojure a toujours encouragé l'utilisation de maps pour ces informations, et ce conseil reste valable quel que soit le type de données. L'utilisation de `defrecord` permet d'obtenir des informations manipulables de manière générique, ainsi que les avantages du polymorphisme piloté par les types et l'efficacité structurelle des champs. En revanche, il est absurde qu'un type de données définissant une collection, comme `vector`, ait une implémentation par défaut de `map`. `deftype` est donc plus adapté à la définition de telles constructions de programmation.
De manière générale, les enregistrements sont supérieurs aux `structmap` pour toutes les opérations de gestion d'informations, et il est recommandé de migrer ces dernières vers `defrecord`. Il est peu probable que beaucoup de code utilise des `structmap` pour des constructions de programmation, mais si tel est le cas, `deftype` s'avérera bien plus approprié.
Les implémentations de `deftype`/`defrecord` compilées AOT peuvent convenir à certains cas d'utilisation de `gen-class`, lorsque leurs limitations ne sont pas rédhibitoires. Dans ces cas, leurs performances seront supérieures à celles de `gen-class`.
Les types de données et les protocoles sont subjectifs.
Bien que les types de données et les protocoles entretiennent des relations bien définies avec les constructions hôtes et constituent un excellent moyen d'exposer les fonctionnalités Clojure aux programmes Java, ils ne sont pas principalement conçus pour l'interopérabilité. Autrement dit, ils ne cherchent pas à imiter ou à s'adapter complètement à tous les mécanismes orientés objet de l'hôte. Ils reflètent notamment les opinions suivantes :
- La dérivation concrète est déconseillée.
- On ne peut pas dériver de types de données à partir de classes concrètes, seulement d'interfaces.
- Il est toujours recommandé de programmer en respectant les protocoles ou les interfaces.
- Les types de données ne peuvent pas exposer de méthodes qui ne figurent pas dans leurs protocoles ou interfaces.
- L'immuabilité devrait être le comportement par défaut
- et c'est la seule option pour les enregistrements
- L'encapsulation des informations est une erreur.
- Les champs sont publics ; utilisez des protocoles/interfaces pour éviter les dépendances.
- Lier le polymorphisme à l'héritage est une mauvaise pratique.
- Les protocoles vous en libèrent.
L'utilisation de types de données et de protocoles vous permettra de proposer une API claire et basée sur des interfaces à vos clients Java. Avec une API Java claire et basée sur des interfaces, les types de données et les protocoles peuvent être utilisés pour l'interopérabilité et l'extension. En revanche, avec une API Java de mauvaise qualité, vous devrez recourir à gen-class. Seule cette approche garantit que les constructions de programmation utilisées pour concevoir et implémenter vos programmes Clojure seront exemptes des complexités inhérentes à la programmation orientée objet.
reify
Alors que `deftype` et `defrecord` définissent des types nommés, `reify` définit un type anonyme et crée une instance de ce type. Son utilité réside dans le besoin d'une implémentation unique d'un ou plusieurs protocoles ou interfaces, en tirant parti du contexte local. À cet égard, son utilisation est similaire à celle des classes internes anonymes (`proxy`) en Java.
Les corps des méthodes de reify sont des fermetures lexicales et peuvent faire référence à la portée locale environnante. reify diffère de proxy par les points suivants :
- Seuls les protocoles et les interfaces sont pris en charge ; aucune superclasse concrète n'est implémentée.
- Les corps des méthodes sont de véritables méthodes de la classe résultante, et non des fonctions externes.
- L'appel des méthodes sur l'instance est direct, sans passer par une table de correspondance.
- Le remplacement dynamique des méthodes dans la table de correspondance n'est pas pris en charge.
Il en résulte de meilleures performances que le proxy, tant au niveau de la construction que de l'invocation. La réification est préférable au proxy dans tous les cas où ses contraintes ne sont pas prohibitives.
Prise en charge des annotations Java
Les types créés avec `deftype`, `defrecord` et `definterface` peuvent générer des classes incluant des annotations Java pour l'interopérabilité Java. Les annotations sont décrites comme des métadonnées sur :
- Le nom du type (`deftype`/`defrecord`/`definterface`) : annotations de classe
- Les noms des champs (`deftype`/`defrecord`) : annotations de champ
- Les noms des méthodes (`deftype`/`defrecord`) : annotations de méthode
Exemple :
- (import [java.lang.annotation Retention RetentionPolicy Target ElementType]
- [javax.xml.ws WebServiceRef WebServiceRefs])
-
- (definterface Foo (foo []))
-
- ;; annotation sur le type
- (deftype ^{Deprecated true
- Retention RetentionPolicy/RUNTIME
- javax.annotation.processing.SupportedOptions ["foo" "bar" "baz"]
- javax.xml.ws.soap.Addressing {:enabled false :required true}
- WebServiceRefs [(WebServiceRef {:name "fred" :type String})
- (WebServiceRef {:name "ethel" :mappedName "lucy"})]}
- Bar [^int a
- ;; dans le champ
- ^{:tag int
- Deprecated true
- Retention RetentionPolicy/RUNTIME
- javax.annotation.processing.SupportedOptions ["foo" "bar" "baz"]
- javax.xml.ws.soap.Addressing {:enabled false :required true}
- WebServiceRefs [(WebServiceRef {:name "fred" :type String})
- (WebServiceRef {:name "ethel" :mappedName "lucy"})]}
- b]
- ;; dans la méthode
- Foo (^{Deprecated true
- Retention RetentionPolicy/RUNTIME
- javax.annotation.processing.SupportedOptions ["foo" "bar" "baz"]
- javax.xml.ws.soap.Addressing {:enabled false :required true}
- WebServiceRefs [(WebServiceRef {:name "fred" :type String})
- (WebServiceRef {:name "ethel" :mappedName "lucy"})]}
- foo [this] 42))
-
- (seq (.getAnnotations Bar))
- (seq (.getAnnotations (.getField Bar "b")))
- (seq (.getAnnotations (.getMethod Bar "foo" nil)))