Functions accepting function pointers with varying parameter layouts

Hello. Standard disclaimer about first time post / non-professional Zig dev here.
(code is written for 0.16)

Recently while tinkering around I came across a situation where I wanted to be able to pass in function pointers to another function (it was a testbench for some algorithms, not really too relevant here though) that had varying layouts in their function signatures.

I tried first having something like this (I unfortunately wasn’t committing diligently enough so I don’t have what I tried exactly):

const TestSignature = struct {
    comptime FnType: type, //signature for the function pointer
    fn: FnType,
    parameters: ParameterLayout,
}
const ParameterLayout = enum {
    LayoutOne,
    LayoutTwo,
}   

The thought was that I could switch on the enum to actually pass the correct arguments to the function pointer. I wasn’t able to get this to work, so throwing together a different approach, I came up with this:

const std = @import("std");

pub fn main(init: std.process.Init) void {
    _ = init;
    
    const c1: contextStruct = .{ .fnPtr = &a, .mapInfo = .LayoutOne };
    const c2: contextStruct = .{ .fnPtr = &b, .mapInfo = .LayoutTwo };

    acceptsCtx(c1);
    acceptsCtx(c2);

}

fn a() void {
    std.debug.print("Hi\n", .{});
}

fn b(x: u8) u8 {
    return x % 5;
}

const mapEnum = enum { LayoutOne, LayoutTwo };
const contextStruct = struct {
    fnPtr: *const anyopaque,
    mapInfo: mapEnum,
};
inline fn mapHelper (m: mapEnum) type {
    return switch (m) {
        .LayoutOne => *const fn () void,
        .LayoutTwo => *const fn (u8) u8,
    };
}
fn acceptsCtx(comptime ctx: contextStruct) void {
    switch (ctx.mapInfo) {
        .LayoutOne => {
            const type_info = mapHelper(ctx.mapInfo);
            const casted: type_info = @ptrCast(ctx.fnPtr);
            casted();
        },
        .LayoutTwo => {
            const type_info = mapHelper(ctx.mapInfo);
            const casted: type_info = @ptrCast(ctx.fnPtr);
            std.debug.print("{d}\n", .{casted(5)});
        },
    }
}

While this seems to work, I was wondering if there were better or more advisable ways of doing something like this.

My two main questions are:

  1. Is there a safer / more elegant / more idiomatic way of supporting something like this? What have other people done?
  2. I admit to not really understanding anyopaque well from the main documentation, and it was kind of just through trial and error that I got to this working version. Could someone explain the concept behind it or point me towards documentation I might have been missing on it?

(Bonus points) If this kind of function wrangling isn’t really advisable for some reason I’m not aware of do let me know and why that would be. :sweat_smile:

Thanks a bunch for taking the time to help me along.

I dont think there’s enough information here for me to give you a good advice. But from the initial description it sounds like you might want a vtable.

1 Like

A tagged union would be safer and simpler:

const Context = union(enum) {
    type_one: *fn () void,
    type_two: *fn (u8) u8,
};

const a: Context = .{
    .type_one = &function,
};

switch (a) {
    .type_one => |f| f(),
    .type_two => |f| f(1),
}
4 Likes

Oh, this is certainly a lot more convenient, and I think more or less exactly what I needed. It didn’t occur to me for some reason that tagged unions could contain function pointers.

Thanks!

Sorry, I probably could have been clearer. As for VTables, they seem a bit complex for now, but I’ll definitely look into them as I get more comfortable with the language. Thanks for the tip.

the tagged union above is a vtable, and it is a more complex one than most, as most vtables don’t have different signatures for the same function so they just use a struct.

Another option is to only have one valid signature, and you either

  • update the functions to conform to it, even if they don’t use some/all the parameters
  • or you make wrappers over those functions to adapt them to your api, which you might be able to generate with comptime.

Is it? That’s good to know. I suppose what happened is I took one look at std.Io.VTable in the documentation and decided that vtables were going to take a bit more time for me to properly look into and understand.

I did use wrapper functions in another project at one point, but I guess I just wanted to try something different this time.

Is it generally alright for a function to ask for parameters it doesn’t end up using? I guess it seems a little counterintuitive, but shouldn’t actually cause any major problem that I can think of.

A vtabe is just a type that holds function pointers.

The thing is that some (most?) interfaces also need to give additional context to those functions, without knowing the context’s type in advance. And in my opinion it’s what obfuscate the inner workings of vtables the most when discovering them.

Type erasure, for example, typically happens by casting a pointer to the context into an opaque pointer (std.mem.Allocator, std.Io). But one can also make the implementer retrieve the context from a pointer to the vtable (what std.Io.Reader and std.Io.Writer do), using @fieldParentPtr for example.

3 Likes