Saltar para o conteúdo

Haskell/Tipos básicos

Origem: Wikilivros, livros abertos por um mundo aberto.


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

Em programação, tipo são usados para agrupar valores similares, com características similares, dentre de uma certa categoria. Em Haskell, seu sistema de tipos é uma ferramenta poderosa usada para evitar muitos erros triviais dentro de um programa.

Quando se cria um programa, geralmente temos que lidar com várias entidades diferentes. Por exemplo, a operação . O que são 2 e 3? Números. E o sinal de mais no meio deles? Não é um número, e trata-se de um símbolo que representa a operação de adição que queremos fazer entre 2 e 3.

Agora, imagine um programa que lhe peça seu nome, e o cumprimente com uma mensagem de "Olá". Nem seu nome, nem a mensagem são números, mas trata-se de palavras. São, portanto, texto. Em programação, quando nos referimos a texto, geralmente dizemos String, que significa "fio" ou "cadeia", em inglês. É uma forma curta de dizer "uma cadeia de caracteres".

Nota: Em Haskell, há uma regra de que o nome de qualquer tipo deve começar em letra maiúscula. De aqui em diante, sempre seguiremos esta regra.

Um banco de dados é um bom exemplo para ilustrar os diferentes tipos de dados possíveis. Imagine que temos uma tabela de contatos, como uma agenda. O conteúdo poderia ser o seguinte:

Primeiro nome Sobrenome Endereço de correspondência Número do telefone
João Romão Ladeira do Livramento, 77, Cortiço, Rio de Janeiro 218444
Severino Retirante Av. Capibaribe-Rio, 1025, Recife 813586

Os campos de cada entrada possuem um valor diferente. João é um valor. Ladeira do Livramento, 77, Cortiço, Rio de Janeiro e 218444 são outros dois. Em termos de seus tipos, "Primeiro nome" e "Sobrenome" são textos, então são do tipo String.

À primeira vista, poderíamos classificar "Endereço de correspondência" também como texto. Entretanto, há diferentes formas de se interpretar um endereço como este. O primeiro nome pode ser o nome do prédio, ou o nome da rua, enquanto números subsequentes podem ser o número do imóvel ou de seu lote escriturado. E as palavras seguintes podem ser o nome do bairro ou da cidade. É possível também que o endereço comece por "Caixa Postal", e neste caso, ele sequer indica um endereço final, mas apenas uma caixa de correspondência em alguma agência postal. Portanto, cada parte do endereço tem um significado próprio.

A princípio poderíamos dizer que o endereço trata-se de um String. De fato, ele o é, mas aí perderíamos muitas informações importantes. Quando algo é definido com String, dizemos que se trata de uma simples sequencia de caracteres, enquanto que, se pudermos encaixá-lo numa categoria mais específica, teremos bem mais informações a cerca de seu conteúdo. Por exemplo, se soubermos que um String trata-se de um Endereço, já temos muito mais informações a seu respeito, e podemos agora interpretá-lo e manipulá-lo de maneira mais apropriada.

Essa mesma lógica também pode ser aplicada aos números de telefone. Podemos especificar um tipo chamado NúmeroTelefone e assim, quando houver algum número desse tipo, poderíamos buscar padrões como códigos de área ou de país. Um outro motivo para não tratá-los como números comuns, é que não convém fazermos operações artiméticas com eles: o que você espera conseguir ao multiplicar um NúmeroTelefone por 100, ou dividí-lo pela metade? É possível que o resultado nem seja um número de telefone válido. Além disso, não é aceitável perder, omitir ou arredondá-los pelo mesmo motivo.

A utilidade dos tipos

[editar | editar código-fonte]

Mas por que é que separar e categorizar coisas nos ajuda a programar? Uma vez que temos os tipos definidos, podemos especificar o que se pode ou não fazer com os valores. Isso ajuda bastante a evitar erros quando tempos programas muito extensos.

Usando o comando interativo :type

[editar | editar código-fonte]

Vamos explorar o funcionamento dos tipos usando uma sessão do GHCi. O tipo de uma expressão pode ser verificado usando-se o comando :type, ou :t. Tente com booleanos e expressões booleanas:

Prelude> :type True
True :: Bool
Prelude> :type False
False :: Bool
Prelude> :t (3 < 5)
(3 < 5) :: Bool

O símbolo :: aparece em todos os resultados, e é lido com o "é do tipo", e é seguido por uma assinatura de tipo, ou anotação de tipo.

