Théorie de la programmation: variante

Bonjour, je m'appelle Dmitry Karlovsky et je ... veux vous parler de la particularité fondamentale des systèmes de types, qui est souvent ou pas du tout comprise ou est mal comprise à travers le prisme de la mise en œuvre d'un langage particulier, qui, en raison du développement évolutif, a de nombreux atavismes. Par conséquent, même si vous pensez savoir ce qu'est une «variation», essayez de regarder les problèmes avec un regard neuf. Nous partirons des bases, donc même un débutant comprendra tout. Et nous continuons sans eau, de sorte que même les pros seront utiles pour structurer leurs connaissances. Les exemples de code seront dans un pseudo-langage similaire à TypeScript. Ensuite, les approches de plusieurs langages réels seront examinées. Et si vous développez votre propre langue, cet article vous aidera à ne pas marcher sur le râteau de quelqu'un d'autre.


et s'il y a des renards?


Arguments et paramètres


Le paramètre est ce que nous acceptons. Décrivant le type de paramètre, nous avons défini une restriction sur l'ensemble des types qui peuvent nous être transmis. Quelques exemples:


//   function log( id : string | number ) {} //   class Logger { constructor( readonly id : Natural ) {} } //   class Node< Id extends Number > { id : Id } 

Un argument est ce que nous transmettons. Au moment du transfert, l'argument a toujours un type particulier. Cependant, dans l'analyse statique, un type particulier peut ne pas être connu, c'est pourquoi le compilateur fonctionne à nouveau avec des restrictions de type. Quelques exemples:


 log( 123 ) //   new Logger( promptStringOrNumber( 'Enter id' ) ) //       new Node( 'root' ) //   ,   

Sous-types


Les types peuvent former une hiérarchie. Un sous - type est un cas particulier d'un sur-type. Un sous-type peut être formé en rétrécissant l' ensemble des valeurs possibles d'un supertype. Par exemple, le type Naturel est un sous-type Entier et Positif. Et tous les trois sont des sous-types de Real en même temps. Et le type Prime est un sous-type de tout ce qui précède. Dans le même temps, les types Positif et Entier se chevauchent, mais aucun d'entre eux ne restreint l'autre.


image


Une autre façon de former un sous-type consiste à le développer en le combinant avec un autre type orthogonal à celui-ci. Par exemple, il y a une «figure de couleur» avec la propriété «couleur», et il y a un «carré» avec la propriété «hauteur». En combinant ces types, nous obtenons un "carré de couleur". Et en ajoutant un «cercle» avec son «rayon», nous pouvons obtenir un «cylindre de couleur».


image


Hiérarchies


Pour une narration plus approfondie, nous avons besoin d'une petite hiérarchie d'animaux et d'une hiérarchie similaire de cellules.


 abstract class Animal {} abstract class Pet extends Animal {} class Cat extends Pet {} class Dog extends Pet {} class Fox extends Animal {} class AnimalCage { content : Animal } class PetCage extends AnimalCage { content : Pet } class CatCage extends PetCage { content : Cat } class DogCage extends PetCage { content : Dog } class FoxCage extends AnimalCage { content : Fox } 

Tout ce qui suit est un rétrécissement du type ci-dessus. Une cage avec un animal domestique ne peut contenir que des animaux domestiques, mais pas des animaux sauvages. Une cage avec un chien ne peut contenir que des chiens.


image


Covariance


La plus simple et la plus compréhensible est la restriction d'un supertype ou d'une covariance. Dans l'exemple suivant, le paramètre de fonction est covariant au type spécifié pour lui. Autrement dit, la fonction peut accepter à la fois ce type lui-même et n'importe quel sous-type de celui-ci, mais ne peut pas accepter de supertypes ou d'autres types.


 function touchPet( cage : PetCage ) : void { log( `touch ${cage.content}` ) } touchPet( new AnimalCage ) // forbid touchPet( new PetCage ) // allow touchPet( new CatCage ) // allow touchPet( new DogCage ) // allow touchPet( new FoxCage ) // forbid 

image


