Zig NEWS

Cover image for When should I use an UNTAGGED Union?
Loris Cro
Loris Cro

Posted on • Updated on

When should I use an UNTAGGED Union?

The basics of Tagged Unions

Unions are a language construct that allows you to reutilize the same variable to hold different types at different times (ie only one at a time, if you want something that lets you put more than one type in a variable at the same time, then you're looking for a struct).

To know which type is there at a given moment, you usually want to the union to be tagged, meaning that internally it also holds an enum field that can be inspected at runtime.


// A Zig tagged union
const MyTaggedUnion = union(enum) { // note the `(enum)` part
   my_string: []const u8,
   my_bool: bool,
};

test MyTaggedUnion {
   // init
   var foo = MyTaggedUnion { .my_string = "foo" };

   // swap the active field
   foo = .{ .my_bool = true };

   // inspect the active field at runtime
   switch (foo) {
      .my_string => |str| {
         std.debug.print("string: {s}\n", .{str});
      },
      .my_bool => |b| {
         std.debug.print("bool: {}\n", .{b});
      },
   }
}

// Conceptually a tagged union has this structure:
// struct { 
//    active_field: union { ... }, // note the lack of `(enum)`
//    active_tag: enum { ... },
// };
Enter fullscreen mode Exit fullscreen mode

Untagged unions

An untagged union has no active_tag field, making it not possible for code to inspect at runtime which is the active field.

In C untagged unions are often used to provide "aliases" / alternative "views" into the data stored in one variable, to do things like this:

const Vector = extern union {
    v: extern struct {x: f32, y: f32, z: f32},
    arr: [3]f32,
};

test Vector {
    const vec1: Vec = .{ .v = .{.x = 0.1, .y = 0.2, .z = 0.3}};
    const vec2: Vec = .{ .arr = .{1.0, 2.0, 3.0}};
    std.debug.print("{} {} {}\n{any}\n{} {} {}\n{any}\n", .{
        vec1.v.x, vec1.v.y, vec1.v.z, vec1.arr,
        vec2.v.x, vec2.v.y, vec2.v.z, vec2.arr,
    });
}
Enter fullscreen mode Exit fullscreen mode

(code taken from this reddit comment)

But as you can see in the code example above, this in Zig requires using extern union, which is the C ABI compatible kind of union.

In normal (ie not extern) unions, trying to access inactive fields is safety-checked UB (ie you will get a panic in safe modes, and potentially read garbage in ReleaseFast and ReleaseSmall).

So how can you use a normal Zig untagged union?

One simple answer is that sometimes you can know which is the active field by reading other data that you keep somewhere else, meaning that you sill have a way of doing inspection at runtime, just through a different mechanism specific to your application.

Usually the basic example looks something like this:

// bar.pos is set when foo > 0
// bar.neg is set when foo < 0
// neither is set when foo == 0
struct {
    foo: i32,
    bar: union {
        pos: bool,
        neg: u32,
    },
};
Enter fullscreen mode Exit fullscreen mode

(code taken from this reddit comment)

In this kind of example you basically have another variable that acts (at least in some contexts) as the union's tag.

Let's see a more interesting use case.

A Data Oriented Design Use Case

This is an example taken from one piece of logic in Autodoc (the piece of the Zig compiler that builds this kind of stuff) that existed at some point. To be clear, the logic still exists, but it's now a bit more complicated than how I'm going to present it here.

Ref paths

One very common Zig operator that Autodoc really cares about is the dot. Specifically in the context of doing things like Foo.Bar.baz.

Autodoc wants to know what's Foo, whats Bar, and also what's baz, and to do so it needs to analyze each component of this ref path (called like this because each component is a Ref in ZIR) and use that information to finally be able to provide a link to the definition of baz to the user.

For Autodoc the starting point is (simplifying a little) this:

const string_table = "Foo\0Bar\0baz\0AndManyMoreStrings\0";
const ref_path = [3]u32 {0, 4, 8};
Enter fullscreen mode Exit fullscreen mode

So we have a string table and an array of indexes pointing at where the corresponding name is in the string table.

Autodoc's job is to take those indexes, grab the full string (leveraging the null terminator), look up into the current scope what those names refer to, and then transform that array into an array of indexes inside our analyzed data, like so:

const result: [3]u32 = undefined;
for (ref_path, 0..) |name_idx, i| {
   const name = string_table.nullTerminatedString(name_idx);
   const type_idx = current_scope.findName(name) orelse @panic("uh oh");
   result[i] = type_idx;
}
Enter fullscreen mode Exit fullscreen mode

The real code has one big complication though:

A ref path might be referring to a decl that we haven't analyzed yet. This is a very common situation since in Zig you're allowed to refer to things out-of-order in declarative scopes:

const Foo = Bar.Baz; 

// Bar is not yet defined (nor Baz) if you read / analyze 
// the code in-order, but it's fine to do in Zig

const Bar = struct { const Baz = void; };
Enter fullscreen mode Exit fullscreen mode

This means that Autodoc needs to resolve decl paths "a bit at a time", resolving the names that it already knows, and pausing the resolution of a ref path until analysis progresses as needed.

Additionally, real ref paths have variable length so heap allocation is required (instead of just hardcoding [3]u32), which finally brings me to the core of this example.

To avoid making unnecessary allocations in Autodoc, my original code modified the array in-place, progressively translating each "index-into-the-string-table" into an "index-into-the-analyzed-types-array", kinda like so:

const string_table = "Foo\0Bar\0baz\0AndManyMoreStrings\0";
const ref_path = [3]u32 {0, 4, 8};

fn analyzeRefPath(
   decl_path: []u32, 
   next_component_to_analyze: usize
) void {
   for (ref_path[next_component_to_analyze..], 0..) |name_idx, i| {
      const name = string_table.nullTerminatedString(name_idx);
      const type_idx = current_scope.findName(name) orelse {
         async call_me_maybe(analyzeRefPath, decl_path, i);
         // I actually don't use async, but you get the idea:
         // first I do some setup to resume from where we left
         // off, and then return.
         return; 
      };

      // This is where the transformation happens!
      ref_path[i] = type_idx;
   }
} 
Enter fullscreen mode Exit fullscreen mode

One day I was chatting with Andrew and he suggested to change the type of ref_path like so:

const Component = union {
   name_idx: u32,
   type_idx: u32,
};

ref_path: []Component
Enter fullscreen mode Exit fullscreen mode

What's the avantage of doing this?

It catches any logic bug where I mess up the next_component_to_analyze index and start mis-interpreting what a component contains. Maybe an off-by-one error might make me assume that an index into the string table is an index into the array of analyzed types.

This works because, as I mentioned before, accessing the wrong field in the union will cause a panic in safe modes:

for (ref_path[next_component_to_analyze..], 0..) |comp, i| {
   // This will panic if we did something wrong 
   // with our indexes:
   const name_idx = comp.name_idx;
   const name = string_table.nullTerminatedString(name_idx);

   // ...

   // In fact, I tricked you, the code I showed 
   // you before does make bad use of indexes and 
   // *will* cause a panic, especially if we make our
   // main invariant explicit in an assert:

   std.debug.assert(ref_path[i] == .name_idx); 

   // (our invariant is that the current index is always
   // a Component.name before we write to it)

   ref_path[i] = .{ .type = type_idx }; 
}
Enter fullscreen mode Exit fullscreen mode

How does this work? The trick is that in safe modes untagged unions actually are secretly tagged, and that's how the compiler knows when you mess up.

This means that untagged unions let you transform a potentially confusing problem (misinterpreting data) into a (runtime) type problem, which is especially handy when doing data-oriented design.

This trick has saved me plenty of times when consuming the compiler's ZIR (Zig's Intermediate Representation).

