Introdução a Haskell

Published:

Funções

Uma função é a definição de uma expressão matemática que recebe um ou mais variáveis de entrada e retorna um valor de saída. No Haskell as funções contém apenas uma única instrução (embora possamos fragmentar tal instrução em múltiplas linhas). Ex.:

media :: Double -> Double -> Double
media x y = (x + y) / 2.0

A função média é descrita em apenas uma linha, recebe dois parâmetros do tipo Double e retorna um Double que é a média aritmética dos valores de entrada.

Um outro exemplo é o cálculo das raízes de uma equação do segundo grau, definida pelos valores de a, b, c:

raizSegGrau :: Double -> Double -> Double -> (Double, Double)
raizSegGrau a b c = ((-b - (sqrt (b**2 - 4*a*c)))/(2*a), (-b + (sqrt (b**2 - 4*a*c)))/(2*a))

A definição da função em uma linha, para esse caso, se torna confusa e repetitiva. A leitura dessa solução torna difícil sua manutenção e validação. Uma forma de quebrar essa expressão em múltiplas linhas é com a instrução where que permite a descrição da expressão principal de forma clara e a explicação detalhada de cada parte da expressão nas linhas seguintes:

raizSegGrau :: Double -> Double -> Double -> (Double, Double)
raizSegGrau a b c = (x1, x2)
  where
    x1 = (-b - sqrt delta) / (2*a)
    x2 = (-b + sqrt delta) / (2*a)
    delta = b**2 - 4*a*c

Nesse caso lemos que a função raizSegGrau recebe como parâmetro os valores a, b, c e retorna uma tupla (x1, x2), sendo que x1 é definido por (-b - sqrt delta) / (2*a), x2 por (-b + sqrt delta) / (2*a) e delta por b**2 - 4*a*c. Dessa forma, a expressão matemática se torna mais clara e melhor definida.

Para o Haskell entender a hierarquia das definições, a instrução where deve estar indentada em relação ao nome da função e as definições seguintes indentadas em relação ao where. Por convenção, indentamos com dois espaços o where e quatro espaços os demais.

Essa função irá retornar as raízes reais de qualquer equação do segundo, porém, nos casos em que a raíz é negativa, ela retornará (NaN, NaN) que significa Not a number. Podemos tratar esses casos específicos com o uso de guards, que são definições condicionais de padrões possíveis esperados da expressão:

raizSegGrau :: Double -> Double -> Double -> (Double, Double)
raizSegGrau a b c
  | delta < 0 = error "Raízes negativas!"
  | otherwise = (x1, x2)
  where
    x1 = (-b - sqrt delta) / (2*a)
    x2 = (-b + sqrt delta) / (2*a)
    delta = b**2 - 4*a*c

O guard representado por |, define condições especiais para a função. Devemos ler a função agora como a função raizSegGrau recebe como parâmetro os valores a, b, c e, se o delta for negativo emite uma mensagem de erro, caso contrário retorna a tupla contendo as raízes.

Note que a primeira linha de definição da função não contém o símbolo =, sendo esse utilizado apenas após as condições necessárias para a escolha do retorno da função. A instrução error emite uma mensagem de erro e encerra o programa.

Exercício 01: Escreva uma função para calcular a média entre dois valores x e y ponderado por w e definida por \(w \cdot x + (1 - w) \cdot y\). Note que para essa expressão funcionar, w deve ser definido no intervalo \([0, 1]\).

mediaPond :: Double -> Double -> Double -> Double

Exercício 02: Escreva uma função que recebe três valores numéricos e retorna uma tripla com esses valores em ordem crescente. Utilize os guards para definir todas as 6 possibilidades. Repare que a assinatura da função deve conter a classe Ord que define todos os números que permitam comparação.

crescente3 :: (Ord a, Num a) => a -> a -> a -> (a, a, a)

