Zig
Design decisions influenced by Zig
Explicit Allocators
Decision: Every allocation takes an explicit Allocator parameter. No hidden heap usage anywhere in the language or standard library.
Reason: Hidden allocation is one of the primary sources of unexpected latency, OOM errors in unexpected places, and difficulty reasoning about memory usage. When allocation is explicit, the programmer always knows where memory comes from, which allocator strategy is being used, and can substitute a different allocator (arena, pool, stack) for testing or performance.
-- WRONG in Violet: no hidden allocation
fn buildList(): List[i32] { ... } -- where does the memory come from?
-- RIGHT: allocation is visible
fn buildList(alloc: Allocator): !List[i32] {
var list = List[i32]::new();
errdefer list.deinit(alloc);
try list.push(alloc, 1);
try list.push(alloc, 2);
return list;
}
-- call site knows exactly what allocator is used
var list = try buildList(arena.allocator());
defer — Explicit Cleanup
Decision: defer runs a statement when the current scope exits, regardless of how it exits (normal return, early return, error). errdefer runs only on error exit.
Reason: Resource cleanup in systems code is notoriously error-prone. defer makes cleanup co-located with acquisition, eliminates cleanup duplication across return paths, and is explicit — unlike destructors, you can always see what runs and when.
fn processFile(alloc: Allocator, path: []const u8): ![]u8 {
var file = try fs.open(path);
defer file.close(); -- always runs
var buf = try alloc.alloc(u8, 4096);
errdefer alloc.free(buf); -- runs only on error
var n = try file.read(buf);
return buf[0..n]; -- success: errdefer skipped, defer runs
}
Why not destructors (Drop)?
Destructors cannot take parameters. Freeing a List[T] requires the allocator it was created with. A deinit(alloc) method solves this without coupling the allocator to the type’s representation.
#undefined — Explicit Uninitialisation
Decision: Uninitialized memory must be declared with #undefined. The compiler poisons it with 0xAA in debug builds. Reading it before assignment is a compile error when statically detectable, a panic otherwise.
Reason: Zig’s undefined teaches the discipline of thinking about initialization explicitly. Silent uninitialized reads are a major source of security vulnerabilities in C. Making the intent explicit (“I will initialize this before reading”) separates legitimate partial initialization from accidental forgetting.
-- partial initialization: valid with compiler tracking
fn buildPacket(alloc: Allocator): !Packet {
var pkt: Packet = #undefined; -- compiler tracks initialization
pkt.magic := 0xDEAD;
pkt.version := 1;
pkt.length := 0;
-- pkt.payload left unset: compile error if read before set
pkt.payload = try alloc.alloc(u8, 64);
return pkt;
}
-- outside unsafe: compiler errors on provably-unread fields
-- inside unsafe: programmer takes responsibility
unsafe {
var buf: [1024]u8 = #undefined;
c.readInto(buf.ptr, buf.len); -- C fills it, we trust the call
}
Comptime — The Language Is The Metaprogramming Tool
Decision: Compile-time code execution uses the same language as runtime code. comptime parameters, inline if, and inline for replace macros entirely.
Reason: Rust’s macro system is a separate sublanguage with different syntax, different error messages, and a different mental model. Zig proves that arbitrary code execution at compile time, over type information, is sufficient for all practical metaprogramming without a second language.
-- inspect type structure at compile time, no macros
fn parseConfig(comptime T: type, input: []const u8): !T {
const info = #typeInfo(T);
var result: T = .{};
inline for info.fields |field| {
const raw = findValue(input, field.name) orelse continue;
inline if field.type.* = u16 {
#fieldSet(result, field.name, try parseU16(raw));
} else inline if field.type.* = []const u8 {
#fieldSet(result, field.name, raw);
} else inline if field.type.* = bool {
#fieldSet(result, field.name, raw = "true");
} else {
#compileError("unsupported field type: {}", .{#typeName(field.type.*)});
}
}
return result;
}
-- zero macro machinery needed
type ServerConfig = struct {
host: []const u8 = "localhost",
port: u16 = 8080,
debug: bool = false,
};
var cfg = try parseConfig(ServerConfig, config_text);
#cImport — C Headers At Comptime
Decision: C headers are parsed at compile time using #cImport. No manual binding files, no separate tool.
Reason: Writing C bindings by hand is error-prone and maintenance-heavy. Parsing the actual header guarantees correctness. The Zig ecosystem proved this approach works for virtually all real-world C libraries.
const c = #cImport({
#cInclude("sqlite3.h");
#cDefine("SQLITE_THREADSAFE", "1");
});
fn openDatabase(path: []const u8): !*c.sqlite3 {
var db: *c.sqlite3 = #undefined;
unsafe {
var rc = c.sqlite3_open(path.ptr, &db);
if rc != c.SQLITE_OK {
return error.SqliteOpen { .code = rc };
}
}
return db;
}
[*c]T — C Pointer Type
Decision: C pointers are a distinct type [*c]T, separate from Violet’s *T and *const T.
Reason: C pointers have unknown provenance, unknown ownership, and no lifetime information. Distinguishing them visually and syntactically makes FFI boundaries explicit. Every [*c]T in code is a visual signal that you’re at the edge of the Violet type system.
extern fn malloc(size: usize): [*c]u8;
extern fn free(ptr: [*c]u8): void;
fn example(): !void {
unsafe {
var raw: [*c]u8 = malloc(64);
if raw = nil { return error.OutOfMemory; }
defer free(raw);
-- convert at the boundary to a typed Violet pointer
var typed: *[64]u8 = raw as *[64]u8;
-- from here, typed has Violet semantics
}
}
Process Context
Decision: main optionally receives a process.Context containing the allocator, scheduler, args, env, and standard streams.
Reason: Zig’s std.process.init pattern provides useful resources at startup without global state. The allocator choice is a startup decision, not a compile-time constant. Making it explicit enables easy allocator substitution for testing.
fn main(ctx: process.Context): !void {
-- allocator, scheduler, args, env: all provided
using ctx.alloc;
for arg in ctx.args {
debug.print("{}\n", .{arg});
}
var home = ctx.env.get("HOME") orelse "/tmp";
-- ctx.io is the scheduler, ctx.alloc is the allocator
}
-- simplified: no context needed for simple programs
fn main(): void {
debug.print("hello\n");
}