Hola, mi nombre es Dmitry Karlovsky y yo ... Quiero contarles sobre las características fundamentales de los sistemas de tipos, que a menudo o no entienden o no entienden correctamente a través del prisma de la implementación de un lenguaje en particular, que, debido al desarrollo evolutivo, tiene muchos atavismos. Por lo tanto, incluso si crees que sabes qué es "variación", trata de ver los problemas con una nueva mirada. Comenzaremos desde lo más básico, por lo que incluso un principiante lo entenderá todo. Y continuamos sin agua, de modo que incluso los profesionales serán útiles para estructurar sus conocimientos. Los ejemplos de código estarán en un pseudo-lenguaje similar a TypeScript. Luego se examinarán los enfoques de varios idiomas reales. Y si está desarrollando su propio lenguaje, este artículo lo ayudará a no pisar el rastrillo de otra persona.

Argumentos y Parámetros
El parámetro es lo que aceptamos. Al describir el tipo de parámetro, establecemos una restricción en el conjunto de tipos que nos pueden pasar. Algunos ejemplos
// function log( id : string | number ) {} // class Logger { constructor( readonly id : Natural ) {} } // class Node< Id extends Number > { id : Id }
Un argumento es lo que transmitimos. En el momento de la transferencia, el argumento siempre tiene algún tipo particular. Sin embargo, en el análisis estático, es posible que no se conozca un tipo particular, por lo que el compilador vuelve a funcionar con restricciones de tipo. Algunos ejemplos
log( 123 ) // new Logger( promptStringOrNumber( 'Enter id' ) ) // new Node( 'root' ) // ,
Subtipos
Los tipos pueden formar una jerarquía. Un subtipo es un caso especial de un supertipo . Se puede formar un subtipo al reducir el conjunto de valores posibles de un supertipo. Por ejemplo, el tipo Natural es un subtipo de Entero y Positivo. Y los tres son subtipos de Real al mismo tiempo. Y el tipo Prime es un subtipo de todo lo anterior. Al mismo tiempo, los tipos positivo e entero se superponen, pero ninguno de ellos restringe al otro.

Otra forma de formar un subtipo es expandirlo combinándolo con otro tipo ortogonal a él. Por ejemplo, hay una "figura de color" con la propiedad "color", y hay un "cuadrado" con la propiedad "altura". Al combinar estos tipos obtenemos un "cuadrado de color". Y agregando un "círculo" con su "radio" podemos obtener un "cilindro de color".

Jerarquías
Para una mayor narración, necesitamos una pequeña jerarquía de animales y una jerarquía similar 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 }
Todo lo de abajo es un estrechamiento del tipo de arriba. Una jaula con una mascota puede contener solo animales domésticos, pero no animales salvajes. Una jaula con un perro solo puede contener perros.

Covarianza
La más simple y más comprensible es la restricción de un supertipo o covarianza. En el siguiente ejemplo, el parámetro de función es covariante para el tipo especificado para él. Es decir, la función puede aceptar tanto este tipo como cualquier subtipo del mismo, pero no puede aceptar supertipos u otros 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

Como no cambiamos nada en la jaula, podemos transferir de manera segura las funciones a la jaula con el gato, ya que no es más que un caso especial de la jaula con una mascota.
Contravarianza
Es un poco más difícil entender la restricción de subtipo o la contravarianza. En el siguiente ejemplo, el parámetro de la función es contraria al tipo especificado para él. Es decir, la función puede aceptar este tipo en sí mismo y cualquiera de sus supertipos, pero no puede aceptar subtipos u otros 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

No podemos pasar la jaula con el gato, ya que la función puede poner al perro allí, lo cual no está permitido. Pero la jaula con cualquier animal se puede transferir de forma segura, ya que tanto el gato como el perro se pueden colocar allí.
Invariancia
La limitación de subtipo y supertipo puede ser al mismo tiempo. Tal caso se llama invariancia. En el siguiente ejemplo, el parámetro de función es invariable para el tipo especificado para él. Es decir, la función solo puede aceptar el tipo especificado y no más.
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

