The Loup Native Book — contents
  1. Chapter 1: Getting started
  2. Chapter 2: Native mode overview
  3. Chapter 3: Variables and numerics
  4. Chapter 4: Functions
  5. Chapter 5: Control flow
  6. Chapter 6: Collections
  7. Chapter 7: Structs
  8. Chapter 8: Enums and pattern matching
  9. Chapter 9: Classes and ARC
  10. Chapter 10: Interfaces
  11. Chapter 11: Generics
  12. Chapter 12: Error handling
  13. Chapter 13: Modules and packages
  14. Chapter 14: Template fences
  15. Chapter 15: Ownership verbs
  16. Chapter 16: borrow, excl borrow and noescape
  17. Chapter 17: unique ownership
  18. Chapter 18: Lifetimes and regions
  19. Chapter 19: @unsafe and calling C
  20. Chapter 20: Concurrency with OS threads
  21. Chapter 21: Async on native
  22. Chapter 22: Desktop UI
  23. Chapter 23: SIMD and GPU
  24. Chapter 24: Building and distributing
  25. Chapter 25: Memory safety patterns

Chapter 4: Functions

Functions in native Loup look like functions in any Loup mode, with every parameter and return type written out. This chapter covers declarations, default and named arguments, function values and closures, generic functions, generators, properties, and annotations, and ends with a first look at the parameter verbs that chapter 15 covers in full.

Declaring functions

A function is declared with func, its parameters with name: Type, and its return type after a colon:

@mode(host.native)

func connectionString(host: string, port: int = 5432, tls: bool = true): string {
    let scheme = if (tls) { "postgres+tls" } else { "postgres" };
    return i"{$ scheme $}://{$ host $}:{$ port $}";
}

func main(): void {
    println(connectionString("db.example.com"));
    println(connectionString("db.example.com", 5433));
    println(connectionString("db.example.com", port = 5433, tls = false));
    println(connectionString(host: "db", tls: false));
}

Listing 4-1: Default and named arguments

postgres+tls://db.example.com:5432
postgres+tls://db.example.com:5433
postgres://db.example.com:5433
postgres://db:5432

void means the function returns no value. Parameters with defaults come after those without, and a default is evaluated again on every call (ruling DEF-1), so a default of [] gives each call a fresh empty list. Any argument can be passed by name, written name = value or name: value; the two spellings mean the same thing. Named arguments make calls with several parameters of the same type readable. The words reads, edits, takes and shares followed by a colon are ownership acknowledgements (see the end of this chapter), so they cannot be used as parameter names.

Mistake: syntax from another language

Rust's fn and -> are not Loup syntax. fn appears only in function types, and there is no arrow before a return type:

This code does not compile
@mode(host.native)

fn area(width: int, height: int) -> int {
    return width * height;
}

func main(): void {
    println(area(3, 4));
}
main.lphc:3:1: error[E0001]: expected expression
main.lphc:3:4: error[E0003]: expected `;`
main.lphc:5:1: error[E0001]: expected expression

The parser does not recognize the declaration at all, so the messages are about what it expected instead. Write func area(width: int, height: int): int.

Mistake: a path that does not return

A function with a return type must return a value on every path:

This code does not compile
@mode(host.native)

func sign(n: int): string {
    if (n > 0) {
        return "positive";
    } else if (n < 0) {
        return "negative";
    }
}

func main(): void {
    println(sign(0));
}
main.lphc:1:1: error[E0001]: function 'sign' may not return a value on all code paths

(Stage0 reports this one at the start of the file rather than at the function.) Add the missing case, here a final return "zero";. Calls are checked too: area(3) is "function 'area' expects 2 argument(s), but 1 provided", and area(3, "4") is "argument 2 of 'area': expected 'int', found 'string'".

⚠ Planned. Rest parameters (*messages: list[string]), a bare * marking keyword-only parameters, and **options keyword rest parameters are specified (Language Reference §1-7.4) but do not parse in 1.25.1. Pass a list[T] explicitly instead.

Function values

Functions are values. A function type is written fn(ParamTypes): ReturnType, and an anonymous function (a lambda) is written with func, either with a block body or, for a single expression, with =>:

@mode(host.native)

