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:
@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:
@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**optionskeyword rest parameters are specified (Language Reference §1-7.4) but do not parse in 1.25.1. Pass alist[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:
@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:
@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 forhost.nativeandhost.dualas 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 nativestops with anE-BUILD-NATIVEerror naming the construct. They run underloup run, and ahost.dualprogram 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:
@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:
@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 arefunc(x: T) => exprorfunc(x: T): R { … }, and their parameter types must be written. - Closures capture their environment; assigning to a captured
varneedsnonlocal(E_GLOBAL_MISSING). Capture lists ([weak self]) are ⚠ planned. gen funcreturns anIterable[T]. Generators and the function-taking list methods run underloup runbut are not yet lowered by native builds.get/setaccessors define properties; unknown@annotationsare ignored with a warning, so there are no user decorators.- A parameter without a verb is
reads; mutating it isE_READS_MUTATION. Useeditsandedits:to change the caller's value. - Chapter 5 covers control flow.