Esse post é baseado no livro Teoria das Categorias para Programadores, de Bartosz Milewski.
A categoria oposta da Kleisli, denominada co-Kleisli leva ao conceito
de Comonads. Agora temos endofunctors w
(m
de cabeça para baixo) e
morfismos do tipo w a -> b
, e queremos definir um operador de
composição para eles, da mesma forma que definimos o operador >=>
:
(=>=) :: (w a -> b) -> (w b -> c) -> (w a -> c)
Da mesma forma, nosso morfismo identidade é similar ao return
mas com
a seta invertida:
extract :: w a -> a
Chamamos essa função de extract
pois ela permite extrair um conteúdo
do functor w
(nosso container).
O oposto de nosso operador bind
deve ter a assinatura
(w a -> b) -> w a -> w b
, ou seja, dada uma função que retira um valor
do tipo a
de um container transformando em um tipo b
no processo,
retorne uma função que, dado um w a
me retorne um w b
. Essa função é
chamada de extend
ou na forma de operador =>>
.
Finalmente, o oposto de join
é o duplicate
, ou seja, insere um
container dentro de outro container, sua assinatura é w a -> w (w a)
.
Nesse ponto, podemos perceber a dualidade entre Monad e Comonad. Em um
Monad criamos uma forma de colocar um valor dentro de um container,
através do return
, mas sem garantias de que poderemos retirá-lo de lá.
Envolvemos nosso valor em um contexto computacional, que pode ficar
escondido até o final do programa, como vimos no comportamento do Monad
IO.
Já um Comonad, nos traz uma forma de retirar um valor de um container,
através de extract
, sem prover uma forma de colocá-lo de volta. Além
disso, ele permite uma computação contextual de um elemento do
container, ou seja, podemos focar em um elemento e manter todo o
contexto em volta dele (e já vimos isso nos tipos buracos!).
Então nossa classe Comonad é definida por:
class Functor w => Comonad w where
(=>=) :: (w a -> b) -> (w b -> c) -> (w a -> c)
extract :: w a -> a
(=>>) :: (w a -> b) -> w a -> w b
duplicate :: w a -> w (w a)
Relembrando o Reader Monad que definimos no post anterior:
data Reader e a = Reader (e -> a)
instance Monad (Reader e) where
-- (Reader e a) -> (a -> Reader e b) -> (Reader e b)
-- (Reader f) >>= k = \e -> runReader (k (f e)) e
ra >>= k = Reader (\e -> let a = runReader ra e
rb = k a
in runReader rb e)
return a = Reader (\e -> a)
A versão embelezada dos morfismos na categoria Kleisli é
a -> Reader e b
que pode ser traduzido para a -> (e -> b)
e colocado
na forma curry de (a, e) -> b
. Com isso, conseguimos definir o
Comonad de Writer e
como o complemento do Monad Reader:
instance Comonad (Writer e) where
(=>=) :: (Writer e a -> b) -> (Writer e b -> c) -> (Writer e a -> c)
f =>= g = \(Writer e a) -> let b = f (Writer e a)
c = g (Writer e b)
in c
extract (Writer e a) = a
Basicamente, a função extract
ignora o ambiente definido por e
e
retorna o valor a
contido no container. O operador de composição
simplesmente pega duas funções que recebem tuplas como parâmetros, sendo
a primeira do mesmo tipo, e aplica sequencialmente utilizando o mesmo
valor de e
nas duas chamadas (afinal e
é read-only).
Examinando o operador =>=
temos como argumentos f :: w a -> b
e
g :: w b -> c
e precisamos gerar uma função h :: w a -> c
. Para
gerar um valor do tipo c
, dado f, g
, a única possibilidade é aplicar
g
em um tipo w b
:
f =>= g = g ...
Tudo que temos a disposição é uma função f
que produz um b
.
Precisamos então de uma função com a assinatura
(w a -> b) -> w a -> w b
, que é nossa função extend
(=>>
). Com
isso temos a definição padrão:
f =>= g = \wa -> g . (f ==>) wa
-- ou
-- f =>= g = g . (f =>>)
Da mesma forma, pensando no operador =>>
com assinatura
(w a -> b) -> w a -> w b
, percebemos que não tem como obter
diretamente um w b
ao aplicar a função argumento em w a
. Porém, uma
vez que w
necessariamente é um Functor, temos a disposição a função
fmap :: (c -> b) -> w c -> w b
, que ao fazer com que c = w a
, temos
(w a -> b) -> w (w a) -> w b
. Se conseguirmos produzir um w (w a)
,
podemos utilizar fmap
para implementar =>>
. Temos a função
duplicate
, o que faz com que:
class Functor w => Comonad w where
extract :: w a -> a
(=>=) :: (w a -> b) -> (w b -> c) -> (w a -> c)
f =>= g = g . (=>>)
(=>>) :: (w a -> b) -> w a -> w b
f =>> wa = fmap f . duplicate
duplicate :: w a -> w (w a)
duplicate = (id =>>)
A ideia de um Comonad é que você possui um container com um ou mais
valores de a
e que existe uma noção de valor atual ou foco em um
dos elementos. Esse valor atual é acessado pela função extract
. O
operador co-peixe (=>=
) faz alguma computação, definida pelas
funções compostas, no valor atual porém tendo acesso a tudo que está em
volta dele. Já a função duplicate
cria diversas versões de w a
cada
qual com um foco diferente, ou seja, temos todas as possibilidades de
foco para aquela estrutura. Finalmente, a função extend
(=>>
),
primeiro gera todas as versões da estrutura via duplicate
para então
aplicar a função co-Kleisli através do fmap
, ou seja, ela aplica a
função em todas as possibilidades de foco.
Podemos definir um Stream de dados como uma lista infinita não-vazia:
data Stream a = Cons a (Stream a)
instance Functor Stream where
fmap f (Cons a as) = Cons (f a) (fmap f as)
Notem que essa estrutura possui naturalmente um foco em seu primeiro
elemento. Com isso podemos definir a função extract
simplesmente como:
extract (Cons a _) = a
Lembrando que a função duplicate
deve gerar uma Stream de Streams,
cada uma focando em um elemento dela, podemos criar uma definição
recursiva como:
duplicate (Cons a as) = Cons (Cons a as) (duplicate as)
Cada chamada recursiva cria uma Stream com a cauda da lista original. Com isso temos as funções necessárias para criar uma instância de Comonad para Streams:
instance Comonad Stream where
extract (Cons a _) = a
duplicate (Cons a as) = Cons (Cons a as) (duplicate as)
Como exemplo de aplicação vamos criar uma função que calcula a média móvel de um stream de dados. Começamos com a definição da média entre os \(n\) próximos elementos:
sumN :: Num a => Int -> Stream a -> a
sumN n (Cons a as) | n <= 0 = 0
| otherwise = a + sumN (n-1) as
avgN :: Fractional a => Int -> Stream a -> a
avgN n as = (sumN n as) / (fromIntegral n)
Notem que avgN
tem assinatura Int -> (w a -> a)
, para gerarmos uma
Stream de médias móveis queremos algo como
Int -> (w a -> a) -> w a -> w a
, que remete a assinatura de =>>
:
movAvg :: Fractional a => Int -> Stream a -> Stream a
movAvg n as = (avgN n) =>> as
Com isso, a função avgN n
será aplicada em cada foco de as
gerando
uma nova Stream contendo apenas os valores das médias.
Podemos implementar o Comonad Stream em Python criando uma lista ligada em que o próximo elemento é definido por uma função geradora passada como parâmetro para o construtor de objetos da classe:
from functools import partial
class Stream:
'''
Data Stream in Python: infinite stream of data with generator function
-- equivalent to Haskell Stream a = Stream a (Stream a)
x: initial value
f: generator function (id if None)
g: mapped function (Fucntor fmap)
'''
def __init__(self, x=1, f=None, g=None):
idfun = lambda x: x
self.f = idfun if f is None else f
self.g = idfun if g is None else g
self.x = x
def __str__(self):
x = self.extract()
return f"{x}..."
def next(self):
''' the tail of the Stream '''
return Stream(self.f(self.x), self.f, self.g)
def extract(self):
''' returns mapped current value '''
return self.g(self.x)
def duplicate(self):
''' a Stream of Streams '''
def nextS(xs):
return self.next()
return Stream(self, nextS)
def fmap(self, g):
''' Functor instance '''
return Stream(self.x, self.f, g)
def extend(self, g):
''' comonad extend =>> '''
return self.duplicate().fmap(g)
def avgN(n, xs):
s = 0
for i in range(n):
s += xs.extract()
xs = xs.next()
return s/n
def movAvg(n, xs):
movAvgN = partial(avgN, n)
return xs.extend(movAvgN)
def f(x):
return x+1
xs = Stream(1, f)
print(xs)
print(movAvg(5, xs))
Relembrando o State
Monad visto anteriormente, ele foi definido como a
composição (Reader s) . (Writer s)
:
data State s a = State (s -> (a, s))
= Reader s (Writer s a)
Isso foi possível pois os Functors Reader
e Writer
são adjuntos. De
forma análoga podemos fazer a composição complementar para criarmos um
Comonad:
data Store s a = Store (s -> a) s
= Writer s (Reader s a)
A instância de Functor para esse Comonad é simplesmente a composição da
função definida por Reader s a
:
instance Functor (Store s) where
fmap g (Store f s) = Store (g . f) s
A assinatura de extract
deve ser Store s a -> a
, sendo que o tipo
Store
armazena uma função s -> a
e um s
, basta aplicar a função no
estado s
que ele armazena. Por outro lado, a função duplicate
pode
se aproveitar da aplicação parcial na construção de um valor definindo:
instance Comonad (Store s) where
extract (Store f s) = f s
duplicate (Store f s) = Store (Store f) s
Podemos imaginar o Comonad Store
como um par que contém um container
(a função f
) que armazena diversos elementos do tipo a
indexados
pelo tipo s
(i.e., array associativa) e um s
que indica o foco
atual da estrutura (como em um Zipper, visto anteriormente). Nessa
interpretação temos que extract
retorna o elemento a
na posição
atual s
e duplicate
simplesmente cria infinitas cópias desse
container de tal forma que cada cópia está deslocada em \(n\) posições
para direita ou para a esquerda.
Como exemplo, vamos implementar o automato celular 1D conforme descrito por Wolfram. Esse automato inicia com uma lista infinita indexada por valores inteiros (positivos e negativos) e centralizada em \(0\). A lista contém inicialmente o valor \(1\) na posição central e \(0\) em todas as outras posições.
A cada passo da iteração, a lista é atualizada através das regras n
em
que \(0 \leq n \leq 255\). A numeração da regra codificam um mapa de
substituição para o número binário formado pela subslita composta do
valor atual e de seus dois vizinhos. Por exemplo, a regra 30 codifica:
Podemos implementar esse autômato utilizando um Store Int Int
,
primeiro definindo a função que aplica a regra:
rule :: Int -> Store Int Int -> Int
rule x (Store f s) = (succDiv2 !! bit) `rem` 2
where
-- qual o bit que devemos recuperar
bit = (f (s+1)) + 2*(f s) + 4*(f (s-1))
-- divisao sucessiva de x por 2
succDiv2 = iterate (`div` 2) x
Fazendo uma aplicação parcial do número da regra, a assinatura da função
fica: Store Int Int -> Int
que deve ser aplicada em um Store Int Int
para gerar a próxima função de indexação. Isso sugere o uso de extend
(=>>
):
nextStep rl st = rl =>> st
Mas queremos uma aplica sucessiva dessa regra infinitamente, para isso
podemos utilizar iterate
:
wolfram :: (Store Int Int -> Int) -> Store Int Int -> [Store Int Int]
wolfram rl st = iterate (rl =>> st)
A representação inicial de nosso ambiente é feita por:
f0 :: Int -> Int
f0 0 = 1
f0 _ = 0
fs :: Store Int Int
fs = Store f0 0
Finalmente, podemos capturar um certo instante do nosso autômato simplesmente acessando o elemento correspondente da lista:
wolf30 = wolfram (rule 30) fs
fifthIteration = wolf30 !! 5
E podemos imprimir nosso ambiente criando uma instância de Show
:
instance (Num s, Enum s, Show a) => Show (Store s a) where
show (Store f s) = show [f (s+i) | i <- [-10 .. 10]]
main = print (take 5 wolf30)
Como referência, o código em Python ficaria:
from functools import partial
import itertools
class Store:
def __init__(self, f, s):
self.f = f
self.s = s
def fmap(self, g):
f = lambda s: g(self.f(s))
return Store(f, self.s)
def extract(self):
return self.f(self.s)
def duplicate(self):
return Store(lambda s: Store(self.f, s), self.s)
def extend(self, g):
return self.duplicate().fmap(g)
def __str__(self):
return str([self.f(self.s+i) for i in range(-10,11)])
def rule(x, fs):
succDiv2 = [x]
while x!=0:
x = x//2
succDiv2.append(x)
bit = fs.f(fs.s+1) + 2*fs.f(fs.s) + 4*fs.f(fs.s-1)
if bit >= len(succDiv2):
return 0
return succDiv2[bit] % 2
def wolfram(rl, fs):
while True:
yield fs
fs = fs.extend(rl)
def f0(x):
if x==0:
return 1
return 0
fs = Store(f0, 0)
wolf30 = wolfram(partial(rule, 30), fs)
top5 = itertools.islice(wolf30, 6)
for w in top5:
print(w)
https://bartoszmilewski.com/2014/10/28/category-theory-for-programmers-the-preface/