O polimorfismo do tipo retorno (apenas) em Haskell é uma coisa boa?


29

Uma coisa com a qual nunca cheguei a um acordo em Haskell é como você pode ter constantes e funções polimórficas cujo tipo de retorno não pode ser determinado pelo tipo de entrada, como

class Foo a where
    foo::Int -> a

Algumas das razões pelas quais eu não gosto disso:

Transparência referencial:

"Em Haskell, dada a mesma entrada, uma função sempre retornará a mesma saída", mas isso é realmente verdade? read "3"retornar 3 quando usado em um Intcontexto, mas gera um erro quando usado em um, digamos, (Int,Int)contexto. Sim, você pode argumentar que readtambém está usando um parâmetro de tipo, mas a implicitação do parâmetro de tipo faz com que perca um pouco de sua beleza na minha opinião.

Restrição de monomorfismo:

Uma das coisas mais irritantes sobre Haskell. Corrija-me se estiver errado, mas o motivo principal do MR é que o cálculo que parece compartilhado pode não ser porque o parâmetro type está implícito.

Tipo padrão:

Novamente, uma das coisas mais irritantes sobre Haskell. Acontece, por exemplo, se você passar o resultado de funções polimórficas em suas saídas para funções polimórficas em suas entradas. Novamente, corrija-me se estiver errado, mas isso não seria necessário sem funções cujo tipo de retorno não possa ser determinado pelo tipo de entrada (e constantes polimórficas).

Portanto, minha pergunta é (correndo o risco de ser carimbado como uma "questão de discussão"): seria possível criar uma linguagem semelhante a Haskell em que o verificador de tipos não permita esses tipos de definições? Em caso afirmativo, quais seriam os benefícios / desvantagens dessa restrição?

Eu posso ver alguns problemas imediatos:

Se, digamos, 2apenas o tipo Integer, 2/3não digitar mais check com a definição atual de /. Mas, neste caso, acho que classes de tipo com dependências funcionais podem vir ao resgate (sim, eu sei que isso é uma extensão). Além disso, acho muito mais intuitivo ter funções que podem receber tipos de entrada diferentes do que funções restritas em seus tipos de entrada, mas apenas passamos valores polimórficos para eles.

A digitação de valores gosta []e Nothingme parece uma noz mais difícil de quebrar. Não pensei em uma boa maneira de lidar com eles.

Respostas:


37

Na verdade, acho que o polimorfismo de tipo de retorno é um dos melhores recursos das classes de tipo. Depois de usá-lo por um tempo, às vezes é difícil voltar à modelagem de estilo OOP, onde não a tenho.

Considere a codificação da álgebra. Em Haskell, temos uma classe de tipo Monoid(ignorando mconcat)

class Monoid a where
   mempty :: a
   mappend :: a -> a -> a

Como podemos codificar isso como uma interface em uma linguagem OO? A resposta curta é que não podemos. Isso memptyocorre porque o tipo de é (Monoid a) => aconhecido como polimorfismo do tipo de retorno. Ter a capacidade de modelar álgebra é IMO incrivelmente útil. *

Você inicia sua postagem com a reclamação sobre "Transparência referencial". Isso levanta um ponto importante: Haskell é uma linguagem orientada a valores. Portanto, expressões como read 3não precisam ser entendidas como coisas que computam valores, elas também podem ser entendidas como valores. O que isso significa é que o problema real não é o polimorfismo do tipo de retorno: são valores com o tipo polimórfico ( []e Nothing). Se o idioma os possuir, é necessário que haja tipos de retorno polimórficos para garantir a consistência.

Deveríamos ser capazes de dizer que []é do tipo forall a. [a]? Acho que sim. Esses recursos são muito úteis e tornam o idioma muito mais simples.

Se Haskell tivesse um subtipo de polimorfismo, []poderia ser um subtipo para todos [a]. O problema é que não conheço uma maneira de codificar que, sem que o tipo da lista vazia seja polimórfico. Considere como isso seria feito no Scala (é mais curto do que na linguagem OOP canônica estaticamente tipada, Java)

abstract class List[A]
case class Nil[A] extends List[A]
case class Cons[A](h: A. t: List[A]) extends List[A]

Mesmo aqui, Nil()é um objeto do tipo Nil[A]**

Outra vantagem do polimorfismo do tipo retorno é que ele torna a incorporação de Curry-Howard muito mais simples.

Considere os seguintes teoremas lógicos:

 t1 = forall P. forall Q. P -> P or Q
 t2 = forall P. forall Q. P -> Q or P

Podemos capturá-los trivialmente como teoremas em Haskell:

data Either a b = Left a | Right b
t1 :: a -> Either a b
t1 = Left
t2 :: a -> Either b a
t2 = Right

Resumindo: eu gosto do polimorfismo de tipo de retorno e só acho que quebra a transparência referencial se você tiver uma noção limitada de valores (embora isso seja menos atraente no caso da classe de tipo ad hoc). Por outro lado, acho seus pontos sobre o MR e digito convincente por padrão.


