Tagged union with comptime tag

I want to keep a log of changes to some data. This change log can contain 3 types of messages: Insert, Update, and Delete. This naturally fits into a tagged union structure:

const Change = union(enum) {
        Insert: struct { index: u32, value: Value },
        Update: struct { index: u32, value: Value },
        Delete: struct { index: u32 },
};

However, at comptime, the type of the change (aka the tag) is always known! Imagine that there is an API function that corresponds to each change to make this more apparent (see example logInsert function below).

const ChangeLog = struct {
  // Implementation omitted for brevity

  pub fn logInsert(self: *Self, index: u32, value: Value) void {
      // `logChange` writes the change to the log
      self.logChange(.{ .Insert = .{ .index = index, .value = value } });
  }

  fn logChange(self: *Self, change: Change) void {
      // not shown
  }
}

It is theoretically possible for the logChange function to know the tag of the change at comptime and therefore emit a specialized function for each type at change during compilation. Is there a way to do this using a tagged union?

I know it can be accomplished the following way but it feels less straightforward than using a tagged union:

// Assume there is an enum type `ChangeCode` that is the tag type of the `Change` union
fn logChange(self: *Self, comptime code: ChangeCode, payload: ChangePayload(code)) void {
    // not shown
}

fn ChangePayload(comptime code: ChangeCode) type {
    return switch (code) {
        .Insert => struct { index: u32, value: Value },
        .Update => struct { index: u32, value: Value },
        .Delete => struct { index: u32 },
    };
}

I am not quite sure whether this is what you want.

What you write sounds like you want to generate separate functions at compile time.
To me that seems like overkill, I think using the log function in my example below, with the tag would satisfy me.

If you really want to use separate functions, I think you would need to generate source code at build time.
(I am not completely sure on that, but it seems to me, that you can’t easily generate completely new function declarations with comptime)

Here is a suggestion, that just uses the tag as a comptime parameter:

const std = @import("std");

const Value = []const u8;
const Change = union(enum) {
    const Self = @This();
    const Tag = std.meta.Tag(Self);

    Insert: struct { index: u32, value: Value },
    Update: struct { index: u32, value: Value },
    Delete: struct { index: u32 },

    pub fn format(
        self: Self,
        comptime fmt: []const u8,
        options: std.fmt.FormatOptions,
        writer: anytype,
    ) !void {
        _ = fmt;
        _ = options;
        switch (self) {
            .Insert => |s| try writer.print("Insert {d} {s}", .{ s.index, s.value }),
            .Update => |s| try writer.print("Update {d} {s}", .{ s.index, s.value }),
            .Delete => |s| try writer.print("Delete {d}", .{s.index}),
        }
    }
};

const ChangeLog = struct {
    const Self = @This();

    const Changes = std.ArrayList(Change);
    changes: Changes,

    pub fn init(allocator: std.mem.Allocator) Self {
        return .{ .changes = Changes.init(allocator) };
    }
    pub fn deinit(self: *Self) void {
        self.changes.deinit();
    }

    pub fn logChange(self: *Self, change: Change) !void {
        try self.changes.append(change);
    }
    pub fn log(self: *Self, comptime tag: Change.Tag, payload: std.meta.TagPayload(Change, tag)) !void {
        try self.logChange(@unionInit(Change, @tagName(tag), payload));
    }
};

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    const allocator = gpa.allocator();
    defer {
        switch (gpa.deinit()) {
            .leak => @panic("leaked memory"),
            else => {},
        }
    }

    var changes = ChangeLog.init(allocator);
    defer changes.deinit();
    try changes.log(.Insert, .{ .index = 4, .value = "foo" });
    try changes.log(.Update, .{ .index = 4, .value = "bar" });
    try changes.log(.Delete, .{ .index = 4 });

    for (changes.changes.items) |c| {
        std.debug.print("{}\n", .{c});
    }
}
3 Likes

I was just about to reply in a similar line to what @Sze replied, but just leaving it at the use of methods for unions. So maybe just by doing this:

const Change = union(enum) {
    Insert: struct { index: u32, value: Value },
    Update: struct { index: u32, value: Value },
    Delete: struct { index: u32 },

    fn log(self: Change) void {
        return switch (self) {
            .Insert => |i| writeToLog("Insert: {}, {}", .{ i.index, i.value }),
            .Update => |u| writeToLog("Update: {}, {}", .{ u.index, u.value }),
            .Delete => |d| writeToLog("Delete: {}", .{d.index}),
        };
    }
};

you can then just call log on any Change handed to you, and it’ll do the right thing for each tag.

3 Likes

Aha! std.meta.TagPayload(Change, tag) is exactly what I was looking for, thank you. Then it’s possible to have the tagged union and “split” it into tag and payload (and allow the tag to be comptime as you have shown).

And to clarify, when I said “emit a specialized function” I meant through monomorphism, not fully generating one via comptime.

1 Like