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 3: Functions and Closures

What you'll learn: how to declare functions, give parameters default values and pass arguments by name; how to write lambdas and closures, including closures that update outer variables; and how generators produce values lazily.

Functions are how you name a piece of logic and reuse it. Every call is checked: the number of arguments, their types and what you do with the result.

Declaring Functions

@mode(host.scripted)

func add(a: int, b: int): int {
    return a + b;
}

func greet(name: string): void {
    println(i"Hello, {$ name $}!");
}

println(add(3, 4));
greet("Alice");

Listing 3-1: A function that returns a value and one that does not

7
Hello, Alice!
  • func starts the declaration, followed by the name and the parameter list.
  • Each parameter is name: Type.
  • The return type follows the parameter list after a colon. A function called only for its effect returns void.
  • A function can be called before the line that declares it.

The compiler checks every call against the declaration. Too few arguments is function 'add' expects 2 argument(s), but 1 provided; a wrong type is argument 2 of 'add': expected 'int', found 'string'.

Mistake: syntax from another language

If you write Rust, Swift or Kotlin, you might reach for fn and ->:

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

fn add(a: int, b: int) -> int {
    return a + b;
}
main.lpscr:3:1: error[E0001]: expected expression
main.lpscr:3:4: error[E0003]: expected `;`
main.lpscr:5:1: error[E0001]: expected expression

The messages do not say "use func", so learn the shape: Loup declares functions with func and puts the return type after a colon, func add(a: int, b: int): int. The word fn exists only in function types (see "Functions as values" below).

Mistake: leaving out the return type

A function declared without a return type returns void, even if its body returns a value:

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

func double(n: int) {
    return n * 2;
}

let d: int = double(4);
println(d);
main.lpscr:7:1: error[E0001]: cannot assign 'void' to binding 'd' of type 'int'

Write the return type: func double(n: int): int. This book always writes it.

Mistake: a path that does not return

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

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

println(sign(5));
main.lpscr:1:1: error[E0001]: function 'sign' may not return a value on all code paths

When n is 0, neither branch runs and the function would fall off its end. (The position in the message is not useful today; the function name is.) Add the missing case:

@mode(host.scripted)

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

println(sign(5));
println(sign(0));
positive
zero

Parameters

Parameters are immutable, like let bindings. Assigning to one is cannot assign to immutable binding; make a local copy instead:

func normalize(input: string): string {
    let cleaned = input.trim();
    return cleaned.toLowerCase();
}

println(normalize("  Hello World  "));
hello world

Default values and named arguments

A parameter can have a default value. Any argument can be passed by name, written name = value:

@mode(host.scripted)

func connect(host: string, port: int = 8080, tls: bool = true): void {
    println(i"connecting to {$ host $}:{$ port $} (tls: {$ tls $})");
}

connect("example.com");
connect("example.com", 3000);
connect("example.com", tls = false);
connect(port = 22, host = "build-server");

Listing 3-2: Defaults and named arguments

connecting to example.com:8080 (tls: true)
connecting to example.com:3000 (tls: true)
connecting to example.com:8080 (tls: false)
connecting to build-server:22 (tls: true)

Named arguments may skip any parameter that has a default and can come in any order. Once a parameter has a default, every parameter after it must have one too (E_PARAM_ORDER). A default expression is evaluated again on every call that omits the argument, so func f(items: list[int] = []) gets a fresh empty list each time (§1-7.1.1).

The Language Reference also allows name: value for a named argument; the two forms mean the same thing. This book uses = at call sites.

⚠ Planned. Rest parameters (*messages: list[string]), keyword-only parameters after a bare *, and **options: dict[string, Any] are specified in §1-7.4. The 1.25 parser does not accept them. Until it does, pass a list or a dict explicitly.

Untyped parameters

In a script you may leave a parameter's type out; it is then Any:

func show(label, value): void {
    println(i"{$ label $} = {$ value $}");
}

show("count", 42);
show("name", "Alice");
count = 42
name = Alice

This is convenient in a quick script, but the compiler can no longer check what you do with value. Write the types in anything you keep.

