Padrões Funcionais
Nesta página
Três padrões clássicos de programação funcional emergem naturalmente dos efeitos:
Reader — contexto somente leitura
O padrão Reader injeta configurações, locale ou flags de feature sem passá-los
explicitamente por toda a cadeia de assinaturas. O handler fecha sobre o "ambiente"
e responde a perform Config::get("chave") de acordo. Trocar ambientes significa
trocar o handler:
greet pede greeting e lang via Config::get; handlers en e pt fornecem mundos diferentes.
// Feature: Reader pattern — inject read-only context via a handler.
// Equivalent to dependency injection of an immutable config: the body
// asks via `perform Config::get("key")` and the handler decides what
// the answer is. Different `handle` sites install different worlds.
// When to use: any function that needs config, locale, feature flags,
// or env-derived constants — without threading them through every
// signature.
effect Config {
fn get(key: str) -> str
}
fn greet(user: str) with Config -> str {
let greeting = perform Config::get("greeting")
let lang = perform Config::get("lang")
return "{greeting}, {user}! (lang={lang})"
}
// Production-like environment.
let en = handler {
Config::get(key) => {
if key == "greeting" { return "Hello" }
if key == "lang" { return "en" }
return ""
},
}
let m1 = handle greet("Marco") with en
print(m1)
// expected: Hello, Marco! (lang=en)
// Same function, different "environment" — no rewiring needed at the
// call site. The body is unaware that the world changed.
let pt = handler {
Config::get(key) => {
if key == "greeting" { return "Olá" }
if key == "lang" { return "pt" }
return ""
},
}
let m2 = handle greet("Marco") with pt
print(m2)
// expected: Olá, Marco! (lang=pt)
State — estado mutável com escopo
O padrão State expõe get, set e reset como operações de efeito. O handler
fecha sobre uma variável mutável local; cada instalação tem seu próprio contador.
O corpo não precisa saber onde o estado vive:
Counter com get/incr/reset; handler fecha sobre n; dois passes sequenciais com reset.
// Feature: State pattern — `get`/`set`/`incr` ops backed by a handler
// that captures a mutable variable in its closure. The effectful body
// sees state as if it were an external resource; each install scopes
// its own counter.
// When to use: counters, accumulators, in-progress flags — short-lived
// mutable state you don't want to lift into every signature.
effect Counter {
fn get() -> int
fn incr()
fn reset()
}
fn tally(items: [str]) with Counter -> int {
for _ in items {
perform Counter::incr()
}
return perform Counter::get()
}
// The handler captures `n` in its closure. All three ops see the same
// variable across every perform within the handle scope.
let n = 0
let h = handler {
Counter::get() => n,
Counter::incr() => { n = n + 1 },
Counter::reset() => { n = 0 },
}
let total = handle tally(["a", "b", "c", "d"]) with h
print("after run: {total}")
// expected: after run: 4
// Outside the handle, `n` still holds the last value — useful for
// inspection (testing how many times an op fired) but risky to rely on
// for sequential reuse without a `reset` first.
print("captured: {n}")
// expected: captured: 4
// Reset for a second pass — same `h`, same closure, fresh logical
// counter.
fn reset_counter() with Counter { perform Counter::reset() }
let _ = handle reset_counter() with h
let again = handle tally(["x", "y"]) with h
print("second run: {again}")
// expected: second run: 2
Writer — produtor desacoplado do consumidor
O padrão Writer permite que o corpo emita mensagens sem saber para onde elas vão. O handler decide o destino — uma lista em memória, stdout com prefixo, um arquivo. Trocar o destino não exige mudança alguma no produtor:
pipeline() emite três passos; to_buffer acumula numa lista; to_stdout imprime diretamente.
// Feature: Writer pattern — every `perform` appends to a collector
// the handler owns. Equivalent to streaming output: the body produces
// values, the handler decides where they go. The body never sees the
// collector — perfect separation of producer and consumer.
// When to use: log accumulation, event buffering, tracing, building a
// report — anywhere the producer should be unaware of the sink.
effect Out {
fn emit(msg: str)
}
fn pipeline() with Out {
perform Out::emit("step 1: parsing")
perform Out::emit("step 2: validating")
perform Out::emit("step 3: writing")
}
// First sink: a list. The handler captures it; the body fills it.
let buffer = []
let to_buffer = handler {
Out::emit(msg) => { buffer.push(msg) },
}
handle pipeline() with to_buffer
print("captured {buffer.len()} messages:")
for line in buffer {
print(" - {line}")
}
// expected:
// captured 3 messages:
// - step 1: parsing
// - step 2: validating
// - step 3: writing
// Same function body, different sink: send everything to stdout
// prefixed. No change in `pipeline()` — only the handler switches.
let to_stdout = handler {
Out::emit(msg) => print(">> {msg}"),
}
handle pipeline() with to_stdout
// expected:
// >> step 1: parsing
// >> step 2: validating
// >> step 3: writing
Caso real: DB + Fs + Log + Metric
Com múltiplos efeitos e handlers-como-valor, o mesmo código de domínio roda em
produção (handlers que chamam serviços reais) e em testes (handlers em memória que
capturam escritas, logs e métricas). Cada teste reconstrói apenas o handle … with
— a função process_orders permanece intacta:
Workflow real com Db + Fs + Log + Metric; cinco testes @test cobrem caminhos feliz, erro e misto.
// Feature: real-world effects — Database + FileSystem + Logger + Metric
// Syntax: combine multiple effects in one signature; install one stack
// of handlers for production, swap them with in-memory mocks in `@test`.
// When to use: any function that touches IO or state — the same body
// runs in prod, in tests, in `--dry-run`, or in replay, by changing
// only the `handle ... with { ... }` block at the call site.
// ─────────────────── Effects (the contracts) ────────────────────────
// Each `effect` block declares operation signatures only — no bodies.
// A function's `with E1 + E2 + ...` clause is the inventory of
// capabilities it needs from whoever calls it.
effect Db {
fn query(sql: str) -> [str]
fn insert(table: str, row: str) -> int
}
effect Fs {
fn read(path: str) -> str
fn write(path: str, data: str)
fn exists(path: str) -> bool
}
effect Log {
fn info(msg: str)
fn error(msg: str)
}
effect Metric {
fn count(name: str)
}
// ─────────────────── Domain logic (knows nothing about IO) ──────────
// Reads pending order IDs from a file, looks each one up in the DB,
// "charges" them (insert into transactions), writes a receipt file,
// and tracks every step through Log/Metric. Returns how many orders
// were processed. There is no `if test_mode`, no injected struct, no
// global — every side effect is a `perform`, and the handler at the
// boundary decides what it actually does.
fn process_orders(path: str) with Fs + Db + Log + Metric -> int {
if !perform Fs::exists(path) {
perform Log::error("orders file missing: {path}")
perform Metric::count("orders.skipped.no_file")
return 0
}
let raw = perform Fs::read(path)
let ids = raw.split(",")
var processed = 0
for id in ids {
let sql = "SELECT name FROM users WHERE id = {id}"
let users = perform Db::query(sql)
if users.len() == 0 {
perform Log::error("unknown user id={id}")
perform Metric::count("orders.skipped.unknown_user")
continue
}
let name = users[0]
let row = "user={name},amount=10"
let txn = perform Db::insert("transactions", row)
let receipt_path = "receipts/{id}.txt"
let receipt_data = "txn={txn} user={name}"
perform Fs::write(receipt_path, receipt_data)
perform Log::info("charged {name} (txn {txn})")
perform Metric::count("orders.processed")
processed = processed + 1
}
perform Log::info("done: {processed} orders")
return processed
}
// ─────────────────── Production-shaped run ──────────────────────────
// In a real app these handlers would call sqlite, fs, journald,
// prometheus. Here they are printable stubs so `zolo run` shows the
// workflow end-to-end without external deps.
let n = handle process_orders("orders.csv") with {
Fs::exists(p) => p == "orders.csv",
Fs::read(_) => "1,2,99,3", // 99 is unknown
Fs::write(p, _) => print("[fs ] wrote {p}"),
Db::query(sql) => {
if sql.contains("id = 1") { return ["Alice"] }
if sql.contains("id = 2") { return ["Bob"] }
if sql.contains("id = 3") { return ["Carol"] }
return []
},
Db::insert(_, _) => 1001,
Log::info(m) => print("[INFO] {m}"),
Log::error(m) => print("[ERR ] {m}"),
Metric::count(_) => nil,
}
print("processed = {n}")
// expected:
// [fs ] wrote receipts/1.txt
// [INFO] charged Alice (txn 1001)
// [fs ] wrote receipts/2.txt
// [INFO] charged Bob (txn 1001)
// [ERR ] unknown user id=99
// [fs ] wrote receipts/3.txt
// [INFO] charged Carol (txn 1001)
// [INFO] done: 3 orders
// processed = 3
// ─────────────────── Tests — `zolo test` runs these ─────────────────
// Each test reinterprets the same `process_orders` against an
// in-memory world. Handler closures capture writes / logs / counters
// into local state; assertions read that state back. No mocking
// framework, no temp files, no globals — the whole "world" is the
// `with { ... }` block.
@test
fn test_happy_path_processes_all_users() {
let logs = []
let writes = #{}
let metrics = #{}
let n = handle process_orders("orders.csv") with {
Fs::exists(_) => true,
Fs::read(_) => "1,2",
Fs::write(p, d) => { writes[p] = d },
Db::query(sql) => {
if sql.contains("id = 1") { return ["Alice"] }
return ["Bob"]
},
Db::insert(_, _) => 42,
Log::info(m) => logs.push(m),
Log::error(m) => logs.push("ERR " + m),
Metric::count(k) => { metrics[k] = (metrics[k] ?? 0) + 1 },
}
assert_eq(n, 2)
assert_eq(writes["receipts/1.txt"], "txn=42 user=Alice")
assert_eq(writes["receipts/2.txt"], "txn=42 user=Bob")
assert_eq(metrics["orders.processed"], 2)
}
@test
fn test_unknown_user_is_skipped_and_logged() {
let errors = []
let metrics = #{}
let n = handle process_orders("orders.csv") with {
Fs::exists(_) => true,
Fs::read(_) => "7,8", // both missing
Fs::write(_, _) => nil,
Db::query(_) => [], // DB returns nothing
Db::insert(_, _) => 0,
Log::info(_) => nil,
Log::error(m) => errors.push(m),
Metric::count(k) => { metrics[k] = (metrics[k] ?? 0) + 1 },
}
assert_eq(n, 0)
assert_eq(errors.len(), 2)
assert_eq(errors[0], "unknown user id=7")
assert_eq(errors[1], "unknown user id=8")
assert_eq(metrics["orders.skipped.unknown_user"], 2)
}
@test
fn test_missing_file_short_circuits() {
// Handlers for ops that should NOT fire `panic` — proves the early
// return actually skips the rest of the workflow.
let errors = []
let metrics = #{}
let n = handle process_orders("ghost.csv") with {
Fs::exists(_) => false, // file is missing
Fs::read(_) => { panic("Fs::read should not be called"); "" },
Fs::write(_, _) => panic("Fs::write should not be called"),
Db::query(_) => { panic("Db::query should not be called"); [] },
Db::insert(_, _) => 0,
Log::info(_) => nil,
Log::error(m) => errors.push(m),
Metric::count(k) => { metrics[k] = (metrics[k] ?? 0) + 1 },
}
assert_eq(n, 0)
assert_eq(errors.len(), 1)
assert_eq(errors[0], "orders file missing: ghost.csv")
assert_eq(metrics["orders.skipped.no_file"], 1)
}
@test
fn test_logs_capture_full_trace() {
// The recorder pattern: a single `info` handler builds the trace
// we want to assert on. No spying, no proxy objects.
let logs = []
handle process_orders("o.csv") with {
Fs::exists(_) => true,
Fs::read(_) => "1",
Fs::write(_, _) => nil,
Db::query(_) => ["Alice"],
Db::insert(_, _) => 7,
Log::info(m) => logs.push(m),
Log::error(m) => logs.push("ERR " + m),
Metric::count(_) => nil,
}
assert_eq(logs.len(), 2)
assert_eq(logs[0], "charged Alice (txn 7)")
assert_eq(logs[1], "done: 1 orders")
}
@test
fn test_mixed_results_partial_processing() {
// 4 IDs, 2 valid, 2 missing — counts must reflect both buckets.
let metrics = #{}
let n = handle process_orders("orders.csv") with {
Fs::exists(_) => true,
Fs::read(_) => "1,99,2,77",
Fs::write(_, _) => nil,
Db::query(sql) => {
if sql.contains("id = 1") { return ["Alice"] }
if sql.contains("id = 2") { return ["Bob"] }
return []
},
Db::insert(_, _) => 1,
Log::info(_) => nil,
Log::error(_) => nil,
Metric::count(k) => { metrics[k] = (metrics[k] ?? 0) + 1 },
}
assert_eq(n, 2)
assert_eq(metrics["orders.processed"], 2)
assert_eq(metrics["orders.skipped.unknown_user"], 2)
}
Veja também