Chapter 6: Structs
What you'll learn: how to define a struct, construct it with named arguments, give it methods and operators, make modified copies with with, and what "value semantics" means when you assign a struct or pass it to a function.
Collections and tuples get you a long way, but a tuple ("db.local", 5432, true) does not say which value is the port. A struct is a named record with named, typed fields. Structs are value types: assigning one copies it, and two structs with equal fields are equal. Classes (Chapter 8) are the reference types, for objects with identity, inheritance or shared state. This chapter covers structs (Language Reference §1-10).
Defining and Constructing a Struct
@mode(host.scripted)
struct Endpoint {
host: string;
port: int;
tls: bool = false; # a default value
}
let db = Endpoint(host = "db.local", port = 5432);
let api = Endpoint(host = "api.example.com", port = 443, tls = true);
println(db);
println(api.host);
println(api.port + 1);
Listing 6-1: A struct with a default field, constructed with named arguments
Endpoint(host: "db.local", port: 5432, tls: false)
api.example.com
444
A field is written name: Type;, with no keyword. The compiler generates the constructor: every field becomes a named parameter, and a field with a default (tls: bool = false) may be left out. Named arguments are written name = value (as here) or name: value.
Mistake: positional arguments
If you come from Python's dataclasses or a C++ aggregate, you will try to pass the fields in order:
struct Endpoint {
host: string;
port: int;
}
let db = Endpoint("db.local", 5432);
println(db);
main.lpscr:7:10: error[E0001]: struct 'Endpoint' constructor requires named arguments: Endpoint(field1=value1, ...)
main.lpscr:7:10: error[E0001]: missing required field 'host' in 'Endpoint' constructor call
main.lpscr:7:10: error[E0001]: missing required field 'port' in 'Endpoint' constructor call
A struct constructor only takes named arguments, so that a call site cannot silently swap two fields of the same type. Name them:
struct Endpoint {
host: string;
port: int;
}
let db = Endpoint(host = "db.local", port = 5432);
println(db);
Endpoint(host: "db.local", port: 5432)
A misspelled name is caught the same way — Endpoint(host = "db.local", prot = 5432) reports unknown field 'prot' in struct 'Endpoint' constructor and missing required field 'port'.
Immutable and Mutable Fields
Fields are immutable unless declared with var:
struct Download {
url: string;
var bytesReceived: int = 0;
}
var d = Download(url = "https://example.com/data.csv");
d.bytesReceived = d.bytesReceived + 4096;
println(d);
Download(url: "https://example.com/data.csv", bytesReceived: 4096)
Mistake: writing an immutable field
struct Download {
url: string;
var bytesReceived: int = 0;
}
var d = Download(url = "https://example.com/data.csv");
d.url = "https://mirror.example.com/data.csv";
main.lpscr:8:1: error[E0001]: cannot assign to immutable field 'url' of struct 'Download'
url has no var, so it is fixed once the struct is built. Either declare it var url: string;, or — usually better — make a new value with a different URL using with, described below.
Write var on the binding too when you intend to change a struct's fields. (In Loup 1.25 a let binding does not stop writes to a var field, but the binding's keyword is how a reader learns that the value changes.)
Value Semantics
Assigning a struct copies it. Every field is copied, including collections, so the two values are fully independent (§1-10.3.1):
@mode(host.scripted)
struct Settings {
var retries: int;
var hosts: list[string];
}
var original = Settings(retries = 3, hosts = ["alpha"]);
var tuned = original;
tuned.retries = 5;
tuned.hosts.push("beta");
println(original);
println(tuned);
Listing 6-2: A struct copy is independent of the original
Settings(retries: 3, hosts: ["alpha"])
Settings(retries: 5, hosts: ["alpha", "beta"])
You never have to ask "who else holds this struct and might change it?" — nobody does.
Mistake: changing a struct parameter
A parameter with no verb is a read-only borrow of the argument (§3-2.4.9), so a function cannot change the caller's struct by accident:
@mode(host.scripted)
struct Settings {
var retries: int;
}
func harden(s: Settings): void {
s.retries = 10;
}
var settings = Settings(retries = 3);
harden(settings);
println(settings);
main.lpscr:8:5: error[E_READS_MUTATION]: E_READS_MUTATION: parameter 's' is a `reads` borrow (a parameter without a verb is read-only, §3-2.4.9) and cannot be mutated (field 'retries' assigned); declare it `edits` and pass `edits: …` to change the caller's value, or copy it into a local first
There are two fixes. If the function exists to change the caller's value, declare the parameter edits and write edits: at the call, so the change is visible where it happens. If not, return a new value:
struct Settings {
var retries: int;
}
# Fix 1: change the caller's value, visibly.
func harden(edits s: Settings): void {
s.retries = 10;
}
# Fix 2: leave the argument alone and return a new value.
func hardened(s: Settings): Settings {
return s with (retries = 10);
}
var a = Settings(retries = 3);
harden(edits: a);
println(a);
let b = hardened(Settings(retries = 1));
println(b);
Settings(retries: 10)
Settings(retries: 10)
Chapter 19 explains the parameter verbs (reads, edits, takes, shares) in full.
Equality
Two struct values are equal when all their fields are equal, compared field by field:
struct Point {
x: float;
y: float;
}
let a = Point(x = 1.5, y = 2.0);
let b = Point(x = 1.5, y = 2.0);
let c = Point(x = 3.0, y = 4.0);
println(a == b);
println(a == c);
true
false
Classes, by contrast, compare by identity unless they define their own equality (Chapter 8).
Modified Copies with with
value with (field = newValue, …) makes a copy of a struct with some fields replaced (§1-10.7). The original is untouched:
@mode(host.scripted)
struct Config {
host: string;
port: int;
tls: bool;
timeout: int;
}
let base = Config(host = "localhost", port = 8080, tls = false, timeout = 30);
let prod = base with (host = "prod.example.com", tls = true);
println(base);
println(prod);
Listing 6-3: Deriving one configuration from another
Config(host: "localhost", port: 8080, tls: false, timeout: 30)
Config(host: "prod.example.com", port: 8080, tls: true, timeout: 30)
with works on immutable fields too, because it builds a new value rather than changing an existing one. It is the usual way to "change" a struct.
Methods
A struct can declare methods. Inside a method, this is the value the method was called on:
@mode(host.scripted)
from core.math import (sqrt);
struct Point {
x: float;
y: float;
## Distance to another point.
pub func distanceTo(other: Point): float {
let dx = this.x - other.x;
let dy = this.y - other.y;
return sqrt(dx * dx + dy * dy);
}
## A new point moved by (dx, dy).
pub func translated(dx: float, dy: float): Point {
return this with (x = this.x + dx, y = this.y + dy);
}
}
let p = Point(x = 3.0, y = 4.0);
let origin = Point(x = 0.0, y = 0.0);
println(p.distanceTo(origin));
println(p.translated(1.5, -1.0));
Listing 6-4: Methods that compute a value and return a modified copy
5
Point(x: 4.5, y: 3.0)
(A float with no fractional part prints as 5; inside a struct or list it prints as 5.0.)
Methods are pub when code in other modules should call them; fields without pub are visible only inside the declaring module (§1-10.2.1). In a single-file script that makes no difference.
Mistake: a mutating method
Swift marks struct methods that change fields with mutating. Loup has no such keyword, and the parser reads mutating as the start of a field declaration:
struct Counter {
var count: int;
pub mutating func increment(): void {
this.count = this.count + 1;
}
}
main.lpscr:5:18: error[E0001]: expected `:` after field name in struct body
modifier, which marks mutating methods on classes (Chapter 8), is rejected on a struct with E_MODIFIER_ON_STRUCT. The idiomatic struct style is the one in Listing 6-4: methods return a new value, and the caller rebinds it:
struct Counter {
count: int;
pub func incremented(): Counter {
return this with (count = this.count + 1);
}
}
var c = Counter(count = 0);
c = c.incremented();
c = c.incremented();
println(c.count);
2
Unsettled: assigning
thisfields in a method. In Loup 1.25, a plain method may assign avarfield throughthis, and the change is made to the value the method was called on. The Language Reference says the opposite (§1-10.5: inside a struct methodthisis not the caller's binding, so in-place mutation cannot reach it). Until the two agree, do not rely on it; return a new value or assign the field directly.
Known gap: static methods. The Language Reference allows
static funcon structs (§1-10.5). In Loup 1.25 calling one, such asPoint.origin(), compiles but fails at run time withinternal interpreter limitation. Use a module-level function such asfunc originPoint(): Pointinstead.
Operators
A struct can overload operators with operator func. Each operator belongs to an interface — + to Add[Rhs, Out], * to Mul[Rhs, Out], and so on — which the struct must name in its header:
@mode(host.scripted)
struct Vec2 implements Add[Vec2, Vec2] {
x: float;
y: float;
pub operator func +(other: Vec2): Vec2 {
return Vec2(x = this.x + other.x, y = this.y + other.y);
}
}
let step = Vec2(x = 1.5, y = 2.0);
let drift = Vec2(x = 0.5, y = 1.0);
println(step + drift);
Listing 6-5: Overloading + on a struct
Vec2(x: 2.0, y: 3.0)
Mistake: an operator without its interface
struct Vec2 {
x: float;
y: float;
pub operator func +(other: Vec2): Vec2 {
return Vec2(x = this.x + other.x, y = this.y + other.y);
}
}
let v = Vec2(x = 1.5, y = 2.0);
println(v + v);
main.lpscr:6:5: error[E_OPERATOR_NO_INTERFACE]: E_OPERATOR_NO_INTERFACE: operator func '+' on 'Vec2' requires `implements Add` on the enclosing type
main.lpscr:12:9: error[E_OPERATOR_MISSING]: type 'Vec2' does not implement 'Add' required for operator '+' (E_OPERATOR_MISSING)
The first error is the cause; the second follows from it. Add implements Add[Vec2, Vec2] (right-hand operand type, result type) to the header, as in Listing 6-5. Chapter 9 covers interfaces.
Choosing by Field Values
match (Chapter 7) works on struct values with guards:
struct Point {
x: float;
y: float;
}
func describe(p: Point): string {
return match p {
q if (q.x == 0.0 and q.y == 0.0) => "origin",
q if (q.y == 0.0) => i"on the x axis at {$ q.x $}",
q => i"({$ q.x $}, {$ q.y $})",
};
}
println(describe(Point(x = 0.0, y = 0.0)));
println(describe(Point(x = 3.5, y = 0.0)));
println(describe(Point(x = 1.5, y = 2.5)));
origin
on the x axis at 3.5
(1.5, 2.5)
⚠ Planned. The Language Reference (§1-10.8) defines struct patterns —
let Point(x = px, y = py) = p;and arms such asPoint(x = 0.0, y = 0.0) => "origin". Loup 1.25 does not accept them; read the fields, or use a guard as above.
Generic Structs
A struct can take type parameters in square brackets:
@mode(host.scripted)
struct Pair[A, B] {
first: A;
second: B;
}
func swap[A, B](p: Pair[A, B]): Pair[B, A] {
return Pair(first = p.second, second = p.first);
}
let setting = Pair(first = "port", second = 8080); # Pair[string, int]
println(setting.second + 1);
println(swap(setting));
Listing 6-6: A generic struct and a generic function over it
8081
Pair(first: 8080, second: "port")
Known gap. In Loup 1.25 a method of a generic struct cannot mention the struct's type parameter:
pub func get(): Tinsidestruct Box[T]reportsundefined type 'T'. Write such operations as free generic functions, asswapis written here.
What a Field May Hold
Because copies must be independent, a struct field cannot hold a class instance — copying the struct would share the instance (§1-10.3.1). Fields may be primitives, strings, collections, other structs, enums, tuples, functions, and Option or Result of those.
Mistake: a class-typed field
@mode(host.scripted)
class Connection {
pub var host: string = "";
}
struct Request {
path: string;
conn: Connection;
}
main.lpscr:7:1: error[E_STRUCT_CLASS_FIELD]: E_STRUCT_CLASS_FIELD: field 'conn' in struct 'Request' has class type 'Connection'. 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
The message lists the ways out. Most often, either the outer type should be a class (Chapter 8), or the struct should hold a value that identifies the connection — its host name, an ID — instead of the connection itself:
struct Request {
path: string;
host: string;
}
let r = Request(path = "/status", host = "api.example.com");
println(i"GET https://{$ r.host $}{$ r.path $}");
GET https://api.example.com/status
A Worked Example: An Invoice
Money is a classic value type. This version stores amounts in cents, so there is no floating-point rounding:
@mode(host.scripted)
## An amount of money in the smallest unit of its currency (cents).
struct Money {
cents: int;
currency: string;
pub func plus(other: Money): Money {
if (this.currency != other.currency) {
throw ValueError(i"currency mismatch: {$ this.currency $} vs {$ other.currency $}");
}
return this with (cents = this.cents + other.cents);
}
pub func times(factor: int): Money {
return this with (cents = this.cents * factor);
}
pub func percent(rate: int): Money {
return this with (cents = this.cents * rate // 100);
}
pub func format(): string {
let whole = this.cents // 100;
let fraction = this.cents % 100;
let pad = if (fraction < 10) { "0" } else { "" };
return i"{$ whole $}.{$ pad $}{$ fraction $} {$ this.currency $}";
}
}
struct LineItem {
description: string;
price: Money;
quantity: int;
pub func subtotal(): Money {
return this.price.times(this.quantity);
}
}
let items = [
LineItem(description = "Widget", price = Money(cents = 999, currency = "USD"), quantity = 3),
LineItem(description = "Gadget", price = Money(cents = 1999, currency = "USD"), quantity = 1),
];
var total = Money(cents = 0, currency = "USD");
for (item in items) {
println(i"{$ item.description $}: {$ item.subtotal().format() $}");
total = total.plus(item.subtotal());
}
let tax = total.percent(8);
println(i"Subtotal: {$ total.format() $}");
println(i"Tax: {$ tax.format() $}");
println(i"Total: {$ total.plus(tax).format() $}");
Listing 6-7: An invoice built from value types
Widget: 29.97 USD
Gadget: 19.99 USD
Subtotal: 49.96 USD
Tax: 3.99 USD
Total: 53.95 USD
Every value here is a plain struct: methods return new values instead of changing old ones, and plus throws ValueError (Chapter 10) when two currencies are mixed.
Summary
- A struct is a named record:
struct Name { field: Type; var mutable: Type = default; }. - The generated constructor takes named arguments only (
Name(field = value)); missing and unknown fields are compile errors. - Fields are immutable unless declared
var;value with (field = x)makes a modified copy. - Structs are values: assignment copies them deeply, and
==compares field by field. - A parameter is a read-only borrow; use
edits(andedits:at the call) or return a new value. - Operators need the matching interface in the header:
implements Add[Vec2, Vec2]. - Struct fields cannot hold class instances (
E_STRUCT_CLASS_FIELD). - Not in Loup 1.25: struct patterns, static struct methods, and methods that mention a generic struct's type parameter.
Try It Yourself
- Define
struct Reading { tempC: float; humidity: float; }and a methodtoFahrenheit(): floatthat returnstempC * 9.0 / 5.0 + 32.0. - Call
Money(cents = 100, currency = "USD").plus(Money(cents = 100, currency = "EUR"))and read the uncaughtValueError. Then catch it after reading Chapter 10. - Add
implements Mul[int, Money]andpub operator func *(factor: int): MoneytoMoney, and replaceprice.times(quantity)withprice * quantity. - Write
func scaled(edits p: Point, factor: float): voidfor aPointwithvarfields and call it withedits:.