:t nos mostra que valores de verdadeiro (True) ou falso (False) são do tipo Bool. Até mesmo expressões possuem um tipo definido: quando elas forem avaliadas, elas resultarão num valor do tipo Bool. Talvez já esteja claro para você, mas vale notar que Bool é o tipo de dado escolhido para representar valores do tipo sim ou não. Por exemplo: se um valor for encontrado ou não, se um botão foi pressionado ou não.

Caracteres e strings

[editar | editar código-fonte]

Vamos agora testar :t com outros coisas. Em Haskell, um único caractere é representado cercando-o por aspas simples:

Prelude> :t 'H'
'H' :: Char

Temos, então, que caracteres são do tipo Char, abreviação de character (caractere, em inglês). Agora, em vez de usarmos um único caractere, para representar uma cadeia deles, devemo usar aspas duplas:

Prelude> :t "Olá, mundo"
"Olá, mundo" :: [Char]

De novo, temos Char, só que cercado por colchetes. [Char] significa uma lista de caracteres. Haskell considera que toda cadeia de caracteres é uma lista. Lista, em geral, são bem importantes em Haskell, e falaremos sobre elas mais adiante.

Exercício

  1. Use :type seguido de um valor como "H" no GHCi. Perceba que usamos aspas duplas aqui. O que acontece? Por quê?
  2. Use :type seguido de um valor como 'Olá, mundo' no GHCi. Perceba que usamos aspas simples aqui. O que acontece? Por quê?

Haskell nos permite criar sinônimos de tipos, que funcionam como sinônimos na linguagem humana: palavras diferentes que tem o mesmo significado. Esses sinônimos são nomes alternativos para os tipos. Por exemplo, String é definido como um sinônimo para [Char], e por isso podemos substituir um pelo outro:

"Olá, mundo" :: String

O código acima é perfeitamente válido, e é muito mais claro para humanos ler String uma única vez, do que ler [Char] e interpretar que trata-se de uma lista de caracteres e, portanto um string.

Tipos das funções

[editar | editar código-fonte]

Até agora vimos os tipos de alguns valores (strings, booleanos, caracteres etc.). Também vimos que tais tipos nos ajudam a categorizar e descrever certos valores de forma mais convenientes. Agora, veremos o que faz o sistema de tipos de Haskell tão importante e poderoso: funções possuem tipos também.

Podemo inverter um booleano usando a função not, como já vimos. Para sabermos o tipo de uma função, temos levar duas coisas em consideração: os tipos de seus argumentos, e o tipo do valor que ela retorna, isto é, o tipo de sua saída. Para o caso de not as coisas são bem simples: ela recebe um valor do tipo Bool e retorna outro do tipo Bool. A notação que usamos em Haskell é a seguinte:

not :: Bool -> Bool

Essa linha pode ser lida como: "not é do tipo que recebe valores do tipo Bool e retorna valores do tipo Bool". Em outras palavras: uma função de Bool (a entrada) em Bool (a saída).

No GHCi, podemos usar :t em funções também:

Prelude> :t not
not :: Bool -> Bool

Portanto, o tipo de uma função sempre é descrito em termos dos tipos de sua entrada e do tipo de sua saída.

Exemplo: chr e ord

[editar | editar código-fonte]

A representação de texto sempre foi um problema para computadores. Em sua linguagem mais básica, um computador só pode interpretar dados binários: 1 e 0. Para representar um texto, cada caractere deve primeiramente ser convertido a um número, que pode ser convertido para sua forma binária e depois interpretada pelo computador. A conversão para a forma binária é uma tarefa trivial para a máquina, portanto, só nos interessa a conversar de um caractere para sua forma numérica.

A forma mais simples para tal tarefa seria escrever todos os caracteres possíveis e enumerá-los. Por exemplos, se fizermos "a" corresponder a 1, "b" poderia ser 2, "c" seria 3, e assim por diante. Haskell já possui um padrão de enumeração de caracteres definido, e seria tedioso buscar em sua tabela os valores numéricos sempre que precisarmos deles. Por conveniência, já temos duas funções que fazem este trabalho: chr (uma abreviação de char), e ord.[nota 1] Vamos investigar os tipos de chr e ord:

    chr :: Int -> Char
    ord :: Char -> Int

Como já sabemos o que Char significa, vamos explicar sobre Int. Este tipo se refere a números inteiros, e é apenas um dentre alguns outros tipos de números definidos em Haskell. Na verdade, existe também mais de um tipo para representar números inteiros em Haskell. A assinatura de tipo de chr nos diz que ela recebe um argumento do tipo Int, um número inteiro, e avalia um resultado do tipo Char. O caso de ord é o oposto: recebe um argumento do tipo Char e retorna um do tipo Int. Analisando ambas assinaturas, fica claro qual das funções converte um caractere num número (ord), e qual faz a operação inversa (chr).

