← Code Compare

Enums & Tagged Unions

A tagged union (also called a sum type, variant, or discriminated union) stores one of several shapes at a time plus a tag saying which - the bytes of every arm overlay the same storage, so the whole value is only as big as its largest member. The task is identical in all seven: model a Shape that is either a Circle(radius) or a Rect(w, h), then compute its area. Watch the safety spectrum, from C/HolyC's hand-rolled enum + union where you guarantee the tag matches the live arm, to C++ std::variant and Zig/Hare/Odin's built-in tagged unions where the compiler enforces exhaustive matching - while Forth, having no types at all, just lays out cells by hand.

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

/* The discriminant: which arm of the union is live. */
typedef enum { SHAPE_CIRCLE, SHAPE_RECT } ShapeTag;

/* A "tagged union": one enum + a union of the per-variant payloads.
 * The union is sized for its LARGEST member and overlays them in
 * the SAME bytes -- the tag tells you which field is valid. */
typedef struct {
    ShapeTag tag;
    union {
        struct { double radius; } circle;
        struct { double w, h; } rect;
    } as;
} Shape;

double area(const Shape *s) {
    switch (s->tag) {                 /* read ONLY the live arm */
    case SHAPE_CIRCLE: return 3.14159265 * s->as.circle.radius * s->as.circle.radius;
    case SHAPE_RECT:   return s->as.rect.w * s->as.rect.h;
    }
    return 0.0;                        /* unreachable, but C wants a return */
}

int main(void) {
    Shape *s = malloc(sizeof *s);      /* one Shape on the heap */
    if (!s) return 1;
    s->tag = SHAPE_CIRCLE;             /* set tag and matching arm together */
    s->as.circle.radius = 2.0;

    printf("%.4f\n", area(s));         /* 12.5664 */

    free(s);                           /* one malloc, one free */
    return 0;
}

A C tagged union is an enum discriminant plus a union whose members overlay the same bytes (sizeof is the largest arm), so the heap block holds one Shape at a time. The compiler does not check that tag matches the arm you read - that contract is yours, and the switch is the idiom for honoring it.

C++
#include <variant>
#include <memory>
#include <iostream>

struct Circle { double radius; };
struct Rect   { double w, h; };

// std::variant is a TYPE-SAFE tagged union: it stores exactly one of
// the alternatives and tracks which (the "index"/discriminant) for you.
using Shape = std::variant<Circle, Rect>;

double area(const Shape &s) {
    // std::visit dispatches on the active alternative; the overload set
    // below is checked at COMPILE TIME to cover every variant member.
    return std::visit([](const auto &v) -> double {
        using T = std::decay_t<decltype(v)>;
        if constexpr (std::is_same_v<T, Circle>)
            return 3.14159265 * v.radius * v.radius;
        else
            return v.w * v.h;
    }, s);
}

int main() {
    // unique_ptr owns the heap Shape; ~unique_ptr frees it (RAII).
    auto s = std::make_unique<Shape>(Circle{2.0});

    std::cout << area(*s) << '\n';   // 12.5664

    // No delete: the variant's stored object and the unique_ptr are
    // both destroyed automatically at scope exit.
}

std::variant<Circle, Rect> is a type-safe tagged union: it stores one alternative plus its index, and std::visit forces you to handle every case at compile time (no forgotten arm). Wrapped in a unique_ptr, the heap Shape is freed by RAII - the variant also runs the active member's destructor for you, so non-trivial payloads are safe.

HolyC
// HolyC (TempleOS): no built-in tagged-union/variant type, so we
// APPROXIMATE C's idiom -- an enum tag plus a union -- by hand.
// Top-level code runs; no main() needed.

#define SHAPE_CIRCLE 0
#define SHAPE_RECT   1

class Shape {
  I64 tag;            // which arm is live
  union {
    F64 radius;       // SHAPE_CIRCLE
    struct { F64 w, h; } rect; // SHAPE_RECT -- overlays radius
  };
};

F64 Area(Shape *s)
{// Read only the arm named by tag.
  switch (s->tag) {
    case SHAPE_CIRCLE: return 3.14159265 * s->radius * s->radius;
    case SHAPE_RECT:   return s->rect.w * s->rect.h;
  }
  return 0.0;
}

Shape *s = MAlloc(sizeof(Shape)); // one Shape from the task heap
s->tag = SHAPE_CIRCLE;
s->radius = 2.0;

Print("%5.4f\n", Area(s));        // 12.5664

Free(s);                          // return the block by hand

HolyC has no variant type, so it falls back to C's recipe: an I64 tag beside a union of the payloads (HolyC unions are anonymous, so members like radius overlay rect directly). MAlloc/Free manage the single heap Shape, and as in C nothing checks that you read the arm the tag advertises - in ring-0 TempleOS a mismatched read just sees the raw overlaid bytes.

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

// A Zig tagged union: `union(enum)` generates the discriminant enum
// for you. The active field IS the tag -- they can never disagree.
const Shape = union(enum) {
    circle: f64,            // radius
    rect: struct { w: f64, h: f64 },
};

