Making Overloaded Function Sets Using Comptime

@compileError("Error: No overload for " ++ @typeName(args_type) ++ "\n" ++ "Candidates are:\n" ++ candidatesMessage());
// ...
fn candidatesMessage() []const u8 {
    var msg: []const u8 = "";
    inline for (functions) |f| {
        msg = msg ++ "    " ++ @typeName(@TypeOf(f)) ++ "\n";
    }
    return msg;
}

Yes this works out quite nicely:

const std = @import("std");

pub fn isTuple(comptime T: type) bool {
    return switch (@typeInfo(T)) {
        .Struct => |s| s.is_tuple,
        else => false,
    };
}

inline fn detectOverloadError(args: anytype) ?[]const u8 {
    inline for (args) |arg| {
        switch (@typeInfo(@TypeOf(arg))) {
            .Fn => {},
            else => {
                return "Non-function argument in overload set.";
            },
        }
    }

    inline for (args) |arg| {
        for (@typeInfo(@TypeOf(arg)).Fn.params) |param| {
            if (param.type == null) {
                return "Generic parameter types in overload set.";
            }
        }
    }

    inline for (0..args.len) |i| {
        const params0 = @typeInfo(@TypeOf(args[i])).Fn.params;
        inline for (i + 1..args.len) |j| {
            const params1 = @typeInfo(@TypeOf(args[j])).Fn.params;
            const signatures_are_identical = params0.len == params1.len and
                for (params0, params1) |param0, param1|
            {
                if (param0.type != param1.type) {
                    break false;
                }
            } else true;
            if (signatures_are_identical) {
                return "Identical function signatures in overload set.";
            }
        }
    }

    return null;
}

pub fn OverloadSet(comptime functions: anytype) type {
    if (comptime detectOverloadError(functions)) |error_message| {
        @compileError("Error: " ++ error_message);
    }

    return struct {
        const no_matching_overload_found = struct {};

        fn findMatchingFunctionIndex(comptime args_type: type) comptime_int {
            const args_fields = @typeInfo(args_type).Struct.fields;
            inline for (functions, 0..) |function, i| {
                const function_type_info = @typeInfo(@TypeOf(function)).Fn;
                const params = function_type_info.params;
                const match = params.len == args_fields.len and
                    inline for (params, args_fields) |param, field|
                {
                    if (param.type.? != field.type) break false;
                } else true;
                if (match) return i;
            }
            return -1;
        }

        fn candidatesMessage() []const u8 {
            var msg: []const u8 = "";
            inline for (functions) |f| {
                msg = msg ++ "    " ++ @typeName(@TypeOf(f)) ++ "\n";
            }
            return msg;
        }

        fn OverloadSetReturnType(comptime args_type: type) type {
            if (!isTuple(args_type)) {
                @compileError("Error: OverloadSet's call argument must be a tuple.");
            }
            if (@typeInfo(args_type).Struct.fields.len == 0) {
                return void;
            }
            const function_index = findMatchingFunctionIndex(args_type);
            if (function_index < 0) {
                return no_matching_overload_found;
            }
            const function = functions[function_index];
            return @typeInfo(@TypeOf(function)).Fn.return_type.?;
        }

        pub fn call(args: anytype) OverloadSetReturnType(@TypeOf(args)) {
            if (comptime args.len == 0) {
                @compileError("Error: Cannot invoke call function on empty OverloadSet.");
            }
            const function_index = findMatchingFunctionIndex(@TypeOf(args));
            if (function_index < 0) {
                @compileError("Error: No overload for " ++ @typeName(@TypeOf(args)) ++ "\n" ++ "Candidates are:\n" ++ candidatesMessage());
            }
            const function = functions[function_index];
            return @call(.always_inline, function, args);
        }
    };
}

fn nothing() void {}
fn add(a: i32, b: i32) i32 {
    return a + b;
}
fn addMul(a: i32, b: i32, c: i32) i32 {
    return (a + b) * c;
}
pub fn main() !void {
    const set = OverloadSet(.{ nothing, add, addMul });
    set.call(.{42});
}
overloadedfunctionsets.zig:117:13: error: value of type 'overloadedfunctionsets.OverloadSet(.{(function 'nothing'), (function 'add'), (function 'addMul')}).no_matching_overload_found' ignored
    set.call(.{42});
    ~~~~~~~~^~~~~~~
overloadedfunctionsets.zig:117:13: note: all non-void values must be used
overloadedfunctionsets.zig:117:13: note: this error can be suppressed by assigning the value to '_'
referenced by:
    ...
