Chapter 7: Structs
What you'll learn. How to declare and construct structs, what value semantics means in practice, the with copy expression, methods and operators on structs, the pure-value invariant, and generic structs.
Why it matters. Structs are the type you reach for when modelling data: a point, a viewport size, a line item on an invoice, a form submission. Because a struct is copied on assignment, a function that receives one cannot change the caller's copy behind its back. That removes the "who else is holding this object?" question that JavaScript objects and Python dicts leave open.
A class is an object with identity: two names can refer to the same instance, and a change made through one is visible through the other. A struct is a value: like an int, it is copied whenever it is assigned or passed. This chapter is about the second kind. Classes come in Chapter 9.
Declaring and constructing a struct
A struct declares its fields with a name and a type. Fields take no let keyword; they are immutable unless marked var. A field may have a default value.
@mode(web.compiled)
struct Point {
x: float;
y: float;
}
struct Viewport {
width: int = 800;
height: int = 600;
scale: float = 1.0;
}
func main(): void {
let origin = Point(x = 0.0, y = 0.0);
let p = Point(x = 3.0, y = 4.0);
println(origin);
println(p.x + p.y);
let defaults = Viewport();
let hd = Viewport(width = 1920, height = 1080);
println(defaults);
println(hd);
}
Listing 7-1: Declaring structs and constructing them with named arguments
Point(x: 0.0, y: 0.0)
7
Viewport(width: 800, height: 600, scale: 1.0)
Viewport(width: 1920, height: 1080, scale: 1.0)
The compiler generates a constructor for every struct. Its parameters are the fields, by name, and a field with a default may be left out. println renders a struct structurally, as Name(field: value, …), so you rarely need to write a formatting function just to look at one. (A whole float printed on its own, like p.x + p.y above, prints as 7; inside a rendered struct it keeps its .0.)
In TypeScript you would write { x: 0, y: 0 } and hope every caller spells the keys the same way. In Loup the struct name is the constructor and the compiler checks every argument.
Mistake: positional arguments
Coming from Python or C, you might try to construct a struct positionally:
struct Point {
x: float;
y: float;
}
func main(): void {
let p = Point(1.0, 2.0);
println(p);
}
main.lpweb:8:13: error[E0001]: struct 'Point' constructor requires named arguments: Point(field1=value1, ...)
main.lpweb:8:13: error[E0001]: missing required field 'x' in 'Point' constructor call
main.lpweb:8:13: error[E0001]: missing required field 'y' in 'Point' constructor call
The generated constructor has only named parameters, so the two positional values do not fill any field; the compiler then also reports both fields as missing. Name each field: Point(x = 1.0, y = 2.0). (Named arguments may also be written with a colon, Point(x: 1.0, y: 2.0).)
Mistake: an int where a float is expected
struct Point {
x: float;
y: float;
}
func main(): void {
let p = Point(x = 1, y = 2);
println(p);
}
main.lpweb:8:19: error[E0001]: field 'x' of 'Point': expected 'float', found 'int'
main.lpweb:8:26: error[E0001]: field 'y' of 'Point': expected 'float', found 'int'
Loup never converts between int and float for you (Chapter 3). Write the literals as floats:
struct Point {
x: float;
y: float;
}
func main(): void {
let p = Point(x = 1.0, y = 2.0);
println(p);
}
Point(x: 1.0, y: 2.0)
Immutable and var fields
A field without var cannot be assigned after construction:
struct Point {
x: float;
y: float;
}
func main(): void {
var p = Point(x = 1.0, y = 2.0);
p.x = 5.0;
println(p);
}
main.lpweb:9:5: error[E0001]: cannot assign to immutable field 'x' of struct 'Point'
Making the binding var does not help: var p lets you replace the whole value (p = Point(…)), but the fields of Point are still immutable. If a field really changes over the struct's life, declare it var:
struct ClickCounter {
label: string;
var count: int = 0;
}
func main(): void {
var clicks = ClickCounter(label = "signup button");
clicks.count = clicks.count + 1;
clicks.count = clicks.count + 1;
println(clicks);
}
Listing 7-2: A struct with one mutable field
ClickCounter(label: "signup button", count: 2)
Bind a struct with var when you intend to change its fields. The 1.25.1 compiler does not yet reject a write to a var field through a let binding, so this is a convention you keep yourself for now.
Often you don't need a var field at all: you can make a new value with the change applied, using with (below).
Value semantics
Assigning a struct copies it. After the copy, the two values are independent:
@mode(web.compiled)
struct Pixel {
var x: int;
var y: int;
}
struct Cart {
owner: string;
var items: list[string];
}
func main(): void {
var a = Pixel(x = 1, y = 1);
var b = a;
b.x = 99;
println(a);
println(b);
let mine = Cart(owner = "ann", items = ["apple"]);
var theirs = mine;
theirs.items.push("pear");
println(mine.items);
println(theirs.items);
println(a == Pixel(x = 1, y = 1));
}
Listing 7-3: Assignment copies a struct, including the collections inside it
Pixel(x: 1, y: 1)
Pixel(x: 99, y: 1)
["apple"]
["apple", "pear"]
true
Three things to notice:
- Changing
b.xdoes not changea. - The
listinsideCartis copied too. The built-in collections are values in Loup (Language Reference §1-10.3, ruling COLL-1), so pushing ontotheirs.itemsleavesmine.itemsalone. In JavaScript,const theirs = { ...mine }would have shared the array. ==compares structs field by field.
Known gap: a struct read out of a list is not yet a copy
The Language Reference says that reading a struct out of a list or dict element gives you an independent copy (§1-10.3). The 1.25.1 interpreter does not do this yet: the binding refers to the element itself, and even a with copy of the element writes into the list.
struct Pixel {
var x: int;
var y: int;
}
func main(): void {
var points = [Pixel(x = 1, y = 1), Pixel(x = 2, y = 2)];
var first = points[0];
first.x = 99;
println(points[0]);
let shifted = points[1] with (y = 20);
println(points[1]);
}
Pixel(x: 99, y: 1)
Pixel(x: 2, y: 20)
Both changes leak into points. Until this is fixed, don't change a struct you read out of a collection. Build the new value with the constructor and store it back:
struct Pixel {
var x: int;
var y: int;
}
func main(): void {
var points = [Pixel(x = 1, y = 1), Pixel(x = 2, y = 2)];
let old = points[0];
points[0] = Pixel(x = 99, y = old.y);
println(old);
println(points);
}
Pixel(x: 1, y: 1)
[Pixel(x: 99, y: 1), Pixel(x: 2, y: 2)]
Known gap: structs as dict keys and set members
Structs are specified to be hashable when their fields are (§1-10.3), so a struct should work as a dict key. In 1.25.1 it does not: the lookup never finds the key, not even with the same binding.
struct Cell {
row: int;
col: int;
}
func main(): void {
let home = Cell(row = 1, col = 1);
let labels = {home: "home"};
println(labels.contains(home));
}
false
Use a tuple of the fields as the key instead; tuples hash by value today:
struct Cell {
row: int;
col: int;
}
func main(): void {
let home = Cell(row = 1, col = 1);
let labels = {(home.row, home.col): "home"};
println(labels.contains((1, 1)));
println(labels[(home.row, home.col)]);
}
true
home
Structs as function parameters
A parameter with no verb is a read-only borrow (reads, Language Reference §3-2.4.9). The function can look at the struct but not change it:
struct Position {
var x: float;
var y: float;
}
func nudge(p: Position, dx: float): void {
p.x = p.x + dx;
}
func main(): void {
var pos = Position(x = 1.0, y = 1.0);
nudge(pos, 2.5);
println(pos);
}
main.lpweb:8:5: error[E_READS_MUTATION]: E_READS_MUTATION: parameter 'p' is a `reads` borrow (a parameter without a verb is read-only, §3-2.4.9) and cannot be mutated (field 'x' assigned); declare it `edits` and pass `edits: …` to change the caller's value, or copy it into a local first
The message names both fixes. If the function should change the caller's value, declare the parameter edits:
struct Position {
var x: float;
var y: float;
}
func nudge(edits p: Position, dx: float): void {
p.x = p.x + dx;
}
func main(): void {
var pos = Position(x = 1.0, y = 1.0);
nudge(pos, 2.5);
println(pos);
}
main.lpweb:13:11: error[E_VERB_REQUIRED]: E_VERB_REQUIRED: parameter 'p' requires 'edits' verb acknowledgment at call site
An edits parameter has to be acknowledged where it is called, so that a reader of main can see that pos may change. Write edits: before the argument:
struct Position {
var x: float;
var y: float;
}
func nudge(edits p: Position, dx: float): void {
p.x = p.x + dx;
}
func main(): void {
var pos = Position(x = 1.0, y = 1.0);
nudge(edits: pos, 2.5);
nudge(edits: pos, 2.5);
println(pos);
}
Listing 7-4: An edits parameter changes the caller's struct, and the call site says so
Position(x: 6.0, y: 1.0)
If the function only needs a changed value for its own use, the other fix from the message applies: copy the parameter into a local (var q = p;) and change that. Chapter 9 covers the other ownership verbs, takes and shares.
The with copy
value with (field = newValue, …) makes a new struct that is a copy of value except for the listed fields:
struct Viewport {
width: int;
height: int;
scale: float = 1.0;
}
func main(): void {
let hd = Viewport(width = 1920, height = 1080);
let retina = hd with (scale = 2.0);
let small = hd with (width = 1280, height = 720);
println(hd);
println(retina);
println(small);
}
Listing 7-5: with copies a struct and changes some fields
Viewport(width: 1920, height: 1080, scale: 1.0)
Viewport(width: 1920, height: 1080, scale: 2.0)
Viewport(width: 1280, height: 720, scale: 1.0)
hd is unchanged. with works on immutable fields, which is the usual way to "change" a struct whose fields are not var. Naming a field the struct does not have is E_WITH_COPY_UNKNOWN_FIELD, and with on a class instance is E_WITH_COPY_WRONG_TYPE: only structs are copied this way.
Methods
A struct can declare methods. Inside a method, the struct's fields are reached through this:
struct Vec2 {
x: float;
y: float;
pub func length(): float {
return (x * x + y * y) ** 0.5;
}
}
func main(): void {
println(Vec2(x = 3.0, y = 4.0).length());
}
main.lpweb:7:17: error[E0001]: undefined identifier `x`
main.lpweb:7:21: error[E0001]: undefined identifier `x`
main.lpweb:7:25: error[E0001]: undefined identifier `y`
main.lpweb:7:29: error[E0001]: undefined identifier `y`
Unlike Java or C#, a bare field name is not in scope inside a method. Write this.x:
struct Vec2 {
x: float;
y: float;
pub func length(): float {
return (this.x * this.x + this.y * this.y) ** 0.5;
}
pub func plus(other: Vec2): Vec2 {
return Vec2(x = this.x + other.x, y = this.y + other.y);
}
pub func scaled(k: float): Vec2 {
return Vec2(x = this.x * k, y = this.y * k);
}
}
func main(): void {
let v = Vec2(x = 3.0, y = 4.0);
println(v.length());
println(v.plus(Vec2(x = 1.0, y = 1.0)));
println(v.scaled(0.5));
}
Listing 7-6: Struct methods read fields through this and return new values
5
Vec2(x: 4.0, y: 5.0)
Vec2(x: 1.5, y: 2.0)
plus and scaled return new values instead of changing this. That is the natural style for a value type. (** 0.5 takes the square root. A web-compiled file cannot import core.math for sqrt; see Chapter 2, ch02-compiled-mode-rules.md, section "The standard library from web code".)
Mistake: modifier on a struct method
If you have read ahead to classes, you might mark a mutating struct method modifier:
struct Counter {
var n: int;
pub modifier func increment(): void {
this.n = this.n + 1;
}
}
func main(): void {
var c = Counter(n = 0);
c.increment();
println(c.n);
}
main.lpweb:5:5: error[E_MODIFIER_ON_STRUCT]: E_MODIFIER_ON_STRUCT: `modifier` keyword on method 'increment' of struct 'Counter' is not allowed — `modifier` applies only to class methods
main.lpweb:5:5: error[E_MODIFIER_ON_STRUCT]: E_MODIFIER_ON_STRUCT: `modifier` keyword on method 'increment' of struct 'Counter' is not allowed — `modifier` applies only to class methods
modifier is the mutation contract for class instances, which are shared by reference (Chapter 9). For a struct, return the updated value, or write a function with an edits parameter as in Listing 7-4:
struct Counter {
var n: int;
pub func incremented(): Counter {
return this with (n = this.n + 1);
}
}
func increment(edits c: Counter): void {
c.n = c.n + 1;
}
func main(): void {
var c = Counter(n = 0);
c = c.incremented();
increment(edits: c);
println(c.n);
}
2
Static methods
The Language Reference allows pub static func on a struct (§1-10.5), for factories such as Vec2.zero(). The 1.25.1 compiler accepts the declaration, but calling one fails at run time with internal interpreter limitation: unsupported instruction: CallVirtual zero on None. Until that is fixed, write the factory as a module-level function, for example func zeroVec(): Vec2.
Operators on structs
A struct can overload an operator with operator func. The struct must also say which operator interface it implements:
struct Money {
cents: int;
pub operator func +(other: Money): Money {
return Money(cents = this.cents + other.cents);
}
}
func main(): void {
println(Money(cents = 1999) + Money(cents = 501));
}
main.lpweb:5:5: error[E_OPERATOR_NO_INTERFACE]: E_OPERATOR_NO_INTERFACE: operator func '+' on 'Money' requires `implements Add` on the enclosing type
main.lpweb:11:13: error[E_OPERATOR_MISSING]: type 'Money' does not implement 'Add' required for operator '+' (E_OPERATOR_MISSING)
Add takes two type arguments: the right-hand operand type and the result type. Declare it on the struct:
struct Money implements Add[Money, Money] {
cents: int;
currency: string;
pub operator func +(other: Money): Money {
return Money(cents = this.cents + other.cents, currency = this.currency);
}
}
func main(): void {
let price = Money(cents = 1999, currency = "USD");
let shipping = Money(cents = 501, currency = "USD");
println(price + shipping);
}
Listing 7-7: + on a struct, declared through implements Add[Money, Money]
Money(cents: 2500, currency: "USD")
Money is held in integer cents here, which avoids binary floating-point rounding; Chapter 3 covers the numeric types. Structs can implement any interface the same way; Chapter 10 covers interfaces.
The pure-value invariant
A struct field cannot have a class type (Language Reference §1-10.3.1):
class Customer {
pub let name: string;
new(name: string) { this.name = name; }
}
struct Invoice {
customer: Customer;
totalCents: int;
}
func main(): void {
println("never runs");
}
main.lpweb:7:1: error[E_STRUCT_CLASS_FIELD]: E_STRUCT_CLASS_FIELD: field 'customer' in struct 'Invoice' has class type 'Customer'. Migration: (a) convert struct to class, (b) replace field with a value type, (c) use structures.* with a class, or (d) add T: ValueType constraint
If a struct could hold a class instance, copying the struct would copy only the reference, and two "independent" copies would share one Customer. The rule keeps every struct copy fully independent. The diagnostic lists the ways out; the most common is (b), keeping the data you need instead of the object:
struct Invoice {
customerName: string;
totalCents: int;
}
func main(): void {
println(Invoice(customerName = "Ann Lee", totalCents = 4250));
}
Invoice(customerName: "Ann Lee", totalCents: 4250)
A struct field may be a primitive, another struct, an enum, a tuple, a function type, Option/Result, or a built-in collection (list, dict, set) whose elements follow the same rule. If the data needs identity, such as an object several parts of the program update, make it a class instead.
For a generic struct, the rule is checked against each type argument. A type parameter can declare the ValueType bound (struct Box[T: ValueType]) to say so up front; the 1.25.1 compiler accepts the bound but does not yet reject a class as the type argument.
Destructuring
⚠ Planned. The Language Reference specifies struct patterns,
let Point(x = px, y = py) = p;andPoint(x = 0.0, y = 0.0) => …in amatch(§1-10.8). The 1.25.1 compiler does not bind the names in either form, so read the fields (p.x) instead. Tuple destructuring,let (w, h) = (1920, 1080);, works today (Chapter 6).
Generic structs
A struct can take type parameters in square brackets:
struct Pair[A, B] {
first: A;
second: B;
}
struct Range[T] {
start: T;
end: T;
}
func main(): void {
let entry = Pair(first = "retries", second = 3);
let window: Range[int] = Range(start = 0, end = 100);
println(entry);
println(entry.second + 1);
println(window.end - window.start);
}
Listing 7-8: Generic structs; the type arguments are inferred from the constructor
Pair(first: "retries", second: 3)
4
100
Chapter 11 covers generics, including bounds.
A complete example: an invoice
Here is a small invoice model. Every function takes values and returns new ones; nothing is changed in place.
@mode(web.compiled)
struct LineItem {
sku: string;
unitCents: int;
quantity: int;
}
struct Invoice {
number: string;
lines: list[LineItem];
taxPercent: int = 0;
}
func lineTotal(item: LineItem): int {
return item.unitCents * item.quantity;
}
func withLine(invoice: Invoice, item: LineItem): Invoice {
var lines = invoice.lines;
lines.push(item);
return invoice with (lines = lines);
}
func subtotal(invoice: Invoice): int {
var sum = 0;
for (item in invoice.lines) {
sum = sum + lineTotal(item);
}
return sum;
}
func total(invoice: Invoice): int {
let net = subtotal(invoice);
return net + net * invoice.taxPercent // 100;
}
func dollars(cents: int): string {
let rest = cents % 100;
let padded = if (rest < 10) { "0" + str(rest) } else { str(rest) };
return "$" + str(cents // 100) + "." + padded;
}
func main(): void {
let empty = Invoice(number = "INV-1042", lines = [], taxPercent = 8);
let one = withLine(empty, LineItem(sku = "MUG-01", unitCents = 1250, quantity = 2));
let two = withLine(one, LineItem(sku = "TEA-07", unitCents = 899, quantity = 1));
println(empty.lines.length());
println(two.lines.length());
println("subtotal " + dollars(subtotal(two)));
println("total " + dollars(total(two)));
}
Listing 7-9: An invoice built from struct values; withLine returns a new invoice
0
2
subtotal $33.99
total $36.70
withLine copies the invoice's list into a local, pushes onto the copy, and returns invoice with (lines = lines). Because lists are values, empty still has no lines after one and two are built from it. This is the same discipline that Redux reducers or Immer ask for in JavaScript, except that here the copies are what the language does by default.
Try it yourself
- Give
InvoiceadiscountCents: int = 0field and changetotalto subtract it before tax. Usewithto apply a discount totwoand print both totals. - Write
func moved(p: Position, dx: float, dy: float): Positionthat returns a new position, and compare it with theeditsversion in Listing 7-4. Which one would you rather call from three places? - Add a
customer: Customerclass field toInvoiceand read the diagnostic. Then fix it by storing a customer ID instead.
Summary
- A struct is a value type: assigning or passing it copies it, including any
list,dictorsetfields, and==compares field by field. - Construct structs with named arguments; fields with defaults may be omitted. There are no positional constructors and no implicit
int→floatconversion. - Fields are immutable unless declared
var.value with (field = v)makes a modified copy. - A parameter without a verb is read-only (
E_READS_MUTATION); aneditsparameter changes the caller's struct and must be acknowledged at the call site withedits:. - Methods read fields through
thisand usually return new values.modifieris for classes only (E_MODIFIER_ON_STRUCT). - Operators need both
operator funcand the matching interface, such asimplements Add[Money, Money]. - A struct field cannot be a class (
E_STRUCT_CLASS_FIELD). - Known gaps in 1.25.1: a struct read out of a list element is not yet copied, structs do not work as dict keys or set members, struct static methods fail when called, and struct destructuring patterns do not bind.