Core Concepts
Zig Core Concepts
The Philosophy
Zig is “C with better ergonomics” - providing modern conveniences while maintaining zero-cost abstractions and a predictable mental model. No hidden allocations, no vtables, no magic.
Structs Are Just Data
In Zig, structs contain only data fields. Methods don’t exist in the struct at runtime.
const Grid = struct {
cells: []bool,
width: usize,
fn get(self: *const Grid, x: usize) bool {
return self.cells[x];
}
};
// In memory, Grid is just:
// [cells_ptr][cells_len][width]
// NO function pointers, NO vtable
Method Calls Are Syntactic Sugar
grid.get(5); // Sugar
Grid.get(&grid, 5); // What actually happens
The self parameter is NOT a keyword - you can name it anything:
fn get(banana: *const Grid, x: usize) bool {
return banana.cells[x];
}
// Still works: grid.get(5)
Contrast with OOP Languages
Python/C++ objects:
[vtable_ptr][field1][field2] // Runtime overhead
Zig structs:
[field1][field2] // Just the data
Memory Layout Is Predictable
const Point = struct {
x: f32, // 4 bytes
y: f32, // 4 bytes
};
// sizeof(Point) == 8 bytes, nothing more
Pointers and Mutability
Zig makes ownership and mutability explicit:
self: Grid // Pass by value (copy)
self: *Grid // Mutable pointer
self: *const Grid // Immutable pointer
The type tells you exactly what you can do with it.
Error Handling
Errors are values, not exceptions. They’re part of the type system.
fn doThing() !void { // Can return an error OR void
// ...
}
// Must explicitly handle:
try doThing(); // Propagate error up
doThing() catch |err| ...; // Handle it
if (doThing()) { ... } else |err| { ... } // Conditional
Error unions: !T means “error or T”
const result: !usize = calcSize(data);
const size = try result; // Unwrap or propagate
Memory Management
No Hidden Allocations
You must explicitly allocate and free:
const data = try allocator.alloc(u8, 100);
defer allocator.free(data); // Cleanup guaranteed
The defer Keyword
Schedules code to run when scope exits (RAII without destructors):
{
const a = try alloc(10);
defer free(a); // Runs second
const b = try alloc(20);
defer free(b); // Runs first (LIFO)
} // <-- defers execute here in reverse order
Works even with early returns or errors!
Allocators Are Explicit
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit(); // Catches memory leaks in debug
const allocator = gpa.allocator();
No global new/malloc - always pass an allocator.
Compile-Time vs Runtime
Zig blurs the boundary. Same code can run at either time.
Builtin Functions (@)
Functions starting with @ are compiler builtins:
@min(a, b) // Works compile-time OR runtime
@intCast(x) // Explicit type conversion
@TypeOf(x) // Get type (compile-time only)
@sizeOf(T) // Size of type (compile-time)
If values are known at compile-time, the compiler just does the math:
const SIZE = @min(1024, MAX); // Computed at compile-time
var buffer: [SIZE]u8 = undefined; // Array size must be compile-time!
Why Builtins vs Stdlib?
- Work seamlessly at compile-time and runtime
- Type-generic without overhead
- Compiler can optimize perfectly
- Can access compiler internals (like type information)
Slices
Slices are fat pointers: [pointer, length]
const str = "hello"; // *const [5:0]u8 (pointer to array)
const slice = str[1..]; // []const u8 (slice: pointer + length)
// Slice syntax:
str[1..] // From index 1 to end
str[0..3] // From 0 to 3 (exclusive)
str[..] // Whole thing
Strings are just []const u8 - no special string type!
For Loops and Captures
// Iterate over elements
for (array) |item| {
// item is each element
}
// With index
for (array, 0..) |item, i| {
// item and index
}
// Range
for (0..10) |i| {
// i from 0 to 9
}
The |variable| syntax captures values - used throughout Zig:
if (optional_value) |value| { ... }
while (iterator.next()) |item| { ... }
result catch |err| { ... }
Format Strings
std.debug.print("{}", .{value}); // Default format
std.debug.print("{d}", .{value}); // Decimal
std.debug.print("{x}", .{value}); // Hex lowercase
std.debug.print("{X}", .{value}); // Hex uppercase
std.debug.print("{b}", .{value}); // Binary
std.debug.print("{s}", .{value}); // String
std.debug.print("{x:0>2}", .{value}); // Hex, zero-padded to 2 chars
Format specification: {type:fill>width}
The New ArrayList API (Zig 0.15.2)
ArrayList is now “unmanaged” - you pass the allocator to methods instead of storing it:
// Old (pre-0.15):
var list = std.ArrayList(u8).init(allocator);
// New (0.15.2+):
var list: std.ArrayList(u8) = .{};
defer list.deinit(allocator); // Pass allocator to deinit!
try list.append(allocator, item); // Pass to each method
The struct is just:
struct {
items: []T = &[_]T{},
capacity: usize = 0,
}
Type Inference with .{}
When the type is known from context, you can use .{} for initialization:
var list: std.ArrayList(u8) = .{}; // Type known, use .{}
var reader: std.Io.Reader = .fixed(buffer);
This is the “struct literal” syntax - the compiler fills in the type.
Key Takeaways
- Explicit over implicit - No hidden costs, no magic
- Zero-cost abstractions - Nice syntax compiles to optimal code
- Compile-time power - Same code works at compile-time and runtime
- Memory safety - No undefined behavior, explicit ownership
- Simplicity - Structs are data, methods are namespaced functions
The learning curve is about unlearning OOP assumptions, not learning new complexity.