Algebraic Effects
Status: v1 entregue (runtime + sintaxe + verificação estrita de efeitos). Row polymorphism completo chega em v2 — ver §13. Spec longa:
ADR 0003. Exemplos rodáveis:examples/features/25-effects/.
Algebraic effects são o mecanismo pelo qual uma função declara que precisa de capacidades (ler arquivo, logar, sortear, falar com banco) sem se acoplar à implementação delas. Quem chama decide o que cada capacidade significa instalando handlers — e a mesma função roda inalterada em produção, em testes, em modo --dry-run, em replay, em sandbox.
Se você já conhece dependency injection, traits dyn, mocks ou monads, efeitos cobrem todos esses casos com uma única abstração mais leve.
1. TL;DR #
use std::fs
effect IO {
fn read(path: str) -> str
}
fn parse_config() with IO -> Config {
let raw = perform IO::read("app.toml")
return Config.parse(raw).unwrap()
}
// Em produção
let cfg = handle parse_config() with {
IO::read(p) => fs.read_to_string(p),
}
// Em teste — mesma fn, handler diferente
let cfg = handle parse_config() with {
IO::read(_) => "[server]\nport = 8080",
}
A assinatura with IO é parte do tipo. A função não conhece fs, não recebe um logger via parâmetro, não tem global. O significado de IO::read é decidido na borda, no handle.
2. Por que isso importa #
A pergunta que efeitos respondem:
"Como faço minha função
parse_config()ler arquivo, logar, e medir tempo — sem acoplar ela afs,LoggereClock?"
Resposta tradicional, três caminhos, todos ruins:
| Estratégia | Problema |
|---|---|
Passar tudo como parâmetro (fn parse(fs, log, clock)) |
Contamina toda a árvore de chamadas. Adicionar uma dependência muda 50 assinaturas. |
Singletons globais (std::fs::read) |
Fácil de chamar, impossível de testar sem mocks invasivos. Vaza entre threads/requests. |
Trait objects / DI containers (fn parse(deps: &dyn Deps)) |
Boilerplate enorme. Cada nova dep mexe na interface. dyn allocations no caminho quente. |
Efeitos são o quarto caminho:
- Assinatura declara o quê, não como —
with IO + Logger + Clocksubstitui três parâmetros. - Handler é local e lexical — sem singleton global, sem container.
- Compõe com
+— adicionar uma dep não muda a forma da função. - Testável por construção — handler de teste é um literal de mapa.
3. A ideia em 3 minutos #
Um efeito tem quatro peças:
| Peça | Sintaxe | Função |
|---|---|---|
| Declaração | effect IO { fn read(...) -> str } |
Nomeia o efeito + suas operações |
| Anotação | fn parse() with IO -> Config { ... } |
Declara que a função usa IO |
| Invocação | perform IO::read("foo") |
Dispara a operação no site do uso |
| Interpretação | handle expr with { IO::read(p) => ... } |
Instala handlers para a sub-árvore |
A semântica:
- A declaração
effect IO { ... }só fixa a forma das operações (assinaturas). - Quem invoca (
perform) só sabe que precisa do efeito — fica desacoplado da implementação. - Quem define o que IO faz é o
handle, não a declaração. - Sem handler instalado,
performlança um erro de runtimeunhandled effect— ou seja, omitir o handler é o sandbox.
4. Sintaxe completa #
4.1 Declarando um efeito #
effect IO {
fn read(path: str) -> str
fn write(path: str, data: str)
}
effecté uma keyword.- Cada operação é uma assinatura
fn op(params) [-> Ret]— sem corpo. - Operações sem
-> Retretornamnil. - Nomes de operação devem ser únicos dentro do mesmo
effect(parser rejeita duplicados).
4.2 Declarando que uma função usa um efeito #
fn parse_config(path: str) with IO -> Config { ... }
fn save_log(msg: str) with IO + Logger { ... }
fn pure_helper(n: int) -> int { ... } // sem efeitos
- A cláusula
withaparece entre os parênteses dos parâmetros e a flecha de retorno. - Múltiplos efeitos:
with E1 + E2 + E3(ordem irrelevante). - Em v1 o verificador estrito emite erro de compilação
TE800se você esquecer a anotação em código que usaperform, eTE802se um chamador não cobrir os efeitos do chamado (ver §12 e §13).
4.3 Invocando uma operação #
let config = perform IO::read("config.toml")
perform IO::write("out.log", "hello")
- Caminho qualificado obrigatório:
Effect::op. Formaperform op(...)(sem efeito) não compila. - O valor que
performproduz é o que a cláusulaEffect::opnohandlemais próximo devolveu.
4.4 Instalando handlers #
let result = handle parse_config("c.toml") with {
IO::read(path) => fs.read_to_string(path),
IO::write(_, _) => panic("read-only filesystem"),
}
- A expressão à esquerda de
withé o corpo que executa com handlers ativos. - Cada cláusula
Effect::op(params) => bodydefine como interpretarperform Effect::op(...). - O valor de retorno da cláusula é o que o
performcorrespondente vê. - Cláusulas são closures normais — têm acesso ao escopo léxico do
handle.
5. Galeria de aplicações #
Onde efeitos mudam o jogo na prática.
5.1 Servidores HTTP / APIs REST #
O caso mais óbvio. Cada handler precisa: ler banco, logar, autenticar, enviar evento.
effect Db { fn query(sql: str) -> [User] }
effect Log { fn info(msg: str) }
effect Metric { fn count(name: str) }
fn create_user(input: NewUser) with Db + Log + Metric -> User {
perform Log::info("creating user {input.email}")
let users = perform Db::query("SELECT * FROM users WHERE email = '{input.email}'")
if users.len() > 0 { panic("email taken") }
perform Metric::count("user.created")
return User.new(input)
}
Em produção:
handle create_user(input) with {
Db::query(sql) => sqlite.query(connection, sql),
Log::info(msg) => journald.write(msg),
Metric::count(n) => prometheus.inc(n),
}
Em teste:
let logs = []
let metrics = #{}
let result = handle create_user(input) with {
Db::query(_) => [],
Log::info(msg) => logs.push(msg),
Metric::count(n) => metrics[n] = (metrics[n] ?? 0) + 1,
}
assert(logs.contains("creating user alice@x.com"))
assert(metrics["user.created"] == 1)
Mesmo create_user, dois mundos. Sem trait objects, sem mocking framework.
5.2 Pipelines ETL #
Você lê de S3, transforma, escreve em Postgres, manda métrica. Em dev quer ler local, escrever SQLite, métrica vira print.
use std::Array
effect Source { fn read(path: str) -> [Record] }
effect Sink { fn write(table: str, rows: [Record]) }
fn pipeline() with Source + Sink {
let raw = perform Source::read("s3://bucket/2026/sales.csv")
let cleaned = raw |> Array.filter(|r| r.amount > 0)
perform Sink::write("sales_clean", cleaned)
}
Um pipeline. Dois handle ... with. Zero código duplicado.
5.3 Game engines #
NPC quer atacar player → consulta posição, sortear dano, tocar som, log de combate.
fn attack(self, target) with World + Random + Audio + CombatLog -> Damage {
let pos = perform World::position_of(target)
let crit = perform Random::float() < self.crit_chance
let dmg = self.base_dmg * (crit ? 2 : 1)
perform Audio::play("hit.ogg")
perform CombatLog::record(self, target, dmg)
return Damage.new(dmg, crit)
}
Replay/save: handler de Random lê de um seed gravado → game determinístico.
Headless tests: Audio é no-op, World é fake → testa AI sem rodar engine.
Demos: World lê de um arquivo gravado → roda gameplay sem input do player.
5.4 CLI tools / DevOps #
deploy_service precisa: ler config, falar com Kubernetes, abrir PR, mandar Slack.
use std::fs
fn deploy(service: str) with Fs + K8s + Github + Slack {
let cfg = perform Fs::read("services/{service}.toml")
perform K8s::apply(cfg)
perform Github::create_pr(service, "Deploy {service}")
perform Slack::post("#deploys", "Deploying {service}")
}
// `--dry-run` é só trocar handlers
handle deploy(name) with {
Fs::read(p) => fs.read(p), // real
K8s::apply(cfg) => print("[dry] would apply: {cfg.name}"),
Github::create_pr(_, _) => print("[dry] would open PR"),
Slack::post(_, _) => print("[dry] would notify"),
}
if dry_run { ... } else { ... } espalhado pelo código? Acabou. Trocar 4 cláusulas no entry-point é o --dry-run.
5.5 Compiladores e LSPs #
Mesmo compilador, três frontends:
- LSP server instala handlers que vão pro VFS (memória do editor)
- CLI instala handlers que leem do disco
- Fuzzer instala handlers que retornam input aleatório
fn type_check(module: str) with FileSystem + Cache -> [Diagnostic] {
let src = perform FileSystem::read(module)
let cached = perform Cache::lookup(module, hash(src))
if cached.is_some() { return cached.unwrap() }
let result = analyze(src)
perform Cache::store(module, hash(src), result)
return result
}
Roslyn, rust-analyzer, gopls fazem isso à mão com traits. Efeitos automatizam.
5.6 Retry / circuit breaker #
use std::http
fn fetch_user(id: int) with Http -> User {
let json = perform Http::get("https://api/users/{id}")
return User.from_json(json).unwrap()
}
// Wrapper que aplica retry; o interno chama HTTP real
fn with_retry<T>(action: fn() -> T with Http) -> T with Http {
let attempt = 0
while true {
let result = handle action() with {
Http::get(url) => {
let r = http.get(url)
if r.is_err() && attempt < 3 {
attempt = attempt + 1
sleep(2 ^ attempt seconds)
continue
}
return r.unwrap()
},
}
return result
}
}
// Use:
let user = with_retry(|| fetch_user(42))
fetch_user permanece ingênuo. Trocar de retry exponencial para circuit-breaker é trocar a fn de wrapping.
5.7 Property-based testing #
A função declara with Random. O framework instala handlers que registram a sequência de números sorteados. Quando um teste falha, faz shrink alterando essa sequência.
fn sort_preserves_length(input: [int]) with Random -> bool {
let perm = perform Random::shuffle(input)
return perm.len() == input.len()
}
// QuickCheck-like driver:
property_test(sort_preserves_length, generations: 1000)
O driver instala um handler de Random que produz inputs determinísticos a partir de um seed, registra a sequência, e regenera com seeds menores quando encontra falha. QuickCheck para código com I/O vira possível.
5.8 Sistemas embarcados / firmware #
Drivers viram efeitos:
effect Gpio { fn write(pin: int, value: bool) }
effect I2c { fn read(addr: u8, reg: u8) -> u8 }
fn read_temperature() with I2c -> float {
let raw = perform I2c::read(0x48, 0x00)
return raw as float * 0.0625
}
No host: handlers simulam o periférico em RAM, você roda cargo test.
No target: handlers chamam o hardware real. Mesmo binário lógico, dois backends de I/O. Hoje isso é feito com embedded-hal no Rust — cada periférico exige um trait novo. Efeitos são mais leves.
5.9 Plugin sandboxing #
Embute Zolo num app maior. O app define quais efeitos o script pode realizar. Quer um plugin sem acesso ao filesystem? Não passe o handler de FileSystem::write. Capabilities por construção, sem VM separada, sem sandbox de syscall.
// O host instala só os efeitos seguros:
handle plugin.run() with {
Console::log(msg) => app.show_notification(msg),
// Fs, Net, Process — não instalados → plugin não pode usar
}
Plugin tenta perform Fs::write(...) → erro de runtime unhandled effect. Sem possibilidade de escapar.
5.10 Web/UI orientada a estado #
Frameworks reativos precisam ler cookie, navegar router, persistir local storage, falar HTTP. SvelteKit/Remix loaders teriam vida mais fácil se "esse loader pode ler cookies" fosse parte do tipo da função, não convenção.
fn load_dashboard() with Cookies + Db + Cache -> DashboardData {
let user_id = perform Cookies::read("session")?.user_id
let cached = perform Cache::lookup("dash:{user_id}")
if cached.is_some() { return cached.unwrap() }
let data = perform Db::query("SELECT ... WHERE user_id = {user_id}")
perform Cache::store("dash:{user_id}", data)
return data
}
6. Padrões idiomáticos #
6.1 Handlers aninhados — escopo de override #
use std::fs
fn read_three() with IO -> str {
return "{perform IO::read(\"a\")}|{perform IO::read(\"b\")}|{perform IO::read(\"c\")}"
}
// Handler externo: arquivo real
handle read_three() with {
IO::read(p) => {
// Para o segundo arquivo, usa um stub — sem rebuildar o resto
if p == "b" {
return handle perform_b() with { IO::read(_) => "STUB" }
}
return fs.read_to_string(p)
},
}
Casos reais: log gravado durante uma seção, retry com timeout-por-tentativa, snapshot de uma chamada.
6.2 Capturando estado — handler-as-recorder #
let logs = []
let result = handle action() with {
Log::info(msg) => logs.push(msg),
}
// `logs` agora tem o histórico
Closure captura. O handler grava. O teste assert no logs.
6.3 Decorando uma função sem mudá-la #
fn with_timing<T>(label: str, action: fn() -> T with Clock) -> T with Clock {
let start = perform Clock::now()
let result = action()
let elapsed = perform Clock::now() - start
print("{label} took {elapsed}ms")
return result
}
// Use:
let user = with_timing("fetch_user", || fetch_user(42))
Adicionar timing é uma função-de-ordem-superior. Sem decorators, sem AOP.
6.4 Substituindo um efeito por outro #
fn translate<T>(action: fn() -> T with Http) -> T with Net {
return handle action() with {
Http::get(url) => perform Net::request(url, "GET"),
Http::post(url, body) => perform Net::request(url, "POST", body),
}
}
Adapter pattern: handler converte chamadas de um efeito para outro. Útil em portas.
6.5 Default handlers via wrapper #
fn with_default_logger<T>(action: fn() -> T with Log) -> T {
return handle action() with {
Log::info(msg) => print("[INFO] {msg}"),
Log::warn(msg) => print("[WARN] {msg}"),
Log::error(msg) => eprint("[ERROR] {msg}"),
}
}
Um wrapper que remove o efeito da assinatura ao instalar handlers padrão. A função interna usa Log; quem chama with_default_logger(...) não precisa pensar em logging.
7. Comparação com alternativas #
7.1 vs. Dependency injection clássica #
// Trait DI
trait Deps {
fn fs(&self) -> &dyn Fs;
fn log(&self) -> &dyn Logger;
}
fn parse(deps: &dyn Deps) -> Config { ... }
Cada nova dep:
- nova fn no trait
- nova implementação em todos os structs
Deps - todos os tests precisam fabricar um
Depsmockado
Com efeitos: with E + F substitui o trait inteiro. Cada nova dep é uma cláusula extra no handle da borda.
7.2 vs. Singletons / módulos globais #
import { fs } from 'std/fs'
function parse() { return fs.read('cfg.toml') }
Não testável sem mocks de módulo (jest.mock(...), monkey-patching). Vaza estado entre testes em paralelo. Não tem como sandboxar um chamador específico.
Com efeitos: a chamada é parametrizada por construção. Cada handle é seu próprio mundo.
7.3 vs. Monads (Haskell-style) #
parseConfig :: IO Config
parseConfig = do
raw <- readFile "cfg.toml"
return (parse raw)
IO polui toda função que toca. Combinar IO + Reader + State + Either exige transformers ou mtl — boilerplate famoso.
Com efeitos: combinar é with E1 + E2 + E3. Sem lift, sem stack de monads.
7.4 vs. Callbacks / hooks #
function parse(opts = {}) {
const fs = opts.fs ?? require('fs')
return fs.readFileSync('cfg.toml')
}
Cada dep é um campo opcional. A assinatura não diz o que é necessário. Defaults vazam por baixo dos panos.
Com efeitos: a assinatura é documentação executável. Esquecer um handler é erro de compilação em v1 (TE803) ou erro de runtime se o handle não cobrir todas as operações.
7.5 vs. Async/await viral #
async function a() { await b() }
async function b() { await c() }
async se propaga para baixo até o entry-point. Função pura que vira async força reescrita de toda cadeia.
Com efeitos (v2+): with Async é só uma anotação que se acumula. Adicionar with Logger no meio da cadeia não muda a estrutura sintática.
7.6 Tabela resumo #
| Abordagem | Testável | Sandbox | Composição | Boilerplate |
|---|---|---|---|---|
| Parâmetros explícitos | sim | não | não (assinatura cresce) | alto |
| Singletons | não | não | sim | zero |
| Trait DI | sim | não | parcial (interfaces grandes) | alto |
| Mocks runtime | frágil | não | sim | médio |
| Monads | sim | sim | parcial (stacks/lift) | alto |
| Algebraic effects | sim | sim | sim (com +) |
baixo |
8. Modelo de runtime #
A v1 usa um handler-stack dinâmico (semelhante a OCaml 5 / Effect.Deep):
- Um global
__zolo_effect_stacké uma lista de "frames". Cada frame é uma tabela{ "Effect::op" = fn(...) ... }. handle expr with { ... }empilha o frame, executaexprnumpcall, desempilha — mesmo em caso de erro.perform Effect::op(args)percorre o stack do topo para a base, encontra a cláusula, chama-a comargs, devolve o resultado.- Sem multi-shot continuations: o handler devolve o valor e o
performrecebe-o (one-shot resume implícito).
parse_config() handle{Production} handle{Test}
| | |
`-- perform IO::read("c") -->| |
|--> Real::read("c") -> fs ----39;
<-- "[server]\nport=8080"
parse_config() handle{Test}
| |
`-- perform IO::read("c") -->|
|--> Mock::read(_) -> stub
<-- "[server]\nport=8080"
Custo #
- Cada
performé O(profundidade do stack). Tipicamente 1–3. - Cada
handleé uma alocação de tabela + umpcall. - Comparado a
try/catch: ~2× mais caro, ainda barato. Em hot loops, evite.
Detalhes em ADR 0003 §3.
9. Quando NÃO usar #
- Hot loops — cada
performé uma busca no stack + chamada indireta. Para milhões de chamadas/s, prefira passar a dep como parâmetro. - Funções puras — matemática, transformação de strings, parsing. Efeitos só engrossam a assinatura.
- Operação com retorno de erro único — quando a "capability" tem só uma chamada que falha (parse de uma string),
Result<T, E>é mais simples. - App pequeno sem testes — efeitos brilham quando você tem mocks/replays/dry-run. Para um script de 50 linhas sem testes, é overkill.
- Equipe sem familiaridade — efeitos são novos pra muita gente. Não introduza num codebase grande sem alinhamento + um exemplo de referência.
10. Capabilities — sandbox de plugins #
Plugins declaram quais efeitos requerem no zolo.toml:
[plugin]
name = "my-yaml-loader"
version = "0.1.0"
requires-effects = ["IO::read"]
provides-effects = []
Em v1 isto é declaração documental. Em v2, a chamada de plugin é tipada. Em v3, runtime injeta o stack conforme política do projeto:
zolo run --no-effect=IO::write app.zolo # v3+
O plugin tenta perform IO::write(...) → erro capability denied: IO::write. Sandbox sem VM separada, sem syscall isolation. Ver ADR 0003 §5.
11. Receita: substituindo async/await por efeitos
Hoje:
async fn fetch_homepage() -> str {
let r = await http.get("https://example.com")
return r.body
}
Equivalente com efeitos (v2+):
fn fetch_homepage() with Async + Http -> str {
let r = perform Http::get("https://example.com")
return r.body
}
A diferença prática:
async fnforça tudo até o topo a serasync.with Asyncé uma anotação que pode ser "removida" por um wrapper que instala um event-loop handler. O event-loop vira biblioteca, não keyword.
A migração planeada (ver §13) preserva async/await como açúcar sintático — você não precisa reescrever código existente.
12. Type checking — o que está em v1 #
O verificador estrito de efeitos (v1) emite erros de compilação para as condições abaixo. Nenhuma delas é apenas aviso.
| Cenário | Diagnóstico v1 |
|---|---|
perform E::op numa fn que não declara with E e não está dentro de um handle que cobre E |
Erro TE800 |
with E referência a um efeito não declarado (salvo import use plugin::prelude::*) |
Erro TE801 |
Chamador invoca fn com efeitos que o chamador não declara e não estão cobertos por handle |
Erro TE802 |
handle expr with { ... } não fornece arm para toda operação de todo efeito requerido |
Erro TE803 |
| Arm do handler referencia operação inexistente no efeito | Erro TE804 |
| Arm do handler tem número diferente de parâmetros que a assinatura da operação | Erro TE805 |
Dois arms para a mesma operação no mesmo handle |
Erro TE806 |
perform em runtime sem handler instalado |
Erro de runtime: unhandled effect: ... |
A tipagem do valor de retorno de perform e dos parâmetros de handler permanece pendente para v1.5. Row polymorphism chega em v2. Para a lista completa de códigos com descrições, ver §16 — Códigos de diagnóstico.
13. Roadmap v1 → v2 #
A v2 introduz row polymorphism estilo Koka:
fn parse() -> Config with <IO | r> // IO + qualquer outro efeito r
fn run<r>(action: () -> a with <IO | r>) -> a with <r>
Onde r é uma "linha" de efeitos polimórfica — você pode passar uma função que usa IO + Logger para um wrapper que só consome IO, e o Logger "atravessa" pelo r.
Custos estimados (ADR 0003 §4):
- 6 semanas pesquisa (algoritmo de inferência)
- 3 meses MVP (parser + checker + lowering)
- 1 mês integração (LSP + diagnostics)
Marcos:
- v1: verificador estrito lançado — TE800/TE801 promovidos a erros, TE802–TE806 adicionados.
- v1.5: tipagem de retorno de
performe parâmetros de handler. - v2.0: row polymorphism + inferência local.
- v2.1:
Async,Panic,Iterreescritos como efeitos da stdlib. - v3.0: capabilities runtime (
zolo run --no-effect).
14. Limitações conhecidas (v1) #
- Sem multi-shot resume. Algoritmos como backtracking, exceptions resumáveis, iteradores não-lineares não são expressáveis. Workaround: use
for ... in iter+ escolha de coleção. - Sem inferência. Você declara
with Eà mão em todas as funções que usamE. - Exaustividade verificada em compilação (TE803). Handlers incompletos são erro estático. Tipagem dos valores de retorno permanece para v1.5.
- Backend nativo não suportado.
crates/zolo-lang/tests/e2e/18_effects/tem@skip-native. Usezolo run(VM). - Sem instrumentação no profiler. Custo é mensurável mas sem hook em
zolo-toolsainda.
15. Comparação com outras linguagens #
| Sistema | Resume | Type tracking | Maturidade |
|---|---|---|---|
| Zolo v1 | One-shot implícito | Verificador estrito (TE800–TE806), erros de compilação | Estável |
| Zolo v2 (planeado) | One-shot implícito | Row polymorphism completo | — |
| Koka (Microsoft) | Multi-shot + abort | Row polymorphism completo | Pesquisa madura |
| Eff | Multi-shot | Tipos efeito explícitos | Pesquisa |
OCaml 5 (Effect.Deep) |
One-shot | Sem checagem estática | Estável (5.0+) |
| Roc | Implícito via Task |
Inferido | Beta |
| Unison | Abilities (efeitos reordenados) | Inferido | Beta |
A escolha do Zolo é OCaml 5 v1 → Koka v2: começa com semântica simples e bem-testada, evolui para tracking estrutural quando o type-system maturar.
16. Códigos de diagnóstico #
| Código | Gatilho |
|---|---|
TE800 |
Uma função usa perform Foo::op sem declarar with Foo e não está dentro de um handle que cobre Foo. |
TE801 |
Uma função declara with Foo mas nenhum effect Foo { ... } está em escopo. (Soft / aviso apenas quando há import use plugin::prelude::* ativo.) |
TE802 |
Um chamador invoca uma função cuja linha with contém um efeito que o chamador não declara e não está coberto por um handle envolvente. |
TE803 |
Um bloco handle expr with { ... } não fornece um arm para cada operação de cada efeito requerido por expr. |
TE804 |
Um arm do handler referencia uma operação que não existe no efeito nomeado. |
TE805 |
Um arm do handler tem número de parâmetros diferente do que a assinatura da operação declara. |
TE806 |
Um bloco handle tem dois arms para a mesma operação de efeito. |
17. Referência rápida #
// Declarar um efeito
effect Name {
fn op1(p: T) -> R
fn op2(p1: T1, p2: T2) // sem retorno -> nil
}
// Função que usa efeitos
fn f(x: int) with E1 + E2 -> R { ... }
// Invocar uma operação
perform Name::op1(arg)
// Instalar handlers
handle expr with {
E1::op1(p) => <expr usando p>,
E2::op2(a, b) => <expr ou bloco>,
}
// Wrappers que removem efeitos da assinatura
fn wrap<T>(action: fn() -> T with E) -> T {
return handle action() with { E::op(p) => <impl> }
}
18. Onde aprender mais #
- Exemplos rodáveis:
examples/features/25-effects/— 5 arquivos, do básico ao aninhado. - Testes e2e:
crates/zolo-lang/tests/e2e/18_effects/. - ADR completo:
docs/decisions/0003-effects-capabilities.md. - Implementação:
- parsing:
crates/zolo-parser/src/parser.rs - codegen:
crates/zolo-compiler/src/lowering.rs - runtime:
crates/zolo-std/src/prelude.rs(helpers__zolo_effect_*)
- parsing:
Leitura externa #
- Plotkin & Pretnar — Handlers of Algebraic Effects — o paper fundador.
- Daan Leijen — Type Directed Compilation of Row-Typed Algebraic Effects — base do Koka, modelo do Zolo v2.
- OCaml 5 Effect handlers tutorial — modelo da v1.
- Effekt programming language — sistema acadêmico que inspira muito.
Próximo: veja Schemas — o irmão "puramente estrutural" dos efeitos: validação de dados sem capacidades.