Les dangers des designers

Bonjour, Habr! Je vous présente la traduction de l'article "Les dangers des constructeurs" d'Aleksey Kladov.


Un de mes articles préférés sur le blog de Rust est Things Rust Shipped Without de Graydon Hoare . Pour moi, l'absence de toute caractéristique de la langue qui peut tirer dans la jambe est généralement plus importante que l'expressivité. Dans cet essai légèrement philosophique, je veux parler de ma caractéristique particulièrement préférée qui manque à Rust - à propos des constructeurs.


Qu'est-ce qu'un constructeur?


Les constructeurs sont couramment utilisés dans les langages OO. La tâche du constructeur est d'initialiser complètement l'objet avant que le reste du monde ne le voie. À première vue, cela semble être une très bonne idée:


  1. Vous définissez les invariants dans le constructeur.
  2. Chaque méthode prend en charge la conservation des invariants.
  3. Ensemble, ces deux propriétés signifient que vous pouvez considérer les objets comme des invariants et non comme des états internes spécifiques.

Le constructeur joue ici le rôle d'une base d'induction, étant le seul moyen de créer un nouvel objet.


Malheureusement, il y a un trou dans ces arguments: le designer lui-même observe l'objet dans un état inachevé, ce qui crée de nombreux problèmes.


Cette valeur


Lorsque le constructeur initialise l'objet, il commence par un état vide. Mais comment définissez-vous cet état vide pour un objet arbitraire?


La façon la plus simple de procéder consiste à définir tous les champs à leurs valeurs par défaut: false pour bool, 0 pour nombres, null pour tous les liens. Mais cette approche nécessite que tous les types aient des valeurs par défaut et introduit le fameux null dans le langage. C'est le chemin emprunté par Java: au début de la création de l'objet, tous les champs sont 0 ou null.


Avec cette approche, il sera très difficile de se débarrasser de null par la suite. Un bon exemple à apprendre est Kotlin. Kotlin utilise des types non nullables par défaut, mais il est obligé de travailler avec la sémantique JVM préexistante. La conception de la langue cache bien ce fait et est bien applicable dans la pratique, mais elle est intenable . En d'autres termes, en utilisant des constructeurs, il est possible de contourner les vérifications nulles dans Kotlin.


La principale caractéristique de Kotlin est l'encouragement à la création de soi-disant «constructeurs primaires» qui déclarent simultanément un champ et lui attribuent une valeur avant l'exécution de tout code personnalisé:


class Person( val firstName: String, val lastName: String ) { ... } 

Autre option: si le champ n'est pas déclaré dans le constructeur, le programmeur doit l'initialiser immédiatement:


 class Person(val firstName: String, val lastName: String) { val fullName: String = "$firstName $lastName" } 

