← Code Compare

Interfaces & Dynamic Dispatch

Runtime polymorphism means picking which area() to run only when the program runs, based on the object's concrete type. Most systems languages have no built-in interface or class hierarchy for this - you build it yourself out of plain data, either with a vtable (a struct of function pointers the object carries) or a tagged union (one value that knows its variant and is switched on). The same task in all seven languages - a Shape whose area() dispatches at runtime - lays the two approaches side by side, and the memory angle is central: a vtable is one shared, static table pointed at by every instance, so the per-object cost is a single pointer, while the objects themselves still need explicit allocation and freeing.

Show: CC++HolyCZigHareOdinForth
C
#include <stdio.h>
#include <stdlib.h>

/* No built-in interfaces in C. A 'vtable' is just a struct of
 * function pointers; every object carries a pointer to ONE shared,
 * static table, so dispatch costs one indirect call. */
struct Shape;
struct ShapeVTable {
    double (*area)(const struct Shape *self);   /* the virtual method */
};

struct Shape {
    const struct ShapeVTable *vtable;           /* points at a shared table */
};

/* Each concrete type embeds Shape as its first member, so a
 * 'struct Circle *' can be treated as a 'struct Shape *'. */
struct Circle { struct Shape base; double r; };
struct Rect   { struct Shape base; double w, h; };

static double circle_area(const struct Shape *s) {
    const struct Circle *c = (const struct Circle *)s;  /* downcast */
    return 3.14159265 * c->r * c->r;
}
static double rect_area(const struct Shape *s) {
    const struct Rect *r = (const struct Rect *)s;
    return r->w * r->h;
}

/* ONE table per type, with static storage: shared by all instances. */
static const struct ShapeVTable CIRCLE_VT = { circle_area };
static const struct ShapeVTable RECT_VT   = { rect_area };

static struct Shape *new_circle(double r) {
    struct Circle *c = malloc(sizeof *c);       /* heap the object */
    if (!c) return NULL;
    c->base.vtable = &CIRCLE_VT;                /* wire up dispatch */
    c->r = r;
    return &c->base;
}
static struct Shape *new_rect(double w, double h) {
    struct Rect *r = malloc(sizeof *r);
    if (!r) return NULL;
    r->base.vtable = &RECT_VT;
    r->w = w; r->h = h;
    return &r->base;
}

int main(void) {
    struct Shape *shapes[2] = { new_circle(2.0), new_rect(3.0, 4.0) };
    for (int i = 0; i < 2; i++) {
        /* Runtime dispatch: the table the object points to decides. */
        printf("area = %.4f\n", shapes[i]->vtable->area(shapes[i]));
    }
    for (int i = 0; i < 2; i++) free(shapes[i]);  /* one free per object */
    return 0;
}

C has no interfaces, so polymorphism is hand-built: a Shape holds a pointer to a ShapeVTable (a struct of function pointers), and each concrete type embeds Shape as its first field so the pointers are interchangeable. The vtables CIRCLE_VT/RECT_VT are static - one shared, read-only table per type - so each heap object only pays for a single vtable pointer; you still malloc each object and match it with a free.

C++
#include <iostream>
#include <memory>
#include <vector>
#include <numbers>

// C++ has interfaces built in: an abstract base class with a pure
// virtual method. The compiler emits the vtable and the vptr for you.
struct Shape {
    virtual double area() const = 0;   // pure virtual -> abstract
    virtual ~Shape() = default;        // virtual dtor: delete via base is safe
};

struct Circle final : Shape {
    double r;
    explicit Circle(double r) : r{r} {}
    double area() const override { return std::numbers::pi * r * r; }
};

struct Rect final : Shape {
    double w, h;
    Rect(double w, double h) : w{w}, h{h} {}
    double area() const override { return w * h; }
};

int main() {
    // Own the polymorphic objects through the base class by unique_ptr;
    // each holds a compiler-generated vptr to its type's shared vtable.
    std::vector<std::unique_ptr<Shape>> shapes;
    shapes.push_back(std::make_unique<Circle>(2.0));
    shapes.push_back(std::make_unique<Rect>(3.0, 4.0));

    for (const auto &s : shapes)
        std::cout << "area = " << s->area() << '\n';  // virtual dispatch

    // unique_ptr + virtual ~Shape() destroys each derived object
    // correctly at scope exit - no manual delete, no slicing, no leak.
}

C++ provides interfaces directly: an abstract Shape with a pure virtual area(), and the compiler synthesizes the per-type vtable and the per-object vptr. A std::vector<std::unique_ptr<Shape>> owns the polymorphic objects through the base pointer; the virtual destructor ensures the correct derived destructor runs, so RAII frees every object exactly once with no manual delete.