Mistake: a reserved word as a parameter name

Several common English words are Loup keywords: from, type, match, in, attempt, spawn and others (the list is in §1-3.4). Using one as a name fails:

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

func countdown(from: int): void {
    var n = from;
    while (n > 0) {
        println(n);
        n = n - 1;
    }
}
main.lpscr:3:16: error[E0001]: `from` is a reserved word (§1-3.4) and cannot name a parameter
  note: rename the parameter — e.g. `from_value`

Rename it — start reads better than the suggested from_value:

@mode(host.scripted)

func countdown(start: int): void {
    var n = start;
    while (n > 0) {
        println(n);
        n = n - 1;
    }
}

countdown(3);
3
2
1

Not every keyword gets such a helpful message: var attempt = 0; reports only expected identifier and expected ';'. If a perfectly ordinary-looking declaration fails to parse, check the name against the keyword list.

Functions as Values

Functions are values. A function's type is written with fn: fn(int): int takes an int and returns an int. A lambda is a function without a name, written with func:

@mode(host.scripted)

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

func triple(n: int): int {
    return n * 3;
}

let square: fn(int): int = func(n: int): int {
    return n * n;
};
let increment = func(n: int) => n + 1;

println(applyTwice(triple, 2));
println(applyTwice(square, 3));
println(applyTwice(increment, 10));

Listing 3-3: Named functions and lambdas passed as values

18
81
12

A lambda has two forms. The block form func(n: int): int { … } needs a return; the expression form func(n: int) => n + 1 returns the expression's value.

Lambdas are what you pass to the collection methods (Chapter 5):

let numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

let sumOfEvenSquares = numbers
    .filter(func(n: int) => n % 2 == 0)
    .map(func(n: int) => n * n)
    .reduce(0, func(total: int, n: int) => total + n);

println(sumOfEvenSquares);
220

Known gap: lambda parameter types are not inferred

The Language Reference (§1-7.2) lets you leave a lambda's parameter types out when the context determines them, as in numbers.map(func(x) => x * 2). The 1.25 compiler does not infer them; the parameter becomes Any and the call is rejected:

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

let numbers = [1, 2, 3];
let doubled = numbers.map(func(x) => x * 2);
println(doubled);
main.lpscr:4:27: error[E0001]: argument 1 of 'map': expected 'fn(int): <error>', found 'fn(Any): <error>'
main.lpscr:4:38: error[E_OPERATOR_MISSING]: type 'Any' does not implement 'Mul' required for operator '*' (E_OPERATOR_MISSING)

Annotate the parameter, func(x: int) => x * 2, and it works.

Closures

A lambda can use variables from the scope where it was created. It captures them, and they stay alive as long as the lambda does:

@mode(host.scripted)

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

let add5 = makeAdder(5);
let add10 = makeAdder(10);

println(add5(3));
println(add10(3));
8
13

Each call to makeAdder produces a separate closure with its own n.

Capture is by reference: a closure that reads an outer var sees its current value, not the value it had when the closure was made.

var message = "hello";
let printMessage = func(): void { println(message); };

printMessage();
message = "world";
printMessage();
hello
world

Mistake: assigning an outer variable from a closure

Reading an outer variable needs nothing special. Assigning to one does:

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

func makeCounter(step: int): fn(): int {
    var count = 0;
    return func(): int {
        count = count + step;
        return count;
    };
}