Agora, alguns exemplos. Estas funções não estão nativamente disponíveis, ao abrir uma sessão do GHCi, precisamos usar o comando :module Data.Char (ou usar :m) para carregar o módulo Data.Char, que é onde elas estão definidas.

Prelude> :m Data.Char
Prelude Data.Char> chr 97
'a'
Prelude Data.Char> chr 98
'b'
Prelude Data.Char> ord 'c'
99

Funções de mais de um argumento

[editar | editar código-fonte]

Qual seria o tipo de uma função com mais de um argumento?

    xor p q = (p || q) && not (p && q)

xor é a função "ou exclusivo", que avalia se apenas um dos argumentos for True. Se ambos ou nenhum forem verdadeiros, ela retorna False.

O método básico para descrever o tipo de uma função de mais de um argumento é simplesmente escrever os tipos de cada argumento, em linha, e em sequência (primeiro p, depois q, por exemplo) e, por último, adicionar o tipo da saída. Depois basta intercalá-los com ->.[nota 2]

  1. Escreva o tipo dos argumentos de entrada, em sequência, e o tipo da saída no fim. A definição de xor já nos diz que ela avalia se seus argumentos são True ou False. Então eles só podem ser do tipo Bool. Sua saída também é do mesmo tipo, Bool:
    Bool                    Bool                    Bool
    ^^^^ p é do tipo Bool   ^^^^ q é do tipo Bool   ^^^^ a saída também é do tipo Bool
    
  2. Intercale os tipos usando ->:
    Bool -> Bool -> Bool

Portanto, a assinatura de xor é:

    xor :: Bool -> Bool -> Bool

Ela pode ser lida como: "xor é do tipo que recebe dois Bool e retorna um Bool".

Exemplo: abrirJanela

[editar | editar código-fonte]

A medida que alguém aprofunda seus conhecimentos de programação, alguns termos técnicos tornam-se triviais. Um deles, são as chamadas bibliotecas, que são arquivos de código com um determinado propósito, mas que podem ser usados por diferentes programas. E um dos tipos de bibliotecas mais comuns é o de GUI (Graphical Unser Interface, ou interface gráfica de usuário, em inglês). O que se espera encontrar neste tipo de biblioteca são funções para criar, botões, menus e interação com mouse e teclado, por exemplo. Uma possível função seria abrirJanela, que provavelmente abre uma nova janela quando executada. A assinatura de uma função como esta poderia ser:

    abrirJanela :: JanelaTitulo -> JanelaTamanho -> Janela

Não precisamos entender a fundo a definição destes tipos, mas seus nomes nos ajudam a entender o que é necessário para criar uma janela. É bastante provável também que eles estejam definidos dentro da biblioteca da própria biblioteca que possui define abrirJanela.

Vamos investigar por partes. Lembre-se do que explicamos na seção anterior: o último tipo descrito na assinatura de uma função é o tipo de sua saída. Portanto, quantos argumentos abrirJanela exige? Dois argumentos. Quais são os seus tipos? JanelaTitulo e JanelaTamanho. E qual o tipo da saída? É Janela. Assim, podemos entender que: "abrirJanela é uma função que cria uma nova janela e que para isso exige um título e um tamanho".

Perceba que não é preciso já ter usado uma função para entender o que ela faz: quando usamos tipos cujos nomes são claros o bastante, a assinatura da função nos dá dicas de seu funcionamento. É um bom hábito testar as funções com :t no GHCi antes de usá-la e, quanto mais você o fizer, melhor será sua intuição para interpretar os tipos das funções.

Exercícios

Tente deduzir quais as anotações de tipos das seguintes funções. Se alguma delas envolver números, suponha que sejam do tipo Int.

  1. negativo: uma função que recebe um Int e retorna seu oposto (sinal invertido). Ex: negativo 4 = -4; negativo (-2) = 2.
  2. (||): a função lógica "ou". Ela recebe dois booleanos e compara se pelo menos um deles é verdadeiro. Se sim, retorna verdadeiro, se não, falso.
  3. diasNoMes: uma função que calcula a quantidade de dias num determinado mês. Ela recebe uma valor para indicar se o ano é bissexto ou não, e o número do mês, de 1 a 12.
  4. f x y = not x || y
  5. g x = (2*x - 1)^2

Anotações de tipo em arquivos de código

[editar | editar código-fonte]

