Generics

Esta es la novena parte de el tour de Ceylon. El la parada anterior cubrimos os tipos unión, intersección y enumerados. En esta parte vamos a ver acerca de los tipos genéricos.

Herencia y subtipos son poderosas herramientas para la abstracción sobre los tipos. Pero esta herramienta tiene sus limitantes. No nos ayuda a expresar contenedores de tipos genéricos como colecciones. Para este problema necesitamos tipos parametrizados. Ya hemos visto muchos tipos parametrizados - por ejemplo, iterables, secuencias y tuplas - pero ahora vamos a explorar esto a detalle.

Definiendo tipos genéricos

Programar con tipos genéricos es una de las partes mas difíciles en Java. Esto es aun verdad, en cierta parte en Ceylon. Pero debido a que el lenguaje Ceylon y el SDK fueron diseñados para generics desde cero, Ceylon esta diseñado para aliviar demasiados aspectos dolorosos de modelo de Java.

Justo como en Java, únicamente tipos y métodos puede declarar parámetros de tipo. También como en Java, los parámetros de tipo son antes que los parámetros ordinarios, y encerrados entre corchetes angulares.

shared interface Iterator<out Element> { ... }
shared class Singleton<out Element>(Element element)
        extends Object()
        satisfies [Element+]
        given Element satisfies Object { ... }
shared Value sum<Value>({Value+} values)
        given Value satisfies Summable<Value> { ... }
shared <key->Item>[] zip<Key,Item>({Key*} keys, {Item*} items)
        given Key satisifes Object
        given Item satisfies Object { ... }

Como puedes ver, la convención en Ceylon es usar nombres significativos para los parámetros de tipo (en otros lenguajes de programación la convención es usar letras).

Un parámetro de tipo puede tener un parámetro por defecto.

shared interface Iterable<out Element, out Absent=Null> ...

Argumentos de tipo

