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!
funcstarts 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 ->:
@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:
@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
@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 alistor adictexplicitly.
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:
@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:
@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:
@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 functionloggedtosquarewhen it is declared. The 1.25 toolchain does not apply them; it printswarning: 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 nofnkeyword and no->. - Write the return type: without one, a function returns
void. Every path of a value-returning function mustreturn. - 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) => exprorfunc(x: T): R { … }; their type isfn(T): R. Annotate lambda parameters. - Closures capture by reference; a closure that assigns an outer variable declares it
nonlocal(orglobal). gen funcwithyieldproduces lazy, possibly infinite sequences.- Properties use
get/setin classes. User decorators are planned.
Try It Yourself
- Write
makeAccumulator(): fn(int): intthat returns a closure adding each argument to a running total and returning the total so far. - Write
gen func fibonacci(): Iterable[int]and print the first ten Fibonacci numbers with thetakegenerator from Listing 3-6. - Write
memoize(f: fn(int): int): fn(int): intthat caches results in adict[int, int]captured by the returned closure. Which variable does the closure assign, and does it neednonlocal?