How to construct tagged union through C-ABI?

I’m writing a Zig library that additionally exposes a C-ABI library. Part of the public API is a tagged union. How would I include this union in the C-compatible API?

Obviously I can’t just export the type, so my thought was to have the external functions take in just the tag and an arbitrary payload that I would simply bit-cast to the union payload type. However, I can’t find a way to initialize a union from a tag value that isn’t comptime-known. If it’s comptime-known, I could use @unionInit(Union, @tagName(tag), ...) but of course it can’t be comptime-known. I also can’t just bit-cast the tag (and the payload), because tagged unions can’t participate in bit-casts as they can’t be marked packed or extern.

I’m a bit stuck now and would love some help. Feel free to tell me if you think my approach is entirely wrong, I’m open for feedback!

Stripped-down sample code:

pub const Symbol = union(enum(u32)) { // tag type is C-ABI-compatible, but union isn't
    a: u32,
    b: i8,
    d: void,
}

export fn send_symbol(symbol: Symbol) c_int {
    // Symbol can't be used in export fn
}

export fn send_symbol(tag: @typeInfo(Symbol).@"union".tag_type.?, payload: u32) c_int {
    // what now?
}

You can use inline else to make it comptime-known:

switch (tag) { // tag is runtime known here
    inline else => |t| { // t is comptime known
        @unionInit(Union, @tagName(t), ...);
4 Likes

You can use an extern union to get a c compatible union, it is ofc not tagged. I’d probably make an alias for the tag, and an extern struct to hold the tag and extern union.

For converting back to zig land: you just need to make the tag comptime known, you do this by making a branch that will only execute if the runtime value matches a comptime known value.

// the naive interpretation
if (tag = .a) return .{ .a = val };
// you'd probably use a switch
switch (tag) {
    .a => return .{ .a = val.a }, // assuming val is an `extern union`
    .b => return .{ .b = val.b },
    .c => return .{ .c = val.b },
}

But that is quite tedious, fortunately zig has a way to do this dynamically at comptime:

switch (tag) {
    inline else => |t| @unionInit(Symbol, @tagName(t), @field(val, @tagName(t))),
}

Explanation: switches can capture the value they operate on, even when operating on tagged unions you can |payload, tag|.

When the prong has a single value .a => |t| then t is comptime known to be .a, but if there are multiple values .a, .b => |t|/else then t is runtime known.

Fortunately you can use inline to make a single value copy of the prong for each possible value, making t comptime known.

on tagged union capture |payload, tag|, the payload capture is always runtime known, unless the switch(expr) expression is completely comptime known

2 Likes

How else than through comptime magic?

const std = @import("std");

pub const Symbol = union(enum(u32)) { // tag type is C-ABI-compatible, but union isn't
    a: u32,
    b: i8,
    d: u0,
	
	/// Create and return a function that accepts a symbol from the C ABI.
	pub fn send(comptime field_name: []const u8) FieldFunctionType(field_name) {
		const acceptor = struct{
			pub fn accept(payload: @FieldType(Symbol, field_name)) callconv(.c) c_int {
				std.log.err("Payload type: {s}, Payload value: {d}", .{@typeName(@TypeOf(payload)), payload});
				return 0;
			}
		};
		
		// Prevent symbol collision by exporting with unique names
		@export(&acceptor.accept, .{.name = "send_symbol_" ++ field_name});
		return acceptor.accept;
	}
	
	pub fn FieldFunctionType(comptime field_name: []const u8) type {
		return fn(@FieldType(@This(), field_name)) callconv(.c) c_int;
	}
};

test Symbol {
	// Equivalent to "send_symbol_a(33)" called from a C library
	_ = Symbol.send("a")(33);
	_ = Symbol.send("b")(-1);
	// void as a function argument is incompatible with the C ABI; instead use u0
	_ = Symbol.send("d")(0);
}

Since we’re exporting a unique function for each field name, we actually can use @unionInit(), since the field name is comptime known.

Thank you guys so much!

I ended up going with @tholmes’ solution since it made the most sense in my case to have separate functions for each kind of Symbol. I modified it a bit and it now correctly handles both void payloads, as well as odd integer payloads (one of them is a u21, for example). It does of course mean that the library ends up having way more functions than I was planning on, but since they’re all named in a predictable way I think it’s fine. For context: my actual Symbol union currently contains 65 entries and it’ll grow bigger in the future still :smiley:

This is the relevant part of the resulting code:
Notable differences to the condensed code from the question are that instead of one send function, there’s a press and a release and the fact that these return Result (but that’s just another name for c_int actually).

comptime {
    for (@typeInfo(Symbol).@"union".fields) |field| {
        const Functions = blk: switch (@typeInfo(field.type)) {
            .void => struct {
                pub fn press() callconv(.c) Result {
                    return wrap(auto.pressSymbol(@unionInit(Symbol, field.name, void{})));
                }
                pub fn release() callconv(.c) Result {
                    return wrap(auto.releaseSymbol(@unionInit(Symbol, field.name, void{})));
                }
            },
            .int => |int| {
                // raise the bits to a power-of-2 value, then later cast it down again:
                const T = @Type(.{ .int = .{ .signedness = int.signedness, .bits = std.math.ceilPowerOfTwo(u16, int.bits) catch unreachable } });
                break :blk struct {
                    pub fn press(payload: T) callconv(.c) Result {
                        return wrap(auto.pressSymbol(@unionInit(Symbol, field.name, std.math.cast(field.type, payload) orelse std.builtin.panic.integerOutOfBounds())));
                    }
                    pub fn release(payload: T) callconv(.c) Result {
                        return wrap(auto.releaseSymbol(@unionInit(Symbol, field.name, std.math.cast(field.type, payload) orelse std.builtin.panic.integerOutOfBounds())));
                    }
                };
            },
            else => |t| @compileError("Unhandled case " ++ @tagName(t)),
        };

        @export(&Functions.press, .{ .name = "auto_press_symbol_" ++ field.name });
        @export(&Functions.release, .{ .name = "auto_release_symbol_" ++ field.name });
    }
}
1 Like