domingo, 16 de dezembro de 2018

Mônadas para Schemers [Dave Herman]

Uma Introdução a Mônadas para Schemers

Uma Introdução a Mônadas para Schemers

1 Introdução

Esta será uma introdução a mônadas da perspectiva Lisp/Scheme, com a suposição que o leitor está acostumado a continuações, estilo de passagem de continuações 1, acumuladores e estilo de passagem de acumuladores.

O principal discernimento de mônadas é que todos os efeitos colaterais, de mutação a E/S a não-terminação, têm uma coisa em comum: a ordem da evaluação é importante. Em expressões lambda puras, simples e com terminação, a ordem da evaluação é completamente irrelevante: não importa como você a reduz, o resultado final é o mesmo sem diferença observável. Mas quando você tem efeitos colaterais, eles devem ocorrer na ordem correta. (Mônadas não são o único formalismo para lidar com isso – EPC e forma normal A também servem. Mas eles estão todos correlacionados.)

2 Estilo de Passagem de Continuação

Assim, mônadas são sobre falar de efeitos no contexto de uma semântica pura. Considere tentar implementar o programa impuro a seguir num subconjunto puro de Scheme:

(begin (turn-on-safety!)
       (pull-trigger!))  

Em nosso Scheme puro, devemos definir BEGIN para evaluar ambos os argumentos (vamos simplesmente nos fixar numa definição de dois argumentos) e evaluar para o valor do segundo. Com a definição ingênua:

(define (begin v1 v2) v2)

Um Scheme puro pode evaluar o programa em ordem arbitrária:

   (begin (turn-on-safety!)
          (pull-trigger!))