La función replacePet
hereda las limitaciones de esas funciones que usa internamente: tomó la restricción del tipo de pushPet
y la restricción del subtipo de pushPet
. Si le damos una jaula con algún animal, no podrá transferirla a la función touchPet, que no sabe cómo trabajar con zorros (un animal salvaje simplemente morderá un dedo). Y si transferimos la jaula con el gato, no funcionará llamar a pushPet
.
Bivariancia
Uno no puede dejar de mencionar la exótica ausencia de restricciones : la bivariancia. En el siguiente ejemplo, una función puede aceptar cualquier tipo que sea un subtipo o 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

En él puedes transferir la jaula con el animal. Luego verificará que haya una mascota en la jaula; de lo contrario, la colocará dentro de una mascota aleatoria. Y puede transferir, por ejemplo, una jaula con un gato, luego ella simplemente no hará nada.
Generalizaciones
Algunos creen que la variación está relacionada de alguna manera con las generalizaciones. A menudo, porque la varianza se explica a menudo utilizando contenedores genéricos como ejemplo. Sin embargo, en toda la historia todavía no hemos tenido una sola generalización: son clases completamente 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 }
Esto se hizo para mostrar que los problemas de varianza no están relacionados con las generalizaciones. Las generalizaciones son necesarias solo para reducir copiar y pegar. Por ejemplo, el código anterior se puede reescribir mediante una simple generalización:
class Cage<Animal> { content : Animal }
Y ahora puede crear instancias de cualquier celda:
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>()
Declaración de limitaciones
Tenga en cuenta que las firmas de las cuatro funciones enumeradas anteriormente son exactamente las mismas:
( cage : PetCage )=> void
Es decir, tal descripción de los parámetros aceptados de la función no tiene integridad; no se puede decir de ella que se pueda transferir a la función. Bueno, a menos que se vea claramente que definitivamente no vale la pena pasar la jaula con el zorro.
Por lo tanto, en los idiomas modernos hay un medio para indicar explícitamente qué tipo de restricciones tiene un parámetro. Por ejemplo, los modificadores de entrada y salida 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
Desafortunadamente, en C # para cada variante de modificadores es necesario comenzar en una interfaz separada. Además, según tengo entendido, la bivariancia en C # es generalmente inexpresable.
Parámetros de salida
Las funciones no solo pueden aceptar, sino también devolver valores. En general, el valor de retorno puede no ser uno. Como ejemplo, tome la función de tomar una jaula con una mascota y devolver dos mascotas.
function getPets( input : PetCage ) : [ Pet , Pet ] { return [ input.content , new Cat ] }
Tal función es equivalente a una función que toma, además de un parámetro de entrada, dos salidas más.
function getPets( input : PetCage , output1 : PetCage , output2 : PetCage ) : void { output1.content = input.content output2.content = new Cat }
El código externo asigna memoria adicional en la pila para que la función coloque todo lo que quiere devolver. Y al finalizar, el código de llamada ya podrá usar estos contenedores para sus propios fines.

De la equivalencia de estas dos funciones se deduce que los valores devueltos por la función, en contraste con los parámetros, son siempre contrariantes al tipo de salida especificado. Para una función puede escribirles, pero no puede leer de ellos.
Métodos de objeto
Los métodos de objeto son funciones que toman un puntero adicional a un objeto como parámetro implícito. Es decir, las siguientes dos funciones son 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 }
Sin embargo, es importante tener en cuenta que un método, a diferencia de una función regular, también es miembro de la clase, que es una extensión del tipo. Esto lleva al hecho de que aparece una restricción de supertipo adicional para las funciones que llaman a este método:
function fillPetCage( cage : PetCage ) { cage.pushPet() }

