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.
#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.
#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 (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.
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.
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.
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 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.