overloadedfunctionsets.zig:100:17: error: Error: No overload for struct{comptime comptime_int = 42}
                                          Candidates are:
                                              fn () void
                                              fn (i32, i32) i32
                                              fn (i32, i32, i32) i32
                                          
                @compileError("Error: No overload for " ++ @typeName(@TypeOf(args)) ++ "\n" ++ "Candidates are:\n" ++ candidatesMessage());
                ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
2 Likes

That’s definitely cleaner than what I was going to attempt. I don’t think I’ve ever tried concatenating with an inline for like that - learn something new every day :slight_smile:

1 Like

Originally saw it in some other answer, in this forum, but couldn’t remember the topic name.

There was a copy-paste error in the last full “catch-up” version I posted here, so this post cleans that up… moving right along…

Anyhow, I added an argument formatter so we get a better error message on failed overload return (no more mention of struct{... :

main.zig:101:17: error: No overload for { i32, i32, i32, i32 }
                        Candidates are:
                            fn () void
                            fn (i32, i32) i32
                            fn (i32, i32, i32) i32

Here’s the code for that

fn formatArguments(comptime args_type: type) []const u8 {
    const params = @typeInfo(args_type).Struct.fields;
    var msg: []const u8 = "{ ";
    inline for (params, 0..) |arg, i| {
        msg = msg ++ @typeName(arg.type) ++ if (i < params.len - 1) ", " else "";
    }
    return msg ++ " }";
}

I also found that with @Sze’s version, we can move the compile errors entirely up to the return type deduction. I moved the custom error type that you were returning into the definition of the overload return type so that everything is local to this one function:

fn OverloadSetReturnType(comptime args_type: type) type {

    const NoMatchingOverload = struct { };

    if (comptime !isTuple(args_type)) {
        @compileError("OverloadSet's call argument must be a tuple.");
    }
    if (comptime @typeInfo(@TypeOf(functions)).Struct.fields.len == 0) {
        return NoMatchingOverload;
    }

    const function_index = findMatchingFunctionIndex(args_type);
    
    if (comptime function_index < 0) {
        @compileError("No overload for " ++ formatArguments(args_type) ++ "\n" ++ "Candidates are:\n" ++ candidatesMessage());
    }
    const function = functions[function_index];
    return @typeInfo(@TypeOf(function)).Fn.return_type.?;
}

pub fn call(args: anytype) OverloadSetReturnType(@TypeOf(args)) {
    const function_index = comptime findMatchingFunctionIndex(@TypeOf(args));
    const function = functions[function_index];
    return @call(.always_inline, function, args);
}

So… without any copy paste errors… here’s my take on @Sze’s version…

const std = @import("std");

pub fn isTuple(comptime T: type) bool {
    return switch (@typeInfo(T)) {
        .Struct => |s| s.is_tuple,
        else => false,
    };
}

inline fn detectOverloadError(args: anytype) ?[]const u8 {
    inline for (args) |arg| {
        switch (@typeInfo(@TypeOf(arg))) {
            .Fn => {},
            else => {
                return "Non-function argument in overload set.";
            },
        }
    }

    inline for (args) |arg| {
        for (@typeInfo(@TypeOf(arg)).Fn.params) |param| {
            if (param.type == null) {
                return "Generic parameter types in overload set.";
            }
        }
    }

    inline for (0..args.len) |i| {
        const params0 = @typeInfo(@TypeOf(args[i])).Fn.params;
        inline for (i + 1..args.len) |j| {
            const params1 = @typeInfo(@TypeOf(args[j])).Fn.params;
            const signatures_are_identical = params0.len == params1.len and
                for (params0, params1) |param0, param1|
            {
                if (param0.type != param1.type) {
                    break false;
                }
            } else true;
            if (signatures_are_identical) {
                return "Identical function signatures in overload set.";
            }
        }
    }

    return null;
}

pub fn OverloadSet(comptime functions: anytype) type {
    if (comptime detectOverloadError(functions)) |error_message| {
        @compileError(error_message);
    }

    return struct {

        fn findMatchingFunctionIndex(comptime args_type: type) comptime_int {
            const args_fields = @typeInfo(args_type).Struct.fields;
            inline for (functions, 0..) |function, i| {
                const function_type_info = @typeInfo(@TypeOf(function)).Fn;
                const params = function_type_info.params;
                const match = params.len == args_fields.len and
                    inline for (params, args_fields) |param, field| {
                        if (param.type.? != field.type) break false;
                    }
                    else true;
                if (match) return i;
            }
            return -1;
        }

        fn candidatesMessage() []const u8 {
            var msg: []const u8 = "";
            inline for (functions) |f| {
                msg = msg ++ "    " ++ @typeName(@TypeOf(f)) ++ "\n";
            }
            return msg;
        }

        fn formatArguments(comptime args_type: type) []const u8 {
            const params = @typeInfo(args_type).Struct.fields;
            var msg: []const u8 = "{ ";
            inline for (params, 0..) |arg, i| {
                msg = msg ++ @typeName(arg.type) ++ if (i < params.len - 1) ", " else "";
            }
            return msg ++ " }";
        }

        fn OverloadSetReturnType(comptime args_type: type) type {
        
            const NoMatchingOverload = struct { };
        
            if (comptime !isTuple(args_type)) {
                @compileError("OverloadSet's call argument must be a tuple.");
            }
            if (comptime @typeInfo(@TypeOf(functions)).Struct.fields.len == 0) {
                return NoMatchingOverload;
            }
        
            const function_index = findMatchingFunctionIndex(args_type);
            
            if (comptime function_index < 0) {
                @compileError("No overload for " ++ formatArguments(args_type) ++ "\n" ++ "Candidates are:\n" ++ candidatesMessage());
            }
            const function = functions[function_index];
            return @typeInfo(@TypeOf(function)).Fn.return_type.?;
        }
        
        pub fn call(args: anytype) OverloadSetReturnType(@TypeOf(args)) {
            const function_index = comptime findMatchingFunctionIndex(@TypeOf(args));
            const function = functions[function_index];
            return @call(.always_inline, function, args);
        }
    };
}

I think when we settle on something (or say good enough), I’m going to update my original post to have that version in it so people don’t have to scroll super far down to see what we’ve been up to. I can keep it up to date if new improvements roll in.

1 Like

I like the cleanup, however you lost the feature of the stack trace pointing to the call of call instead it points to OverloadSetReturnType(@TypeOf(args)) which isn’t very helpful for the user.

This is why I prefer this variation (here the stack trace shows main / includes the call of the function):

fn OverloadSetReturnType(comptime args_type: type) type {
    const NoMatchingOverload = struct {};
    if (comptime @typeInfo(@TypeOf(functions)).Struct.fields.len == 0) {
        return NoMatchingOverload;
    }

    const function_index = findMatchingFunctionIndex(args_type);
    if (comptime function_index < 0) {
        return NoMatchingOverload;
    }
    const function = functions[function_index];
    return @typeInfo(@TypeOf(function)).Fn.return_type.?;
}

pub fn call(args: anytype) OverloadSetReturnType(@TypeOf(args)) {
    const args_type = @TypeOf(args);
    if (comptime !isTuple(args_type)) {
        @compileError("OverloadSet's call argument must be a tuple.");
    }
    const function_index = comptime findMatchingFunctionIndex(args_type);
    if (comptime function_index < 0) {
        @compileError("No overload for " ++ formatArguments(args_type) ++ "\n" ++ "Candidates are:\n" ++ candidatesMessage());
    }
    const function = functions[function_index];
    return @call(.always_inline, function, args);
}
1 Like

Aha, there it is… it’s in the fine print lol…

referenced by:
    main: main.zig:137:23

Otherwise, the stacktrace gets diverted into the function definition and not the call-site.

So I think that clears up both issues then - line of invocation and message formatting. Nice work.

1 Like

I think we can also get rid of this then:

    if (comptime @typeInfo(@TypeOf(functions)).Struct.fields.len == 0) {
        return NoMatchingOverload;
    }

Because your loop will just exit with -1 if the tuple is empty.

2 Likes

And on the same token, maybe we should move the “arguments is not a tuple” check into the call function too. That way, we get a stack trace on that error as well.

Kinda like so…

fn OverloadSetReturnType(comptime args_type: type) type {

    const NoMatchingOverload = struct { };
        
    if (comptime !isTuple(args_type)) {
        return NoMatchingOverload;
    }
    const function_index = findMatchingFunctionIndex(args_type);
    
    if (comptime function_index < 0) {
        return NoMatchingOverload;
    }
    const function = functions[function_index];
    return @typeInfo(@TypeOf(function)).Fn.return_type.?;
}

pub fn call(args: anytype) OverloadSetReturnType(@TypeOf(args)) {
    const args_type = @TypeOf(args);

    if (comptime !isTuple(args_type)) {
        @compileError("OverloadSet's call argument must be a tuple.");
    }
    const function_index = comptime findMatchingFunctionIndex(args_type);

    if (comptime function_index < 0) {
        @compileError("No overload for " ++ formatArguments(args_type) ++ "\n" ++ "Candidates are:\n" ++ candidatesMessage());
    }
    const function = functions[function_index];
    return @call(.always_inline, function, args);
}
``

I am confused, I already did that?

Looks like you did - I’m honestly losing track. I’ve copied this over from here to helix like 30 times in the course of this conversation. I think I was working on the version where I moved everything out of the call into the return type.

At this point though, I’m personally happy with what it’s all doing. I don’t think there’s a lot else I’d change or add.

1 Like

At first I was skeptical about the whole idea, but playing around with it, I think it could be quite nice to use in some situations.

1 Like

The thread has become way too long with too much code. I lost track. Would it be possible to put the final version of the code into a github repo or a gist? Thanks.

Final version is in the first comment, under “Source Code” header (second code block). Several people helped here quite a bit so I’d only put it in a repo with their blessing and proper credit. @Sze, @Tosti, @Luke, thoughts? If ya’ll are okay with this idea and if you have Git profiles, can you send me your Git profile names so I can accredit you properly?

2 Likes

@AndrewCodeDev I’m fine, no need to credit. Thanks a lot for your effort !

2 Likes

Yes, this works. Could someone explain why exactly? According to the language reference, ++ works on tuples and arrays. But msg is a slice, not an array. Is the language reference missing something, or does it somehow imply inderectly via other rules that it is possible for slices (at least in some cases)?

Could you elaborate why do you return a custom type instead of noreturn? I see 2 advataged of noreturn in this case.

  1. This is what OverloadSet.call actually “returns” (@compileError’s return type is noreturn).
  2. Using it doesn’t provoke the compiler to emit an error message about ignored returned value. Solution with a custom struct gives the following error.
src\main.zig:147:20: error: value of type 'main.OverloadSet(.{(function 'funcVoid'), (function 'funcVoidI32'), (function 'funcI32I32I64')}).OverloadSetReturnType.NoMatchingOverload' ignored
    MyOverload.call(.{ @as(i64, 42), });
    ~~~~~~~~~~~~~~~^~~~~~~~~~~~~~~~~~~~
src\main.zig:147:20: note: all non-void values must be used
src\main.zig:147:20: note: this error can be suppressed by assigning the value to '_'
referenced by:
    callMain: C:\Program Files\Zig\zig-windows-x86_64-0.12.0-dev.1828+225fe6ddb\lib\std\start.zig:575:17
    initEventLoopAndCallMain: C:\Program Files\Zig\zig-windows-x86_64-0.12.0-dev.1828+225fe6ddb\lib\std\start.zig:519:34
    remaining reference traces hidden; use '-freference-trace' to see all reference traces
src\main.zig:110:17: error: No overload for { i64 }
                            Candidates are:
                                fn () void
                                fn (i32) void
                                fn (i32, i64) i32

                @compileError("No overload for " ++ formatArguments(args_type) ++ "\n" ++ "Candidates are:\n" ++ candidatesMessage());
                ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Even though it explains actual problem (“No overload”), it obfuscates the error message by emitting “ignored value” error. To supress it, it’s possible to use _ =, but it doesn’t help with the actual problem anyway.

On the other hand, noreturn in this case won’t provoke the “ignored value” error whether you use _ = or not. It gives you exactly the error you want to see (“No overload”).

I suggest

            if (comptime function_index < 0) {
                return noreturn;
            }

return noreturn looks hilarious, not gonna lie :sweat_smile:

It does look funny. That said, respectfully, I have to take an opposing stance.

I think this is a debatable interpretation - noreturn has semantic meaning outside of the use case we’re currently in - the second point is where I actually disagree…

What’s actually happening here is we would be taking one set of error messages and splitting them across two compilation cycles. With @Sze’s idea, you can see both issues in one cycle and fix them both. With the noreturn and unused value, you still have another compile error lurking… it’s just being suppressed until you try to compile again. This isn’t a huge issue in these toy examples we’re throwing around, but I like to read my compilation error messages and fix multiple at once instead of getting one message per compilation.

1 Like

It will be lurking only if the return type of the correct overload is not void. If it’s void, _ = is unnecessary. But we can’t tell what’s the correct overload is, because there is no match. So on the first cycle you correct your arguments, and only after that it’ll be clear whether _ = is actually required.

1 Like

But as you can see, we’ve already handled that too in the error message:

Candidates are:
    fn () void
    fn (i32) void
    fn (i32, i64) i32

Here you can clearly see what the return types are for any overload.

1 Like

Well, the person knows the correct overload, but the compiler doesn’t. So I don’t think it should emit “unused value” error, because the compiler can’t be sure whether it is a problem and whether it should be fixed at all.

Arguable, but that’s my stance. I won’t be disappointed if you disagree. Unless this code some day ends up in the standard library :sweat_smile:

1 Like

I’m a little bit uncomfortable with how similar bodies of OverloadSetReturnType and call are. On the one hand I understand why requiring function return types in a good thing (simplifies tooling), but on the other hand we have this. Inferring of return types could remove the need in duplication like this. At least I don’t see how to avoid it in current Zig.

And in this case we were lucky that the good chunk of common code could be refactored as separate findMatchingFunctionIndex. There may be even more duplication in other metaprogramming cases.