A diferencia de Java, siempre necesitamos especificar el parámetro tipo en la declaración de tipo (No hay tipos raw en Ceylon. El siguiente código no compilara:

Iterator it = ...; //error: missing type agmuente to parameter Element of Iterable

En vez de ello, necesitamos proveer el argumento tipo como esto:

Iterator<String> it = ...;

Por otro lado, no necesitamos explícitamente especificar el tipo de los argumentos en muchas invocaciones de métodos o instanciaciones de clases. No necesitamos usualmente tener que escribir esto:

Array<String> strings = array<String> { "Hello", "World" };
{<Integer->String>*} things = entries<String>(strings);

En vez de ello, es posible inferir los tipos de los argumentos desde argumentos ordinarios.

value string = array { "Hello", "World" }; //tipo: Array<String>
value things = entries(strings); //tipo: Iterable<Entry<Integer,String>>

El algoritmo de inferencia de tipos generic esta ligeramente involucrado, así que deberás referirte a las especificaciones del lenguaje para una definición completa. Pero esencialmente lo que pasa es que Ceylon infiere el tipo de un argumento combinando los tipos de los correspondientes argumentos usando unión en el caso de un parámetro de tipo covariante o intersección en el caso de un parámetro de tipo contravariante.

value points = array { Polar(pi/4, 0.5), Cartesian(-1.0, 2.5) }; // tipo: Array<Polar|Cartesian>
value entries = entries(points); //tipo: Entries<Integer,Polar|Cartesian>

Si un parámetro de tipo tiene un argumentos por defecto, esta permitido dejarlo fuera cuando subministremos la lista de argumentos de tipo. Entonces Iterable<String> significa Iterable<String,Null>.

Covarianza y contravarianza

Ceylon elimina una de la partes de los generics de Java que hacían realmente difíciles las cosas: tipos wildcard. Los tipos wildcard fueron la solución al problema de covarianza en un sistema de tipos genérico en Java. Conozcamos la idea de covarianza, y entonces podremos ver como trabaja la covarianza en Ceylon.

Esto comienza con la intuitiva expectación de que una colección de geeks es una colección de Person. Esta es una intuición razonable, pero si la colección mutan, esto cambiara a ser incorrecto. Consideremos la siguiente posible definición de Collection:

interface Collection<Element> {
    shared formal Iterator<Element> iterator();
    shared formal void add(Element x);

Y vamos a suponer que Geek es un subtipo de Person. La expectación intuitiva es que el siguiente código deberá de funcionar:

Collection<Geek> geeks = ... ;
Collection<Person> people = geeks; //compile error
for (person in people) { ... }

Este código es, francamente, perfectamente razonable tomado enserio. Aun en ambos Ceylon y Java, este código resulta en un error en tiempo de compilación en la segunda linea, donde Collection<Geek> es asignado a una collection<Person>. ¿Por qué? Bueno, debido a que si permitimos la asignación, el siguiente código deberá también compilar:

Collection<Geek> geeks = ...;
Collection<Person> people = geeks; //compile error
people.add( Person("Fonzie") );

¡No podemos permitir que el código de Fonzie sea un Geek!

En otras palabras, diremos que Collection es invariante en Element. O, cuando no estemos tratando de impresiones gente con terminología confusa, podremos decir que Collection producen ambos a través del métodos iterator() y consume a través del método add() el tipo Element.

Aquí es donde Java queda fuera y se dirige a abajo por el agujero del conejo, exitosamente usando wildcards para disputar un tipo covariante o contravariante de un tipo invariante, pero también exitosamente dejando confusos a todos. No vamos a seguir a Java hasta el fondo del agujero.

En vez, vamos a refactorizar Collection en una interfaz puramente Producer y en una puramente Consumer:

interface Producer<out Output> {
    shared formal Iterator<Output> iterator();
}
interface Consumer<in Input> {
    shared formal void add(Input x);
}

Note que hemos anotado los parámetros de tipo de estas interfaces.

  • La anotación out especifica que Producer es covariante en Output Esto es que produce una instancia de Output, pero nunca consume instancias de Output.
  • El anotación in especifica que Consumer es una contravariante de Input. Esto es que consume una instancia Input, pero nunca produce una instancia de Input.

El compilador de Ceylon valida el esquemas de la declaración del tipo y se asegura que la anotaciones de varianza estén satisfechas. Si tratas de declarar un método add() en Producer dara como resultado un error de compilación. Si tratas de declarar un método iterate() en Consumers obtendrás en error de compilación similar.

Ahora, veamos que sacamos de esto:

  • Desde que Producer es covariante en su parámetro de tipo output, y desde que Geek es un subtipo de Person, Ceylon nos permite asignar Producer<Geek> a Producer<Person>.
  • Además, desde que Consumer es una contravariante en su parámetro de tipo Input, y desde que geek es un subtipo de Person, Ceylon nos permite asignar Consumer<Person> a Consumer<Geek>.

Y así poder definir nuestra interfaz Collection como una mezcla de Producer con Consumer.

interface Collection<Element>
        satisfies Producer<Element> & Consumer<Element> {}

Note que Collection permanece invariante en Element. Si tratamos de agregar una anotación de varianza a Element en Collection dará como resultado un error de compilación, debido a que la anotación deberá contradecir la anotación de varianza de cualquiera Producer o Consumer.

Ahora el siguiente código finalmente compila:

Collection<Geek> geeks = ...;
Producer<Person> people = geeks;
for (person in people) { ... }

El cual coincide con nuestra intuición original.

El siguiente código también compila:

Collection<Person> people = ...;
Consumer<Geek> geekConsumer = people;
geekConsumer.add( Geek("James") );

Que es también intuitivamente correcto - “James” deberá ser una persona.

Hay dos elementos adicionales a la definición de covarianza y contravariaza:

  • Producer<Anything> es un supertiopo de Producer<T> para cualquier tipo T, y
  • Consumer<Nothing> es un supertipo de Consumer<T> para cualquier tipo T.

Estas invariantes pueden ser útiles si necesitas abstraer todos los Producers o todos los Consumers. (Nota, sin embargo, si Producer declaro restricciones obligatorias para tipos en Output, entonces Producer<Anything> no deberá ser un tipo legal.)

No gastaras mucho tiempo escribiendo tus propias colecciones, desde que Ceylon SDK deberá próximamente tener un poderoso framework para construir colecciones. Pero aun deberás apreciar el enfoque de Ceylon a la convarianza como un usuario de los tipos colección incorporados.

Covarianza y contravarianza con unión e intersección

Hay un conjunto de relaciones interesantes que surge cuando introducimos los tipos unión e intersección en la pintura.

Primero, consideremos un tipo covariante como List<Element>. Entonces para cualquier tipo X y Y:

  • List<X>|List<Y> es un subtipo de List<X|Y>, y
  • List<X>&List<Y> es un supertipo de List<X&Y>.

Después, consideremos un tipo contravariante como Consumer<Element>. Entonces para cualquier tipo X y Y:

  • Consumer<X>|Consumer<Y> es un subtipo de Consumer<X&Y>, y
  • Consumer<X>&Consumer<Y> es un supertipo de Consumer<X|Y>.

Esto es valioso volveremos a esta sección mas adelante, y trataremos de desarrollar alguna intuición acerca del por que estas relaciones son correctas y que significan. No gastes tu tiempo en esto por ahora. Tenemos cosas mas importantes que hacer.

Generics y herencia

Considere las siguientes clases:

class LinkedList()
        satisfies List<Object> { ... }

class LinkedStringList()
        extends LinkedList()
        satisfies List<String> { ... }

Este tipo de herencia es ilegal en Java. Una clase no puede heredar el mismo tipo mas de una vez, con diferentes argumentos de tipo. Podemos decir que Java suporta únicamente single instantiation inheretance.

Ceylon es menos restrictivo en este aspecto. El código anterior es perfectamente legal si (y solo si) la interfaz List<Element> es covariante en sus parámetros de tipo Element, que es, declarado como esto:

inteface List<out Element> { ... }

Diremos que Ceylon cuenta con principal instantiation inheritance. Incluso el siguiente código es legal:

interface ListOfSomething satisfies List<Something> { }
interface ListOfSomthingElse satisfies List<SomethingElse> {}
class MyList() satisfies ListOfSomething & ListOfSomethingElse { ... }

Entonces el siguiente código es lagal y bien tipado:

List<Something&SomethingElse> list = MyList()

Por favor hagamos una pausa aquí, y toma tu tiempo para notar que tan ridículamente impresionante es esto. Nosotros nunca mencionamos explícitamente que MyList() fue una List<Something&SomethingElse>. El compilador solo lo dedujo por nosotros.

Note que cuando heredaste el mismo tipo mas de una vez, tu tal vez necesites refinar algunos de sus miembros, en orden para satisfacer todas las firmas heredadas. No te preocupes el compilador te lo notificara y te obligara a hacerlo.

Restricciones en tipos generic

Es muy común, cuando estamos escribiendo un tipo parametrizado, queremos invocar un método o evaluar un atributo a instancias del parámetro de tipo. Por ejemplo, si estamos escribiendo un tipo parametrizado Set<Element>, necesitamos ser capaces de comparar instancias de Element usando == para ver si cierta instancia de Element es contenida en el Set. Desde que == esta definido para expresiones de tipo Object necesitamos alguna manera de asegurar que Element es un subtipo de Object. Este es un ejemplo de una restricción de tipo - de hecho, este es un ejemplo del caso mas común de restricción de tipo, un upper bound.

shared class Set<out Element>(Element* elements)
        given Element satisifes Object {

    ...

    shared Boolean contains(Object obj) {
        if (is Element obj){
            return obj in bucket(obj.hash);
        }
        else {
            return false;
        }

Un argumento de tipo a Element deberá ser un subtipo de Object.

Set<String> set1 = Set("C", "Java", "Ceylon"); //ok
Set<String?> set2 = Set("C", "Java", "Ceylon", null); //compile error

En Ceylon, un parámetro de tipo genérico es considerado un tipo propio, así una restricción de tipo luce mas a una declaración de una clase o interfaz. Esta es otra forma en la que Ceylon es mas regular que otros lenguajes parecidos a C.

En futuras versiones de Ceylon, después de la 1.0, también introduciremos soporte para varios tipos adicionales de restricciones de tipos genéricos. Podrás encontrar mas detalles en las especificaciones del lenguaje.

Tipos genéricos totalmente cosificados

La causa principal de muchos problemas cuando trabajamos con tipos genéricos en Java es el borrado de tipos. Los parámetros y argumentos de tipo son descartados por el compilador y simplemente no están disponibles en tiempo de ejecución. Así el siguiente, perfectamente sensible, fragmento de código no deberá compilar en Java;

if (is List<Person> list) { ... }
if (is Element obj) { ... }

(Donde elemento es un parámetro de tipo genérico.)

El sistema de tipos de Ceylon ha cosificado los argumentos de tipo genérico, Como Java, el compilador de Ceylon lleva acabo limpieza, descartando parámetros de tipo desde el esquema de el tipo genérico. En la plataforma de JavaScript, los tipos son descartados cuando se produce el código de JavaScript. Pero a diferencia de Java, los parámetros de tipo son cosificados(disponibles en tiempo de ejecución). Los tipos son incluso cosificados cuando se ejecutan en una máquina virtual de Java.

Así los fragmentos de código anteriores compilan y funciones como se esperan en ambas plataformas. Una vez hemos terminado de implementar el metamodelo, incluso podrás seras capaz usar reflexión para para descubrir el argumento de tipo de una instancia de un tipo genérico.

Ahora por supuesto, argumentos de tipos genéricos no son revisada para seguridad de tipos a nivel de la máquina virtual cuando se esta ejecutando, pero esto no es estrictamente necesario desde que el compilador desde que el compilarlo ya ha revisado la solidez del código.

Nota de implementación En el release M5 no hemos tenido tiempo de implementar algunas optimizaciones importantes relacionadas a genéricos cosificados. Entonces, tal vez experimentes algunos problemas usando tipos genéricos en este release. No te preocupes que resolveremos estos problemas al tiempo en que lancemos la versión de Ceylon 1.0. (¡Por favor haz nos saber tus experiencias!)

Aun hay mas

Ahora estamos listos para mirar una característica muy importante de Ceylon: modularidad.