Introdução a Haskell

Published:

Tipos de Dados

Toda expressão e função no Haskell possui um tipo associado. Por exemplo, um inteiro \(1\) está associado ao tipo Integer enquanto o valor False está associado ao tipo Bool de expressões lógicas.

Essencialmente para o computador todos os valores são bits, as linguagens de programação definem tipos de dados para criar um significado para sequências de bits. Esse significado aumenta o poder de abstração e permite aplicar contexto nas operações realizadas.

Os tipos também permitem evitar erros na implementação de algoritmos uma vez que operações entre tipos inconsistentes não são permitidas.

A linguagem Haskell é conhecida por ter uma tipagem forte e estática, a tipagem forte impede o uso de tipos de forma inconsistente, por exemplo, um número real não pode ser utilizado em divisão inteira. A tipagem estática indica que um tipo não pode se transformar em outro no tempo de execução.

let x = 1 :: Integer
x + "texto"  -- erro! Não pode somar um número a um texto
x * 2.0      -- erro! O valor inteiro não pode se transformar em real
x * 2        -- ok!

Podemos especificar o tipo de uma variável com a instrução :: Tipo logo após o valor. Em alguns casos, quando o tipo da variável não é óbvio, essa instrução é obrigatória. Da mesma forma é possível (e recomendável) especificar os tipos de entrada e saída das funções, conforme veremos adiante.

Nas aulas anteriores tivemos contato com dois tipos de dados disponíveis no Haskell: valores numéricos e boleanos. Nessa aula iremos explorar em maiores detalhes os principais tipos de dados disponíveis na linguagem.

Tipos numéricos

O Haskell possui diversos tipos numéricos, sendo os mais usuais:

  • Int: valores inteiros representado por quantidade fixa de bits (32 ou 64 bits, dependendo do sistema operacional), possui um valor mínimo e máximo representável.
  • Integer: valores inteiros de tamanho arbitrário, como contra partida operações com esse tipo são mais lentas e ocupam maior espaço na memória.
  • Float: valores em ponto-flutuante (números reais) de precisão simples, possui uma faixa de valores e precisão.
  • Double: valores em ponto-flutuante (números reais) de precisão dupla, possui uma faixa de valores e precisão.

Para declarar os tipos de entrada e saída de uma função utilizamos a seguinte notação:

soma :: Integer -> Integer -> Integer
soma x y = x + y

Que deve ser lido como: “a função soma recebe (::) um tipo inteiro (Integer) seguido de (->) outro tipo inteiro e retorna (último ->) um tipo inteiro”. Se tentarmos utilizar a função soma com parâmetros reais o compilador emitirá uma mensagem de erro.

Considere as seguintes funções (teste o programa no repl.it):

fatorialInt :: Int -> Int 
fatorialInt n = product [1..n]

fatorialInteger :: Integer -> Integer
fctorialInteger n = product [1..n]

main = do
  print (fatorialInt 50)
  print (fatorialInteger 50)

A função fatorialInt retornou um número negativo, enquanto fatorialInteger retornou o valor correto de $50!$. Uma vez que os valores do tipo Int são limitados ao número de bits utilizados, ao ultrapassar esse limite os bits são truncados e, por consequência, retorna um valor inesperado.

Agora considere o seguinte programa:

circunferenciaFloat :: Float -> Float 
circunferenciaFloat r = 2 * pi * r

circunferenciaDouble :: Double -> Double
circunferenciaDouble r = 2 * pi * r

main = do
  print (circunferenciaFloat 4.0)
  print (circunferenciaDouble 4.0)

A diferença entre a função do tipo Float e Double está nas casas decimais, sendo o segundo com maior precisão numérica.

Além dos tipos numéricos, o Haskell possui classes desses tipos:

  • Integral: compreende todos os tipos numéricos inteiros (Integer, Int, Int32, Int64, …).
  • Floating: compreende todos os tipos numéricos que representam números reais (Double, Float, …)
  • Num: compreende todos os números

Para usar essas classes de tipos nas declarações, utilizamos a seguinte notação:

soma :: Num a => a -> a -> a
soma x y = x + y

Que pode ser lido como: “a função soma recebe o tipo a seguido de outro tipo a e retorna esse tipo a sendo que a é qualquer tipo da classe Num (Num a =>)”. Entenda uma classe de tipo como uma generalização de uma função que pode aceitar qualquer um dos tipos compreendidos pela classe.

Considere o programa:

circunferenciaFloating :: Floating a => a -> a 
circunferenciaFloating r = 2 * pi * r 

main = do
  let x = 4.0 :: Double
  let y = 4.0 :: Float 
  print (circunferenciaFractional y)
  print (circunferenciaFractional x)

A função circunferenciaFloating funciona com qualquer um dos tipos de representação de números reais, a saída da função será do mesmo tipo da entrada.

O ghc contém algumas funções pré-definidas para converter entre tipos numéricos:

fromInteger             :: (Num a) => Integer -> a
fromRational            :: (Fractional a) => Rational -> a
toInteger               :: (Integral a) => a -> Integer
toRational              :: (RealFrac a) => a -> Rational

Que converte de um tipo/classe para uma classe mais abrangente ou vice-versa.

Lista

Um outro tipo comumente utilizado em Haskell são as listas, uma lista definida internamente pelo Haskell como:

 data [a] = [] | a : [a]

Ou seja, uma lista vazia ([]) ou (|) um elemento do tipo a seguido de outra parte da lista. Repare que tal representação, chamada de representação recursiva, é suficiente para representar uma lista de qualquer tipo. Digamos que temos a lista [1, 2, 3], por essa notação ela é, na verdade 1 : 2 : 3 : [], ou a sequência de números de 1 até 3 seguida do símbolo de final da lista ([]).

