By Jacob Strieb.
Published on July 30, 2024.
Zig is a nascent programming language with an emphasis on low-level and systems programming that is positioned to be a C replacement.1 Despite being under active development (and having some rough edges as a result), Zig is extremely powerful, and is already used by a few substantial projects such as Bun and TigerBeetle.
Zig has many interesting features, but its outstanding interoperability (“interop”) with C is especially impressive. It is easy to call an external library, as in this example from the Zig website:
const win = @import("std").os.windows;
extern "user32" fn MessageBoxA(
?win.HWND,
[*:0]const u8,
[*:0]const u8,
u32,
) callconv(win.WINAPI) i32;
pub fn main() !void {
_ = MessageBoxA(null, "world!", "Hello", 0);
}
Calling external functions from C libraries is convenient, but lots of languages can do that. What is more impressive is that, in Zig, it is trivial to import C header files and use them as if they were regular Zig imports. We can rewrite the above to use the Windows header files, instead of manually forward-declaring extern
functions:2
const win32 = @cImport({
@cInclude("windows.h");
@cInclude("winuser.h");
});
pub fn main() !void {
_ = win32.MessageBoxA(null, "world!", "Hello", 0);
}
The following command will compile both of the code examples above for Windows from any host operating system:
I continue to be astounded and delighted that that this code can both be written and cross-compiled so easily on any system.3
I have done my fair share of C programming, but until recently, I had never written a Win32 application,4 nor had I ever written a program in Zig.5
A typical Windows application has a main
(or wWinMain
) function and a “window procedure” (WindowProc
) function. The main function initializes the application, and runs the loop in which messages are dispatched to the window procedure. The window procedure receives and handles the messages, typically taking a different action for each message type. To quote the Microsoft website:
Windows uses a message-passing model. The operating system communicates with your application window by passing messages to it. A message is simply a numeric code that designates a particular event. For example, if the user presses the left mouse button, the window receives a message that has the following message code.
Some messages have data associated with them. For example, the
WM_LBUTTONDOWN
message includes the x-coordinate and y-coordinate of the mouse cursor.
In practice, the window procedure becomes an enormous switch
statement that matches the message code (uMsg
in the example below) against macros defined in winuser.h
. A minimal Zig example of a Win32 application with the standard structure (abridged from the Microsoft Win32 tutorial sequence) is as follows:
const std = @import("std");
const windows = std.os.windows;
const win32 = @cImport({
@cInclude("windows.h");
@cInclude("winuser.h");
});
var stdout: std.fs.File.Writer = undefined;
pub export fn WindowProc(hwnd: win32.HWND, uMsg: c_uint, wParam: win32.WPARAM, lParam: win32.LPARAM) callconv(windows.WINAPI) win32.LRESULT {
// Handle each type of window message we care about
_ = switch (uMsg) {
win32.WM_CLOSE => win32.DestroyWindow(hwnd),
win32.WM_DESTROY => win32.PostQuitMessage(0),
else => {
stdout.print("Unknown window message: 0x{x:0>4}\n", .{uMsg}) catch undefined;
},
};
return win32.DefWindowProcA(hwnd, uMsg, wParam, lParam);
}
pub export fn main(hInstance: win32.HINSTANCE) c_int {
stdout = std.io.getStdOut().writer();
// Windows boilerplate to set up and draw a window
var class = std.mem.zeroes(win32.WNDCLASSEXA);
class.cbSize = @sizeOf(win32.WNDCLASSEXA);
class.style = win32.CS_VREDRAW | win32.CS_HREDRAW;
class.hInstance = hInstance;
class.lpszClassName = "Class";
class.lpfnWndProc = WindowProc; // Handle messages with this function
_ = win32.RegisterClassExA(&class);
const hwnd = win32.CreateWindowExA(win32.WS_EX_CLIENTEDGE, "Class", "Window", win32.WS_OVERLAPPEDWINDOW, win32.CW_USEDEFAULT, win32.CW_USEDEFAULT, win32.CW_USEDEFAULT, win32.CW_USEDEFAULT, null, null, hInstance, null);
_ = win32.ShowWindow(hwnd, win32.SW_NORMAL);
_ = win32.UpdateWindow(hwnd);
// Dispatch messages to WindowProc
var message: win32.MSG = std.mem.zeroes(win32.MSG);
while (win32.GetMessageA(&message, null, 0, 0) > 0) {
_ = win32.TranslateMessage(&message);
_ = win32.DispatchMessageA(&message);
}
return 0;
}
The output of the code above looks like the following when it is run:
Unknown window message: 0x0024
Unknown window message: 0x0081
Unknown window message: 0x0083
Unknown window message: 0x0001
...
Unknown window message: 0x0008
Unknown window message: 0x0281
Unknown window message: 0x0282
Unknown window message: 0x0082
When extending the Windows code above to handle new message types, it is troublesome to determine which C macro corresponds to each message the window procedure receives. The numeric value of each message code is printed to the standard output, but mapping the numeric values back to C macro names involves either searching through documentation, or manually walking the header #include
tree to find the right macro declaration.
The underlying cause of difficulty in mapping macro values back to macro names is that C does not have reflection for preprocessor macros – there is no way to get a list of all defined macros, let alone all macros with a specific value, from within C code. The preprocessor runs before the code is actually compiled, so the compiler itself is unaware of macros.6 The separation between the preprocessor and the compiler enables the user to make advanced changes to the code at compile time, but in practice, that separation means compiled code cannot introspect macros.7
Though it may not be obvious from the code above, in Zig, references to macro and non-macro declarations from imported C header files are made in the same way. For example, win32.TranslateMessage
is a function declared in the header file, and win32.WM_CLOSE
is a macro declared using #define
. Both are used in Zig by doing imported_name.declared_value
. The Zig @import
function returns a struct
, so regular declarations and macros, alike, are represented as fields in the struct generated from importing the C header files.
It is significant that declarations are represented in imports as struct fields because, unlike C, Zig does have reflection. In particular, the @typeInfo
function lists the fields and declarations of structs passed to it. This means that, though we cannot introspect C macros within C, we can introspect C macros within Zig. Consequently, we can create a mapping of macro values to macro names:
const window_messages = get_window_messages();
// The WM_* macros have values less than 65536, so an array of that size can
// represent all of them
fn get_window_messages() [65536][:0]const u8 {
var result: [65536][:0]const u8 = undefined;
@setEvalBranchQuota(1000000);
// Loop over all struct fields and match against the expected prefix
for (@typeInfo(win32).Struct.decls) |field| {
if (field.name.len >= 3 and std.mem.eql(u8, field.name[0..3], "WM_")) {
const value = @field(win32, field.name);
result[value] = field.name;
}
}
// We return by value here, not by reference, so this is safe to do
return result;
}
Using the global constant window_messages
, we can change our WindowProc
function to print more helpful information about the messages it is receiving:
pub export fn WindowProc(hwnd: win32.HWND, uMsg: c_uint, wParam: win32.WPARAM, lParam: win32.LPARAM) callconv(windows.WINAPI) win32.LRESULT {
_ = switch (uMsg) {
win32.WM_CLOSE => win32.DestroyWindow(hwnd),
win32.WM_DESTROY => win32.PostQuitMessage(0),
else => {
// New: print the macro for the current window message
stdout.print(
"{s}: 0x{x:0>4}\n",
.{ window_messages[uMsg], uMsg },
) catch undefined;
},
};
return win32.DefWindowProcA(hwnd, uMsg, wParam, lParam);
}
Now, the output of the program looks much nicer when run:
...
WM_NCHITTEST: 0x0084
WM_SETCURSOR: 0x0020
WM_MOUSEMOVE: 0x0200
WM_SYSKEYDOWN: 0x0104
WM_CHAR: 0x0102
WM_KEYUP: 0x0101
WM_SYSKEYUP: 0x0105
WM_WINDOWPOSCHANGING: 0x0046
WM_WINDOWPOSCHANGED: 0x0047
WM_NCACTIVATE: 0x0086
WM_ACTIVATE: 0x0006
WM_ACTIVATEAPP: 0x001c
WM_KILLFOCUS: 0x0008
WM_IME_SETCONTEXT: 0x0281
WM_NCDESTROY: 0x0082
Though this example is small, it illustrates that Zig can do what C does, but can do so more ergonomically by employing modern programming language constructs. One of Zig’s unique superpowers is that it bundles a C compiler toolchain – that is what enables it to transcend C FFI and seamlessly include declarations from C header files, among other capabilities.
Incorporating C interoperability so deeply into the language highlights Zig’s prudent acknowledgment that C has been around for a long time, and is here to stay for a while longer. Integrating with C in this way means that Zig developers have had access to thousands of existing, battle-tested software libraries since the language’s first release. It also gives developers responsible for existing C or C++ codebases a path to transition them to Zig. Availability of high-quality libraries and transition paths for existing code are both critical obstacles to language adoption that Zig has cleverly bypassed by electing to subsume C in the course of replacing it.
Zig’s philosophy of pragmatism is apparent as soon as you begin learning the language. Within a few hours of getting started, I was able to come up with this C macro reflection trick, and also able to be generally productive. That is, to me, clear evidence of Zig’s intuitive, consistent design.8
Zig’s straightforward cross-compilation and C integration are what drew me to the language, but its philosophy and design are what will keep me here to stay.
Thanks to Logan Snow and Amy Liu for reviewing a draft of this post.
Shout out to Andrew Kelley and the other Zig contributors.
Maybe also a C++ replacement, but there are more contenders vying for that role, such as Rust and Go.↩︎
It’s not so bad when it’s just one external function. But when it’s tens or hundreds, importing the header file directly makes development a lot smoother.↩︎
Zig also has zig cc
, which is a drop-in replacement for GCC and Clang that enables easier-than-ever cross-compilation for C projects. If you ever do cross-compilation, I implore you to read this awesome intro to zig cc
, then try it for yourself.↩︎
Mainly because getting the MSVC compiler set up for command-line use outside of Visual Studio is painful. Even figuring out what files to download and where to download them from is not straightforward. On the other hand, Zig cross-compilation has been painless.↩︎
As a result, I may not be writing idiomatic (or correct) Zig or Windows code. Everything included here should only be treated as “proof of concept” code for demonstrating an interesting technique.↩︎
Most gcc
or clang
invocations automatically invoke the preprocessor. When I talk about “the compiler” here, I specifically mean the C compiler proper, which runs after the preprocessor is done.↩︎
At least not without debug information or explicit macro name-value mappings being included in the binary. You could hack something together using X macros to achieve the latter. But those are a little gross (albeit kind of clever), and only apply if you control the header file where the macros are originally declared, which we don’t in the case of windows.h
.↩︎
The design goals are best explained by Andrew Kelley, the creator of Zig, in his post from 2016 introducing the language and its philosophy.↩︎