Pular para o conteúdo

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.

12-reader-pattern.zolo
Playground
// 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.

13-state-pattern.zolo
Playground
// 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.

14-writer-pattern.zolo
Playground
// 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.

06-database-and-files.zolo
Playground
// 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)
}

Buscar no Zolo

9 resultados

enespt-br