7b: Structs, Unions, and Value Types
Audience: All
Time: 120 minutes
Prerequisites: 02-Values, 04-Functions, 07-Classes
You'll learn: Structs (value types), unions (tagged unions), ^T heap indirection, except for struct updates, branch pattern matching on unions
The Big Picture
Zebra has three kinds of composite types:
| Type | Semantics | Assignment copies... | Allocated on... |
|---|---|---|---|
class | Reference | The pointer | Heap |
struct | Value | The whole value | Stack |
union | Tagged union | The whole value | Stack |
You already know classes (Chapter 07). This chapter covers the other two — and they're essential for writing efficient, safe Zebra code.
Structs: Value Types
A struct is like a class, but with value semantics. When you assign a struct variable, the entire value is copied.
# file: 07b_struct_basic.zbr
# teaches: struct definition and value semantics
# chapter: 07b-Structs-Unions
struct Point
var x: int
var y: int
cue init(x: int, y: int)
this.x = x
this.y = y
def distSq(): int
return x * x + y * y
def main()
var a = Point(3, 4)
var b = a # copies the entire Point
print(a.distSq()) # 25
print(b.x) # 3
When to use struct vs class
- Struct: Small, short-lived values. Config objects, coordinates, tokens, AST nodes.
- Class: Long-lived objects with identity. Collections, services, shared mutable state.
Rule of thumb: if you'd never ask "is this the same object?", use a struct.
Cues on structs
A struct takes the same cues as a class (Chapter 07, "Cues"): toString
for printing, equals for ==, hash for use as a key, compare for
< and sort(), iter / next for for. They work by value.
There is no identity to fall back on, so == on a struct needs an
equals, written by hand or by @derive(Eq).
# file: 07b_struct_cues.zbr
# teaches: cues on a struct -- toString, equals, hash, compare, by value
# chapter: 07b-Structs-Unions
struct Version
var major: int
var minor: int
cue toString(): str
return "v${.major}.${.minor}"
cue equals(other: Version): bool
return .major == other.major and .minor == other.minor
cue hash(): int
return .major * 1000 + .minor
cue compare(other: Version): int
if .major != other.major
return .major - other.major
return .minor - other.minor
def main()
var installed = Version(major: 1, minor: 10)
var wanted = Version(major: 1, minor: 2)
print(installed)
print(installed > wanted) # compare: 10 > 2, not "1.10" < "1.2"
var releases = List(Version)()
releases.add(installed)
releases.add(wanted)
releases.add(Version(major: 0, minor: 9))
releases.sort()
for r in releases
print(r)
var copy = installed # a struct copy...
print(copy == installed) # ...is equal by value
var seen: Set(Version) = Set()
seen.add(installed)
seen.add(copy)
print(seen.count())
Output:
v1.10
true
v0.9
v1.2
v1.10
true
1
@derive(Debug, Eq, Hash) (Chapter 07) writes toString, equals and
hash field by field. It does not write compare; write that one
yourself, as Version does. compare needs an equals on the same type,
and a derived one counts, so @derive(Eq) plus a hand-written
cue compare is enough. A hand-written cue wins over a derived one,
so you can derive Eq, Hash and still write your own toString.
except — Struct Update Copies
The except keyword creates a copy of a struct with specific fields overridden. This is the primary idiom for immutable-style struct manipulation:
# file: 07b_except.zbr
# teaches: except struct update syntax
# chapter: 07b-Structs-Unions
struct Config
var indent: int
var owner: str
var verbose: bool
cue init(indent: int, owner: str, verbose: bool)
this.indent = indent
this.owner = owner
this.verbose = verbose
def indented(): Config
var c = this except
indent = indent + 1
return c
def withOwner(name: str): Config
var c = this except
owner = name
return c
def main()
var base = Config(0, "nobody", false)
var inner = base.indented()
var owned = inner.withOwner("Alice")
print(owned.indent) # 1
print(owned.owner) # Alice
print(owned.verbose) # false (unchanged)
Critical rule: no method chaining on temporaries
# WRONG — temporary struct can't be mutated:
# var c = makeConfig().indented().withOwner("Foo")
# RIGHT — use intermediate variables:
var c0 = makeConfig()
var c1 = c0.indented()
var c = c1.withOwner("Foo")
Enums
Enums define a fixed set of named constants:
# file: 07b_enum.zbr
# teaches: enum types
# chapter: 07b-Structs-Unions
enum Color
red
green
blue
enum Status(int)
ok = 0
err = 1
def main()
var c = Color.red
var s = Status.ok
print(s) # 0
Use branch (see below) to match on enum values.
Unions: Tagged Unions
A union is a type that can hold one of several variants, each with its own payload type. Think of it as a type-safe alternative to "this could be an int OR a string OR nothing."
# file: 07b_union_basic.zbr
# teaches: union definition and construction
# chapter: 07b-Structs-Unions
union Value
int_: int
str_: str
bool_: bool
none_
def main()
var v1 = Value.int_(42)
var v2 = Value.str_("hello")
var v3 = Value.none_()
Naming convention: Variants that collide with Zebra keywords get a trailing underscore: int_, str_, bool_, none_.
branch — Pattern Matching on Unions
Use branch to inspect which variant a union holds:
# file: 07b_branch.zbr
# teaches: branch pattern matching
# chapter: 07b-Structs-Unions
union Shape
circle: float # radius
rect: Dims # width/height struct
point # no payload
struct Dims
var w: float
var h: float
cue init(w: float, h: float)
this.w = w
this.h = h
def describe(s: Shape): str
branch s
on Shape.circle as r
return "Circle with radius ${r}"
on Shape.rect as d
return "Rectangle ${d.w} x ${d.h}"
on Shape.point
return "Point"
else
pass
return "unknown"
def main()
var c = Shape.circle(3.14)
var r = Shape.rect(Dims(10.0, 5.0))
print(describe(c)) # Circle with radius 3.14
print(describe(r)) # Rectangle 10.0 x 5.0
Rules:
- Each
onarm binds the variant's payload to a name (as r,as d) elsewithpassis required for non-exhaustive branches- Payload-less variants (
point) don't needasbinding
Branch on enums and strings
branch also works on enums and strings:
branch color
on Color.red
print("Stop")
on Color.green
print("Go")
else
print("Unknown")
branch command
on "quit"
sys.exit(0)
on "help"
print("Available commands: quit, help")
else
print("Unknown command")
^T — Heap Indirection for Recursive Types
Structs and unions live on the stack. But what if a type needs to reference itself? A Node with a next field that's also a Node would be infinitely sized.
^T solves this — it's a heap-allocated pointer to T:
# file: 07b_heap_indirect.zbr
# teaches: ^T heap indirection
# chapter: 07b-Structs-Unions
struct Node
var value: int
var next: ^Node? # optional heap pointer to another Node
cue init(value: int, next: ^Node?)
this.value = value
this.next = next
def main()
var a = Node(1, nil)
var b = Node(2, nil)
a.next = b # auto-boxes: copies b to the heap
print(a.value) # 1
Key points:
^Tin field type →*Tin Zig (a pointer)^T?→?*T(an optional pointer)- Assignment to a
^Tfield auto-boxes: allocates a heap copy - Inside
branch,^Tpayloads are transparent — the binding has typeT, not*T
Where you see ^T in practice
The Zebra compiler's own AST uses ^T heavily:
union Expr
int_: int
str_: str
binary: ^ExprBinary # recursive: contains two sub-Exprs
call: ^ExprCall
struct ExprBinary
var left: ^Expr
var op: BinaryOp
var right: ^Expr
Putting It All Together: A Small AST
# file: 07b_mini_ast.zbr
# teaches: combining structs, unions, and ^T
# chapter: 07b-Structs-Unions
union Expr
num: int
add: ^BinExpr
neg: ^Expr
struct BinExpr
var left: ^Expr
var right: ^Expr
cue init(left: ^Expr, right: ^Expr)
this.left = left
this.right = right
def eval(e: Expr): int
branch e
on Expr.num as n
return n
on Expr.add as b
return eval(b.left) + eval(b.right)
on Expr.neg as inner
return 0 - eval(inner)
else
pass
return 0
def main()
# 3 + (neg 2) = 1
var three = Expr.num(3)
var two = Expr.neg(Expr.num(2))
var sum = Expr.add(BinExpr(three, two))
print(eval(sum)) # 1
Exercises
Exercise 1: RGB Color Struct
Define a Color struct with r, g, b fields (all int). Add a mix method that averages two colors using except.
Solution
struct Color
var r: int
var g: int
var b: int
cue init(r: int, g: int, b: int)
this.r = r
this.g = g
this.b = b
def mix(other: Color): Color
var c = this except
r = (r + other.r) / 2
g = (g + other.g) / 2
b = (b + other.b) / 2
return c
def main()
var red = Color(255, 0, 0)
var blue = Color(0, 0, 255)
var purple = red.mix(blue)
print(purple.r) # 127
print(purple.b) # 127
Exercise 2: Expression Evaluator
Extend the mini AST to support multiplication. Add a mul variant and handle it in eval.
Key Takeaways
struct= value type; assignment copies the whole value- Cues work on structs by value —
==on a struct needsequals(hand-written or@derive(Eq));compareis always hand-written and needs anequalsbeside it union= tagged union; holds one variant at a timebranch= pattern matching on unions, enums, and stringsexcept= create a struct copy with specific fields overridden^T= heap-indirection pointer; breaks recursive type cycles- Use structs for small config/data objects, classes for stateful services
Next: Head to 08-Interfaces for polymorphism without inheritance.