Function signature that uses variadic args as part of the interface vtable

Hi all,

Newbie here, I’m currently trying out/ learning zig, so please bear with me. This might have been answered before, but I couldn’t find anything related to my specific scenario.

As an exercise (interfaces, comptime etc.), I’m trying to create small logging facility. There is a single interface LogTarget which can be implemented by various log sinks, e.g. file, stdout, syslog etc. And it looks like this:

/// This is interface representing log target (e.g. file, std etc.)
pub const LogTarget = struct {
  impl: *anyopaque,
  vtable: *const VTable,

  // vtable for dispatching
  const VTable = struct {
    // should be implemented by log target
    op_write: *const fn(*anyopaque, Level, comptime []const u8, args: anytype) anyerror!void,
  };

  fn init(from: anytype) LogTarget {
    const T = @TypeOf(from);

    const proxy = struct {
      pub fn write(ptr: *anyopaque, lvl: Level, comptime fmt: []const u8, args: anytype) anyerror!void {
        try unwrap(ptr, T).write(lvl, fmt, args);
      }
    };

    return .{
      .impl = from,
      .vtable = &VTable{
        .op_write = proxy.write
      }
    };
  }

  pub fn write(self: LogTarget, lvl: Level, comptime fmt: []const u8, args: anytype) anyerror!void {
    try self.vtable.op_write(self.impl, lvl, fmt, args);
  }
};

Now, I have another type Logger which can have multiple log targets and each one of them should invoke write when I try to log something. Here is how Logger type looks like:

pub const Logger = struct {
  alloc: Alloc,
  targets: std.ArrayList(LogTarget),

  pub fn create(alloc: Alloc, name: []const u8) Logger {
    return .{
      .alloc = alloc,
      .targets = .empty,
    };
  }

  // register log target
  pub fn register(self: *Logger, target: anytype) !void {
    try self.targets.append(
      self.alloc, 
      LogTarget.init(target)
    );
  }

  // log to all registered targets
  pub fn log(self: Logger, lvl: Level, comptime fmt: []const u8, args: anytype) !void {
    for (self.targets.items) |target| {
      try target.write(lvl, fmt, args);
    }
  }

  pub fn deinit(self: *Logger) void {
    self.targets.deinit(self.alloc);
  }
};

One possible implementations of the LogTarget:

// simple stdout target
pub const Console = struct {
  level: Level = .debug,

  // 
  pub fn write(self: *Console, lvl: Level, comptime fmt: []const u8, args: anytype) anyerror!void {
    _ = self;
    _ = args;

    // I would like to be able to use 'args' passed from Logger to construct a message
    // and then manipulate further prior to writing to stdout
    std.debug.print("[{s}] - {s}\n", .{ lvl.str(), fmt });
  }
};

Here is a simple test:

test "logger:noop" {
  const allocator = std.testing.allocator;

  var logger = Logger.create(allocator, "noop-logger");
  defer logger.deinit();

  try logger.log(.info, "we shouldn't see this", .{});
}

Problem arises when I change LogTarget interface and add args:anytype argument to the LogTarget.write function. I would like to be able to forward all args from Logger.log to LogTarget.write (implementation), but when I try to do so, compiler yells at me:

[2026-08-05T16:08:27.585Z] Running test: logger.zig - logger:noop
Command failed: /home/civa/.asdf/shims/zig test --test-filter logger:noop /home/civa/dev/projects/zig/demo/src/lib/logger.zig
src/lib/logger.zig:109:7: error: variable of type 'logger.Logger' must be const or comptime
  var logger = Logger.create(allocator, "noop-logger");
      ^~~~~~
src/lib/logger.zig:62:25: note: struct requires comptime because of this field
  targets: std.ArrayList(LogTarget),
           ~~~~~~~~~~~~~^~~~~~~~~~~
/home/civa/.asdf/installs/zig/0.15.1/lib/std/array_list.zig:615:16: note: struct requires comptime because of this field
        items: Slice = &[_]T{},
               ^~~~~
src/lib/logger.zig:31:11: note: struct requires comptime because of this field
  vtable: *const VTable,
          ^~~~~~~~~~~~~
src/lib/logger.zig:34:15: note: struct requires comptime because of this field
    op_write: *const fn(*anyopaque, Level, comptime []const u8, args: anytype) anyerror!void,
              ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
src/lib/logger.zig:34:15: note: function is generic

Now, I can’t say I fully understand entire compiler error, but I know for sure anytype must be comptime. I cant wrap my head around how to combine comptime with function pointers inside vtable (is it possible?) and should LogTarget interface become comptime entirely?

Sorry for the long post and thanks for the help.

civa

1 Like

Consider this, when the compiler has a anytype as a parameter, it generates the code neeeed for the code at comprime.
So if you are using a a VTable it does not what function it may be and generate the he expected code for each type.

Yep, I’m aware of that. It was just my understanding (or rather lack of) that variadic args (tuple?) could be emulated with anytype like in pub fn print(comptime fmt: []const u8, args: anytype) void.

(Note: I’m still learning zig myself so take this with a grain of salt.)

That’s correct, but the issue is that your vtable contains a function pointer to a function whose arguments are unresolved. Normally, the type of anytype is resolved at the call site based on the arguments, but here it’s just a function pointer so type resolution has to happen independently. When I copied over the code (slightly edited because some code is missing) and ran it, I got this:

test.zig:33:24: error: unable to resolve comptime value
        try self.vtable.op_write(self.impl, lvl, fmt, args);
            ~~~~~~~~~~~^~~~~~~~~

Note that self.vtable.op_write is all that’s highlighted. When the compiler sees a reference to the variable, it tries to resolve the type, but it can’t, and it can’t even use the local context.

But there’s an easy fix. Do all formatting before selecting the target so the LogTarget can just accept a slice representing what it should print.

You can also “compile” the format string and the arguments into a type-erased representation, and that can now be passed to a virtual function.

const RuntimeFormatArg = struct {
    data: *const anyopaque,
    format_string: *const u8,
    type: *const RuntimeTypeInfo,
};
fn printImpl(args: []const RuntimeFormatArg) void;

You’ll need to come up with a useful RuntimeTypeInfo though (it can be just an enum or even a vtable-like object itself).

Something like https://en.cppreference.com/cpp/utility/format/vformat

1 Like

Hi @splinterofchaos and thanks for taking the time to look into it and the explanation - it makes sense.

Do all formatting before selecting the target so the LogTarget can just accept a slice representing what it should print.

Yes, that was my initial plan, but I really wanted each LogTarget to has its own formatting options.

Thanks @gyozo actually I had an idea to use something like this:

const Val = union(enum) {
  str: []const u8,
  num: i64,
  ....
}

but had given up and just tried to slap anytype which obviously wasn’t good idea. Thanks for the help, will go from there and see what happens.

2 Likes

Your enum might be just good enough. Implementing a generic type-erased solution can be a good learning exercise but in practice I think most of the time you would just print strings and objects that you know or are easy to turn into strings (like ints)