Field defaults

std.Io.Mutex is intended to be initialized with a declaration literal:

my_mutex: Mutex = .init;

An alternative design would be to make the following work:

my_mutex: Mutex = .{};

And this is how it used to work.

What are the consequences of these different design choices? Why should I prefer one over the other?

2 Likes

Default fields make sense when you can change fields independently of each other.
But if doing so can cause an invalid state, a declaration is preferred as it prevents that.

Declarations also provide a clear name to the state, there can be multiple, and can coexist with default fields.

5 Likes

But I guess in this case these two don’t really apply?

There’s just .init, and it is called .init, rather than .unlocked. And Mutex has just a single field internally.

1 Like

For that specific question, it shouldn’t be .unlocked because a user shouldn’t do, for example:

my_lock.lock();
defer my_lock = .unlocked;

That is, .init should only be used for initialization, so it is named as such.

In general, it seems the consensus of the core team is that default fields are only the way to do things in a few specific cases, and initialization of variables tends to not be one of them. The main “rule” is that you should not use default fields if setting one of the fields but not another has the potential for the struct to be initialized in an invalid state. This has cascaded in a general style change of using decl literals for “default” initialization across the std, which includes the mutex. Another advantage is that this gives an explicit API surface point of “this is how you initialize this” instead of just knowing that an empty initialization (.{}) is a usable construct.

As far as I understand it, the only reason default field values are still in the language at all is because they’re really useful for options structs.

12 Likes

Something that might remove the need for defaults is if you could do .default with .{ .foo = 10 }. I’m not sure the caveats and footguns of a system like that, but in the very least it’s trivially implementable in userspace.

2 Likes

For uniformity sake, can we always make init a function? Making it an expression sometimes just increases what people have to remember.

I would argue a function adds more cognitive overhead as functions imply running logic. If it just returns constant data why should it be a function instead of a constant?

4 Likes

They would be better to clarify than I, but I understood it as keeping the convention of the name init for a function, and use another name if it is a constant.

Why? You can clearly tell if something is or isn’t a function call, and if it’s a declaration literal it is not allowed to be a function unless you are calling it (and it returns the type it is in).

2 Likes

I think the issue here is where you’re working with something like a library you’re not super familiar with, and you want to know whether you can do = .init; without having to see compiler output.
Obviously, it’s a more pronounced issue if you don’t have an IDE with autocompletion.

Agreed. The same issue exists with deinit too, often the only clue you need to .deinit(...) is that the function exists, and sometimes you only need to call it if you’ve not used an arena, plus it’s plausible the struct’s internals may add or change that requirement in the future. This is, unfortunately, an evil of RAII and is best avoided if possible.

When not avoidable I’ve been experimenting with conventions to hint towards correct usage based on how parts of std are doing it, along the lines of:

  • .init(...) indicates no sane default state exists without runtime logic, and that .deinit(...) exists but is only required if some other action has taken place on the struct that can have taken resource ownership
  • .empty/.default/.init similarly indicate an optional .deinit(...) exists. The exact keyword is chosen for readability (std.Io.Mutex wouldn’t really make sense being “empty” or “default”)
  • .new(...) or .create(...) indicate that .deinit(...) is required (e.g. it closes a file)
  • None of the above existing indicates that .{} is allowed and no deinit exists
7 Likes

agree, init is usually suppose to be “idempotent” and used to initialize something, I don’t think a function is necessary if it’s in the form of Foo.init() then it’s better to do const foo : Foo = .init; the function call is pointless since there’s no value to tweak the init with. I don’t think it should confuse anyone.

1 Like

Expression contains logic too. The only difference is that an expression is required to be comptime known. Most of the times, this fact is irrelevant on the consumption side. Consider the following:

fn foo() void {
    const bar: Bar = .init();
    // ...
}

Why should foo care whether the result from init is always the same? The function should just work with whatever it receives. What is matter here is whether bar is invariant within the scope of the function. What happens beyond that, what init returns at a different time or a different place is totally irrelevant.

