Tagged union panic when reading and writing same union in one assignment

Hi,

I hit a runtime panic with a tagged union and want to confirm whether this is expected based on Zig’s evaluation rules.

Given:

const State = union(enum) {
    alive: Alive,
    exploding: Exploding,
};

And:

if (bullet.state != .alive) return;

bullet.state = .{
    .exploding = .{
        .origin = bullet.state.alive.body.pos(),
    },
};

This panics at runtime:

panic: access of union field 'alive' while field 'exploding' is active

Logging shows bullet.state is alive at function entry. There is no threading and nothing else mutates bullet.state in between.

However, this works:

const exploding = Exploding{
    .origin = bullet.state.alive.body.pos(),
};
bullet.state = .{ .exploding = exploding };

It seems that Zig does not guarantee evaluation order here, and that during the first form the union tag may be updated as part of the assignment before the nested bullet.state.alive expression is evaluated.

Is that the correct interpretation?

More generally:

  • Does Zig guarantee that the RHS of an assignment is fully evaluated before the LHS is mutated?
  • Is it considered undefined / unspecified to read from x in the same expression where x is assigned a different union tag?
  • Are there any other related gotchas to be aware of?

Thanks.

This is expected. See the Result Location section of the language reference. E.g.

When compiling the simple assignment expression x = e, many languages would create the temporary value e on the stack, and then assign it to x, potentially performing a type coercion in the process. Zig approaches this differently. The expression e is given a result type matching the type of x, and a result location of &x. For many syntactic forms of e, this has no practical impact. However, it can have important semantic effects when working with more complex syntax forms.

And the swap example in that section.

It’s a subtle, but important difference to C.

Edit: ok. I’ll defer to vupesx here. Ignore the above.

1 Like

.{} writes directly to the destination, but T{} creates a new instance then copies to the destination.

For some reason, the payload is being written before the tag. Seems like a regression/unhandled edge case. Even if the payload is written first, the compiler should probably still allow it since the tag will be updated anyway.

1 Like

Thanks. So, should I raise it with ziglang on codeberg?

The bug is in your code, but it’s subtle.

Let’s start with your questions:

No. Zig uses a strict LHS-to-RHS evaluation order. Assignments happen as they are evaluated in that order.

It’s not undefined, but you can’t do what you’re trying to do.

On this line:

bullet.state = .{
    .exploding = .{ // <- now .exploding

You’ve assigned the .exploding tag to bullet.state.

Here:

        .origin = bullet.state.alive.body.pos(),

You try to read from .alive, which is no longer the tag, so Zig won’t let you. Ironically, in .ReleaseFast mode this would work: please don’t count on that.

When you use the Exploding{ form, Zig creates an entirely new struct before copying it to the destination. Less efficient (presumably) but it avoids the bad interaction.

Best is probably to cache the value you need into a temp variable, then use .{ form to assign it.

1 Like