Comme nous ne changeons rien dans la cage, nous pouvons transférer en toute sécurité des fonctions dans la cage avec le chat, car il ne s'agit que d'un cas spécial de la cage avec un animal de compagnie.


Contravariance


Il est un peu plus difficile de comprendre la restriction ou la contravariance des sous -types. Dans l'exemple suivant, le paramètre de fonction est contraire au type spécifié pour lui. Autrement dit, la fonction peut accepter à la fois ce type lui-même et n'importe lequel de ses supertypes, mais ne peut pas accepter de sous-types ou d'autres types.


 function pushPet( cage : PetCage ) : void { const Pet = random() > .5 ? Cat : Dog cage.content = new Pet } pushPet( new AnimalCage ) // allow pushPet( new PetCage ) // allow pushPet( new CatCage ) // forbid pushPet( new DogCage ) // forbid pushPet( new FoxCage ) // forbid 

image


Nous ne pouvons pas passer la cage avec le chat, car la fonction peut y placer le chien, ce qui n'est pas autorisé. Mais la cage avec n'importe quel animal peut être transférée en toute sécurité, car le chat et le chien peuvent y être placés.


Invariance


La limitation du sous-type et du supertype peut être simultanée. Un tel cas est appelé invariance. Dans l'exemple suivant, le paramètre de fonction est invariant au type spécifié pour lui. Autrement dit, la fonction ne peut accepter que le type spécifié et pas plus.


 function replacePet( cage : PetCage ) : void { touchPet( cage ) pushPet( cage ) } replacePet( new AnimalCage ) // forbid replacePet( new PetCage ) // allow replacePet( new CatCage ) // forbid replacePet( new DogCage ) // forbid replacePet( new FoxCage ) // forbid 

image


La fonction replacePet hérite des limitations de ces fonctions qu'elle utilise en interne: elle a pris la restriction sur le type de pushPet , et la restriction sur le sous-type par pushPet . Si nous lui donnons une cage avec n'importe quel animal, elle ne pourra pas la transférer vers la fonction touchPet, qui ne sait pas comment travailler avec les renards (un animal sauvage mordra simplement un doigt). Et si nous transférons la cage avec le chat, cela ne fonctionnera pas d'appeler pushPet .


Bivariance


On ne peut que mentionner l' absence exotique de restrictions - la bivariance. Dans l'exemple suivant, une fonction peut accepter n'importe quel type qui est un sous-type ou un sous-type.


 function enshurePet( cage : PetCage ) : void { if( cage.content instanceof Pet ) return pushPet( cage ) } replacePet( new AnimalCage ) // allow replacePet( new PetCage ) // allow replacePet( new CatCage ) // allow replacePet( new DogCage ) // allow replacePet( new FoxCage ) // forbid 

image


Vous pouvez y transférer la cage avec l'animal. Ensuite, elle vérifiera qu'il y a un animal dans la cage, sinon elle le mettra à l'intérieur d'un animal au hasard. Et vous pouvez transférer, par exemple, une cage avec un chat, alors elle ne fera rien.


Généralisations


Certains croient que la variance est en quelque sorte liée aux généralisations. Souvent parce que la variance est souvent expliquée en utilisant des conteneurs génériques comme exemple. Cependant, dans toute l'histoire, nous n'avons toujours pas eu une seule généralisation - ce sont des classes entièrement concrètes:


 class AnimalCage { content : Animal } class PetCage extends AnimalCage { content : Pet } class CatCage extends PetCage { content : Cat } class DogCage extends PetCage { content : Dog } class FoxCage extends AnimalCage { content : Fox } 

Cela a été fait pour montrer que les problèmes de variance ne sont pas liés aux généralisations. Des généralisations ne sont nécessaires que pour réduire le copier-coller. Par exemple, le code ci-dessus peut être réécrit par une simple généralisation:


 class Cage<Animal> { content : Animal } 

Et maintenant, vous pouvez créer des instances de n'importe quelle cellule:


 const animalCage = new Cage<Animal>() const petCage = new Cage<Pet>() const catCage = new Cage<Cat>() const dogCage = new Cage<Dog>() const foxCage = new Cage<Fox>() 

Déclaration de limitations


