Skip to content

Instantly share code, notes, and snippets.

@perky
Created April 29, 2023 19:34
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save perky/b6ec7efd0c2c3754f350a2adcebc121b to your computer and use it in GitHub Desktop.
Save perky/b6ec7efd0c2c3754f350a2adcebc121b to your computer and use it in GitHub Desktop.
Exploring different ways of using "anytype" in Zig for both static and runtime dispatch.
//! ==============
//! Anytype Antics.
//! ==============
const std = @import("std");
const debug = std.debug;
const builtin = std.builtin;
const trait = std.meta.trait;
fn print(string: []const u8) void { debug.print("{s}\n", .{string}); }
const DONT_COMPILE = false;
// ^ Used to put code that would throw a compile error inside a block
// that gets stripped out at compile time, but we can still read the code
// here for educational purposes.
// Contents:
pub fn main() void {
example_01_duck_typing.main(); // line 48.
example_02_traits.main(); // line 78.
example_03_tagged_union.main(); // line 114.
example_04_auto_union.main(); // line 159.
example_05_assert_interface.main(); // line 221.
example_06_comptime_interface.main(); // line 271.
example_07_anyopaque_interface.main(); // line 335.
print("\nend;");
}
// Toy example of "writers" that share the same "contract".
const FileWriter = struct {
pub fn writeAll(_: FileWriter, bytes: []const u8) void {
debug.print("[FileWriter] {s};", .{bytes});
}
};
const MultiWriter = struct {
pub fn writeAll(_: MultiWriter, bytes: []const u8) void {
debug.print("[MultiWriter] {s};", .{bytes});
}
};
const NullWriter = struct {
pub fn writeAll(_: NullWriter, _: []const u8) void {}
};
// This writer differs from the other, so could be said to have a different "contract".
const BadWriter = struct {
pub fn writeAll(_: BadWriter, val: i32) void {
debug.print("[BadWriter] {d};", .{val});
}
};
const example_01_duck_typing = struct {
// Use antype for duck typing. You'll get a useful compile error if you pass in a type that doesn't have function
// declared called "writeAll" that takes an array of bytes.
// However, to someone reading the code, the requirements of the function signature at first glance are not
// understandable. The reader will either have to read the function body (where the call may be buried), or
// rely on user documentation (which is prone falling out of date).
fn save(writer: anytype, bytes: []const u8) void {
writer.writeAll(bytes);
}
pub fn main() void {
const title = "Duck typing";
print("\n# " ++ title);
var writer = FileWriter{};
save(writer, title);
var multi_writer = MultiWriter{};
save(multi_writer, title);
save(&multi_writer, title);
// ^ Passing by val or by ptr just works :)
var null_writer = NullWriter{};
save(null_writer, title);
if (DONT_COMPILE) {
var bad_writer = BadWriter{};
save(bad_writer, title);
// ^ this will throw a compile error, because BadWriter doesn't implement the proper writeAll.
}
}
};
const example_02_traits = struct {
fn save(writer: anytype, bytes: []const u8) void {
// You can improve the readability of an anytype function by using trait functions to assert
// that the actual type passed in meets the correct requirements. If you make putting this at the
// top of your functions a consistent pattern, it reads like it is part of the function signature.
comptime {
if (!trait.isPtrTo(.Struct)(@TypeOf(writer))) @compileError("Expects writer to be pointer type.");
if (!trait.hasFn("writeAll")(@TypeOf(writer.*))) @compileError("Expects writer.* to have fn 'writeAll'.");
}
writer.writeAll(bytes);
}
pub fn main() void {
const title = "Traits";
print("\n# " ++ title);
var writer = FileWriter{};
save(&writer, title);
var multi_writer = MultiWriter{};
save(&multi_writer, title);
if (DONT_COMPILE) {
save(multi_writer, title);
// ^ will throw compile error.
}
// Conclusion: I like being more explicit with the contact of a function.
// I think that fits into Zig's design pillars:
// - Communicate intent precisely.
// - Favor reading code over writing code.
//
// However this example starts to fall apart when you want to disallow some types
// even if they have the correct contract, or only allow a subset of types.
// Example 03 will try to find a solution to that.
}
};
const example_03_tagged_union = struct {
// Using a tagged enum to make what is effectively a subset of anytype.
// Notice NullWriter is not used, even though it has the correct contract,
// we are explictly defining which types to include in the set.
const AnyWriter = union(enum) {
FileWriter: FileWriter,
MultiWriter: MultiWriter
// ^ Works, but a lot of repeated information.
};
// The save signature is now clear and explicit, lovely.
fn save(writer: AnyWriter, bytes: []const u8) void {
switch (writer) {
inline else => |w| w.writeAll(bytes)
}
// ^ but we've sacrificed how easy it is to read and write the call to "writeAll".
}
fn asUnion(comptime UnionT: type, data: anytype) UnionT {
inline for (@typeInfo(UnionT).Union.fields) |union_field| {
if (union_field.type == @TypeOf(data)) {
return @unionInit(UnionT, union_field.name, data);
}
}
@compileError(@typeName(UnionT) ++ " does not include type " ++ @typeName(@TypeOf(data)));
}
pub fn main() void {
const title = "Tagged Union";
print("\n# " ++ title);
var writer = FileWriter{};
save(AnyWriter{ .FileWriter = writer }, title);
// ^ Too verbose for my liking, we need to state FileWriter twice.
var multi_writer = MultiWriter{};
save(asUnion(AnyWriter, multi_writer), title);
// ^ We can use a helper function to infer type and initialise the union at comptime.
if (DONT_COMPILE) {
var null_writer = NullWriter{};
save(asUnion(AnyWriter, null_writer), title);
// ^ throws a compile error: "AnyWriter does not include type NullWriter".
}
}
};
const example_04_auto_union = struct {
const AnyWriter = TypeSet(.{FileWriter, MultiWriter});
// ^ Go a step further and generate the union type at comptime.
// | There is quite a bit of machinery going on here to generate the union,
// v but this function only has to be defined once and is general purpose.
fn TypeSet(comptime types: anytype) type {
var enum_fields: [types.len]builtin.Type.EnumField = undefined;
inline for (types, 0..) |T, i| {
enum_fields[i] = .{ .name = @typeName(T), .value = i };
}
const Tag = @Type(.{
.Enum = .{
.tag_type = std.meta.Int(.unsigned, std.math.log2_int_ceil(u16, types.len)),
.fields = &enum_fields,
.decls = &[_]builtin.Type.Declaration{},
.is_exhaustive = true,
}
});
var union_fields: [types.len]builtin.Type.UnionField = undefined;
inline for (types, 0..) |T, i| {
union_fields[i] = .{ .name = @typeName(T), .type = T, .alignment = @alignOf(T) };
}
const U = @Type(.{
.Union = .{
.layout = .Auto,
.tag_type = Tag,
.fields = &union_fields,
.decls = &[_]builtin.Type.Declaration{}
}
});
return U;
}
fn asUnion(comptime UnionT: type, data: anytype) UnionT {
return @unionInit(UnionT, @typeName(@TypeOf(data)), data);
// ^ Generating the union type actual simplifies comptime initialisation too.
}
// The save function remains unchanged from the last example.
fn save(writer: AnyWriter, bytes: []const u8) void {
switch (writer) {
inline else => |w| w.writeAll(bytes)
}
}
pub fn main() void {
const title = "Auto Union";
print("\n# " ++ title);
var file_writer = FileWriter{};
save(asUnion(AnyWriter, file_writer), title);
var multi_writer = MultiWriter{};
save(asUnion(AnyWriter, multi_writer), title);
if (DONT_COMPILE) {
var null_writer = NullWriter{};
save(asUnion(AnyWriter, null_writer), title);
// ^ throws compile error: "no field named 'anytype.NullWriter' in union..."
}
}
};
const example_05_assert_interface = struct {
const IWriter = struct {
const writeAll = fn (anytype, []const u8) void;
};
fn save(writer: anytype, bytes: []const u8) void {
// Expressing the requirements is less verbose now.
// You simply read the IWriter definition.
comptime assertInterface(IWriter, writer);
writer.writeAll(bytes);
}
fn assertInterface(comptime I: type, val: anytype) void {
const T = @TypeOf(val);
inline for (@typeInfo(I).Struct.decls) |decl| {
if (!@hasDecl(T, decl.name)) {
@compileError(@typeName(T) ++ " does not have function: " ++ decl.name);
}
const IFn_t = @field(I, decl.name);
const TFn_t = @TypeOf(@field(T, decl.name));
const IFn_info = @typeInfo(IFn_t).Fn;
const TFn_info = @typeInfo(TFn_t).Fn;
if (IFn_info.params.len != TFn_info.params.len) {
@compileError(@typeName(T) ++ " function: " ++ decl.name ++ " has invalid params count.");
}
inline for (IFn_info.params, TFn_info.params, 0..) |a, b, i| {
if (i == 0) continue;
if (a.type != b.type) {
@compileError(@typeName(T) ++ " function: " ++ decl.name ++ " has invalid param: " ++ @typeName(b.type.?));
}
}
}
}
pub fn main() void {
const title = "Assert Interface";
print("\n# " ++ title);
var writer = FileWriter{};
save(writer, title);
var multi_writer = MultiWriter{};
save(multi_writer, title);
if (DONT_COMPILE) {
var bad_writer = BadWriter{};
save(bad_writer, title);
// ^ assertion will throw "error: anytype.BadWriter function: writeAll has invalid param: i32".
}
}
};
const example_06_comptime_interface = struct {
const FileWriter2 = struct {
prefix: []const u8,
// In this example we need to change the self type to anytype,
// because the interface will keep a function pointer to this
// but can't know about the concrete type!
pub fn writeAll(self: anytype, bytes: []const u8) void {
debug.print("[{s}] {s};", .{self.prefix, bytes});
}
};
const NullWriter2 = struct {
pub fn writeAll(_: anytype, _: []const u8) void {}
};
// The interface is a simple struct, where each field is a function pointer.
// The first param has to be anytype, so it can correctly call it as a member function.
const IWriter = struct {
writeAll: *const fn (anytype, []const u8) void
};
// A comptime helper function that automatically instantiates the interface struct
// with the fields pointing to those in the concrete type.
fn interface(comptime I: type, comptime T: type) I {
var result: I = undefined;
inline for (@typeInfo(I).Struct.fields) |field| {
@field(result, field.name) = @field(T, field.name);
}
return result;
}
// The save function now takes an extra parameter: "interface", with the type of the interface.
// The function signature explicitly defines the contract now, however we still had to keep
// the anytype param to the writer around. "interface" has to be comptime known, because it has pointers to
// generic functions, so the interface struct cannot hold the runtime writer data, hence needing "writer" as a
// runtime parameter.
fn save(comptime iwriter: IWriter, writer: anytype, bytes: []const u8) void {
iwriter.writeAll(writer, bytes);
}
pub fn main() void {
const title = "Comptime Interface";
print("\n# " ++ title);
var file_writer = FileWriter2{ .prefix = "FileWriter-Foobar" };
save(interface(IWriter, FileWriter2), file_writer, title);
// ^ Verbosity has increased at the call site, but the function is more explicit in what it expects.
var null_writer = NullWriter2{};
save(interface(IWriter, NullWriter2), null_writer, title);
if (DONT_COMPILE) {
// Pass the wrong interface.
save(interface(IWriter, FileWriter2), null_writer, title);
// ^ Throws compile error: "no field named 'prefix' in struct 'anytype.example_06_comptime_interface.NullWriter2'".
save(interface(IWriter, NullWriter2), file_writer, title);
// ^ Interestingly, passing the NullWriter interface with a FileWriter instance compiles and runs!
// That's becuase NullWriter doesn't actually do anything with the instance, so the contract
// is still technically valid.
}
// Conclusion: I'm not particularily keen on this pattern. It's requires extra boilerplate
// and is a maybe too verbose, also being able to compile & run save(NullWriter, file_writer, bytes)
// is interesting but it smells of potential bugs that slip by unnoticed.
}
};
const example_07_anyopaque_interface = struct {
const FileWriter2 = struct {
prefix: []const u8,
pub fn writeAll(ptr: *anyopaque, bytes: []const u8) void {
var self = ptrCast(FileWriter2, ptr);
// ^ Extra work is required in each of the concrete types to convert the opaque ptr.
debug.print("[{s}] {s};", .{self.prefix, bytes});
}
};
const NullWriter2 = struct {
pub fn writeAll(_: *anyopaque, _: []const u8) void {}
};
// The first param to each function-ptr is now *anyopaque,
// and the first field needs to be "impl: *anyopaque".
const IWriter = struct {
impl: *anyopaque,
writeAll: *const fn (*anyopaque, []const u8) void
};
// Helper function to convert an opaque-ptr to ptr-of-concrete-type.
fn ptrCast(comptime T: type, ptr: *anyopaque) *T {
return @ptrCast(*T, @alignCast(@alignOf(T), ptr));
}
// A comptime helper function that automatically instantiates the interface struct
// with the fields pointing to those in the concrete type, and also an opaque pointer
// pointing to the implementation.
fn interface(comptime I: type, impl: anytype) I {
var result: I = undefined;
result.impl = impl;
const T = @TypeOf(impl.*);
inline for (@typeInfo(I).Struct.fields) |field| {
if (@hasDecl(T, field.name)) {
@field(result, field.name) = @field(T, field.name);
}
}
return result;
}
// The save function signature has less clutter now.
fn save(writer: IWriter, bytes: []const u8) void {
writer.writeAll(writer.impl, bytes);
}
pub fn main() void {
const title = "Anyopaque Interface";
print("\n# " ++ title);
var file_writer = FileWriter2{ .prefix = "FileWriter-Foobar" };
save(interface(IWriter, &file_writer), title);
var null_writer = NullWriter2{};
save(interface(IWriter, &null_writer), title);
}
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment