Vamos mergulhar um pouquinho em Haskell? A ideia é fazer um pouco de aritmética de Peano. Estou usando o site https://www.jdoodle.com/execute-haskell-online para testar o código Haskell.

Aritmética de Peano

Peano criou um conjunto de axiomas para construir os números naturais. Entre diversos formalismos, duas coisas se destacam para a construção do sistema de números:

  1. existe um elemento que pertence aos naturais
  2. o sucessor desse elemento é um número natural

O conjunto de axiomas de Peano vai garantir questões como igualdade, que não seja possível possível entrar em laço e propriedade da função de sucessão. Você pode encontrar mais sobre isso nessa página da Wikipedia.

Definindo tipo base

Por uma questão de convenção, comecemos com o elemento 0. Os números naturais são zero ou algo que venha depois de um número natural. Então, podemos definir nosso tipo de dados:

data Nat = Zero | Suc Nat

Ok, já temos um começo. Se eu quiser representar o número 1, eu só preciso Suc Zero. Se eu quiser o número 2, só fazer Suc Suc Zero, certo? Bem, não tão rápido. Nesse caso vou precisar englobar o natural anterior entra parênteses, já que o Suc é definido para um natural, e o próximo token que ele recebe é Suc que não é um natural por si só. Então eu preciso colocar entre parênteses: Suc (Suc Zero). Agora eu atendo a questão, já que Zero é natural e Suc Nat também é. Aos pouquinhos, é como se fosse algo assim (muito freestyle, apenas o jeito como eu reconheci a questão, não leve como sendo formalidade):

Suc (Suc Zero) -- `Zero` é `Nat`
Suc (Suc Nat) -- `Suc Nat` é `Nat`
Suc Nat -- `Suc Nat` é `Nat`
Nat

Depurando

Perfeito. Entendimento concluído. Agora, para imprimir naturais? Para eu conseguir visualmente olhar se estou fazendo a coisa certa? Bem, temos a função print. Vamos testar:

print (Suc (Suc Zero))
jdoodle.hs:62:1: error:
    • No instance for (Show Nat) arising from a use of ‘print’
    • In a stmt of a 'do' block: print (Suc (Suc Zero))
      In the expression: do print (Suc (Suc Zero))
      In an equation for ‘main’: main = do print (Suc (Suc Zero))
   |
62 | print (Suc (Suc Zero))
   | ^^^^^^^^^^^^^^^^^^^^^^

Hmmm, não deu certo. está falando aqui que não tem instância para Show Nat. Pesquisei um pouco e no StackOverflow achei uma resposta que indicava o caminho: preciso indicar quem é Show Nat, mais ou menos assim:

instance Show Nat where
    show = someFancyFunction

O padrão é nomear a função que ele chamará com show<Type>, então no caso aqui seria chamar de showNat. Bem, vamos precisar que a função seja Nat -> String. Vamos primeiro ensinar como lidar com o Zero?

showNat :: Nat -> String
showNat Zero = "Zero"

instance Show Nat where
    show = showNat

print Zero
[1 of 1] Compiling Main             ( jdoodle.hs, jdoodle.o )
Linking jdoodle ...
Zero

Perfeito, funcionou! Vamos expandir pro sucessor do zero:

showNat :: Nat -> String
showNat Zero = "Zero"

instance Show Nat where
    show = showNat

print (Suc Zero)
[1 of 1] Compiling Main             ( jdoodle.hs, jdoodle.o )
Linking jdoodle ...

jdoodle: jdoodle.hs:6:1-21: Non-exhaustive patterns in function showNat

Ok, justo. Ele não conseguiu achar algo digno. Então, vamos ensinar ele como lidar com o sucessor de um número. Usemos aqui o operador ++, que faz concatenação entre strings:

showNat :: Nat -> String
showNat Zero = "Zero"
showNat Suc v = "Suc " ++ (showNat v)

instance Show Nat where
    show = showNat

print (Suc Zero)
[1 of 1] Compiling Main             ( jdoodle.hs, jdoodle.o )
Linking jdoodle ...
Suc Zero