Veuillez noter que les signatures des quatre fonctions précédemment répertoriées sont exactement les mêmes:


 ( cage : PetCage )=> void 

Autrement dit, une telle description des paramètres acceptés de la fonction n'est pas complète - on ne peut pas en dire qu'elle peut être transférée à la fonction. Eh bien, à moins qu'il ne soit clairement vu que cela ne vaut vraiment pas la peine d'y passer la cage avec le renard.


Par conséquent, dans les langues modernes, il existe un moyen d'indiquer explicitement les restrictions de type d'un paramètre. Par exemple, les modificateurs d' entrée et de sortie en C #:


 interface ICageIn<in T> { T content { set; } } // contravariant generic parameter interface ICageOut<out T> { T content { get; } } // covariant generic parameter interface ICageInOut<T> { T content { get; set; } } // invariant generic parameter 

Malheureusement, en C # pour chaque variante de modificateurs, il est nécessaire de démarrer sur une interface distincte. De plus, si je comprends bien, la bivariance en C # est généralement inexprimable.


Paramètres de sortie


Les fonctions peuvent non seulement accepter, mais aussi renvoyer des valeurs. En général, la valeur de retour peut ne pas être une. Par exemple, prenez la fonction de prendre une cage avec un animal de compagnie et de rendre deux animaux de compagnie.


 function getPets( input : PetCage ) : [ Pet , Pet ] { return [ input.content , new Cat ] } 

Une telle fonction équivaut à une fonction qui prend, en plus d'un paramètre d'entrée, deux sorties supplémentaires.


 function getPets( input : PetCage , output1 : PetCage , output2 : PetCage ) : void { output1.content = input.content output2.content = new Cat } 

Le code externe alloue de la mémoire supplémentaire sur la pile afin que la fonction y mette tout ce qu'elle veut y retourner. Et une fois terminé, le code appelant pourra déjà utiliser ces conteneurs à leurs propres fins.


image


De l'équivalence de ces deux fonctions, il s'ensuit que les valeurs renvoyées par la fonction, contrairement aux paramètres, sont toujours contravariantes au type de sortie spécifié. Car une fonction peut leur écrire, mais ne peut pas lire d'eux.


Méthodes d'objet


Les méthodes objet sont des fonctions qui prennent un pointeur supplémentaire sur un objet comme paramètre implicite. Autrement dit, les deux fonctions suivantes sont équivalentes.


 class PetCage { pushPet() : void { const Pet = random() > .5 ? Cat : Dog this.content = new Pet } } 

 function pushPet( this : PetCage ) : void { const Pet = random() > .5 ? Cat : Dog this.content = new Pet } 

Cependant, il est important de noter qu'une méthode, contrairement à une fonction régulière, est également membre de la classe, qui est une extension du type. Cela conduit au fait qu'une restriction de supertype supplémentaire apparaît pour les fonctions appelant cette méthode:


 function fillPetCage( cage : PetCage ) { cage.pushPet() } 

image


Nous ne pouvons pas lui passer un tel sur-type où la méthode pushPet pas encore définie. Ceci est similaire au cas de l'invariance en ce sens qu'il existe une restriction à la fois par le bas et par le haut. Cependant, l'emplacement de la méthode pushPet peut être plus élevé dans la hiérarchie. Et c'est là que sera la limitation de surcharge.


Principe de substitution de Barbara Lisk (LSP)


Beaucoup de gens croient que le rapport d'un sous-type à un sous-type est déterminé non pas sur la base des méthodes susmentionnées de rétrécissement et d'extension du type, mais par la capacité de remplacer le sous-type à n'importe quel endroit où le supertype est utilisé. Apparemment, la raison de cette erreur est précisément dans le LSP. Cependant, lisons attentivement la définition de ce principe, en faisant attention à ce qui est primaire et à ce qui est secondaire:


Les fonctions qui utilisent le type de base doivent pouvoir utiliser des sous-types du type de base sans le savoir et sans violer l'exactitude du programme.

Pour les objets immuables (y compris ceux qui ne font pas référence à des objets mutables), ce principe est exécuté automatiquement, car il n'y a pas de place pour prendre la restriction de sous-type.


