Making Overloaded Function Sets Using Comptime

Edited: Thanks to everyone who has contributed to this idea. After a lot of collaboration, this is the version we’re currently on:

  • Using this structure, you can tie together multiple functions under one call.
  • Can tie together functions with different names and numbers of arguments.
  • Functions do not need to have the same or common return types.
  • The call is automatically dispatched based on the set of arguments.

Using the OverloadSet:

const std = @import("std");

// our three functions to combine into an overload set
pub fn sqr16(x: i16) i16 { return x * x; }
pub fn sqr32(x: i32) i32 { return x * x; }
pub fn sqr64(x: i64) i64 { return x * x; }

// make the overload set
const sqr = OverloadSet(.{ sqr16, sqr32, sqr64 });

pub fn main() !void  {
    const x: i32 = 42;
    const y: i32 = sqr.call(.{ x });
    std.debug.print("\nResult: {}\n\n", .{ y });
}

@Sze’s version with Implicit Conversion control: Overloaded Function Sets Refactored + TypeConverters + implicitTypeConverter + implicitArrayToSlice + implicitComptimeInt · GitHub

Source Code:

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

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)) {
                return NoMatchingOverload;
            }
            const function_index = comptime 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);
        }
    };
}

Here’s where the original post begins.


I’m playing around with different dispatch mechanisms and came across this interesting example. This trick allows us to generate structs using comptime that act like overload sets.

The idea is if I create N different functions, can I generate an object that will dispatch to them automatically using a single call function? Surprisingly, yes - I have only made this example for unary functions, but I’m sure it could be extended.

Ultimately, the end product of this is used like so:

// our three functions to combine into an overload set
pub fn sqr16(x: i16) i16 {
    return x * x;
}
pub fn sqr32(x: i32) i32 {
    return x * x;
}
pub fn sqr64(x: i64) i64 {
    return x * x;
}

// make the overload set
const sqr = OverloadSet(.{ sqr16, sqr32, sqr64 }){ };

pub fn main() !void  {
    const x: i32 = 42;
    std.debug.print("\nResult: {}\n\n", .{ sqr.call(x) });
}

Here’s the code for the OverloadSet:

// turn the first argument into a name for generating unique unary functions members
fn argTypeName(func: anytype) []const u8 {
    return @typeName(@typeInfo(@TypeOf(func)).Fn.params[0].type.?);
}

pub fn OverloadSet(comptime args: anytype) type {

    comptime var fields: [args.len]std.builtin.Type.StructField = undefined;

    // Create a field for each function in the tuple
    inline for (0..args.len) |i| {
        fields[i] = .{
            .name = "func_" ++ argTypeName(args[i]),
            .type = @TypeOf(args[i]),
            .default_value = args[i],
            .is_comptime = true,
            .alignment = 0,
        };
    }

    // Create an internal struct type with the function fields
    const Internal = @Type(.{
        .Struct = .{
            .layout = .Auto,
            .fields = fields[0..],
            .decls = &.{},
            .is_tuple = false,
            .backing_integer = null
        },
    });

    // Return a wrapper struct with a call function
    return struct {
        internal: Internal = .{ },
        pub fn call(comptime self: @This(), x: anytype) @TypeOf(x) {
            return @field(self.internal, "func_" ++ @typeName(@TypeOf(x)))(x);
        }
    };
}

This is actually more generic than name overloading by itself - you can combine any functions into a set that have different parameter types.

5 Likes

I always think this is important to look at how it fails too. If I do this:

pub fn main() !void  {
    const x = "42";
    std.debug.print("\nResult: {}\n\n", .{ sqr.call(x) });
}

I get this:

sqr.zig:38:50: error: no field named 'func_*const [2:0]u8' in struct 'sqr.OverloadSet.Internal'
            return @field(self.internal, "func_" ++ @typeName(@TypeOf(x)))(x);
                                         ~~~~~~~~^~~~~~~~~~~~~~~~~~~~~~~~
