Overview
C and Rust establish the mental models that many Zig developers bring along: manual malloc/free, RAII destructors, Option<T>, Result<T, E>, and trait objects. This appendix translates those habits into idiomatic Zig so you can port real codebases without fighting the language.
Zig’s tightened pointer alignment rules (@alignCast) and improved allocator diagnostics show up repeatedly when wrapping foreign APIs. v0.15.2
Learning Goals
- Swap manual resource cleanup for
defer/errdeferwhile preserving the control you expect from C. - Express Rust-inspired
Option/Resultlogic with Zig optionals and error unions in a composable way. - Adapt callback- or trait-based polymorphism to Zig’s
comptimegenerics and pointer shims.
Translating C Resource Lifetimes
C programmers habitually pair every malloc with a matching free. Zig lets you encode the same intent with errdefer and structured error sets so buffers never leak even when validation fails. 4 The following example contrasts a direct translation with a Zig-first helper that frees memory automatically, highlighting how allocator errors compose with domain errors. mem.zig
//! Reinvents a C-style buffer duplication with Zig's defer-based cleanup.
const std = @import("std");
pub const NormalizeError = error{InvalidCharacter} || std.mem.Allocator.Error;
pub fn duplicateAlphaUpper(allocator: std.mem.Allocator, input: []const u8) NormalizeError![]u8 {
const buffer = try allocator.alloc(u8, input.len);
errdefer allocator.free(buffer);
for (buffer, input) |*dst, src| switch (src) {
'a'...'z', 'A'...'Z' => dst.* = std.ascii.toUpper(src),
else => return NormalizeError.InvalidCharacter,
};
return buffer;
}
pub fn cStyleDuplicateAlphaUpper(allocator: std.mem.Allocator, input: []const u8) NormalizeError![]u8 {
const buffer = try allocator.alloc(u8, input.len);
var ok = false;
defer if (!ok) allocator.free(buffer);
for (buffer, input) |*dst, src| switch (src) {
'a'...'z', 'A'...'Z' => dst.* = std.ascii.toUpper(src),
else => return NormalizeError.InvalidCharacter,
};
ok = true;
return buffer;
}
test "duplicateAlphaUpper releases buffer on failure" {
const allocator = std.testing.allocator;
try std.testing.expectError(NormalizeError.InvalidCharacter, duplicateAlphaUpper(allocator, "zig-0"));
}
test "c style duplicate succeeds with valid input" {
const allocator = std.testing.allocator;
const dup = try cStyleDuplicateAlphaUpper(allocator, "zig");
defer allocator.free(dup);
try std.testing.expectEqualStrings("ZIG", dup);
}
$ zig test 01_c_style_cleanup.zigAll 2 tests passed.The explicit NormalizeError union tracks both allocator failures and validation failures, a pattern encouraged throughout Chapter 10’s allocator tour.
Mirroring Rust’s Option and Result Types
Rust’s Option<T> maps cleanly to Zig’s ?T, while Result<T, E> becomes an error union (E!T) with rich tags instead of stringly typed messages. 4 This recipe pulls a configuration value from newline-separated text, first with an optional search and then with a domain-specific error union that converts parsing failures into caller-friendly diagnostics. fmt.zig
//! Mirrors Rust's Option and Result idioms with Zig optionals and error unions.
const std = @import("std");
pub fn findPortLine(env: []const u8) ?[]const u8 {
var iter = std.mem.splitScalar(u8, env, '\n');
while (iter.next()) |line| {
if (std.mem.startsWith(u8, line, "PORT=")) {
return line["PORT=".len..];
}
}
return null;
}
pub const ParsePortError = error{
Missing,
Invalid,
};
pub fn parsePort(env: []const u8) ParsePortError!u16 {
const raw = findPortLine(env) orelse return ParsePortError.Missing;
return std.fmt.parseInt(u16, raw, 10) catch ParsePortError.Invalid;
}
test "findPortLine returns optional when key absent" {
try std.testing.expectEqual(@as(?[]const u8, null), findPortLine("HOST=zig-lang"));
}
test "parsePort converts parse errors into domain error set" {
try std.testing.expectEqual(@as(u16, 8080), try parsePort("PORT=8080\n"));
try std.testing.expectError(ParsePortError.Missing, parsePort("HOST=zig"));
try std.testing.expectError(ParsePortError.Invalid, parsePort("PORT=xyz"));
}
$ zig test 02_rust_option_result.zigAll 2 tests passed.Because Zig separates optional discovery from error propagation, you can reuse findPortLine for fast-path checks while parsePort handles the slower, fallible work—mirroring the Rust pattern of splitting Option::map from Result::map_err. 17
Bridging Traits and Function Pointers
Both C and Rust lean on callbacks—either raw function pointers with context payloads or trait objects with explicit self parameters. Zig models the same abstraction with *anyopaque shims plus comptime adapters, so you keep type safety and zero-cost indirection. 33 The example below shows a C-style callback and a trait-like handle method reused via the same legacy bridge, relying on Zig’s pointer casts and alignment assertions. builtin.zig
//! Converts a C function-pointer callback pattern into type-safe Zig shims.
const std = @import("std");
pub const LegacyCallback = *const fn (ctx: *anyopaque) void;
fn callLegacy(callback: LegacyCallback, ctx: *anyopaque) void {
callback(ctx);
}
const Counter = struct {
value: u32,
};
fn incrementShim(ctx: *anyopaque) void {
const counter: *Counter = @ptrCast(@alignCast(ctx));
counter.value += 1;
}
pub fn incrementViaLegacy(counter: *Counter) void {
callLegacy(incrementShim, counter);
}
pub fn dispatchWithContext(comptime Handler: type, ctx: *Handler) void {
const shim = struct {
fn invoke(raw: *anyopaque) void {
const typed: *Handler = @ptrCast(@alignCast(raw));
Handler.handle(typed);
}
};
callLegacy(shim.invoke, ctx);
}
const Stats = struct {
total: u32 = 0,
fn handle(self: *Stats) void {
self.total += 2;
}
};
test "incrementViaLegacy integrates with C-style callback" {
var counter = Counter{ .value = 0 };
incrementViaLegacy(&counter);
try std.testing.expectEqual(@as(u32, 1), counter.value);
}
test "dispatchWithContext adapts trait-like handle method" {
var stats = Stats{};
dispatchWithContext(Stats, &stats);
try std.testing.expectEqual(@as(u32, 2), stats.total);
}
$ zig test 03_callback_bridge.zigAll 2 tests passed.The additional @alignCast calls reflect a 0.15.2 footgun—pointer casts now assert alignment, so leave them in place when wrapping *anyopaque handles from C libraries. v0.15.2
Patterns to Keep on Hand
- Keep allocator cleanup localized with
errdeferwhile exposing typed results, so C ports stay leak-free without sprawlinggotoblocks. 4 - Convert foreign enums into Zig error unions early, then re-export a focused error set at your module boundary. 57
- Implement trait-style behavior with
comptimestructs that expose a small interface (handle,format, etc.), letting the optimizer inline the call sites. 15
Notes & Caveats
- Manual allocation helpers should surface
std.mem.Allocator.Errorexplicitly so callers can continue propagating failures transparently. - When porting Rust crates that rely on drop semantics, audit every branch for
returnorbreakexpressions—Zig will not automatically invoke destructors. 36 - Function-pointer shims must respect calling conventions; if the C API expects
extern fn, annotate your shim accordingly before shipping. 33
Exercises
- Extend the normalization helper to tolerate underscores by translating them to hyphens, and add tests covering both success and failure cases. 10
- Modify
parsePortto return a struct containing both host and port, then document how the combined error union expands. 57 - Generalize
dispatchWithContextso it accepts a compile-time list of handlers, mirroring Rust’s trait object vtables. 15
Alternatives & Edge Cases
- Some C libraries expect you to allocate with their custom functions—wrap those allocators in a shim that implements the
std.mem.Allocatorinterface, so the rest of your Zig code stays uniform. 10 - When porting Rust
Option<T>that owns heap data, consider returning a slice plus length sentinel instead of duplicating ownership semantics. 3 - If your callback bridge crosses threads, add synchronization primitives from Chapter 29 before mutating shared state. 29