Clases de tipo frente a interfaces de objeto

33

No creo entender las clases de tipos. Leí en alguna parte que pensar que las clases de tipos son "interfaces" (de OO) que implementa un tipo es incorrecto y engañoso. El problema es que tengo problemas para verlos como algo diferente y cómo eso está mal.

Por ejemplo, si tengo una clase de tipo (en sintaxis de Haskell)

class Functor f where
  fmap :: (a -> b) -> f a -> f b

¿Cómo es eso diferente de la interfaz [1] (en la sintaxis de Java)

interface Functor<A> {
  <B> Functor<B> fmap(Function<B, A> fn)
}

interface Function<Return, Argument> {
  Return apply(Argument arg);
}

Una posible diferencia que se me ocurre es que la implementación de la clase de tipo utilizada en una determinada invocación no se especifica sino que se determina desde el entorno, por ejemplo, examinando los módulos disponibles para una implementación para este tipo. Parece ser un artefacto de implementación que podría abordarse en un lenguaje OO; como el compilador (o tiempo de ejecución) podría buscar un wrapper / extender / monkey-patcher que exponga la interfaz necesaria en el tipo.

¿Qué me estoy perdiendo?

[1] Tenga en cuenta que el f aargumento se ha eliminado fmapya que dado que es un lenguaje OO, estaría llamando a este método en un objeto. Esta interfaz asume que el f aargumento ha sido arreglado.

oconnor0
fuente

Respuestas:

46

En su forma básica, las clases de tipos son algo similares a las interfaces de objetos. Sin embargo, en muchos aspectos, son mucho más generales.

  1. El envío está en tipos, no en valores. No se requiere ningún valor para realizarlo. Por ejemplo, es posible realizar despachos en el tipo de función resultante, como con la Readclase de Haskell :

    class Read a where
      readsPrec :: Int -> String -> [(a, String)]
      ...
    

    Tal despacho es claramente imposible en OO convencional.

  2. Las clases de tipos se extienden naturalmente a despachos múltiples, simplemente al proporcionar múltiples parámetros:

    class Mul a b c where
      (*) :: a -> b -> c
    
    instance Mul Int Int Int where ...
    instance Mul Int Vec Vec where ...
    instance Mul Vec Vec Int where ...
    
  3. Las definiciones de instancia son independientes de las definiciones de clase y tipo, lo que las hace más modulares. Un tipo T del módulo A se puede adaptar a una clase C desde el módulo M2 sin modificar la definición de ninguno de ellos, simplemente proporcionando una instancia en el módulo M3. En OO, esto requiere características de lenguaje más esotéricas (y menos OO-ish) como los métodos de extensión.

  4. Las clases de tipos se basan en polimorfismos paramétricos, no en subtipos. Eso permite una escritura más precisa. Considere por ejemplo

    pick :: Enum a => a -> a -> a
    pick x y = if fromEnum x == 0 then y else x
    

    vs.

    pick(x : Enum, y : Enum) : Enum = if x.fromEnum() == 0 then y else x
    

    En el primer caso, la aplicación pick '\0' 'x'tiene tipo Char, mientras que en el último caso, todo lo que sabrías sobre el resultado sería que es una enumeración. (Esta es también la razón por la cual la mayoría de los lenguajes OO en estos días integran polimorfismo paramétrico).

  5. Muy relacionado está el tema de los métodos binarios. Son completamente naturales con clases de tipo:

    class Ord a where
      (<) :: a -> a -> Bool
      ...
    
    min :: Ord a => a -> a -> a
    min x y = if x < y then x else y
    

    Con el subtipo solo, la Ordinterfaz es imposible de expresar. Necesita una forma más recursiva o polimorfismo paramétrica llamada "cuantificación limitada por F" para hacerlo con precisión. Compare Java Comparabley su uso:

    interface Comparable<T> {
      int compareTo(T y);
    };
    
    <T extends Comparable<T>> T min(T x, T y) {
      if (x.compareTo(y) < 0)
        return x;
      else
        return y;
    }
    

