Zig to C / C to Zig
Zig often touts its excellent compatibility with the C ABI, and it’s true. There are many examples of writing code in Zig and using it in C/C++, or vice versa:
lib.zig
export fn sum(a: c_int, b: c_int) c_int {
return a + b;
}
lib.h
extern int sum(int, int);
I won’t go deeply into this topic since there are numerous examples and discussions around it. However, there’s another question that, in my opinion, has not been fully addressed yet.
What about Zig-to-Zig?
So, how do we handle libraries written in Zig that are intended to be used in other Zig code?
You might confidently say: use modules!
Yes… if we’re using static linking for the entire library/module. Zig strongly promotes this idea of including library sources into your application’s code, and I have to agree—it’s particularly convenient for standard libraries, where the compiler can directly include only the code being used. This also allows for additional optimizations and eliminates the need for a runtime in Zig!
BUT!
However, there comes a time when dynamic linking for Zig is needed.
There can be various reasons for this, and I mean Zig to Zig, not dynamic linking from C/C++ to Zig. For example, imagine you wrote a massive game engine called Real Engine 5 in Zig, and Zig is its main scripting language. Now, someone develops a game using your engine. Compiling the entire engine from source is feasible, but if we include the entire engine in the game’s code, it could become problematic. You wouldn’t want to recompile the entire Real Engine 5 just to add a new entity to your game, right? The same goes for drivers dynamically linked to the OS kernel or plugins loaded dynamically into your application.
Zig itself generally has no special issues with the dynamic linking process. Here, I would like to talk about API and writing Zig code that uses dynamically linked Zig code.
When writing in C/C++, we have to separate files into .h
and .c
/.cpp
, representing headers and implementation. This concept is well-known and understood, though it introduces certain inconveniences. Zig seems to follow a different path, freeing us from this requirement. Convenient!
However, for this very reason, when I write C/C++ code, I don’t need to write wrappers for my library later because providing my header files is enough.
In Zig, this has to be done manually. In general, this is not an issue for libraries if we know in advance that we’re writing a dynamic library and our library serves a specific purpose. But for a game engine, OS kernel, or something similar, it might not be the case. Furthermore, many systems and mechanisms in a game engine are usually transparent for the games developed on it. For instance, if you have a Scene
structure in your engine’s code, using it in the game’s code feels natural, and you don’t want to write a separate SceneApi
wrapper for the game’s code.
Zig has many features and possible optimizations (comptime
, error
, ?<Optionals>
, etc.) that I don’t want to lose when exporting my code to a dynamic module because I plan to use it again in another Zig code.
The Problem, with an Example:
assets.zig
pub const Asset = struct {
name_hash: u32,
...
// Comptime function
pub fn init(comptime name: []const u8) Asset {
return .{
.name_hash = comptime hash(name),
...
};
}
};
var assets: []Asset = &.{};
// Function that combines comptime and dynamic calculations.
pub fn findAsset(comptime name: []const u8) ?*Asset {
const name_hash = comptime hash(name);
for (assets) |*asset| {
if (asset.name_hash == name_hash) return asset;
}
return null;
}
This is a simplified example, but the main point is that some APIs like assets.find(...)
can be successfully used both inside the engine code and in the game that uses the engine. However, the function contains compile-time calculations, which can lead to issues. This can be solved by separating the dynamic code:
// Function that combines comptime and dynamic calculations.
pub fn findAsset(comptime name: []const u8) ?*Asset {
const name_hash = comptime hash(name);
return findAssetByHash(name_hash);
}
export fn findAssetByHash(name_hash: u32) ?*Asset {
for (assets) |*asset| {
if (asset.name_hash == name_hash) return asset;
}
return null;
}
Great, it seems like we can now compile our engine as a .dll
/.so
and use it in our game! …Seems, but not quite. What about the game’s code? We can’t just do this:
// Wrong!
const assets = @import("assets.zig");
fn game() !void {
const map = assets.find("zigland.map") orelse return error.MapNotFound;
}
If we do this, everything will be statically compiled, and our game won’t be linked with the engine dynamically. The issue isn’t just that I didn’t include a call to load the .dll
/.so
; it’s that assets.zig
contains both the API and the implementation, resulting in our own find(...)
function and an independent asset slice var assets: []Asset = &.{};
in our game.
It seems like we have to manually separate the API from the implementation. However, there’s a problem. I love Zig, and I enjoy that it allows writing methods (functions inside structures):
pub const Temp = struct {
num: usize = 0,
pub fn add(self: *Temp, other: usize) void {
self.num += other;
}
};
But this does not align well with separating implementation from the API. Imagine Temp.add(...)
as an internal implementation used within the engine. To export this function with the same nesting inside the Temp
structure for the game, I would have to write a wrapper:
extern fn addImpl(self: *Temp, other: usize);
pub const Temp = struct {
num: usize = 0,
pub inline fn add(self: *Temp, other: usize) {
addImpl(self, other);
}
};
And I would need to do this for every structure. Moreover, I would have to define the structure twice—first for the engine, then for the game. Now imagine the scale of Real Engine 5 with hundreds of structures, thousands of functions, methods, and more.
Simply “wonderful”! In reality, it’s HORRIBLE! If I need to change the structure in the engine, I will have to rewrite the API wrapper. It turns out I would have to abandon inline functions, and due to the unstable ABI (even when writing a Zig library for Zig), I would need to avoid using error
/optionals
in API functions or write additional WRAPPERS. Oh, and I would have to write WRAPPERS anyway, manually adding extern
/export
for all functions because Zig has no macros!
In this case, it seems easier to write everything in C/C++ or another language
What Language Improvements Are Possible?
Finally, I would like to discuss potential language improvements and tools that would allow us to write dynamic code without the pain. But Zig is too strict for any kind of dynamic approach, and honestly, I’m afraid nothing will change in this direction. For instance, adding tools in the build system to simplify or even skip writing wrappers would be a good step. Ideally, introducing new constructs in the language that allow separating implementation from external API while keeping everything in a single .zig
file would be great.
What do you think about this?
What solutions do you use?
What ideas do you have for introducing new features in the language to solve this problem?