Unión, intersección y tipos enumerados

Este es la octava parada en el tour de Ceylon. En la parada anterior aprendimos acerca de los alias y la inferencia de tipos. Continuemos explorando el sistema de tipos de Ceylon.

En este capitulo, vamos a discutir los cercanos tópicos de tipos unión e intersección y los tipos enumerados. En esta área, el sistema de tipos de Ceylon trabaja un poco distinto a otros lenguajes de tipos estáticos.

Reduciendo el tipo de un objeto referencia

En cualquier lenguaje con subtipos hay ocasiones en las que necesitas realizar reducción con conversiones. En demasiados lenguajes de tipos estáticos, esta es la segunda parte del proceso. Por ejemplo, en Java primero probamos el tipo del objeto usando el operador instanceof, y entonces intentar reducirlo usando un typecast estilo C. Esto es un poco curioso, desde que estos no son virtualmente buenos usos para instanceof que no involucra una conversión inmediata al tipo que fue probado, y el cambio de tipo sin probar el tipo es peligrosamente un tipo no seguro.

Como puedes imaginar, Ceylon, con su énfasis sobre los tipos estáticos, hace las cosas diferente, Ceylon no tiene un cambio de tipos al estilo de C. En ves de ellos, debemos de probar y reducir el tipo de la referencia de un objeto en un solo paso, usando la construcción especial if (is ...). Esta construcción es muy parecida a if (exists ...) y if (nonempty ...), las cuales conocimos tempranamente.

void PrintIfPrintable(Object obj) {
    if (is Printable obj){
        obj.printObj();
    }
}

También hay una construcción especial if (!is ...) que es practica algunas veces.

La declaración switch puede ser usada de una manera similar:

void switchingPrint(Object obj) {
    switch(obj)
    case (is Hello) {
        obj.printMsg();
    }
    case (is Person) {
        print(obj.firstName);
    }
    else {
        print(obj.string);
    }
}

Estas construcciones nos protegen de escribir inadvertidamente codigo que pueda causar una ClassCastException en Java, justo como if (exists ...) nos protege de escribir codigo que pueda causar NullPointerException.

La construcción if (is ...) actualmente reduce el código a un tipo intersección.

Tipos intersección

Una expresión es asignable a un tipo intersección, escrito X&Y, si este es asignable a ambos X&Y. Por ejemplo, desde que Tuple es un subtipo de Iterable<Nothing> y de Correspondence, la tupla tipo [String,String] es también un subtipo de la intersección.

Iterable<String>&Correspondence<Integer,String>. El supertipo de un tipo intersección incluye todos los supertipos de cada tipo interceptado.

Entonces, el siguiente código esta bien tipado:

Iterable<String>&Correspondece<Integer,String> string =
        ["Hello", "world"];
String? str = strings.get(0);
Integer size = strings.size;

Ahora considerar este código, para ver el efecto de if (is ...):

Iterable<String> strings = ["hello","world"];
if (is Correspondence<Integer,String> strings) {
    //Aqui string tiene el tipo
    //Iterables<String> & Correspondence<Integer,String>
    String? str = strings.get(0);
    Integer size = strings.size();
}

Dentro del cuerpo de la construcción if, strings tiene el tipo Iterable<String>&Correspondence<Integer,String>, así podemos llamar las operaciones de ambos, Iterable y Correspondence.

Tipos unión

Una expresión es asignable a un tipo unión, escrito X|Y, si este es asignable a cualquiera X o Y. El tipo X|Y es siempre un supertipo de ambos X y Y. El siguiente código esta bien tipado:

void printType(String|Integer|Float val) { ... }

printType("hello");
printType(69);
printType(-1.0);

Pero, ¿qué operaciones tiene un tipo como String|Integer|Float? ¿Cuales son sus supertipos? Bueno, la respuesta es muy intuitiva: T es un supertipo de X|Y si y solo si es un supertipo de X y de Y. El compilador de Ceylon determina esto automáticamente. Así el siguiente código esta bien tipado.

Integer|Float x= -1;
Number num = x; // number es un supertipo de ambos Integer y Float
String|Integer|Float val = x; // String|Integer|Float es un supertipo
                              // de Integer|Float
Object obj = val; //Object es un supertipo de String, Integer y Float.

Sin embargo, el siguiente código no esta bien tipado, desde que Number no es un supertipo of String.

String|Integer|Float x = -1;
Number num = x; //compile error:  String is not a subtype of Number

Por supuesto, es muy común reducir una expresión de tipo unión usando una declaración switch. Usualmente el compilador de Ceylon nos fuerza a escribir una clausula else en un switch para recordarnos que puede haber casos adicionales que no han sido manejados. Pero si agotamos todos los casos de un tipo unión, el compilador nos permitirá dejar fuera la clausula else.

void printType(String|Integer|Float val) {
    switch (val)
    case (is String) { print("String: ``val``"); }
    case (is Integer) { print("Integer: ``val``"); }
    case (is Float) { print("Float: ``val``"); }
}

Un tipo unión es una especia de tipo enumerado.

Tipos Enumerado

Algunas veces es útil que este disponible realizar el mismo tipo de cosas con subtipos de una clase o interfaz. Primero, necesitamos explícitamente enumerar los subtipos de el tipo usando la clausula of:

abstract class Point()
        of Polar | Cartesian {
    //...
}

(Esto crea un punto en la versión de Ceylon de lo que llaman en la comunidad de programación funcional un tipo “algebraico” o “sum”.

Ahora el compilador no nos permitirá declarar subclases adicionales a Point, así el tipo unión Polar|Cartesian es exactamente el mismo tipo como Point. Ademas podemos escribir declaraciones switch sin la clausula else:

void printPoint(Point point) {
    switch (point)
    case (is Polar){
        print("r = " + point.radius.string);
        print("theta = " + point.angle.string);
    }
    case (is Cartesian) {
        print("x = " + point.x.string);
        print("y = " + point.y.string);
    }
}

Ahora, es usualmente considerado una mala practica escribir largas declaraciones de switch que manejen todos los subtipos de un tipo. Esto crea un código no extensibles. Agregar una nueva subclase a Point significa romper todas las declaraciones que agotaron los subtipos. En programación orientada a objetos, tratamos de refactorizar construcciones como esta usando un método abstracto de la superclase que es sobrescrita apropiadamente por subclases.

Sin embargo, esta es una clase de problema donde este tipo de refactorisación no es apropiado. En muchos lenguajes de programación orientados a objetos, estos problemas son usualmente resueltos usando el patrón “visitor”.

Visitors

Consideremos las 3 siguientes implementaciones visitor:

abstract class Node() {
    shared formal void accept(Visitor v);
}

class Leaf(shared object element)
        extends Node() {
    accept (Visitor v) => v.visitLeaf(this);
}

class Branch(shared Node left, shared Node rigth)
        extends node() {
    accept(Visitor v) => v.visitBranch(this);
}

interface Visitor {
    shared formal void visitLeaf(Leaf l);
    shared formal void visitBranch(Branch b);
}

Podemos crear un método que imprima las tres implementando la interfaz Visitor:

void printTree(Node node) {
    object printVisitor satisfies Visitor {
        shared actual void visitLeaf(Leaf leaf) {
            print("Found a leaf: ``leaf.element``!");
        }
        shared actual void visitBranch(Branch branch){
            branch.left.accept(this);
            branch.rigth.accept(this);
        }
    }
    node.accept(printVisitor);
}

Note que el código de printVisitor luce como la delaración de switch. El deberá explícitamente enumerar todos los subtipos de Node. Estará “roto” si agregamos un nuevo subtipo de Node a la interfaz Visitor. Esto es correcto, y es el comportamiento deseado; “roto” significa que el compilador nos permitirá conocer que tenemos que actualizar nuestro código para manejar un nuevo subtipo.

En Ceylon podemos lograr el mismo efecto, con menos verbosidad, enumerando los subtipos de Node en su definición y usando un switch:

abstract class Node() of Leaf | Branch {}

class Leaf(shared Object element)
        extends Node() {}

class Branch(shared Node left, shared Node  right)
        extends Node() {}

Nuestro método print() es ahora mucho mas simple, pero aun tiene el mismo comportamiento deseado de “romperse” cuando un nuevo subtipo de Node es agregado.

void printTree(Node node) {
    switch (node)
    case (is Leaf) {
        print("Found a leaf: ``node.element``!");
    }
    case (is Branch) {
        printTree(node.left);
        printTree(node.right);
    }
}

Interfaz enumeradas

Ordinariamente, Ceylon no nos permitirá usar tipos interfaz como un case de switch. Si File,Directory, y Link son interfaces, no podemos escribir tal cual:

File|Directory|Link resources = ...;
switch (resources)
case (is File) { ... }
case (is Directory) { ... } //compile error: cases are not disjoint
case (is Link) { ... } //compile error: cases are not disjoint

El problema es que los casos no están disjuntos. Podemos tener una clase que satisfaga ambos File y Directory y entonces no saber que rama ejecutar.

(En todos nuestros ejemplos anteriores, nuestros case referenciaban a tipos que eran probablemente disjuntos, debido a que eran clases, que soportan únicamente herencia simple.)

Hay una solución, a pesar de todo. Cuando una interfaz tiene subtipos enumerados, el compilador fuerza a estos subtipos a estar disjuntos. Así que si definimos la siguiente interfaz enumerada:

interface Resource of File|Directory|Link { ... }

Entonces la siguiente declaración es un error:

class DirectoryFile()
        satisfies File&Directory {} //compile error: File and Directory are
                                    // disjoint types

Ahora esto es aceptado por el compilador:

Resource resource = ...;
switch (resource)
case (is File) { ... }
case (is Directory) { ... }
case (is Link) { ... }

El compilador es muy inteligente cuando razón acerca de las desuniones y agotamiento. Por ejemplo, esto es aceptable:

Resources resource = ...;
switch (resource)
case (is File|Directory) { ... }
case (is Link) { ... }

Como este, asumiendo la anterior declaración de Resource:

File|Link resource = ... ;
switch (resource)
case (is File) { ... }
case (is Link) { ... }

Si estas interesando en conocer mas acerca de esto, lee esto

Instancias enumeradas

Ceylon no tiene algo exactamente como la declaración enum de Java. Pero emular el efecto usando la clausula of.

abstract class Suit(String name)
        of hearts|diamons|clubs|spades {}

object hearts extends Suit("hearts") {}
object diamonds extends Suit("diamonds") {}
object clubs extends Suit("clubs") {}
object spades extends Suit("spades") {}

Es permitido usar los nombres de las declaraciones object en la clausula of.

Ahora podemos agotar todos los casos de Suit en un switch:

void printSuit(Suit suit) {
    switch (suit)
    case (hearts) { print("Heartzes"); }
    case (diamonds) { print("Diamondzes"); }
    case (clubs) { print("Clidubs"); }
    case (spades) { print("Spidades"); }
}

Note que estos casos son casos de valor, no casos de tipo case (is ...). Ellos no necesitan reducir el tipo Suit

Así es esto es un poco mas verboso que el enum de Java, pero también es algo mas flexible.

Para mas ejemplos prácticos, revisa la definición de Boolean y Comparison en el modulo del lenguaje.

Aun hay mas

En la siguiente estación veremos el sistema genérico de tipos en profundidad.