Std_options but it doesn't polute the root namespace

I’ve been thinking about std_options and how it effectively doesn’t allow you to use the identifier std_options in your root source file:

const std = @import("std");

pub const std_options = enum { 
    herpes_simplex_virus_1, 
    herpes_simplex_virus_2 
};

pub fn main() void {
    std.debug.print("You've got {}!", .{std_options.herpes_simplex_virus_1});
}
error: expected type 'std.Options', found 'type'
pub const options: Options = if (@hasDecl(root, "std_options")) root.std_options else .{};

Granted, nobody is going to use std_options as an identifier, unless you work for the CDC or something… but the std library serves as an example for us all, so maybe it could avoid using @hasDecl on the root source file?

  1. hard to discover the type of std_options before being hit with a compile error (no “goto definition”)
  2. can’t namespace the identifier
  3. large “action at a distance” feel

I came up with a couple options for std_options:

  1. Make std a generic functions that has std_options are arguments. Not sure this is really practical. std is a namespace, so if we make it a generic function then we have to enclose std in some other namespace…
  2. Make std.log a generic function that accepts a log level? This pattern could be extended to all things in std with comptime options.
  3. Somehow write a std.withOptions() function that returns a customized std.

I prototyped option 3, but it has the downside of now being required to initialize std, and there’s a good amount of comptime shenanigains.

std2.zig

// My version of std that doesn't polute the root namespace with std_options

options: Options = .{},

pub const LogLevel = enum { debug, info, warn, err };

pub const Options = struct {
    log_level: LogLevel = .err,
};

pub fn withOptions(comptime options: Options) @This() {
    return @This(){ .options = options };
}

pub fn warn(comptime self: @This(), message: []const u8) void {
    if (comptime self.options.log_level == .warn) {
        std.debug.print("WARN: {s}\n", .{message});
    }
}

const std = @import("std");

test.zig

// weird, now I have to initialize std2
pub const std2 = @import("std2.zig"){};

test {
    std2.warn("this message doesnt print."); // default log level is err

    const std2_with_options = comptime @import("std2.zig").withOptions(.{ .log_level = .warn });
    std2_with_options.warn("Hello warn!");
}

related: Proposal: Avoid using root namespace for global behavior configuration · Issue #6514 · ziglang/zig · GitHub

1 Like

I think the status quo is good. It would be very annoying to initialize std in every file you use it. And if you have modules that initialize it differently, you now have N different instantiations of std slowing compile times and potentially increasing code bloat.

2 Likes

I think the status quo is the most ergonomic, what’s sacrificing a single possible identifier in root to allow the configuration of behaviour of certain abstractions in std such as logging.
I dont think there is a different approach that is just as ergonomic, at least with current zig features.

perhaps there is a way to keep the ergonomics but move the configuration into the build system, like passing them as build options to std

1 Like

I agree that the status quo is suboptimal. Ideally, setting up the global options of the standard library should be done in an explicit fashion:

const std = @import("std");

comptime {
    std.setOptions(.{ .log_level = .debug });
}

As far as I know this doesn’t work, however, since the entirety of std will have been processed and its comptime code executed by the time this setOptions call is hit. This is effectively a “pre-comptime” step, from the point of view of application code, and the only way to influence it is through static declarations like std_options.

(And even that only works because @import("root") is a thing).

Perhaps a build.zig-based solution could work here? Users could bind a module into a known import name, say "std-options" – either through b.addModule or b.addOptions – which std would then import. I’m not sure if this would work with the limitation that a single .zig file can only belong to a single module; it might force awkward restructuring of application code that wants to use the symbols referenced by std-options.

A language-level feature such as import options:

const std = @import("std", .{ .log_level = .debug });

could potentially solve it, as would the capability of choosing a different symbol than the implicit top-level struct to be the “default export” of a file (c.f. default exports in TypeScript):

// Foo.zig
fn Foo(comptime config: anytype) type { 
 // ...
}

export = Foo;

// bar.zig
const foo = @import("Foo.zig")({ .config_option = true });

The latter seems like a decent idea in general, since it removes one level of indirection when a file implements just one generic type.

1 Like

Something like what @matlkad proposes in the Modules are isomorphic to generic functions thread could work for this.

But still has the issue of needing to redeclare the options every time you import std.

The simplest solution, I think, is to use a function in root with a @“weird name”:

pub fn @"std(get-options)"() : std.Options {
    return .{ 
        // ... 
    };
}

We can then handle the panic function in the same way:

pub fn @"std(panic)"(msg: []const u8, error_return_trace: ?*std.builtin.StackTrace, ret_addr: ?usize) noreturn {
    // ...
}

And overriding of os:

pub fn @"std(get-os)"() type {
    return struct {
        // ....
    };
}
2 Likes

Am I missing something? I don’t see why @"std(get-options)" and @"std(get-os)" would need to be functions here as they take no parameters. They could equally well be defined as constants.

I think the status quo is fine for most use cases; std_options is not an identifier I think one would want very commonly.

Heck, even with your example of an enum of STD virus options, it makes more sense to use an StdOption or similar for the identifier, given types (including enums) tend to be in TitleCase.

That said, I think it does make sense to be able to pass options into the std through the build system instead. There’s still no replacement for the BYOS feature the stdlib dropped in Zig 0.12, where you could provide a backend for the OS-specific functions, by adding pub const os = @import("myOSSystem"); to main.

The language could reserve a namespace for itself: ZIG_*, for example. So you would have ZIG_std_options, ZIG_get_os, etc.

2 Likes

That’s to address the concern over “action at a distance”. These function can have comptime side-effects (additional functions getting linked in, for instance). Having that be triggered by a reference to a const is sort of spooky.

Functions with special names just feel less like a hack. We have always had them. We have main(), of course. In Windows we have WinMain() and DllMain(). Zig currently has panic().

Just how programmers perceive thing. Exposing a variable is seen as “leaking implementation” while exposing a function is “defining an interface”.

That’s to address the concern over “action at a distance”. These function can have comptime side-effects (additional functions getting linked in, for instance). Having that be triggered by a reference to a const is sort of spooky.

I don’t really see this as much of an issue in Zig. If anything, it just moves the “spooky-action-at-a-distance” into the standard library. If the standard library is to be held as an example of reasonably idiomatic Zig, then I don’t see why the spooky-action-at-a-distance should be put there rather than the root module.

Just how programmers perceive thing. Exposing a variable is seen as “leaking implementation” while exposing a function is “defining an interface”.

In more object oriented languages, sure, but in Zig that seems to be pretty normal. Take ArrayList.items, or std.heap.smp_allocator — a global singleton. The level of abstraction and indirection should fit the problem at hand, rather than conforming to arbitrary best practices that don’t fit every case. But I think I’m veering off topic now.

Well, we’re having this conversation for a reason. Pragmatics exist in programming languages just as they do in human languages. There’re certain unwritten rules that seems arbitrary but must be observed nonetheless. Like how in English we say “tall blond American actress” and never “American blond tall actress”. Logically the two are the same but the second sounds completely off.

1 Like