Recently I've been working on a Wayland client, it gave me the very first obvious opportunity to use comptime. I know it's obvious and the name kinda gives it away but till I tried it out I did not grasp idea of being able to write code that would then be expanded during compilation. Maybe this is just how generics works in most languages but I like that in Zig it's quite explicit and there is quite a lot you can do with it. One of the very first challenges was just understanding that somethings run in comptime that means for example you can't use std.debug.print and have to use @compileLog instead. Seems obvious now but at the time I was so confused. At the same time I can't explain how I got it either. It just clicked.
There was this pattern in my code that kept repeating in my Wayland client which I wanted to be cleverer and not so repetitive. Hearing me say that out loud makes me realize it's best explained with an example. I'll use the example of sending requests to Wayland over the socket. Let's take get_registry and get_surface, you don't need to know what they do but they are one of the first couple of request you make to the Wayland compositor.
Before comptime
fn getRegistry(socket: std.posix.socket_t, buf: []u8, new_id: u32) !void {
const Request = packed struct {
header: Header,
new_id: u32,
};
const h = Header{ .id = ObjectId.Display, .op = 1, .message_size = @sizeOf(Request) };
var m = Request{ .header = h, .new_id = new_id };
const msg: []const u8 = std.mem.asBytes(&m);
const sent_len = try std.posix.send(socket, msg, 0);
std.debug.assert(msg.len == sent_len);
}
fn createSurface(socket: std.posix.socket_t, buf: []u8, new_id: u32) !void {
const Request = packed struct {
header: Header,
new_id: u32,
};
const h = Header{ .id = ObjectId.Compositor, .op = 0, .message_size = @sizeOf(Request) };
var m = Request{ .header = h, .new_id = new_id };
const msg: []const u8 = std.mem.asBytes(&m);
const sent_len = try std.posix.send(socket, msg, 0);
std.debug.assert(msg.len == sent_len);
}
There are quite a few requests you need to call before you get a window. So this pattern repeats itself quite a lot. I did wait till I got a window to do the refactor which I think was the right call as it helped me see all the possible request arguments I would need to handle. One important detail is that the opcode (op) is just the index of the request within the Wayland interface.
Before I show you the comptime version I created a Request type for each interface but they are still just structs.
Display Type
pub const Display = struct {
id: u32 = 1,
pub const Request = union(enum) {
sync: struct {
callback: u32,
},
get_registry: struct {
registry_id: u32,
},
};
pub const Event = union(enum) {
...
};
};
Making them a tagged union was not my idea, I was inspired by another implementation of the Wayland client in Zig which you can find here.
Compositor Type
pub const Compositor = struct {
id: u32,
pub const Request = union(enum) {
create_surface: struct {
id: u32,
},
};
};
Comptime version
Finally we can get to the fun bit, the comptime request sending.
fn sendRequest(socket: std.posix.socket_t, object_id: u32, request: anytype) !void {
var buf: [128]u8 = undefined;
var w = std.Io.Writer.fixed(&buf);
const tag = std.meta.activeTag(request);
switch (request) {
inline else => |x| {
const req_type = @TypeOf(x);
const req_size = if (@hasDecl(req_type, "size")) x.size() else @sizeOf(req_type);
const header = interfaces.Header{ .object_id = object_id, .op = @intFromEnum(tag), .message_size = @sizeOf(interfaces.Header) + req_size };
try w.writeStruct(header, .little);
try interfaces.writeRequest(&w, x);
},
}
const msg = w.buffered();
const sent_len = try std.posix.send(socket, msg, 0);
std.debug.assert(msg.len == sent_len);
}
The inline section within the switch is where all the comptime magic happens. I could have used writeRequest to write the header too but it was helpful to keep the size logic obvious. The only reason there is that strange looking size field check is that some arguments were strings (or []const u8 in Zig) and Wayland has some specific rules to sending and receiving strings. So it made it easier to make that a method in the Request type itself and the for the ones that didn't the @sizeOf(type) was the only thing that was needed.
The cool thing is that now within the writeRequest method I can specify how I want each argument to be handled and Zig will produce code that can run at runtime. When I got this working the first time I was so excited.
writeRequest
pub fn writeRequest(w: *std.Io.Writer, data: anytype) !void {
switch (@typeInfo(@TypeOf(data))) {
.@"struct" => |s| {
inline for (s.fields) |f| {
try writeRequest(w, @field(data, f.name));
}
},
.pointer => |p| {
switch (p.size) {
.slice => {
const str_len: u32 = @intCast(data.len);
const padding = utils.getPadding(str_len);
try w.writeInt(u32, str_len, .little);
try w.writeAll(data);
try w.splatByteAll(0, padding);
},
else => return error.UnhandledPointerRequestType,
}
},
.int => {
try w.writeInt(@TypeOf(data), data, .little);
},
else => {
return error.UnhandledRequestType;
},
}
}
After getting the write done I was able to do the same for reading events from the socket which in total allowed me to turn 800 lines of code into 517 not very impressive I know but ones I had this in place I had far fewer undefined behaviors and was easily able add support for new events and request by simply defining the type that was expected and the rest simply just worked. Which for me is my favorite part of programming when things just worked. Most of all I finally understood comptime and how Zig handles types and data a little bit better. It was also just a lot of fun. Which on it's own would have been enough for me.