Getting the name of a struct field @runtime

I’ll try and explain what I need to get working.
In the base code I work with the Terms array.

Now I am making a function which has to extract the name of one of the arrays, when passing the address of one ScorePair argument using the global var terms.

pub fn using_scorepair(sp: *const ScorePair) void {
    // in which array resides this sp argument (kind of doable if the compiled order is the same)
    // what is the declaration name of that array inside Terms (without if if if if)
}

pub const terms: *const Terms = &default_terms; // default_terms not included here.

pub const Terms = struct {
    piece_value: [6]ScorePair,
    king_passed_pawn_distance: [8]ScorePair,
    enemy_king_passed_pawn_distance: [8]ScorePair,
    pawn_phalanx: [8]ScorePair,
    passed_pawn: [8]ScorePair,
    protected_pawn: [8]ScorePair,
    doubled_pawn: [8]ScorePair,
    isolated_pawn: [8]ScorePair,
    backward_pawn: [8]ScorePair,
    king_cannot_reach_passed_pawn: ScorePair,
    bishop_pair: ScorePair,
    tempo: ScorePair,
    knight_mobility: [9]ScorePair,
    bishop_mobility: [14]ScorePair,
    rook_mobility: [15]ScorePair,
    queen_mobility: [28]ScorePair,
    attack_power: [6][8]ScorePair,
    // some more....
};

pub const ScorePair = struct {
    mg: i16,
    eg: i16,
};

Probably can’t be done without a pointer to Terms, but if that’s fine, I came up with this comptime monster:

pub const Terms = struct {
    piece_value: [6]ScorePair,
    king_passed_pawn_distance: [8]ScorePair,
    enemy_king_passed_pawn_distance: [8]ScorePair,
    pawn_phalanx: [8]ScorePair,
    passed_pawn: [8]ScorePair,
    protected_pawn: [8]ScorePair,
    doubled_pawn: [8]ScorePair,
    isolated_pawn: [8]ScorePair,
    backward_pawn: [8]ScorePair,
    king_cannot_reach_passed_pawn: ScorePair,
    bishop_pair: ScorePair,
    tempo: ScorePair,
    knight_mobility: [9]ScorePair,
    bishop_mobility: [14]ScorePair,
    rook_mobility: [15]ScorePair,
    queen_mobility: [28]ScorePair,
    attack_power: [6][8]ScorePair,
    // some more....
};

pub const ScorePair = struct {
    mg: i16,
    eg: i16,
};

fn isScorePairType(T: type) bool {
    if (T == ScorePair)
        return true;

    return switch (@typeInfo(T)) {
        .array => |array| isScorePairType(array.child),
        else => false,
    };
}

fn scorePairName(terms: *const Terms, pair: *const ScorePair) []const u8 {
    const names = comptime blk: {
        var names: [@sizeOf(Terms) / @sizeOf(ScorePair)][]const u8 = @splat("UNKNOWN");
        for (std.meta.fields(Terms)) |field| {
            if (!isScorePairType(field.type))
                @compileError("Field '" ++ field.name ++ "' is not a (array of) ScorePair");

            const start_idx = @offsetOf(Terms, field.name) / @sizeOf(ScorePair);
            const end_idx = start_idx + @sizeOf(field.type) / @sizeOf(ScorePair);

            names[start_idx..end_idx].* = @splat(field.name);
        }
        break :blk names;
    };

    const terms_arr: []const ScorePair = @ptrCast(terms);
    std.debug.assert(@intFromPtr(pair) >= @intFromPtr(terms_arr.ptr));
    const idx = pair - terms_arr.ptr;
    std.debug.assert(idx < terms_arr.len);
    return names[idx];
}

pub fn main() void {
    const some_terms: Terms = undefined;
    std.debug.print("{s}\n", .{scorePairName(&some_terms, &some_terms.isolated_pawn[3])});
}

const std = @import("std");

EDIT: typo (as pointed out by @ericlang below)

1 Like

That looks like a working comptime monster. I don’t care if it is monster :slight_smile:
Thx. I’ll try and go from this one.

1 Like

Working! Many thanks.

register us black .{ .mg = 11, .eg = 75 } times 1 index = 35 array = passed_pawn

one change: use of >=

std.debug.assert(@intFromPtr(pair) >= @intFromPtr(terms_arr.ptr));
1 Like

Ah, of course, that’s a typo :slight_smile:

I’ll fix that in my comment