Avec les mutables, c'est de plus en plus difficile, car les deux situations suivantes s'excluent mutuellement pour le principe LSP:


  1. La classe A a une sous-classe de B , où le champ B::foo est un sous-type de A::foo .
  2. La classe A a une méthode qui peut changer le champ A::foo .

En conséquence, il ne reste que trois façons:


  1. Empêchez les objets d'hériter de restreindre leurs types de champs. Mais alors vous pouvez pousser un éléphant dans la cage pour un chat.
  2. Guidé non pas par le LSP, mais par la variabilité de chaque paramètre de chaque fonction séparément. Mais alors vous devez réfléchir beaucoup et expliquer au compilateur où se trouvent les restrictions de type.
  3. Crache sur tout et va à le monastère programmation fonctionnelle, où tous les objets sont immuables, ce qui signifie que leurs paramètres acceptant sont covariants au type déclaré.

TypeScript


Dans le script temporel, la logique est simple: tous les paramètres de la fonction sont considérés comme covariants (ce qui n'est pas vrai) et les valeurs de retour sont considérées comme contravariantes (ce qui est vrai). Il a été montré précédemment que les paramètres d'une fonction peuvent avoir absolument n'importe quelle variation, selon ce que fait cette fonction avec ces paramètres. Par conséquent, ce sont les incidents suivants:


 abstract class Animal { is! : 'cat' | 'dog' | 'fox' } abstract class Pet extends Animal { is! : 'cat' | 'dog' } class Cat extends Pet { is! : 'cat' } class Dog extends Pet { is! : 'dog' } class Fox extends Animal { is! : 'fox' } class Cage<Animal> { content! : Animal } function pushPet( cage : Cage<Pet> ) : void { const Pet = Math.random() > .5 ? Cat : Dog cage.content = new Pet } pushPet( new Cage<Animal>() ) // forbid to push Pet to Animal Cage :-( pushPet( new Cage<Cat>() ) // allow to push Dog to Cat Cage :-( 

Pour résoudre ce problème, vous devez aider le compilateur avec un code plutôt simple:


 function pushPet< PetCage extends Cage<Animal> >( cage: Cage<Pet> extends PetCage ? PetCage : never ): void { const Pet = Math.random() > .5 ? Cat : Dog cage.content = new Pet } pushPet( new Cage<Animal>() ) // allow :-) pushPet( new Cage<Pet>() ) // allow :-) pushPet( new Cage<Cat>() ) // forbid :-) pushPet( new Cage<Dog>() ) // forbid :-) pushPet( new Cage<Fox>() ) // forbid :-) 

Essayez en ligne


Flowjs


FlowJS a un système de type plus avancé. En particulier, dans la description du type, il est possible d'indiquer sa variabilité pour les paramètres généralisés et pour les champs d'objet. Dans notre exemple de cellule, cela ressemble à ceci:


 class Animal {} class Pet extends Animal {} class Cat extends Pet {} class Dog extends Pet {} class Fox extends Animal {} class Cage< Animal > { content : Animal } function touchPet( cage : { +content : Pet } ) : void { console.log( `touch ${typeof cage.content}` ) } function pushPet( cage: { -content: Pet } ): void { const Pet = Number((0: any)) > .5 ? Cat : Dog cage.content = new Pet } function replacePet( cage : { content : Pet } ) : void { touchPet( cage ) pushPet( cage ) } touchPet( new Cage<Animal> ) // forbid :-) touchPet( new Cage<Pet> ) // allow :-) touchPet( new Cage<Cat> ) // allow :-) touchPet( new Cage<Dog> ) // allow :-) touchPet( new Cage<Fox> ) // forbid :-) pushPet( new Cage<Animal> ) // allow :-) pushPet( new Cage<Pet> ) // allow :-) pushPet( new Cage<Cat> ) // forbid :-) pushPet( new Cage<Dog> ) // forbid :-) pushPet( new Cage<Fox> ) // forbid :-) replacePet( new Cage<Animal> ) // forbid :-) replacePet( new Cage<Pet> ) // allow :-) replacePet( new Cage<Cat> ) // forbid :-) replacePet( new Cage<Dog> ) // forbid :-) replacePet( new Cage<Fox>) // forbid :-) 

Essayez en ligne


La bivariance est ici inexprimable. Malheureusement, je n'ai pas pu trouver un moyen de régler la variance de manière plus pratique sans décrire explicitement les types de tous les champs. Par exemple, quelque chose comme ceci:


 function pushPet( cage: Contra< Cage<Pet> , 'content' > ): void { const Pet = Number((0: any)) > .5 ? Cat : Dog cage.content = new Pet } 

C forte


C # a été initialement conçu sans aucune compréhension des variations. Cependant, plus tard, des modificateurs de paramètres ont été ajoutés, ce qui a permis au compilateur de vérifier correctement les types d'arguments transmis. Malheureusement, l'utilisation de ces modificateurs n'est à nouveau pas très pratique.


 using System; abstract class Animal {} abstract class Pet : Animal {} class Cat : Pet {} class Dog : Pet {} class Fox : Animal {} interface ICageIn<in T> { T content { set; } } interface ICageOut<out T> { T content { get; } } interface ICageInOut<T> { T content { get; set; } } class Cage<T> : ICageIn<T>, ICageOut<T>, ICageInOut<T> { public T content { get; set; } } public class Program { static void touchPet( ICageOut<Pet> cage ) { Console.WriteLine( cage.content ); } static void pushPet( ICageIn<Pet> cage ) { cage.content = new Dog(); } static void replacePet( ICageInOut<Pet> cage ) { touchPet( cage as ICageOut<Pet> ); pushPet( cage as ICageIn<Pet> ); } void enshurePet( Cage<Pet> cage ) { if( cage.content is Pet ) return; pushPet( cage as ICageIn<Pet> ); } public static void Main() { var animalCage = new Cage<Animal>(); var petCage = new Cage<Pet>(); var catCage = new Cage<Cat>(); var dogCage = new Cage<Dog>(); var foxCage = new Cage<Fox>(); touchPet( animalCage ); // forbid :-) touchPet( petCage ); // allow :-) touchPet( catCage ); // allow :-) touchPet( dogCage ); // allow :-) touchPet( foxCage ); // forbid :-) pushPet( animalCage ); // allow :-) pushPet( petCage ); // allow :-) pushPet( catCage ); // forbid :-) pushPet( dogCage ); // forbid :-) pushPet( foxCage ); // forbid :-) replacePet( animalCage ); // forbid :-) replacePet( petCage ); // allow :-) replacePet( catCage ); // forbid :-) replacePet( dogCage ); // forbid :-) replacePet( foxCage ); // forbid :-) } } 

Essayez en ligne


Java


Pour Java, la possibilité de changer de variation a été ajoutée assez tard et uniquement pour les paramètres généralisés, qui eux-mêmes sont apparus relativement récemment. Si le paramètre n'est pas généralisé, alors problème.


 abstract class Animal {} abstract class Pet extends Animal {} class Cat extends Pet {} class Dog extends Pet {} class Fox extends Animal {} class Cage<T> { public T content; } public class Main { static void touchPet( Cage<? extends Pet> cage ) { System.out.println( cage.content ); } static void pushPet( Cage<? super Pet> cage ) { cage.content = new Dog(); } static void replacePet(Cage<Pet> cage ) { touchPet( cage ); pushPet( cage ); } void enshurePet( Cage<Pet> cage ) { if( cage.content instanceof Pet ) return; pushPet( cage ); } public static void main(String[] args) { Cage<Animal> animalCage = new Cage<Animal>(); Cage<Pet> petCage = new Cage<Pet>(); Cage<Cat> catCage = new Cage<Cat>(); Cage<Dog> dogCage = new Cage<Dog>(); Cage<Fox> foxCage = new Cage<Fox>(); touchPet( animalCage ); // forbid :-) touchPet( petCage ); // allow :-) touchPet( catCage ); // allow :-) touchPet( dogCage ); // allow :-) touchPet( foxCage ); // forbid :-) pushPet( animalCage ); // allow :-) pushPet( petCage ); // allow :-) pushPet( catCage ); // forbid :-) pushPet( dogCage ); // forbid :-) pushPet( foxCage ); // forbid :-) replacePet( animalCage ); // forbid :-) replacePet( petCage ); // allow :-) replacePet( catCage ); // forbid :-) replacePet( dogCage ); // forbid :-) replacePet( foxCage ); // forbid :-) } } 

Essayez en ligne


C ++


Le C ++, grâce à son puissant système de modèles, peut exprimer diverses variantes, mais bien sûr, il y a beaucoup de code.


 #include <iostream> #include <typeinfo> #include <type_traits> class Animal {}; class Pet: public Animal {}; class Cat: public Pet {}; class Dog: public Pet {}; class Fox: public Animal {}; template<class T> class Cage { public: T *content; }; template<class T, class = std::enable_if_t<std::is_base_of<Pet, T>::value>> void touchPet(const Cage<T> &cage) { std::cout << typeid(T).name(); } template<class T, class = std::enable_if_t<std::is_base_of<T, Pet>::value>> void pushPet(Cage<T> &cage) { cage.content = new Dog(); } void replacePet(Cage<Pet> &cage) { touchPet(cage); pushPet(cage); } int main(void) { Cage<Animal> animalCage {new Fox()}; Cage<Pet> petCage {new Cat()}; Cage<Cat> catCage {new Cat()}; Cage<Dog> dogCage {new Dog()}; Cage<Fox> foxCage {new Fox()}; touchPet( animalCage ); // forbid :-) touchPet( petCage ); // allow :-) touchPet( catCage ); // allow :-) touchPet( dogCage ); // allow :-) touchPet( foxCage ); // forbid :-) pushPet( animalCage ); // allow :-) pushPet( petCage ); // allow :-) pushPet( catCage ); // forbid :-) pushPet( dogCage ); // forbid :-) pushPet( foxCage ); // forbid :-) replacePet( animalCage ); // forbid :-) replacePet( petCage ); // allow :-) replacePet( catCage ); // forbid :-) replacePet( dogCage ); // forbid :-) replacePet( foxCage ); // forbid :-) return 0; } 

Essayez en ligne


D


D n'a aucun moyen sensé d'indiquer explicitement la variance, mais il sait déduire les types en fonction de leur utilisation.


 import std.stdio, std.random; abstract class Animal {} abstract class Pet : Animal { string name; } class Cat : Pet {} class Dog : Pet {} class Fox : Animal {} class Cage(T) { T content; } void touchPet( PetCage )( PetCage cage ) { writeln( cage.content.name ); } void pushPet( PetCage )( PetCage cage ) { cage.content = ( uniform(0,2) > 0 ) ? new Dog() : new Cat(); } void replacePet( PetCage )( PetCage cage ) { touchPet( cage ); pushPet( cage); } void main() { Cage!Animal animalCage; Cage!Pet petCage; Cage!Cat catCage; Cage!Dog dogCage; Cage!Fox foxCage; animalCage.touchPet(); // forbid :-) petCage.touchPet(); // allow :-) catCage.touchPet(); // allow :-) dogCage.touchPet(); // allow :-) foxCage.touchPet(); // forbid :-) animalCage.pushPet(); // allow :-) petCage.pushPet(); // allow :-) catCage.pushPet(); // forbid :-) dogCage.pushPet(); // forbid :-) foxCage.pushPet(); // forbid :-) animalCage.replacePet(); // forbid :-) petCage.replacePet(); // allow :-) catCage.replacePet(); // forbid :-) dogCage.replacePet(); // forbid :-) foxCage.replacePet(); // forbid :-) } 

Essayez en ligne


Épilogue


C'est tout pour l'instant. J'espère que le matériel présenté vous a aidé à mieux comprendre les restrictions sur les types et comment elles sont mises en œuvre dans différentes langues. Quelque part mieux, quelque part pire, quelque part pas du tout, mais dans l'ensemble - tant bien que mal. C'est peut-être vous qui développerez le langage dans lequel tout cela sera implémenté à la fois de manière pratique et sécurisée. En attendant, rejoignez notre chat télégramme, où nous discutons parfois des concepts théoriques des langages de programmation .

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


All Articles