Runtime polymorphism—the ability to call a method on a value whose concrete type is unknown at compile time—is a cornerstone of extensible software design. In languages without inheritance hierarchies (Zig, Rust, Go), polymorphism is achieved through virtual function tables (vtables): structs of function pointers that map abstract operations to concrete implementations. Understanding vtable mechanics is essential for building plugin systems, generic containers, and abstraction layers in systems code.
Each language takes a different approach. Go uses implicit interface satisfaction with fat pointers (an iface header containing a type descriptor and a data pointer). Zig uses explicit anyopaque pointers with hand-rolled vtable structs, giving the programmer full control over the dispatch layout. Rust uses dyn Trait objects that pair a data pointer with a compiler-generated vtable. All three share the same underlying mechanism—indirect function calls through a table of pointers—but differ in ergonomics, safety guarantees, and performance characteristics.
In a typical systems database codebase, vtable-based polymorphism appears throughout the storage and indexing layers. The allocator interface itself (std.mem.Allocator) is a vtable: a pointer to an AllocatorVTable plus an anyopaque context pointer. Index backends, compaction strategies, and I/O interfaces all use this pattern to allow swapping implementations without recompilation. This design enables test doubles, tiered storage backends, and runtime-configurable behavior in a language that has no classes and no inheritance.
A vtable (virtual method table) is a struct containing function pointers. Each function pointer corresponds to one method of an abstract interface. At runtime, calling a method on a polymorphic value means loading the function pointer from the vtable and performing an indirect call. The vtable itself is typically shared across all instances of the same concrete type—only one vtable exists per type, and each instance holds a pointer to it.
A fat pointer is a two-word value: one word points to the data, the other points to the vtable (or type metadata). Go interfaces and Rust dyn Trait objects are fat pointers. Zig’s approach packs both the context pointer and the vtable pointer into a single struct, making the fat-pointer representation explicit in user code rather than hidden by the compiler.
Static dispatch (monomorphization) generates specialized code for each concrete type at compile time. Dynamic dispatch uses vtables at runtime. Static dispatch is faster (no indirect call, enables inlining) but increases binary size and requires all types to be known at compile time. Dynamic dispatch supports open-ended extensibility—new types can be added without recompiling existing code—at the cost of an indirect call per method invocation.
A vtable is a struct of function pointers that maps abstract operations to concrete implementations. Combined with an opaque data pointer, it enables runtime polymorphism without inheritance. The pattern is: struct { ptr: *anyopaque, vtable: *const VTable }.
Type erasure converts a concrete type into an opaque pointer plus a vtable. The concrete type information is “erased” from the static type system but preserved at runtime through the function pointers in the vtable. This allows heterogeneous collections and plugin interfaces.
In Zig, you define a vtable as a struct of function pointers, where each function takes an *anyopaque as its first argument (the erased self). The interface struct holds both the opaque pointer and a pointer to the vtable. Concrete types provide an init function that casts *Self to *anyopaque and returns the interface struct with the appropriate vtable.
The standard library’s std.mem.Allocator is the canonical example. Its VTable contains pointers for alloc, resize, and free. Each allocator implementation (page allocator, arena, general purpose) provides a const vtable and an allocator() method that returns the fat-pointer interface.
const Writer = struct {
ptr: *anyopaque,
vtable: *const VTable,
const VTable = struct {
writeFn: *const fn (ptr: *anyopaque, data: []const u8) WriteError!usize,
flushFn: *const fn (ptr: *anyopaque) WriteError!void,
};
pub fn write(self: Writer, data: []const u8) WriteError!usize {
return self.vtable.writeFn(self.ptr, data);
}
pub fn flush(self: Writer) WriteError!void {
return self.vtable.flushFn(self.ptr);
}
};
const FileWriter = struct {
fd: std.os.fd_t,
buffer: [4096]u8 = undefined,
pos: usize = 0,
const vtable = Writer.VTable{
.writeFn = writeImpl,
.flushFn = flushImpl,
};
pub fn writer(self: *FileWriter) Writer {
return .{
.ptr = @ptrCast(self),
.vtable = &vtable,
};
}
fn writeImpl(ptr: *anyopaque, data: []const u8) Writer.WriteError!usize {
const self: *FileWriter = @ptrCast(@alignCast(ptr));
// Buffer the data, flush when full
const space = self.buffer.len - self.pos;
const n = @min(data.len, space);
@memcpy(self.buffer[self.pos..][0..n], data[0..n]);
self.pos += n;
if (self.pos == self.buffer.len) try self.flushImpl(ptr);
return n;
}
fn flushImpl(ptr: *anyopaque) Writer.WriteError!void {
const self: *FileWriter = @ptrCast(@alignCast(ptr));
if (self.pos == 0) return;
_ = std.os.write(self.fd, self.buffer[0..self.pos]) catch
return error.BrokenPipe;
self.pos = 0;
}
};
In Go, any type that implements all methods of an interface automatically satisfies it— no explicit declaration required. The compiler generates an itab (interface table) that contains the concrete type descriptor and function pointers. An interface value is a two-word pair: (itab*, data*). The itab is cached per (interface, concrete type) pair, so repeated conversions are cheap.
// Go interface - any type with these methods satisfies io.Writer
type Writer interface {
Write(p []byte) (n int, err error)
}
// Concrete type satisfies Writer implicitly
type BufferedWriter struct {
fd int
buf []byte
offset int
}
func (bw *BufferedWriter) Write(p []byte) (int, error) {
n := copy(bw.buf[bw.offset:], p)
bw.offset += n
if bw.offset == len(bw.buf) {
if err := bw.flush(); err != nil {
return n, err
}
}
return n, nil
}
// Usage: pass concrete type where interface is expected
func writeAll(w Writer, data []byte) error {
for len(data) > 0 {
n, err := w.Write(data)
if err != nil {
return err
}
data = data[n:]
}
return nil
}
Converting between *anyopaque and concrete pointer types requires @ptrCast (type conversion) and @alignCast (alignment assertion). These are the “unsafe” boundary in Zig’s vtable pattern—if the anyopaque pointer does not actually point to the expected type, behavior is undefined. Encapsulating these casts inside the concrete type’s init function limits the blast radius.
In a typical distributed database, the storage layer uses vtable-based polymorphism to support multiple backends (LSM-tree, B-tree, in-memory) behind a single Storage interface. Each backend provides implementations for get, put, delete, and scan. The query engine and replication layer operate on the abstract interface, allowing backend changes without modifying upper layers.
The search indexing engine in such a system defines an IndexWriter vtable interface with methods like addDocument, deleteDocument, and commit. Different index types (full-text, vector, graph) implement this interface. The document ingestion pipeline processes documents through whichever index backends are configured, using dynamic dispatch to avoid compile-time coupling.
VTable interfaces make testing straightforward. A test can provide a mock implementation that records calls, returns canned data, or simulates failures. In Zig, this is especially powerful with the testing.FailingAllocator—a vtable-compatible allocator that fails after N allocations, enabling exhaustive allocation-failure testing without any mocking framework.
An experimental distributed database can use Go interfaces extensively for its Raft transport, storage engine, and compaction scheduler. The Transport interface abstracts TCP, gRPC, and in-memory transports. During tests, the in-memory transport enables full cluster simulation without network overhead.
Zig’s standard library follows a consistent pattern for vtable interfaces:
ptr: *anyopaque and vtable: *const VTableVTable as a nested const struct of function pointersconst vtable and a method that returns the interface structWhile Zig does not have a built-in concept of “implementing an interface,” you can use comptime assertions to verify at compile time that a concrete type provides all required methods:
fn assertImplementsWriter(comptime T: type) void {
const has_write = @hasDecl(T, "write");
const has_flush = @hasDecl(T, "flush");
if (!has_write or !has_flush) {
@compileError(@typeName(T) ++ " does not implement Writer interface");
}
}
// Use at comptime to verify:
comptime {
assertImplementsWriter(FileWriter); // passes
assertImplementsWriter(NetworkWriter); // passes
// assertImplementsWriter(BadType); // compile error
}
Go interfaces support embedding, allowing you to compose larger interfaces from smaller ones. This follows the interface segregation principle—consumers depend only on the methods they need:
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
// Composed interface
type ReadWriter interface {
Reader
Writer
}
// StorageEngine composes multiple small interfaces
type StorageEngine interface {
Reader
Writer
Closer
Compactor
}
Prefer many small interfaces over few large ones. A function that only reads should accept a Reader, not a ReadWriteCloser. This minimizes coupling, maximizes testability, and makes it easy to provide lightweight test doubles. Go’s standard library exemplifies this with io.Reader, io.Writer, io.Closer, and their compositions.
VTable dispatch involves an indirect function call: load the vtable pointer, load the function pointer from the vtable, then call through it. This adds roughly 2–5 nanoseconds per call on modern hardware (two dependent loads plus a branch prediction miss on the first call). The overhead is negligible for coarse-grained operations (open a file, commit a transaction) but can matter in tight inner loops (per-byte processing, per-element comparisons).
VTable pointers are typically cache-friendly because all instances of the same type share one vtable. After the first call, the vtable is in L1 cache. The data pointer, however, may point to heap-allocated memory that is not contiguous, leading to cache misses when iterating over heterogeneous collections. For performance-critical paths, consider using a tagged union instead of a vtable when the set of types is closed and small.
When the set of concrete types is known and fixed at compile time, a tagged union (union(enum) in Zig, sum type) is faster than vtable dispatch. The switch over the tag is a direct branch (predictable) rather than an indirect call (hard to predict). Use vtables for open sets of types; use tagged unions for closed sets.
anyopaque pointer dangles. Either heap-allocate the concrete value or ensure the interface does not escape the scope where the concrete value lives.@ptrCast from *anyopaque does not check alignment. Forgetting @alignCast leads to undefined behavior on architectures that enforce alignment (ARM, RISC-V). Always use @ptrCast(@alignCast(ptr)) together.nil only when both its type and value pointers are nil. An interface holding a nil concrete pointer is not nil. This causes subtle bugs: var w Writer = (*FileWriter)(nil) makes w != nil true, but calling w.Write(...) panics with a nil pointer dereference.comptime-parameterized function uses static dispatch (monomorphized at compile time), while an anyopaque-based interface uses dynamic dispatch. Accidentally using dynamic dispatch in a hot path when static dispatch was intended can cause significant performance regressions.