func applyTwice(f: fn(int): int, x: int): int {
    return f(f(x));
}

func main(): void {
    let double = func(x: int): int { return x * 2; };
    let square = func(x: int) => x * x;
    let add: fn(int, int): int = func(a: int, b: int) => a + b;

    println(double(21));
    println(add(3, 4));
    println(applyTwice(square, 3));
    println([1, 2, 3, 4].map(square));
    println([1, 2, 3, 4, 5].filter(func(x: int) => x % 2 == 1));
    println([1, 2, 3, 4].fold(0, func(total: int, x: int) => total + x));
}

Listing 4-2: Lambdas and function types

42
7
81
[1, 4, 9, 16]
[1, 3, 5]
10

Mistake: leaving out a lambda's parameter types

If you are used to TypeScript or Kotlin you may expect the compiler to infer a lambda's parameter types from where it is passed. Stage0 does not:

This code does not compile
@mode(host.native)

func main(): void {
    println([1, 2, 3, 4].map(func(x) => x * x));
}
main.lphc:4:30: error[E0001]: argument 1 of 'map': expected 'fn(int): <error>', found 'fn(Any): <error>'
main.lphc:4:41: error[E_OPERATOR_MISSING]: type 'Any' does not implement 'Mul' required for operator '*' (E_OPERATOR_MISSING)

An unannotated parameter is Any, which matches neither map's expected fn(int): … nor the * operator. Annotate it: func(x: int) => x * x.

Closures

A lambda can use variables from the scope it was created in. It keeps them alive as long as it lives, so a function can return a closure over its own parameter:

func makeAdder(delta: int): fn(int): int {
    return func(x: int) => x + delta;
}

func main(): void {
    let addFive = makeAdder(5);
    println(addFive(10));
}

Listing 4-3: Returning a closure

15

Reading a captured variable needs nothing special. Assigning to one does: a closure that changes a var of an enclosing function must say so with nonlocal (or global for a module variable) at the top of its body:

This code does not compile
@mode(host.native)

