Accessing the field of a struct by name at runtime - Why is indexing the fields array not allowed?

I’ve been learning zig, and while I’ve been able to understand it’s quirks, especially around comptime, this one has me confused.

Can anyone tell me why this is allowed:

const A = struct {
    a: u32 = 0,
    b: u32 = 0,
    c: u32 = 0,
    d: u32 = 0,
};

// This function compiles fine
fn set_by_index(s: *A, index: u32, value: u32) void {
    const Ti = @typeInfo(A).@"struct";
    inline for (Ti.fields, 0..) |f, i| {
        if (i == index) {
            @field(s, f.name) = value;
        }
    }
}

While this is not allowed:

// error: values of type 'builtin.Type.StructField' must be comptime-known, but index value is runtime-known
// const f = Ti.fields[index];
//                     ^~~~~
//
fn set_by_index_bad(s: *A, index: u32, value: u32) void {
    const Ti = @typeInfo(A).@"struct";
    const f = Ti.fields[index];
    @field(s, f.name) = value;
}

I came about this looking for a better/faster method of accessing a struct field by name at runtime. I’m sure everyone’s seen the classic “inline loop over the struct fields with mem.eql“:

// Just loops over the fields...
fn set_by_name(s: *A, fname: []const u8, value: u32) void {
    const Ti = @typeInfo(A).@"struct";
    inline for (Ti.fields) |f| {
        if (std.mem.eql(u8, f.name, fname)) {
            @field(s, f.name) = value;
        }
    }
}

While this is the best I’ve come up with as an improvement:

// It still loops over the fields, but by checking the index, not doing a string compare
// hopefully it is optimized into a lookup at compile time
fn set_by_name_faster_maybe(s: *A, fname: []const u8, value: u32) void {
    const Ti = @typeInfo(A).@"struct";

    // setup static string map
    comptime var map_data: [Ti.fields.len]struct { [:0]const u8, usize } = undefined;
    inline for (Ti.fields, 0..) |f, i| {
        map_data[i][0] = f.name;
        map_data[i][1] = i;
    }
    const map = std.StaticStringMap(usize).initComptime(map_data);

    const idx = map.get(fname) orelse return;
    set_by_index(s, @intCast(idx), value);
}

Does anyone have any better/cleaner ways to do this?

When doing an inline loop, the captures are comptime known, which is why your first example compiled. Same goes for inline prongs in switch.
Your last example is the standard way of doing field access at runtime.

1 Like

The first example works because the inline for loop is expanded at compile time, it becomes a long chain of if statements:

const A = struct {
    a: u32 = 0,
    b: u32 = 0,
    c: u32 = 0,
    d: u32 = 0,
};

fn set_by_index(s: *A, index: u32, value: u32) void {
    const Ti = @typeInfo(A).@"struct";
    if (i == 0) {
        @field(s, "a") = value;
    }
    if (i == 1) {
        @field(s, "b") = value;
    }
    if (i == 2) {
        @field(s, "c") = value;
    }
    if (i == 3) {
        @field(s, "d") = value;
    }
}

and then each @field expression is resolved correctly.

The fields array used in the second example contains types and thus cannot be used in runtime, because we don’t have a runtime representation for all of the types.

1 Like

Ah, I missed that one of the fields was a type.

There is one open proposal for switching the @typeInfo return value to a struct of array, allowing your second example (with trivial modifications) to compile.

2 Likes

That would be a nice change, but it probably wouldn’t help in this case, because @field needs a comptime-only string. Accessing an array of strings by runtime-index will only give runtime string. Or have I missed something there?

The array of strings could then be stored in the executable directly without creating it in user code.