HolyC
// HolyC has no virtual methods, so build a C-style vtable by hand:
// each object carries a pointer to a shared table of function pointers.
class ShapeVTable {
  F64 (*area)(U8 *self);   // the one virtual method
};

class Shape {
  ShapeVTable *vtable;     // points at a shared, static-ish table
};

// Concrete types put Shape first so a Circle* doubles as a Shape*.
class Circle { Shape base; F64 r; };
class Rect   { Shape base; F64 w, h; };

F64 CircleArea(U8 *self) {
  Circle *c = self;        // reinterpret the bytes as a Circle
  return 3.14159265 * c->r * c->r;
}
F64 RectArea(U8 *self) {
  Rect *r = self;
  return r->w * r->h;
}

// One table per type. (HolyC has no 'static const'; these live as
// global instances, shared by every object of that type.)
ShapeVTable circle_vt, rect_vt;
circle_vt.area = &CircleArea;
rect_vt.area   = &RectArea;

Shape *NewCircle(F64 r) {
  Circle *c = MAlloc(sizeof(Circle));   // per-task heap
  c->base.vtable = &circle_vt;          // wire up dispatch
  c->r = r;
  return &c->base;
}
Shape *NewRect(F64 w, F64 h) {
  Rect *r = MAlloc(sizeof(Rect));
  r->base.vtable = &rect_vt;
  r->w = w; r->h = h;
  return &r->base;
}

// Top-level code runs in TempleOS -- no main needed.
Shape *shapes[2];
shapes[0] = NewCircle(2.0);
shapes[1] = NewRect(3.0, 4.0);

I64 i;
for (i = 0; i < 2; i++)
  Print("area = %5.4f\n", shapes[i]->vtable->area(shapes[i]));  // dispatch

for (i = 0; i < 2; i++)
  Free(shapes[i]);          // one Free per object; no GC in ring-0

HolyC has no virtual methods, so the idiom is exactly C's: a Shape holds a pointer to a hand-built ShapeVTable, and each concrete class embeds Shape first so the pointers interchange. The vtables are global instances shared by every object, while each object is carved from the per-task data heap with MAlloc and returned with Free - and since ring-0 TempleOS has no memory protection, a stray cast or double-free can corrupt the system.

Zig
const std = @import("std");

// Zig has no interface keyword. The idiomatic runtime-polymorphism
// tool is a TAGGED UNION: one value that knows its own variant, with
// dispatch done by an exhaustive switch (no vtable, no heap per call).
const Shape = union(enum) {
    circle: struct { r: f64 },
    rect: struct { w: f64, h: f64 },

    fn area(self: Shape) f64 {
        return switch (self) {
            .circle => |c| std.math.pi * c.r * c.r,
            .rect => |r| r.w * r.h,
        };
    }
};

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();          // reports leaks on exit
    const allocator = gpa.allocator();

    // Heap-allocate a slice of shapes; the allocator is explicit.
    const shapes = try allocator.alloc(Shape, 2);
    defer allocator.free(shapes);    // paired free, runs on every exit
    shapes[0] = .{ .circle = .{ .r = 2.0 } };
    shapes[1] = .{ .rect = .{ .w = 3.0, .h = 4.0 } };

    for (shapes) |s|
        std.debug.print("area = {d:.4}\n", .{s.area()});  // switch dispatch
}

Zig has no interfaces; the idiomatic runtime-polymorphism tool is a tagged union (union(enum)) whose area() switches exhaustively over the active variant - no vtable, no per-call indirection, and the compiler errors if you forget a case. The shapes here live in an explicitly allocated slice (allocator.alloc) paired with defer allocator.free, so dispatch itself allocates nothing and the leak-checking GPA confirms the single free balances the alloc. (For open-ended sets Zig also supports a manual *anyopaque + *const VTable fat pointer, as std.mem.Allocator itself uses.)

Hare
use fmt;
use math;

// Hare has no interfaces. Runtime polymorphism is a TAGGED UNION:
// the value carries its variant tag, and 'match' dispatches on it.
type circle = struct { r: f64 };
type rect = struct { w: f64, h: f64 };
type shape = (circle | rect);

fn area(s: *shape) f64 = {
	match (*s) {
	case let c: circle =>
		return math::PI * c.r * c.r;
	case let r: rect =>
		return r.w * r.h;
	};
};