So there you have it, one interesting use of untagged unions that gives you a much better developer experience at true zero cost for the final executable.

Solution to the index problem (click to expand)
In the code examples above I left on purpose a bug related to indexes. The mistake is that I first slice ref_path and then use indexes relative to that smaller slice to write into the full ref_path itself, resulting in a write into the wrong slot.

The wrong write could be solved by accepting comp by reference:

for (ref_path[next_component_to_analyze..], 0..) |*comp, i| {
   // ...
   comp.* = .{ .type_idx = type_idx };
}
Enter fullscreen mode Exit fullscreen mode

But then we would also have to remember to offset i properly when writing it down in the "async" part of the code:

const type_idx = current_scope.findName(name) orelse {
   const my_number = next_component_to_analyze + i;
   async call_me_maybe(analyzeRefPath, decl_path, my_number);
   // I actually don't use async, but you get the idea:
   // first I do some setup to resume from where we left
   // off, and then return.
   return; 
};
Enter fullscreen mode Exit fullscreen mode

In my opinion the best solution would be to make sure to always iterate using absolute numbers, so that you don't have to remember when an index is relative and when it's not.


for (next_component_to_analyze..ref_path.len) |comp_idx| {
   const comp = &ref_path[comp_idx]; // note how this is a pointer
   const name_idx = comp.name_idx;

   // ...

   comp.* = .{ .type_idx = type_idx };
}
Enter fullscreen mode Exit fullscreen mode

Guess how I came up with this example :^)


Top comments (1)

Collapse
 
jnordwick profile image
Jason Nordwick • Edited

I think one of the biggest uses would be arrays of unions. If you keep the tag with the Union you're going to pay up to 7 bytes padding per struct. If you keep the discriminant separate in its own array you can eliminate the padding then you have an array of descriminants and array of untagged unions. Or if you keep in their own array of possible you can entirely get rid of the per struct tag. Semantically these are all tagged unions they just store the tag in different locations