sqr.zig:24:22: note: struct declared here
    const Internal = @Type(.{
                     ^~~~~
referenced by:
    main: sqr.zig:59:52
    callMain: /snap/zig/9938/lib/std/start.zig:585:32
    remaining reference traces hidden; use '-freference-trace' to see all reference traces
/snap/zig/9938/lib/std/fmt.zig:604:25: error: cannot format array ref without a specifier (i.e. {s} or {*})
                        @compileError("cannot format array ref without a specifier (i.e. {s} or {*})");
                        ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

I had a look and it is salvageable thanks to @hasField:

    // Return a wrapper struct with a call function
    return struct {
        internal: Internal = .{ },
        pub fn call(comptime self: @This(), x: anytype) @TypeOf(x) {
            if (!@hasField(@TypeOf(self.internal), "func_" ++ @typeName(@TypeOf(x)))) {
              @compileError("No overload for " ++ @typeName(@TypeOf(x)));
            } else {
              return @field(self.internal, "func_" ++ @typeName(@TypeOf(x)))(x);
            }
        }
    };
}

You end up with:

sqr.zig:39:15: error: No overload for *const [2:0]u8
              @compileError("No overload for " ++ @typeName(@TypeOf(x)));
              ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
referenced by:
    main: sqr.zig:63:52
    callMain: /snap/zig/9938/lib/std/start.zig:585:32
    initEventLoopAndCallMain: /snap/zig/9938/lib/std/start.zig:519:34
    callMainWithArgs: /snap/zig/9938/lib/std/start.zig:469:12
    posixCallMainAndExit: /snap/zig/9938/lib/std/start.zig:425:39
    _start: /snap/zig/9938/lib/std/start.zig:338:40
/snap/zig/9938/lib/std/fmt.zig:604:25: error: cannot format array ref without a specifier (i.e. {s} or {*})
                        @compileError("cannot format array ref without a specifier (i.e. {s} or {*})");
                        ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

But then again this pesky error about array ref keeps showing up.

1 Like

Yeah, adding guard clauses is something worth doing - no doubt. It can clean up the error messages.

I literally realized how you can make this work for arbitrary arity functions as soon as I turned my main computer off, so I’ll post a more robust one tomorrow and I’ll add your check in like you’re suggesting. I can also add checks to make sure each argument going into the OverloadSet are indeed functions with at least one argument.

I’ll also strip out the spaces and invalid characters to make sure the names are properly formed for things like []const u8 where the white space and brackets could throw it off.

I think functions’ generation is a convoluted way of doing overloading sets. Simple type introspection is enough. Different number of parameters and return types are already handled. Please consider this. Note that this is a rough code, for example, it assumes that there is no functions with identical signatures, that functions is indeed a tuple of functions, and that args is a struct. It should report better error messages if those assumptions don’t hold.

const std = @import("std");

pub fn OverloadSet(comptime functions: anytype) type {
    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;
            }
            @compileError("No overload for " ++ @typeName(args_type));
        }

        fn OverloadSetReturnType(comptime args_type: type) type {
            const function_index = findMatchingFunctionIndex(args_type);
            const function = functions[function_index];
            return @typeInfo(@TypeOf(function)).Fn.return_type.?;
        }

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

fn funcVoid() void {
    std.debug.print("funcVoid\n", .{});
}
fn funcVoidI32(arg: i32) void {
    _ = arg;
    std.debug.print("funcVoidI32\n", .{});
}
fn funcI32I32I64(arg0: i32, arg1: i64) i32 {
    _ = arg0;
    _ = arg1;
    std.debug.print("funcI32I32I64\n", .{});
    return 42;
}

const MyOverload = OverloadSet(.{ funcVoid, funcVoidI32, funcI32I32I64 });

pub fn main() void {
    const result0 = MyOverload.call(.{});
    std.debug.print("{s}\n", .{ @typeName(@TypeOf(result0)) });
    const result1 = MyOverload.call(.{ @as(i32, 42) });
    std.debug.print("{s}\n", .{ @typeName(@TypeOf(result1)) });
    const result2 = MyOverload.call(.{ @as(i32, 42), @as(i64, 43) });
    std.debug.print("{s}\n", .{ @typeName(@TypeOf(result2)) });
}

Output

funcVoid
void
funcVoidI32
void
funcI32I32I64
i32

Hepler functions findMatchingFunctionIndex and OverloadSetReturnType may be defined outside of the struct that is returned by OverloadSet. Just details.

By the way, I don’t know whether there is a way to create findMatchingFunction that returns an actual function, so that

const function_index = findMatchingFunctionIndex(@TypeOf(args));
const function = functions[function_index];

may be simplified to

const function = findMatchingFunction(@TypeOf(args));

This is my first actual Zig code. Thank you @AndrewCodeDev for the case that interested me to start writing Zig :slightly_smiling_face:

5 Likes

@Tosti Sure thing - happy to have you.

