Odin Speaks Odin ยท Chapter 5

Chapter 5: Structs and enums: modelling structured data

This chapter:

  • Give Muspel a struct: named, fixed-field composite data, and see why plain data, no methods, is the right choice for a scripting language that already has functions.
  • Give Muspel an enum: a small, closed set of named states, cheaper than a string and safer than a magic number.
  • Extend the postfix chain from Chapter 4 with .field access, so enemy.pos.x chains exactly like f(x)(y) already does.
  • Name, and deliberately defer, the question of where a struct instance actually lives once it can outlive the statement that created it.

Where we left off: musc parse handles the full expression grammar, function declarations and calls, and a counting for loop. Every value a script can build is still a number, a string, a bool, or nil; there's no way to give a shape a name.


5.1 Why now, and why plain data

Every function this book has written so far that models "a position" or "an enemy" either takes four or five loose parameters, or fakes a record with parallel arrays. Neither scales past a handful of call sites: move_to(id, x, y) is fine once, move_to(id, x, y, vx, vy, hp, phase) is a call site nobody can read without the function's own source open beside it. Structs are the fix, and they're worth introducing before Part III's bytecode compiler for the same reason functions were introduced before Chapter 7's resolver: a real language feature needs to exist as a real, parseable AST shape before any later pass can be taught to check or compile it.

Design decision: plain-data structs, no methods, no inheritance, no vtables. Alternatives considered: object-ish structs with attached behaviour, the way a class in most mainstream languages bundles data and the functions that act on it into one declaration. Trade-off: attached methods are convenient at the call site (enemy.take_damage(5) reads nicely), but they mean designing dynamic dispatch into the language, and eventually the VM, before Part IV even exists to justify the cost. For a game-scripting language whose functions already take their subject as a plain first argument (take_damage(enemy, 5)), that's real machinery bought for a readability difference, not a capability one. Choice: plain data. A struct declares field names and nothing else; every operation on a struct instance is an ordinary function that takes it as an argument, exactly mirroring Odin's own philosophy that data and behaviour are separate, free functions operating on structs, not methods bolted onto them. Chapter 21's move_to, distance, lerp, all take a Vec2 as data, never as self.

5.2 Grammar: declaring a struct

structDecl ::= "struct" IDENT "{" ( IDENT ( "," IDENT )* )? "}" ;

Field names only, no declared types: Muspel is dynamically typed at the value level, the same trade-off the language already made for variables and function parameters, so a field carries no more type information than a var does.

// Stmt_Struct_Decl declares a plain-data record type: a name and an ordered list of field
// names, no declared field types and no methods.
Stmt_Struct_Decl :: struct {
	line:   int,
	name:   string,
	fields: []string,
}

parse_struct_decl is the shortest declaration parser in the book: a name, then a comma-separated field list inside braces, no type annotations to parse at all:

parse_struct_decl :: proc(p: ^Parser, allocator: runtime.Allocator) -> Stmt {
	line := previous(p).line
	name_tok := consume(p, .Ident, "expected a struct name after 'struct'")
	consume(p, .Left_Brace, "expected '{' after struct name")

	fields := make([dynamic]string, allocator)
	if !check(p, .Right_Brace) {
		for {
			field_tok := consume(p, .Ident, "expected a field name")
			append(&fields, field_tok.lexeme)
			if !match(p, .Comma) do break
		}
	}
	consume(p, .Right_Brace, "expected '}' after struct fields")

	node := new(Stmt_Struct_Decl, allocator)
	node^ = Stmt_Struct_Decl{line = line, name = name_tok.lexeme, fields = fields[:]}
	return node
}

Like func, a struct is top-level only: parse_block_declaration (Chapter 4) rejects one found inside a block with a clear Parse_Error, the same "still parses into a complete node, but is reported as wrong" shape section 4.4 already established for a nested function.

5.3 Struct literals: keyed, not positional

