Dynamic polymorphism is mildly put not encouraged by zig by having to manually do vtables à la c. But I see a contradiction in that at the same time zig ends up relying on it for every single method that does allocation or io. Zig chose the polymorphic route to begin with for allocation as cpp pmr (and not the comptime like original cpp allocators to avoid hell) and now also for io for its flexibility in plugging in async runtimes.
So if admittedly dynamic dispatch has such valid cool use cases and devirtualization is also there to eliminate even the overhead why not have language support for ergonomics. Do “simple language” and “no hidden control flow” have to really exclude it when its performed on every alloc/io method?
The only ergonomic improvement I can think of is syntactic sugar for creating the vtable and I don’t think that’s worth doing as manually creating it is inconvenient but not difficult.
I don’t believe zig discourages you from creating a dynamic dispatch based interface but pushes you to think about what type of interface you should use as dynamic dispatch is only bad when used in the wrong place.
Zigs stance is that abstraction, and interfaces by extension, should be avoided until necessary.
The allocator interface is necessary to allow you to choose allocation strategy at runtime.
Zig has deemed Io to be the best place to do most platform abstraction, as that’s where most interaction with the platform happens. It also deemed it the best place to implement async, as non-blocking I/O is the primary use case of async. An interface allows you to have multiple async strategies if necessary, and lets the compile deal with the common case of only having one strategy with devirtualisation.
Aside from friction to discourage you from doing it too early, another reason to not have it has a language feature is there are so many ways to do it. Zig doesn’t want to encourage a specific kind of interface over the others.
On this issue, I believe dynamic polymorphism is not bad; it’s just that its implementation details have many variations and should not be abstracted away.
Zig already has the tagged union, which is the encouraged way of doing runtime polymorphism. And if you look at the numbers, it is actually used quite a lot:
I don’t think it makes sense to add a language feature for something that is only used 3 times in the standard library. Yes it is very useful in these rare cases, but surely you can afford to write a few more lines of code in these rare cases in exchange for a much simpler language and more freedom (e.g. no other language lets me put other data types into the VTable, instead I’d have to workaround it by using a getter function that returns a constant)
One point I want to bring up here is that: while dynamic dispatch is generally frowned upon, there are language features that can make it work better and safer: pinned structs, restricted functions, safe pointer casting, …
It seems solving those problems interest Zig developers more than bikeshedding syntaxes.
Zig encourages concrete implementations, that’s just how it is. Enum switches (runtime or comptime) are the preferred way.
I was thinking about this recently and realized how very differently I think in Zig code vs elsewhere. In C++, vtables are so easy to do, that I tend to overuse virtual methods. The same applies for heap allocations, I just do it if I don’t have to see what’s being done.
There are times I wish for a nice way of representing comptime interfaces instead of anytype, but for runtime interfaces I can deal with explicit vtables.
From a performance perspective, I think the gist is that an indirect jump isn’t a problem on modern CPUs compared to a direct jump (because of prefetching, branch prediction and speculative execution), but that going through a jump table is an optimization barrier for the compiler (e.g. the code can’t be inlined which would then allow further optimizations).
E.g. TL;DR: “it depends”, sometimes it might make a massive difference, sometimes none at all.
There’s been plenty of discussion on this topic. Currently, memory allocation in Zig suffers from what I call a “lexicon deficit”. Because the word “allocator” is already used for the vtable interface, we don’t have a comptime duck-typed interface. The concept doesn’t exist for the lack of a name.
It’d be helpful if we introduce two new constructs, that of memory sources and allocation strategies. The former represents basically the “where” while the latter represents the “how”. So the heap is a source, the stack is a source, while debug, fallback, arena, and others are strategies.
Both of these things can provide the std.mem.Allocator interface, but they’d also have their own duck-typed interface with the same methods that we can call directly. For example, say we have the following:
There’s no reason to go through a vtable here. Polymorphicism is not involved. We shouldn’t have to rely on the compiler de-virtualizing the call behind the scene for us. If we have the concept of memory source, then we can go straight to the source:
The great thing about comptime interfaces is that they’re easy to extend. If a particular implementation wants to expose additional functionalities, it can just do so. We don’t need to weight the benefit of their availability against the cost of expanding the vtable.