Veja a playlist no Youtube com todos os vídeos do curso.
Em algumas situações o tipo de saída da função possui uma dependência
com os tipos de entrada. Por exemplo, se quisermos criar uma função
merge :: Vec m a -> Vec n a -> Vec o a
, o tipo o
não pode ser
qualquer tipo, ele necessariamente deve ser m + n
.
Ou seja, esse tipo de definição demanda que o tipo de saída seja
definido após a aplicação de uma função (no caso, (+)
) nos tipos de
entrada.
Em Haskell podemos definir essas funções aplicadas em tipos através do Type Family .
Vamos começar com um exemplo básico no nosso sistema de controle de
usuários e portas. Imagine que queremos definir uma função track
que recebe um usuário u1
(que é o usuário efetuando a consulta), um
usuário u2
que é o usuário procurado. A função pode devolver:
u2
caso u1
seja Admin
e u2
seja Noob
.Admin
(assim u1
pergunta
diretamente a u2
onde ele está).Denied
, caso u1
seja Noob
e u2
seja Admin
.Request
, caso ambos sejam Noob
, para que ele possa
requisitar a informação para o Admin
mais próximo.Primeiro, vamos reescrever nosso tipo User
utilizando GADTs para
relembrarmos alguns conceitos do capítulo anterior:
{-# LANGUAGE GADTs #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE StandaloneKindSignatures #-}
data UserType = Noob | Admin
type User :: UserType -> *
data User t where
User :: String -> User Noob
Sudo :: User Noob -> User Admin
_username :: User a -> String
_username (User s) = s
_username (Sudo (User s)) = s
getUser :: String -> IO (Maybe (User Noob))
getUser username = do
db <- connect
u <- getUser db username
close db
pure u
sudo :: User Noob -> IO (Maybe (User Admin))
sudo u = do
db <- connect
b <- checkAccess u
close db
if b then pure (Just $ Sudo u)
else pure Nothing
Nesta definição para User
estamos usando o estilo de
definição de assinaturas standalone kind signatures. Essa versão é
equivalente ao modo que vimos até agora (data User :: UserType -> * where
) mas deixa a sintaxe mais próxima daquela de funções. O motivo
de fazermos esta escolha agora vai ficar claro em alguns
parágrafos. Para usar essa sintaxe é preciso habilitar a extensão
StandaloneKindSignatures.
Agora precisamos criar os tipos de cada uma das possíveis saídas:
data Location = Location { _x :: Double, _y :: Double }
newtype Contact = Contact String
data Denied = Denied
newtype Request = Request (String -> IO Location)
Finalmente podemos criar a função track
, que deve ter a assinatura:
track :: User a -> User b -> c
A implementação seria algo como:
track :: User a -> User b -> c
track (User u1) (User u2) = Request (const (pure (Location 0.0 0.0)))
track (Sudo u1) (Sudo u2) = Contact "0000-0000"
track (Sudo u1) (User u2) = Location 10.0 3.01
track (User u1) (Sudo u2) = Denied
Porém esse código não compila pois o compilador não consegue pré-determinar qual deve ser
o tipo c
. Para resolver essa situação, vamos criar uma função que determina o tipo
de c
dependendo dos tipos a
e b
. Para isso precisamos habilitar a extensão
TypeFamilies
:
{-# LANGUAGE TypeFamilies #-}
-- Usando a sintaxe kind signatures
type family Track (a :: UserType) (b :: UserType) where
Track Noob Noob = Request
Track Admin Admin = Contact
Track Admin Noob = Location
Track Noob Admin = Denied
-- Ou melhor ainda, usando a sintaxe fornecida pela extensão
-- StandaloneKindSignatures
type Track :: UserType -> UserType -> *
type family Track a b where
Track Noob Noob = Request
Track Admin Admin = Contact
Track Admin Noob = Location
Track Noob Admin = Denied
Ambas as sintaxes são equivalentes neste caso, mas nós tendemos (nós == os autores deste post) a preferir a segunda opção por ela deixa o código bem mais próximo do formato de uma função convencional, com a assinatura seguida da implementação. Isso na nossa opinião é desejável já que estamos, efetivamente, escrevendo uma função que opera no nível dos tipos.
O código acima, cria uma função que pode ser aplicada em tipos com os
kinds especificados. Em nosso exemplo, criamos o type family
chamado de Track
que recebe dois tipos com kind UserType
e
retorna um tipo como saída. As definições de entrada e saída são
similares a uma definição de função no nível dos termos com uso de
pattern matching.
Com isso podemos fazer:
track :: User a -> User b -> Track a b
track (User u1) (User u2) = Request (const (pure (Location 0.0 0.0)))
track (Sudo u1) (Sudo u2) = Contact "0000-0000"
track (Sudo u1) (User u2) = Location 10.0 3.01
track (User u1) (Sudo u2) = Denied
E, agora, o compilador consegue determinar o tipo correto da saída de acordo com as entradas.
Feito o hello world!, vamos agora ver em mais detalhes o que está acontecendo neste código..
A extensão TypeFamilies
introduz dois novos construtores além
daqueles que já apresentamos. Considere um construtor T
, temos
então as seguintes sintaxes fazem com que T
seja:
data T a b c
: construtor de tiposnewtype T a b c
: construtor de sinônimos newtype
class T a b c
: construtor de classetype T a b c
: construtor de sinônimosCom a extensão habilitada, adicionamos:
type family T a b c
: construtor de type familydata family T a b c
: construtor de data familyUma type family pode ser fechada (closed) ou aberta (open). Dentre as type families na categoria aberta elas se sub-dividem em nível superior (top-level) e associadas (associated).
Já uma data family só pode ser aberta e nestes casos também é sub-dividida entre nível superior e associada .
Antes de entendermos Closed Type Families, vamos relembrar que agora temos duas formas de computar:
Vamos exemplificar ambas com a função or'
, no nível de termos e Or
no nível de tipos:
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE StandaloneKindSignatures #-}
or' :: Bool -> Bool -> Bool
or' False False = False
or' _ _ = True
type Or :: Bool -> Bool -> Bool
type family Or a b where
Or False False = False
Or _ _ = True
No primeiro exemplo, temos uma função que recebe dois valores do tipo Bool
e retorna um Bool
. No segundo, temos uma função que recebe dois tipos
de kind Bool
e retorna um tipo de kind Bool
. Podemos ver a diferença de ambos
no ghci:
> :t or'
or' :: Bool -> Bool -> Bool
> :k Or
Or :: Bool -> Bool -> Bool
E podemos verificar como elas podem ser aplicadas:
> or' True False
True
> :k! Or True False
Or True False :: Bool
= 'True
Em resumo, com uma type family podemos aplicar funções em tipos e aplicar tais operações no nível de tipos nas assinaturas das funções no nível de termos.
Assim como em funções, podemos ter type families com definições
polimórficas. Para explicitar o polimorfismo, vamos habilitar a
extensão PolyKinds
:
fst' :: forall a b . (a, b) -> a
fst' (x, _) = x
type Fst :: forall a b . (a, b) -> a
type family Fst t where -- note que t ~ (a, b)
Fst '(a, _) = a -- precisamos usar o tick
A extensão de compilador PolyKinds
permite usar a
palavra-chave forall
na assinatura de kinds para um type family.
A type family Fst
recebe um tipo que é formado por uma tupla de tipos
com kind a
e b
, e retorna o tipo com kind a
.
Na sua definição não podemos fazer type family Fst (a, b)
pois (a, b)
representa apenas um tipo e o kind desse tipo é (a, b)
. Outro ponto para tomar cuidado
é que o compilador não consegue diferenciar a sintaxe de tuplas no nível de termos
e no nível de tipos, por isso precisamos especificar que estamos tratando do nível de tipos
com um tick na frente do construtor da tupla: ~’(a, b)'.
Esse tipo de type family é denominada fechada pois todas as equações pertencentes a essa família devem ser definidas no respectivo bloco. Não é possível deixar uma definição em aberto para ser criada posteriormente.
Nat
No capítulo anterior definimos o tipo Nat
que foi utilizado em conjunto da
sintaxe GADT para criarmos tipos bem estruturados como o vetor com informação
de tamanho.
Nessa ocasião, a criação da função append
não era possível pois
o tipo de saída deveria ser a soma dos tamanhos dos vetores de entrada.
Essas operações sugerem um caso de uso de type families. Antes de
redefinirmos as funções citadas para esses tipos, vamos praticar um
pouco mais criando algumas operações básicas para Nat
no nível de tipos.
Lembrando que o tipo Nat
foi definido como:
{-# LANGUAGE DataKinds #-}
data Nat = Z | S Nat
As operações de adição e multiplicação são simples de serem implementadas no nível de tipos:
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE StandaloneKindSignatures #-}
type (+) :: Nat -> Nat -> Nat
type family m + n where
Z + n = n
(S m) + n = S (m + n)
type (.*) :: Nat -> Nat -> Nat
type family m .* n where
Z .* n = Z
(S m) .* n = n + (m .* n)
A extensão de compilador TypeOperators
permite criar
type families como operadores infixos.
A definição da operação de adição indica que ao somar \(0\) com qualquer número \(n\), o resultado é \(n\). E, ao somar o sucessor de um valor \(m\) com \(n\) o resultado é o sucessor de \(m + n\). Isso é simplesmente a propriedade \((1 + m) + n = 1 + (m + n)\) que reduz o nosso problema original \((1 + m)\) em um para então fazer a chamada recursiva \(m + n\). Por exemplo, a operação \(3 + 2\) fica:
>:k! S (S (S Z)) + S (S Z)
S (S (S Z)) + S (S Z) :: Nat
= S ( S (S Z) + S (S Z) ) :: Nat
= S ( S ( S Z + S (S Z) ) ) :: Nat
= S ( S ( S ( Z + S (S Z) ) ) ) :: Nat
= 'S ('S ('S ('S ('S 'Z))))
A multiplicação diz que ao multiplicar \(0\) por qualquer valor, temos como resultado o próprio \(0\). E, a indução utilizada para a redução da entrada da recursão é a propriedade \((1 + m) \cdot n = n + (m \cdot n)\). A operação \(2 * 1\) fica:
(S (S Z)) .* (S Z) -- (1 + 1) * 1
= (S Z) + (S Z .* S Z) -- 1 + (1 * 1) (def. 2 de .*)
= S (Z + (S Z .* S Z)) -- 1 + (0 + (1 * 1)) (def. 2 de +)
= S (S Z .* S Z) -- 1 + (1 * 1) (def. 1 de +)
= S (S Z + (Z .* S Z)) -- 1 + (1 + (0 * 1)) (def. 2 de .*)
= S (S (Z + (Z .* S Z))) -- 1 + (1 + (0 + (0 * 1))) (def. 2 de +)
= S (S (Z .* S Z)) -- 1 + (1 + (0 * 1)) (def. 1 de +)
= S (S (Z)) -- 1 + (1 + 0) (def. 1 de .*)
As operações de subtração e divisão não são fechadas, isto é, há resultados destas operações que não estão contidas nos naturais. No caso da subtração, a operação \(m - n\) quando \(n > m\) não retorna um número natural. Da mesma forma, \(m / 0\) também não é um número natural. Para definirmos essas operações, podemos assumir que \(m - n = 0\) quando \(n > m\) e \(m / 0 = 0\). Com isso temos:
type (-) :: Nat -> Nat -> Nat
type family m - n where
m - Z = m
Z - (S n) = Z
(S m) - (S n) = m - n
O primeiro caso representa a propriedade \(m - 0 = m\), o segundo caso foi a definição \(0 - (n + ) = 0\) que criamos para garantir um número natural como resultado. Finalmente, a recursão usa a propriedade \((m + 1) - (n + 1) = m - n\) até que chegue em um dos casos bases, quando um dos valores atingirem \(0\).
A divisão requer algumas operações que ainda não temos. Vamos verificar os casos bases:
type Div :: Nat -> Nat -> Nat
type family Div m n where
Div Z _ = Z
Div _ Z = Z
Div m m = S Z
Div m n = ???
O primeiro caso diz que \(0 / n = 0\) e o segundo que \(m / 0 = 0\) conforme convencionamos anteriormente. O terceiro caso indica que se dividirmos um número por ele mesmo, temos como resultado o valor \(1\).
Note que, diferentemente das definições de funções, as variáveis usadas no pattern matching da entrada de cada uma das equações da nossa type family podem ser iguais. Neste caso elas indicam que efetivamente ambos os parâmetros se tratam do mesmo tipo. Esse comportamento é comum em outras linguagens como Erlang e Prolog para a implementação de funções no nível dos termos.
O caso da recursão \(m / n\) precisa levar em conta se \(m < n\). Se for o caso, o resultado é \(0\). Caso contrário fazemos a chamada recursiva \(m / n = 1 + (m - n) / n\). Podemos verificar que de fato isso é verdade pois \(1 + (m - n) / n = (n + (m - n)) / n = m / n\). Assumindo a existência de todas as operações necessárias, nosso código ficaria:
type Div :: Nat -> Nat -> Nat
type family Div m n where
Div Z _ = Z
Div _ Z = Z
Div m m = S Z
Div m n = IF (IsLT (Cmp m n)) Z (S (Div (m - n) n))
Vamos então implementar as type families que faltam!
type IF :: Bool -> Nat -> Nat -> Nat
type family IF b m n where
IF True m n = m
IF False m n = n
type IsLT :: Ordering -> Bool
type family IsLT o where
IsLT LT = True
IsLT _ = False
type Cmp :: Nat -> Nat -> Ordering
type family Cmp m n where
Cmp Z Z = Eq
Cmp Z _ = LT
Cmp (S _) Z = GT
Cmp (S m) (S n) = Cmp m n
Com isso podemos fazer operações aritméticas básicas com nosso
tipo Nat
. Nas próximas seções, aplicaremos algumas delas
nas nossas definições de árvore binária e vetores indexados.
Uma aplicação interessante de GADTs e type families é a implementação de uma lista heterogênea que armazena valores de tipos diferentes em cada elemento. A definição dessa lista é:
data HList xs where
HNil :: HList '[]
(:.) :: x -> HList xs -> HList (x : xs)
infixr 5 :.
Com isso podemos criar uma lista como:
xs :: HList [Int, Double, String]
xs = 12 :. 13.4 :. "Ola" :. HNil
Da mesma forma que fizemos anteriormente, podemos criar a versão
total das funções head
e tail
, pois podemos casar exatamente
o padrão de listas não vazias:
hhead :: HList (x:xs) -> x
hhead (x :> _) = x
htail :: HList (x:xs) -> HList xs
htail (_ :> xs) = xs
Note que a assinatura da função exige que o valor de entrada
seja uma lista contendo pelo menos um tipo x
. Além disso,
as definições da assinatura e a implementação da função se tornam
muito similares. Não existe outra forma de implementar tais funções!
Para implementar uma função happend
temos que definir uma type family
para determinar o tipo de saída, pois a assinatura dessa função deve ser:
happend :: HList xs -> HList ys -> HList (Append xs ys)
A implementação da type family Append
é muito similar com uma implementação
simples de append
ou (++)
:
type Append :: [a] -> [a] -> [a]
type family Append xs ys where
Append '[] ys = ys
Append (x : xs) ys = x : Append xs ys
E a implementação da função happend
é quase idêntica a sua type family:
happend :: HList xs -> HList ys -> HList (Append xs ys)
happend HNil ys = ys
happend (x :> xs) ys = x :> happend xs ys
Podemos utilizar a definição de Append
para criar o type family Reverse
:
type Reverse :: [a] -> [a]
type family Reverse xs where
Reverse '[] = '[]
Reverse (x : xs) = Append (Reverse xs) '[x]
E com isso implementamos a função hreverse
de forma similar:
hreverse :: HList xs -> HList (Reverse xs)
hreverse HNil = HNil
hreverse (x :> xs) = happend (hreverse xs) (x :> HNil)
Suponha que sua lista heterogênea vai ser utilizada para manipulação de dados
provenientes de uma base de dados bem estruturada e, para efeito de exportação,
você precisa transformar os tipos em String
contendo o nome correspondente
no servidor de dados.
Uma forma de fazer tal conversão é criando um type family tal que [1]:
type Label :: Type -> Symbol
type family Label t where
Label Double = "number"
Label Int = "number"
Label String = "string"
Label Bool = "boolean"
Podemos converter um tipo utilizando a classe KnownSymbol
importado
de GHC.TypeLits
:
label :: forall t. KnownSymbol (Label t) => t -> String
label x = symbolVal (Proxy @(Label t))
A assinatura dessa função diz: para todo tipo t
que possui uma
instância de Label
e pertence a KnownSymbol
, eu gero um valor
único do tipo String
associado a esse tipo.
A função symbolVal
definida em GHC.TypeLits
funciona de forma análoga
à natVal
que usamos no post anterior.
Agora podemos fazer uma função labels
associada a uma lista de tipos
e retorna uma lista de String
:
labels :: HList ts -> [String]
labels HNil = []
labels (x :> xs) = label x : labels xs
Porém isso não compila, pois não sabemos se todos os tipos associados
à HList
possuem um Label
ou fazem parte de KnownSymbol
. Precisamos
mapear a restrição para cada um dos tipos de nossa lista. Podemos criar um
type family AllSymblLbl
que faz esse mapeamento:
type AllSymbLbl :: [a] -> Constraint
type family AllSymbLbl xs where
AllSymbLbl '[] = ()
AllSymbLbl (x : xs) = (KnownSymbol (Label x), AllSymbLbl xs)
Ou seja, a cada tipo da lista ele adiciona a restrição KnownSymbol (Label x)
e
compõe com a restrição no restante da lista. Com isso agora podemos fazer:
labels :: AllSymbLbl ts => HList ts -> [String]
labels HNil = []
labels (x :> xs) = label x : labels xs
Isso funciona muito bem até que criemos novos tipos User
e Door
e
queiramos criar uma instância da type family Label
para esses
tipos.
Se as definições de Door
e User
fizerem parte de nossa biblioteca,
basta alterarmos nosso código para:
type Label :: Type -> Symbol
type family Label t where
Label Double = "number"
Label Int = "number"
Label String = "string"
Label Bool = "boolean"
Label User = "user"
Label Door = "door"
Porém, caso não tenhamos acesso a essa definição, não podemos incluir novas definições externamente! Isso ocorre porque essa família de tipos é fechada (closed type family) já que não aceita novos membros.
Uma família de tipos fechada deve ser criada com a definição completa. Uma outra forma de definir uma família de tipos é:
type Label :: Type -> Symbol
type family Label t
type instance Label Double = "number"
type instance Label Int = "number"
type instance Label String = "string"
type instance Label Bool = "boolean"
Esse tipo de definição é chamade de família de tipos aberta (open type family) pois ela permite a inserção de novos membros a qualquer instante, mesmo que externa ao arquivo-fonte em que ela foi definida. Em qualquer momento podemos fazer:
type instance Label User = "user"
type instance Label Door = "door"
Com isso aumentamos a flexibilidade da definição de família de tipos. A desvantagem
em deixar uma família de tipos aberta é que ela não permite sobreposição em suas
equações. Considere, por exemplo, a família And
:
type And :: Bool -> Bool -> Bool
type family And a b where
And True True = True
And _ _ = False
Uma vez que a família fechada permite sobreposição, podemos definir a instância
genérica And _ _
que casa com todos os padrões que não casam com a primeira
definição. Se implementarmos essa família como aberta, teríamos que enumerar todas
as possíveis instâncias:
type And' :: Bool -> Bool -> Bool
type family And' a b
type instance And' True True = True
type instance And' True False = False
type instance And' False True = False
type instance And' False False = False
O que em alguns casos é proibitivo por causar uma explosão combinatória.
Imaginem que queremos criar uma classe MyLogger
com o objetivo de criar um
relatório de aberturas de portas por nossos usuários. A ideia é que essa
classe defina um método chamado trackUser
que é responsável por armazenar
o histórico das ações dos usuários em nossas portas.
Em Haskell, temos diversas maneiras de implementar um logger
, podemos utilizar
a mônada Writer
com uma lista que armazena a sequência das ações, ou uma mônada
State
que guarda apenas a última ação feita, ou até um IO
que armazena as
ações em um banco de dados.
O que queremos é:
class MyLogger logger where
trackUser :: User a -> Int -> MyMonad ()
Ou seja, a classe define um método trackUser
que, ao receber
um usuário e o número de uma porta, faz uma ação da mônada escolhida.
Mas qual deve ser essa mônada? Queremos que ela dependa do tipo logger
.
Sabemos como associar um tipo a outro utilizando type family:
type MonadLog :: * -> *
type family MonadLog logger
class MyLogger logger where
trackUser :: User a -> Int -> MonadLog logger ()
Agora podemos definir tipos que representam o logger
que queremos
implementar e criar uma instância para eles com a implementação correspondente.
Por exemplo, se quisermos fazer uso da mônada Writer
:
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
newtype DoorsSequence = DoorsSequence [String]
deriving (Semigroup, Monoid, Show)
type instance MonadLog DoorsSequence = Writer DoorsSequence
instance MyLogger DoorsSequence where
-- trackUser :: User a -> Int -> Writer DoorsSequence ()
trackUser user door = tell $ DoorsSequence ["User " <> _username user <> " opened the door " <> show door]
Isso funciona perfeitamente. Podemos inclusive definir uma função runActions
que executa uma sequência
de ações de acordo com nossa mônada:
runActions :: forall m . (MyLogger m, Monad (MonadLog m)) => MonadLog m ()
runActions = sequence_ [trackUser @m u d | (u,d) <- actions]
Note que temos que garantir que o tipo m
possui uma instância de MyLogger
, para podermos
utilizar o método trackUser
, o tipo MonadLog m
deve ter uma instância de Monad
, para
podermos utilizar sequence_
e, precisamos da quantificação forall m
para que possamos
instanciar o tipo m
correto de trackUser
. Dessa forma podemos deixar para decidir qual
instância utilizar durante o uso dessa função.
Embora o código funcione, por questão de organização pode ser que não faça sentido criar uma
instância de MonadLog
sem que seja criada uma de MyLogger
. Ou seja, podemos querer forçar
que a criação de um implique a criação da outra. Para isso podemos utilizar a definição
de família de tipos associadas, em que movemos a definição da família de tipos para dentro
da definição da classe de tipos:
class MyLogger logger where
type MonadLog logger :: * -> *
trackUser :: User a -> Int -> MonadLog logger ()
Agora para criar uma instância de MyLogger
precisamos definir o tipo MonadLog
correspondente:
instance MyLogger DoorsSequence where
type (MonadLog DoorsSequence) = Writer DoorsSequence
trackUser user door = tell $ DoorsSequence ["User " <> _username user <> " opened the door " <> show door]
Vamos criar outros dois exemplos de instâncias utilizando outras mônadas:
newtype LastAction = LastAction String deriving Show
instance MyLogger LastAction where
type (MonadLog LastAction) = State LastAction
trackUser user door = put $ LastAction $ "User " <> _username user <> " opened the door " <> show door
newtype Printer = Printer String deriving Show
instance MyLogger Printer where
type (MonadLog Printer) = IO
trackUser user door = print $ "User " <> _username user <> " opened the door " <> show door
E podemos utilizá-las fazendo:
main = do
print $ execWriter $ runActions @DoorsSequence
print $ execState (runActions @LastAction) $ LastAction ""
runActions @Printer
Vimos no início desse livro a diferença entre as definições de tipos com o uso
de type
e data
. O primeiro cria apenas um apelido para um tipo enquanto
o segundo cria um novo tipo. Por exemplo:
type Door = Int
data DoorStats = Open | Closed
O tipo Door
nada mais é que um Int
, em qualquer momento do código podemos
intercambiar o uso de Door
e Int
sem qualquer distinção.
Já DoorStats
define um tipo completamente novo e único contendo dois valores.
As famílias de tipo, conforme vimos até agora, também definem apenas apelidos para nossos tipos. Quando fazemos:
type And :: Bool -> Bool -> Bool
type family And a b where
And True True = True
And _ _ = False
Estamos dizendo que o tipo And True True
é apenas um apelido para o
tipo True
(note que aqui estamos trabalhando com os tipos promovidos
no nível dos tipos, então aqui, True
e False
são tipos, ao invés
de valores). Não temos qualquer distinção entre um e outro, mas é
suficiente para simularmos operações com nossos tipos em tempo de
compilação.
Imagine que queremos criar um novo tipo, chamado Operation
que indica as operações
que um usuário pode executar.
data Operation = OpenDoor Int | Walk | Work | InsertUser String | DeleteUser String | CreateDoor Int
Porém, nem todas as operações podem ser feitas por todos os usuários. Ao implementar uma função
runOp
precisamos tomar o cuidado de restringir as operações disponíveis:
runOp :: User a -> Operation -> IO ()
runOp (User _) op = case op of
OpenDoor x -> putStrLn $ "opening door " <> show x
Walk -> putStrLn "walk this way!"
Work -> putStrLn "faster! FASTER!"
_ -> putStrLn "Access denied!"
runOp (Sudo _) op = case op of
InsertUser x -> putStrLn $ "inserting user " <> x
DeleteUser x -> putStrLn $ "removing user " <> x
CreateDoor x -> putStrLn $ "creating door " <> show x
_ -> putStrLn $ "you're too good for that!"
Embora isso funcione, o desenvolvedor pode implementar uma operação para o usuário errado por engano e o compilador não vai emitir nenhum erro, pois para todos os efeitos não tem nada que ligue uma operação a um usuário.
Uma primeira ideia seria fazer:
type Operation :: UserType -> *
data Operation t where
OpenDoor :: Int -> Operation Noob
InsertUser :: String -> Operation Admin
Embora isso permita que você implemente casos absurdos na função runOp
:
runOp :: User a -> Operation -> IO ()
runOp (User _) op = case op of
OpenDoor x -> putStrLn $ "opening door " <> show x
Walk -> putStrLn "walk this way!"
Work -> putStrLn "faster! FASTER!"
InsertUser x -> putStrLn $ "inserting user " <> x
_ -> putStrLn "Access denied!"
runOp (Sudo _) op = case op of
InsertUser x -> putStrLn $ "inserting user " <> x
DeleteUser x -> putStrLn $ "removing user " <> x
CreateDoor x -> putStrLn $ "creating door " <> show x
_ -> putStrLn $ "you're too good for that!"
Ele vai causar um erro de compilação ao escrever runOp (User "Homer") (InsertUser "Bart")
,
pois os tipos são incompatíveis. Uma forma mais clara de fazer essa implementação é utilizando
data family, em que associamos um determinado tipo com a criação de um novo tipo:
type Operation :: UserType -> *
data family Operation t
data instance Operation Noob = OpenDoor Int | Walk | Work
data instance Operation Admin = InsertUser String | DeleteUser String | CreateDoor Int
Aqui definimos a existência de uma família de novos tipos, chamada de Operation
que,
ao ser associada ao tipo Noob
cria um tipo contendo três valores correspondentes
as operações permitidas. Da mesma forma, associamos um novo tipo para Admin
.
A implementação de runOp
fica:
runOp :: User a -> Operation a -> IO ()
runOp (User _) op = case op of
OpenDoor x -> putStrLn $ "opening door " <> show x
Walk -> putStrLn "walk this way!"
Work -> putStrLn "faster! FASTER!"
runOp (Sudo _) op = case op of
InsertUser x -> putStrLn $ "inserting user " <> x
DeleteUser x -> putStrLn $ "removing user " <> x
CreateDoor x -> putStrLn $ "creating door " <> show x
E, caso façamos algo como:
runOp :: User a -> Operation a -> IO ()
runOp (User _) op = case op of
OpenDoor x -> putStrLn $ "opening door " <> show x
Walk -> putStrLn "walk this way!"
Work -> putStrLn "faster! FASTER!"
InsertUser x -> putStrLn $ "inserting user " <> x
O compilador emitirá um erro indicando que InsertUser
não é um valor válido para Operation Noob
.
Strings
Vamos tomar como exemplo um sistema que lida com strings que precisam ser salvas em um banco de dados. Para que tudo seja seguro, consistente e coerente, queremos passar essas strings por uma série de filtros/normalizações para garantir que nenhum ataque de injeção de SQL seja possível além de evitar termos textos salvos com encoding errado, ou todos em maíuscula, etc…
A parte inicial dessa seção é uma elaboração mais detalhada do aperitivo que demos na seção onde apresentamos tipos fantasmas 👻. Reproduzimos abaixo as partes relevantes para conveniência.
Ao contrário dos posts anteriores, aqui vamos fazer lado a lado uma solução em Java e outra em Haskell para então mostrar quando termos funções no nível dos tipos passa a realmente a fazer diferença nas nossas implementações.
Vamos começar apenas com uma operação, a sanitização das strings.
Uma possível solução em Java poderia ter a seguinte cara:
public static void runOnDB(String sql) {
//...
}
public static String sanitize(String sql) {
// Do some work
...
return sql;
}
public static void main(String args[]) {
String sql = "blah blah; drop database;";
runOnDB(sanitize(sql)); //o compilador aceita isso tranquilamente
runOnDB(sql); //Mas isso aqui também!
}
O código equivalente em Haskell não seria muito diferente:
runOnDB :: String -> IO ()
runOnDB sql = ...
sanitize :: String -> String
sanitize str = ...
main :: IO ()
main = do
let sql = "blah blah; drop database;";
runOnDB (sanitize sql) -- Tudo ok!
runOnDB sql -- Xi....
Ambas essas implementações sofrem do mesmo problema! Ambas são capazes de, em um descuido do programador, enviar para o banco de dados um SQL que não foi sanitizado!
Nós não queremos permitir que isso seja possível. Queremos garantir que todos os SQLs enviados para o banco sejam sanitizados.
Uma possível solução para isso em Java seria:
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)); //Compilador gosta disso...
runOnDB(sql); //Mas não gosta disso...Perfeito!
}
-- O construtor UnsafeSanitizedString não deve ser exportado do módulo
-- onde foi declarado
data SanitizedString = UnsafeSanitizedString String
-- Apenas o smart constructor abaixo que deverá estar visível para
-- módulos externos
sanitize :: String -> SanitizedString
sanitize str = UnsafeSanitizedString sanitizedString
where
sanitizedString = doSanitization str
-- Em um outro módulo
runOnDB :: SanitizedString -> IO ()
runOnDB sql = ...
main :: IO ()
main = do
let sql = "blah blah; drop database;";
runOnDB (sanitize sql) -- Tudo ok!
runOnDB sql -- Compilador grita! Legal!
Perfeito! Agora como o tipo necessário para rodarmos algo no BD precisam indicar que o SQL foi sanitizado, é impossível deixarmos passar sem querer uma string não tratada.
Essa abordagem, contudo, tem as suas limitações. Suponha por exemplo que agora não só queiramos sanitizar as strings como também desejamos validá-las, para não ficar mandando lixo para o BD.
Se quisermos seguir esta mesma abordagem, poderíamos seguir na seguinte linha:
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) {...}
E em Haskell
data SanitizedString = UnsafeSanitizedString String
sanitize :: String -> SanitizedString
sanitize str = UnsafeSanitizedString sanitizedString
where
sanitizedString = doSanitization str
data ValidatedString = UnsafeValidatedString String
validate str = UnsafeValidatedString validatedString
where
validatedString = doValidation str
data SanitizedAndValidatedString = UnsafeSanitizedAndValidatedString String
sanitizeAndValidate :: String -> SanitizedAndValidatedString String
sanitizeAndValidate str = UnsafeSanitizedAndValidatedString sanitizedAndValidatedString
where
sanitizedAndValidatedString = doValidation (doSanitization str)
runOnDB :: SanitizedAndValidatedString -> IO ()
runOnDB sql = ...
runSanitized :: SanitizedString -> IO ()
runSanitized str = ...
runValidated :: ValidatedString -> IO ()
runValidated str = ...
Muito bem. Para duas verificações precisamos de três tipos diferentes. Não é tão bom, mas ainda é aceitável. E se quisermos uma terceira validação?
class SanitizedString {...}
class ValidatedString {...}
class CapitalizedString {...}
class SanitizedAndValidated {...}
class SanitizedAndCapitalized {...}
class ValidatedAndCapitalized {...}
class SanitizedValidatedAndCapitalized {...}
data SanitizedString = ...
data ValidatedString = ...
data CapitalizedString = ...
data SanitizedAndValidated = ...
data SanitizedAndCapitalized = ...
data ValidatedAndCapitalized = ...
data SanitizedValidatedAndCapitalized = ...
Agora com 3 verificações, precisamos de 7 tipos diferentes. Está começando a ficar ruim, mas pode ficar pior. Considere agora 4 verificações:
class SanitizedString {...}
class ValidatedString {...}
class CapitalizedString {...}
class UTF8Converted {...}
...
...
Neste caso, para 4 verificações precisaríamos de 15 tipos! Inaceitável!
Podemos fazer melhor? Sim, usando tipos fantasmas! 👻
Comecemos com uma verificação:
public static class CheckedString1<T> {
private String contents;
private CheckedString1(String str) {
contents = str;
}
public String getContents() {
return contents;
}
}
public static class Sanitized {
private Sanitized() {
}
public static CheckedString1<Sanitized> sanitize(String str) {
// sanitizes and creates a CheckedString
return new CheckedString1<Sanitized>(str);
}
}
public static class Validated {
private Validated() {
}
public static CheckedString1<Validated> validate(String str) {
// validates string, returns CheckedString
return new CheckedString1<Validated>(str);
}
}
public void runSanitized(CheckedString1<Sanitized> str) {...}
public void runValidated(CheckedString1<Validated> str) {...}
E com duas verificações?
public static 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){...}
Podemos usar esse código da seguinte maneira:
CheckedString2<Sanitized, Validated> sAndV =
new CheckedString2<Sanitized, Validated>(
sql,
Sanitized::sanitize,
Validated::validate);
runOnDB(sAndV);
O código equivalente em Haskell ficaria:
import Prelude hiding (getContents)
data CheckedString1 t = CheckedString1 {getContents :: String}
data Sanitized
data Validated
sanitize :: String -> CheckedString1 Sanitized
sanitize str = ...
validate :: String -> CheckedString1 Validated
validate str = ...
runSanitized :: CheckedString1 Sanitized -> IO ()
runSanitized str = ...
runValidated :: CheckedString1 Validated -> IO ()
runValidated str = ...
data CheckedString2 t1 t2 = UnsafeCheckedString2 {getContents2 :: String}
mkCheckedString2 :: String -> (String -> CheckedString1 t1) -> (String -> CheckedString1 t2) -> CheckedString2 t1 t2
mkCheckedString2 str f1 f2 = UnsafeCheckedString2 c2
where
c1 = getContents (f1 str)
c2 = getContents (f2 c1)
runOnDB :: CheckedString2 Sanitized Validated -> IO ()
runOnDB str = ...
E o seu uso:
main :: IO ()
let sAndV = mkCheckedString2 sql sanitize validate
runOnDB sAndV
Muito bom! Saímos de uma solução cujo número de classes/tipos era exponencial e agora temos uma que é linear no número de verificações que desejamos fazer.
Mas ainda assim, e se quisermos 3, 4, ou um número não espeficicado e arbitrariamente grande de verificações?
É aqui que linguagens como Java, que não tem funções no nível dos tipos, alcança o seu limite. É lógico, é bem fácil fazer este tipo de verificações em tempo de execução! Bastaria passar como parâmetro, junto com a string, uma lista de verificações que já foram feitas.
Contudo, aqui queremos resolver tudo em tempo de compilação, para salvar a sua ceia de natal!!!
Daqui em diante, usaremos novamente apenas Haskell (\o/) pois ele fornece as ferramentas necessárias para trabalharmos.
Vamos começar definindo uma lista das verificações que nos interessam.
data Checks = Sanitized | Validated | Capitalized | UTF8Converted
type CheckedString :: [Checks] -> *
data CheckedString rs where
CheckedString :: String -> CheckedString rs
deriving Show
Agora podemos começar a usar esses tipos.
-- Cria uma checked string que não passou por verificação alguma
checkedString :: String -> CheckedString '[]
checkedString str = CheckedString str
Agora basta adicionar verificações à lista conforme formos precisando. A questão toda é, como fazemos para adicionar um elemento no nível de tipos, à uma lista?
O GHCI nos mostra que existe o (':)
, a versão no nível de tipos
do famoso operador cons para listas:
> :k! 5 ': '[]
5 ': '[] :: [ghc-prim-0.6.1:GHC.Types.Nat]
= '[5]
> :k! Sanitized ': '[]
Sanitized ': '[] :: [Checks]
= '[ 'Sanitized]
> :k! Validated ': Sanitized ': '[]
Validated ': Sanitized ': '[] :: [Checks]
= '[ 'Validated, 'Sanitized]
Perfeito! Agora podemos implementar a função sanitize
:
{-# LANGUAGE TypeOperators #-}
sanitize :: CheckedString a -> CheckedString (Sanitized ': a)
sanitize (CheckedString t) =
-- sanitizes t and creates a new CheckedString
CheckedString t
validate :: CheckedString a -> CheckedString (Validated ': a)
validate (CheckedString t) = ...
capitalize :: CheckedString a -> CheckedString (Capitalized ': a)
capitalize (CheckedString t) = ...
convertToUTF8 :: CheckedString a -> CheckedString (UTF8Converted ': a)
convertToUTF8 (CheckedString t) = ...
Vamos ver se funciona:
> :t "drop database cascade"
"drop database cascade" :: [Char]
> :t checkedString "drop database cascade;"
checkedString "drop database cascade;" :: CheckedString '[]
> sanitize $ checkedString "drop database cascade;"
CheckedString "drop database cascade;"
> :t sanitize $ checkedString "drop database cascade;"
sanitize $ checkedString "drop database cascade;"
:: CheckedString '[ 'Sanitized]
> :t sanitize $ sanitize $ checkedString "drop database cascade;"
sanitize $ sanitize $ checkedString "drop database cascade;"
:: CheckedString '[ 'Sanitized, 'Sanitized]
Droga! Estávamos tão perto! A nossa função funciona, mas permite
duplicações. Se uma string passar duas vezes por uma sanitização,
não faz sentido ficar acumulando as verificações nos tipos. Para
isto, vamoz fazer a nossa própria versão do cons, uma que verifica
antes se um elemento já faz parte da lista antes de inserir
novamente. Para isso implementaremos a type family Elem
que
verifica se um elemento está contido pela lista e em seguida nossa
versão do cons que chamaremos de (#:)
.
{-# LANGUAGE PolyKinds #-}
{-# LANGUAGE UndecidableInstances #-}
import Data.Type.Bool
type Elem :: a -> [a] -> Bool
type family Elem r rs where
Elem r '[] = 'False
Elem r (r ': rs) = 'True
Elem r (_ ': rs) = Elem r rs
type (#:) :: a -> [a] -> [a]
type family r #: rs where
r #: rs = If (Elem r rs) rs (r ': rs)
Apesar de termos definido o If
logo acima, podemos importar o
módulo Data.Type.Bool
que já traz as funções booleanas e o If
implementado para nós.
Mas, esse não é o maior “destaque” do código. O destaque vai para a
extensão UndecidableInstances
. Apesar do nome assustador, sem
essa extensão o compilador reclama que não pode provar que a
execução da type family (#:)
irá terminar, ou seja, que a
compilação terminará (que é o momento onde as type families são
efetivamente executadas). Isso ocorre pois temos chamadas aninhadas
de type families. A ativação dessa extensão diz para o compilador
que sabemos o que estamos fazendo e que a execução irá sim
terminar. No pior dos casos, seu código irá ficara compilando
indefinidamente caso não esteja bem implementado.
Tirado isso do caminho, vamos trocar a assinatura de tipos das
nossas funções para usar (#:)
:
sanitize :: CheckedString a -> CheckedString (Sanitized #: a)
sanitize (CheckedString t) = ...
validate :: CheckedString a -> CheckedString (Validated #: a)
validate (CheckedString t) = ...
capitalize :: CheckedString a -> CheckedString (Capitalized #: a)
capitalize (CheckedString t) = ...
convertToUTF8 :: CheckedString a -> CheckedString (UTF8Converted #: a)
convertToUTF8 (CheckedString t) = ...
Vamos ver se o comportamento está correto:
> :t checkedString "drop database cascade;"
checkedString "drop database cascade;" :: CheckedString '[]
> :t sanitize $ checkedString "drop database cascade;"
sanitize $ checkedString "drop database cascade;"
:: CheckedString '[ 'Sanitized]
> :t validate $ sanitize $ checkedString "drop database cascade;"
validate $ sanitize $ checkedString "drop database cascade;"
:: CheckedString '[ 'Validated, 'Sanitized]
> :t sanitize $ validate $ sanitize $ checkedString "drop database cascade;"
sanitize $ validate $ sanitize $ checkedString "drop database cascade;"
:: CheckedString '[ 'Validated, 'Sanitized]
> :t capitalize $ sanitize $ validate $ sanitize $ checkedString "drop database cascade;"
capitalize $ sanitize $ validate $ sanitize $ checkedString "drop database cascade;"
:: CheckedString '[ 'Capitalized, 'Validated, 'Sanitized]
Sucesso! Agora precisamos conseguir limitar o uso, no banco de dados, apenas para strings que tenham passado pelas validações apropriadas.
Para isto, o que gostaríamos é alguma coisa na seguinte linha:
runOnDB :: Observes [Sanitized, Validated] rs => CheckedString rs -> IO ()
runOnDB (CheckedString t) = ...
Basta que implementemos a validação das verificações chamada de
Observes
acima. Caso a lista rs
contenha as validações
desejadas (Sanitized, Validated
), podemos deixar que a chamada
prossiga.
Vamos tentar implementar Observes
:
type Observes :: [Checks] -> [Checks] -> Constraint
type family Observes rs0 rs1 where
Observes rs0 rs1 = In rs0 rs1 ~ 'True
Essa type family recebe uma lista de verificações obrigatórias,
uma lista das verificações já feitas e devolve uma Constraint
que
só é válida caso as verificações tenham sido corretamente feitas.
Ela usa uma outra type family In
que verifica se uma lista está
contida na outra (independentemente da ordem dos elementos). A sua
implementação está abaixo:
type In :: [a] -> [a] -> Bool
type family In xs ys where
In '[] _ = 'True -- Por definição uma lista vazia está contida em qualquer outra
In _ '[] = 'False -- Se não for a lista vazia, qualquer lista não está contida na outra
In (x ': xs) ys = If (Elem x ys) (In xs ys) 'False
A implementação é direta. No primeiro caso, por definição, uma
lista vazia está contida em qualquer outra. No segundo caso,
qualquer lista não vazia contém pelo menos um elemento que não está
na segunda. E finalmente o caso recursivo, se o primeiro elemento
da primeira lista estiver contido na segunda lista, então basta que
o resto dos elementos da lista também estejam. Caso contrário, já
devolve 'False
e para a recursão.
Note o uso de Elem
, If
e a chamada recursiva à In
. Estamos
escrevendo efetivamente um programa que roda no nível dos tipos!
Apenas por completude, não estamos limitados a apenas adicionar elementos na lista. Podemos, por exemplo, escrever uma função que retire um elemento da lista caso presente:
type (#-) :: [a] -> a -> [a]
type family rs #- r where
'[] #- _ = '[]
(r ': rs) #- r = rs
(r0 ': rs) #- r = r0 ': (rs #- r)
A implementação acima não é muito diferente do que faríamos para uma lista no nível dos termos. Note que ela funciona para listas sem repetição. Tente adaptá-la para fazê-la funcionar para listas com repetição!
Para usar essa função, poderíamos fazer algo assim:
removeSanitized :: Observes '[Sanitized] rs => CheckedString rs -> CheckedString (rs #- Sanitized)
removeSanitized (CheckedString t) =
-- does whatever is needed to t and returns a new CheckedString
CheckedString t
Note que a função removeSanitized
só poderá ser chamada caso a
string em questão seja, de fato, uma string sanitizada!
Como falado no inicio desse post e no post passado quando discutimos vetores indexados, há algumas operações que não somos capazes de implementar sem o uso de type families. Vamos começar recordando o estado atual do nosso vetor:
{-# LANGUAGE GADTs #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE StandaloneKindSignatures #-}
{-# OPTIONS_GHC -Wno-unticked-promoted-constructors #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE ConstraintKinds #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE FlexibleInstances #-}
import Data.Kind
-- Definição indutiva de números naturais
data Nat = Z | S Nat
-- Definição indutiva de um vetor.
type Vector :: Nat -> * -> *
data Vector n a where
Nil :: Vector Z a
(:>) :: a -> Vector n a -> Vector (S n) a
infixr 5 :>
deriving instance Show a => Show (Vector n a)
-- Instância para duas classes comuns
instance Functor (Vector n) where
fmap _ Nil = Nil
fmap f (x :> xs) = f x :> fmap f xs
instance Foldable (Vector n) where
foldMap _ Nil = mempty
foldMap f (x :> xs) = f x <> foldMap f xs
-- Versão safe de operações comuns em vetores
vhead :: Vector (S n) a -> a
vhead (x :> _) = x
vtail :: Vector (S n) a -> Vector n a
vtail (_ :> xs) = xs
vinit :: Vector (S n) a -> Vector n a
vinit (_ :> Nil) = Nil
vinit (x :> xs@(_ :> _)) = x :> vinit xs
vlength :: Vector n a -> Int
vlength Nil = 0
vlength (_ :> xs) = 1 + vlength xs
-- Apesar de termos implementado acima uma versão de length, ela não é
-- type-safe. Poderiamos somar 1 ou 3 e ela continuaria a
-- compilar. Para resolver isto usamos um singleton e implementamos a
-- vlength2
type SNat :: Nat -> *
data SNat n where
SZ :: SNat Z
SS :: SNat n -> SNat (S n)
deriving instance Show (SNat n)
vlength2 :: Vector n a -> SNat n
vlength2 Nil = SZ
vlength2 (_ :> xs) = SS (vlength2 xs)
-- Aproveitamos o singleton para implementar o replicate de forma
-- segura
vreplicate :: SNat n -> a -> Vector n a
vreplicate SZ _ = Nil
vreplicate (SS n) x = x :> vreplicate n x
-- A indexação também é interessante, pois queremos que seja
-- type-safe. Para isto criamos o tipo Ix.
type Ix :: Nat -> *
data Ix n where
IxZ :: Ix (S n)
IxS :: Ix n -> Ix (S n)
deriving instance Show (Ix n)
(!) :: Vector n a -> Ix n -> a
(x :> _) ! IxZ = x
(_ :> xs) ! (IxS i) = xs ! i
-- Também apresentamos o conceito de tipos existenciais. Eles são
-- necessários para implementar funções como filter
type ExVector :: (Nat -> Constraint) -> * -> *
data ExVector c a where
MkExVector :: c n => Vector n a -> ExVector c a
type (>=) :: Nat -> Nat -> Constraint
class m >= n
instance m >= Z
instance m >= n => S m >= n
instance {-# OVERLAPPING #-} m >= n => S m >= S n
vfilter :: (a -> Bool) -> Vector n a -> ExVector ((>=) n) a
vfilter _ Nil = MkExVector Nil
vfilter p (x :> xs)
| p x =
case vfilter p xs of
MkExVector fTail -> MkExVector (x :> fTail)
| otherwise =
case vfilter p xs of
MkExVector fTail -> MkExVector fTail
Apesar de bem recheada, a nossa implementação de vetores ainda deixa a desejar em algumas funções como concatenação de vetores. Vamos começar por ela!
No início deste post já tínhamos definido a soma de dois Nat
, então e só usar!
(++) :: Vector n a -> Vector m a -> Vector (n + m) a
Nil ++ v = v
(x :> xs) ++ v = x :> (xs ++ v)
Note que o tipo de retorno é calculado a partir da soma dos tamanhos das entradas!
A mesma ideia pode ser usada para implementar a função vdrop
. Mas
agora usamos o fato de que quando definimos a subtração de Nat
,
escolhemos representar \(n - m\), com \(m > n\) como \(0\).
Primeiro vamos pensar nos seus tipos. Como queremos que ela seja
segura, vamos usar o tipo SNat
que já tínhamos definido. Ela terá
a seguinte cara:
vdrop :: SNat n -> Vector m a -> Vector (m - n) a
vdrop _ Nil = Nil
vdrop SZ v = v
vdrop (SS n') (_ :> xs) = vdrop n' xs
Primeiramente os casos base. Se o vetor é vazio, devolvemos vazio, não há o que jogar fora. Se descartar 0 elemento, o resultado é o próprio vetor. Finalmente o caso recursivo é, subtraio 1 do número de elementos a serem descartados, descarto um elemento e faço a chamada recursiva.
Parece ok, mas essa implementação, tem um probleminha. O GHC nos diz:
• Could not deduce: ('Z - n) ~ 'Z
from the context: m ~ 'Z
bound by a pattern with constructor: Nil :: forall a. Vector 'Z a,
in an equation for ‘vdrop’
at src/Main.hs:123:9-11
Expected type: Vector (m - n) a
Actual type: Vector 'Z a
• In the expression: Nil
In an equation for ‘vdrop’: vdrop _ Nil = Nil
• Relevant bindings include
vdrop :: SNat n -> Vector m a -> Vector (m - n) a
(bound at src/Main.hs:123:1)
Ele não está sabendo que \(0 - x = 0\) pela nossa definição? O que
está acontecendo? Vamos olhar novamente a definição de (-)
:
type (-) :: Nat -> Nat -> Nat
type family m - n where
m - Z = m
Z - (S n) = Z
(S m) - (S n) = m - n
Note que a segunda equação diz que Z - (S n)
é Z
. Mas não fala
exatamente o que precisamos que é Z - n = Z
. A distinção é
pequena, mas é necessária para que o GHC possa provar que os tipos
de vdrop
estão corretos. Vamos ajustar:
type (-) :: Nat -> Nat -> Nat
type family m - n where
m - Z = m
Z - n = Z
(S m) - (S n) = m - n
Et voilà, ça marche!
Vamos agora ver uma implementação um pouco mais desafiadora. Vamos
dizer que queiramos implementar a função take
. Dado um número \(n\)
de elementos e um vetor, devolvemos um novo vetor que tem \(n\)
elementos ou, caso o vetor dado como entrada não tenha \(n\), um vetor
com todos os seus elementos.
Assim como vdrop
, como queremos que a função seja segura, vamos
usar o tipo SNat
. Ela terá a seguinte assinatura:
vtake :: SNat n -> Vector m a -> (Min n m) a
Precisamos então definir Min
. Vamos usar uma type family!
{-# LANGUAGE TypeFamilies #-}
type Min :: Nat -> Nat -> Nat
type family Min a b where
Min Z _ = Z
Min (S _) Z = Z
Min (S n) (S m) = S (Min n m)
A implementação dos casos base é trivial. Zero é sempre o mínimo entre ele e qualquer número (à esquerda ou à direita). O último caso, recursivo, usa o fato de que \(\min\{n,m\} = 1 + min\{n - 1, m - 1\}\).
Agora podemos implementar a função vtake
:
vtake :: SNat n -> Vector m a -> Vector (Min n m) a
vtake SZ _ = Nil
vtake (SS _) Nil = Nil
vtake (SS n') (x :> xs) = x :> vtake n' xs
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.