Language Basics
The basics of the Zig language are quite straightforward. Given here are examples of each basic concept, which should be picked up and experimented upon.
For code examples that don't define a main
function, please define your own main
function and paste the code inside in order to run it.
Hello, world!
We have to import the standard library here using @import("std")
, which we then store in the std
variable. We can access functions (and types) from the standard library using the .
syntax.
const std = @import("std");
pub fn main() !void {
std.debug.print("Hello, world!\n", .{});
}
Primitives
// ints
const my_32_bit_int: i32 = -42;
const my_64_bit_int: i64 = -323;
const my_32_bit_unsigned_int: u32 = 3424;
const my_64_bit_unsigned_int: u64 = 34;
// more ints ...?
const my_17_bit_int: i17 = 17;
const my_38_bit_unsigned_int: i38 = 38;
// floats
const my_32_bit_float: f32 = 3.14;
const my_64_bit_float: f64 = 3.14159;
// bool
const my_bool: bool = true;
// string
const my_string: []const u8 = "Hello, world!";
std.debug.print("32-bit int: {}\n", .{my_32_bit_int});
std.debug.print("64-bit int: {}\n", .{my_64_bit_int});
std.debug.print("32-bit unsigned int: {}\n", .{my_32_bit_unsigned_int});
std.debug.print("64-bit unsigned int: {}\n", .{my_64_bit_unsigned_int});
std.debug.print("17-bit int: {}\n", .{my_17_bit_int});
std.debug.print("38-bit unsigned int: {}\n", .{my_38_bit_unsigned_int});
std.debug.print("32-bit float: {}\n", .{my_32_bit_float});
std.debug.print("64-bit float: {}\n", .{my_64_bit_float});
std.debug.print("bool: {}\n", .{my_bool});
std.debug.print("string: {s}\n", .{my_string});
Arrays, Pointers & Slices
Arrays
Arrays in Zig have a fixed size (defined in the type of the array). There aren't many differences between Zig arrays and those found in C, C++, Java or Go besides syntax.
// Arrays have a fixed size.
var my_int_array = [5]i32{ 1, 2, 3, 4, 5 };
// You can use the `_` character to have the compiler infer the size.
const my_other_int_array = [_]i32{ 1, 2, 3, 4, 5 };
std.debug.print("my_int_array: {any}\n", .{my_int_array});
std.debug.print("my_other_int_array: {any}\n", .{my_other_int_array});
// `len` is the only field of an array.
std.debug.print("length of my_int_array: {any}\n", .{my_int_array.len});
// Access and modify items within an array using the `[]` syntax.
std.debug.print("my_int_array[1]: {}\n", .{my_int_array[1]});
// Arrays are copied by default.
const my_copied_int_array = my_int_array;
my_int_array[2] = 33;
std.debug.print("my_copied_int_array: {any}\n", .{my_copied_int_array});
// Arrays can have sentinel values.
const my_int_sentinel_array = [5:0]i32{ 1, 2, 3, 4, 5 };
std.debug.print("my_int_sentinel_array: {any}\n", .{my_int_sentinel_array});
std.debug.print("my_int_sentinel_array.len: {any}\n", .{my_int_sentinel_array.len});
std.debug.print("my_int_sentinel_array (with sentinel): {any}\n", .{@as([6]i32, @bitCast(my_int_sentinel_array))});
Pointers
Zig defines two kind of pointers: pointers to a single value, and pointers to multiple values. This is a departure from C and C++, where a pointer to an array with 1000000 integer looks the same as a pointer to a single integer (int*
).
// Pointers work similarly to C or C++.
var some_int: i32 = 42;
const some_int_pointer: *i32 = &some_int;
std.debug.print("some_int_pointer: {*}\n", .{some_int_pointer});
// Zig distinguishes between pointers to single values and pointers to multiple values (C-style arrays).
var some_int_array = [3]i32{ 1, 2, 3 };
const single_int_pointer: *i32 = &some_int_array[0];
const many_int_pointer: [*]i32 = &some_int_array;
std.debug.print("single_int_pointer: {*}\n", .{single_int_pointer});
std.debug.print("many_int_pointer: {*}\n", .{many_int_pointer});
std.debug.print("single_int_pointer == many_int_pointer: {}\n", .{@intFromPtr(single_int_pointer) == @intFromPtr(many_int_pointer)});
// Just like arrays, pointers to multiple values can have sentinel values.
var some_int_sentinel_array = [3:0]i32{ 1, 2, 3 };
const many_int_sentinel_pointer: [*:0]i32 = &some_int_sentinel_array;
std.debug.print("many_int_sentinel_pointer[3]: {}\n", .{many_int_sentinel_pointer[3]});
Notice that the [*:0]u8
type is perfect for representing strings in C, since they terminate in \0
and we keep track of a pointer to their first character.
Slices
var some_array = [_]i32{ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
// Slices are a view into an array.
const some_slice: []i32 = some_array[2..6];
std.debug.print("some_slice: {any}\n", .{some_slice});
// Slices are represented by a pointer to the first element and a length.
std.debug.print("some_slice.ptr: {*}\n", .{some_slice.ptr});
std.debug.print("some_slice.len: {}\n", .{some_slice.len});
std.debug.print("some_slice.ptr == &some_array[2]: {}\n", .{@intFromPtr(some_slice.ptr) == @intFromPtr(&some_array[2])});
// Slices should be treated as pointers to arrays. Modifying the slice modifies the original array.
some_slice[2] = 33;
std.debug.print("some_slice: {any}\n", .{some_slice});
std.debug.print("some_array: {any}\n", .{some_array});
// Slices can be sliced further.
const some_subslice = some_slice[1..3];
std.debug.print("some_subslice: {any}\n", .{some_subslice});
// Just like arrays, slices can have sentinel values.
var some_sentinel_array = [10:0]i32{ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
const some_sentinel_slice: [:0]i32 = some_sentinel_array[2..10];
std.debug.print("some_sentinel_slice: {any}\n", .{some_sentinel_slice});
std.debug.print("some_sentinel_slice[8]: {}\n", .{some_sentinel_slice[8]});
Control Flow
If/else
const x = 5;
if (x > 7) {
std.debug.print("x is greater than 7!\n", .{});
} else {
std.debug.print("x is smaller than or equal to 7...\n", .{});
}
// If-else can also be used as expressions rather than statements.
const y = if (x > 4) 10 else 20;
std.debug.print("y: {}\n", .{y});
Switch
const x = 34;
switch (x) {
1...5 => {
std.debug.print("x is between 1 and 5!\n", .{});
},
6...10 => {
std.debug.print("x is between 6 and 10!\n", .{});
},
else => {
std.debug.print("x is not between 1 and 10...\n", .{});
},
}
// Switch can also be used as an expression rather than a statement.
const y = switch (x) {
1...5 => 10,
6...10 => 20,
else => 30,
};
std.debug.print("y: {}\n", .{y});
While
var x: i32 = 0;
while (x < 32) {
std.debug.print("x: {}\n", .{x});
x += 1;
}
// You can also pass an expression to perform each iteration.
var y: i32 = 0;
while (y < 32) : (y += 1) {
std.debug.print("y: {}\n", .{y});
}
For
var some_array = [_]i32{ 1, 2, 3, 4, 5 };
for (some_array) |item| {
std.debug.print("array item: {}\n", .{item});
}
// Slices work too!
const some_slice = some_array[1..4];
for (some_slice) |item| {
std.debug.print("slice item: {}\n", .{item});
}
// You can also iterate over pointers to each element rather than the value.
for (some_slice) |*item| {
std.debug.print("slice item pointer: {*}\n", .{item});
}
std.debug.print("{any}\n", .{some_slice});
Structs
const std = @import("std");
const Point = struct {
x: i32,
y: i32,
};
const Rect = struct {
top_left: Point,
bottom_right: Point,
// You can define methods in structs.
fn area(self: Rect) i32 {
return (self.bottom_right.x - self.top_left.x) * (self.bottom_right.y - self.top_left.y);
}
};
pub fn main() !void {
var point1 = Point{ .x = 32, .y = 32 };
const point2 = Point{ .x = 99, .y = 44 };
const rect = Rect{ .top_left = point1, .bottom_right = point2 };
std.debug.print("point1: {any}\n", .{point1});
std.debug.print("point2: {any}\n", .{point2});
std.debug.print("rect: {any}\n", .{rect});
// You can access struct members using `.`. Works for nesting too.
std.debug.print("point1.x: {}\n", .{point1.x});
std.debug.print("rect.bottom_right.y: {}\n", .{rect.bottom_right.y});
// Methods are accessed in a similar way.
std.debug.print("rect.area(): {}\n", .{rect.area()});
// Structs are copied.
point1.x = 99;
std.debug.print("point1.x: {}\n", .{point1.x});
std.debug.print("rect.top_left.x: {}\n", .{rect.top_left.x});
}
Enums
const std = @import("std");
const Color = enum {
red,
green,
blue,
yellow,
brown,
// ...
};
// The integer representation of enums can be overrided.
const Operation = enum(u8) {
add = 0,
sub = 1,
mul = 2,
div = 3,
rem = 4,
shift_left = 5,
shift_right = 6,
// Enums can have methods too!
fn name(self: Operation) []const u8 {
switch (self) {
.add => return "add",
.sub => return "sub",
.mul => return "mul",
.div => return "div",
.rem => return "rem",
.shift_left => return "shift_left",
.shift_right => return "shift_right",
}
}
};
pub fn main() !void {
std.debug.print("red: {any}\n", .{Color.red});
std.debug.print("blue: {any}\n", .{Color.blue});
std.debug.print("mul: {any}\n", .{Operation.mul});
std.debug.print("mul (tag value): {}\n", .{@intFromEnum(Operation.mul)});
std.debug.print("shift_left: {any}\n", .{Operation.shift_left});
std.debug.print("shift_left (tag value): {}\n", .{@intFromEnum(Operation.shift_left)});
// You can also use enums in switch statements.
const some_color = Color.red;
switch (some_color) {
.red => std.debug.print("some_color is red\n", .{}),
.green => std.debug.print("some_color is green\n", .{}),
.blue => std.debug.print("some_color is blue\n", .{}),
.yellow => std.debug.print("some_color is yellow\n", .{}),
.brown => std.debug.print("some_color is brown\n", .{}), // try removing this and compiling
}
}
Unions
Unions allow you to store one of their members at a time, instead of all at once like in a struct. Zig unions can be treated similarly to C unions, except that they do throw a runtime error if you access the incorrect member (at the cost of a larger runtime size due to storing extra info).
const std = @import("std");
// We could use an enum to represent a shape, but we can't store any
// shape data within it.
const ShapeEnum = enum {
circle,
rectangle,
square,
};
// We can use a union instead. A union is like a struct, but it can only
// store one of its members at a time, rather than all at once.
const ShapeUnion = union {
circle: struct { radius: f32 },
rectangle: struct { width: f32, height: f32 },
square: struct { size: f32 },
};
const TaggedShapeUnion = union(ShapeEnum) {
circle: struct { radius: f32 },
rectangle: struct { width: f32, height: f32 },
square: struct { size: f32 },
};
// We can also use an automatic enum to tag the union.
const TaggedShapeUnionAutomatic = union(enum) {
circle: struct { radius: f32 },
rectangle: struct { width: f32, height: f32 },
square: struct { size: f32 },
};
pub fn main() !void {
// We can access members of a union directly.
const some_rectangle = ShapeUnion{ .rectangle = .{ .width = 3.14, .height = 2.71 } };
std.debug.print("some_rectangle has width {} and height {}\n", .{ some_rectangle.rectangle.width, some_rectangle.rectangle.height });
// But what if we do the following???
// ------------ try to uncomment the code below -------------
// std.debug.print("some_rectangle has radius {}\n", .{some_rectangle.circle.radius});
// Notice how given a shape of type ShapeUnion, we don't know which kind
// shape it is? We can't switch on it...
// ------------ try to uncomment the code below -------------
// const some_shape = ShapeUnion{ .circle = .{ .radius = 3.14 } };
// switch (some_shape) {
// .circle => std.debug.print("some_shape is a circle of radius {}\n", .{some_shape.circle.radius}),
// .rectangle => std.debug.print("some_shape is a rectangle of width {} and height {}\n", .{ some_shape.rectangle.width, some_shape.rectangle.height }),
// .square => std.debug.print("some_shape is a square of size {}\n", .{some_shape.square.size}),
// }
// We must "tag" the union with an enum to know which kind of shape it is.
const some_shape = TaggedShapeUnion{ .circle = .{ .radius = 3.14 } };
switch (some_shape) {
.circle => std.debug.print("some_shape is a circle of radius {}\n", .{some_shape.circle.radius}),
.rectangle => std.debug.print("some_shape is a rectangle of width {} and height {}\n", .{ some_shape.rectangle.width, some_shape.rectangle.height }),
.square => std.debug.print("some_shape is a square of size {}\n", .{some_shape.square.size}),
}
}
Functions
const std = @import("std");
pub fn foo(x: i32, y: f32) f32 {
std.debug.print("inside the foo function... x: {}, y: {}\n", .{ x, y });
return @as(f32, @floatFromInt(x + 2)) * y;
}
pub fn main() !void {
std.debug.print("foo(3, 5.34) returned {}\n", .{foo(3, 5.34)});
}
Optionals
// Zig values can never be `null`, unless they are explicitly marked as optional.
const some_int: i32 = 34;
const some_optional_int: ?i32 = 34;
const some_optional_int_null: ?i32 = null;
std.debug.print("some_int: {}\n", .{some_int});
std.debug.print("some_optional_int: {any}\n", .{some_optional_int});
std.debug.print("some_optional_int_null: {any}\n", .{some_optional_int_null});
// You can check if an optional is null using an if-else, and unwrap it at the same time.
if (some_optional_int) |an_int| {
std.debug.print("some_optional_int is not null: {}\n", .{an_int});
} else {
std.debug.print("some_optional_int is null\n", .{});
}
// You can also unwrap it with a default value.
const an_int = some_optional_int orelse 0;
std.debug.print("an_int: {}\n", .{an_int});
// If you know an optional is definitely not null, you can unwrap it using `.?`.
std.debug.print("some_optional_int is definitely not null: {}\n", .{some_optional_int.?});
// But this will crash if you attempt to unwrap a null.
// ------------ try to uncomment the code below -------------
// std.debug.print("some_optional_int_null is definitely not null: {}\n", .{some_optional_int_null.?});
// Optionals aren't for free. They take up more space.
std.debug.print("size of i32: {}\n", .{@sizeOf(i32)});
std.debug.print("size of ?i32: {}\n", .{@sizeOf(?i32)});
// However, they're free if the underlying value is a pointer! Any guesses why?
std.debug.print("size of *i32: {}\n", .{@sizeOf(*i32)});
std.debug.print("size of ?*i32: {}\n", .{@sizeOf(?*i32)});
Last updated