Half-open ranges in switch, and empty switch ranges without compile error

There is this isFieldOptional example function in the docs (I want to do something similar):

fn isFieldOptional(comptime T: type, field_index: usize) !bool {
    const fields = @typeInfo(T).@"struct".fields;
    return switch (field_index) {
        inline 0...fields.len - 1 => |idx| @typeInfo(fields[idx].type) == .optional,
        else => return error.IndexOutOfBounds,
    };
}
...
const Struct1 = struct { a: u32, b: ?u32 };
std.debug.print("{}\n", .{try isFieldOptional(Struct1, 0)}); // prints "false"
std.debug.print("{}\n", .{try isFieldOptional(Struct1, 1)}); // prints "true"
std.debug.print("{}\n", .{try isFieldOptional(Struct1, 2)}); // returns error: IndexOutOfBounds

Now the problem is what happens for a struct that does not have any fields. I think it would make sense that it just returned “IndexOutOfBounds” in that case for any field_index:

const Struct2 = struct {};
std.debug.print("{}\n", .{try isFieldOptional(Struct2, 0)}); // should return error: IndexOutOfBounds

But it doesn’t compile due to “overflow of integer type ‘usize’ with value ‘-1’”. And even if I avoid the overflow with

inline 1...fields.len => |idx_plus_one| @typeInfo(fields[idx_plus_one - 1].type) == .optional,

it still doesn’t compile, because “range start value is greater than the end value”.

I think the only way to make it work is to add an if (fields.len == 0) return error.IndexOutOfBounds check before the switch, but this is ugly because it means I have to duplicate the error handling. Or am I missing some better solution?

I think what is needed here would be

  • “half-open” switch ranges that are exclusive for the upper bound, so I could simply have a switch case like 0..fields.len, without the overflow
  • instead of the “range start value is greater than the end value” compile error, ranges that are known to be empty at comptime should just be removed without an error, so that for Struct2 only the else => return error.IndexOutOfBounds prong remains and is run unconditionally.
1 Like

FWIW this will work if the index is comptime known, which would seem reasonable for most use cases.

fn isFieldOptional(comptime T: type, comptime field_index: usize) !bool {
    const fields = @typeInfo(T).@"struct".field_types;
    if (field_index >= fields.len)
        return error.IndexOutOfBounds;
    return @typeInfo(fields[field_index]) == .optional;
}

test isFieldOptional {
    const Struct1 = struct { a: u32, b: ?u32 };
    try std.testing.expect(!try isFieldOptional(Struct1, 0));
    try std.testing.expect(try isFieldOptional(Struct1, 1));
    try std.testing.expectError(error.IndexOutOfBounds, isFieldOptional(Struct1, 2));

    const Struct2 = struct {};
    try std.testing.expectError(error.IndexOutOfBounds, isFieldOptional(Struct2, 0));
}

const std = @import("std");

And a slightly mad option that is runtime known:

fn isFieldOptional(comptime T: type, field_index: usize) !bool {
    const field_types = @typeInfo(T).@"struct".field_types;
    inline for (0..field_types.len) |i| if (i == field_index) {
        return @typeInfo(field_types[i]) == .optional;
    };
    return error.IndexOutOfBounds;
}
2 Likes

I’ve also run into this recently and also found it kinda annoying and arbitrarily restrictive. You can use an inline for loop but in certain situations inline switch prongs are simply the preferable and more expressive choice.

I ended up settling with:

return if (fields.len != 0) switch (field_index) {
    inline 0...(fields.len - 1) => |i| foo(i),
    else => error.Bar,
} else error.Bar;

If you don’t want to duplicate the error condition you could do the slightly more verbose:

return blk: {
    if (fields.len != 0) switch (field_index) {
        inline 0...(fields.len - 1) => |i| break :blk foo(i),
        else => {}
    };
    break :blk error.Bar;
};

I guess the following using a labeled continue might also work (I haven’t actually tested if it compiles):

return sw: switch (field_index) {
    inline 0...(fields.len -| 1) |i| if (fields.len == 0) continue :sw 1 else foo(i),
    else => error.Bar,
};

But I too wish that switch ranges supported .. with exclusive endpoints (and if it was supported, I would prefer if ... was replaced with ..= because supporting both .. and ... seems like it would be an easy source of typos).

2 Likes

I like ... → ..= just because it’s obviously different from ..

For the OP, I see three options.

Use unreachable to avoid duplicate error handling.

fn isFieldOptional(comptime T: type, field_index: usize) !bool {
    const fields = @typeInfo(T).@"struct".fields;
    if (field_index >= fields.len) return error.IndexOutOfBounds;
    return switch (field_index) {
        inline 0...fields.len - 1 => |idx| @typeInfo(fields[idx].type) == .optional,
        else => unreachable,
    };
}

Just use an inline for loop.

fn isFieldOptional(comptime T: type, field_index: usize) !bool {
    inline for (@typeInfo(T).@"struct".fields, 0..) |field, i| {
        if (i == field_index) return @typeInfo(field.type) == .optional;
    }
    return error.IndexOutOfBounds;
}

Construct the info you want at comptime:

fn isFieldOptional(comptime T: type, field_index: usize) !bool {
    const is_optional = comptime blk: {
        const fields = @typeInfo(T).@"struct".fields;
        var is_optional: [fields.len]bool = undefined;
        for (fields, &is_optional) |field, *opt| {
            opt.* = @typeInfo(field.type) == .optional;
        }
        break :blk is_optional;
    };
    return if (field_index < is_optional.len) is_optional[field_index] else error.IndexOutOfBounds;
}

I kinda expected at least the first two to result in the optimizer generating the same code. However:
The switch version constructs a table to look up the value in
while ironically the for loop version creates a jump table you’d typically expect out of a switch
and the constructed array version generates a simple bounds checked access as expected.

That these three pieces of code trivially do the same thing shows that the LLVM optimizer isn’t consistently choosing a “most optimized” version.

3 Likes