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 4: Control Flow

What you'll learn: branching with if (as a statement and as an expression) and a first look at match; looping with while, do/while, counted for and for-in over collections and ranges; break, continue and labels; destructuring; and running cleanup code with try/finally, with and del.

if, else if, else

@mode(host.scripted)

let temperature = 72;

if (temperature > 90) {
    println("hot");
} else if (temperature > 70) {
    println("pleasant");
} else if (temperature > 50) {
    println("cool");
} else {
    println("cold");
}

Listing 4-1: A chain of conditions

pleasant

Two rules apply to every if: the condition is in parentheses, and it must be a bool (Chapter 2 showed the error for if (count)). The branches are always blocks in braces.

Mistake: leaving out the parentheses

Python, Go, Rust and Swift write conditions without parentheses. Loup does not:

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

let temperature = 72;
if temperature > 90 {
    println("hot");
}
main.lpscr:4:4: error[E0001]: E_CONDITION_PARENS: the condition of `if` must be in parentheses; add them: `if (...) {`

The same rule, with the same message, applies to while (…) and to the header of for (…). Add the parentheses: if (temperature > 90) {.

if as an expression

if produces a value when both branches end in an expression. Use it where other languages use the ternary operator ?:, which Loup does not have:

let count = 1;
let noun = if (count == 1) { "file" } else { "files" };
println(i"{$ count $} {$ noun $} changed");
1 file changed

Blocks are scopes

A binding declared inside a block is visible only inside it, and may shadow an outer binding of the same name:

let level = 1;
if (level > 0) {
    let level = 2;
    let note = "inner";
    println(i"{$ note $}: {$ level $}");
}
println(level);
inner: 2
1

Using note after the if would be undefined identifier. Declaring the same name twice in one scope is E_REDECLARED.

Loops

while and do/while

while checks its condition before each pass; do { … } while (…) checks after, so the body runs at least once:

@mode(host.scripted)

var countdown = 3;
while (countdown > 0) {
    println(countdown);
    countdown = countdown - 1;
}

var attempts = 0;
do {
    attempts = attempts + 1;
    println(i"attempt {$ attempts $}");
} while (attempts < 2);
3
2
1
attempt 1
attempt 2

for-in over collections and ranges

for (x in items) visits each element of a list, the keys of a dict, or the values of a generator. start..end counts from start up to, but not including, end:

@mode(host.scripted)

let fruits = ["apple", "banana", "cherry"];
for (fruit in fruits) {
    println(fruit);
}

for (i in 0..fruits.length()) {
    println(i"{$ i + 1 $}. {$ fruits[i] $}");
}

let ages = {"alice": 30, "bob": 25};
for ((name, age) in ages.entries()) {
    println(i"{$ name $} is {$ age $}");
}

Listing 4-2: Iterating a list, a range and a dict

apple
banana
cherry
1. apple
2. banana
3. cherry
alice is 30
bob is 25

ages.entries() yields (key, value) tuples, and (name, age) unpacks each one. Iterating the dict itself, for (name in ages), visits the keys in insertion order.

Note. Some older Loup material uses range(1, 6) and list.enumerate(). Neither exists in Loup 1.25; use 1..6 and an index range as above.

Mistake: a range as a value

0..n is part of the loop syntax, not a value:

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

let indexes = 0..5;
for (i in indexes) {
    println(i);
}
main.lpscr:3:15: error[E0001]: E_RANGE_FORM_MISPLACED: the range form `start..end` is permitted only as the iteration source of a `for` loop or a comprehension clause (§1-6.4)

Write the range in the loop header, for (i in 0..5), or build a list with a comprehension, let indexes = [i for i in 0..5];. There is no inclusive form (0..=5) and no step; use a while loop when you need either.

Mistake: assigning to the loop variable

The variable of a for-in loop is immutable, like a let:

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

for (price in [10, 20, 30]) {
    price = price * 2;
    println(price);
}
main.lpscr:4:5: error[E0001]: cannot assign to immutable binding 'price'

If you want a changeable copy, declare it with var. Assigning it changes only that iteration's copy, never the list:

@mode(host.scripted)

for (var price in [10, 20, 30]) {
    price = price * 2;
    println(price);
}
20
40
60

Counted for

The three-part loop from C is available too. Its counter must be a var, because the update clause assigns it:

for (var i = 0; i < 3; i = i + 1) {
    println(i);
}
0
1
2

The Language Reference writes this loop with let i = 0 (§1-6.4); the 1.25 compiler rejects that with cannot assign to immutable binding 'i', so use var. For simple counting, for (i in 0..3) is shorter.

Caution. Do not add elements to a list while a for loop is iterating it. The Language Reference says a script gets a warning; in Loup 1.25 the loop keeps visiting the new elements, so for (x in items) { items.push(x); } never ends. Build a new list instead.

break, continue and labels

break leaves the innermost loop and continue skips to its next pass. To leave an outer loop from inside an inner one, label the outer loop:

@mode(host.scripted)

let grid = [[1, 2, 3], [4, -5, 6], [7, 8, 9]];

search: for (r in 0..grid.length()) {
    for (c in 0..grid[r].length()) {
        if (grid[r][c] < 0) {
            println(i"first negative at row {$ r $}, column {$ c $}");
            break search;
        }
    }
}

Listing 4-3: Breaking out of two loops with a label

first negative at row 1, column 1

continue search; would move on to the next row instead. A break or continue naming a label that does not exist is a compile error (break label 'nowhere' not found).

Comprehensions

A list comprehension builds a list from a loop in one expression, optionally with a filter:

let squares = [n * n for n in 0..6];
let evenSquares = [n * n for n in 0..6 if n % 2 == 0];
println(squares);
println(evenSquares);
[0, 1, 4, 9, 16, 25]
[0, 4, 16]

Note that a comprehension's for clause has no parentheses. Chapter 5 shows comprehensions over other collections.

Destructuring

A let or var can unpack a tuple or a list into several bindings, including nested ones:

let (width, height) = (1920, 1080);
println(width * height);

let (name, (lat, lon)) = ("Lisbon", (38.7, -9.1));
println(i"{$ name $}: {$ lat $}, {$ lon $}");

let [first, second] = ["gold", "silver"];
println(first);
2073600
Lisbon: 38.7, -9.1
gold

⚠ Planned. Rest patterns (let (first, *rest) = …) and struct patterns (let Point { x, y } = p;) are in the Language Reference (§1-6.8) but are not accepted by the 1.25 parser. Inside match, struct and enum patterns do work (Chapter 7).

A First Look at match

match compares a value against a list of patterns and evaluates the arm of the first one that fits. Chapter 7 covers it in full; here it is on strings:

@mode(host.scripted)

func describe(status: string): string {
    return match status {
        "active" => "user is active",
        "inactive" => "user is inactive",
        _ => i"unknown status: {$ status $}",
    };
}

println(describe("active"));
println(describe("banned"));
user is active
unknown status: banned

_ matches anything. Leave it out and the program still compiles — a string can hold any value, so the compiler cannot check that you covered them all — but a value no arm matches throws at run time:

This code throws at run time
@mode(host.scripted)

let status = "banned";
let message = match status {
    "active" => "user is active",
    "inactive" => "user is inactive",
};
println(message);
Uncaught MatchError: No matching arm for value in match expression
  at <main> (main.lpscr:4:15)

Give every match on a string or a number a _ arm. For enums, the compiler does check that every variant is covered (Chapter 7).

Cleanup That Always Runs

Scripts open files, start processes and create temporary state that must be cleaned up even when something fails. Loup has three tools for this.

try/finally

A finally block runs when its try block is left in any way — falling off the end, return, break, continue or an exception:

@mode(host.scripted)

func loadConfig(path: string): string {
    println(i"opening {$ path $}");
    try {
        return readText(path);
    } finally {
        println(i"done with {$ path $}");
    }
}

try {
    println(loadConfig("missing.toml"));
} catch (IoError as e) {
    println(i"could not load: {$ e.message $}");
}

Listing 4-4: finally runs even when the read fails

opening missing.toml
done with missing.toml
could not load: read_text failed on 'missing.toml': No such file or directory (os error 2)

catch (IoError as e) handles one type of exception and binds it to e. Chapter 10 covers exceptions in full.

with

with (name = expression) { … } acquires a resource, runs the block and releases the resource when the block ends, however it ends. The resource's class must implement Closeable (it then needs a close() method) or the more general Scoped[T] (§1-6.6). Several resources are released in reverse order:

@mode(host.scripted)

class LogFile implements Closeable {
    pub let name: string;

    new(name: string) {
        this.name = name;
        println(i"open {$ name $}");
    }

    pub func close(): void {
        println(i"close {$ this.name $}");
    }
}

try {
    with (input = LogFile("in.log"), output = LogFile("out.log")) {
        println(i"copying {$ input.name $} to {$ output.name $}");
        throw RuntimeError("disk full");
    }
} catch (RuntimeError as e) {
    println(i"failed: {$ e.message $}");
}

Listing 4-5: with closes both resources, in reverse order, when the block throws

open in.log
open out.log
copying in.log to out.log
close out.log
close in.log
failed: disk full

A class that implements neither interface is rejected with E_WITH_NOT_SCOPED, and the message reminds you that Closeable and Releasable provide what with needs.

del

del name; ends a binding early and calls the object's finalize() method, if it has one, at that point (§1-6.11). It is rarely needed; use it when a large object should be released before the end of its scope.

Known gap: defer

The Language Reference defines defer (§1-6.5): defer close(handle); registers a statement to run when the enclosing block ends, and several defers run last-registered-first. The 1.25 compiler accepts defer, but the interpreter never runs the deferred statement:

This code does not do what it should
@mode(host.scripted)

func work(): void {
    defer println("cleanup");
    println("working");
}

work();
println("after");
working
after

cleanup is never printed. Until this is fixed, use try/finally or with for cleanup that must happen.

A Worked Example: Guessing by Halving

This script guesses a secret number between 1 and 100 by halving the range on every try. It uses while (true) with break, an else if chain, an if expression and the main guard from Chapter 1:

@mode(host.scripted)

## Find `secret` between 1 and 100 by halving the range each time.
func findByHalving(secret: int): int {
    var low = 1;
    var high = 100;
    var tries = 0;
    while (true) {
        let guess = (low + high) // 2;
        tries = tries + 1;
        if (guess < secret) {
            println(i"{$ guess $}: too low");
            low = guess + 1;
        } else if (guess > secret) {
            println(i"{$ guess $}: too high");
            high = guess - 1;
        } else {
            let noun = if (tries == 1) { "guess" } else { "guesses" };
            println(i"{$ guess $}: found in {$ tries $} {$ noun $}");
            break;
        }
    }
    return tries;
}

if (__name__ == "__main__") {
    findByHalving(73);
    findByHalving(50);
}

Listing 4-6: Binary search with while, break and an if expression

50: too low
75: too high
62: too low
68: too low
71: too low
73: found in 6 guesses
50: found in 1 guess

Summary

  • if, while and for headers always take parentheses (E_CONDITION_PARENS), and conditions must be bool.
  • if is also an expression; there is no ternary operator.
  • for (x in items) iterates lists, dict keys, entries() and generators; start..end is an exclusive range that only appears in loop headers and comprehensions.
  • The loop variable is immutable; write for (var x in …) to get a changeable copy. A counted for needs a var counter.
  • break and continue take an optional label to leave or continue an outer loop.
  • let (a, b) = … and let [a, b] = … destructure tuples and lists.
  • match on strings needs a _ arm, or an unmatched value throws MatchError.
  • For cleanup, use try/finally, with on a Closeable resource, or del. defer is accepted but does not run in Loup 1.25.

Try It Yourself

  1. Print FizzBuzz for 1 to 30 using for (n in 1..31) and a match on (n % 3, n % 5) — tuple patterns such as (0, 0) => "FizzBuzz" work in match.
  2. Write a Closeable class Timer that prints how many loop iterations ran when it is closed, and use it in a with around a loop that breaks early.
  3. Rewrite Listing 4-3 without a label, using a var found = false; flag. Which version is easier to read?