Classes de tipo versus interfaces de objeto

33

Acho que não entendo classes de tipo. Eu li em algum lugar que pensar em classes de tipo como "interfaces" (do OO) implementadas por um tipo é errado e enganoso. O problema é que estou tendo problemas para vê-los como algo diferente e como isso está errado.

Por exemplo, se eu tiver uma classe de tipo (na sintaxe Haskell)

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

Como isso é diferente da interface [1] (na sintaxe Java)

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

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

Uma possível diferença em que posso pensar é que a implementação da classe de tipo usada em uma determinada invocação não é especificada, mas sim determinada a partir do ambiente - por exemplo, examinando os módulos disponíveis para uma implementação para esse tipo. Esse parece ser um artefato de implementação que pode ser endereçado em uma linguagem OO; como o compilador (ou o tempo de execução) poderia procurar um wrapper / extensor / monkey-patcher que exponha a interface necessária no tipo.

o que estou perdendo?

[1] Observe que o f aargumento foi removido fmapporque, como é uma linguagem OO, você chamaria esse método em um objeto. Essa interface assume que o f aargumento foi corrigido.

oconnor0
fonte

Respostas:

46

Em sua forma básica, as classes de tipo são um pouco semelhantes às interfaces de objeto. No entanto, em muitos aspectos, eles são muito mais gerais.

  1. A expedição está nos tipos, não nos valores. Nenhum valor é necessário para executá-lo. Por exemplo, é possível fazer despacho no tipo de resultado da função, como na Readclasse de Haskell :

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

    Esse despacho é claramente impossível no OO convencional.

  2. As classes de tipo naturalmente se estendem a vários despachos, simplesmente fornecendo vários 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. As definições de instância são independentes das definições de classe e tipo, o que as torna mais modulares. Um tipo T do módulo A pode ser adaptado para uma classe C do módulo M2 sem modificar a definição de qualquer um, simplesmente fornecendo uma instância no módulo M3. No OO, isso requer recursos de linguagem mais esotéricos (e menos isentos de OO), como métodos de extensão.

  4. As classes de tipo são baseadas no polimorfismo paramétrico, não na subtipagem. Isso permite uma digitação mais precisa. Considere, por exemplo,

    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
    

    No primeiro caso, a aplicação pick '\0' 'x'tem tipo Char, enquanto no último caso, tudo o que você saberia sobre o resultado seria um Enum. (Essa também é a razão pela qual a maioria das linguagens OO atualmente integra polimorfismo paramétrico.)

  5. Intimamente relacionado é a questão dos métodos binários. Eles são completamente naturais com as classes 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
    

    Somente com a subtipagem, Ordé impossível expressar a interface. Você precisa de uma forma mais complicada e recursiva ou polimorfismo paramétrico chamado "quantificação limitada por F" para fazer isso com precisão. Compare o Java Comparablee seu 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 outro lado, interfaces baseadas em subtipagem naturalmente permitem a formação de coleções heterogêneas, por exemplo, uma lista de tipos List<C>pode conter membros com vários subtipos de C(embora não seja possível recuperar seu tipo exato, exceto usando downcasts). Para fazer o mesmo com base nas classes de tipos, você precisa de tipos existenciais como um recurso adicional.

Andreas Rossberg
fonte
Ah, isso faz muito sentido. O envio baseado em valor versus tipo é provavelmente a grande coisa na qual eu não estava pensando corretamente. A questão do polimorfismo paramétrico e tipagem mais específica faz sentido. Eu tinha acabado de juntar essas interfaces baseadas em subtipagem em minha mente (aparentemente eu penso em Java: - /).
precisa saber é o seguinte
Os tipos existenciais são algo semelhante à criação de subtipos Csem a presença de downcasts?
precisa saber é o seguinte
Mais ou menos. Eles são um meio de tornar um tipo abstrato, ou seja, ocultar sua representação. No Haskell, se você também anexar restrições de classe a ele, ainda poderá usar métodos dessas classes, mas nada mais. - Os downcasts são, na verdade, um recurso separado da subtipagem e da quantificação existencial, e podem, em princípio, ser adicionados na presença destes últimos. Assim como existem idiomas OO que não fornecem.
Andreas Rossberg
PS: FWIW, tipos curinga em Java são tipos existenciais, embora bastante limitados e ad-hoc (que podem ser parte do motivo pelo qual são um pouco confusos).
Andreas Rossberg
1
@didierc, isso seria restrito a casos que podem ser totalmente resolvidos estaticamente. Além disso, para corresponder às classes de tipos, seria necessária uma forma de resolução de sobrecarga capaz de distinguir com base apenas no tipo de retorno (consulte o item 1).
Andreas Rossberg
6

Além da excelente resposta de Andreas, lembre-se de que as classes de tipo visam otimizar a sobrecarga , o que afeta o espaço de nome global. Não há sobrecarga no Haskell além do que você pode obter através das classes de tipo. Por outro lado, quando você usa interfaces de objeto, apenas as funções declaradas para receber argumentos dessa interface precisam se preocupar com os nomes das funções nessa interface. Portanto, as interfaces fornecem espaços de nomes locais.

Por exemplo, você tinha fmapem uma interface de objeto chamada "Functor". Seria perfeitamente bom ter outro fmapem outra interface, diga "Structor". Cada objeto (ou classe) pode escolher qual interface ele deseja implementar. Por outro lado, em Haskell, você pode ter apenas um fmapem um contexto específico. Você não pode importar as classes do tipo Functor e Structor para o mesmo contexto.

As interfaces de objeto são mais semelhantes às assinaturas ML padrão do que às classes de tipo.

Uday Reddy
fonte
e, no entanto, parece haver uma estreita relação entre os módulos ML e as classes do tipo Haskell. cse.unsw.edu.au/~chak/papers/DHC07.html
Steven Shaw
1

No seu exemplo concreto (com a classe de tipo Functor), as implementações Haskell e Java se comportam de maneira diferente. Imagine que você tenha Talvez o tipo de dados e queira que seja Functor (é realmente um tipo de dados popular no Haskell, que também pode ser facilmente implementado em Java). No seu exemplo de Java, você fará com que a classe Maybe implemente sua interface do Functor. Então, você pode escrever o seguinte (apenas pseudo-código, porque eu tenho apenas c # background):

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

Observe que restem o tipo Functor, não Talvez. Portanto, isso torna a implementação do Java quase inutilizável, porque você perde informações concretas e precisa fazer projeções. (pelo menos falhei ao escrever uma implementação em que tipos ainda estavam presentes). Com as classes do tipo Haskell, você obterá Talvez Int como resultado.

struhtanov
fonte
Eu acho que esse problema ocorre porque o Java não suporta tipos mais altos e não está relacionado à discussão sobre interfaces de classe de interfaces. Se o Java tivesse tipos mais altos, o fmap poderia muito bem retornar a Maybe<Int>.
dcastro 6/12/2015