-> (begin (turn-on-safety!)  ; effect: pull-trigger!
          #<void>)
-> (begin #<void>            ; effect: turn-on-safety!
          #<void>)
-> #<void>

Isso não é bom. Acabamos de acidentalmente atingir um dos membros de nosso comitê de teses. Ops. Vamos fazer um EPC de nossos programas de tal forma que possamos garantir a ordem de evaluação (estou usando o infixo \[\[–]] para representar a tradução para EPC de uma expressão):

\[\[(begin (turn-on-safety!)
           (pull-trigger!))]] =
(lambda (k)
  (\[\[turn-on-safety!]] (lambda (res1)
                           (\[\[pull-trigger!]] (lambda (res2)
                                                 (k res2))))))

Então, em um programa escrito em EPC, podemos definir BEGIN como

(define (begin cps-exp1 cps-exp2)
  (lambda (k)
    (cps-exp1 (lambda (res1)
                (cps-exp2 (lambda (res2)
                            (k res2)))))))

Note que o resultado da primeira expressão é recebido no argumento res1, e subsequentemente ignorado (nenhuma subexpressão refere-se a res1).

3 Estilo de Passagem de Acumulador

Agora nós também sabemos como escrever programas em estilo de passagem de acumulador, onde todos os procedimentos têm que ter uma entrada extra que representa um "registrador" que é atualizado enquanto a computação avança. Considere um pequeno gerador de números aleatórios:

(define seed (current-time))

(define (rand)
  (let ([ans (modulo (* seed 16807) 2147483647)])
    (begin (set! seed ans)
           ans)))

A cada aplicação de RAND, ele atualiza a semente para o novo número aleatório gerado. Se fôssemos implementar isso em Scheme puro, teríamos que passar a semente como um argumento extra ao longo de quaisquer procedimentos que possam atualizar este valor. Todos os procedimentos também teriam que retornar um par de valores: o real resultado do procedimento, mais a nova semente, em caso de ela ter sido atualizada durante a computação do resultado do procedimento.

;; rand : number -> (number x number)
(define (rand seed)
  (let ([ans (modulo (* seed 16807) 2147483647)])
    (cons ans ans)))

;; rand-point : number -> (point x number)
(define (rand-point seed)
  (let* ([r1 (rand seed)]
         [r2 (rand (cdr r1))]
         [r3 (rand (cdr r2))])
    (cons (make-point (car r1) (car r2) (car r3))
          (cdr r3))))

;; rand-segment : number -> (segment x number)
(define (rand-segment seed)
  (let* ([r1 (rand-point seed)]
         [r2 (rand-point (cdr r1))])
    (cons (make-segment (car r1) (car r2))
          (cdr r2))))
;;; ...

O programa todo iniciaria com uma semente primária mais ou menos assim:

(run-my-program (current-time))  

Cada um destes procedimentos têm um par de características em comum, a saber, eles tomam um parâmetro "semente" e retornam um par consistindo de seu resultado e da nova semente. Vamos começar abstraindo isto pelo currying do parâmetro semente:

;; rand : -> (number -> (number x number))
(define (rand)
  (lambda (seed)
    (let ([ans (modulo (* seed 16807) 2147483647)])
      (cons ans ans))))

;; rand-point : -> (number -> (point x number))
(define (rand-point)
  (lambda (seed)
    (let* ([r1 ((rand) seed)]
           [r2 ((rand) (cdr r1))]
           [r3 ((rand) (cdr r2))])
      (cons (make-point (car r1) (car r2))
            (cdr r2)))))

;; rand-segment : -> (number -> (segment x number))
(define (rand-segment)
  (lambda (seed)
    (let* ([r1 ((rand-point) seed)]
           [r2 ((rand-point) (cdr r1))])
      (cons (make-segment (car r1) (car r2))
            (cdr r2)))))

Qualquer procedimento que não pode ver ou mudar o valor da semente pode ser deixado intocado. Chamamos os procedimentos que podem ter efeitos colaterais de "operações", e procedimentos que não podem de "puros". Por exemplo, podemos escrever uma função distância:

(define (distance pt1 pt2)
  (sqrt (+ (sqr (- (point-x pt1) (point-x pt2)))
           (sqr (- (point-y pt1) (point-y pt2)))
           (sqr (- (point-z pt1) (point-z pt2))))))  

Esta função não afeta a semente, então ela permanece pura. Ela não toma um parâmetro extra para representar a semente corrente, e não retorna um par. Isto significa que qualquer procedimento potencialmente com efeitos pode invocar distance, mas distance não pode invocar nenhum procedimento potencialmente com efeitos (dado que descartaria o efeito e não o passaria para a próxima operação).

Poderíamos simular a obtenção e atribuição de um valor para a semente como operações:

;; get-seed : -> (number -> (number x number))
(define (get-seed)
  (lambda (seed)
    (cons seed seed)))

;; set-seed : number -> (number -> (void x number))
(define (set-seed new)
  (lambda (old)
    (cons (void) new)))

Podemos abstrair o padrão comum nos tipos também: dizemos que uma operação que retorna um valor de tipo alpha tem o tipo T(alpha), onde

T(alpha) = number -> (alpha x number)  

Então nossas operações terão os tipos a seguir:

get-seed     : -> T(number)
set-seed     : number -> T(void)
rand         : -> T(number)
rand-point   : -> T(point)
rand-segment : -> T(segment)  

A seguir tentamos definir BEGIN como antes, só que em vez de EPC é apenas a costura do acumulador:

;; begin : T(alpha) T(beta) -> T(beta)
(define (begin comp1 comp2)
  (lambda (seed0)
    (let* ([res1 (comp1 seed0)]
           [val1 (car res1)]
           [seed1 (cdr res1)])
      (comp2 seed1))))  

Como a versão EPC, este BEGIN é um "combinador de operadores": ele toma duas operações e retorna uma nova. Isso não é muito útil para implementar nossas operações, porém:

(define (rand)
  (begin (get-seed)
         (let ([ans (modulo (* ??? 16807) 2147483647)])
           (begin (set-seed ans)
                  (lambda (seed)
                    (cons ans ans))))))

Como a segunda operação em RAND obtém a semente corrente da operação GET-SEED? O problema é que BEGIN descarta o resultado da primeira operação. Vamos escrever um novo combinador que faz o resultado da primeira operação disponível na segunda:

;; pipe : T(alpha) (alpha -> T(beta)) -> T(beta)
(define (pipe comp1 build-comp2)
  (lambda (seed0)
    (let* ([res1 (comp1 seed0)]
           [val1 (car res1)]
           [seed1 (cdr res1)])
      ((build-comp2 val1) seed1))))

Este novo combinador toma uma operação e uma função que recebe o resultado da primeira operação e constrói uma segunda operação baseada no resultado da primeira. Finalmente, ele executa a segunda operação:

(define (rand)
  (pipe (get-seed)
        (lambda (seed)
          (let ([ans (modulo (* seed 16807) 2147483647)])
            (begin (set-seed ans)
                   (lambda (seed)
                     (cons ans ans)))))))  

Também abstraímos a operação de "elevar" um valor puro como uma operação:

;; lift : alpha -> T(alpha)
(define (lift v)
  (lambda (seed)
    (cons v seed)))  

Agora podemos limpar o fim de RAND:

(define (rand)
  (pipe (get-seed)
        (lambda (seed)
          (let ([ans (modulo (* seed 16807) 2147483647)])
            (begin (set-seed ans)
                   (lift ans))))))  

Cada mônada tem um construtor de tipo T para comunicar-se sobre suas "operações", bem como duas operações:

lift : alpha -> T(alpha)
pipe : T(alpha) (alpha -> T(beta)) -> T(beta)  

\[As operações recebem outros nomes: algumas vezes pipe é conhecido como bind, >>=, * ou let. A operação lift é geralmente chamada unit ou return.]

Além disso essas operações devem satisfazer as três leis seguintes:

(pipe (lift x) f)   = (f x)
(pipe m lift)       = m
(pipe (pipe m f) g) = (pipe m (lambda (x) (pipe (f x) g)))  

\[Estou cansado e não me sinto a fim de provar se as leis da mônada valem para nosso exemplo. Na realidade eles provavelmente não estão porque nossa versão de pipe tem dois argumentos (em vez de ser uma versão com curry completo) – a não-terminação ferraria tudo. Oh, bem.]

A mônada pode ter quaisquer outras operações enquanto não invalidar as leis.

Note que uma operação na nossa mônada é na verdade uma função que precisa de uma semente inicial a fim de produzir um valor. Note também que o resultado de executar uma operação produz um par de valores contendo o valor final mais a semente acumulada. Desde que estamos realmente apenas interessados no valor final, podemos construir um procedimento "run" para executar uma operação monádica e extrair seu valor final:

;; T(alpha) -> alpha
(define (run m)
  (car (m (current-time))))  

Note também que essa é a única maneira de "sair" da mônada, i.e., de ir de T(alpha) para alpha. Uma operação monádica é feita de combinadores para produzir uma longa cadeia de operações e então "executar" com um procedimento de nível mais alto. Este procedimento de nível mais alto é bastante semelhante ao processo de rodar um programa em EPC com uma continuação inicial.

4 Sumário

Os dois maiores conceitos de mônadas como eu descrevi aqui são:

  1. Garantir a ordem de evaluação
  2. Abstrair acumuladores

A operação PIPE é um combinador para montar uma operação de composição a partir de duas operações menores; ela força as operações a serem realizadas em uma ordem de maneira muito semelhante a EPC. (De fato, ocorre que EPC é um caso especial de mônada.)

Isto nos permite adicionar efeitos a uma linguagem de núcleo puro de tal forma que é garantido que os efeitos acontecerão na ordem correta. isto é útil para a semântica de linguagens de programação, porque nos permite modelar características úteis da linguagem tais como estado mutável e continuações de primeira classe em uma semântica puramente funcional (i.e., cálculo lambda), e nos permite raciocinar abstratamente sobre execução sequencial dos efeitos.

Ela também é usada em Haskell como uma maneira de realizar operações sequenciais em uma linguagem preguiçosa. Em particular, usa-se mônadas para realizar E/S de forma que é garantido que todas as operações de E/S ocorrem na ordem correta. Haskell também usa mônadas para simular diversos tipos de efeito que foi decidido deixar de fora da linguagem base (e.g., estado mutável e continuações de primeira classe).

5 Mais

Eu me ative a uma visão de programador acerca das mônadas. Eu não descrevi o uso de leis algébricas, nem a matemática por detrás delas. De fato, eu nem mesmo defini mônadas. A razão é que uma mônada é realmente um objeto semântico, que é difícil de apontar quando se está observando o código bruto. Decidi então ficar apenas nas intuições e deixar as definições precisas para outro dia. Então, tem muito mas para se aprender.

6 META

Table 1: META
Título A Schemer's Introduction to Monads
Autor Dave Herman
Link http://www.ccs.neu.edu/home/dherman/research/tutorials/monads-for-schemers.txt
Arquivo http://archive.is/QHSkG

Footnotes:

1
EPC, ou CPS, de "continuation passing style"

Date: Draft of 13 July 2004

Author: Dave Herman

Created: 2018-12-16 dom 18:58

Validate

Nenhum comentário:

Postar um comentário

Entendendo o Combinador Y [hishamhm]

Entendendo, Finalmente, o Combinador Y - Uma Abordagem de uma Perspectiva Amigável a Programadores Entendendo, Finalmente, o...