Uma lista no Haskell pode conter quantos elementos você desejar (até mesmo uma quantidade infinita) contanto que todos sejam do mesmo tipo.

O Haskell permite a criação de listas de diversas formas: através de funções, sequências, compreensão de listas, digitas diretamente. Vamos ver como podemos criar a lista [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] das duas últimas formas:

 main = do
   let lista1 = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
   let lista2 = [1..10]
   print lista1
   print lista2

A sintaxe [1..10] indica que queremos criar a lista como a sequência iniciando do número 1 até o 10 de um em um. Digamos que queremos a lista dos 5 primeiros números pares, [2, 4, 6, 8, 10], podemos fazer:

 main = do
   let lista1 = [2, 4, 6, 8, 10]
   let lista2 = [2,4..10]
   let lista3 = [2*i | i <- [1..5]]
   print lista1
   print lista2
   print lista3

A sintaxe da lista3 é denominada compreensão de lista (list comprehension) e permite utilizar uma notação matemática de conjuntos. Podemos traduzir essa sintaxe para:

\[\{ 2x | x \in [1,5] \}\]

Ou, “o dobro de x para todo x de 1 a 5”. Em uma próxima aula abordaremos o uso de funções para construção de listas.

As listas possuem operadores e funções especiais para lidar com elas:

  • head: retorna o primeiro elemento da lista.
  • tail: retorna a cauda da lista, ou todos os elementos após o primeiro.
  • take n lista: retorna os n primeiros elementos da lista.
  • drop n lista: retorna a lista sem os n primeiros elementos.
  • lista !! i: retorna o i-ésimo elemento da lista.
  • ++: concatena duas listas.
  • :: adiciona um elemento a uma lista.

Compile e execute o seguinte programa para verificar o uso de cada uma dessas funções. Tente advinhar a saída de cada instrução print:

 main = do
  let lista = [1..10]
  print (head lista)
  print (tail lista)
  print (lista !! 5)
  print (take 2 lista)
  print (drop 8 lista)
  print (lista ++ [11..15])
  print (0 : lista)

Nesse ponto é importante destacar uma característica da linguagem Haskell: a avaliação preguiçosa. A avaliação preguiçosa significa que o Haskell irá postergar a execução de todo e qualquer cálculo até que seja realmente necessário. Tome como exemplo o seguinte programa:

 main = do
   let lista = [1..10]  -- a lista não existe, apenas a instrução de como criá-la
   print (head lista)   -- nesse ponto foi requisitado o primeiro elemento, apenas ele será criado
   print (take 4 lista) -- o primeiro elemento já existe, computa os 3 seguintes

Por conta disso, a linguagem Haskell permite a criação de listas infinitas:

 main = do
   let lista = [2,4..]   -- lista de todos os números pares!
   print (head lista)    -- 2
   print (take 4 lista)  -- [2, 4, 6, 8]
   print (product lista) -- estouro de memória!!!

Contanto que você não tente utilizar TODA a lista infinita, você pode trabalhar com essa abstração.

Char e String

Para manipularmos informações textuais o Haskell possui o tipo Char que representa um caractere e String que é uma lista de caracteres e é definido como:

data String = [Char]

Essas duas instruções são equivalentes:

let s1 = "Ola mundo!"
let s2 = ['O', 'l', 'a', ' ', 'm', 'u', 'n', 'd', 'o', '!']

Note que os caracteres são definidos por símbolos envolvidos em aspas simples e as strings por aspas dupas.

Uma vez que uma String é uma lista, podemos fazer as mesmas operações que fazemos com o tipo lista:

let x = "Ola" ++ (' ' : "mundo!") -- concatena "Ola" com a lista formada por ' ' e "mundo!"

Bool

O tipo Bool visto anteriormente é definido como:

data Bool = True | False

Ou seja, assume os valores de verdadeiro ou falso. Esse tipo possui os operadores: && (e), || (ou) e not (não) e é gerado a partir de operações como ‘==’, ‘/=’, ‘<’, ‘>’, ‘<=’, ‘>=’.

Tupla

Uma tupla, definida por (a, b, ..) é um recipiente (container) de dados heterogêneo, ou seja, permite misturar os tipos:

let x = (1, "palavra", False)

Esse tipo de dado possui apenas duas funções padrões para manipulá-lo: fst, que retorna o primeiro elemento e snd, que retorna o segundo. Se uma tupla contém mais do que dois elementos podemos facilmente criar uma função thrd da seguinte forma:

thrd :: (t1, t2, t3) -> t3
thrd (a, b, c) = c

Os nomes t1, t2, t3 são entendidos pelo Haskell como tipos genéricos, ou seja, lemos como “a função thrd recebe uma tripla contendo elementos de 3 tipos diferentes e retorna um valor do terceiro tipo”.

De String para outros tipos e vice-versa

Em algumas situações podemos precisar converter uma String em um tipo numérico (leitura de entrada do usuário, texto de um arquivo, etc.) ou converter um tipo qualquer em String (para imprimir na tela).

Para isso utilizamos as funções read e show:

x = read "2" :: Integer
y = read "2.3" :: Double
z = read "True" :: Bool
sx = show x
sy = show y
sz = show z

Note que a função read requer a especificação do tipo a ser lido. A função print que utilizamos até então é definida como:

print = putStr . show

Que significa: “aplique a função show no parâmetro de entrada e, em seguida, a função putStr que imprime uma string”.