structLit ::= IDENT "{" ( IDENT ":" expression ( "," IDENT ":" expression )* )? "}" ;

Vec2{ x: 1, y: 2 }, not Vec2(1, 2). This is a small decision with an outsized payoff for a language that expects scripts to be edited constantly:

Design decision: keyed struct literals, not positional construction. Alternatives considered: Vec2(1, 2), matching each argument to a field by position, the way a constructor call reads in most languages. Trade-off: positional construction is shorter to type, at the cost that every call site silently depends on the struct's field order. Reorder Vec2's fields, or add a third field in the middle, and every positional call site is now wrong in a way nothing catches until it runs. Choice: keyed literals. A script can reorder x/y in Vec2{ y: 2, x: 1 } freely, and, just as usefully, can omit a field on purpose (an enemy spawned without a phase set gets nil there, not a compile error) without every other call site needing to change. For a scripting language whose whole value proposition is being edited often by someone who isn't necessarily the language's own author, that resilience is worth more than four characters saved.

Expr_Struct_Literal records the type name, the keys and value expressions the literal actually wrote, and two fields the parser leaves unresolved:

Expr_Struct_Literal :: struct {
	line:          int,
	type_name:     string,
	keys:          []string,
	values:        []Expr,
	field_indices: []int,    // resolved index for keys[i]; -1 until Chapter 7
	all_fields:    []string, // the declaring struct's full field order; nil until Chapter 7
}

field_indices/all_fields are Chapter 7's job, exactly the way Expr_Ident.depth/.slot were left -1 back in Chapter 3: the parser has no way to know a struct's declared field order yet (it hasn't necessarily even seen the struct declaration when it parses a literal that uses it), so it leaves the question open for the pass that resolves the whole program at once.

5.4 Field access: one more postfix case

postfix ::= primary ( "(" args? ")" | "." IDENT )* ;

.field slots into the exact postfix loop Chapter 4 built for calls, no new precedence level, no new grammar shape, just a second case in the same for loop:

} else if match(p, .Dot) {
	line := previous(p).line
	field_tok := consume(p, .Ident, "expected a field name after '.'")

	node := new(Expr_Field_Access, allocator)
	node^ = Expr_Field_Access{line = line, object = expr, field = field_tok.lexeme, field_index = -1, enum_tag = -1}
	expr = node
}

enemy.pos.x now falls out for free, exactly the way f(x)(y) already did: the outer .x wraps whatever enemy.pos built, which itself wrapped the bare enemy identifier, one postfix layer at a time. Assignment gets the same treatment: parse_assignment (Chapter 4) already turned a plain identifier target into an Expr_Assign; it now also recognises an Expr_Field_Access target and builds an Expr_Field_Assign instead, obj.field = value.

5.5 Enums: a closed set of named states

enumDecl ::= "enum" IDENT "{" ( IDENT ( "," IDENT )* )? "}" ;

An enum is a named set of distinct, comparable values, used everywhere a script would otherwise reach for a string or an integer to mean "which state":

Stmt_Enum_Decl :: struct {
	line:    int,
	name:    string,
	members: []string,
}

Phase.Attacking and Phase.Idle parse as exactly the same shape as enemy.pos, an Expr_Field_Access whose object is a bare identifier and whose field is the member name. The parser genuinely cannot tell an enum member reference apart from a struct field access at parse time, both are ident '.' ident, and it doesn't try to: is_enum_member/enum_tag, like field_indices in section 5.3, are resolved fields the parser leaves at their defaults (false, -1) for Chapter 7 to fill in once it knows whether Phase names an enum type or enemy names a struct-valued variable.

Why no associated payload on an enum member? Unlike Odin's own tagged unions, or Rust's enums, a Muspel enum member carries no data, just a name and, under the hood, a plain integer tag. That's not a missing feature; it's what section 5.1's struct already covers. "A tagged value with data attached" is a struct composing an enum field with whatever else it needs (Enemy{ phase: Phase.Attacking, target: Vec2{...} }), not a reason to make the enum itself heavier. Keeping enum members payload-free is what lets Phase.Attacking compile to a bare constant later (Chapter 11) with no runtime representation beyond that one number.

