Saltar para o conteúdo

Haskell/Tipos básicos II

Origem: Wikilivros, livros abertos por um mundo aberto.


Este módulo encontra-se em processo de tradução. A sua ajuda é bem vinda.

Neste capítulo veremos como Haskell lida com tipos de dados numéricos, e também serão introduzidos conceitos bastante importantes do sistema de tipos. Mas antes de prosseguirmos, uma pergunta: qual deveria ser o tipo da função (+)?[nota 1]

Na Matemática há poucas restrições quanto ao tipo de números que podemos somar ou subtrair. Por exemplo: (dois números naturais), (um inteiro negativo e um número racional), ou (um número racional e um irracional). Todas essas operações são válidas. Na verdade, quais quer dois números reais podem ser somados ou subtraídos. Para poder abranger essa generalidade de forma bem simples, precisamos de um tipo geral Numero em Haskell. Assim, o tipo de (+) seria

(+) :: Numero -> Numero -> Numero

Entretando, essa ideia não é muito compatível com o modo como um computador realiza operações aritméticas. Mesmo que se possa representar um número inteiro como sendo uma sequência de dígitos 1 ou 0 armazenada na memória, essa mesma abordagem não funciona para números não-inteiros.[nota 2] Isso faz com que a solução não seja tão simples assim: representação por ponto flutuante. E mesmo que ela resolva o problema de muitos números não-inteiros, alguns outros inconvenientes (perda de precisão, por exemplo) fazem com formas mais simples de representação sejam desejáveis para números inteiros. Devemos considerar ainda que o computador só conseguiria realizar a operação se ambos os números estiverem representados da mesma forma.

Tudo isso leva a conclusão de que é inviável ter um tipo Numero universal para todos os números. Parece que é até impossível ter uma única função (+) para adição. Contudo, em Haskell, podemos ao menos usar o mesmo nome (+) para a operação de adição entre os diferentes tipos de números. Veja:

Prelude>3 + 4
7
Prelude>4.34 + 3.12
7.46

No capítulo passado, vimos que funções podem ser polimórficas, isto é, elas podem receber argumentos de tipos diferentes. Pensando nisso, uma possível anotação de tipo para (+) seria:

(+) :: a -> a -> a

Com este tipo, (+) receberia dois argumentos do mesmo tipo a (que poderiam ser inteiros ou números de ponto flutuante) e avaliar o resultado no mesmo tipo a. O problema é que essa assinatura quer dizer qualquer a, mas não faz sentido somar dois Bool, ou dois Char, por exemplo. Então, para restringirmos o tipo a, a assinatura de (+) usa uma funcionalidade de Haskell para restringir a:

(+) :: (Num a) => a -> a -> a

(Num a) => restringe a para que ele pertença a classe (ou typeclass, em inglês) Num, que define todos os tipos de números em Haskell. Na terminologia mais usada em Haskell, a deve ser uma instância de Num.

Tipos numéricos

[editar | editar código-fonte]

Mas então quais são os verdadeiro tipos numéricos em Haskell? Ou, em outras palavras: quais são as instâncias de Num que a poderia ser? Os mais importantes são Int, Integer e Double.

  • Int representa um número inteiro, e pode ser encontrado na maioria das linguagens de programação. Este tipo possui limites de valores máximos e mínimos que ele pode representar, os quais dependem do tipo de processador do computador. Numa máquina de 32 bits, por exemplo, o limite inferior é -2147483648, e o superior é 2147483647.
  • Integer também representa números inteiros, mas não a mesma restrição de valores que Int, ao custo de ser menos eficiente. O limite para representar um número é a quantidade de memória disponível na máquina.
  • Double é uma representação de ponto flutuante de precisão dobrada (double significa "dobro" em inglês). Esta é uma boa opção para representar números não-inteiros na maioria dos casos. Haskell também possui o tipo Float, para números em ponto flutuante comuns, mas que são menos atraentes devido a maior perda de precisão quando comparados a um Double.

Existem tipos de números em Haskell, mas estes cobre a maioria dos casos.

Números polimórficos

[editar | editar código-fonte]

Se você chegou até aqui, já deve saber que nem sempre precisamos especificar os tipos das funções e variáveis porque o compilador consegue inferí-los. Também já sabe que não podemos usar argumentos de tipos diferentes daqueles especificados na assinatura de uma função. Agora, combine estas informações com o que acabamos de ver para entender com o Haskell lida com aritmética básica:

Prelude> (-7) + 5.12
-1.88

Parece que estamos adicionando números de tipos diferentes, isto é, um inteiro e um não-inteiro. Vejamos qual o tipo de cada parcela dessa soma:

Prelude> :t (-7)
(-7) :: (Num a) => a


Aqui, (-7) não é nem um Int, nem um Integer. Na verdade, é um valor polimórfico que pode se encaixar em qual tipo da classe Num. Vejamos 5.12:

Prelude> :t 5.12
5.12 :: (Fractional t) => t

Trata-se de mais um valor polimórfico, mas da classe Fractional, que é um subconjunto de Num, ou seja, todo Fractional é um Num, mas nem todo Num, é um Fractional.

Quando o compilador avalia (-7) + 5.13, ele precisa definir o tipo das parcelas para que se encaixem na assinatura de (+). O processo de inferir também leva em consideração a classe: como Fractional é um subconjunto de Num, temos que 5.12 tem maiores restrições, portanto, (-7) será também restringido para a classe Fractional. Agora já não há outras restrições, e assim, ambos assumem o tipo de Double para que a operação seja realizada.[nota 3]

