Hi everyone,
This repository — zig-pflag ( GitHub - rebornwwp/zig-pflag · GitHub ) — was built with the help of AI, porting Go’s spf13/pflag to Zig.
My motivation is straightforward: I want to see more ready-to-use third-party libraries in the Zig ecosystem. pflag has been battle-tested in the Go community for over a decade — the POSIX/GNU parsing rules, edge cases, and user expectations it handles aren’t something you can cover well starting from scratch. Porting it over means it at least works out of the box, so people who need it don’t stumble on half-baked behavior. From there, we can iterate together and make it better over time.
So far, 16 out of 26 flag types from Go pflag v1.0.9 have been ported, with 110 tests, plus some examples covering different allocator combinations. That said, I’m aware that an API produced this way — porting from another language with AI assistance — likely isn’t idiomatic Zig yet. The code style, design patterns, and memory model almost certainly have room for improvement.
So I’d like to ask: what can be improved in this library? Which parts should be rewritten rather than translated directly from Go? I’m also genuinely curious — how does the community feel about this approach (AI-assisted + porting from another language)? Is this kind of thing welcome? Honest feedback very much appreciated — I won’t take it personally.
(The above was translated from Chinese by AI — but it reflects my genuine thoughts. Please forgive the indirectness.)
bellow is what i lean(AI teach me
)
Zig Memory Ownership Patterns — Lessons from zig-pflag
zig-pflag is a port of Go’s spf13/pflag to Zig 0.16.
This document captures the memory ownership patterns that emerged during development.
1. []const u8: One Type, Three Lifetimes
This is the fundamental problem of string handling in Zig:
const a: []const u8 = "hello"; // ① static read-only (.rodata) — cannot free
const b: []const u8 = &[_]u8{1, 2, 3}; // ② stack — cannot free (invalid after scope)
const c: []const u8 = gpa.dupe(u8, "..."); // ③ heap — must free
a, b, and c have identical type signatures. The compiler cannot help you
distinguish them. This is not a compiler limitation — it’s a deliberate design
choice in Zig: express all strings as one type, rather than introducing a
multi-type system like Rust’s &str / String / Cow<str>.
The consequence: when you write a library that accepts strings, the
[]const u8 you receive might be a static literal, a stack temporary, or a
heap allocation. The library must track ownership at runtime to know which
ones it can safely free. Rust solves this in the type system (at compile time).
Zig leaves it to you (at runtime).
This is not a flaw. It is a tradeoff. But you must accept it before the rest of
the design makes sense.
A concrete example from zig-pflag
// User registers a slice flag with a default value
var tags: std.ArrayListUnmanaged([]const u8) = .empty;
tags.append(gpa, "default") catch {}; // "default" might be a literal!
var state = pflag.StringSliceState{ .value = &tags, .gpa = gpa };
try fs.stringSliceVarP(&state, "tag", "t", &.{}, "tags (repeatable)");
The library now holds *ArrayListUnmanaged([]const u8). The string
"default" sits inside it. Is that string on the heap? A literal? The library
has no way to know just by looking at the type.
2. changed / allocated: The Necessary Cost Forced to the Surface
“Why not just gpa.dupe all default values at registration and be done with it?”
Two practical obstacles block that path:
Obstacle A: Container sharing
The library only holds a pointer to the user’s container — the user still
owns the container variable. If the library created its own deep copy, the user
could no longer read parsed results through their variable.
var tags: std.ArrayListUnmanaged([]const u8) = .empty;
// Both the user and the library reference the same `tags` container.
// If the library makes its own copy → user reads `tags.items` → always
// sees defaults, never the parsed results.
Obstacle B: Literals cannot be freed
Even if container sharing were solved, the user’s default values might live in
.rodata. Calling gpa.free("default") is a segfault.
var map: std.StringHashMapUnmanaged(i32) = .empty;
map.put(gpa, "Content-Type", 0) catch {};
// Where does "Content-Type" live?
// If it's a literal → gpa.free() = UB / segfault
// If it's gpa.dupe'd → not freeing it = memory leak
The solution: runtime ownership flags
These two booleans are not design clumsiness. They are **ownership markers
forced from the type system into runtime** by Zig’s design choices:
pub const StringSliceState = struct {
value: *std.ArrayListUnmanaged([]const u8),
gpa: std.mem.Allocator,
changed: bool = false, // "Has set() been called at least once?"
};
pub const StringState = struct {
value: *[]const u8,
gpa: std.mem.Allocator,
allocated: bool = false, // "Was the current value dupe'd by the library?"
};
deinit logic for slice/map types — two-tier release:
fn deinit(state: *StringSliceState, gpa: std.mem.Allocator) void {
if (state.changed) {
for (state.value.items) |item| gpa.free(item); // only library-dupe'd items
}
state.value.deinit(gpa); // container backing array — always freed
}
| What is freed | Responsibility | Condition |
|--------------|---------------|-----------|
| Container (backing array / hash table) | Library, always | None |
| Data items (key / value strings) | Library | changed == true |
Why containers are always freed
ArrayListUnmanaged.append() and StringHashMapUnmanaged.put() always
heap-allocate the container’s backing storage. The container itself is always
on the heap regardless of where its data items point. So state.value.deinit(gpa)
is unconditional.
Why data items are conditionally freed
The user’s default values may be literals or stack data. Freeing them is UB.
The library only frees items when changed == true — meaning at least one
set() call has run, and every item in the container was dupe’d by the library.
The ordering of changed matters
Setting changed = true before the first successful put/append is a bug:
// ❌ Wrong — changed set too early
map.clearRetainingCapacity();
state.changed = true;
try map.put(gpa, key, val); // OOM here? changed is already true → deinit double-frees
// ✅ Correct — changed set after first successful put
try map.put(gpa, key, val);
if (!state.changed) state.changed = true;
If OOM strikes after clearing defaults but before the first successful insert,
changed being already true causes deinit to iterate and attempt to free
items that may be literals — double-free or segfault.
This pattern generalizes
This is not zig-pflag-specific. Any Zig library that:
-
Accepts user-provided string data whose origin (literal/stack/heap) is unknown,
-
Writes new string data into the same container, and
-
Needs to clean up at deinit
…will eventually converge on a similar mechanism. The Unmanaged suffix on
std.ArrayListUnmanaged and std.StringHashMapUnmanaged already signals this —
“the allocator is not baked in; you manage ownership.” Push that statement one
step further, and you end up inventing changed.
Summary: three Zig design choices, one predictable outcome
| Zig design choice | Consequence | Pattern that emerges |
|—|—|—|
| []const u8 unifies all strings | Ownership invisible at type level | Runtime boolean flags |
| Explicit allocators, no RAII | No automatic destructor to distinguish ownership | Manual deinit + conditional free |
| Unmanaged containers strip allocators | Caller tracks “who allocated what” | Explicit gpa parameter flow |
These three choices combine to produce a single inevitable result: **every Zig
library that handles dynamic strings must invent its own ownership tracking
mechanism**. changed and allocated are not a sign that the design is
unpolished — they are the necessary cost pressed to the surface by Zig’s
deliberate simplicity. Internalize this, and designing string-heavy libraries
in Zig stops feeling awkward — it starts making sense.