Teoria da Programação: Variante

Olá, meu nome é Dmitry Karlovsky e eu ... quero falar sobre as características fundamentais dos sistemas de tipos, que geralmente não entendem ou não entendem corretamente através do prisma da implementação de uma linguagem específica que, devido ao desenvolvimento evolutivo, possui muitos atavismos. Portanto, mesmo que você pense que sabe o que é "variação", tente analisar os problemas com uma nova aparência. Começaremos do básico, para que mesmo um iniciante entenda tudo. E continuamos sem água, para que até os profissionais sejam úteis para estruturar seus conhecimentos. Os exemplos de código estarão em uma pseudo-linguagem semelhante ao TypeScript. Em seguida, as abordagens de várias línguas reais serão examinadas. E se você estiver desenvolvendo seu próprio idioma, este artigo o ajudará a não pisar no rake de outra pessoa.


e se houver raposas?


Argumentos e Parâmetros


O parâmetro é o que aceitamos. Descrevendo o tipo de parâmetro, definimos uma restrição no conjunto de tipos que podem ser passados ​​para nós. Alguns exemplos:


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

Um argumento é o que transmitimos. No momento da transferência, o argumento sempre tem algum tipo específico. No entanto, na análise estática, um tipo específico pode não ser conhecido, e é por isso que o compilador opera novamente com restrições de tipo. Alguns exemplos:


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

Subtipos


Tipos podem formar uma hierarquia. Um subtipo é um caso especial de um supertipo . Um subtipo pode ser formado restringindo o conjunto de valores possíveis de um supertipo. Por exemplo, o tipo Natural é um subtipo de Inteiro e Positivo. E todos os três são subtipos de Real ao mesmo tempo. E o tipo Prime é um subtipo de todos os itens acima. Ao mesmo tempo, os tipos Positivo e Inteiro se sobrepõem, mas nenhum deles restringe o outro.


imagem


Outra maneira de formar um subtipo é expandi- lo combinando-o com outro tipo ortogonal a ele. Por exemplo, existe uma "figura colorida" com a propriedade "cor" e existe um "quadrado" com a propriedade "altura". Ao combinar esses tipos, obtemos um "quadrado colorido". E adicionando um "círculo" com seu "raio", podemos obter um "cilindro colorido".


imagem


Hierarquias


Para uma narração adicional, precisamos de uma pequena hierarquia de animais e uma hierarquia semelhante de células.


 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 } 

Tudo abaixo é um estreitamento do tipo acima. Uma gaiola com um animal de estimação pode conter apenas animais domésticos, mas não animais selvagens. Uma gaiola com um cachorro só pode conter cães.


imagem


Covariância


O mais simples e mais compreensível é a restrição de um supertipo ou covariância. No exemplo a seguir, o parâmetro de função é covariante para o tipo especificado para ele. Ou seja, a função pode aceitar esse tipo em si e qualquer subtipo dele, mas não pode aceitar supertipos ou outros tipos.


 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 

imagem


Como não mudamos nada na gaiola, podemos transferir com segurança as funções para a gaiola com o gato, pois nada mais é do que um caso especial da gaiola com um animal de estimação.


Contravariância


É um pouco mais difícil entender restrição ou contravariância de subtipo . No exemplo a seguir, o parâmetro de função é contrário ao tipo especificado para ele. Ou seja, a função pode aceitar esse tipo em si e qualquer um de seus supertipos, mas não pode aceitar subtipos ou outros tipos.


 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 

imagem


Não podemos passar a gaiola com o gato, pois a função pode colocar o cachorro ali, o que não é permitido. Mas a gaiola com qualquer animal pode ser transferida com segurança, pois o gato e o cachorro podem ser colocados lá.


Invariância


Limitar subtipo e supertipo podem ser simultaneamente. Tal caso é chamado invariância. No exemplo a seguir, o parâmetro de função é invariável para o tipo especificado para ele. Ou seja, a função pode aceitar apenas o tipo especificado e não mais.


 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 

