Veja a playlist no Youtube com todos os vídeos do curso.
Os tipos padrões são suficientes para construírmos nossos programas e inferirmos alguns fatos sobre as funções que estamos implementando. Por exemplo, considerando a seguinte assinatura de função, temos um número limitado de operações que podemos efetuar:
f :: Int -> Int
Sabendo que a entrada e saída dessa função são valores do tipo Int
,
podemos somar um valor a entrada, ou talvez multiplicar esse valor
por uma constante:
f :: Int -> Int
f x = x + 1
g :: Int -> Int
g x = x * 2
Além das operações óbvias que podemos fazer com um inteiro, podemos
também gerar transformações intermediárias nesse tipo, contanto
que o resultado final seja um Int
.
f :: Int -> Int
f = length . show
Ou seja, o conjunto de operações permitidas dentro de uma função pode se estender caso tenhamos funções intermediárias de conversão de tipos.
Quando falamos em desenvolvimento guiado por tipos, a assinatura da função deve restringir ao máximo o conjunto de operações que podemos aplicar internamente e que façam sentido ao contexto.
No nosso exemplo acima, o que esse inteiro representa? É uma moeda? Altura de uma pessoa em centímetros? Temperatura atual? Dependendo do contexto, o conjunto de operações pode variar. Faze sentido multiplicarmos duas alturas? Saber quantos dígitos tem a quantidade de dinheiro que você possui?
Para isso, precisamos que a linguagem permita envolver os tipos de entrada e saída da função dentro de um contexto. Uma forma de fazer isso é criando apelidos para tipos.
Para apelidar os tipos em Haskell utilizamos a palavra-chave type
seguida
do apelido que queremos dar e o tipo que estamos apelidando separados pelo
sinal de igualdade =
:
type Money = Int
withdraw :: Money -> Money -> Money
withdraw account qty
| account < qty = error "Are you trying to rob a bank?"
| otherwise = account - qty
Sabendo que os inteiros representam moedas, podemos pensar um pouco mais sobre o que podemos e devemos fazer na implementação da função. Por exemplo, sabemos que o saldo na conta não pode ficar negativo e, com isso, criamos um caso de exceção. Note que com isso, a função se tornou parcial e, ao ser utilizada de forma incorreta, emite um erro em tempo de execução. Em breve veremos como transformar tais funções em totais.
Um ponto problemático de type
é que ele cria apenas um apelido para um
tipo (à la typedef
em C). Para todos os efeitos, todo valor do tipo Money
é simplesmente um Int
e todo valor do tipo Int
pode ser utilizado como
Money
. Portanto, isso aqui é válido:
type Money = Int
type Temperature = Int
x = -3 :: Temperature
y = withdraw x 4
Mesmo que x
seja do tipo incorreto, ele é aceito durante a compilação pois,
ao compilar o código, todos os tipos definidos por type
são substituídos
pelos tipos correspondentes em uma etapa de pré-processamento.
O que precisamos para tornar nosso código mais seguro é criar novos tipos que são completamente distintos dos tipos já existentes.
Podemos criar um novo tipo que possui uma interpretação diferente
de um Int
utilizando a palavra-chave newtype
:
newtype Money = MkMoney Int
withdraw :: Money -> Money -> Money
withdraw (MkMoney account) (MkMoney qty)
| account < qty = error "Are you trying to rob a bank?"
| otherwise = MkMoney (account - qty)
Agora temos um tipo Money
que é um tipo completamente diferente
dos tipos que temos disponíveis. Esse tipo simplesmente armazena
um valor do tipo Int
. Esse valor está encapsulado dentro de uma
caixa definida pelo construtor MkMoney
.
Um construtor de valores de um tipo pode ser visto como uma função
que recebe um valor do que ele irá conter e devolve um valor do
tipo desejado. E, de fato, ao consultarmos o tipo de MkMoney
no
ghci, obtemos:
> :t MkMoney
MkMoney :: Int -> Money
Podemos ler da seguinte maneira: “se você me der um valor do tipo
Int
, eu te devolvo um valor do tipo Money
que contém esse
inteiro.
A criação desse novo tipo cria um inconveniente pois precisamos
implementar todas as operações fundamentais necessárias ou
conviver com o uso do pattern matching na definição das funções.
Por exemplo, podemos definir uma instância de Num
para esse novo
tipo:
newtype Money = MkMoney Int
instance Num Money where
(MkMoney x) + (MkMoney y) = MkMoney (x + y)
(MkMoney x) - (MkMoney y) = MkMoney (x - y)
(MkMoney x) * (MkMoney y) = MkMoney (x * y)
negate (MkMoney x) = MkMoney (negate x)
abs (MkMoney x) = MkMoney (abs x)
signum (MkMoney x) = MkMoney (signum x)
fromInteger x = MkMoney x
Com isso nossa função withdraw
se torna:
newtype Money = MkMoney Int
withdraw :: Money -> Money -> Money
withdraw account qty
| account < qty = error "Are you trying to rob a bank?"
| otherwise = account - qty
Porém, esse é um padrão tão comum que o compilador consegue criar essas instâncias automaticamente.
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
newtype Money = MkMoney Int
deriving (Read, Show, Num)
Com a extensão GeneralizedNewtypeDeriving
podemos
derivar automaticamente as instâncias de Read, Show, Num
dentre
outras para nosso tipos criados por newtype
. O compilador infere
para a gente as operações numéricas! Para mais informações consulte
a documentação do GHC.
Essa derivação automática reflete um pouco a quantidade de informação contida em um tipo. Por saber exatamente o tipo que ele carrega e as operações fundamentais, o compilador desenvolve (== gera código automaticamente) diversas funcionalidades sem intervenção do desenvolvedor.
Retomando nosso exemplo original, se implementarmos o seguinte código o compilador vai retornar uma mensagem de erro:
x = -3 :: Int
y = withdraw x (MkMoney 5) -- erro de compilação
Agora, o tipo de x
não é o mesmo que o tipo esperado pela função
withdraw
, não podemos misturar valores de natureza diferente.
Ainda assim, podemos fazer algo como withdraw (fromInteger x) (MkMoney 5)
e o compilador aceitará. Veremos algumas estratégias para evitar
essa situação mais adiante.
Em algumas situações queremos que nossos tipo contenham múltiplos valores do mesmo ou de diferentes tipos. Por exemplo, considerem que você quer representar sua conta no banco com um valor na sua conta corrente e outro em um determinado investimento:
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
newtype Money = MkMoney Int
deriving (Read, Show, Num)
newtype Account = MkAccount (Money, Money)
Para juntar essa informação você pode utilizar o tipo tupla. Porém,
com essa definição nosso código não possui uma semântica clara: qual
dos dois valores do tipo Money
representa a conta corrente?
Se quisermos incluir um novo campo para indicar dívidas com o banco
e acrescentarmos uma String
junto da informação de investimento, o
nosso tipo passa a ser:
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
newtype Money = MkMoney Int
deriving (Read, Show, Num)
newtype Account = MkAccount ((Money, (String, Money)), Money)
A cada novo campos que acrescentamos, maior será o aninhamento
de tuplas. Isso faz com que nosso código fique cada vez mais difícil de manter.
Além disso cada um dos tipos Money
podem permitir um conjunto diferente
de operações. Por exemplo, você pode depositar e sacar da sua conta, mas só pode
pagar sua dívida.
Um outro aspecto importante, tanto da definição dos tipos, como das funções é a parametricidade. Um tipo paramétrico é criado de tal forma que parte de sua informação será definida posteriomente, dependendo do contexto da aplicação.
Digamos que queremos criar um tipo que armazena uma lista da
frequência observada de cada um dos valores de um certo tipo. A
contagem é representada pelo tipo Int
, o tipo que queremos contar
deve ficar em aberto para definirmos durante a aplicação:
newtype Counter a = MkCounter [(a, Int)]
O tipo Counter
armazena uma lista de tuplas de um tipo a
(seja
ele qual for), junto de um Int
, representando quantas vezes
observamos dado valor de a
. Nessa sintaxe, o a
representa um
parâmetro ou variável de tipo . Em outras palavras, deixamos
em aberto o tipo que Counter
carregará.
countWords :: Counter String
countWords = MkCounter [("Oi", 10), ("Olá", 15), ("Mundo", 25)]
evidences :: Counter Bool
evidences = MkCounter [(False, 10), (True, 15)]
Quando o tipo possui um parâmetro de tipo, dizemos que ele é um Tipo de Dados Abstrato , pois certos detalhes de sua definição estão escondidos. Podemos implementar funções que recebem versões específicas desse tipo ou funções abstratas que também deixam a definição em aberto:
checkFacts :: Counter Bool -> Double
checkFacts counts = trues / falses
where
trues = fromIntegral $ filterAndGroup id counts
falses = fromIntegral $ filterAndGroup not counts
filterAndGroup :: (a -> Bool) -> Counter a -> Int
filterAndGroup p (MkCounter xs) = foldr countIf 0 xs
where
countIf (x, c) acc
| p x = c + acc
| otherwise = acc
A função checkFacts
aceita apenas valores do tipo Counter Bool
e,
como consequência, a função que é passada como argumento de filterAndGroup
dentro dessa implementação deve ser do tipo Bool -> Bool
. O contexto
dessa função restringe as outras funções utilizadas dentro dela.
Já a função filterAndGroup
aceita o tipo Counter a
para qualquer
a
. Para tanto, um dos argumentos deve ser uma função que recebe
exatamente esse tipo a
e devolve um Bool
.
É tentador imaginar que uma função genérica que recebe um tipo a
é
mais flexível em sua implementação. Porém, veremos mais adiante que,
na verdade, ela restringe o conjunto de implementações possíveis.
Tipos mais interessantes podem ser criados como uma composição de tipos fundamentais, conforme veremos em seguida.
Vimos anteriormente que podemos utilizar o newtype
para criar
novos tipos de dados e, com certas limitações, tipos compostos.
Tanto no Haskell como em muitas linguagens modernas, podemos
criar novos tipos com maior flexibilidade utilizando os
Tipos de Dados Algébricos .
A ideia dos Tipos de Dados Algébricos, ou ADTs (Algebraic Data Types), é que podemos construir novos tipos utilizando operações algébricas de soma e produto.
Os tipos internos da linguagem e os novos tipos criados servem como operandos dos tipos algébricos. Como veremos mais adiante, as operações algébricas dos ADTs também possuem algumas das propriedades observadas na aritmética, para isso, temos dois tipos fundamentais que representam os valores \(0\) e \(1\).
data Void
data () = ()
A definição de um novo tipo é feita com a palavra-chave data
. A sintaxe
completa é data Nome-do-tipo = Valores
. Tanto o nome do tipo, como seus
valores devem iniciar em letras maiúsculas (exceto quando usamos símbolos).
Dos tipos definidos acima, Void
é um tipo que não contém valor nenhum e,
portanto, não podemos tentar avaliar um valor desse tipo. Apesar dessa limitação,
esse tipo pode nos ajudar a guiar o desenvolvimento de nossas aplicações, como
veremos em breve.
O tipo ()
, também conhecido como unitário (en: unit),
possui apenas um único valor (()
). Inicialmente esse tipo parece
muito chato e previsível, pois sempre que observamos um valor
desse tipo, ele será o mesmo. Porém, assim como o Void
, ele
também será de grande ajuda no desenvolvimento guiado por tipos,
talvez com outros nomes.
Tendo definido os tipos fundamentais, vamos aprender em seguida os tipos produto e soma.
O Tipo Produto compõe dois ou mais tipos criando um novo tipo que carrega informação de todos eles. Em Haskell, um Tipo Produto é definido pela sintaxe
data Nome = Construtor Tipo1 Tipo2 .. TipoN
Esse tipo é análogo ao struct
da linguagem C e C++:
struct Nome {
Tipo1 x;
Tipo2 y;
...
TipoN z;
}
Vamos exemplificar criando um tipo que contém um Bool
indicando
se um usuário é admin, uma String
com o nome do usuário e
uma função que recebe um Int
representando o número de uma sala,
acessa um banco de dados e devolve um Int
representando
um PIN Code para ser utilizado naquela porta.
data User = MkUser Bool String (Int -> IO Int)
Contrastando com o uso de newtype
e tuplas, essa sintaxe é bem melhor.
Porém, podemos deixar mais clara a intenção de cada campo usando a sintaxe
para registros record syntax :
data User = MkUser { _isAdmin :: Bool
, _username :: String
, _openDoor :: Int -> IO Int
}
Essa sintaxe se assemelha ainda mais ao struct
de C e NamedTuples
de Python.
Os nomes dos campos são funções criadas automaticamente em tempo de compilação
que recupera o respectivo campo. Por convenção os nomes dessas funções começam com
underline.
Com isso podemos fazer:
openSecretDoor :: User -> IO Int
openSecretDoor user
| _isAdmin user = getSecretPIN
| otherwise = print "Nice try, Mr. Robot!" >> pure 0
O objetivo dessa função é permitir que um usuário com permissão de admin possa abrir uma porta secreta, ou seja, ao chamar essa função para um usuário que tenha a permissão correta, ela deve retornar o código PIN para abertura da porta. Caso contrário, imprimimos uma mensagem de erro e retornamos um código \(0\).
Está claro através do nome da função e de seu tipo o que desejamos implementar. Ainda assim, o desenvolvedor pode esquecer de verificar se o usuário tem permissão ou inverter a verificação, levando a um código incorreto e inseguro. Será que conseguimos restringir ainda mais essa função?
Antes de respondermos essa pergunta, vamos verificar o quanto de informação o tipo produto traz para nossas implementações.
Ao criar uma biblioteca que define uma nova estrutura do tipo produto junto com algumas operações, você restringe as implementações possíveis de funções que recebem ou devolvem esse tipo.
Considere a função openSecretDoor
definida acima. Ao receber um
argumento do tipo User
, em primeiro momento você está limitado a
aplicar apenas três funções: _isAdmin, _username, _openDoor
. A
partir desse ponto, sua função pode aplicar qualquer função que
aceite como entrada os tipos de retorno de uma dessas três
funções. Ainda assim temos bastante opções de implementação…
Um outro ponto a ser considerado é que pode não ser interessante permitir que o usuário de sua biblioteca possa criar um valor desse tipo, imagine que seja possível fazer o seguinte no código dele:
hackTheSystem :: User
hackTheSystem = MkUser True "mrRobot" (pure 42)
Ele teria como acessar a informação para abrir a porta secreta…
Uma forma de melhorar a segurança é limitar o que estará exposto ao usuário de sua biblioteca:
module Users
( createNewUser
, getYourUser
, openSecretDoor
, openDoor
) where
getYourUser :: String -> String -> IO User
getYourUser username password = do
db <- openDB
user <- queryDB db username password
closeDB db
return user
createNewUser :: User -> Bool -> String -> (Int -> IO Int) -> IO ()
createNewUsere user isAdm username opendoors
| _isAdmin user = openDB >>= createUser isAdm username opendoors >>= closeDB
| otherwise = print "ACCESS DENIED! TERMINATE!"
openSecretDoor :: User -> IO Int
...
openDoor :: User -> Int -> IO Int
openDoor = _openDoor
Dessa forma o usuário dessa biblioteca só terá disponível para uso
essas quatro funções. Com isso, ele não consegue criar um novo usuário
exceto se ele for admin.
Como bônus, limitamos ainda mais as operações que podemos fazer com uma função
que recebe ou retorna valores do tipo User
. Imagine uma função com a seguinte
assinatura:
funcao :: User -> User
Quais são as implementações possíveis?
funcao :: User -> User
funcao = id
E a seguinte função?
funcao :: User -> IO String
Essa permite algumas implementações extras:
funcao :: User -> IO String
funcao user = do
pass <- openSecretDoor
pure (show pass)
funcao :: User -> IO String
funcao user = do
passwords <- mapM (openDoor user) [0 .. 1000]
pure (concatMap ((++",") . show) passwords )
Além dessas implementações temos uma variedade de outras possibilidades.
Imagine que, ao converter um Int
para String
, podemos fazer todas
as implementações de uma função String -> IO String
.
De todo modo, o contexto da aplicação nos permite limitar essas possibilidades.
Certamente a implementação correta não é aplicar o valor 0
repetidamente
na função openDoor
. Caso a funcao
tenha um nome específico da tarefa
que queremos executar, a intuição do que queremos implementar se torna ainda
mais clara.
O Tipo Soma permite você enumerar os possíveis valores de um tipo.
Essa definição de tipo é similar ao enum
do C, porém mais
seguro e flexível. Para criar um tipo soma, definimos os valores separados por |
:
data Nome = Valor1 | Valor2 | ... | ValorN
Em C, isso é equivalente a
enum Nome{ Valor1, Valor2, ... ValorN };
Só que, diferente da linguagem C, os nomes Valor1, Valor2, ...
não são
apenas apelidos para uma sequência de int
. Eles são valores únicos e bem
definidos.
Continuando com nossos exemplos, podemos criar um tipo que descreve o estado atual da porta que queremos abrir.
data Door = Open | Closed
deriving Eq
Notem que Open, Closed
são valores do tipo Door
e não podemos
fazer nada com eles além de checar se dois valores desse tipo são iguais.
Com isso podemos alterar nossa função de abrir portas para:
openDoor :: User -> Int -> IO Int
openDoor user door =
case doorStatus door
Open -> print "You don't need a code!" >> pure 0
Closed -> _openDoor user door
Note que caso a porta esteja aberta, estamos retornando o código \(0\) por padrão. Podemos criar um tipo soma que define um valor padrão nulo ou ausente.
data Optional a = None | Some a
openDoor :: User -> Int -> IO (Optional Int)
openDoor user door =
case doorStatus door
Open -> print "You don't need a code!" >> pure None
Closed -> Some <$> _openDoor user door
Agora, caso não seja necessário retornar um código de abertura,
podemos retornar None
, indicando que nada foi retornado.
Na verdade, esse tipo já existe no Haskell com outro nome:
data Maybe a = Nothing | Just a
Note que esse é um tipo paramétrico que possui mais do que dois valores:
um valor é Nothing
, indicando ausência de informação e, os outros valores,
é a enumeração de todos os valores representáveis pelo tipo a
.
Para tornar nosso exemplo mais interessante, vamos criar um novo tipo chamado
Breach
, indicando possíveis problemas de segurança com a porta, e vamos
parametrizar nosso tipo Door
:
data Door a = Closed | Open | Breached a
deriving Eq
data Breach = Hacked | Broken | NoEnergy
deriving Eq
openDoor :: User -> Int -> IO (Optional Int)
openDoor user door =
case doorStatus door
Open -> print "You don't need a code!" >> pure None
Closed -> Some <$> _openDoor user door
Breached breach -> print "Door has been breached! Warn security!"
>> printInstructions breach
>> pure None
A decisão de deixar o valor Breached
com um parâmetro de tipo é de
tal forma a permitir uma flexibilidade de seu uso. Por exemplo, se
temos um valor do tipo Breached Breach
e, eventualmente, queremos
transformar tal valor para um tipo Breached String
, permitindo a
leitura das instruções por um usuário, basta fazer (dado que
implementamos uma instância de Functor
para esse tipo):
fromBreachToStr :: Door Breach -> Door String
fromBreachToStr = fmap b2s
where
b2s Hacked = "you've been hacked!"
b2s Broken = "the door has been forced and broken!"
b2s NoEnergy = "we lost energy, guard the door!"
No desenvolvimento guiado por tipos, os tipos soma costumam limitar o tipo de operações que são possíveis de serem implementadas. Por exemplo, considere a função:
funcao :: Door Breach -> Door Breach
Como podemos implementar essa função? O primeiro passo é dividir os casos de valores
possíveis para o tipo Door Breach
:
funcao :: Door Breach -> Door Breach
funcao Closed =
funcao Open =
funcao (Breached Hacked) =
funcao (Breached Broken) =
funcao (Breached NoEnergy) =
Em seguida, temos que, para cada entrada, escrever a saída correspondente. Para cada um dos \(5\) casos de entrada, temos \(5\) possibilidades de saídas. Ou seja, dada essa entrada e saída, temos \(5^5 = 3125\) implementações diferentes.
Embora os tipos estejam limitando as possíveis implementações, ainda assim
temos um número muito grande. Quando especificamos um nome para a função
que descreve seu objetivo, podemos reduzir intuitivamente a quantidade
de possibilidades. Por exemplo, uma função chamada de closeDoor
, dificilmenete
vai gerar uma saída com valor Open
.
Caso o parâmetro de tipo de Door
não esteja especificado, reduzimos ainda mais
as possibilidade:
funcao :: Door a -> Door a
funcao Closed =
funcao Open =
funcao (Breached x) =
Uma vez que essa função deve ser implementada para qualquer tipo a
,
temos agora \(3^3 = 27\) possíveis implementações. Novamente, precisamos
de mais informações sobre o objetivo da função para determinar a
implementação correta.
É possível combinar os tipos soma e produto na definição de um novo tipo. Por exemplo, poderíamos criar uma nova definição do tipo representando usuário como:
data User = Admin { _openDoor :: Int -> IO Int }
| User { _username :: String
, _openDoor :: Int -> IO Int
}
Agora, com essa nova definição, temos dois valores completamente distintos para o usuário admin e para usuários normais. Nosso código de manipulação de usuários se torna:
module Users
( createNewUser
, getYourUser
, openSecretDoor
, openDoor
) where
getYourUser :: String -> String -> IO User
getYourUser username password = do
db <- openDB
user <- queryDB db username password
closeDB db
return user
createNewUser :: User -> Bool -> String -> (Int -> IO Int) -> IO ()
createNewUser (Admin _) isAdm username opendoors =
openDB >>= createUser isAdm username opendoors >>= closeDB
createNewUser _ = print "ACCESS DENIED! TERMINATE!"
openSecretDoor :: User -> IO (Maybe Int)
openSecretDoor (Admin _) = pure $ Just 1234
openSecretDoor _ = print "DENIED!" >> pure Nothing
openDoor :: User -> Int -> IO Int
openDoor = _openDoor
Uma vez que a diferença entre um admin e um usuário normal não depende de um atributo booleano, é impossível injetar essa informação de qualquer forma em um usuário normal. Fica mais difícil na implementação você atribuir um poder extra para um usuário normal por não fazer a verificação _isAdmin
ou inverter a lógica.
Either
: o tipo soma padrão
No Haskell temos um tipo que representa a soma canônica:
data Either a b = Left a | Right b
Um tipo canônico significa que pode ser utilizado para construir qualquer tipo soma.
Por exemplo, nosso tipo User
pode ser reescrito como:
data User = Admin { _openDoor :: Int -> IO Int }
| User { _username :: String
, _openDoor :: Int -> IO Int
}
type EitherUser = Either (Int -> IO Int) (String, Int -> IO Int)
Para todos os efeitos eles contém a mesma informação, apenas estruturada de forma diferente.
Embora os tipos soma e produtos sejam suficientes para implementar muitas das estruturas comuns em diversas aplicações, elas são insuficientes para criação de estruturas lineares e não-lineares. Por exemplo, como implementar uma lista ligada utilizando apenas tais recursos?
Para implementar tais tipos, podemos definir um novo tipo em função dele mesmo. Por exemplo, uma lista ligada é definida como1:
data [a] = [] | a : [a]
Ou seja, ou o valor dela é uma lista vazia ou ela contém um elemento (cabeça) e um restante de lista (cauda). Como em Haskell trabalhamos com avaliação preguiçosa, não importa se existe um valor terminal. Podemos definir um tipo de stream infinito de dados como:
data Stream a = a |> Stream a
Nesse caso, o Stream
é composto por um elemento e uma outra Stream
, não existe final para essa estrutura (e tudo bem, contanto que não
seja requisitado o elemento final!).
Árvore Binária
Um outro exemplo de estrutura recursiva é a Árvore Binária que pode ser definida como:
data Tree a = Empty | Node (Tree a) a (Tree a)
Essa estrutura permite criar uma diversidade de árvores binárias, como por exemplo:
Node (Node Empty 1 Empty) 2 (Node Empty 3 Empty)
Que é desenhada como:
A única garantia que essa definição nos dá é que um nó folha não guarda
qualquer informação sobre o tipo a
, valores do tipo a
estão apenas
nos nós internos. Podemos desenhar árvores balanceadas, como o exemplo acima,
ou árvores que pendem para um dos lados. A árvore pode ser uma árvore de busca
se assim quisermos, mas nada na estrutura garante essa propriedade.
Considere o seguinte código:
insert_balanced :: a -> Tree a -> Tree a
insert_balanced x Empty = Node Empty x Empty
insert_balanced x (Node l y r)
| height l > height r = Node l y (insert_balanced x r)
| otherwise = Node (insert_balanced x l) y r
Não existe nada na estrutura que nos impeça de inserir o novo elemento no ramo errado e causar um desbalanceamento. Uma simples troca no operador de comparação faz com que nossa implementação fique incorreta. E se eu entregar esse código no meu trabalho? E se o código for público e as pessoas perceberem meu erro? E se eu ficar conhecido como o Bug Builder?
Existe uma forma de garantir que nossa árvore sempre será balanceada (não confundir o nome da estrutura abaixo com a estrutura B-Tree):
data BTree a = BEmpty | BNode a (BTree (a, a))
Note que essa estrutura é similar a lista ligada, porém, a cada recursão eu dobro a quantidade de elementos que ela carrega. Por exemplo, a árvore do exemplo anterior seria representada como:
btree = BNode 2 (BNode (1,3) BEmpty)
Por definição, cada nível \(n\) dessa árvore possui \(2^n\) nós, fazendo com que ela seja uma árvore perfeitamente balanceada. Porém, ela exige que cada nível esteja completo. Não podemos inserir uma lista de valores \([1,2,3,4]\) nessa árvore (tente!).
A inserção de elemento fica:
insert_balanced2 x BEmpty = BNode x BEmpty
insert_balanced2 x (BNode y ys) = BNode y $ insert_balanced2 x ys
Isso não compila! Mas, qual o problema? Tente definir a assinatura dessa função.
No primeiro caso de entrada a assinatura deveria ser
a -> BTree b -> BTree a
, no segundo caso seria
a -> BTree b -> BTree b
. A única diferença está na saída
da função que pode ser um entre dois tipos, podemos utilizar
o tipo soma canônico Either
para representar a saída:
insert_balanced2 :: a -> BTree b -> Either (BTree a) (BTree b)
insert_balanced2 x BEmpty = Left BNode x BEmpty
insert_balanced2 x (BNode y ys) = BNode y $ insert_balanced2 x ys
O primeiro caso está correto e de acordo com a assinatura.
Para o segundo caso, temos que retornar um Right (BTree b)
.
Como fazer isso? Tente por alguns instantes.
Não temos como implementar essa função com o que temos agora. Como alternativa, podemos implementar uma função que cria uma árvore dado que você providencie um elemento e duas árvores representando os ramos da esquerda e direita:
insertB :: a -> BTree a -> BTree a -> BTree a
insertB x l r = BNode x (merge l r)
where
merge :: BTree a -> BTree a -> BTree (a, a)
merge BEmpty BEmpty = BEmpty
merge (BNode y ty) (BNode z tz) = BNode (y,z) $ merge ty tz
Note que a nossa estrutura garante que as árvores esquerda e direita possuem a mesma quantidade de elementos. Com isso, podemos criar nossa árvore exemplo como:
bnode1 = insertB 1 BEmpty BEmpty
bnode3 = insertB 3 BEmpty BEmpty
bnode2 = insertB 2 bnode1 bnode3
Árvore de Expressão
Vamos agora apresentar um outro tipo de árvore que pode ter um número variado de filhos dependendo do tipo do nó (embora inicialmente teremos apenas os casos com dois filhos). Essa árvore vai representar uma expressão matemática composta de operadores binários, ternários, funções univariadas, e constantes.
data Expr = Const Int
| Add Expr Expr
| Mul Expr Expr
Vamos implementar uma função que avalia uma expressão para um inteiro. Faremos passo a passo com a ajuda da informação desse tipo:
evalExpr :: Expr -> Int
evalExpr _ = _
O primeiro passo é expandir os padrões de entrada:
evalExpr :: Expr -> Int
evalExpr (Const x) = _
evalExpr (Add l r) = _
evalExpr (Mul l r) = _
No primeiro padrão temos um valor \(x\) do tipo Int
envolvido no construtor
Const
. Como queremos retornar um Int
e esse construtor representa um
valor constante, podemos retornar o próprio x
.
evalExpr :: Expr -> Int
evalExpr (Const x) = x
evalExpr (Add l r) = _
evalExpr (Mul l r) = _
No segundo padrão temos o construtor Add
contendo dois valores do
tipo Expr
. A ideia é que a gente converta esses valores para Int
de alguma forma e some o resultado. Bom, a única função que temos
disponível para converter um Expr
para Int
é a própria evalExpr
!
evalExpr :: Expr -> Int
evalExpr (Const x) = x
evalExpr (Add l r) = evalExpr l + evalExpr r
evalExpr (Mul l r) = _
O último padrão é análogo só que efetuando multiplicação.
evalExpr :: Expr -> Int
evalExpr (Const x) = x
evalExpr (Add l r) = evalExpr l + evalExpr r
evalExpr (Mul l r) = evalExpr l * evalExpr r
Digamos que queremos introduzir outros tipos de nós que armazenam um valor booleano e acrescentar operações entre booleanos.
data Expr = IConst Int
| BConst Bool
| Add Expr Expr
| Mul Expr Expr
| And Expr Expr
| Or Expr Expr
Com essa mudança, nosso evalExpr
se torna:
evalExpr :: Expr -> Int
evalExpr (IConst x) = x
evalExpr (BConst x) = x
evalExpr (Add l r) = evalExpr l + evalExpr r
evalExpr (Mul l r) = evalExpr l * evalExpr r
evalExpr (And l r) = evalExpr l && evalExpr r
evalExpr (Or l r) = evalExpr l || evalExpr r
E…o compilador explode com uma mensagem de erro. Note que
o valor retornado por nossa função deve ser um Int
, mas
nossa árvore pode terminar com um Bool
. Nesse caso,
nosso evalExpr
pode retornar ou um inteiro ou um booleano:
evalExpr :: Expr -> Either Int Bool
evalExpr (IConst x) = Left x
evalExpr (BConst x) = Right x
evalExpr (Add l r) = case evalExpr l of
Left x -> case evalExpr r of
Left y -> x + y
Right _ -> error "invalid expression"
Right _ -> error "invalid expression"
evalExpr (Mul l r) = case evalExpr l of
Left x -> case evalExpr r of
Left y -> x * y
Right _ -> error "invalid expression"
Right _ -> error "invalid expression"
evalExpr (And l r) = case evalExpr l of
Right x -> case evalExpr r of
Right y -> x && y
Left _ -> error "invalid expression"
Left _ -> error "invalid expression"
evalExpr (Or l r) = case evalExpr l of
Right x -> case evalExpr r of
Right y -> x || y
Left _ -> error "invalid expression"
Left _ -> error "invalid expression"
E o código agora ficou muito mais complicado…mas ele vai nos avisar se tentarmos combinar booleanos com inteiros. E se fosse possível extrair a informação de qual tipo a árvore está carregando?
Um truque para tentar organizar essa bagunça é modificar nosso tipo para que ele seja paramétrico e o parâmetro do tipo irá nos indicar que tipo de expressão estamos avaliando.
data Expr a = IConst Int
| BConst Bool
| Add (Expr a) (Expr a)
| Mul (Expr a) (Expr a)
| And (Expr a) (Expr a)
| Or (Expr a) (Expr a)
Agora podemos criar um Expr Int
para indicar que nossa expressão avalia
para inteiro. Mas peraí…o parâmetro a
não é utilizado em momento algum
na definição do nosso tipo. Na verdade, esse parâmetro serve apenas para
rotular nosso tipo. Isso é conhecido como tipo fantasma (pois o valor
do tipo a
não existe).
Agora podemos criar um avaliador para inteiros e outro para booleanos:
evalInt :: Expr Int -> Int
evalInt (IConst x) = x
evalInt (Add l r) = evalInt l + evalInt r
evalInt (Mul l r) = evalInt l * evalInt r
evalInt _ = undefined
evalBool :: Expr Bool -> Bool
evalBool (BConst x) = x
evalBool (And l r) = evalBool l && evalBool r
evalBool (Or l r) = evalBool l || evalBool r
evalBool _ = undefined
Porém, nada nos impede de fazer algo como:
fakeExpr = BConst True :: Expr Int
Não existe nenhuma instrução para o compilador de que isso é uma construção inválida!
Para minimizar esse problema, precisamos esconder os construtores
do usuário (já fizemos isso com o tipo User
) e exportar apenas
construtores que garantam uma expressão bem formada.
fromInt :: Int -> Expr Int
fromInt = IConst
(.+.) :: Expr Int -> Expr Int -> Expr Int
(.+.) = Add
(.*.) :: Expr Int -> Expr Int -> Expr Int
(.*.) = Mul
fromBool :: Bool -> Expr Bool
fromBool = BConst
(.&.) :: Expr Bool -> Expr Bool -> Expr Bool
(.&.) = And
(.|.) :: Expr Bool -> Expr Bool -> Expr Bool
(.|.) = Or
treeA = fromInt 1 .+. fromInt 2
treeB = fromInt 1 .|. fromBool True -- não compila
Relembrando o tipo User
, podemos eliminar a soma de tipos fazendo:
data User a = User { _username :: String
, _openDoor :: Int -> IO Int
}
E para identificar se um usuário é admin, criamos dois tipos vazios (nós tínhamos prometido mostrar que eles eram úteis, não tínhamos? 😜):
data Noob
data Admin
Veja que esses tipos são iguais ao tipo Void
, não possuem valores válidos associados a eles.
Mas tudo bem, não precisaremos de valores da forma que utilizaremos esses tipos:
module Users
( createNewUser
, getYourUser
, openSecretDoor
, openDoor
, getAdminAccess
) where
getYourUser :: String -> String -> IO (User Noob)
getYourUser username password = do
db <- openDB
user <- queryDB db username password
closeDB db
return user
createNewUser :: User Admin -> String -> (Int -> IO Int) -> IO ()
createNewUser _ username opendoors
= openDB >>= createUser username opendoors >>= closeDB
openSecretDoor :: User Admin -> IO (Maybe Int)
openSecretDoor _ = pure $ Just 1234
openDoor :: User a -> Int -> IO Int
openDoor = _openDoor
getAdminAccess :: User a -> IO (Maybe (User Admin))
getAdminAccess (User username opener) = do
db <- openDB
isAdm <- verifyPermission username
let user = if isAdm
then Just $ User username opener
else Nothing
As funções se tornaram mais simples pois não existe necessidade
de verificar se o usuário é Admin
para operações exclusivas,
a própria função não aceita outro tipo de usuário.
Note que na função getAdminAccess
, a instrução Just $ User username opener
faz uma cópia do usuário original, porém mudando o tipo para User Admin
.
Para garantir algumas propriedades em diversos tipos de árvores como Roseiras (Rose Trees) ou Árvores Rubro-Negras, podemos usar tipos fantasmas. Dê uma olhada neste link caso tenha curiosidade.
Tipos fantasmas, contudo, não são exclusividade de linguagens como Haskell. Também somos capazes de expressá-los em Java por exemplo.
Vamos tomar como exemplo uma aplicação onde só possamos enviar para o nosso banco de dados strings que foram higienizadas (sanitized), sob pena de sofrermos algum ataque de SQL Injection, por exemplo.
Podemos tentar uma primeira solução assim:
public static void runOnDB(String sql) {
//...
}
public static String sanitize(String sql) {
// Do work
return sql;
}
public static void main(String args[]) {
String sql = "blah blah; drop database;";
runOnDB(sanitize(sql)); //Compiler is ok with this
runOnDB(sql); //But also with this
}
Neste exemplo temos uma função runOnDB
que executa um SQL no
banco, uma função sanitize
que limpa a string e finalmente uma
função main
que executa alguns statements no banco.
Note que neste exemplo, o desenvolvedor é obrigado a lembrar por
conta própria (isto é, o compilador não o auxilia de maneira
nenhuma) que é preciso higienizar a string antes de enviá-la ao
banco. Na primeira chamada (dentro da função main
) o
desenvolvedor lembrou, mas na segunda chamada o sql passado como
parâmetro não foi higienizado e vai tocar o horror no banco de
dados.
Dá para melhorarmos um pouco:
class SanitizedString {
private String contents;
private SanitizedString(String str) {
contents = str;
}
public static SanitizedString sanitize (String str) {
//do stuff
return SanitizedString(str);
}
}
public static void runOnDB(SanitizedString sql) {...}
public static void main(String args[]) {
String sql = "blah blah; drop database;";
runOnDB(sanitize(sql)); //Compiler is ok with this
runOnDB(sql); //But does not like this! Nice!
}
Agora sim, é impossível passar uma string não higienizada para o
BD. O tipo da função runOnDB
exige um SanitizedString
como
parâmetro e a única maneira de criar um valor deste tipo é através
do método sanitize
. O exemplo igual ao acima passa a causar um
erro de compilação! O compilador passa a ajudar o desenvolvedor.
Contudo essa abordagem não é de todo sem limitações. Suponha que agora queiramos duas verificações na string a ser enviada para o BD. Vamos dizer que a string deveria ser validada e higienizada. Podemos querer uma ou ambas verificações. Seguindo a mesma ideia o nosso código ficaria:
class SanitizedString {...}
class ValidatedString {...}
class SanitizedAndValidated {...}
// Sanitized AND Validated
public static void runOnDB(SanitizedAndValidated str) {...}
//Sanitized only
public static void runSanitized(SanitizedString str) {...}
//Validated only
public static void runValidated(ValidatedString str) {...}
Não tão belo, mas ainda tratável. E se quisermos três validações: higienizada, validada e normalizada (maiúsculas/minísculas)?
class SanitizedString {...}
class ValidatedString {...}
class CapitalizedString {...}
class SanitizedAndValidated {...}
class SanitizedAndCapitalized {...}
class ValidatedAndCapitalized {...}
class SanitizedValidatedAndCapitalized {...}
E se agora quisermos saber se a string foi convertida para UTF-8?
class SanitizedString {...}
class ValidatedString {...}
class CapitalizedString {...}
class UTF8Converted {...}
...
...
Logo dá para ver que essa abordagem não escala bem. Precisamos de 7 combinações para 3 validações, 15 combinações para 4 validações, …
Dá pra fazer melhor? Conseguimos usar em Java a mesma ideia de tipos fantasmas que mostramos há pouco em Haskell?
Vamos começar com uma validação:
public class CheckedString1<T> {
private String contents;
private CheckedString1(String str) {
contents = str;
}
public String getContents() {
return contents;
}
}
public class Sanitized {
private Sanitized() {}
public static CheckedString1<Sanitized> sanitize (String str) {
//sanitizes and creates a CheckedString
return new CheckedString1<Sanitized>(str);
}
}
public class Validated {
private Validated() {}
public static CheckedString1<Validated> validate (String str) {
//validates string, returns CheckedString
return new CheckedString1<Validated>(str);
}
}
Note que assim como tínhamos feito em Haskell, agora a nossa
classe CheckedString1
tem um parâmetro de tipo T
, que não é
utilizado diretamente na implementação, mas serve para o
compilador nos ajudar a verificar se tudo está conforme
esperávamos.
Agora basta criamos os métodos que lidam com a validação que queremos e deixar que o compilador faça o trabalho duro de garantir que tudo está dentro do esperado:
public void runSanitized(CheckedString1<Sanitized> str) {...}
public void runValidated(CheckedString1<Validated> str) {...}
Mas e se quisermos duas validações? Também dá!
public class CheckedString2<T1, T2> {
private String contents;
public CheckedString2(String str,
Function<String, CheckedString1<T1>> f1,
Function<String, CheckedString1<T2>> f2) {
String c1 = f1.apply(str).getContents();
String c2 = f2.apply(c1).getContents();
contents = c2;
}
}
// Sanitized AND Validated
public void runOnDB(CheckedString2<Sanitized, Validated> str){...}
E podemos usar assim:
CheckedString2<Sanitized, Validated> sAndV =
new CheckedString2<Sanitized, Validated>(
sql,
Sanitized::sanitize,
Validated::validate);
runOnDB(sAndV);
Mas e se quisermos três?
public class CheckedString3<T1, T2, T3> {
...
Já tá começando a ficar chato… Note que apesar de termos
melhorado o problema que tínhamos antes (precisávamos de 7
combinações para 3 checagens, 15 para 4, … e agora precisamos de
apenas 1 nova classe CheckedStringN
para fazer n checagens) ainda
é necessário criar uma nova classe para cada número de validações desejado e
a ordem das validações deve ser sempre a mesma!
Mas não era bem isso que queríamos. O que queremos, na verdade, é usar uma lista de checagens de tamanho arbitrário como o tipo fantasma da nossa String! Em outras palavras, queremos uma lista funcione no nível dos tipos!
Dá pra fazer isso em Java? Infelizmente não2. Mas é claro que conseguimos fazer isso em Haskell! \o/ Nós voltaremos a este exemplo quando falarmos de Type Families.
Estes slides foram preparados para os cursos de Paradigmas de Programação e Desenvolvimento Orientado a Tipos na UFABC.
Este material pode ser usado livremente desde que sejam mantidos, além deste aviso, os créditos aos autores e instituições.
Essa
definição é correta e poderia ser aceita pelo compilador, mas não é
o caso. A sintaxe tem um tratamento especial para []
já que
colchetes e listas são uma parte tão importante da linguagem. Ainda
é possível definir listas idênticas à padrão usando apenas nomes
diferentes, por exemplo: data Lista a = Vazia | Nó a (Lista a)
↩︎
Você não achou que nós falaríamos de Java neste site gratuitamente e se não fosse para malhar Java um pouquinho, achou? 😝 ↩︎