Perfeitinho. Vamos imprimir agora o sucessor dele:

showNat :: Nat -> String
showNat Zero = "Zero"
showNat Suc v = "Suc " ++ (showNat v)

instance Show Nat where
    show = showNat

print (Suc (Suc Zero))
[1 of 1] Compiling Main             ( jdoodle.hs, jdoodle.o )
Linking jdoodle ...
Suc Suc Zero

Foi o suficiente na minha opinião. Mas… a função show deveria permitir que eu simplesmente colasse a string de volta no código Haskell, seria muito conveniente isso para mim. Mas ela não permite do jeito que está. Vamos por uns parênteses ao redor da representação interna?

showNat :: Nat -> String
showNat Zero = "Zero"
showNat Suc v = "Suc (" ++ (showNat v) ++ ")"

instance Show Nat where
    show = showNat

print (Suc (Suc Zero))
[1 of 1] Compiling Main             ( jdoodle.hs, jdoodle.o )
Linking jdoodle ...
Suc (Suc (Zero))

Hmmmm, funciona. Mas eu não achei adequado envelopar o Zero em (Zero). Vamos criar um caso especial para o sucessor do zero:

showNat :: Nat -> String
showNat Zero = "Zero"
showNat Suc Zero = "Suc " ++ (showNat Zero)
showNat Suc v = "Suc (" ++ (showNat v) ++ ")"

instance Show Nat where
    show = showNat

print (Suc (Suc Zero))
[1 of 1] Compiling Main             ( jdoodle.hs, jdoodle.o )
Linking jdoodle ...
Suc (Suc Zero)

Minha primeira implementação eu fiz em cima de show na verdade, e funcionava:

showNat :: Nat -> String
showNat Zero = "Zero"
showNat Suc v = "Suc " ++ (show v)

instance Show Nat where
    show = showNat

Mas particularmente achei mais elegante depender apenas de showNat em recursão direta, do que delegar para show que depois eu iria fazer o bind de volta para showNat, ocasionando uma recursão indireta.

Função de soma

Bem, pra somar a moda aritmética de Peano, primeiro eu pensei nos casos de soma com zero:

addNat :: Nat -> Nat -> Nat

addNat Zero X = X
addNat X Zero = X

Ok, e além disso? Bem, posso tirar de um lado e passar pro outro, até esgotar um lado:

addNat x (Suc y) = addNat (Suc x) y

Com o tempo, o lado direito vai sendo secado até não sobrar nada. A cada passo, se incrementa algo no lado esquerdo. Como o lado direito vai sempre diminuindo, e ele é composto de algum sucessor de zero, ele eventualmente chegará no valor zero. Portanto, essa recursão vai chegar ao fim, e o valor irá ficar no operador esquerdo (caso addNat X Zero = X).

Função de cauda

A recursão aqui consiste em chamar constantemente a função addNat, com valores distintos, até chegar num valor final. Note que outra possível maneira de fazer isso seria diminuindo o problema e chamando o Suc de uma soma menor:

addNat x (Suc y) = Suc (addNat x y)

Esse é o tipo caso de recursão estrutural, em que o problema fica menor a cada passo. Porém, nesse caso aqui, eu preciso manter uma espécie de estado anterior, pois eu só posso aplicar o Suc na saída de addNat. Em Trampolim, exemplo em Java foi feito um exemplo de chamada de cauda para Fibonacci. Também foi feita uma versão dessa chamada de cauda para a função sum para possibilitar o trampolim.

Podemos descrever as duas versões de sum descritas no artigo sobre trampolim:

sum_classico :: Int -> Int
sum_classico 0 = 0
sum_classico n = n + (sum_classico (n-1))


sum_tailcall :: Int -> Int -> Int
sum_tailcall 0 acc = acc
sum_tailcall n acc = sum_tailcall (n-1) (acc+n)

sum_tailcall_bootstrap :: Int -> Int
sum_tailcall_bootstrap n = sum_tailcall n 0