imagem


A função replacePet herda as limitações daquelas funções que usa internamente: tomou a restrição no tipo do pushPet e a restrição no subtipo pelo pushPet . Se dermos a ela uma gaiola com qualquer animal, ela não poderá transferi-la para a função touchPet, que não sabe trabalhar com raposas (um animal selvagem simplesmente morderá um dedo). E se transferirmos a gaiola com o gato, não funcionará para chamar pushPet .


Bivariance


Não se pode deixar de mencionar a exótica ausência de restrições - a divergência. No exemplo a seguir, uma função pode aceitar qualquer tipo que seja um subtipo ou subtipo.


 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 

imagem


Nele você pode transferir a gaiola com o animal. Então ela verificará se há um animal de estimação na gaiola, caso contrário, ela o colocará dentro de um animal de estimação aleatório. E você pode transferir, por exemplo, uma gaiola com um gato, então ela simplesmente não fará nada.


Generalizações


Alguns acreditam que a variação está de alguma forma relacionada a generalizações. Muitas vezes, porque a variação é explicada usando contêineres genéricos como exemplo. No entanto, em toda a história ainda não tivemos uma única generalização - são classes inteiramente concretas:


 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 } 

Isso foi feito para mostrar que os problemas de variação não estão relacionados às generalizações. Generalizações são necessárias apenas para reduzir copiar e colar. Por exemplo, o código acima pode ser reescrito através de uma simples generalização:


 class Cage<Animal> { content : Animal } 

E agora você pode criar instâncias de qualquer célula:


 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>() 

Declaração de limitações


Observe que as assinaturas de todas as quatro funções listadas anteriormente são exatamente as mesmas:


 ( cage : PetCage )=> void 

Ou seja, essa descrição dos parâmetros aceitos da função não tem abrangência - não se pode dizer que possa ser transferida para a função. Bem, a menos que seja claramente visto que definitivamente não vale a pena passar a gaiola com a raposa para dentro.


Portanto, nas linguagens modernas, existe um meio de indicar explicitamente quais restrições de tipo um parâmetro possui. Por exemplo, os modificadores de entrada e saída em 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 

Infelizmente, em C # para cada variante de modificador, é necessário iniciar em uma interface separada. Além disso, pelo que entendi, a divergência em C # geralmente é inefável.


Parâmetros de saída


As funções podem não apenas aceitar, mas também retornar valores. Em geral, o valor de retorno pode não ser um. Como exemplo, tome a função de pegar uma gaiola com um animal de estimação e devolver dois animais de estimação.


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

Essa função é equivalente a uma função que recebe, além de um parâmetro de entrada, mais duas saídas.


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

O código externo aloca memória adicional na pilha para que a função coloque tudo o que deseja retornar nela. E, após a conclusão, o código de chamada já poderá usar esses contêineres para seus próprios fins.


imagem


A partir da equivalência dessas duas funções, segue-se que os valores retornados pela função, em contraste com os parâmetros, sempre são contrários ao tipo de saída especificado. Para uma função pode escrever para eles, mas não pode ler a partir deles.


Métodos de objeto


Métodos de objeto são funções que levam um ponteiro adicional para um objeto como um parâmetro implícito. Ou seja, as duas funções a seguir são equivalentes.


 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 } 

No entanto, é importante observar que um método, diferentemente de uma função regular, também é um membro da classe, que é uma extensão do tipo. Isso leva ao fato de que uma restrição adicional de supertipo aparece para funções que chamam este método:


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

imagem


Não podemos passar esse tipo de pushPet para ele onde o método pushPet ainda não está definido. Isso é semelhante ao caso da invariância, pois existe uma restrição de baixo e de cima. No entanto, a localização do método pushPet pode ser mais alta na hierarquia. E é aí que estará a limitação do excesso de tipo.


Princípio de substituição de Barbara Lisk (LSP)


