Trouble Creating Multi-File Modules

Hi!

I’m new to zig and trying to figure out how exactly the build system works with modules. In my current project, I have the following directory structure:

.
├── build.zig
├── build.zig.zon
└── src
    ├── geo
    │   ├── geo.zig
    │   ├── ray.zig
    │   └── vec3.zig
    └── main.zig

Here are the relevant bits of the various files.

vec3.zig:

const std = @import("std");

pub const Vec3 = struct {
    x: f64,
    y: f64,
    z: f64,

    pub fn inv(self: Vec3) Vec3 {
        return .{ .x = -self.x, .y = -self.y, .z = -self.z };
    }
...
}

pub fn add(v1: Vec3, v2: Vec3) Vec3 {
    return .{ .x = v1.x + v2.x, .y = v1.y + v2.y, .z = v1.z + v2.z };
}

geo.zig:

pub const Vec3 = @import("vec3.zig");

main.zig:

const std = @import("std");
const geo = @import("geo");

pub fn main() !void {
    _ = geo.Vec3.Vec3{ .x = 1, .y = 3, .z = 4 };
}

build.zig:

const std = @import("std");

pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    const geo = b.addModule("geo", .{
        .root_source_file = b.path("src/geo/geo.zig"),
        .target = target,
    });

...

    const raytracer = b.addModule("raytracer", .{
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = optimize,
        .imports = &.{
            .{ .name = "geo", .module = geo },
            .{ .name = "color", .module = color },
        },
    });

    const exe = b.addExecutable(.{
        .name = "raytracer",
        .root_module = raytracer,
    });

    const exe_check = b.addExecutable(.{
        .name = "raytracer",
        .root_module = raytracer,
    });

    b.installArtifact(exe);
...
}

What I’d like to do is import the geo module then access geo.Vec3{} and geo.Add() instead of having to call geo.Vec3.Vec3{}… essentially I want to “flatten” the module as if it was all in one file like how #include in C or the package system in Go. Alternatively, if I’m approaching this completely wrong and there is a more idiomatic way of handling modules, I’d love to find out!

I can’t seem to find any good resources on building zig module past the very basic examples. I also tried going through the std lib source code for examples, but I couldn’t quite figure out what the pattern was (seemingly @This is used somehow?).

Thank you in advance!

The general rule of thumb is that there isn’t any kind of magic in Zig, so if you want to do something, you need to type out the relevant code. In your case, I think what you are asking for is

//geo/geo.zig

pub const vec3 = @import("vec3.zig");
pub const Vec3 = vec3.Vec3;
pub const add = vec3.add;

Though, in your particular case, I think you can use “files are structs” trick:

//geo/geo.zig
const Vec3 = @import("./Vec3.zig");
//geo/Vec3.zig

x: f64,
y: f64,
z: f64,

const Vec3 = @This();

pub fn inv(self: Vec3) Vec3  { ... }
pub fn add(v1: Vec3, v2: Vec3) Vec3 { ... }
1 Like

That makes total sense thank you so much, that totally clears it up! Maybe the documentation of @Thiscould make it more clear that files are structs.

“When @This() is used at file scope, it returns a reference to the struct that corresponds to the current file.”

Wasn’t super clear (at least to me), I’m happy to make a pull request for that if it would be helpful.

1 Like

Just to add onto @matklad’s answer, a common pattern in std is to create a higher level file module that publicly imports all of the modules in a folder with the same name. So in your case:

.
├── build.zig
├── build.zig.zon
└── src
    ├── geo.zig
    ├── geo
    │   ├── ray.zig
    │   └── vec3.zig
    └── main.zig

By no means is this required. Just a pattern you might be interested in for handling these cases.

Also, some unsolicited advice!

I think I get where you are coming from (my rustraytracer was rather module-y), but if I were writing a raytracer in Zig today, I think I would go for a flat structure, where there’s just a single module with fn main, and everything else are just files. I would still do some hierarchy, but I wouldn’t express it as Modules.

So,

const geo = @import("./geo.zig");

rather than

const geo = @import("geo");

The reasoning here is that, as far as I understand, there’s relatively little “physical” difference between modules and separate files in Zig. In Rust and C++, spreading code across crates/CUs gives you separate compilation. But Zig always does “unity builds”, so spreading across module doesn’t change much in terms of how compiler sees your code.

Perhaps this difference is best observed in the fact that Zig modules, unlike Rust crates, can be mutually recursive. Not only your code imports std, but std imports your code as well. The “actual” main function, start, is defined in the std:

And this function works by importing your module and calling your main:

It really just a single big pile of Zig code to the compiler, with little difference between separate files in a single module and separate modules. For this reason, I think it makes sense to default to simpler way to do hierarchical organization, using only files rather than modules.

(curious if I am missing some real reasons to default to modules, I am only about 0.8-ish confident in the above)

2 Likes

I guess one could say that the primary purpose of Zig modules is to abstract away concrete file system paths. You write @import("std") rather than @import("/path/to/actual/std.zig") because the latter isn’t fixed across machines. The same goes for dependencies, you don’t want to care where on your file system the dependency is actually stored.

But inside your own code, you fully control relative paths, so there’s relatively little motivation to add indirection there.

6 Likes