No podemos pasarle tal pushPet
sobre donde el método pushPet
aún no pushPet
definido. Esto es similar al caso de la invariancia en que hay una restricción tanto desde abajo como desde arriba. Sin embargo, la ubicación del método pushPet
puede ser mayor en jerarquía. Y ahí es donde estará la limitación de sobretipo.
Principio de sustitución de Barbara Lisk (LSP)
Muchas personas piensan que la relación de un subtipo a un subtipo se determina no sobre la base de los métodos mencionados anteriormente de reducir y expandir el tipo, sino más bien por la posibilidad de sustituir el subtipo en cualquier lugar de uso del supertipo. Aparentemente, la razón de este error está precisamente en el LSP. Sin embargo, leamos detenidamente la definición de este principio, prestando atención a lo que es primario y secundario:
Las funciones que usan el tipo base deberían poder usar subtipos del tipo base sin saberlo y sin violar la corrección del programa.
Para los objetos inmutables (incluidos los que no hacen referencia a mutable), este principio se lleva a cabo automáticamente, ya que no hay lugar para tomar la restricción de subtipo.
Con los mutables, es cada vez más difícil, ya que las siguientes dos situaciones son mutuamente excluyentes para el principio LSP:
- La clase
A
tiene una subclase de B
, donde el campo B::foo
es un subtipo de A::foo
. - La clase
A
tiene un método que puede cambiar el campo A::foo
.
En consecuencia, solo quedan tres formas:
- Evita que los objetos hereden reduzcan sus tipos de campo. Pero luego puedes meter un elefante en la jaula para un gato.
- Guiado no por LSP, sino por la variabilidad de cada parámetro de cada función por separado. Pero luego tienes que pensar mucho y explicarle al compilador dónde están las restricciones de tipo.
- Escupe todo y ve a
el monasterio programación funcional, donde todos los objetos son inmutables, lo que significa que sus parámetros de aceptación son covariantes para el tipo declarado.
TypeScript
En el script de tiempo, la lógica es simple: todos los parámetros de la función se consideran covariantes (lo cual no es cierto), y los valores de retorno se consideran contravariantes (lo cual es cierto). Anteriormente se demostró que los parámetros de una función pueden tener absolutamente cualquier variación, dependiendo de lo que haga esta función con estos parámetros. Por lo tanto, estos son los siguientes 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 este problema, debe ayudar al compilador con un código bastante no 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 :-)
Probar en línea
Flowjs
FlowJS tiene un sistema de tipos más avanzado. En particular, en la descripción del tipo es posible indicar su variabilidad para parámetros generalizados y para campos de objeto. En nuestro ejemplo de celda, se ve más o menos así:
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 :-)
Probar en línea
La bivariancia aquí es inexpresable. Desafortunadamente, no pude encontrar una manera de establecer la varianza de manera más conveniente sin describir explícitamente los tipos de todos los campos. Por ejemplo, algo como esto:
function pushPet( cage: Contra< Cage<Pet> , 'content' > ): void { const Pet = Number((0: any)) > .5 ? Cat : Dog cage.content = new Pet }
Do sostenido
C # fue diseñado originalmente sin ninguna comprensión de la variación. Sin embargo, más adelante se agregaron modificadores de parámetros, lo que permitió al compilador verificar correctamente los tipos de argumentos pasados. Desafortunadamente, usar estos modificadores nuevamente no es muy 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 :-) } }
Probar en línea
Java
Para Java, la capacidad de cambiar las variaciones se agregó bastante tarde y solo para los parámetros generalizados, que aparecieron relativamente recientemente. Si el parámetro no está generalizado, entonces 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 );
Probar en línea
C ++
C ++, gracias a su poderoso sistema de plantillas, puede expresar varias variaciones, pero por supuesto hay mucho 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; }
Probar en línea
D
D no tiene ningún medio sensato de indicar explícitamente la varianza, pero sabe cómo inferir tipos en función de su 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();
Probar en línea
Epílogo
Eso es todo por ahora. Espero que el material presentado lo haya ayudado a comprender mejor las restricciones sobre los tipos y cómo se implementan en diferentes idiomas. En algún lugar mejor, en algún lugar peor, en algún lugar de ninguna manera, pero en general, más o menos. Tal vez sea usted quien desarrolle el lenguaje en el que todo esto se implementará de manera conveniente y segura. Mientras tanto, únete a nuestro chat de telegramas, donde a veces discutimos los conceptos teóricos de los lenguajes de programación .