Vamos fazer alguns testes para esclarecer esse processo. Crie um novo arquivo contendo

x = 2

Agora carrege-o no GHCi e use :t para verificar o tipo de x. Depois, altere o arquivo para

x = 2
y = x + 3

e refaça o teste para x e y. Agora, mude y para

x = 2
y = x + 3.1

e veja o que acontece com o tipo das variáveis.


Conversão monomórfica

[editar | editar código-fonte]

Toda essa diferença de classes e subclasses de números em Haskell gera certas complicações. Por exemplo, considere a operação de divisão. O operador (/) tem o tipo

(/) :: (Fractional a) => a -> a -> a

Isso quer dizer seu uso está restrito a valores não-inteiros. Faz sentido, porque em muitos casos o resultado de uma divisão não é inteiro:

Prelude> 4 / 3
1.3333333333333333

Novamente, aqui temos dois valores polimórficos, 3 e 4, que são convertidos para Double para que a operação seja realizada. Agora suponha que você queira dividir um número pelo comprimento de uma lista. Esta é uma operação bastante comum para calcular uma média aritmética, por exemplo. A maneira mais simples seria usar a função length (length em inglês significa "comprimento") para calcular o tamanho da lista:

Prelude> length [1,2,3]
3
Prelude> 4 / length [1,2,3]

Infelizmente, teremos um erro neste caso:

<interactive>:2:1: error:
    * No instance for (Fractional Int) arising from a use of `/'
    * In the expression: 4 / length [1, 2, 3]
      In an equation for `it': it = 4 / length [1, 2, 3]

Como já e de costume, o problema pode ser entendido ao verificarmos a assinatura de length:

length :: (Foldable t) => t a -> Int

Por enquanto, considere que (Foldable t) => t a significa uma lista, e equivale a [a] (veremos mais sobre isto logo abaixo, na próxima seção). Agora observe que a saída é um Int, mas já sabemos que este tipo não pertence a classe Fractional. Portanto, não é possível usar o operador (/) que exige que tanto divisor quanto dividendo sejam não-inteiros.

Para resolver este tipo de problema, temos a seguinte função em Haskell:

fromIntegral :: (Integral a, Num b) => a -> b

O que você acha que ela faz? Pare, observe sua assinatura, tente deduzir, e depois siga com a leitura deste texto.

fromIntegral converte um argumento inteiro, da classe Integral[nota 4] em um valor polimórfico pretencente à classe Num. Com isso, ao usarmos esta função associada ao operador (/), o compilador infere que o tipo polimórfico resultante de fromIntegral deve ser um Fractional, e consegue realizar a operação de forma correta:

Prelude> 4 / fromIntegral (length [1,2,3])
1.3333333333333333

De certa forma, este é um problema bem inconveniente, mas é um efeito colateral inevitável de termos um sistema de tipos tão rígido. Em Haskell, se uma função for definida com argumentos do tipo Int, ela nunca poderá ser usada com números de outros tipos, nem mesmo Integer, a não ser que você use fromIntegral de forma explícita para converter um argumento ao aplicar uma função. Outra consequência desse sistema de tipos é que fromIntegral não é a única função responsável por converter números, além de haver outras classes além de Integral e Fractional.

Outros tipos de classes

[editar | editar código-fonte]

Em Haskell, as classes se extendem para além dos tipos numéricos. Veja a assinatura de (==), por exemplo:

(==) :: (Eq a) => a -> a -> Bool

Assim como (+) ou (/), o operador (==) é uma função polimórfica. Ela compara dois valores que possuam o mesmo tipo e retorna um Bool. Entretanto, existe a restrição de os argumentos pertençam à classe Eq, que é a classe que reúne tipos que podem ser iguais entre si. Todos os tipos não-funcionais de Haskell pertecem a esta classe.[nota 5]

Um exemplo de classe bem diferente é o que vimos na assinatura de length. Já que operamos com listas, sua assinatura poderia ser

length :: [a] -> Int

mas o que vimos é que a assinatura real é

length :: (Foldable t) => t a -> Int

O fato é que além de listas, podemos ter outras estruturas de dados para armazenar e agrupar valores. Muitas delas, assim com as listas, pertencem à classe chamda de Foldable (foldable em inglês significa "dobrável"). O que o tipo de length nos diz é que ela funciona não apenas com listas, mas qualquer estrutura de dados pertencente a Foldable. Veremos mais tarde outras estruturas deste tipo, mas por equanto, como dito anteriormente, considere que Foldable é o mesmo que lista.

Também voltaremos a este tópico, classes de tipos, mais tarde. Veremos como podemos definir novos tipos e como fazê-los pertencer a uma ou outra classe de acordo com nossa necessidade.

  1. Se você seguiu a recomendação deste wikilivro de sempre testar funções com o comando :t no GHCi, você já deve saber resposta. Se souber, explicaremos agora o motivo desta resposta.
  2. Dentre outros problemas, existem infinitos números reais entre quaisquer dois números reais, e não importa o que façamos, é impossível representá-los todos na memória do computador
  3. Para programadores mais experientes: esse processo parece bastante com o que acontece em C, por exemplo, onde um inteiro seria convertido em ponto flutuante implicitamente. A diferença é que em Haskell essa conversão só ocorre se o valor for polimórfico desde o começo.
  4. Assim como Fractional, a classe Integral é um subconjunto de Num.
  5. Compara duas função não é permitido, e nem faz muito sentido tal operação.