export fn main() void = {
	// Manual heap: alloc each shape onto the heap, defer the frees.
	let shapes: [2]*shape = [
		alloc(circle { r = 2.0 }: shape),
		alloc(rect { w = 3.0, h = 4.0 }: shape),
	];
	defer for (let i = 0z; i < len(shapes); i += 1)
		free(shapes[i]);          // one free per alloc, at scope exit

	for (let i = 0z; i < len(shapes); i += 1)
		fmt::printfln("area = {}", area(shapes[i]))!;  // match dispatch
};

Hare has no interface or class system, so runtime polymorphism uses a tagged union (type shape = (circle | rect)) that area dispatches on with match, which the compiler checks for exhaustiveness. Each shape is placed on the heap with alloc and reclaimed with free (Hare has no GC); defer schedules one free per alloc so every object is released at scope exit no matter how the function returns.

Odin
package main

import "core:fmt"
import "core:math"

// Odin has no interfaces. Runtime polymorphism is a tagged 'union':
// the value knows its variant, and a type 'switch' dispatches on it.
Circle :: struct { r: f64 }
Rect   :: struct { w, h: f64 }
Shape  :: union { Circle, Rect }

area :: proc(s: ^Shape) -> f64 {
	switch v in s^ {        // type switch over the union's variants
	case Circle: return math.PI * v.r * v.r
	case Rect:   return v.w * v.h
	}
	return 0                // nil-variant fallthrough
}

main :: proc() {
	// new() allocates each Shape via the implicit context.allocator;
	// returns ^Shape. defer the frees so every path releases them.
	shapes: [2]^Shape
	shapes[0] = new(Shape); shapes[0]^ = Circle{r = 2.0}
	shapes[1] = new(Shape); shapes[1]^ = Rect{w = 3.0, h = 4.0}
	defer for s in shapes do free(s)   // one free per new

	for s in shapes do
		fmt.printf("area = %.4f\n", area(s))   // type-switch dispatch
}

Odin offers no interface keyword; the idiom is a tagged union (Shape :: union { Circle, Rect }) that area resolves with a type switch, the union carrying its own variant tag at runtime. Each Shape is allocated with new through the implicit context.allocator (returning ^Shape) and matched by free; defer ... do free(s) runs one free per new - swap the context allocator for an arena and you could drop the per-object frees entirely.

Forth
\ Forth has no objects or interfaces. The closest idiom to a vtable
\ is an execution-token (xt) field: each object stores the xt of its
\ own 'area' word, and EXECUTE dispatches it at runtime.
\ Layout per object: cell 0 = xt of area, cell 1.. = the data fields.

0 CELLS CONSTANT .AREA    \ field: execution token of the area word
1 CELLS CONSTANT .D0      \ first data field (r, or w)
2 CELLS CONSTANT .D1      \ second data field (h, for a rect)

\ Each 'method' takes the object address and returns its area*100
\ (integer Forth: we scale to avoid floats).
: CIRCLE-AREA ( obj -- n )  .D0 + @  DUP *  314 * ;
: RECT-AREA   ( obj -- n )  DUP .D0 + @  SWAP .D1 + @  *  100 * ;

: NEW-CIRCLE ( r -- obj )       \ allocate from the heap word set
  2 CELLS ALLOCATE THROW        ( r obj )
  ['] CIRCLE-AREA OVER .AREA + !  ( r obj )  \ store the xt = 'vtable'
  TUCK .D0 + ! ;                ( obj )      \ store r

: NEW-RECT ( w h -- obj )
  3 CELLS ALLOCATE THROW        ( w h obj )
  ['] RECT-AREA OVER .AREA + !  ( w h obj )  \ store the xt
  >R  R@ .D1 + !  R@ .D0 + !  R> ;  \ store h then w, leave obj

: AREA ( obj -- n )  DUP .AREA + @  EXECUTE ;  \ fetch xt, dispatch

3 4 NEW-RECT   2 NEW-CIRCLE     ( r-obj c-obj )
DUP  AREA  ." circle area*100 = " . CR
FREE THROW                      \ free the circle (paired with ALLOCATE)
DUP  AREA  ." rect area*100 = "   . CR
FREE THROW                      \ free the rect; no GC in Forth

Forth has no objects, so the nearest idiom to dynamic dispatch is storing an execution token (xt) in each object: cell 0 holds ['] CIRCLE-AREA or ['] RECT-AREA, and the generic AREA word does @ EXECUTE to call whichever method the object carries - a one-slot vtable. Memory comes from the optional ALLOCATE/FREE heap word set (ALLOCATE THROW and FREE THROW check the error code), and each ALLOCATE must be hand-paired with a FREE since there is no GC. (Classic integer Forth has no floats, so areas are scaled by 100 here.)