In Zig, what's a writer?
openmymind.net
openmymind.net
Alternatively you could argue that this just isn't the right approach within the boundaries of the Zig language: don't try to abstract, just provide a meaningful implementation (in the standard library) and re-use (using composition).
Note:
*) Yes, I know, you can create interfaces but this introduces lots of boilerplate code where to cast/forward pointers to their correct type/implementation. For an example, the author refers to another article: https://www.openmymind.net/Zig-Interfaces/.
Interfaces solve most of the problems of inheritance with none of the problems. Some people would point out that you can implement interfaces yourself, but that isn't a real solution since either people won't bother or there will end up being a library everyone uses to do it.
I think it's a useful abstraction and leaving it out will slow the adoption of Zig.
The proper technique is to "define the system", then "generate the code". Yes, it is very non-trivial and difficult, but all interesting information processing systems are always like that :-)
I may be misunderstanding the article - but it looks like GenericWriter in zig still has dynamic dispatch overheads at runtime in all cases. Rust traits are more like “anytype” - since they get monomorphized by the compiler and have no runtime overhead at all. But unlike zig’s anytype, traits have excellent documentation (since they’re explicit, not implicit interfaces). Rust can also implicitly create an “AnyWriter” style object if you don’t want monomorphization via &dyn Trait. But you often don’t need to, because you can store trait objects in other structs just fine. - Though admittedly, you can do the same in zig via comptime structs.
There are a lot of things I admire about zig. But for interfaces like Writer, rust’s trait system seems like the better tool. I wish zig would copy rust’s trait system into the language.
genericWriter - 31987.66ns per iterations
appendSlice - 20112.35ns per iterations
appendSliceOptimized - 12996.49ns per iterations
`appendSliceOptimized` is implemented using knowledge of the underlying writer, the way that say an interface implementation in Go would be able to. It's a big part of the reason that reading a file in Zig line-by-line can be so much slower than in other languages (2)(1) https://gist.github.com/karlseguin/1d189f683797b0ee00cdb8186...
I think you forgot to run your benchmark in release mode. In debug mode, I get similar results to you. But in release mode, it runs ~5x faster than you reported:
genericWriter - 4035.47ns per iterations
appendSlice - 4026.41ns per iterations
appendSliceOptimized - 2884.84ns per iterations
I bet the first two implementations are emitting identical code. But appendSliceOptimized is clearly much more efficient.For some reason, rust is about twice as fast as zig in this test:
generic_writer time: [1.8812 µs 1.8837 µs 1.8870 µs]
append_slice time: [2.0019 µs 2.0065 µs 2.0126 µs]
append_slice_raw time: [1.6932 µs 1.7041 µs 1.7182 µs]
(1µs = 1000ns)[1] https://gist.github.com/josephg/e2dd6e7baf0a764a21bd724f8a2e...
Tests running on linux 6.8.0, CPU: Ryzen 7950x. zig v0.13 / rust v1.84.1.
Zig command: zig build --release=fast -Dtarget=native run
Rust command: RUSTFLAGS='-C target-cpu=native' cargo run --release -- --bench
e.g., an anytime parameter is checked by the compiler, just a like a parameter of an interface type in a language with interfaces. In either case, if you try to pass a non-conforming type you get a compiler error.
Now, with an interface, you get a symbol name you can hang your hat on. That can really help when you're in the "I'm not exactly sure what I'm doing, I'm trying to figure out this API" phase of development. API documentation and design need to compensate. But you need to be past that phase before you're writing actually useful code anyway, so this isn't that big a deal. Zig's overall simplicity really mitigates this too. You're probably going to be spending less overall time in the confusion stage.
(re "writer: anytype" the article says, "You either have to go through the source code and see how writer is used, or let the compiler tell you which function is expected." which is true, but also true of writer: IWriter. It also leaves off the option of reading the docs. doc comments are a first-class construct in zig, so they are pretty accessible.)
As for anytype specifically, in simple cases where it's being used in a single function, you can quickly figure out what it needs.
But in non trivial cases, the parameter can be passed all over the place, including into different packages. For example `std.json.stringify`. Not only does its own usage of `out_stream: anytype` take more than a glance, it passes it into any custom `jsonStringify` function. So you don't just need to know what `std.json.stringify` needs, but also any what any custom serialization needs.
Like pretty much any parameter in any language? That's abstraction.
Re jsonStringfy, that's a way for a type to control how its own instances are serialized as JSON. std.json.stringify doesn't depend on the details of a jsonStringfy implementation (nor a caller of std.json.stringify). A type being able to implement a method to customize how instances of the type are serialized is a common/typical feature of a lot of JSON serializers.
This is only an issue in languages with duck-typing though. You're not really abstracting something if you need to go look how it's used to understand what to pass.
It’s crappy abstraction. Why make every user of the interface figure out how to use it properly? And guess what functions are required and which are always available?
Rust traits seem like a strictly better tool here. You get exactly the same emitted code as anytype, but the expected interface is explicit and well documented. Any time a trait name appears, you can cmd+click the trait name and see what functions are parts of the trait. It’s clean and explicit.
And traits can also have associated types (eg for an error type). They can have default implementations of functions. You can store a trait object in a struct. And you can use &dyn Trait if you want dynamic dispatch instead of monomorphisation. If this article is anything to go by, Zig makes all of this stuff difficult to impossible.
Anytype only solves the problem for the compiler. Traits solve the problem for the compiler and the programmer at the same time.
Well, having done it, it’s not.
If the authors of the zig standard library can't do it, I assume its impossible. And hence this article talking about 3 different definitions of a "writer" appearing in the standard library, each trying to make up for shortcomings of the others. You have anytype, AnyWriter and GenericWriter. All with weird and complex tradeoffs and interactions. Not only do people need to learn 3 different interfaces, they also need to figure out how to convert from one kind of writer to another!
I talked to Rob Pike, back before Go hit version 1.0. I told him I really thought he should have not only enums, but parametric enums in Go. Of course, he disagreed. He said enums don't add enough value. Honestly I'm not sure if he'd ever used a language with parametric enums - and he didn't understand their value. Having used typescript, and now rust and swift, going back to C or Go and needing to build my own enums out of union structs and so on is awful. It feels like I'm trying to build a house using a rock and a piece of flint.
I see rust's trait system as just like that. What a pity that Zig doesn't have something similar. To me, traits seem like an obvious thing to copy into the language.
1. Documented Interface: Documentation would be a struct with the desired function declarations (combined with some type-checking, I spit-balled an idea for that recently [0]).
2. Default Function Implementations: The exact tool you'll use will vary since the language hasn't hit 1.0, but a `pub usingnamespace FakeInheritanceFooBarBaz(TheTrait, @This());` could have the desired effect.
3. Stored in other structs: Rust doesn't even really give you this. It has syntactic sugar to make the dynamic case easier, and it has the ability to store concrete implementations. For the latter, Zig just works. For the former, you want something that automagically wraps any trait-conforming object in a dynamic pointer-based structure. That's a weak point in the language since the metaprogramming facilities for creating functions and for creating objects with decls aren't very solid (that will get fixed by 1.0). You could make do for now by using comptime fields instead of decls in the wrapper logic that generates this thing (think `foo: fn (A) B` instead of `fn foo(A) B {}`), and that generation code would have to handle different argument counts by hand.
4. Monomorphization: You'd get that for free, since this is just ordinary comptime duck-typing.
5. Dynamic (runtime dispatch: This is really identical to the work for (3).
How would you clean up Writer in std?
The broad theme is that other languages use keywords for traits or subclasses or whatever. In Zig, you can define libraries using the language's metapgrogramming facilities that do the same thing (or similar things).
Zig doesn't have a complete metaprogramming story yet either, which makes it harder (no custom types with custom decls, can't create a function dynamically, ...), but there are still a few options for creating something that looks like an interface.
In my mind, all the obvious solutions have something like a "template" and utilities that operate on that template. The template serves as documentation and also a hint to the LSP. Its exact semantics aren't important; they just have to match however that library decides the implementation should look.
I haven't had as much time as I would have liked to play around with Zig. But, it seems like Zig is like C, but with much stronger static checks, much less UB, lots of useful syntactic sugar, an anti-global agenda (explicitly passed allocator in Zig vs strtok in C) etc. And, truthfully, I really like this.
If we can move from a C/C++/Java world to a Zig/Rust/Go world, I would view that as an absolute win.
As for C++, my experience is that templates can help produce some things in a type safe way that you later tend to regret and understand you should have never produced in the first place :-)
Just created some custom closure producing code that takes a pointer to any function along with arguments. The arguments get stored in custom allocations and can be applied to the function at any later point. Creating the template code is quite painful and probably none of my teammates will be able to maintain it. On the flipside, closures like this might be the right API for the use case.
(The downside is that there's no template parameter inference, but I'm pretty sure that this is one of those things that Zig would avoid by design anyway.)
It is the almost safe path we could have taken if the likes of Modula-2 and Object Pascal, among others, had stayed in the mainstream.
My day job is with TypeScript, so "any" gives me some heartache, but it seems unwarranted because Zig verifies the correctness at compile time. I won't make any claims to really understand Zig yet, but it was enough to make me feel comfortable with continuing to learn it.
However, I recently just swallowed my pride and try to work by convention: provide a method on the implementation type by the same name as the `anytype` argument. `T.writer()` in this case, `T.allocator()` in others, etc.
Previously trying to battle this, I went about as far as the language would let me attempt to automate the type validation: https://github.com/nilslice/zig-interface
This at least allows you to leverage composition to combine "interfaces" - but the caller still faces the `anytype` issue.
I'd like to re-write this (with admitted of help from Claude!) to follow the stdlib pattern used in things like `std.mem.Allocator`, but I reach for this too infrequently to dedicate the time.
I suppose the : operator from lua would be useful.
int(* fnptr_name)(int param) becomes typeof(int(int param))* fnptr_name
There is a recent proposal to update standard headers to this style: https://www.open-std.org/jtc1/sc22/wg14/www/docs/n3450.txt
-void (*signal(int sig, void (*func)(int)))(int);
+typeof(void (int)) *signal(int sig, typeof(void (int)) *func);
Much better honestly. The original took me a few reads to understand, until I noticed the outer (int) is part of the return type.For the most common use cases C syntax is quite ergonomic once you've learned the principle.
// declare func as normal function
void func(ARGUMENTS);
// declare (*func) as normal function: func is a pointer to a normal function
void (*func)(ARGUMENTS);
// can also declare a type name instead of a function
typedef void functype(ARGUMENTS);
// and then use that to declare function-pointer
functype *func;
// can also declare a function pointer type directly (I don't normally do this)
typedef void (*funcptrtype)(ARGUMENTS);
funcptrtype func; // declare function pointerImagine there are two functions with the same signature dog_noise and goose_noise, and goose_noise needs to set up a Honk Apparatus but dog_noise does not, it can easily Bark without prior setup.
Now suppose we want to use our own make_noise_six_times function, but we're going to pass in a function to say which noise. make_noise_six_times(dog_noise) and make_noise_six_times(goose_noise)
With this function pointer approach, make_noise_six_times has no idea about the Honk Apparatus, it will just call into goose_noise six times, each time setting up and tearing down a Honk Apparatus. At runtime these are likely CALL instructions.
However, in a language like Rust that's going to be mono-morphized, make_noise_six_times(dog_noise) and make_noise_six_times(goose_noise) end up generating two implementations which get optimised, there's a good chance the noise sub-functions are inlined - so no function calls - and the creation of the Honk Apparatus may get hoisted out of the loop for the make_noise_six_times(goose_noise) implementation, even though it's across function boundaries, so long as that obeys the "As if" rule.
The reduction in overhead can be dramatic - if your inner functions are tiny the call overhead might dwarf what they actually do, so the inlining makes the whole program orders of magnitude faster in this case. This is very noticeable for e.g. sorting, since the comparison function is executed so often in a hot loop, if that's a C function call it's so much more expensive than if it's a single inlined CPU instruction.
What I'd love is a language which is able to compile 'impl TraitName' into dynamic dispatch in debug mode and only monomorphize it in release mode.
Basically, an automation of this technique: https://play.rust-lang.org/?version=stable&mode=debug&editio...
In Zig or any other C like language without "interfaces", you would implement the V-table by hand, which is a common idiom in Zig.
I'm a bit confused about when you would construct this table and how one would use it
The rest of OOP is lipstick on arrays and arrays of arrays and "structs / records" or software defined arrays.
In my opinion.
In order to make this mechanism generic, you can instead say, "every implementer of the 'Reader' interface has a pointer as its first field, and that pointer leads to an array of two elements: the first element is a pointer to a 'Read' method, the second to a 'Close' method."
This way, the user of a Reader knows nothing of the internals of each implementation, other than how to find its methods
typedef struct Writer {
int (*method_one)(const char*, int);
//...
} Writer;
void takes_a_writer(const Writer *obj) {
// somewhere
obj->method_one(buff, buff_size);
// ...
}
// a method implementation
int method_one_impl(const char *buff, int n) {
//...
}
// somewhere else
Writer w;
w.method_one = method_one_impl;
//...
takes_a_writer(&w);
It isn't the best example, but should do the job giving you an overview. const std = @import("std");
// base struct
const Animal = struct {
// points to the derived struct
ctx: *anyopaque,
// points to the vtable of the concrete type
vtable: *const VTable,
// the vtable interface derived struct must implement
const VTable = struct {
make_noise: *const fn (ctx: *anyopaque, loudness: u32) anyerror!void,
};
// call the derived struct's implementation
pub fn make_noise(animal: Animal, loudness: u32) anyerror!void {
return animal.vtable.make_noise(animal.ctx, loudness);
}
};
const Dog = struct {
// extra data
weight: u32,
// implement the interface
const vtable = Animal.VTable{
.make_noise = &make_noise,
};
pub fn make_noise(ctx: *anyopaque, loudness: u32) anyerror!void {
const dog: *Dog = @alignCast(@ptrCast(ctx));
std.debug.print("woof {} {}\n", .{ dog.weight, loudness });
}
// helper to convert to the base struct
pub fn _animal(self: *Dog) Animal {
return Animal{
.ctx = @ptrCast(self),
.vtable = &vtable,
};
}
};
const Cat = struct {
weight: u32,
const vtable = Animal.VTable{
.make_noise = &make_noise,
};
pub fn _animal(self: *Cat) Animal {
return Animal{
.ctx = @ptrCast(self),
.vtable = &vtable,
};
}
pub fn make_noise(ctx: *anyopaque, loudness: u32) anyerror!void {
const cat: *Cat = @alignCast(@ptrCast(ctx));
std.debug.print("meow {} {}\n", .{ cat.weight, loudness });
}
};
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const alloc = gpa.allocator();
// list of base structs
var animal_list = std.ArrayList(Animal).init(alloc);
defer {
for (animal_list.items) |animal| {
if (animal.vtable == &Dog.vtable) {
const dog: *Dog = @alignCast(@ptrCast(animal.ctx));
alloc.destroy(dog);
} else if (animal.vtable == &Cat.vtable) {
const cat: *Cat = @alignCast(@ptrCast(animal.ctx));
alloc.destroy(cat);
}
}
animal_list.deinit();
}
for (0..20) |i| {
if (i % 2 == 0) {
var dog = try alloc.create(Dog);
dog.* = Dog{ .weight = @intCast(i) };
try animal_list.append(dog._animal());
} else {
var cat = try alloc.create(Cat);
cat.* = Cat{ .weight = @intCast(i) };
try animal_list.append(cat._animal());
}
}
// meows and woofs here
for (animal_list.items) |animal| {
try animal.make_noise(10);
}
}
ive written a couple and still find them mindbendy const Animal = union(enum) {
cat: Cat,
dog: Dog,
pub fn make_noise(self: Animal) void {
switch (self) {
inline else => |impl| impl.make_noise(),
}
}
};Note that the code I wrote is not any particular language; it's just demonstrative.
You just define the required methods and that's the interface.
So, as I stated, you would use the interface keyword.
public interface IFromString<T>
{
static abstract T FromString(string value);
}
public struct MyHasFromString : IFromString<MyHasFromString>
{
public string Text = "";
public MyHasFromString(string value)
{
Text = value;
}
public static MyHasFromString FromString(string value)
=> return new MyHasFromString(value);
public override string ToString() => Text;
}Somebody, once, in the userspace of the language, needs to write a utility that reads a type and produces an interface checker for that type, so that you're able to write code like the following:
const IDog = Checker(TemplateDogType);
Then you can use that when defining a function expecting to conform to some interface: fn bark(_dog: anytype) void {
const dog: IDog(_dog) = _dog;
dog.bark();
}
You can easily get nice compiler errors, no runtime overhead, and all the usual sorts of things you expect from a simple interface system. It's just more verbose.Limiting access to non-interface methods without runtime overhead would be a bit more cumbersome I think. Off the top of my head, the following API is possible though:
fn bark(dog: anytype) void {
IDog.bark(&dog);
}It's not perfect since it's all in the userspace of the language (it'd be nicer to be able to express an interface type in the function signature), but it solves the problem you mentioned completely.