Qual a diferença entre elas? Bem, que no caso de sum_classico precisa manter implicitamente o valor até a resolução da função, enquanto que em sum_tailcall o valor é passado adiante. Em linguagens que permitem otimização de chamada de cauda, a chamada de cauda do jeito que foi feito em sum_tailcall (ie, que não precisa manter implicitamente o estado) é potencialmente muito mais eficiente.

Veja esse post do Leandro Proença que ele fala mais sobre chamada de cauda e otimização de chamada de cauda (tail call optimization, TCO): Entendendo fundamentos de recursão.

Aliases de tipo

Conforme eu ia avançando no código comecei a ficar bem chateado de ficar repetindo sempre a declaração do tipo da função: Nat -> Nat -> Nat. Então decidi que deveria ter menos trabalho. Criei então um tipo com a intenção pura e simples de diminuir meu trabalho:

type BinNat = Nat -> Nat -> Nat

addNat :: BinNat

addNat Zero X = X
addNat X Zero = X

Multiplicação

Para a multiplicação, resolvi adotar uma estratégia menos elaborada. Como x*(n + 1) = x + x*n, implementei isso literalmente. Aqui eu não deixei a chamada de cauda adequada para qualquer eventual otimização:

multNat :: BinNat

multNat _ Zero = Zero
multNat Zero _ = Zero
multNat _ Zero = Zero
multNat (Suc x) y = addNat y (multNat x y)

Ah, nota algo aqui bem legal: o argumento com _ significa algo que será prontamente ignorado. Não importa com o que estou multiplicando, ao multiplicar com zero o valor é zero.

Se eu quisesse ir para um lado voltado para otimização da chamada de cauda:

multNat :: BinNat

multNat x y = bootstrap_multNat x y Zero

bootstrap_multNat :: Nat -> Nat -> Nat -> Nat
bootstrap_multNat Zero _ acc = acc
bootstrap_multNat _ Zero acc = acc
bootstrap_multNat (Suc x) y acc = bootstrap_multNat x y (addNat y acc)

Módulo e divisão

Vamos calcular o módulo de um número natural pelo outro? O jeito mais fácil de fazer n % d é quando n < d, porque isso quer dizer que o módulo é n. Então, vamos tentar reconhecer se um número é maior do que o outro?

Menor que

Temos aqui uma função que vai pegar dois naturais e retornar um booleano:

ltNat :: Nat -> Nat -> Bool

Começando com o caso base, dos dois argumentos como zero. Temos que a avaliação vai ser falsa, pois zero não é menor do que zero:

ltNat Zero Zero = False

Joia. E se eu encontrar zero do lado esquerdo e algo maior do que zero do lado direito? Aí vai ser verdade!

ltNat Zero (Suc _) = True

Mas o contrário é falso:

ltNat (Suc _) Zero = False

Mas se os dois forem maiores do que zero? Bem, podemos tirar um de cada e verificar de novo, recursivamente:

ltNat (Suc x) (Suc y) = ltNat x y

Condições

Agora que temos a condicional, como poderíamos calcular o módulo?

Por hora vamos ignorar a saída no caso que n >= d, vamos fixar aqui para questão de teste que esse caso vai retornar um placeholder, zero. Para essas situações, podemos usar um if:

modNat :: BinNat
modNat n d = if (ltNat n d) then n else Zero

Ótimo. Mas agora precisamos resolver de fato o caso do else. Como estamos lidando com o módulo, temos que n % d == (n-d) % d, portanto vamos diminuir o problema e calcular recursivamente:

modNat :: BinNat
modNat n d = if (ltNat n d) then n else modNat (diffNat n d) d

Para uma questão de evitar problemas de cálculos, vou usar como convenção que o resto da divisão por zero é zero, só para não precisar mexer no tipo de modNat:

modNat :: BinNat
modNat _ Zero = Zero
modNat n d = if (ltNat n d) then n else modNat (diffNat n d) d

Agora precisamos calcular diffNat. Como naturais não tem números negativos, vou simplesmente usar a liberdade de definir que, caso eu tente remover mais elementos do que eu tenho, eu fico com zero:

diffNat :: BinNat
diffNat Zero _ = Zero