Muitas pessoas pensam que a proporção de um subtipo para um subtipo é determinada não com base nos métodos mencionados anteriormente de restringir e expandir o tipo, mas sim pela possibilidade de substituir o subtipo em qualquer local em que o supertipo seja usado. Aparentemente, a razão para esse erro está precisamente no LSP. No entanto, vamos ler a definição desse princípio com atenção, prestando atenção no que é primário e no que é secundário:


As funções que usam o tipo base devem poder usar subtipos do tipo base sem conhecê-lo e sem violar a correção do programa.

Para objetos imutáveis ​​(incluindo aqueles que não fazem referência a mutáveis), esse princípio é executado automaticamente, pois não há lugar para se retirar a restrição de subtipo.


Com mutáveis, é cada vez mais difícil, pois as duas situações a seguir são mutuamente exclusivas para o princípio LSP:


  1. A classe A possui uma subclasse B , onde o campo B::foo é um subtipo de A::foo .
  2. A classe A possui um método que pode alterar o campo A::foo .

Consequentemente, existem apenas três maneiras restantes:


  1. Impedir que objetos herdem restrinja seus tipos de campo. Mas então você pode enfiar um elefante na gaiola para um gato.
  2. Guiado não pelo LSP, mas pela variabilidade de cada parâmetro de cada função separadamente. Mas você precisa pensar muito e explicar ao compilador onde estão as restrições de tipo.
  3. Cuspa em tudo e vá para o mosteiro programação funcional, onde todos os objetos são imutáveis, o que significa que seus parâmetros aceitos são covariantes ao tipo declarado.

TypeScript


No script de tempo, a lógica é simples: todos os parâmetros da função são considerados covariantes (o que não é verdadeiro) e os valores de retorno são considerados contravariantes (o que é verdadeiro). Foi mostrado anteriormente que os parâmetros de uma função podem ter absolutamente qualquer variação, dependendo do que essa função faz com esses parâmetros. Portanto, estes são os seguintes incidentes:


 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 :-( 

Para resolver esse problema, você precisa ajudar o compilador com código não trivial:


 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 :-) 

Experimente on-line


Flowjs


O FlowJS possui um sistema de tipo mais avançado. Em particular, na descrição do tipo, é possível indicar sua variabilidade para parâmetros generalizados e para campos de objetos. No nosso exemplo de célula, é algo parecido com isto:


 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 :-) 

Experimente on-line


Bivariância aqui é inexprimível. Infelizmente, não foi possível encontrar uma maneira de definir a variação de maneira mais conveniente sem descrever explicitamente os tipos de todos os campos. Por exemplo, algo como isto:


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

C afiado


O C # foi originalmente projetado sem nenhum entendimento de variação. No entanto, posteriormente, foram adicionados modificadores de parâmetro, que permitiram ao compilador verificar corretamente os tipos de argumentos passados. Infelizmente, o uso desses modificadores novamente não é muito conveniente.


 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 :-) } } 

Experimente on-line


Java


Para Java, a capacidade de alternar variações foi adicionada bastante tarde e apenas para parâmetros generalizados, que apareceram relativamente recentemente. Se o parâmetro não for generalizado, problemas.


 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 :-) } } 

Experimente on-line


C ++


O C ++, graças ao seu poderoso sistema de modelos, pode expressar várias variações, mas é claro que há muito código.


 #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; } 

Experimente on-line


D


D não possui meios sãos de indicar explicitamente variação, mas ele sabe como inferir tipos com base em seu uso.


 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 :-) } 

Experimente on-line


Epílogo


Por enquanto é tudo. Espero que o material apresentado tenha ajudado você a entender melhor as restrições de tipos e como elas são implementadas em diferentes idiomas. Em algum lugar melhor, em algum lugar pior, em algum lugar de jeito nenhum, mas no todo - mais ou menos. Talvez seja você quem desenvolverá a linguagem na qual tudo isso será implementado de maneira conveniente e segura para o tipo. Enquanto isso, participe do nosso bate-papo por telegrama, onde às vezes discutimos os conceitos teóricos das linguagens de programação .

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


All Articles