Hey gang - working on an ergonomic decoupled notification pattern and have come up with the following. As you can see in the add()
method i’m comptime introspecting on the adding listener type to rendezvous with the notification functions by name. I’m generating a const struct instance at comptime and then using the address of that in the listener struct that gets added to the list. Is this a legit way to cache that struct at comptime and then just use a pointer rather than copying the whole struct around (some notifiers may have loads of notifications)?
Here’s the code - any constructive critique welcomed:
const std = @import("std");
const LinkedList = @import("LinkedList.zig").LinkedList;
pub const ListenerError = error{ListenerError};
pub fn Listeners(comptime Vtable: type) type {
// Check that Vtable's fields are all optional functions with an initial argument of *anyopaque
comptime {
const info = @typeInfo(Vtable);
if (info != .@"struct") @compileError("Vtable must be a struct");
for (info.@"struct".fields) |field| {
// Check if field is optional
if (@typeInfo(field.type) != .optional) {
@compileError("Vtable field '" ++ field.name ++ "' must be optional");
}
// Get the function type (unwrap optional)
const fn_ptr_type = @typeInfo(field.type).optional.child;
if (@typeInfo(fn_ptr_type) != .pointer) {
@compileError("Vtable field '" ++ field.name ++ "' must be a function pointer");
}
const fn_info = @typeInfo(@typeInfo(fn_ptr_type).pointer.child);
if (fn_info != .@"fn") {
@compileError("Vtable field '" ++ field.name ++ "' must be a function");
}
// Check first parameter is *anyopaque
if (fn_info.@"fn".params.len == 0 or fn_info.@"fn".params[0].type != *anyopaque) {
@compileError("Vtable field '" ++ field.name ++ "' must have first parameter of *anyopaque");
}
}
}
return struct {
allocator: std.mem.Allocator,
listeners: LinkedList(Listener),
const Listener = struct {
context: *anyopaque,
vtable: *const Vtable,
pub fn equals(this: Listener, other: Listener) bool {
return this.context == other.context;
}
};
const Self = @This();
pub fn init(allocator: std.mem.Allocator) Self {
return .{
.allocator = allocator,
.listeners = LinkedList(Listener).init(),
};
}
pub fn deinit(self: *Self) void {
self.listeners.deinit(self.allocator);
}
pub fn add(self: *Self, listener: anytype) !void {
const ListenerType = @TypeOf(listener);
const listener_info = @typeInfo(ListenerType);
// Must be a pointer to struct
comptime if (listener_info != .pointer or @typeInfo(listener_info.pointer.child) != .@"struct") {
@compileError("Listener must be a pointer to a struct");
};
// Create vtable by matching function signatures
const vtable: Vtable = comptime blk: {
var vtable: Vtable = undefined;
var has_match = false;
// Iterate through Vtable fields
for (@typeInfo(Vtable).@"struct".fields) |vtable_field| {
const fn_type = @typeInfo(vtable_field.type).optional.child;
const fn_info = @typeInfo(@typeInfo(fn_type).pointer.child).@"fn";
// Check if listener has this method
if (@hasDecl(listener_info.pointer.child, vtable_field.name)) {
const listener_fn = @field(listener_info.pointer.child, vtable_field.name);
const listener_fn_info = @typeInfo(@TypeOf(listener_fn)).@"fn";
// Check parameter count matches
if (listener_fn_info.params.len != fn_info.params.len) {
@compileError("Parameter count mismatch for '" ++ vtable_field.name ++ "'");
}
// Check all parameters match (skip first *anyopaque)
for (fn_info.params, listener_fn_info.params, 0..) |param, listener_param, i| {
if (i == 0) {
if (listener_param.type != @TypeOf(listener)) {
@compileError("First parameter type mismatch for '" ++ vtable_field.name ++ "'");
}
} else if (param.type != listener_param.type) {
@compileError("Parameter type mismatch for '" ++ vtable_field.name ++ "'");
}
}
// Check return type matches
if (fn_info.return_type != listener_fn_info.return_type) {
@compileError("Return type mismatch for '" ++ vtable_field.name ++ "'");
}
@field(vtable, vtable_field.name) = @ptrCast(&listener_fn);
has_match = true;
} else {
@field(vtable, vtable_field.name) = null;
}
}
if (!has_match) {
@compileError("No matching functions found in listener!");
}
break :blk vtable;
};
try self.listeners.append(.{
.context = @ptrCast(listener),
.vtable = &vtable,
}, self.allocator);
}
pub fn remove(self: *Self, listener: anytype) !void {
self.listeners.remove(.{
.context = @ptrCast(listener),
.vtable = undefined, // We only compare context in equals()
}, self.allocator);
}
pub fn dispatch(self: *Self, comptime func_name: []const u8, args: anytype) !void {
// Ensure args is a tuple
const ArgsType = @TypeOf(args);
comptime if (@typeInfo(ArgsType) != .@"struct" or !@typeInfo(ArgsType).@"struct".is_tuple) {
@compileError("dispatch args must be a tuple");
};
var iter = self.listeners.iterator();
while (iter.next()) |listener| {
if (@field(listener.vtable, func_name)) |func| {
const FuncInfo = @typeInfo(@typeInfo(@TypeOf(func)).pointer.child).@"fn";
const returns_error = comptime switch (@typeInfo(FuncInfo.return_type.?)) {
.error_union => true,
else => false,
};
if (returns_error) {
try @call(.auto, func, .{listener.context} ++ args);
} else {
@call(.auto, func, .{listener.context} ++ args);
}
}
}
}
};
}
const TestNotification = struct {
onSomething: ?*const fn (target: *anyopaque, object: *TestNotifier, val: i32) ListenerError!void,
onSomethingElse: ?*const fn (target: *anyopaque, object: *TestNotifier, before: []const u8, after: []const u8) ListenerError!void,
};
const TestNotifier = struct {
value: i32,
listeners: Listeners(TestNotification),
fn init(allocator: std.mem.Allocator) TestNotifier {
return .{
.listeners = Listeners(TestNotification).init(allocator),
.value = 0,
};
}
fn deinit(self: *TestNotifier) void {
self.listeners.deinit();
}
fn setValue(self: *TestNotifier, value: i32) !void {
self.value = value;
try self.listeners.dispatch("onSomething", .{ self, value });
}
fn setOtherValue(self: *TestNotifier, before: []const u8, after: []const u8) !void {
try self.listeners.dispatch("onSomethingElse", .{ self, before, after });
}
};
const TestListener = struct {
onSomethingReceived: bool = false,
onSomethingElseReceived: bool = false,
fn onSomething(self: *TestListener, object: *TestNotifier, val: i32) ListenerError!void {
_ = object;
_ = val;
self.onSomethingReceived = true;
}
fn onSomethingElse(self: *TestListener, object: *TestNotifier, before: []const u8, after: []const u8) ListenerError!void {
_ = object;
_ = before;
_ = after;
self.onSomethingElseReceived = true;
return error.ListenerError;
}
};
test "Notification" {
const allocator = std.testing.allocator;
var notifier = TestNotifier.init(allocator);
defer notifier.deinit();
var listener = TestListener{};
try notifier.listeners.add(&listener);
var listener2 = TestListener{};
try notifier.listeners.add(&listener2);
try notifier.setValue(12);
try std.testing.expect(listener.onSomethingReceived);
try std.testing.expectError(ListenerError.ListenerError, notifier.setOtherValue("before", "after"));
try std.testing.expect(listener.onSomethingElseReceived);
}