Chapter 4: Functions
What you'll learn. How to declare functions, default and named arguments, lambdas and closures, function types, higher-order functions, a first look at generic functions and generators, property accessors, and what pub means.
Why it matters. A function's signature is the contract every caller relies on. Loup asks you to write that contract down — parameter types and the return type — and then checks every call against it.
Declaring a function
A function declaration has the keyword func, a name, typed parameters, a return type after a colon, and a body in braces:
@mode(web.compiled)
func lineTotal(unitPrice: float, quantity: int): float {
return unitPrice * (quantity as float);
}
func printReceipt(item: string, total: float): void {
println(i"{$ item $}: {$ total $}");
}
func main(): void {
let total = lineTotal(2.5, 4);
printReceipt("Notebooks", total);
}
Listing 4-1: Two functions and the main that calls them
Notebooks: 10
A function that returns nothing has the return type void; it may end without a return, or leave early with a bare return;. main is the entry point of a web.compiled program: it takes no parameters and returns void or int (Chapter 2).
If you come from Rust or TypeScript, your fingers may type fn and ->. Loup uses fn only in function types (below), and there is no ->:
@mode(web.compiled)
fn add(a: int, b: int) -> int {
return a + b;
}
func main(): void {
println(add(2, 3));
}
main.lpweb:3:1: error[E0001]: expected expression
main.lpweb:3:4: error[E0003]: expected `;`
main.lpweb:5:1: error[E0001]: expected expression
The parser does not know fn as a declaration keyword, so it reports generic syntax errors at the start of the line. Write func add(a: int, b: int): int { … }.
The return type is part of the signature
If you leave out the return type, the function returns void. The mistake then surfaces where you use the result, not where you declared it:
@mode(web.compiled)
func add(a: int, b: int) {
return a + b;
}
func main(): void {
println(add(2, 3));
}
main.lpweb:8:13: error[E0001]: argument 1 of 'println': expected 'Any', found 'void'
add(2, 3) has the type void, and println cannot print "nothing". The fix is to declare what the function returns: func add(a: int, b: int): int.
A function with a return type must return a value on every path. This one forgets zero:
@mode(web.compiled)
func sign(n: int): string {
if (n > 0) {
return "positive";
} else if (n < 0) {
return "negative";
}
}
func main(): void {
println(sign(0));
}
main.lpweb:1:1: error[E0001]: function 'sign' may not return a value on all code paths
(The position 1:1 is where Loup 1.25.1 reports it; the function is named in the message.) Add the missing case:
@mode(web.compiled)
func sign(n: int): string {
if (n > 0) {
return "positive";
} else if (n < 0) {
return "negative";
}
return "zero";
}
func main(): void {
println(sign(5));
println(sign(0));
}
positive
zero
Arguments are checked against parameter types
Every argument must have the parameter's type. There is no implicit int to float conversion, so an area function called with whole numbers is rejected:
@mode(web.compiled)
func area(width: float, height: float): float {
return width * height;
}
func main(): void {
println(area(3, 4));
}
main.lpweb:8:18: error[E0001]: argument 1 of 'area': expected 'float', found 'int'
main.lpweb:8:21: error[E0001]: argument 2 of 'area': expected 'float', found 'int'
Pass float literals (area(3.0, 4.0)), or convert a variable with as float.
Default parameter values
A parameter may have a default. A call can then leave out that argument:
@mode(web.compiled)
func greet(name: string, salutation: string = "Hello"): string {
return i"{$ salutation $}, {$ name $}!";
}
func main(): void {
println(greet("Alice"));
println(greet("Bob", "Hi"));
}
Hello, Alice!
Hi, Bob!
The default expression is evaluated on every call that omits the argument (Language Reference §1-7.1.1, ruling DEF-1). Python programmers know the trap where a default list is shared between calls; in Loup each call gets a fresh one:
func addTag(tag: string, tags: list[string] = []): list[string] {
var result = tags;
result.push(tag);
return result;
}
func main(): void {
println(addTag("new"));
println(addTag("sale")); # not ["new", "sale"]
println(addTag("sale", ["new"]));
}
Listing 4-2: A default is evaluated on each call
["new"]
["sale"]
["new", "sale"]
Once a parameter has a default, every later parameter needs one too.
Named arguments
Any argument can be passed by name, as name = value or name: value; the two spellings mean the same thing. Named arguments let you skip defaults and make long calls readable:
@mode(web.compiled)
func windowTitle(width: int, height: int, title: string = "Untitled"): string {
return i"{$ title $} ({$ width $}x{$ height $})";
}
func main(): void {
println(windowTitle(800, 600));
println(windowTitle(width = 800, height = 600, title = "Editor"));
println(windowTitle(title: "Settings", width: 400, height: 300));
}
Listing 4-3: Positional and named arguments
Untitled (800x600)
Editor (800x600)
Settings (400x300)
The number of arguments is checked against the signature:
func windowTitle(width: int, height: int, title: string = "Untitled"): string {
return i"{$ title $} ({$ width $}x{$ height $})";
}
func main(): void {
println(windowTitle(800));
}
main.lpweb:8:13: error[E0001]: function 'windowTitle' expects 2 to 3 argument(s), but 1 provided
"2 to 3" because title has a default and width and height do not.
⚠ Planned. Rest parameters (
*messages: list[string]), keyword-only parameters after a bare*, and keyword rest parameters (**options: dict[string, T]) are specified in Language Reference §1-7.4 but do not parse in Loup 1.25.1 (expected identifierat the*). Pass a list or a dict explicitly instead.
Lambdas
A lambda is a function without a name. The expression form func(params) => expr returns the expression; the block form has a body and an explicit return:
func main(): void {
let double = func(n: int) => n * 2;
let clamp = func(n: int, low: int, high: int): int {
if (n < low) {
return low;
}
if (n > high) {
return high;
}
return n;
};
let answer = func() => 42;
println(double(21));
println(clamp(150, 0, 100));
println(answer());
}
Listing 4-4: Expression-bodied and block-bodied lambdas
42
100
42
The Language Reference lets you leave out a lambda's parameter types when the context fixes them, as in nums.map(func(n) => n * 2) (§1-7.2). Loup 1.25.1 does not infer them yet; an unannotated parameter becomes Any, and arithmetic on it fails:
func main(): void {
let prices = [12, 30, 7];
let doubled = prices.map(func(p) => p * 2);
println(doubled);
}
main.lpweb:5:30: error[E0001]: argument 1 of 'map': expected 'fn(int): <error>', found 'fn(Any): <error>'
main.lpweb:5:41: error[E_OPERATOR_MISSING]: type 'Any' does not implement 'Mul' required for operator '*' (E_OPERATOR_MISSING)
Annotate the parameters (and, for clarity, the result):
func main(): void {
let prices = [12, 30, 7];
let doubled = prices.map(func(p: int): int => p * 2);
println(doubled);
}
[24, 60, 14]
Closures
A lambda can use variables from the scope around it. Reading them needs nothing special. Assigning to an outer var from inside a closure must be declared with nonlocal (or global for a module-level variable), so that the mutation is visible in the source (Language Reference §1-7.3). Here is a counter factory that forgets:
@mode(web.compiled)
func makeCounter(start: int): fn(): int {
var count = start;
return func(): int {
count = count + 1;
return count;
};
}
func main(): void {
let next = makeCounter(10);
println(next());
}
main.lpweb: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)
Add nonlocal count; as the first statement of the lambda:
@mode(web.compiled)
func makeCounter(start: int): fn(): int {
var count = start;
return func(): int {
nonlocal count;
count = count + 1;
return count;
};
}
func main(): void {
let next = makeCounter(10);
println(next());
println(next());
let other = makeCounter(0);
println(other());
println(next());
}
Listing 4-5: A closure that owns its own counter
11
12
1
13
Each call to makeCounter creates a new count, and the returned closure keeps it alive after makeCounter has returned. next and other do not share state.
Function types
The type of a function is written fn(ParamTypes): ReturnType. Functions are values, so they can be passed, returned and stored, and a type alias keeps long function types readable:
@mode(web.compiled)
type Discount = fn(float): float;
func applyAll(price: float, rules: list[Discount]): float {
var result = price;
for (rule in rules) {
result = rule(result);
}
return result;
}
func tenPercentOff(price: float): float {
return price * 0.9;
}
func main(): void {
let minusFive: Discount = func(p: float): float => p - 5.0;
println(applyAll(100.0, [tenPercentOff, minusFive]));
}
Listing 4-6: Named functions and lambdas as values of one function type
85
A named function such as tenPercentOff and a lambda are interchangeable wherever their signatures match.
Higher-order functions
list has map, filter, reduce (also spelled fold), any and all, which take functions. reduce takes the starting value first and then the combining function:
func main(): void {
let orders = [120, 45, 300, 80, 15];
let large = orders.filter(func(n: int): bool => n >= 100);
let total = orders.reduce(0, func(acc: int, n: int): int => acc + n);
let withFee = orders.map(func(n: int): int => n + 5);
let anyTiny = orders.any(func(n: int): bool => n < 20);
let largeTotal = orders
.filter(func(n: int): bool => n >= 100)
.reduce(0, func(acc: int, n: int): int => acc + n);
println(large);
println(total);
println(withFee);
println(anyTiny);
println(largeTotal);
}
Listing 4-7: filter, reduce, map and any on a list of order totals
[120, 300]
560
[125, 50, 305, 85, 20]
true
420
Chapter 6 covers the collection methods in full.
Generic functions
A function can take type parameters in square brackets. T below stands for whatever element type the caller's list has:
@mode(web.compiled)
func first[T](items: list[T]): Option[T] {
if (items.length() == 0) {
return None;
}
return Some(items[0]);
}
func mapAll[T, R](items: list[T], transform: fn(T): R): list[R] {
var result: list[R] = [];
for (item in items) {
result.push(transform(item));
}
return result;
}
func main(): void {
println(repr(first([3, 4])));
let empty: list[string] = [];
println(repr(first(empty)));
println(mapAll(["a", "bb", "ccc"], func(s: string): int => s.length()));
}
Listing 4-8: Two generic functions
Some(3)
None
[1, 2, 3]
Constraints on type parameters (T: Comparable[T]) and the rest of the generics story are in Chapter 11.
Generator functions
A gen func produces a sequence lazily: each yield hands one value to the loop that is consuming it, and the function resumes from there on the next iteration (Language Reference §1-7.5). A natural parameter name for a countdown is from, but from is a keyword:
@mode(web.compiled)
gen func countdown(from: int): Iterable[int] {
var n = from;
while (n > 0) {
yield n;
n = n - 1;
}
}
func main(): void {
for (n in countdown(3)) {
println(n);
}
}
main.lpweb:3:20: error[E0001]: `from` is a reserved word (§1-3.4) and cannot name a parameter
note: rename the parameter — e.g. `from_value`
Rename the parameter:
@mode(web.compiled)
gen func countdown(start: int): Iterable[int] {
var n = start;
while (n > 0) {
yield n;
n = n - 1;
}
}
func main(): void {
for (n in countdown(3)) {
println(n);
}
}
Listing 4-9: A generator
3
2
1
async func, the fourth kind of function, is covered in Chapter 15.
Recursion
Functions may call themselves, and functions in the same module may call each other regardless of declaration order:
@mode(web.compiled)
func isEven(n: int): bool {
if (n == 0) {
return true;
}
return isOdd(n - 1);
}
func isOdd(n: int): bool {
if (n == 0) {
return false;
}
return isEven(n - 1);
}
func main(): void {
println(isEven(10));
println(isOdd(7));
}
true
true
Loup does not eliminate tail calls. Very deep recursion exhausts the call stack and throws StackOverflowError; on the interpreter that happened at a depth of about 64,000 calls. For unbounded depth, use a loop or a gen func.
Property getters and setters
A class can expose a computed value with field syntax using get and set (Language Reference §1-7.7). Classes are Chapter 9's subject; this is the function side of it:
@mode(web.compiled)
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;
}
}
func main(): void {
let oven = Temperature(200.0);
println(oven.fahrenheit);
safe { oven.fahrenheit = 212.0; }
println(oven.celsius);
}
Listing 4-10: A computed property
392
100
A setter changes the object, so the specification treats it as a modifier and requires the call to sit in a safe { } block (Chapter 9). Loup 1.25.1 does not check this for setters yet, but write the safe block anyway.
Decorators
⚠ Planned. The Language Reference defines decorators as functions of the form
func(target: T, ...args): Tthat wrap or annotate a declaration (@logged func square(…), §1-7.6). Loup 1.25.1 does not apply them: an@nameit does not recognise produces the warningUnknown annotation '@logged'and the function runs unwrapped. Wrap explicitly instead, as Listing 4-11 does withwithLogging(…).
Visibility: pub
A module-level function, type or constant is private to its module unless it is marked pub (Language Reference §1-8.2). There are only these two levels; there is no protected and no pub(package). Chapter 13 shows pub across files.
A worked example: composable request handlers
The pieces of this chapter combine into a small request pipeline. A handler is a function from Request to Response; middleware is a function that takes a handler and returns a new one:
@mode(web.compiled)
struct Request {
method: string;
path: string;
}
struct Response {
status: int;
body: string;
headers: dict[string, string];
}
type Handler = fn(Request): Response;
func notFound(req: Request): Response {
return Response(status = 404, body = "Not Found", headers = {});
}
func routes(req: Request): Response {
if (req.path == "/") {
return Response(status = 200, body = "Welcome", headers = {});
}
return notFound(req);
}
func withCors(inner: Handler): Handler {
return func(req: Request): Response {
let res = inner(req);
var headers = res.headers;
headers["access-control-allow-origin"] = "*";
return res with (headers = headers);
};
}
func withLogging(inner: Handler): Handler {
return func(req: Request): Response {
let res = inner(req);
println(i"{$ req.method $} {$ req.path $} -> {$ res.status $}");
return res;
};
}
func main(): void {
let app: Handler = withLogging(withCors(routes));
let home = app(Request(method = "GET", path = "/"));
println(home.body);
println(home.headers);
let missing = app(Request(method = "GET", path = "/admin"));
println(missing.body);
}
Listing 4-11: Middleware as functions that wrap functions
GET / -> 200
Welcome
{"access-control-allow-origin": "*"}
GET /admin -> 404
Not Found
struct values and the with (…) copy are explained in Chapter 7. Everything else is functions: withCors and withLogging each return a closure over the handler they were given, and composing them is ordinary function application.
Try it yourself
- Add a
withTimingmiddleware to Listing 4-11 that appends anx-handled-byheader, and put it between the other two. - Write
retry(attempts: int, body: fn(): bool): boolthat callsbodyuntil it returnstrueor the attempts run out. - Turn
countdownfrom Listing 4-9 intoevens(limit: int), a generator of the even numbers belowlimit.
Summary
func name(param: Type): ReturnType { … }declares a function; leaving out the return type makes itvoid, and a value-returning function must return on every path.- Arguments are checked against parameter types and counts; there is no implicit
inttofloatconversion. - Defaults are evaluated on each call; any argument can be passed by name with
=or:. Rest and keyword-only parameters are planned. - Lambdas are
func(x: T) => exprorfunc(x: T): R { … }; in Loup 1.25.1 annotate their parameters. - Closures read outer variables freely and assign them only after
nonlocal(orglobal). - Function types are
fn(A, B): R; named functions and lambdas are interchangeable values. gen funcproduces a lazy sequence withyield; reserved words such asfromcannot name parameters.pub get/pub setdefine computed properties; decorators are planned.- Declarations are module-private unless marked
pub.