5.6 Design decision, named now, answered later: where does a struct instance live?

A struct instance can outlive the statement that created it: returned from a function, stored in a global, held in a variable across many ticks of a running script. It can't simply sit on a value stack the way a number does, the way a 1 + 2 computation's intermediate result can, because nothing here has decided yet when it stops being needed.

Two shapes exist for the answer. Give every instance to a general-purpose heap, with reference counting or a tracing garbage collector to reclaim it once nothing points to it any more, the way most dynamically-typed scripting languages do. Or allocate it from a bounded arena owned by whatever VM instance created it, and free the entire arena in one step when that instance is torn down, no per-object bookkeeping at all.

This chapter names the fork and previews the answer, arena allocation, because Chapter 10's VM instances will have exactly the short, well-defined lifetime (one instance per scripted entity, destroyed when the entity dies, Chapter 17) that makes freeing everything at once both correct and cheap. But the honest truth is that this decision cannot be implemented yet: there is no VM, no instance, and no notion of "torn down" until Chapter 10 and Chapter 17 respectively exist to give those words meaning. For now, in this chapter and the tree-walker that follows it in Chapter 8, a struct instance simply lives on Odin's own heap, an explicitly flagged simplification that does not carry over once the bytecode VM's arena exists to replace it.

5.7 musc parse on structs and enums

./run.sh parse examples/structs_enums.mus

Against:

struct Vec2 { x, y }
struct Enemy { pos, phase }
enum Phase { Idle, Attacking, Fleeing }

func make_enemy(x, y) {
    return Enemy{ pos: Vec2{ x: x, y: y }, phase: Phase.Idle }
}
StructDecl Vec2 ["x", "y"]
StructDecl Enemy ["pos", "phase"]
EnumDecl Phase ["Idle", "Attacking", "Fleeing"]
Func make_enemy([x, y])
  Return
    StructLiteral Enemy ["pos", "phase"] [-1, -1]
      StructLiteral Vec2 ["x", "y"] [-1, -1]
        Ident x [depth=-1 slot=-1]
        Ident y [depth=-1 slot=-1]
      FieldAccess .Idle [index=-1]
        Ident Phase [depth=-1 slot=-1]

Every field_indices entry reads -1, and Phase.Idle prints as an ordinary FieldAccess, not yet the EnumMember it becomes once Chapter 7 has resolved it: exactly the "the tree is right, the annotations aren't filled in yet" shape Chapter 3 established for Expr_Ident.

Checkpoint

musc parse correctly parses a struct declaration, an enum declaration, a struct literal (including a nested one), and chained field access, producing the AST shape every later chapter, the resolver, the tree-walker, and the compiler, builds on.

Exercises

  1. Parse struct Vec2 { x, y } followed by var v = Vec2{ y: 2 } (omitting x on purpose) and confirm the tree shows exactly one entry in keys/values, not a padded-out pair.
  2. Declare two structs with a field of the same name (struct A { pos } and struct B { pos }) and confirm musc parse treats a.pos and b.pos as the same shape of node; nothing at parse time distinguishes which struct's pos a given access means. Keep this in mind for Chapter 7.
  3. Write a struct literal for an undeclared type, Ghost{ x: 1 }, and confirm musc parse still produces a tree (the parser has no way to know Ghost doesn't exist yet); predict, before reading Chapter 7, what musc check will say about it once it can.
  4. enemy.pos.x = enemy.pos.x + 1 chains a field access two deep on both sides of an assignment. Parse it and trace, node by node, how Expr_Field_Assign's object ends up being an Expr_Field_Access in its own right, not a bare identifier.

Further reading