The Loup Web Book — contents
  1. Chapter 1: Getting Started
  2. Chapter 2: The Web Modes
  3. Chapter 3: Variables and Data Types
  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 Objects
  10. Chapter 10: Interfaces
  11. Chapter 11: Generics
  12. Chapter 12: Error Handling
  13. Chapter 13: Modules and Packages
  14. Chapter 14: Template Fences — HTML and CSS
  15. Chapter 15: Async and Concurrency
  16. Chapter 16: Reactive UI
  17. Chapter 17: Web Components
  18. Chapter 18: DOM, Storage, Workers, and JavaScript Interop
  19. Chapter 19: SSR, Hydration, and Deployment
  20. Chapter 20: Memory Safety in Web Apps

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 ->:

This code does not compile
@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:

This code does not compile
@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:

This code does not compile
@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:

This code does not compile
@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:

This code does not compile
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 identifier at 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:

This code does not compile
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:

This code does not compile
@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:

This code does not compile
@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): T that wrap or annotate a declaration (@logged func square(…), §1-7.6). Loup 1.25.1 does not apply them: an @name it does not recognise produces the warning Unknown annotation '@logged' and the function runs unwrapped. Wrap explicitly instead, as Listing 4-11 does with withLogging(…).

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

  1. Add a withTiming middleware to Listing 4-11 that appends an x-handled-by header, and put it between the other two.
  2. Write retry(attempts: int, body: fn(): bool): bool that calls body until it returns true or the attempts run out.
  3. Turn countdown from Listing 4-9 into evens(limit: int), a generator of the even numbers below limit.

Summary

  • func name(param: Type): ReturnType { … } declares a function; leaving out the return type makes it void, and a value-returning function must return on every path.
  • Arguments are checked against parameter types and counts; there is no implicit int to float conversion.
  • 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) => expr or func(x: T): R { … }; in Loup 1.25.1 annotate their parameters.
  • Closures read outer variables freely and assign them only after nonlocal (or global).
  • Function types are fn(A, B): R; named functions and lambdas are interchangeable values.
  • gen func produces a lazy sequence with yield; reserved words such as from cannot name parameters.
  • pub get/pub set define computed properties; decorators are planned.
  • Declarations are module-private unless marked pub.