fn area(s: Shape) f64 {
    // `switch` on a tagged union must be EXHAUSTIVE at comptime, and
    // each prong captures the payload of the matched arm by value.
    return switch (s) {
        .circle => |r| 3.14159265 * r * r,
        .rect => |q| q.w * q.h,
    };
}

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

    const s = try allocator.create(Shape); // *Shape, may fail -> try
    defer allocator.destroy(s);             // freed at scope exit

    s.* = .{ .circle = 2.0 };               // set the active arm
    std.debug.print("{d:.4}\n", .{area(s.*)}); // 12.5664
}

Zig's union(enum) is a first-class tagged union: it derives the discriminant enum, so the live field and the tag are inseparable, and a switch over it must be exhaustive at compile time (add an arm to the union and old switches stop compiling). The allocator is explicit - create/destroy paired with defer keep the single heap Shape's free deterministic, with no hidden allocations.

Hare
use fmt;

// Hare tagged unions list member TYPES; the active type is the tag.
// Distinct structs give each arm its own identity to match on.
type circle = struct { radius: f64 };
type rect = struct { w: f64, h: f64 };
type shape = (circle | rect);

fn area(s: shape) f64 = {
	// `match` switches on the runtime type; binding `c`/`r` narrows
	// the value to that arm. Hare checks the match is exhaustive.
	match (s) {
	case let c: circle =>
		return 3.14159265 * c.radius * c.radius;
	case let r: rect =>
		return r.w * r.h;
	};
};

export fn main() void = {
	// alloc a shape on the heap; it aborts if the heap is exhausted.
	let s: *shape = alloc(circle { radius = 2.0 });
	defer free(s);                  // released at scope exit, once

	fmt::printfln("{:.4f}", area(*s))!; // 12.5664
};

Hare tagged unions are written as a set of member types (circle | rect); the stored type is the tag, and match narrows to each arm and must be exhaustive. Memory is fully manual - alloc returns *shape (aborting on OOM rather than returning null) and defer free(s) keeps the one matching free beside the allocation.

Odin
package main

import "core:fmt"

Circle :: struct { radius: f64 }
Rect   :: struct { w, h: f64 }

// Odin `union` is a tagged union; it tracks which variant is live and
// adds an implicit `nil` state. The payloads are the named structs.
Shape :: union { Circle, Rect }

area :: proc(s: Shape) -> f64 {
	// type `switch` on a union binds `v` to the active variant's type.
	switch v in s {
	case Circle: return 3.14159265 * v.radius * v.radius
	case Rect:   return v.w * v.h
	case:        return 0.0   // the nil (unset) state
	}
	return 0.0
}

main :: proc() {
	// new(Shape) uses the implicit context.allocator; returns ^Shape.
	s := new(Shape)
	defer free(s)               // freed via the same allocator at scope end

	s^ = Circle{ radius = 2.0 } // assigning a variant sets the tag
	fmt.printfln("%.4f", area(s^)) // 12.5664
}

Odin's union { Circle, Rect } is a tagged union with an extra implicit nil (unset) state, matched by a switch v in s that binds v to the live variant's type. new(Shape) allocates through the implicit context.allocator and returns ^Shape; defer free(s) returns it to that same allocator exactly once - swap the allocator for an arena to drop the per-object free.

Forth
\ Forth has no enums, unions, or type system -- a "Shape" is just
\ raw cells we lay out by HAND. Convention: cell 0 = tag, then the
\ payload cells (circle uses 1 payload cell, rect uses 2). We size
\ the record for the LARGEST arm, mimicking a C union overlay.
0 CONSTANT CIRCLE
1 CONSTANT RECT
3 CELLS CONSTANT /SHAPE          \ tag + up to two payload cells

\ Use integers to stay in core Forth (no floats): area as an int.
: SHAPE-TAG ( addr -- tag )  @ ;
: !CIRCLE ( r addr -- )  CIRCLE OVER !  CELL+ ! ;     \ tag, then radius
: !RECT   ( w h addr -- ) RECT OVER !  >R             \ store tag, save addr
  R@ 2 CELLS + !  R> CELL+ ! ;                        \ h at +2, w at +1

: AREA ( addr -- n )
  DUP SHAPE-TAG CIRCLE = IF      ( circle? )
    CELL+ @ DUP *  3 *           \ ~ pi*r*r, pi approximated as 3
  ELSE
    DUP CELL+ @  SWAP 2 CELLS + @  * \ w*h
  THEN ;

CREATE S  /SHAPE ALLOT           \ reserve a Shape in the dictionary
2 S !CIRCLE                      \ tag=CIRCLE, radius=2
S AREA .  CR                     \ prints 12  (3 * 2 * 2)

Forth has no enums, unions, or types, so a tagged union is approximated by convention: lay out raw cells where cell 0 holds an integer tag and the rest hold the payload, sizing the record for the largest arm (/SHAPE) just as a C union overlays its members. AREA branches on the tag by hand; here it uses integers and pi≈3 (core Forth has no floats), so the result (12) is an approximation rather than the faithful 12.5664.