Agora que já entendemos um pouco o que são os tipos e no que eles podem nos ajudar, vejamos como fazer este tipo de anotação num código fonte de um programa que você está escrevendo. Considere a função xor que definimos antes. Se quisermos defini-la dentro de um arquivo, e quisermos anotar seu tipo, basta escrever:

    xor :: Bool -> Bool -> Bool
    xor p q = (p || q) && not (p && q)

Só isso. Para maior clareza, é comum que a anotação apareça imediatamente antes da definição, então é recomendável que você se atenha a esta convenção.

Além de facilitar que humanos entendam o funcionamento da função, as anotações de tipo tão são fundamentais para que o compilador as interprete corretamente, como veremos a seguir.

Inferindo tipos

[editar | editar código-fonte]

Como dissemos antes, funções possuem tipo. É importante destacar que elas sempre possuem tipo. Mas como é que escrevemos as funções do capítulo anterior sem nenhum assinatura? Isso é possível porque o compilador consegue inferir o tipo de uma função baseando-se nas operações que ela executa. Vejamos o seguinte caso:

    ehL c = c == 'l'

ehL é uma função de um argumento e que retorna o resultado da expressão c == 'l'. Mesmo sem anotarmos explicitamente o tipo de c e da saída, podemos tentar deduzir a assinatura de ehL. Veja:

  • É uma função de um argumento, c. E toda função em Haskell tem apenas uma saída.
   _ -> _
  • A saída é resultado da operação c == 'l'. Como já vimos, (==) é uma comparação de igualdade, e comparações geralmente resultam em respostas do tipo sim ou não, o que pode ser representado pelo tipo Bool. Na verdade, já vimos (==) em Verdadeiro ou falso, então sabemos que sua saída é um Bool, com certeza.
  _ -> Bool
  • A função (==) compara c com 'l'. Sabemos que não faz sentido comparar coisas de tipos diferentes. O valor 'l' é do tipo Char, portanto, só faz sentido que c seja do mesmo tipo.
  Char -> Bool

Se quiser verificar o que acabamos de fazer, defina ehL no GHCi sem uma assinatura e depois use :t para verificar seu tipo:

Prelude> let ehL c = c == 'l'
Prelude> :t ehL
ehL :: Char -> Bool

O compilador realiza um processo bastante parecido com este para definir o tipo de uma função. Entretanto, ele pode não resultar na assinatura que você espera ou deseja, ou sequer conseguir chegar a uma assinatura final. Por isso, é recomendável sempre escrever a anotação de tipo de suas funções:

    ehL :: Char -> Bool
    ehL c = c == 'l'

Há também outros bons motivos para incluí-las no código:

  • Documentação: como já vimos, anotações de tipo ajudam a entender o que uma função faz. Documentar um programa, é justamente descrever o que suas partes fazem e como funcionam. Claro que comentários ajudam, mas em Haskell as anotações de tipo são tão importantes quanto comentários.
  • Debugging: muitos bugs, ou erros encontrados nos programas são causados quando se uma função de forma errada. As anotações de tipo ajudam você e o compilador a descobrir se uma função está sendo usada de forma correta, verificando os tipos das muitas entradas e saídas que seu programa pode gerar. Quando declaramos as anotações e ocorre algum erro de incompatibilidade de tipos, o compilador sempre mostra uma mensagem dizendo onde está o problema. Se os tipos forem omitidos, o compilador pode inferi-las de uma forma que funcionem num parte, mas que causem outros erros inesperados durante a execução, ou outro erro de compilação em outra parte.

Vejamos um exemplo simples de debugging. Tente o seguinte no GHCi:

Prelude> "olá" ++ " mundo"
"olá mundo"
Prelude> "olá" + " mundo"

<interactive>:2:1: error:
    * No instance for (Num [Char]) arising from a use of `+'
    * In the expression: "ola" + " mundo"
      In an equation for `it': it = "ola" + " mundo"

Acabamos de introduzir a função (++), que nada mais faz que concatenar os dois Strings: "ola" e " mundo". Se por um erro de digitação você escrever + em vez de ++, a mensagem de erro acima apareceria. De forma resumida, ela nos diz que (+) só está definida para dados Num, e que estamos tentando aplicá-la a dados do tipo [Char], ou String, como já vimos.

É importante dizer que a checagem de tipos não previne completamente a ocorrência de erros. Na verdade, ainda podemos escrever uma função de uma forma que não funcione como a desejada. Mas mesmo aí, pensar nas operações que podemos ou não fazer como cada tipo de dado, nos ajuda guiar nossa lógica programação.

  1. Não é exatemente assim que chr e ord funcionam, mas descrição é válida por enquanto.
  2. Cobriremos mais sobre este assunto no capítulo sobre funções de alta ordem.