Achieving the same behaviour with C's conditional compilation

I’m coming from a C background and I’m currently trying to make a game in Zig to understand it better, I’m trying to do as much of it myself so I’ll be using GLFW with OpenGL.

In C I was easily able to create different platform layers via conditionally compiling different .c files. For example, I’d have a platform.h file which would just declare some generic platform functionality, and according to my target or some extra compilation flags, I was able to just for example compile the sdl2_platform.c or webgl_platform.c, and in those files i’d just implemented pre declared functions.

I assume zig doesn’t have this feature since it doesn’t have something like a header file all together, but what is the best or more so the zig way of achiving similar behaviour in code?

1 Like

Hey welcome to ziggit, there are multiple ways to achieve that, within your code you can do something like this :slight_smile:

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

fn windowsImpl() void {}
fn linuxImpl() void {}
fn macosImpl() void {}

pub fn myFunction() void {
    switch (builtin.os.tag) {
        .windows => windowsImpl(),
        .linux => linuxImpl(),
        .macos => macosImpl(),
    }
}

pub fn main() !void {}

Basically the builtin module is given to you by the build system everything in there is comptime known variable that you can leverage in your code, things, like abi, os, etc that this code was compiled with.

Than you can also do some more of this kind of stuff in the build system

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

    const exe = b.addExecutable(.{
        .name = "foobar",
        .root_module = b.createModule(.{
            .root_source_file = null,
            .target = target,
            .optimize = optimize,
            .link_libc = true,
        }),
    });

    const c_source_files: []const []const u8 = &.{
        "src/foo.c",
        "src/bar.c",
    };

    const linux_files: []const []const u8 = &.{
        "src/linux.c",
    };
    const macos_files: []const []const u8 = &.{
        "src/macos.c",
    };
    const windows_files: []const []const u8 = &.{
        "src/windows.c",
    };

    switch (target.result.os.tag) {
        .windows => {
            exe.root_module.addCSourceFiles(.{
                .files = c_source_files ++ windows_files,
                .flags = &.{},
                .language = .c,
            });
        },
        .linux => {
            exe.root_module.addCSourceFiles(.{
                .files = c_source_files ++ linux_files,
                .flags = &.{},
                .language = .c,
            });
        },
        .macos => {
            exe.root_module.addCSourceFiles(.{
                .files = c_source_files ++ macos_files,
                .flags = &.{},
                .language = .c,
            });
        },
        else => unreachable,
    }

    b.installArtifact(exe);
}

This is probably not the best way to do, it but for the sake of simplicity here are an example.

You can also do it with CLI arguments if you fancy :

const Backend = enum {
    opengl,
    sld2,
    vulkan,
};

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

    const exe = b.addExecutable(.{
        .name = "foobar",
        .root_module = b.createModule(.{
            .root_source_file = null,
            .target = target,
            .optimize = optimize,
            .link_libc = true,
        }),
    });

    const backend_choice = b.option(Backend, "Backend", "Select a Backend") orelse .opengl;

    const c_source_files: []const []const u8 = &.{
        "src/foo.c",
        "src/bar.c",
    };

    const vulkan_backend: []const []const u8 = &.{
        "src/vulkan.c",
    };
    const sdl2_backend: []const []const u8 = &.{
        "src/sdl2.c",
    };
    const opengl_backend: []const []const u8 = &.{
        "src/opengl.c",
    };

    switch (backend_choice) {
        .opengl => {
            exe.root_module.addCSourceFiles(.{
                .files = c_source_files ++ opengl_backend,
                .flags = &.{},
                .language = .c,
            });
        },
        .sld2 => {
            exe.root_module.addCSourceFiles(.{
                .files = c_source_files ++ vulkan_backend,
                .flags = &.{},
                .language = .c,
            });
        },
        .vulkan => {
            exe.root_module.addCSourceFiles(.{
                .files = c_source_files ++ sdl2_backend,
                .flags = &.{},
                .language = .c,
            });
        },
    }

    b.installArtifact(exe);
}

and if you look at the ouput of zig build --help :

you will see

  -DBackend=[enum]             Select a Backend
                                 Supported Values:
                                   opengl
                                   sld2
                                   vulkan
10 Likes

Not at a computer right now but conditional import should work, smth like this:

const gfx = switch (platform) {
    .windows =>  @import("windows/gfx.zig"),
    .mac => @import("mac/gfx.zig"),
    .linux => @import("linux/gfx.zig"),
};

…where platfrom is coming in via the buildsystem as comptime const.

15 Likes

I think it’s interesting to note here the difference in coding style encouraged by zig.
In the c example given, you have the header file, which acts as an interface, specifying the function calls and perhaps structs.
In zig, there isn’t really an equivalent (please correct me if I’m wrong) that forces two interchangeable files to satisfy the same interface (although presumably you will get compile errors if any non-matching functions get called).
In the zig standard library, it seems like the tendency is to get fairly granular before it starts “branching” for the operating system, meaning that you almost always call the same function in the same file regardless of your operating system, and then the onus is on that function to figure out where to go next, rather than on the caller to decide which file to import.

Is this a principled decision? Is there some sense in which we can say this is “better” than the alternative? Are there situations where we should prefer to do it another way?

2 Likes

Another possibility would be to have n modules and only pick one of them for the right platform (always importing them under the same name), then add that module to your app or dependent module while not adding the unused ones (based on some build option or the target).

I think it depends on what you are trying to achieve, the std is by definition meant to be a cross-platform api, technically it could expose us all the platform specific variant, but it would be quite tedious for each function call to switch on witch platform we are on etc. Which is why it’s often handled in the background, I don’t think there’s really a good/bad option, Zig std, is meant to support the compiler, and ofc provide conveniences to the users, so code reuse is a big deal and as such it makes a lot of sense for std to provide a function that works across platform, by doing the work of deciding which code to use depending on the current target.

As for including libraries, code, backend conditionally it depends again on what you are trying to achieve i suppose.

I think an important piece of information is that all these techniques only work because Zig is doing lazy analysis. So even if the code for all platforms is there, if the compile time conditional only leads to a branch for one specific platform, all the other branches are simply ignored.

11 Likes

Thank you all for your responses, all of them are quite educational. I didn’t know zig was doing lazy analysis, so i think I’ll try to take advantage of that, but I have another question. How can I make my build.zig file to export a value to my source files, similar to how we can use defines in C?

In your build function, create an options step via b.addOptions().
From there, you can add an option of any Zig type by calling addOption() on this options step, you can turn it into a generated source file with getOutput() and you can turn it into a module with createModule().

2 Likes

I see, thank you. This is how we expose a comptime comp via the build system like @floooh mentioned right?

Yeah, pretty much

Here is some official documentation for the build system: link.
Please note that in the call to options.addOption you of course can just hardcode a value or get it from something else. It doesn’t have to be passed in from a b.option call (which are flags set by the user when running zig build).

1 Like