This is very similar to the rewrite I had in mind - using tuples for the argument parameter is the big payoff because you get arbitrary length. The index idea is interesting - I think comptime memoization could negate the some of the performance issues surrounding infline-for.

I’d say this is a step in the right direction.

So here’s a way to add those checks to @Tosti’s version:

const std = @import("std");

fn detectOverloadError(args: anytype) ?[]const u8 {

    if (args.len < 2) {
        return "Overload set requires at least 2 arguments.";
    }

    // verify that each element is a function
    inline for (args) |arg| {
        switch (@typeInfo(@TypeOf(arg))) {
            .Fn => { }, else => { return "Non-function argument in overload set."; }
        }
    }

    // compare all signatures
    comptime var i: usize = 0;
    inline while (i < args.len) : (i += 1) {

        comptime var j: usize = i + 1;
        inline while (j < args.len) : (j += 1) {
            if (@TypeOf(args[i]) == @TypeOf(args[j])) {
                return "Identical function signatures in overload set.";
            }
        }
    }
    return null; // no error message
}

pub fn sqr16(x: i16) i16 { return x * x; }
pub fn sqr32(x: i32) i32 { return x * x; }
pub fn sqr64(x: i64) i64 { return x * x; }

pub fn main() !void {    
    // no error message
    if (comptime detectOverloadError(.{ sqr16, sqr32, sqr64 })) |error_message| {
        std.debug.print("\nError Message: {s}\n\n", .{ error_message });
    } else {
        std.debug.print("\nNo error detected in overload set.\n\n", .{});
    }

    // error message
    if (comptime detectOverloadError(.{ sqr16, sqr16, sqr64 })) |error_message| {
        std.debug.print("\nError Message: {s}\n\n", .{ error_message });
    } else {
        std.debug.print("\nNo error detected in overload set.\n\n", .{});
    }
}

We can see that this prints out what we’d expect in a clean error message.

Altogether, we’d have this:

fn detectOverloadError(args: anytype) ?[]const u8 {

    if (args.len < 2) {
        return "Overload set requires at least 2 arguments.";
    }

    // verify that each element is a function
    inline for (args) |arg| {
        switch (@typeInfo(@TypeOf(arg))) {
            .Fn => { }, else => { return "Non-function argument in overload set."; }
        }
    }

    // compare all signatures
    comptime var i: usize = 0;
    inline while (i < args.len) : (i += 1) {

        comptime var j: usize = i + 1;
        inline while (j < args.len) : (j += 1) {
            if (@TypeOf(args[i]) == @TypeOf(args[j])) {
                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 {
        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;
            }
            @compileError("No overload for " ++ @typeName(args_type));
        }

        fn OverloadSetReturnType(comptime args_type: type) type {
            const function_index = findMatchingFunctionIndex(args_type);
            const function = functions[function_index];
            return @typeInfo(@TypeOf(function)).Fn.return_type.?;
        }

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

What performance issues are you reffering to? Do you mean compiletime or runtime performance? Regarding runtime, OverloadSet.call should be compiled to a single call assembly instruction (maybe plus arguments and stack setup). This is a disassembly of three call instantiations from my example (debug build).

00007FF76ABE1150  push        rbp  
00007FF76ABE1151  sub         rsp,20h  
00007FF76ABE1155  lea         rbp,[rsp+20h]  
00007FF76ABE115A  call        funcVoid (07FF76ABE1680h)  
00007FF76ABE115F  nop  
00007FF76ABE1160  add         rsp,20h  
00007FF76ABE1164  pop         rbp  
00007FF76ABE1165  ret
00007FF76ABE1250  push        rbp  
00007FF76ABE1251  sub         rsp,20h  
00007FF76ABE1255  lea         rbp,[rsp+20h]  
00007FF76ABE125A  mov         ecx,2Ah  
00007FF76ABE125F  call        funcVoidI32 (07FF76ABE18B0h)  
00007FF76ABE1264  nop  
00007FF76ABE1265  add         rsp,20h  
00007FF76ABE1269  pop         rbp  
00007FF76ABE126A  ret
00007FF76ABE1270  push        rbp  
00007FF76ABE1271  sub         rsp,20h  
00007FF76ABE1275  lea         rbp,[rsp+20h]  
00007FF76ABE127A  mov         ecx,2Ah  
00007FF76ABE127F  mov         edx,2Bh  
00007FF76ABE1284  call        funcI32I32I64 (07FF76ABE18D0h)  
00007FF76ABE1289  nop  
00007FF76ABE128A  add         rsp,20h  
00007FF76ABE128E  pop         rbp  
00007FF76ABE128F  ret 

By the way, for that reason I believe the implementation of OverloadSet.call should use @call(.always_inline).

Please note that this check disambiguates functions with different return types, calling conventions, alignment, generic’ness, vararg’ness, and noalias’ness of parameters. So, for example, this set of functions

fn f0(arg: i32) void;
fn f1(arg: i32) i32;
fn f2(comptime arg: i32) void; // Fn.is_generic == true, but Fn.params[0].is_generic == false

passes this check and @compileError is not raised. So for the current OverloadSet just params[i].type should be compared.

    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.";
            }
        }
    }