Forcing code to become dependent on incidental implementation details creates problems down the line. Suppose we need to change the initialization of Bar such that it’s slightly different when an env variable is set. If we had gone with an expression we would to make changes everywhere where it’s used.

1 Like

that’s a fair point, when I think of a const bar : Bar = .init; I’m thinking this initialization is basically just the only reasonable value this can be. For example if you make a special counter, the only reasonable value is 0. or some neutral context fitting value. Same for many thing and in those cases, I think an init decl, makes more sense than a init function. But having said that there’s an argument, to name decls something else than init like .empty or .default which is a fair argument.

2 Likes

I think in this specific Mutex example, the type of the state field is not a primitive data or some sorts of simple data. It is an atomic Value with an enum type, which is a type function, and it also needs to init. You are not suppose to manually mutate the state atomic value. Even if you do, you will need an atomic operation. So for consistency, the Mutex may also want to have an init. Init also implies that you are going to call it at the beginning, and only once.

It’s also worth to mention that, literally, Zig allows you to do anything, but also Zig’s missions includes educating, reusable codes, and improving safety, so it’s good to have the standard libraries including some information or implication to guild the users to do the right thing. Let’s say if there is no init value or init() function, the users may do var my_mutex: Mutex = .{ .state = .init(.locked) };… This may cause problems, and this doesn’t look very right, right?

Simpler examples may use default, and it implies the users may directly change the fields by using any sorts of normal operation:

pub const Monster = struct { hp: i64  = 100, mp: i64 = 100};

For those simple things and I want to mutate to values whenever in whatever ways I want, then I would probably go with default.

1 Like

init should be always a function because deinit is always a function. Maintaining that symmetry makes std simpler. If you want to use an expression, use a different word. init is taken and it’s short for initialize. What does init stand for when you do const bar: Bar = .init? You say initializers, but then what the heck does “bar is its initializers” mean?

2 Likes

I like const foo = Foo.init more than const foo = Foo.init() for a very simple reason: It makes clear that the initialization data is comptime known. This has a few advantages:

  1. It makes the job of the compiler easier because it doesn’t need to that many optimization passes on it and just puts the resulting thing into .rodata and is done with it. It may even be able to do better optimizations, in matter of execution speed, by inlining this into .text directly near where it is used(though I personally don’t like putting data in .text) or realizing that it’s just a bunch of zeroes.
  2. It makes the compilers job especially easier when Foo is generic. Because the compiler can more easily realize that all the instances of .init across the different monomorphizations of Foo are the same and just collapse them into one while doing semantic analysis(maybe even earlier I don’t quite know how Zig does it).
  3. It makes the job of the programmer easier because they know that the initialization value doesn’t depend on any runtime global state[1].
  4. It makes the job of the programmer easier because they can’t inadvertently introduce bugs by changing the initialization to depend on runtime global state. Because if they did the compiler would tell them so.

  1. Of course assuming no mmaping read-only things as read-write and changing it or stuff like that. ↩︎

8 Likes

I read it as initialized, as in an initialized value of Bar. :person_shrugging:

3 Likes

Okay, let us goes with that. So init stands for initialized. Now, let us replace the abbreviation with the full word. In theory, that’d make our code clearer, right?

fn foo() void {
    const bar: Bar = .initialized;
    // ...
}

Is programmer’s intention in the above code clear? The answer is no, because the past-participate is often used for bit flags or enum. The reader is more likely to think that Bar is something like:

const Bar = enum { uninitialized, initialized, deinitialized };

We only know .init is not actually an enum literal because init exists as a function in other contexts (where arguments are required, for instance). It’s a construct that relies on its hypothetical alternative. It’s convoluted and stupid.

I don’t feel strongly about it, but I do tend to agree, and use names such as default, empty, or whatever makes sense in the context, but avoid init.
I just feel it clashes with the init/deinit and create/destroy conventions, and there seems to always be a better/equal alternative. There is probably some situation out there where that isn’t the case, but I have yet to encounter it.

2 Likes