func main(): void {
    var requests = 0;
    let record = func(): void {
        requests = requests + 1;
    };
    record();
    println(requests);
}
main.lphc:6:9: error[E_GLOBAL_MISSING]: E_GLOBAL_MISSING: assignment to outer-scope `var` `requests` inside a closure needs a `nonlocal requests` (an enclosing function's variable) or `global requests` (a module variable) declaration at the top of the closure's body (§1-7.3)

Without the declaration, an assignment inside a closure would be ambiguous: Python, for example, silently creates a new local. Loup makes you choose:

var requests = 0;
let record = func(): void {
    nonlocal requests;
    requests = requests + 1;
};
record();
record();
println(requests);

Listing 4-4: A closure that updates an outer variable

2

A closure that captures a class instance holds a strong, reference-counted reference to it. Chapter 9 explains why that matters, and chapter 17 shows that capturing a unique value moves it into the closure.

⚠ Planned. Capture lists, func() [weak self] { … } and [unowned self], are specified for host.native and host.dual as the way to stop a closure stored on an object from keeping that object alive (Language Reference §1-7.3). Stage0 1.25.1 rejects every capture list: capture lists are only allowed in @compiled files targeting --target native, even in a native build. Until they land, break such cycles by clearing the stored closure when you are done with it.

Generic functions

Type parameters go in square brackets after the name. The compiler infers them from the arguments, or you can pass them explicitly:

func firstOr[T](items: list[T], fallback: T): T {
    if (items.length() == 0) {
        return fallback;
    }
    return items[0];
}

func main(): void {
    println(firstOr([3, 1, 2], 0));
    println(firstOr[string]([], "none"));
}

Listing 4-5: A generic function

3
none

Chapter 11 covers generics in depth, including bounds such as T: Comparable[T].

Generators

A gen func produces a sequence lazily: each yield hands one value to the caller's loop and suspends the function until the next value is wanted. Its return type is Iterable[T]:

gen func backoffDelays(attempts: int): Iterable[int] {
    var delay = 100;
    var i = 0;
    while (i < attempts) {
        yield delay;
        delay = delay * 2;
        i = i + 1;
    }
}

func main(): void {
    for (ms in backoffDelays(4)) {
        println(i"retry after {$ ms $} ms");
    }
}

Listing 4-6: A generator of retry delays

retry after 100 ms
retry after 200 ms
retry after 400 ms
retry after 800 ms

⚠ Planned. In Loup 1.25.1 the native code generator does not lower generators yet, and neither do the list methods that take a function value (map, filter, fold): loup build --target native stops with an E-BUILD-NATIVE error naming the construct. They run under loup run, and a host.dual program can use them in a WebAssembly build. Ordinary closures such as Listing 4-3 build natively.

Properties

A class can expose computed properties with get and set accessors. From the outside they look like fields:

class Temperature {
    var celsiusValue: float;

    new(celsius: float) {
        this.celsiusValue = celsius;
    }

    pub get fahrenheit(): float {
        return this.celsiusValue * 9.0 / 5.0 + 32.0;
    }

    pub set fahrenheit(f: float) {
        this.celsiusValue = (f - 32.0) * 5.0 / 9.0;
    }
}

func main(): void {
    let t = Temperature(20.0);
    println(t.fahrenheit);
    t.fahrenheit = 212.0;
    println(t.fahrenheit);
}

Listing 4-7: A computed property

68
212

Keep accessors cheap and free of surprises; a reader seeing t.fahrenheit expects a field read. Chapter 9 covers classes.

Annotations are not decorators

Names that start with @ are compiler annotations with fixed meanings: @mode, @overflowWrapping (chapter 3), @unsafe("reason") (chapter 19), and so on. Loup 1.25.1 has no user-defined decorators. An unknown annotation is only a warning, so a Python-style decorator compiles and silently does nothing:

This code does not do what it should
@mode(host.native)

func logged(f: fn(int): int): fn(int): int {
    return func(x: int): int {
        println(i"calling with {$ x $}");
        return f(x);
    };
}

@logged
func triple(x: int): int {
    return x * 3;
}

func main(): void {
    println(triple(4));
}
12
main.lphc:10:1: warning: Unknown annotation '@logged'

triple was never wrapped: there is no "calling with 4" line. Read warnings; this one says the annotation was ignored. Wrap explicitly instead: let loggedTriple = logged(triple);.

A first look at parameter verbs

Every parameter has an ownership verb saying what the function does with the argument. With no verb, a parameter is reads: the function may look at the argument but not change it. That is why this compiles in Python but not in Loup:

This code does not compile
@mode(host.native)

func appendSentinel(scores: list[int]): void {
    scores.push(0);
}

func main(): void {
    var scores = [90, 72];
    appendSentinel(scores);
    println(scores);
}
main.lphc:4:5: error[E_READS_MUTATION]: E_READS_MUTATION: parameter 'scores' is a `reads` borrow (a parameter without a verb is read-only, §3-2.4.9) and cannot be mutated (`push` called on it); declare it `edits` and pass `edits: …` to change the caller's value, or copy it into a local first

The message gives both fixes. To change the caller's list, declare the parameter edits and acknowledge it at the call site:

func appendSentinel(edits scores: list[int]): void {
    scores.push(0);
}

func main(): void {
    var scores = [90, 72];
    appendSentinel(edits: scores);
    println(scores);
}

Listing 4-8: An edits parameter

[90, 72, 0]

The edits: at the call site is required: anyone reading main can see that the call changes scores. Chapter 15 covers all four verbs (reads, edits, takes, shares) and the rules behind them.

Summary

  • Functions are declared func name(p: T, …): R; defaults are re-evaluated per call, and arguments can be passed by name with = or :.
  • Rest and keyword-only parameters are ⚠ planned.
  • Function types are fn(A): R; lambdas are func(x: T) => expr or func(x: T): R { … }, and their parameter types must be written.
  • Closures capture their environment; assigning to a captured var needs nonlocal (E_GLOBAL_MISSING). Capture lists ([weak self]) are ⚠ planned.
  • gen func returns an Iterable[T]. Generators and the function-taking list methods run under loup run but are not yet lowered by native builds.
  • get/set accessors define properties; unknown @annotations are ignored with a warning, so there are no user decorators.
  • A parameter without a verb is reads; mutating it is E_READS_MUTATION. Use edits and edits: to change the caller's value.
  • Chapter 5 covers control flow.