I don’t know whether it’s possible to overload by comptime’ness. Anyway, I don’t see a usecase for it.

On the other hand, overloading by return type is more intereseting. I haven’t seen it in another languages. AFAIK, in Zig only some builtings are overloaded by return type (e.g. @intFromFloat). What do you think about adding this ability to user-defined functions? I don’t think it fits in the Zig philosophy, but it is just an idea to consider. If it is implemented, then it will be possible to write OverloadSet such that

fn f0() i32;
fn f1() f32;
const F = OverloadSet(.{ f0. f1 });
const result0: i32 = F.call(.{}); // calls f0
const result1: f32 = F.call(.{}); // calls f1

Don’t know in which cases it may be useful though.

Also, why is there a check if (args.len < 2)? It looks arbitrary and hurts generality. OverloadSet works with args.len == 1 and even args.len == 0 (if corresponding call function isn’t compiled). Arbitrary limitations like this hurt metaprogramming.

Edited - see next post on part about comptime_int and comptime overloads.

Here’s my justification for having two arguments at least - I’m trying to emulate function overloads from other languages. If I have no functions (the empty set) then there’s nothing to overload. If I have only one function, then there’s nothing else to overload it with. If we want to veer away from the original concept and open it up more broadly to other things, then I’m interested in exploring that possibility.

There were some compiler regressions on inline for that don’t seem to be effecting this. I can go dig up the threads, but it’s subtly unrelated to this issue so I think this is resolved.

I agree with your point about making it always inline - I think that is a smart thing to do.


Now this I have not thought about - it’s an interesting case. It’s currently only catching the first function in the set because the empty tuple arguments resolve to it first (thus breaking the loop).

Unless we really want to dig into making this work on return location type as well, we’d have to choose whether input parameters are the main decision point. In which case, you’re right - we’d need to compare the arguments and not the entire function signature.

OR (and I’m not a fan of this idea), you’d specify the return type as well. Seems redundant though. I’m personally in favor of your suggestion of just comparing params.

fn f0() i32;
fn f1() f32;
const F = OverloadSet(.{ f0. f1 });
const result0: i32 = F.call(.{}); // calls f0
const result1: f32 = F.call(.{}); // calls f1

I can think of a use case in the following way… let’s imagine that you wanted to make an overload for increasing/decreasing some epsilon based on the type of value you’re returning. So in this case, I am thinking f32 and f64 and the use case may be computing derivatives. Too small of epsilons for f32 actually leads to very bad accuracy if you’re doing something like directly computing the Newtonian derivative - by assigning out to f64, you could change the accuracy internally because we’re literally asking for a bigger floating point number as a return and that could internally adjust the epsilon of the derivative.

Really interesting idea, but I think parameter type is sufficient.

1 Like

Here’s an example of overloading on comptime_int (trivial, but you get the picture). The parameter itself is comptime so that’s one requirement, but I’m not finding a way on the same type (say i32 vs comptime i32) to dispatch on… I’ll keep messing with it.

pub fn sqr32(x: i32) i32 {
    std.debug.print("\nSquare Runtime.\n", .{ });
    return x * x;
}
pub fn sqr32C(comptime x: comptime_int) i32 {
    std.debug.print("\nSquare Comptime.\n", .{ });
    return x * x;
}

const sqr = OverloadSet(.{ sqr32, sqr32C });

pub fn main() !void {
    const x: i32 = 42;
    _ = sqr.call(.{ x }); // calls i32
    _ = sqr.call(.{ 42 }); // calls comptime_int
}
1 Like

One more thing… we get a pretty awkward error if the argument to call is not a tuple…

