How to use callbacks with run-time arguments?

Here is an example in which a callback with compile-time argument is used.

const std = @import("std");

fn count_until(data: []const u8, comptime untilFn: fn (u8) bool) usize {
    var index: usize = 0;
    while (index < data.len) {
        const c = data[index];
        if (untilFn(c)) {
            return index;
        }
        index += 1;
    }

    return data.len;
}

fn count_until_not_char(data: []const u8, comptime c: u8) usize {
    const T = struct {
        fn isNot(r: u8) bool {
            return r != c;
        }
    };

    return count_until(data, T.isNot);
}

pub fn main() !void {
    const data = "AAAA, world!";
    std.debug.print("{}\n", .{count_until_not_char(data, 'A')}); // 4
}

But I failed to find a way to use callback with run-time arguments. One of my (failed) attempts is shown below.

fn count_until_not_char(data: []const u8, c: u8) usize {
    const T = struct {
    	c: u8,
    	
        fn isNot(r: u8) bool {
            return r != c;
        }
    };
    
    const t = T{.c = c};

    return count_until(data, t.isNot); // error: no field named 'isNot' ...
}

It looks Zig doesn’t support using object methods as function values and it looks function parameters must be comptime in Zig.

How do you implement such needs?

Never mind, just found a way.

const std = @import("std");

fn count_until(data: []const u8, until: anytype) usize {
    var index: usize = 0;
    while (index < data.len) {
        const c = data[index];
        if (until.check(c)) {
            return index;
        }
        index += 1;
    }

    return data.len;
}

const UntilNotChar = struct {
	c: u8,
	
	fn check(self: @This(), r: u8) bool {
		return r != self.c;
	}
};

const UntilChar = struct {
	c: u8,
	
	fn check(self: @This(), r: u8) bool {
		return r == self.c;
	}
};



pub fn main() !void {
    const data = "AAAA, world!";
    
    std.debug.print("{}\n", .{count_until(data, UntilNotChar{.c = 'A'})}); // 4
    std.debug.print("{}\n", .{count_until(data, UntilChar{.c = '!'})});    // 11
}

Do you have simpler ways?

Yeah, if want to have runtime state associated with a function you will need to pass that state around alongside the function (or use a static variable, but I usually try to avoid that).

Your version seems good. Another version similar to what you originally posted and inspired by the implementation of std.sort.insertion is:

const std = @import("std");

fn count_until(
    data: []const u8,
    context: anytype,
    comptime untilFn: fn (@TypeOf(context), u8) bool,
) usize {
    var index: usize = 0;
    while (index < data.len) {
        const c = data[index];
        if (untilFn(context, c)) {
            return index;
        }
        index += 1;
    }

    return data.len;
}

fn count_until_not_char(data: []const u8, c: u8) usize {
    const T = struct {
        c: u8,

        pub fn isNot(self: @This(), r: u8) bool {
            return r != self.c;
        }
    };

    const t = T{ .c = c };
    return count_until(data, t, T.isNot);
}

pub fn main() !void {
    const data = "AAAA, world!";
    std.debug.print("{}\n", .{count_until_not_char(data, 'A')}); // 4
}
2 Likes

From what I’ve seen out in the wild, this technique is also common in many C libraries, where they use *void where Zig uses anytype.

2 Likes

I think an important question here is whether the set of possible “callbacks” is closed or open, meaning: “are all the possible kinds of callbacks known at compile time and they only differ by runtime data?”. If that is the case I would ditch the callbacks and use a tagged union instead:

const std = @import("std");

const Compute = union(enum) {
    found: u8,
    not_found: u8,
    sum_at_least: struct {
        target: usize,
        start: usize = 0,
    },

    fn compute(self: *@This(), r: u8) bool {
        switch (self.*) {
            .found => |f| return f == r,
            .not_found => |n| return n != r,
            .sum_at_least => |*u| {
                u.start += r;
                return u.start >= u.target;
            },
        }
    }
};

fn countUntil(data: []const u8, c: *Compute) usize {
    var index: usize = 0;
    while (index < data.len) {
        const r = data[index];
        if (c.compute(r)) {
            return index;
        }
        index += 1;
    }

    return data.len;
}

pub fn main() !void {
    const seed: u64 = @intCast(std.time.nanoTimestamp());
    std.debug.print("seed: {}\n", .{seed});

    var gen = std.rand.DefaultPrng.init(seed);
    const rng = gen.random();

    const data = "AAAA, world!";
    var c: Compute = switch (rng.uintLessThan(u8, 3)) {
        0 => .{ .found = '!' },
        1 => .{ .not_found = 'A' },
        2 => .{ .sum_at_least = .{ .target = 800 } },
        else => unreachable,
    };
    std.debug.print("compute: {}\n", .{c});

    std.debug.print("{}\n", .{countUntil(data, &c)});
}

If the set is open and you need to choose at runtime, you can define an interface that uses type erasure like std.mem.Allocator instead of the tagged union.

If the choice can be made at comptime you could use a comptime enum (possibly associated with a runtime payload/tagged union) like here: Tagged union with comptime tag - #2 by Sze

I think the other answers are also valid and useful, I just prefer union if it is enough to do the job. I enjoy that it can be used to basically limit the scope of what needs to be considered, in my example you simply see it takes a *Compute you look at compute and can see all the cases, which also makes it easier to evaluate whether countUntil works correctly for all cases. With open solutions you don’t get that. So I guess my 2 cents are, don’t use too generic solutions, staying specific has its benefits.

4 Likes

Thanks all for the suggested ideas. Learned much new Zig knowledge.