Tutorial 3: Modelling data#
> Shapes, nested shapes, methods, and both of V3's collection surfaces — including the restriction that catches everyone. About 25 minutes.
V3 gives you one user-defined type (shape) and two ways to hold a sequence. This tutorial builds a squadron roster and shows where each tool fits.
Step 1 — A shape with behaviour#
shape Member {
callsign: word
sorties: int
}
impl Member {
task enlist(callsign: word) -> Member {
give back Member { callsign: callsign, sorties: 0 }
}
task veteran(self) -> bool {
give back self.sorties >= 20
}
}enlist has no self, so it is an associated method: Member::enlist("V-1"). veteran takes self, so it is an instance method: m.veteran().
Constructor-style associated methods are the idiomatic way to build a shape with defaults, since fields have no default values.
Step 2 — Nesting#
Shapes hold shapes, and access chains:
shape Airframe {
model: word
thrust: int
}
shape Member {
callsign: word
sorties: int
frame: Airframe
}Construct inline:
pilot m: Member = Member {
callsign: "Valkyrie-1",
sorties: 0,
frame: Airframe { model: "Takemikazuchi", thrust: 880 }
}
say word_from_int(m.frame.thrust)
say "{m.callsign} flies a {m.frame.model}"Dotted paths work inside interpolation, including {self.frame.thrust} from within a method.
Shapes are handles into runtime storage, not values. Passing one to a task and mutating a field is visible to the caller, and assigning one binding to another aliases the same storage. To get an independent copy, construct a new shape.
Step 3 — Lists#
List<T> is the typed sequence. The element is a scalar or a shape name:
pilot names: List<word> = ["Valkyrie-1", "Valkyrie-2", "Valkyrie-3"]
say word_from_int(names.length())
say names[1]Three rules to internalise:
- **
.length()is a method**, not a field.names.lengthreports "non-shape value has no fields". - **Indexed assignment needs
pilot mut**, otherwise "indexed assignment requires a mutable list binding". - The literal infers the type.
[1, 2, 3]is aList<int>; all elements must share one type.
List::filled(value, count) builds a pre-populated list, which is the way to size one up front:
pilot mut tally: List<int> = List::filled(0, 3)
tally[0] = 24Lists grow as of v0.14.2: .push(v), .pop(), .reserve(n), .capacity() and .clear(), with List::new() and List::with_capacity(n) as constructors. Mutating methods need pilot mut.
pilot mut queue: List<int> = List::new()
queue.push(10)
queue.push(20)
say word_from_int(queue.pop()) -- 20Still no insert, remove, sort or iterator.
Step 4 — The restriction#
This is the one that catches people:
shape Roster {
members: List<Member> -- does not compile
}type error: V3 owned shape fields do not yet support List valuesA List<T> may be a binding, a task parameter and a return type — but not a shape field. So the "container shape holding a list of children" pattern is unavailable.
Work around it one of three ways:
- Keep the list beside the shape rather than inside it.
- Store a legacy
intarray handle in the field. - Pass lists through tasks, which is allowed:
task total_sorties(counts: List<int>) -> int {
pilot mut sum: int = 0
pilot mut i: int = 0
repeat counts.length() times {
sum += counts[i]
i += 1
}
give back sum
}Step 5 — The legacy handle#
Alongside List<T> there is an older untyped array: array_new() returns an int handle, and every element is a word.
pilot h: int = array_new()
array_push(h, "alpha")
array_push(h, "bravo")
say word_from_int(array_len(h))
say array_get(h, 0)
array_release(h)Now that List<T> grows too, it is worth keeping around for one reason: it is the only thing the std/algorithm.fk helpers accept — and the only collection you can store in a shape field.
array_sort_int(h)
say array_join(h, ", ")
say word_from_int(array_max_int(h))Those helpers are declared handle: int, so passing a List<word> fails:
type error: call to 'array_join' argument 1 expects int, got List<word>The array_* builtins are more forgiving — array_len, array_get, array_set, array_push accept either. Builtins bridge; library tasks do not.
Two helpers are broken in the shipping compiler: array_sort_word() segfaults, and array_unique() returns an empty array. Use array_sort_int, and deduplicate by hand.
Step 6 — Choosing between them#
| Need | Use |
|---|---|
| Typed elements, fixed size | List<T> |
Numbers without converting to word | List<int> / List<num> |
| Growing a collection | List<T> + .push() |
| Sorting, joining, searching | Legacy handle + std/algorithm.fk |
| A field inside a shape | Legacy handle — List is rejected |
| Returning a collection from a task | Either; or mutate a handle passed in |
array_release(h) frees a legacy array. V3 has no garbage collector, so a long-running program that allocates in a loop must release.
The finished program#
-- Tutorial 3, finished program.
--
-- Model a squadron with shapes, keep the members in a List, and compute
-- a summary. Shows the two collection surfaces working side by side and
-- the one restriction that catches people: a shape cannot hold a List.
shape Airframe {
model: word
thrust: int
}
shape Member {
callsign: word
sorties: int
frame: Airframe
}
impl Member {
task enlist(callsign: word, model: word, thrust: int) -> Member {
give back Member {
callsign: callsign,
sorties: 0,
frame: Airframe { model: model, thrust: thrust }
}
}
task veteran(self) -> bool {
give back self.sorties >= 20
}
task line(self) -> word {
give back "{self.callsign} {self.frame.model} thrust {self.frame.thrust} sorties {self.sorties}"
}
}
-- A List may be a parameter and a return type. It may NOT be a field on
-- a shape: that reports
-- "V3 owned shape fields do not yet support List values"
task total_sorties(counts: List<int>) -> int {
pilot mut sum: int = 0
pilot mut i: int = 0
repeat counts.length() times {
sum += counts[i]
i += 1
}
give back sum
}
task main() -> void {
pilot mut ichi: Member = Member::enlist("Valkyrie-1", "Takemikazuchi", 880)
pilot mut ni: Member = Member::enlist("Valkyrie-2", "Shiranui", 640)
pilot mut san: Member = Member::enlist("Valkyrie-3", "Gekishin", 520)
repeat 24 times { ichi.sorties += 1 }
repeat 8 times { ni.sorties += 1 }
repeat 31 times { san.sorties += 1 }
say ichi.line()
say ni.line()
say san.line()
-- A typed List holds the numbers we want to aggregate.
pilot counts: List<int> = [ichi.sorties, ni.sorties, san.sorties]
say "total sorties: " + word_from_int(total_sorties(counts))
say "squadron size: " + word_from_int(counts.length())
-- Names in a List<word>, indexed like any other list.
pilot names: List<word> = [ichi.callsign, ni.callsign, san.callsign]
pilot mut i: int = 0
repeat names.length() times {
say " member " + word_from_int(i) + ": " + names[i]
i += 1
}
-- Counting veterans needs the shapes themselves, so loop over them
-- directly rather than trying to put Members in a List.
pilot mut veterans: int = 0
if ichi.veteran() { veterans += 1 }
if ni.veteran() { veterans += 1 }
if san.veteran() { veterans += 1 }
say "veterans: " + word_from_int(veterans)
-- The legacy handle is still the right tool when you need the
-- std/algorithm.fk helpers, which are declared with `handle: int`.
pilot ranking: int = array_new()
array_push(ranking, word_from_int(ichi.sorties))
array_push(ranking, word_from_int(ni.sorties))
array_push(ranking, word_from_int(san.sorties))
array_sort_int(ranking)
say "sorted sorties: " + array_join(ranking, ", ")
say "busiest pilot flew " + word_from_int(array_max_int(ranking))
array_release(ranking)
}
Valkyrie-1 Takemikazuchi thrust 880 sorties 24
Valkyrie-2 Shiranui thrust 640 sorties 8
Valkyrie-3 Gekishin thrust 520 sorties 31
total sorties: 63
squadron size: 3
member 0: Valkyrie-1
member 1: Valkyrie-2
member 2: Valkyrie-3
veterans: 2
sorted sorties: 8, 24, 31
busiest pilot flew 31
Where to go next#
- Lists & arrays — the full reference for both surfaces
- Shapes & impl — operator doctrines and backend caveats
- Standard library —
ByteBuffer,word_builder, JSON, semver - Bible vs V3 — how far this is from the specification