Uma outra forma de definir funções com casos especiais é através do pattern matching. Considere uma função chamada numero7 que retorna a string “Você ganhou!” sempre que a entrada for igual a 7 e “Você perdeu :(“, caso contrário. Podemos introduzir múltiplas definições da função para cada caso:

numero7 :: Integer -> String
numero7 7 = "Você ganhou!"
numero7 x = "Você perdeu :("

Em outro exemplo, imagine que queremos simplificar a função soma para os casos em que um dos valores seja igual a zero:

soma :: (Eq a, Num a) => a -> a -> a 
soma 0 y = y
soma x 0 = x
soma x y = x + y

A classe Eq indica que queremos aplicar a função para todos os valores numéricos que possam ser comparados com o operador de igualdade. Isso é necessário pois comparamos o valor de x e y com 0.

Similarmente, vamos definir uma função mult:

mult :: (Eq a, Num a) => a -> a -> a 
mult 1 y = y
mult x 1 = x
mult 0 _ = 0
mult _ 0 = 0
mult x y = x * y

O símbolo _ indica que não nos importamos com o valor dessa variável, uma vez que o resultado será sempre o mesmo independente do valor dela.

É importante notar que no Haskell os padrões e os guards são avaliados na ordem. Considere as seguintes funções:

maiorMenor1 :: (Ord a, Num a) => a -> String
maiorMenor1 x
  | x <= 5 = "Menor"
  | x >= 5 = "Maior"
  
maiorMenor2 :: (Ord a, Num a) => a -> String
maiorMenor2 x
  | x >= 5 = "Maior"  
  | x <= 5 = "Menor"

Essas duas funções retornarão um resultado diferente para x = 5 pois a igualdade é prioritária na primeira ocorrência.

Exercício 03: Crie uma função que retorne True caso o ano seja bissexto e False caso contrário. Um ano é bissexto se ele for divisível por 400 ou se ele for divisível por 4 mas não por 100. Crie três guards para essa definição.

bissexto :: Integer -> Bool
bissexto ano

Composição de Funções

Vamos imaginar que temos duas funções: uma para calcular a média ponderada das provas e outra para transformar a média final em conceito:

mediaFinal :: Double -> Double -> Double
mediaFinal p1 p2 = 0.4*p1 + 0.6*p2

conceito :: Double -> Char
conceito media
  | media < 5 = 'F'
  | media < 6 = 'D'
  | media < 7 = 'C'
  | media < 8 = 'B'
  | otherwise = 'A'

Digamos que queremos agora definir uma função chamada geraConceito que retorna o conceito final a partir das notas p1, p2. Podemos criar essa função como uma composição de funções. Podemos criar:

geraConceito :: Double -> Double -> Char
geraConceito p1 p2 = conceito $ mediaFinal p1 p2

O símbolo $ indica a prioridade de avaliação, primeiro o lado direito é avaliado e o resultado passado para a função do lado esquerdo.

Quando queremos compor funções de uma variável, podemos utilizar a notação matemática \((g \circ f)(x) = g(f(x))\), utilizando o símbolo .:

nota :: Double -> Double
nota x = x*2

conceito :: Double -> Char
conceito x
  | x > 5 = 'A'
  | otherwise = 'F'

calcConceito = conceito . nota

main = do
  print (calcConceito 5) -- imprime 'A'

Recursão

Um conceito bastante poderoso para abstração e definição de funções é a recursão, ou seja, funções definidas por um caso base e um caso de redução que é definida pela própria função. Vimos um exemplo na definição do algoritmo de Euclides para determinar o máximo divisor comum:

mdc :: Integer -> Integer -> Integer
mdc 0 b = b
mdc a 0 = a
mdc a b = mdc b (a `rem` b)

Os casos bases desse algoritmo são definidos quando um dos dois parâmetros é igual a zero, nesse caso existindo uma resposta bem definida. Para os outros casos, reduzimos o problema definindo a função através da própria mas com parâmetros reduzidos.

A ideia por trás dessa definição é de que, a cada chamada da função mdc os parâmetros a, b se tornem cada vez menores até que eventualmente um deles se torne 0 permitindo a obtenção do resultado.

Considere a definição do fatorial:

\[n! = n \cdot (n-1) \cdot (n-2) \cdot (n-3) \dots 1\]

Podemos definir essa função recursivamente como:

\[0! = 1\] \[1! = 1\] \[n! = n \cdot (n-1)!\]

Temos as definições bases para os casos com resultado conhecido e a definição genérica para calcularmos casos desconhecidos. Ao tentar calcular $4!$ seguindo tal definição, faríamos os seguintes passos:

\[4! = 4 \cdot 3!\] \[4! = 4 \cdot 3 \cdot 2!\] \[4! = 4 \cdot 3 \cdot 2 \cdot 1!\] \[4! = 4 \cdot 3 \cdot 2 \cdot 1 = 24\]

No Haskell temos:

fatorial :: Integer -> Integer
fatorial 0 = 1
fatorial 1 = 1
fatorial n = n * fatorial (n-1)

Exercício 04: O número de Fibonacci é definido por:

\[F(0) = 0\] \[F(1) = 1\] \[F(2) = 1\] \[F(n) = F(n-1) + F(n-2)\]

Escreva a função correspondente em Haskell.

fibonacci :: Integer -> Integer

Nota: as linguagens de programação imperativas e orientadas a objeto implementam a recursão com o conceito de pilhas. Para isso a cada chamada recursiva da função todo o estado atual da memória é armazenado, a chamada atual é suspensa enquanto aguarda o retorno da função recursiva. Considere o caso do fatorial(5):

fatorial(5) = 5 * fatorial(4) // armazena todo o estado da função na pilha fatorial(4) = 4 * fatorial(3) // armazena mais esse estado na pilha, etc.

Isso ocasiona o chamado estouro de pilha (stack overflow) quando armazenamos muitas chamadas recursivas na memória através da pilha. No Haskell, por conta da avaliação preguiçosa, ele não armazena o estado da chamada anterior, mas apenas expande a expressão como uma promessa de cálculo realizando o cálculo de forma similar ao que fazemos manualmente. Ao invés de pilha de estado, ele usa uma pilha de expressão a ser avaliada, o que na prática aumenta a capacidade de avaliar expressões maiores sem ocorrer estouro de pilha. Para evitar tais problemas utilizamos o conceito de recursão caudal.

Recursão caudal

A recursão caudal ocorre quando a expressão de retorno de uma função recursiva consiste apenas da chamada recursiva. Ex.:

fatorial n = n * fatorial (n-1) -- não é caudal, pois existe um n * a ser avaliado
gcd a b = gcd b (a `rem` b)     -- caudal, pois o retorno consiste apenas da chamada de gcd

Muitas funções recursivas podem ser facilmente convertidas para uma recursão caudal. Considere a função fatorial:

fatorial :: Integer -> Integer
fatorial 0 = 1
fatorial 1 = 1
fatorial n = fatorial' n 1
  where
    fatorial' 1 r = r
    fatorial' n r = fatorial' (n-1) (n*r)

A função fatorial passou a definir uma função auxiliar fatorial' de dois parâmetros, um do número que queremos calcular o fatorial e outro armazenando os resultados parciais. Vamos simular a execução de fatorial' 4 1 para entendermos como o cálculo é realizado:

fatorial' 4 1 = fatorial' 3 4 = fatorial' 2 12 = fatorial' 1 24 = 24

Essa forma de recursão é facilmente otimizada pelo compilador para evitar problemas de estouro de pilha. Quando tratarmos do conceito de Listas aprenderemos uma forma simples de calcular o número de fibonacci sem utilizar recursão não-caudal.

Exercício 05: Crie a função multEtiope que define o conceito de Multiplicação Etíope. A multiplicação etíope pode ser descrita como, dados dois valores inteiros m, n, se m for ímpar, o resultado será n adicionado do resultado de multEtiope (m `div` 2) (n*2), caso contrário será igual o resultado de multEtiope (m `div` 2) (n*2). Quando m = 1 o resultado será n. Tente modificar a função recursiva para se tornar caudal.