This is a very fair question to ask. From the discussions I’ve seen, anytype is one of the most highly debated features of the language, and there isn’t a consensus on how it should be used.
Regarding your point on the number of constraints becoming too big, it’s not necessarily about the absolute quantity/complexity of the constraints, but the complexity relative to the non-generic alternative. If you keep adding methods to an interface, it will increase the work for both the anytype and vtable implementations of the interface. However, for constraints that are expressed in a less consistent/clear way through text than they are though types, more additional work will be required for the anytype implementation.
To give you a picture of the state of anytype and generics, I’d like to list a few interesting points of contention I’ve seen:
Should you use fn (x: anytype) or fn (comptime T: type, x: T) for single-argument generic functions? People seem to lean towards the former, but when you have multiple arguments of the same type, like fn (a: anytype, b: @TypeOf(a)) or fn (comptime T: type, a: T, b: T), people lean towards the latter. If you already have a value of your desired type that you want to pass in to a generic function, then the anytype approach is more convenient because it just infers the type, so if you refactor the value to another type you don’t have to manually change the type parameter. However, this is not always what you want, and it can lead to unintended chain reactions where changing the type of a value you pass in propagates to other values that you wanted to be the original type. The type parameter approach also gives the parameters a concrete result type, allowing you to do things like fun(u32, @intCast(my_i32)).
Should generic types, whether given through anytype or a type parameter, go through a validation step at the beginning of the function? A validation step would allow you to give the caller a tuned compile error about how their type doesn’t meet the constraints, which could better inform them on how to fix it, but this is actually considered improper in Zig. The justification is that this leads to programmers needlessly over-constraining their types, like forcing people to implement many interface methods when only one is actually used in the function.
Should the allocator interface be anytype? This one’s really tough, because allocator parameters are needed everywhere, but allocator usage patterns vary so widely. The vtable interface makes sense in the most common case of C-style programming where you assume allocation is an expensive operation, but if you are using an allocator that is specialized for your use-case and efficiently allocates a lot of little things quickly, then the proportional cost of the virtual calls can be a lot higher. However, with the anytype allocator, you have the problem of storing allocators in structs: do you convert the generic allocator into a vtable one, or do you make the struct itself generic over the allocator type? It’s also tough to reason about how compiler optimization plays into all of this. In theory, the compiler can optimize function pointers into static functions if it knows the function pointer will always point to the same function, but it’s unclear how much you can rely on the compiler to do this, and it’s hard to figure out what you as a programmer would have to do to make sure the compiler knows the function pointer won’t change.
One of the points of using the writer interface is that it allows you to write your code in such a way that you don’t create unnecessary intermediate allocations and instead directly output to whatever the writer outputs to, which in the case of the DummyWriter is nowhere.
So I don’t really understand your argument here. Why do you need to allocate? From my point of view you shouldn’t be mutating while printing something anyway (except maybe create some iterator on the stack), just print to the writer.
If you really have expensive code somewhere (which can be largely avoided if you don’t pre-assemble a large string that is then just output and instead directly write to the writer) you still can use the comptime information that anytype is of type DummyWriter to short circuit and avoid whatever extra work would have to be correctly optimized out by the compiler, but wasn’t.
I think if you are that worried about it you would be inspecting the generated code anyway and add those optimizations where they are needed.
That sounds a bit like (compile-time) generic programming is “infectious”?
From Rust, I have experienced similar issues where type definitions or functions end up with a lot of type parameters when you try to stay generic. Even though Zig uses duck-typing and it’s relatively easy to accept an anytype argument as a function, we cannot do this in types, i.e. the following code is not valid:
Aside from syntax reasons, I also understand that generic programming with anytype may bloat up binary size. So I guess it’s often a compromize whether to be generic at compiletime or use dynamic dispatching at runtime.
I’m still wondering what is the takeaway here? Maybe it is: “There are a lot of ways to accomplilsh polymorphism in Zig. Pick the way that is the best compromise between syntax overhead, documentation style, runtime overhead, binary size, etc.”?
I may have over-emphasized this point. If you own all the code then I agree that allocation isn’t necessary, but I was mainly thinking about OP’s specific case where they call PQerrorMessage (which allocates).
That’s true, I didn’t think about this. I think I lean towards the DummyWriter solution a bit more now, as you can still optimize it to the same degree as the other ones if you really need to.
The thing is that anytype is not a type, it’s permission for a value to have any type. We can have a ‘family’ of types where some fields etc. are of arbitrary type, but that type needs to be provided somehow.
Yes, it absolutely is. If Allocator were implemented as just any instance of anytype, then two structs with an “allocator” field would only be of the same type when the allocation type is identical. Normally that’s not how we want things to work, which is why Allocator is a type-erased fat pointer.