# Closures in Zig with Function Pointers and Context ## Synopsis - Zig has no lambdas or closures: a function can't capture runtime locals, and there is no anonymous function syntax. - A closure can be built by hand from two pointers: a type-erased pointer to the captured data (`*const anyopaque`) and a function pointer that knows how to use it. - This is what C# generates behind every lambda, and how `std.mem.Allocator` implements interfaces (`ptr` + `vtable`). - The cost: the cast back from `anyopaque` is unchecked, captured data must be allocated to outlive its creator, and the structure is opaque, so nested closures can only be run by calling them. ## The delegate type A delegate is a function pointer plus the data it captured: ```zig const std = @import("std"); const Allocator = std.mem.Allocator; fn Fn(comptime Arg: type, comptime Ret: type) type { return struct { context: *const anyopaque, call_fn: *const fn (context: *const anyopaque, arg: Arg) Ret, pub fn call(self: @This(), arg: Arg) Ret { return self.call_fn(self.context, arg); } }; } const IntFn = Fn(i32, i32); ``` `Fn(i32, i32)` is the same type whatever was captured, so different closures can be passed around interchangeably. That is why `context` is `*const anyopaque`: the caller doesn't need to know what's behind it. ## Example: a curried adder The curried adder from *Fabulous Adventures in Data Structures and Algorithms*, section 2.7.2: `curried(x)` returns a function that adds `x` to its argument. ```zig fn adder(allocator: Allocator, x: i32) Allocator.Error!IntFn { const Captured = struct { x: i32, fn call(context: *const anyopaque, y: i32) i32 { const self: *const @This() = @ptrCast(@alignCast(context)); return self.x + y; } }; const captured = try allocator.create(Captured); captured.* = .{ .x = x }; return .{ .context = captured, .call_fn = Captured.call }; } ``` ```zig const add4 = try adder(a, 4); add4.call(3); // 7 add4.call(0); // 4: calling with the identity element retrieves the captured value ``` ## Example: composition `x => f(g(x))` captures two closures. It has the same shape as the Hughes list's `hl1.c(hl2.c(stack))` in section 2.7.3. ```zig fn compose(allocator: Allocator, f: IntFn, g: IntFn) Allocator.Error!IntFn { const Captured = struct { f: IntFn, g: IntFn, fn call(context: *const anyopaque, x: i32) i32 { const self: *const @This() = @ptrCast(@alignCast(context)); return self.f.call(self.g.call(x)); } }; const captured = try allocator.create(Captured); captured.* = .{ .f = f, .g = g }; return .{ .context = captured, .call_fn = Captured.call }; } ``` ```zig const add10 = try compose(a, add4, try adder(a, 6)); add10.call(0); // 10 ``` ## How it maps to a C# lambda Section 2.7.5 of the book shows the closure class the C# compiler generates. Each piece has a hand-written Zig counterpart: | C# (compiler-generated) | Zig (by hand) | | --- | --- | | `class Closure { public int x; ... }` | `const Captured = struct { x: i32, ... }`, declared inside the function | | `new Closure { x = x }` | `allocator.create(Captured)`, then `captured.* = .{ .x = x }` | | the method `M` the lambda body becomes | `Captured.call` | | the delegate (object reference plus method) | `Fn`: `context` plus `call_fn` | ## Things to watch - **The cast is unchecked.** `@ptrCast(@alignCast(context))` recovers `*const Captured`; `@alignCast` restores the alignment information that `anyopaque` dropped. Pairing a context with the wrong function compiles and misbehaves at run time. C# guarantees the pairing; here it is the programmer's job. - **A struct inside a function is not a closure.** It can see `comptime` values and types from the surrounding code, but not runtime locals. That's why `x` must be copied into `Captured` explicitly. - **The context must outlive the call that creates it.** A pointer to a local `Captured` would dangle once `adder` returns, so the context is allocated, and the caller owns its lifetime (an arena works well). - **Equality is possible.** Function pointers compare with `==`, so an "is this the identity function?" check can mirror C#'s `ReferenceEquals` on delegates. - **Nesting means recursion.** Composing closures builds a chain that can only be run by calling it: ```zig var total = try adder(a, 0); for (1..101) |i| total = try compose(a, total, try adder(a, @intCast(i))); total.call(0); // 5050, 100 calls deep ``` With a million compositions the call stack overflows. Nothing outside can walk the chain without calling it, because the structure is hidden behind `anyopaque`. This is the limitation the book describes for the Hughes list in section 2.7.6. ## The alternative: closures as data Instead of hiding captured values behind a function pointer, give each kind of closure its own variant in a tagged union and replace "call the delegate" with a `switch`. The structure becomes ordinary data that code can inspect and walk however it likes, including with an explicit work stack instead of recursion. This is the more common choice in Zig when the set of closure shapes is fixed. ## References - *Fabulous Adventures in Data Structures and Algorithms*, Eric Lippert: 2.7.2 (currying), 2.7.5 (closure classes), 2.7.6 (stack depth). - Zig language reference: `#Functions` (function pointers), `#opaque` and `anyopaque`, `#ptrCast`, `#alignCast`. - `lib/std/mem/Allocator.zig` in the Zig distribution: the same pattern with a vtable of several functions. - Verified with Zig 0.17.0.