Você não pode criar uma função pura chamada random
que fornecerá um resultado diferente toda vez que for chamada. De fato, você não pode nem "chamar" funções puras. Você os aplica. Então você não está perdendo nada, mas isso não significa que números aleatórios estejam fora dos limites na programação funcional. Permita-me demonstrar, usarei a sintaxe Haskell por toda parte.
Vindo de um contexto imperativo, você pode inicialmente esperar que aleatório tenha um tipo como este:
random :: () -> Integer
Mas isso já foi descartado porque o aleatório não pode ser uma função pura.
Considere a ideia de um valor. Um valor é uma coisa imutável. Isso nunca muda e todas as observações que você pode fazer sobre isso são consistentes o tempo todo.
Claramente, aleatório não pode produzir um valor inteiro. Em vez disso, produz uma variável aleatória Inteiro. Seu tipo pode ficar assim:
random :: () -> Random Integer
Exceto que passar um argumento é completamente desnecessário, as funções são puras, então uma random ()
é tão boa quanto a outra random ()
. Vou dar aleatoriamente, a partir daqui, este tipo:
random :: Random Integer
Tudo bem, mas não muito útil. Você pode escrever expressões como random + 42
, mas não pode, porque não será verificado. Você ainda não pode fazer nada com variáveis aleatórias.
Isso levanta uma questão interessante. Quais funções devem existir para manipular variáveis aleatórias?
Esta função não pode existir:
bad :: Random a -> a
de qualquer maneira útil, porque então você pode escrever:
badRandom :: Integer
badRandom = bad random
O que introduz uma inconsistência. badRandom é supostamente um valor, mas também é um número aleatório; uma contradição.
Talvez devêssemos adicionar esta função:
randomAdd :: Integer -> Random Integer -> Random Integer
Mas este é apenas um caso especial de um padrão mais geral. Você deve poder aplicar qualquer função à coisa aleatória para obter outras coisas aleatórias como:
randomMap :: (a -> b) -> Random a -> Random b
Em vez de escrever random + 42
, agora podemos escrever randomMap (+42) random
.
Se tudo o que você tinha fosse randomMap, não seria possível combinar variáveis aleatórias. Você não pôde escrever esta função, por exemplo:
randomCombine :: Random a -> Random b -> Random (a, b)
Você pode tentar escrever assim:
randomCombine a b = randomMap (\a' -> randomMap (\b' -> (a', b')) b) a
Mas tem o tipo errado. Em vez de terminar com um Random (a, b)
, terminamos com umRandom (Random (a, b))
Isso pode ser corrigido adicionando outra função:
randomJoin :: Random (Random a) -> Random a
Mas, por razões que podem eventualmente ficar claras, não vou fazer isso. Em vez disso, vou adicionar isso:
randomBind :: Random a -> (a -> Random b) -> Random b
Não é imediatamente óbvio que isso realmente resolve o problema, mas sim:
randomCombine a b = randomBind a (\a' -> randomMap (\b' -> (a', b')) b)
De fato, é possível escrever randomBind em termos de randomJoin e randomMap. Também é possível escrever randomJoin em termos de randomBind. Mas vou deixar de fazer isso como um exercício.
Poderíamos simplificar um pouco isso. Permita-me definir esta função:
randomUnit :: a -> Random a
randomUnit transforma um valor em uma variável aleatória. Isso significa que podemos ter variáveis aleatórias que não são realmente aleatórias. Esse sempre foi o caso; nós poderíamos ter feito randomMap (const 4) random
antes. O motivo para definir randomUnit é uma boa ideia é que agora podemos definir randomMap em termos de randomUnit e randomBind:
randomMap :: (a -> b) -> Random a -> Random b
randomMap f x = randomBind x (randomUnit . f)
Ok, agora estamos chegando a algum lugar. Temos variáveis aleatórias que podemos manipular. Contudo:
- Não é óbvio como podemos realmente implementar essas funções,
- É bastante complicado.
Implementação
Vou abordar números pseudo-aleatórios. É possível implementar essas funções para números aleatórios reais, mas essa resposta já está ficando muito longa.
Essencialmente, a maneira como isso vai funcionar é que vamos repassar um valor inicial em todo lugar. Sempre que gerarmos um novo valor aleatório, produziremos uma nova semente. No final, quando terminarmos de construir uma variável aleatória, desejaremos fazer uma amostra dela usando esta função:
runRandom :: Seed -> Random a -> a
Vou definir o tipo aleatório assim:
data Random a = Random (Seed -> (Seed, a))
Então, precisamos fornecer implementações de randomUnit, randomBind, runRandom e random, o que é bastante direto:
randomUnit :: a -> Random a
randomUnit x = Random (\seed -> (seed, x))
randomBind :: Random a -> (a -> Random b) -> Random b
randomBind (Random f) g =
Random (\seed ->
let (seed', x) = f seed
Random g' = g x in
g' seed')
runRandom :: Seed -> Random a -> a
runRandom seed (Random f) = (snd . f) seed
Por acaso, vou assumir que já existe uma função do tipo:
psuedoRandom :: Seed -> (Seed, Integer)
Nesse caso, aleatório é justo Random psuedoRandom
.
Tornando as coisas menos complicadas
Haskell tem açúcar sintático para tornar as coisas mais agradáveis aos olhos. Isso se chama do-notation e, para usar tudo o que temos, criar uma instância do Monad for Random.
instance Monad Random where
return = randomUnit
(>>=) = randomBind
Feito. randomCombine
de antes agora poderia ser escrito:
randomCombine :: Random a -> Random b -> Random (a, b)
randomCombine a b = do
a' <- a
b' <- b
return (a', b')
Se eu estivesse fazendo isso por mim mesmo, daria um passo além e criaria uma instância de Applicative. (Não se preocupe se isso não faz sentido).
instance Functor Random where
fmap = liftM
instance Applicative Random where
pure = return
(<*>) = ap
Então randomCombine poderia ser escrito:
randomCombine :: Random a -> Random b -> Random (a, b)
randomCombine a b = (,) <$> a <*> b
Agora que temos essas instâncias, podemos usar em >>=
vez de randomBind, ingressar em vez de randomJoin, fmap em vez de randomMap, retornar em vez de randomUnit. Também recebemos uma grande quantidade de funções gratuitamente.
Vale a pena? Você poderia argumentar que chegar a esse estágio em que trabalhar com números aleatórios não é completamente horrendo foi bastante difícil e demorado. O que recebemos em troca desse esforço?
A recompensa mais imediata é que agora podemos ver exatamente quais partes do nosso programa dependem da aleatoriedade e quais são inteiramente determinísticas. Na minha experiência, forçar uma separação estrita como essa simplifica imensamente as coisas.
Supomos até agora que queremos apenas uma amostra de cada variável aleatória que geramos, mas se acontecer que, no futuro, gostaríamos de ver mais da distribuição, isso é trivial. Você pode usar runRandom muitas vezes na mesma variável aleatória com sementes diferentes. É claro que isso é possível em linguagens imperativas, mas, neste caso, podemos ter certeza de que não executaremos IO imprevisíveis toda vez que amostramos uma variável aleatória e não precisamos ter cuidado com a inicialização do estado.