The Loup Scripting Book — contents
  1. Chapter 1: Getting Started
  2. Chapter 2: Variables and Data Types
  3. Chapter 3: Functions and Closures
  4. Chapter 4: Control Flow
  5. Chapter 5: Collections
  6. Chapter 6: Structs
  7. Chapter 7: Enums and Pattern Matching
  8. Chapter 8: Classes and Objects
  9. Chapter 9: Interfaces and Generics
  10. Chapter 10: Error Handling
  11. Chapter 11: Modules and Packages
  12. Chapter 12: Strings, Interpolation, and Template Fences
  13. Chapter 13: Concurrency
  14. Chapter 14: Reflection and Dynamic Compilation
  15. Chapter 15: Reactive UI
  16. Chapter 16: Testing
  17. Chapter 17: JavaScript Host Interop
  18. Chapter 18: Building Script Tools
  19. Chapter 19: Memory Safety and Resource Management

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:

This code does not compile
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

This code does not compile
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:

This code does not compile
@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:

This code does not compile
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 this fields in a method. In Loup 1.25, a plain method may assign a var field through this, 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 method this is 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 func on structs (§1-10.5). In Loup 1.25 calling one, such as Point.origin(), compiles but fails at run time with internal interpreter limitation. Use a module-level function such as func originPoint(): Point instead.

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

This code does not compile
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 as Point(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(): T inside struct Box[T] reports undefined type 'T'. Write such operations as free generic functions, as swap is 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

This code does not compile
@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 (and edits: 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

  1. Define struct Reading { tempC: float; humidity: float; } and a method toFahrenheit(): float that returns tempC * 9.0 / 5.0 + 32.0.
  2. Call Money(cents = 100, currency = "USD").plus(Money(cents = 100, currency = "EUR")) and read the uncaught ValueError. Then catch it after reading Chapter 10.
  3. Add implements Mul[int, Money] and pub operator func *(factor: int): Money to Money, and replace price.times(quantity) with price * quantity.
  4. Write func scaled(edits p: Point, factor: float): void for a Point with var fields and call it with edits:.