Tenter d'utiliser un champ avant l'initialisation est statiquement refusé:


 class Person(val firstName: String, val lastName: String) { val fullName: String init { println(fullName) // :     fullName = "$firstName $lastName" } } 

Mais avec un peu de créativité, tout le monde peut contourner ces contrôles. Par exemple, un appel de méthode convient à ceci:


 class A { val x: Any init { observeNull() x = 92 } fun observeNull() = println(x) //  null } fun main() { A() } 

Saisir également cela avec un lambda (qui est créé dans Kotlin comme suit: {args -> body}) convient également:


 class B { val x: Any = { y }() val y: Any = x } fun main() { println(B().x) //  null } 

Des exemples comme ceux-ci semblent irréalistes dans la réalité (et ce l'est), mais j'ai trouvé des erreurs similaires dans le code réel (règle de probabilité de Kolmogorov 0-1 dans le développement de logiciels: dans une base de données suffisamment grande, tout morceau de code est presque garanti d'exister, du moins sinon interdit statiquement par le compilateur; dans ce cas, il n'existe certainement pas).


La raison pour laquelle Kotlin peut exister avec cet échec est la même que pour les tableaux covariants Java: les vérifications se produisent toujours pendant l'exécution. Au final, je ne voudrais pas compliquer le système de type Kotlin afin de rendre les cas ci-dessus incorrects au stade de la compilation: compte tenu des limitations existantes (sémantique JVM), le rapport prix / bénéfice des validations en runtime est bien meilleur que celui des validations statiques.


Mais que se passe-t-il si la langue n'a pas de valeur par défaut raisonnable pour chaque type? Par exemple, en C ++, où les types définis par l'utilisateur ne sont pas nécessairement des références, vous ne pouvez pas simplement attribuer null à chaque champ et dire que cela fonctionnera! Au lieu de cela, C ++ utilise une syntaxe spéciale pour définir les valeurs initiales des champs: listes d'initialisation:


 #include <string> #include <utility> class person { person(std::string first_name, std::string last_name) : first_name(std::move(first_name)) , last_name(std::move(last_name)) {} std::string first_name; std::string last_name; }; 

Comme il s'agit d'une syntaxe spéciale, le reste du langage ne fonctionne pas parfaitement. Par exemple, il est difficile de placer des opérations arbitraires dans les listes d'initialisation, car C ++ n'est pas un langage orienté expression (ce qui est normal en soi). Pour travailler avec les exceptions qui se produisent dans les listes d'initialisation, vous devez utiliser une autre fonction obscure de la langue .


Méthodes d'appel à partir du constructeur


Comme le montrent les exemples de Kotlin, tout se brise en puces dès que nous essayons d'appeler une méthode à partir du constructeur. Fondamentalement, les méthodes s'attendent à ce que l'objet accessible par ce biais soit déjà entièrement construit et correct (cohérent avec les invariants). Mais dans Kotlin ou Java, rien ne vous empêche d'appeler des méthodes du constructeur, et de cette façon nous pouvons accidentellement opérer sur un objet semi-construit. Le concepteur promet d'établir des invariants, mais en même temps, c'est l'endroit le plus facile pour leur éventuelle violation.


Des choses particulièrement étranges se produisent lorsque le constructeur de la classe de base appelle une méthode substituée dans une classe dérivée:


 abstract class Base { init { initialize() } abstract fun initialize() } class Derived: Base() { val x: Any = 92 override fun initialize() = println(x) //  null! } 

Pensez-y: le code d'une classe arbitraire est exécuté avant d' appeler son constructeur! Un code C ++ similaire conduira à des résultats encore plus intéressants. Au lieu d'appeler la fonction de la classe dérivée, la fonction de la classe de base sera appelée. Cela n'a pas beaucoup de sens car la classe dérivée n'a pas encore été initialisée (rappelez-vous, nous ne pouvons pas simplement dire que tous les champs sont nuls). Cependant, si la fonction de la classe de base est entièrement virtuelle, son appel conduira à UB.


Signature du designer


La violation des invariants n'est pas le seul problème pour les concepteurs. Ils ont une signature avec un nom fixe (vide) et un type de retour (la classe elle-même). Cela rend les surcharges de conception difficiles à comprendre pour les utilisateurs.


Question de renvoi: à quoi correspond std :: vector <int> xs (92, 2)?

a. Vecteur de deux longueurs 92

b. [92, 92]

c. [92, 2]

Des problèmes avec la valeur de retour surviennent, en règle générale, lorsqu'il est impossible de créer un objet. Vous ne pouvez pas simplement renvoyer Result <MyClass, io :: Error> ou null du constructeur!


Ceci est souvent utilisé comme argument en faveur du fait que l'utilisation de C ++ sans exception est difficile, et que l'utilisation de constructeurs vous oblige également à utiliser des exceptions. Cependant, je ne pense pas que cet argument soit correct: les méthodes d'usine résolvent ces deux problèmes, car elles peuvent avoir des noms arbitraires et renvoyer des types arbitraires. Je crois que le modèle suivant peut parfois être utile dans les langues OO:


  • Créez un constructeur privé qui prend les valeurs de tous les champs comme arguments et les affecte simplement. Ainsi, un tel constructeur fonctionnerait comme une structure littérale dans Rust. Il peut également rechercher des invariants, mais il ne doit rien faire d'autre avec des arguments ou des champs.


  • des méthodes de fabrique publique sont fournies pour l'API publique avec des noms et des types de retour appropriés.



Un problème similaire avec les constructeurs est qu'ils sont spécifiques et ne peuvent donc pas être généralisés. En C ++, «il y a un constructeur par défaut» ou «il y a un constructeur de copie» ne peut pas être exprimé plus simplement que «certaines syntaxes fonctionnent». Comparez cela à Rust, où ces concepts ont des signatures appropriées:


 trait Default { fn default() -> Self; } trait Clone { fn clone(&self) -> Self; } 

La vie sans designers


Rust n'a qu'une seule façon de créer une structure: fournir des valeurs pour tous les champs. Les fonctions d'usine, telles que la nouvelle généralement acceptée, jouent le rôle de constructeurs, mais, surtout, elles ne vous permettent d'appeler aucune méthode tant que vous n'avez pas au moins une instance plus ou moins correcte de la structure.


L'inconvénient de cette approche est que tout code peut créer une structure, il n'y a donc pas un seul endroit, tel qu'un constructeur, pour maintenir les invariants. En pratique, cela est facilement résolu par la confidentialité: si les champs de la structure sont privés, cette structure ne peut être créée que dans le même module. Au sein d' un même module, il n'est pas difficile d'adhérer à l'accord "toutes les méthodes de création d'une structure doivent utiliser la nouvelle méthode". Vous pouvez même imaginer une extension de langage qui vous permet de marquer certaines fonctions avec l'attribut # [constructeur], de sorte que la syntaxe littérale de la structure n'est disponible que dans les fonctions marquées. Mais, encore une fois, des mécanismes linguistiques supplémentaires me semblent redondants: suivre les conventions locales nécessite peu d'efforts.


Personnellement, je pense que ce compromis est exactement le même pour la programmation des contrats en général. Les contrats comme «non nul» ou «valeur positive» sont mieux encodés en types. Pour les invariants complexes, écrire simplement assert! (Self.validate ()) dans chaque méthode n'est pas si difficile. Entre ces deux modèles, il y a peu de place pour les conditions # [pre] et # [post] implémentées au niveau de la langue ou basées sur des macros.

Et Swift?


Swift est un autre langage intéressant qui mérite un regard sur les mécanismes de conception. Comme Kotlin, Swift est un langage sans danger. Contrairement à Kotlin, les contrôles nuls de Swift sont plus forts, donc le langage utilise des astuces intéressantes pour atténuer les dommages causés par les constructeurs.


Tout d'abord , Swift utilise des arguments nommés, et cela aide un peu avec «tous les constructeurs ont le même nom». En particulier, deux constructeurs avec les mêmes types de paramètres ne sont pas un problème:


 Celsius(fromFahrenheit: 212.0) Celsius(fromKelvin: 273.15) 

Deuxièmement , pour résoudre le problème "le constructeur appelle la méthode virtuelle de la classe de l'objet qui n'a pas encore été entièrement créé" Swift utilise un protocole d'initialisation en deux phases bien pensé. Bien qu'il n'y ait pas de syntaxe spéciale pour les listes d'initialisation, le compilateur vérifie statiquement que le corps du constructeur a la forme correcte et sûre. Par exemple, l'appel de méthodes n'est possible qu'après l'initialisation de tous les champs de la classe et de ses descendants.


Troisièmement , au niveau du langage, il existe un support pour les constructeurs, dont l'appel peut échouer. Le constructeur peut être désigné comme nullable, ce qui rend le résultat de l'appel de la classe une option. Le constructeur peut également avoir un modificateur throws, qui fonctionne mieux avec la sémantique de l'initialisation en deux phases dans Swift qu'avec la syntaxe des listes d'initialisation en C ++.


Swift parvient à fermer tous les trous des constructeurs dont je me plaignais. Cependant, cela a un prix: le chapitre d'initialisation est l' un des plus importants du livre Swift.


Quand les constructeurs sont vraiment nécessaires


Contre toute attente, je peux trouver au moins deux raisons pour lesquelles les constructeurs ne peuvent pas être remplacés par des littéraux de structure, comme dans Rust.


Premièrement , l'héritage, à un degré ou à un autre, oblige le langage à avoir des constructeurs. Vous pouvez imaginer une extension de la syntaxe des structures avec prise en charge des classes de base:


 struct Base { ... } struct Derived: Base { foo: i32 } impl Derived { fn new() -> Derived { Derived { Base::new().., foo: 92, } } } 

Mais cela ne fonctionnera pas dans une disposition d'objet typique d'un langage OO avec un héritage simple! En règle générale, un objet commence par un titre suivi de champs de classe, de la base au plus dérivé. Ainsi, le préfixe de l'objet de la classe dérivée est l'objet correct de la classe de base. Cependant, pour qu'une telle mise en page fonctionne, le concepteur doit allouer de la mémoire à l'ensemble de l'objet à la fois. Il ne peut pas simplement allouer de la mémoire uniquement pour la classe de base, puis attacher des champs dérivés. Mais une telle allocation de mémoire en morceaux est nécessaire si nous voulons utiliser la syntaxe pour créer une structure où nous pourrions spécifier une valeur pour la classe de base.


Deuxièmement , contrairement à la syntaxe littérale de la structure, les concepteurs ont un ABI qui fonctionne bien avec le placement de sous-objets objet en mémoire (ABI convivial pour le placement). Le constructeur travaille avec un pointeur sur celui-ci, qui pointe vers la zone de mémoire que le nouvel objet doit occuper. Plus important encore, un constructeur peut facilement passer un pointeur vers des constructeurs de sous-objets, permettant ainsi la création d'arbres de valeurs complexes "en place". En revanche, dans Rust, la construction de structures comprend sémantiquement quelques copies, et nous espérons ici la grâce de l'optimiseur. Ce n'est pas un hasard si Rust n'a pas encore de proposition de travail acceptée concernant le placement des sous-objets en mémoire!


Upd 1: correction d'une faute de frappe. Remplacé le "littéral d'écriture" par "littéral de structure".

Source: https://habr.com/ru/post/fr460831/


All Articles