main.zig:38:53: error: access of union field 'Struct' while field 'Int' is active
            const args_fields = @typeInfo(args_type).Struct.fields;
                                ~~~~~~~~~~~~~~~~~~~~^~~~~~~
/snap/zig/9956/lib/std/builtin.zig:228:18: note: union declared here
pub const Type = union(enum) {
                 ^~~~~
main.zig:53:61: note: called from here
            const function_index = findMatchingFunctionIndex(args_type);
                                   ~~~~~~~~~~~~~~~~~~~~~~~~~^~~~~~~~~~~
main.zig:58:57: note: called from here
        pub fn call(args: anytype) OverloadSetReturnType(@TypeOf(args)) {

So if we plug this in like so…

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

// ...

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

We now get this:

main.zig:60:17: error: Error: OverloadSet's call argument must be a tuple.
                @compileError("Error: OverloadSet's call argument must be a tuple.");
                ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
main.zig:67:57: note: called from here
        pub fn call(args: anytype) OverloadSetReturnType(@TypeOf(args)) {
                                   ~~~~~~~~~~~~~~~~~~~~~^~~~~~~~~~~~~~~

It needs to get put on the OverloadSetReturnType and not call because this gets resolved first as the return parameter to the call. Lookin’ good.

Here’s one more addendum… anytype causes it to break:

main.zig:56:39: error: unable to unwrap null
                        if (param.type.? != field.type) break false;
                            ~~~~~~~~~~^~
main.zig:68:61: note: called from here
            const function_index = findMatchingFunctionIndex(args_type);
                                   ~~~~~~~~~~~~~~~~~~~~~~~~~^~~~~~~~~~~
main.zig:73:57: note: called from here
        pub fn call(args: anytype) OverloadSetReturnType(@TypeOf(args)) {

I think it should be also be a requirement that null param.type’s are prohibited as well. We can catch that here in the detectOverloadError function like so…

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

So we’ll get…

main.zig:53:9: error: Error: Generic parameter types are prohibitted.
        @compileError("Error: " ++ error_message);
1 Like

Imagine a metaprogramming context. Let’s say you want to overload a set of functions from some namespace that share a common affix or something. There may be 0 or 1 function with a given affix. With your if (args.len < 2) check, you’ll have to handle this case separately.

if (functionsWithAffix.len < 2) {
    // some handling
}
else OverloadSet(functionsWithAffix);

But if you remove this arbitrary check, it will just be OverloadSet(functionsWithAffix).

Agree. It even may be a function that takes a type for these computations as a parameter. It would allow

fn epsilonF32() f32;
fn epsilonF64() f64;
const Epsilon = OverloadSet(.{ epsilonF32, epsilonF64 });
fn derivative(comptime T: type, ...) T {
    // ...
    const eps: T = Epsilon.call(.{}); // calls epsilongF32 or epsilonF64 depending on a type
    // ...
}

But on the other hand it can be rewritten as

fn epsilon(comptime T: type) T;
fn derivative(comptime T: type, ...) T {
    // ...
    const eps = epsilon(T);
    // ...
}

And the second version is better, because there in no need in OverloadSet at all.

So maybe overloading by return type indeed useless.

I came to the same conclusion.

First, I want to recognize you’re absolutely correct about it being a more general structure without the check. Mathematical sets have the empty set, for instance, and I can appreciate all the things that can be accomplished with that structure.

Interesting example. We’re adding another constraint here with common affix, but I’m torn on the use case - I see two ways this could go.

Here’s an everyday practical example: let’s say I’m tired of writing switch statements all over the place (which this can definitely help avoid) or I’m tying together some existing functions. This is something I can see myself using this for (and actually intend to for a project I am working on). If I ended up with one that contained an empty overload and threw a really weird error, that’d be annoying.

On the contrary, I can see removing the check being useful in a pipeline of combinations. So we can append two tuples together so combining two overload sets would be the same as combining their tuples and passing that to a new overload set. If one is empty, it would act like an identity/idempotent operation. In this case, yeah, good idea.

Given that this datastructure is starting to outgrow my original vision of this, I’m okay with passing an empty tuple if we don’t get a firestorm of ugly errors (and if we do, we can handle those).

Just to be clear, I’m not suggesting adding handling of affixes to OverloadSet. I meant some code that somehow programmatically builds functionsWithAffix and uses OverloadSet for its internal needs. If OverloadSet is general, it’ll be easier for this code to use it.

Sure, @compileError is quite handy here. functions.len != 0 check may be added to OverloadSet.call to produce a comprehensive error message.

Agreed - let’s go with that and add some prohibitive checks for empty sets :beers:

We may need to add that check further up. Since your implementation is indexing the tuple to find a return type, we’ll probably need to rework some of how that’s currently implemented.

Looking at this function, we can probably handle some of it here…

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

Since this expects a return type, we could just say if functions.len = 0 then return void as the type. If we don’t return something here or try to index into an empty tuple, we’ll get compile time issues.

Then, in the call, we can add the compile error as per functions.len = 0 too.

1 Like

For anyone else playing along at home… here’s where I gather we’re at:

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 {
        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;
            }
            @compileError("Error: No overload for " ++ @typeName(args_type));
        }

        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);
            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));
            const function = functions[function_index];
            return @call(.always_inline, function, args);
        }
    };
}

