Created
April 29, 2023 19:34
-
-
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
//! ============== | |
//! 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