Por otro lado, las interfaces basadas en List<C>subtipos permiten naturalmente la formación de colecciones heterogéneas, por ejemplo, una lista de tipos puede contener miembros que tienen varios subtipos de C(aunque no es posible recuperar su tipo exacto, excepto mediante el uso de descargas). Para hacer lo mismo en función de las clases de tipos, necesita tipos existenciales como una característica adicional.

Andreas Rossberg
fuente
Ah, eso tiene mucho sentido. El despacho basado en tipo vs valor es probablemente la gran cosa en la que no estaba pensando correctamente. El tema del polimorfismo paramétrico y la tipificación más específica tiene sentido. Acababa de juntar eso y las interfaces basadas en subtipos en mi mente (aparentemente pienso en Java: - /).
oconnor0
¿Son los tipos existenciales algo parecido a crear subtipos Csin la presencia de abatidos?
oconnor0
Mas o menos. Son un medio para hacer un tipo abstracto, es decir, ocultar su representación. En Haskell, si también le asigna restricciones de clase, aún puede usar métodos de esas clases en él, pero nada más. - Los downcasts son en realidad una característica que está separada tanto del subtipo como de la cuantificación existencial y que, en principio, también podrían agregarse en presencia de este último. Así como hay idiomas OO que no lo proporcionan.
Andreas Rossberg
PD: FWIW, los tipos de comodines en Java son tipos existenciales, aunque bastante limitados y ad-hoc (lo que puede ser parte de la razón por la cual son algo confusos).
Andreas Rossberg
1
@didierc, eso estaría restringido a casos que pueden resolverse completamente de forma estática. Además, para hacer coincidir las clases de tipos se requeriría una forma de resolución de sobrecarga que sea capaz de distinguir basándose únicamente en el tipo de retorno (consulte el elemento 1).
Andreas Rossberg
6

Además de la excelente respuesta de Andreas, tenga en cuenta que las clases de tipos están destinadas a simplificar la sobrecarga , lo que afecta el espacio de nombres global. No hay sobrecarga en Haskell que no sea lo que puede obtener a través de clases de tipo. Por el contrario, cuando utiliza interfaces de objetos, solo aquellas funciones que se declaran que toman argumentos de esa interfaz deberán preocuparse por los nombres de las funciones en esa interfaz. Entonces, las interfaces proporcionan espacios de nombres locales.

Por ejemplo, tenía fmapen una interfaz de objeto llamada "Functor". Estaría perfectamente bien tener otro fmapen otra interfaz, diga "Structor". Cada objeto (o clase) puede elegir qué interfaz quiere implementar. Por el contrario, en Haskell, solo puede tener uno fmapdentro de un contexto particular. No puede importar las clases de tipo Functor y Structor en el mismo contexto.

Las interfaces de objetos son más similares a las firmas ML estándar que a las clases de tipos.

Uday Reddy
fuente
y, sin embargo, parece haber una estrecha relación entre los módulos ML y las clases de tipo Haskell. cse.unsw.edu.au/~chak/papers/DHC07.html
Steven Shaw
1

En su ejemplo concreto (con la clase de tipo Functor), las implementaciones de Haskell y Java se comportan de manera diferente. Imagine que tiene el tipo de datos Quizás y quiere que sea Functor (es un tipo de datos muy popular en Haskell, que también puede implementar fácilmente en Java). En su ejemplo de Java, hará que la clase Quizás implemente su interfaz Functor. Entonces puede escribir lo siguiente (solo pseudocódigo porque solo tengo fondo de C #):

Maybe<Int> val = new Maybe<Int>(5);
Functor<Int> res = val.fmap(someFunctionHere);

Tenga en cuenta que restiene el tipo Functor, no Quizás. Esto hace que la implementación de Java sea casi inutilizable porque pierde información de tipo concreta y necesita hacer conversiones. (al menos no pude escribir tal implementación donde los tipos todavía estaban presentes). Con las clases de tipo Haskell obtendrás tal vez Int como resultado.

struhtanov
fuente
Creo que este problema se debe a que Java no admite tipos de tipo superior y no está relacionado con la discusión de las clases de tipos Vs de las interfaces. Si Java tenía tipos más altos, entonces fmap podría devolver a Maybe<Int>.
dcastro