API design and boolean flags

Let us consider a basic API which has functions which have flags. e.g.

fn action(..., enable_x:  bool, enable_y: bool) {
    ...
    if (enable_x) { ... }
    if (enable_y) { ... }
}

and things of that variation.

My mind came up with three forms to approach this.
One is just the above, which is the clearest in terms of how the API behaves, but, without knowing how the compiler does its thing, makes it unclear how all those flags may be represented. E.g., naively, each of those flags could be its own u8 on the stack.

By contrast, we have the ol’ C approach where you would use bitmasks. We define a bunch of flags of the form

const ENABLE_X = 0x0000_0001;
const ENABLE_Y = 0x0000_0010;

and then can just define our command as const command = ENABLE_X | ENABLE_Y if we wanted both, with the logic in the function doing & ENABLE_X or & ENABLE_Y to check if it should execute the conditional branches. This makes it clearer how the data is represented, but then the function signature becomes

fn action(..., flags: u8) { ... }

which is less ergonomic in terms of API design. Of course, this could also be a bitset from the standard lib, but the point remains.

The third form I thought was, ok, just use a packed structed with boolean fields. Then, although the function signature is slightly less ergonomic, at least each possible flag is encoded into the type itself. E.g.

const ActionFlags = packed struct {
    enable_x: bool,
    enable_y: bool,
};
fn action(..., flags: ActionFlags) { ... }

Between these three, I view it as deciding between clearest API for a user vs. clearest representation of data. Does anyone have any opinions on which methodology is the best (if there is one), for API design?

I prefer the packed struct approach. It is basically a syntax sugar for what you do in C with bitmasks. If you use packed structs you may want to set default values for the fields.

2 Likes

I also prefer the (packed) struct approach. A nice thing about it is that you can have presets that are then easy to use for the user for most common things. So something similar to this:

const Flags = struct {
    a: bool,
    c: enum { variant_1, variant_2 },
    d: u32,

    const low_latency = Flags{ .a = false, .c = .variant_1, .d = 1 };
    const default = Flags{ .a = false, .c = .variant_1, .d = 8 };
    const high_throuput = Flags{ .a = true, .c = .variant_2, .d = 128 };
};

fn action(flags: Flags) !void {
    _ = flags;
    // TODO: do something
}

// Usage
action(.low_latency);
action(.default);
action(.{.a = false, .c = .variant_2, .d = 4096});
2 Likes

My default would be

fn action(options: struct {
    x: bool, 
    y: bool, 
}) void

The heuristic I use for this sort of decisions is that compiler couldn’t care less about what I write in the source code, that it just produces optimal machine code having an equivalent logical effect, unless there’s some constraint that forces the compiler to externalize the specific representation in the source code.

Two externalizing constraints:

  • External ABIs, if I write an extern fn action, than whatever ABI I choose for the function, would be its ABI.
  • Storing to memory. If I don’t simply pass Options as single value, but rather materialize something like []Option, then compiler can’t magically turn my array of pairs of bools into two bitsets.

In the presented case, there are no such externalizing constraints, so I wouldn’t expect to see a function call to action in the generated code at all. My default expectation is that everything is inlined in one big wad of code.

This heuristic obviously doesn’t work for simple non-optimizing compilers, and I haven’t checked if it works with Zig+LLVM combo, but, given Zig’s whole-program compilation model and ambition to produce optimal software, I’d say that, even if it doesn’t entirely work that way today, that is a toolchain bug.