Why not use an array with one items instead of using sometimes arrays, sometime the struct directly ? It would reduce the complexity of your monster, with no runtime costs since the array will be embedded in the struct anyways.

I think you’re reading it incorrectly? It’s actually always just an O(1) array access at runtime. The comptime monster above is just code that generates that array.

EDIT: Oh I’m actually the one reading your comment incorrectly. I can’t just use a flat array of ScorePair, I think? I need to get the field names from somewhere.

Sorry for the misunderstanding, I was thinking of code complexity rather than runtime complexity.

Hmm, some of the code doesn’t actually need to be there, but it checks the assumptions that make it work. Specifically that the parent struct only consists of fields of type ScorePair or (n-dimensional) arrays thereof, and that we haven’t been given a bogus pointer to ScorePair.

I could have made the code generic (while actually being simpler, code-wise), using an array that maps to individual bytes of the struct. The field types wouldn’t matter then, but the array would be 4x bigger.

Come to think of it, it still makes an assumption that Zig will actually just pack the structs together and not add some magic u16s in between, which would throw off the alignment. It would probably be better to change Terms and ScorePair into extern structs to guarantee the assumed layout (should have no runtime cost, as the structs are basically arrays with named fields).

That is very true. Currently everything aligns by luck.
And what if the struct needs types which are not allowed in them? Like a zig-enum.
Is a struct actually controllable regarding layout? Without packing.

No, the layout of a Zig struct is not controllable, basically whatsoever, apart from being able to use align to control the alignment of certain fields – it’s explicitly made so Zig can do whatever to make the layout optimal.

Controlling the layout is what extern struct is for. As for enum, I believe if you set a tag type for them (e.g. enum(u8)), you can then put them in extern structs.


Well, there are multiple solutions:

  • If extern struct is good enough for you, then you just change these structs to that and use the original code I put here;
  • OR, you can set ScorePair’s alignment to align(4), which should make the original assumption hold as well, I believe;
  • OR, you can edit the code so that it actually only assumes u16’s natural alignment (makes the array 2x bigger);
  • OR OR, make it generic, as I mentioned above;
1 Like

wow i did not know that.

zig still has to respect the alignment of fields, including the default type alignment, otherwise you couldn’t reliably get a valid pointer to fields since zig pointers depend on a comptime known (minimum) alignment.

Modifying the code to not rely on the layout is a preferable solution over making the type extern.

I’ll make a fixed version tomorrow, if no one else has

1 Like

Yes, but my original solution relies on ScorePair being aligned to 4 bytes (as it would in an extern struct), while it’s only guaranteed to be aligned to 2.

I think you can reduce the static executable size by splitting it into two arrays one with the strings and another with the indices which then can use a smaller type.

Here is a godbolt comparison Compiler Explorer

fn scorePairName(terms: *const Terms, pair: *const ScorePair) []const u8 {
    const names = std.meta.fieldNames(Terms);
    const indices = comptime blk: {
        var count: usize = 0;
        for (std.meta.fields(Terms)) |field| {
            const FT = if (field.type == ScorePair) [1]ScorePair else field.type;
            if (@typeInfo(FT) != .array)
                @compileError("Field '" ++ field.name ++ "' is not a (array of) ScorePair");

            count += @as(FT, undefined).len;
        }
        if (count == 0) @compileError("Empty struct not supported");

        const Index = std.math.IntFittingRange(0, count - 1);
        var indices: [count]Index = undefined;
        var current: Index = 0;
        for (std.meta.fields(Terms), 0..) |field, i| {
            const FT = if (field.type == ScorePair) [1]ScorePair else field.type;

            const start_idx = current;
            current += @as(FT, undefined).len;
            const end_idx = current;

            indices[start_idx..end_idx].* = @splat(i);
        }
        break :blk indices;
    };

    const start = @intFromPtr(terms);
    const current = @intFromPtr(pair);
    std.debug.assert(current >= start);
    const idx = (current - start) / @sizeOf(ScorePair);
    std.debug.assert(idx < indices.len);
    return names[indices[idx]];
}

You probably also could go further by generating a bitfield that has a 1 set for every position where the index increases and then you could do a popcount of all bits up to the queried position to calculate the index. I think that would also make it easy to write an iterator that enumerates all ScorePairs while also keeping the index of the current field name.

If you would go with the iterator, you also could create the array of names with the array of counts and then when accessing a specific index you would create the iterator and let it count (it would internally have a field index and sub index for the field within that field’s array and a running total of the previous counts).

2 Likes