Chapter 58Mapping C Rust Idioms

Appendix D. Mapping C/Rust Idioms → Zig Constructs

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/errdefer while preserving the control you expect from C.
  • Express Rust-inspired Option/Result logic with Zig optionals and error unions in a composable way.
  • Adapt callback- or trait-based polymorphism to Zig’s comptime generics 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

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);
}
Run
Shell
$ zig test 01_c_style_cleanup.zig
Output
Shell
All 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

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"));
}
Run
Shell
$ zig test 02_rust_option_result.zig
Output
Shell
All 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

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);
}
Run
Shell
$ zig test 03_callback_bridge.zig
Output
Shell
All 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 errdefer while exposing typed results, so C ports stay leak-free without sprawling goto blocks. 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 comptime structs 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.Error explicitly so callers can continue propagating failures transparently.
  • When porting Rust crates that rely on drop semantics, audit every branch for return or break expressions—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 parsePort to return a struct containing both host and port, then document how the combined error union expands. 57
  • Generalize dispatchWithContext so 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.Allocator interface, 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

Help make this chapter better.

Found a typo, rough edge, or missing explanation? Open an issue or propose a small improvement on GitHub.