Prism: uma linguagem funcional impura com efeitos tipados
(stephendiehl.com)- Prism é um compilador experimental que não esconde efeitos como variáveis mutáveis, exceções e streams, expondo-os nos tipos, mas ainda mantém mudanças locais não observáveis externamente como tipos puros, como
Int -> Int - A ideia central combina handlers de efeitos algébricos e polimorfismo de linhas (row polymorphism): os efeitos são mesclados à linha do tipo de função, e os handlers tratam apenas os rótulos necessários antes de encaminhar o restante
- O mesmo sistema de efeitos expressa exceções, geradores/streams, lentes, estado com
vare fluxosfail/guard; alguns caminhos são baixados sem listas intermediárias nem composição em runtime - Do lado de desempenho, combina evidence passing com contagem de referências Perceus para evitar alocação por chamada de efeito e otimiza atualizações funcionais de valores com posse única como mutações in-place
- Prism oferece LLVM IR, MLIR, runtime em C, interpretador em Rust, modelo em Lean 4 e playground em WASM, permitindo inspecionar diretamente a inferência de tipos e os resultados de lowering
Mudanças invisíveis externamente e efeitos tipados
- O ponto de partida do Prism é que uma função
fibque usavare atribuição internamente também pode parecer uma função pura para um observador externo- O exemplo
fibatualiza duas variáveis in-place, mas não expõe estado para fora da função - O tipo é
Int -> Int, e o efeito não aparece no tipo
- O exemplo
- Prism é um compilador funcional de prova de conceito desenvolvido ao longo dos últimos 3 anos, modelando efeitos com base em ideias modernas de tipos das famílias OCaml 5, Haskell e Koka
- A direção central não é evitar efeitos, mas colocá-los no sistema de tipos e otimizar para que o compilador elimine seus custos
Efeitos funcionam como interfaces
- Os efeitos do Prism seguem a abordagem de handlers de efeitos algébricos, em que operações são declaradas e handlers atribuem significado a essas operações
effect Gen { ctl yield(Int) : Unit }declara a operaçãoyield- Em
fn produce(n) : !{Gen} Unit,!{Gen}indica no tipo que a função executayield
- Mesmo para o mesmo produtor, o significado muda conforme a continuation
ké tratadatotalsoma os valores deyieldcountconta o número de chamadas ayield- Descartar
kvira uma exceção; chamá-la uma vez vira estado ou gerador; chamá-la várias vezes vira comportamento de busca
- O exemplo do efeito
Ambexpressa a busca por triplas pitagóricas comchooseerejectchoose(n)fornece valores no intervalo0..n-1- O handler retoma a mesma continuation para cada candidato, criando uma árvore de busca
- Diferentemente do OCaml 5, Prism inclui efeitos nos tipos e não exige empilhar camadas manualmente como em stacks de monad transformers do Haskell
- As linhas de efeitos são mescladas estruturalmente ao longo das chamadas
- Elas se comportam como um conjunto de rótulos, não como uma pilha
Recursos expressos por um único mecanismo
-
Exceções
- Em Prism, exceções são handlers que descartam a continuation
- Uma cláusula
final ctldescartake usa o valor do corpo como resultado do handler - Não é uma abordagem de propagar
Resultnem de espalhar?pela pilha de chamadas - Como exceções são rótulos na linha de efeitos, elas se compõem como exceções extensíveis
- Cada falha é uma operação separada, e a linha no tipo da função indica quais exceções podem escapar
- No exemplo com
AborteTimeout,fetchtem!{Abort, Timeout} with_defaulttrata apenasTimeoutpara retornar o fallback"cached"; após o tratamento, só resta!{Abort}no tipo- Diferentemente das checked exceptions do Java, elas não ficam presas a uma hierarquia de classes e funcionam como um conjunto estrutural aberto
-
Geradores e streams
- Um stream é composto por um produtor que executa
emit, transformadores que capturam e reemitem, e um consumidor que faz fold - O pipeline tem a forma de handlers aninhados em torno de um produtor, então listas intermediárias não surgem estruturalmente
- Exemplo:
srange(1, n).smap(square).skeep(even).stake(5).ssum() - Interrupção antecipada, como
stake(5), é um handler que descarta a continuation depois de obter os valores necessários - A biblioteca de streams foi influenciada por pipes e conduit, do Haskell
- Um stream é composto por um produtor que executa
-
Lentes
- As lentes do Prism não são uma biblioteca separada, mas uma combinação de caminhos de atualização de registros com o modelo de memória
- Em registros aninhados, uma única expressão de caminho pode atualizar vários campos profundos
- Exemplo:
{ g | player.pos.x = 30, player.hp = 95, score = 110 } - A spine do registro aninhado é reconstruída, mas, quando o valor tem posse única, as células desmontadas são reutilizadas
- Graças a essa estrutura, uma atualização funcional pode ser compilada para escritas de ponteiro
- Nenhum tipo optic é alocado ou composto em runtime
deriving (Lens)gera acessores comuns como funções, comoscore_ofewith_score
-
Estado mutável
varé dessugarizado para operaçõesget/setde um efeito privado- Um handler instalado no fim do bloco trata esse efeito
- A análise de escape de closures rejeita casos em que o estado escapa para fora
- A função envolvente pode manter uma linha de efeitos vazia
-
Falha
- Falhas são expressas por um efeito anônimo
Fail, e o sistema de tipos lida com “esta expressão pode não produzir um valor” fail()executa uma falha, eguard(cond)falha quando a condição é falsa??usa um fallback quando o lado esquerdo falha?.percorre uma cadeia opcional e interrompe cedo- Um guard em comprehension poda elementos que falharam, em vez de causar crash
- Como
vartambém é açúcar sintático para handlers, um blocotransactpode tirar snapshots de variáveis live e fazer rollback em caso de falha
- Falhas são expressas por um efeito anônimo
Recursos de tipos modernos
- Prism usa inferência de tipos bidirecional higher-rank da família Dunfield-Krishnaswami para que a maioria do código não precise de assinaturas de tipo explícitas
- Em fronteiras que exigem polimorfismo de ordem superior, os binders são explicitados com
forallpick(g : forall a. (a) -> a)pode aplicargtanto aBoolquanto aInt- Em um núcleo Damas-Milner,
aseria unificado comBoolna primeira chamada, e a segunda chamada seria rejeitada
- Polimorfismo ad hoc é expresso com type classes no estilo Lean
- Instâncias são valores nomeados
given Ord(a)exige um dicionário- Quando há várias instâncias, uma é marcada como
canonical, e as outras são explicitadas comusing
derivinggera instâncias repetitivas e acessores de campos paraEq,Ord,Show,Lense similares- Pattern matching também se conecta a classes
pattern First(n) for Peek = view peeké um padrão que usa um método de classe como viewhead_orpode usar o mesmo padrão para vários tipos que tenham uma instância dePeek
showfunciona de modo orientado por tipo, sem uma classe separada- O compilador sintetiza um printer estrutural a partir do tipo estático
- Ele não lê tags de tipo em runtime para decidir como imprimir
- Linhas de efeitos também são polimórficas
fn twice(f : (Unit) -> Int ! {| e})soma os resultados independentemente de qual seja a linha de efeitoseda função recebida- Em um thunk puro,
eé unificado com a linha vazia{} - Em um thunk que executa efeitos como
TickouSay, a linha correspondente é encaminhada intacta
Estratégia de compilação para reduzir o custo dos efeitos
- Implementações didáticas de efeitos algébricos podem reconstruir a computação como uma árvore em forma de free monad e alocar pequenas células a cada operação
- O caminho rápido do Prism é o evidence passing da família Koka
- Em vez de reconstruir a computação para encontrar handlers, as cláusulas de handlers ativas são passadas para cada ponto de operação como parâmetros comuns
do opé baixado para uma chamada direta- Como apenas uma closure é alocada ao instalar um handler, o custo é
O(handlers)e não proporcional ao número de operações
- A codificação por free monad permanece como fallback
- Computações que escapam para uma estrutura de dados
- Resumption verdadeiramente multishot
- Padrões como masked handler
- Pipelines de streams calculam, por análise de fluxo interprocedural, a evidence de efeito necessária além das fronteiras de funções
- A evidence é encadeada por produtores e transformadores
- A cadeia inteira é baixada para um único loop
- Não há listas intermediárias nem alocação de célula por operação
- Essa abordagem obtém, em um compilador de efeitos, resultados semelhantes à stream fusion do Haskell ou à unificação de adapters de iterators do Rust
Modelo de memória e runtime
- Prism usa contagem de referências Perceus
- Células de heap são liberadas deterministicamente em pontos conhecidos estaticamente
- Não há pausas nem tracing
- A reutilização limitada ao frame passa células recém-desmontadas por pattern match de volta ao lado do construtor
- Um
mapsobre uma lista com posse única parece uma função pura que retorna uma nova lista, mas na prática pode ser feito in-place - A compilação de atualizações por lentes para escritas de ponteiro usa o mesmo mecanismo
- Um
- A estrutura de runtime é semelhante à disciplina de memória do Lean 4, mas Prism emite LLVM IR
- O LLVM IR é gerado via
inkwell - Para o mesmo programa, também é gerado um módulo MLIR textual
- O resultado é ligado com
clangao runtime em C escrito manualmente,prism_rt.c
- O LLVM IR é gerado via
prism_rt.cé um runtime pequeno, com cerca de 2 mil linhas- Células de heap têm uma estrutura de 4 ou mais words no formato
{ refcount, tag, arity, fields... } - Inclui allocator,
rc_inc/rc_dec, allocator de reutilização in-place e primitivos de bignum e strings - Não há collector thread, card table nem safepoint
- Células de heap têm uma estrutura de 4 ou mais words no formato
- O allocator padrão é o
mallocda libc, com uma configuração opt-in demimallocpara benchmarking - O live-cell oracle verifica a propriedade garbage-free da suíte de testes fazendo assert, ao final da execução, de que o balanço do heap é 0
Modelo em Lean e validação de backends
- O interpretador do Prism é da família de máquinas CEK, e essa máquina é refletida no modelo Lean 4
models/Prism.lean - O modelo Lean 4 tem um teorema verificado mecanicamente de que a relação small-step é determinística
- Um programa avança para exatamente um próximo estado
- O interpretador em Rust também é usado como oracle diferencial
- Todos os programas do corpus passam pelo interpretador e pelos backends LLVM, MLIR e binário linkado com C
- O build só passa se as saídas dos quatro forem byte-identical
- Determinismo é a base para execução durável reproduzível
- A ideia é que, fixando entradas e registrando a execução, seja possível reproduzi-la bit-for-bit
- Em versões futuras, imagina-se um Prism que verifique como propriedade de tipo a segurança de replay após crash
Playground WASM e código-fonte
- O mesmo interpretador é compilado para WASM e permite executar código Prism sem instalação no playground
- O playground executa programas em um worker
- Botões permitem despejar assinaturas de tipos inferidas, effect rows e o core IR baixado
- É possível ver para qual forma loops com
varou pipelines de streams realmente são baixados
- É possível ver para qual forma loops com
- Os exemplos do texto estão incluídos em um dropdown
- O código-fonte completo, o modelo Lean e o runtime em C estão no repositório do Prism no GitHub
Linhagem de implementação e natureza do projeto
- O core IR do Prism pertence à família call-by-push-value, baseada em Call-by-Push-Value: A Functional/Imperative Synthesis, de Levy
- O caminho rápido de efeitos é próximo de Generalized Evidence Passing for Effect Handlers e Effect Handlers, Evidently, de Xie e Leijen
- O modelo de memória se baseia em Perceus: Garbage Free Reference Counting with Reuse, Reference Counting with Frame-Limited Reuse e FP^2: Fully in-Place Functional Programming
- As linhas de efeitos seguem row polymorphism e scoped labels, e os handlers adotam, via Eff e Koka, a forma de Handlers of Algebraic Effects, de Plotkin e Pretnar
- O pattern matching é baseado em decision tree e usefulness matrix, e a forma
patterncombina view patterns com Pattern Synonyms, do GHC - A camada de falhas reaproveita The Verse Calculus como handlers
final ctl - A direção do Prism está mais próxima de tornar efeitos visíveis, tipá-los e rastreá-los de forma componível do que de “programação funcional pura”
- O projeto em si está mais próximo de um brinquedo e de uma obra artística do que de uma ferramenta prática, e é descrito como um compilador criado pela beleza intelectual das ideias de programação funcional
1 comentários
Comentários no Lobste.rs
Não entendo o que lentes têm a ver com efeitos. Toda vez que o texto menciona lentes, elas parecem não ter relação entre si, exceto por estarem agrupadas sob “um truque de cinco maneiras”
E também não faço ideia do que
tick_use()está tentando fazer. Esperam que o leitor entenda um exemplo tão enrolado sem explicação? Anotações de tipo teriam ajudadoMesmo assim, fora o nível metafórico, ainda não vejo bem o que lentes têm a ver com efeitos. O autor escreve:
Vou precisar dedicar mais tempo para entender, mas parece realmente bonito
Realmente impressionante. Justamente por isso fico curioso por que Diehl chama o compilador de brinquedo no fim do texto. Parece uma prova de conceito bem-sucedida que mostra um novo nível de elegância
Gostaria de ver em mais detalhes como a representação intermediária call-by-push-value realmente é. Tenho curiosidade especialmente sobre como ela lida com pontos de junção (join points)
Já houve artigos tratando da teoria de acrescentar efeitos ao CBPV. Dizer que computações têm tipos de efeito e valores não têm parece bem natural, mas eu não tinha visto isso concretizado o bastante para ser aplicado diretamente à passagem de evidências do Koka, então é interessante
No geral, queria entender onde isso se posiciona em comparação ao Koka. Olhando para FBIP, Perceus e passagem de evidências, é claro que há forte inspiração no trabalho do Koka e, ao mesmo tempo, o uso de CBPV como representação intermediária também o torna claramente diferente. Só não fica muito visível o quanto é diferente
Parece muito com algo que eu venho tentando arrumar tempo para construir. Gostei!
Fugindo um pouco do tema, sempre achei meio triste que o projeto “write you a haskell” do Stephen tenha parado alguns anos atrás. Para o Prism, seria ótimo ter uma explicação de implementação em nível de tutorial
Fico curioso sobre o que é “impuro” nesta linguagem. Essa palavra aparece só no título e não volta no corpo do texto
Como todos os efeitos parecem ser rastreados, funções sem efeitos continuam sendo funções matemáticas. Estou deixando algo passar?
fibmostrada.var xé uma variável mutável impura, enquantolet xé uma variável imutável puraÉ muito legal que coisas que normalmente eram tratadas como recursos da linguagem, como o
yieldda linguagem X ou othrowda linguagem Y, tenham sido implementadas como mais uma interfacePoder construir vários fluxos de controle, como efeitos colaterais, exceções e continuações, sobre uma única abstração é uma forma interessante de enxergar de outro jeito todo o conjunto de perguntas de design, e espero que isso ajude ou estimule mais exploração e inovação. Acho que vou voltar a isso por muitos anos para buscar inspiração