Como a ideia é sair removendo até chegar no final, ao esgotar o quanto de coisa se tem pra remover, também cheguei ao fim:

diffNat x Zero = x

E nos outros casos? Bem, tal qual fizemos com o “menor que”, vamos retirar um ponto de cada um e continuar recursivamente. Afinal, x - y == (x-1) - (y-1):

diffNat (Suc x) (Suc y) = diffNat x y

A função como um todo:

diffNat :: BinNat
diffNat Zero _ = Zero
diffNat x Zero = x
diffNat (Suc x) (Suc y) = diffNat x y

modNat :: BinNat
modNat n d = if (ltNat n d) then n else modNat (diffNat n d) d

Para achar o resultado da divisão, podemos seguir um pensamento semelhante. Ao chegar na situação que n < d, temos que o resultado da divisão é 0. Caso n >= d, temos que o resultado da divisão inteira vai ser um além do que (n-d) // d, ou seja, n//d == 1 + (n-d)//d:

divNat :: BinNat
divNat n d = if (ltNat n d) then Zero else Suc(divNat (diffNat n d) d)

De modo semelhante a o que foi com modNat, vou usar como convenção o retorno zero para divisão por zero:

divNat :: BinNat
divNat _ Zero = Zero
divNat n d = if (ltNat n d) then Zero else Suc(divNat (diffNat n d) d)

Records

Se por acaso eu precisar do resultado do módulo e da divisão inteira entre dois números, como proceder? Bem, já temos o suficiente para isso com as coisas acima, mas isso não é eficiente. Não deveria ser necessário fazer essa conta duas vezes.

Temos aqui que sempre damos um passo para baixo, e no caso da divisão pegamos o resultado desse passo e somamos um. Seria interessante se a linguagem desse um tipo que permitisse extrair essas informações… E adivinha? Tem sim.

Podemos pegar um record, uma espécie de tupla nomeada. Você declara o formato como quer o seu tipo e isso já vai te dar funções especiais que trazem as informações de cada campo. Por exemplo, queremos o resultado da divisão e do módulo, vou chamar esse tipo de dado de DivMod:

data DivMod = DivMod{ divN :: Nat, modN :: Nat}

let teste = DivMod{ divN = Zero, modN = (Suc Zero)}
print (divN teste) -- imprime Zero
print (modN teste) -- imprime Suc Zero

Para calcular o divModNat, eu preciso pegar o step e retornar o seguinte:

-- assuma `step :: DivMod`
DivMod{ divN=(Suc (divN step)), modN=(modN step) }

Isso é verdade onde o valor de step é definido como step = divModNat (diffNat n d) d. E o Haskell fornece where para isso:

divModNat :: Nat -> Nat -> DivMod
divModNat n d = if (ltNat n d) then DivMod{ divN=Zero, modN=n} else DivMod{ divN=(Suc (divN step)), modN=(modN step) }
                where step = divModNat (diffNat n d) d

Adicionando a salva guarda para a divisão por zero:

divModNat :: Nat -> Nat -> DivMod
divModNat _ Zero = DivModNat{ divN=Zero, modN=Zero }
divModNat n d = if (ltNat n d) then DivMod{ divN=Zero, modN=n } else DivMod{ divN=(Suc (divN step)), modN=(modN step) }
                where step = divModNat (diffNat n d) d

Ackermann Peter

Vamos fazer alguma computação pesada? Vamos computar o valor da função de Ackermann Peter?

Essa função tem dois casos base: uma quando o m é zero e outra quando o n é zero.

ackPeter :: BinNat
ackPeter Zero n = Suc n
ackPeter (Suc m) Zero = ackPeter m (Suc Zero)
ackPeter (Suc m) (Suc n) = ackPeter m (ackPeter (Suc m) n)

Fontes

Alguns links que serviram de base para o estudo deste post:

Muitas outras fontes foram usadas, mas esqueci de catalogá-las antes de escrever este post, então as perdi.

Você vai encontrar mais também em alguns gists que eu fix sobre Haskell:

Note que esses gists foram feitos antes de eu escrever este post, então diversas coisas que descobri depois estão de fora.