@Tosti, anything else we’re missing? I’m thinking we’re homing in to something that’s getting close. I’ll have to use it more but it’s looking pretty good so far.

I’ll check tomorrow. Currently I’d like to ask about one small thing. findMatchingFunctionIndex and OverloadSetReturnType are inside OverloadSet struct and it is inconsistent with detectOverloadError which is in the file namespace. Maybe it’s better to move findMatchingFunctionIndex and OverloadSetReturnType there as well? In this case comptime functions: anytype should be added as a parameter.

I don’t know why but nitpicks like those always drive me crazy :melting_face:

Sounds good. Funny you mention because I was thinking that would clean up the source code readability as well.

I noticed 2 issues.

  1. Calling OverloadSet.call with unsupported argument types don’t show the line where the call happens.
  2. I tried to add printing of all candidates as part of the error message “No overload”. But I don’t know whether it’s possible to construct a string at comptime from functions to pass it to @compileError.

So I have this code.

    @compileError("No overload for " ++ @typeName(args_type) ++ "\n"
        ++ "Candidates are:\n"
        ++ "    " ++ @typeName(@TypeOf(functions[0])));

The error message.

src\main.zig:68:5: error: No overload for struct{comptime i64 = 42}
                          Candidates are:
                              fn () void
    @compileError("No overload for " ++ @typeName(args_type) ++ "\n"
    ^~~~~~~~~~~~~
src\main.zig:77:53: note: called from here
    const function_index = findMatchingFunctionIndex(functions, args_type);
                           ~~~~~~~~~~~~~~~~~~~~~~~~~^~~~~~~~~~~~~~~~~~~~~~
src\main.zig:89:57: note: called from here
        pub fn call(args: anytype) OverloadSetReturnType(functions, @TypeOf(args)) {
                                   ~~~~~~~~~~~~~~~~~~~~~^~~~~~~~~~~~~~~~~~~~~~~~~~
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

I’d like to get a location where the call was made. Also, I’d like to print types of all functions, not just functions[0]. Is it possible to somehow do an inline loop to concatenate all " " ++ @typeName(@TypeOf(functions[i]))?

Is using @compileLog the only way? Anyway, it feels like a severe limitation to not be able to concatenate array of arrays at comptime. Am I missing something?

Edit: Probably a solution for the first issue is to return void (or even noreturn? call shouldn’t return in this case) from OverloadSetReturnType in case of an error and instead invoke @compileError from call.

Yeah, interesting stuff…

My initial thought is to use std.meta.ArgsTuple to collect the arguments for each function and format them via a comptime string against their types and concatenate that with the return type so you’d get a print statement like:

Available Overloads:
    (i32, i32) -> i32
    (i64, i64) -> i64

If the strings themselves are comptime, their str.len field should be comptime as well, so we can do a sum such that N = sum of str len's and then flatten it (via direct overwrite) into a comptime var _: [N]u8 buffer where the entire thing represents a return string. I’ll rig up an example here in a bit.

For getting the line of the call, I wonder if we can use @src - here’s an example:

const std = @import("std");
const expect = std.testing.expect;

test "@src" {
    try doTheTest();
}

fn doTheTest() !void {
    const src = @src();

    try expect(src.line == 9);
    try expect(src.column == 17);
    try expect(std.mem.endsWith(u8, src.fn_name, "doTheTest"));
    try expect(std.mem.endsWith(u8, src.file, "test_src_builtin.zig"));
}

The issue here is it needs to be at the call site itself… Jason Turner had a hack for this in C++ a while ago but let me see what I can dig up here too.

1 Like

Edited - nvm, wrong error message I’m looking at. I’ll keep looking at @src