* Nos comentários, o ysdx ressalta que isso não é totalmente verdade: poderíamos reimplementar as classes de tipos modelando a álgebra como outro tipo. Como o java:

abstract class Monoid<M>{
   abstract M empty();
   abstract M append(M m1, M m2);
}

Você precisa passar objetos desse tipo com você. Scala tem uma noção de parâmetros implícitos que evita alguns, mas na minha experiência não todos, a sobrecarga de gerenciar explicitamente essas coisas. Colocar seus métodos utilitários (métodos de fábrica, métodos binários, etc.) em um tipo separado de limite F é uma maneira incrivelmente agradável de gerenciar as coisas em uma linguagem OO que oferece suporte a genéricos. Dito isto, não tenho certeza de que teria adotado esse padrão se não tivesse experiência em modelar coisas com classes tipográficas e não tenho certeza de que outras pessoas o farão.

Ele também possui limitações, já que não há como obter um objeto que implemente a classe de tipo para um tipo arbitrário. Você deve passar os valores explicitamente, usar algo como os implícitos de Scala ou usar alguma forma de tecnologia de injeção de dependência. A vida fica feia. Por outro lado, é bom que você possa ter várias implementações para o mesmo tipo. Algo pode ser um monóide de várias maneiras. Além disso, transportar essas estruturas separadamente oferece à IMO uma sensação mais matematicamente moderna e construtiva. Portanto, embora eu ainda prefira a maneira de Haskell de fazer isso, provavelmente exagerei meu caso.

As classes com polimorfismo do tipo retorno tornam esse tipo de coisa fácil de manusear. Isso não significa que é a melhor maneira de fazê-lo.

** Jörg W Mittag ressalta que essa não é realmente a maneira canônica de fazer isso no Scala. Em vez disso, seguiríamos a biblioteca padrão com algo mais como:

abstract class List[+A] ...  
case class Cons[A](head: A, tail: List[A]) extends List[A] ...
case object Nil extends List[Nothing] ...

Isso tira proveito do suporte da Scala para tipos de fundo, bem como paramaters de tipo covariante. Então, Nilé do tipo Nilnão Nil[A]. Neste ponto, estamos bem longe de Haskell, mas é interessante observar como Haskell representa o tipo inferior

undefined :: forall a. a

Ou seja, não é o subtipo de todos os tipos, é polimorficamente (sp) um membro de todos os tipos.
Ainda mais polimorfismo de tipo de retorno.


4
"Como podemos codificar isso como uma interface em uma linguagem OO?" Você poderia usar álgebra de primeira classe: interface Monoid <X> {X empty (); X acrescenta (X, X); } No entanto, você precisa transmiti-lo explicitamente (isso é feito nos bastidores em Haskell / GHC).
ysdx 3/09/11

@ysdx Isso é verdade. E nas linguagens que suportam parâmetros implícitos, você obtém algo muito próximo das classes de tipo do haskell (como no Scala). Eu estava ciente disso. Meu argumento, porém, foi que isso dificulta bastante a vida: eu tenho que usar recipientes que armazenam a "classe" em todo o lugar (eca!). Ainda assim, eu provavelmente deveria ter sido menos hiperbólica na minha resposta.
Philip JF

+1, resposta incrível. Um nitpick irrelevante, no entanto: Nilprovavelmente deve ser um case object, não um case class.
Jörg W Mittag

@ Jörg W Mittag Não é totalmente irrelevante. Eu editei para endereçar seu comentário.
Philip JF

1
Obrigado por uma resposta muito agradável. Provavelmente levará um tempo para digerir / entender.
dainichi

12

Apenas para observar um equívoco:

"Em Haskell, dada a mesma entrada, uma função sempre retornará a mesma saída", mas isso é realmente verdade? leia "3" retorne 3 quando usado em um contexto Int, mas gera um erro quando usado em um contexto, digamos, (Int, Int).

Só porque minha esposa dirige um Subaru e eu dirijo um Subaru não significa que dirigimos o mesmo carro. Só porque existem 2 funções nomeadas readnão significa que é a mesma função. De fato, read :: String -> Inté definido na instância Read de Int, onde read :: String (Int, Int)é definido na instância Read de (Int, Int). Portanto, são funções completamente diferentes.

Esse fenômeno é comum em linguagens de programação e geralmente é chamado de sobrecarga .


6
Eu acho que você meio que não entendeu a questão. Na maioria dos idiomas que possuem polimorfismo ad-hoc, também conhecido como sobrecarga, a seleção de qual função chamar depende apenas dos tipos de parâmetros, não do tipo de retorno. Isso facilita o raciocínio sobre o significado das chamadas de função: basta começar pelas folhas da árvore de expressões e seguir seu caminho "para cima". No Haskell (e no pequeno número de outros idiomas que oferecem suporte à sobrecarga de retorno), você deve considerar a árvore de expressão inteira de uma só vez, até para descobrir o significado de uma pequena subexpressão.
Laurence Gonsalves

1
Eu acho que você atingiu o cerne da questão perfeitamente. Até Shakespeare disse: "Uma função com qualquer outro nome funcionaria igualmente". 1
Thomas Eding 28/10/2013