let next = makeCounter(2);
println(next());
main.lpscr:6:9: error[E_GLOBAL_MISSING]: E_GLOBAL_MISSING: assignment to outer-scope `var` `count` inside a closure needs a `nonlocal count` (an enclosing function's variable) or `global count` (a module variable) declaration at the top of the closure's body (§1-7.3)

Loup wants you to say that the closure changes something outside itself. count belongs to the enclosing function, so declare it nonlocal as the first statement of the closure:

@mode(host.scripted)

func makeCounter(step: int): fn(): int {
    var count = 0;
    return func(): int {
        nonlocal count;
        count = count + step;
        return count;
    };
}

let next = makeCounter(2);
println(next());
println(next());
println(next());

Listing 3-4: A counter closure that updates its captured variable

2
4
6

For a variable at the top level of the script, write global name; instead. Only closures need the declaration: a top-level func may assign a top-level var directly.

Generic Functions

A function can take type parameters in square brackets. This retry helper works for work that returns any type T:

@mode(host.scripted)

func retry[T](times: int, work: fn(): T): T {
    var failures = 0;
    while (true) {
        try {
            return work();
        } catch (Exception as e) {
            failures = failures + 1;
            if (failures >= times) {
                throw e;
            }
            println(i"attempt {$ failures $} failed: {$ e.message $}");
        }
    }
}

var calls = 0;
let flakyFetch = func(): string {
    global calls;
    calls = calls + 1;
    if (calls < 3) {
        throw RuntimeError(i"timeout #{$ calls $}");
    }
    return "payload";
};

println(retry(5, flakyFetch));

Listing 3-5: A generic retry helper built from a closure

attempt 1 failed: timeout #1
attempt 2 failed: timeout #2
payload

retry knows nothing about fetching; the caller's closure supplies the work. If every attempt fails, the last exception is thrown again to the caller. Chapter 9 covers generics and Chapter 10 covers try/catch.

Generators

A generator produces a sequence one value at a time. Declare it with gen func, give it the return type Iterable[T], and yield each value. The body runs only as far as the consumer asks:

@mode(host.scripted)

gen func naturals(): Iterable[int] {
    var n = 1;
    while (true) {
        yield n;
        n = n + 1;
    }
}

gen func take[T](source: Iterable[T], count: int): Iterable[T] {
    var taken = 0;
    for (value in source) {
        if (taken >= count) {
            return;
        }
        yield value;
        taken = taken + 1;
    }
}

for (n in take(naturals(), 5)) {
    println(n * n);
}

Listing 3-6: An infinite generator, limited by another generator

1
4
9
16
25

naturals() never ends on its own, but that is fine: each yield hands one value to the consumer and pauses until the next one is requested. take stops asking after five values, and a return inside a generator ends the sequence.

async gen func and for await (asynchronous generators) are covered with the rest of async code in Chapter 13.

Computed Properties

Classes (Chapter 8) can declare properties: methods that are read and written with field syntax. A get runs when the property is read; a set runs when it is assigned, and like any method that changes an object it is called inside safe { … }:

@mode(host.scripted)

class Temperature {
    pub var celsius: float;

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

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

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

let t = Temperature(celsius = 100.0);
println(t.fahrenheit);
safe {
    t.fahrenheit = 41.0;
}
println(t.celsius);
212
5

Decorators

⚠ Planned. The Language Reference (§1-7.6) defines user decorators: @logged func square(…) would apply the function logged to square when it is declared. The 1.25 toolchain does not apply them; it prints warning: Unknown annotation '@logged' and leaves the function unchanged. Until decorators are implemented, wrap the function explicitly: let loggedSquare = logged(square);.

Summary

  • Declare functions as func name(param: Type): ReturnType { … }; there is no fn keyword and no ->.
  • Write the return type: without one, a function returns void. Every path of a value-returning function must return.
  • Parameters are immutable. Defaults and named arguments (name = value) make calls readable; rest and keyword-only parameters are planned.
  • Some common words (from, type, match, attempt, …) are reserved and cannot be names.
  • Lambdas are func(x: T) => expr or func(x: T): R { … }; their type is fn(T): R. Annotate lambda parameters.
  • Closures capture by reference; a closure that assigns an outer variable declares it nonlocal (or global).
  • gen func with yield produces lazy, possibly infinite sequences.
  • Properties use get/set in classes. User decorators are planned.

Try It Yourself

  1. Write makeAccumulator(): fn(int): int that returns a closure adding each argument to a running total and returning the total so far.
  2. Write gen func fibonacci(): Iterable[int] and print the first ten Fibonacci numbers with the take generator from Listing 3-6.
  3. Write memoize(f: fn(int): int): fn(int): int that caches results in a dict[int, int] captured by the returned closure. Which variable does the closure assign, and does it need nonlocal?