@ Laurence Gonsalves - A inferência de tipo em Haskell não é referencialmente transparente. O significado do código pode depender do contexto em que é usado devido à inferência de tipo que puxa as informações para dentro. Isso não se limita a problemas de tipo de retorno. Haskell efetivamente incorporou o Prolog ao seu sistema de tipos. Isso pode tornar o código menos claro, mas também possui grandes vantagens. Pessoalmente, acho que o tipo de transparência referencial mais importante é o que acontece no tempo de execução, porque seria impossível lidar com a preguiça sem ela.
Steve314

@ Steve314 Acho que ainda estou vendo uma situação em que a falta de inferência de tipo referencialmente transparente não deixa o código menos claro. Para raciocinar sobre qualquer coisa de complexidade, é preciso ser capaz de "dividir" mentalmente as coisas. Se você me diz que tem um gato, não penso em uma nuvem de bilhões de átomos ou células individuais. Da mesma forma, ao ler o código, particiono expressões em suas subexpressões. Haskell derrota isso de duas maneiras: inferência do tipo "errado" e sobrecarga de operador excessivamente complexa. A comunidade Haskell também tem aversão a parênteses, tornando a última ainda pior.
Laurence Gonsalves

1
@LaurenceGonsalves Você está certo de que o infixrecurso pode ser abusado. Mas isso é uma falha dos usuários, então. OTOH, restritividade, como em Java, não é o caminho certo. Para ver isso, não procure mais do que algum código que lida com BigIntegers.
Ingo

7

Eu realmente gostaria que o termo "polimorfismo de tipo de retorno" nunca fosse criado. Encoraja um mal-entendido do que está acontecendo. Basta dizer que, embora eliminar o "polimorfismo do tipo de retorno" seja uma mudança extremamente ad-hoc e expressiva, que mata, nem remotamente resolverá os problemas levantados na questão.

O tipo de retorno não é de forma especial. Considerar:

class Foo a where
    foo :: Maybe a -> Bool

x = foo Nothing

Esse código causa os mesmos problemas que o "polimorfismo de tipo de retorno" e também demonstra os mesmos tipos de diferenças em relação ao OOP. Ninguém fala sobre "primeiro argumento Talvez digite polimorfismo".

A idéia principal é que a implementação esteja usando tipos para resolver qual instância usar. Os valores (tempo de execução) de qualquer tipo não têm nada a ver com isso. Na verdade, funcionaria mesmo para tipos que não têm valores. Em particular, os programas Haskell não têm significado sem seus tipos. (Ironicamente, isso faz de Haskell uma linguagem no estilo da Igreja, em oposição a uma linguagem no estilo Curry. Eu tenho um artigo de blog nos trabalhos detalhando isso.)


'Este código causa os mesmos problemas que o "polimorfismo do tipo de retorno"'. Não, não faz. Eu posso olhar para "foo Nothing" e determinar qual é o seu tipo. É um Bool. Não é necessário olhar para o contexto.
Dainichi

4
Na verdade, o código não digita check porque o compilador não sabe como aé o caso "return type". Novamente, não há nada de especial nos tipos de retorno. Precisamos saber o tipo de todas as subexpressões. Considere let x = Nothing in if foo x then fromJust x else error "No foo".
Derek Elkins

2
Sem mencionar "polimorfismo de segundo argumento"; uma função como Int -> a -> Boolé, ao currying, na verdade Int -> (a -> Bool)e pronto , polimorfismo no valor de retorno novamente. Se você permitir em qualquer lugar, deve estar em todo lugar.
Ryan Reich

4

Com relação à sua pergunta sobre transparência referencial em valores polimórficos, aqui está algo que pode ajudar.

Considere o nome 1 . Geralmente denota objetos diferentes (mas fixos):

  • 1 como em um número inteiro
  • 1 como em um real
  • 1 como em uma matriz quadrada de identidade
  • 1 como em uma função de identidade

Aqui é importante notar que, em cada contexto , 1é um valor fixo. Em outras palavras, cada par nome-contexto denota um objeto único . Sem o contexto, não podemos saber qual objeto estamos denotando. Assim, o contexto deve ser inferido (se possível) ou explicitamente fornecido. Um bom benefício (além da notação conveniente) é a capacidade de expressar código em contextos genéricos.

Mas como essa é apenas uma notação, poderíamos ter usado a seguinte notação:

  • integer1 como em um número inteiro
  • real1 como em um real
  • matrixIdentity1 como em uma matriz quadrada de identidade
  • functionIdentity1 como em uma função de identidade

Aqui, obtemos nomes explícitos. Isso nos permite derivar o contexto apenas do nome. Portanto, apenas o nome do objeto é necessário para denotar completamente um objeto.

As classes do tipo Haskell elegeram o antigo esquema de notação. Agora, aqui está a luz no fim do túnel:

readé um nome, não um valor (não tem contexto), mas read :: String -> aé um valor (tem um nome e um contexto). Assim, as funções read :: String -> Inte read :: String -> (Int, Int)são literalmente diferentes funções. Portanto, se eles discordarem de suas informações, a transparência referencial não será quebrada.

Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.