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 2: Variables and Data Types

What you'll learn: how Loup binds names to values with let, var and const; the primitive types you use every day; how to convert between numeric types; how Option[T] replaces null; and when to use Any and union types.

Loup is statically typed: every value has a type the compiler knows before the script runs. Most of the time you do not write the types — the compiler infers them from the values — but they are checked all the same.

Bindings: let, var and const

A binding gives a name to a value. let makes a binding that cannot be reassigned, var makes one that can, and const makes one whose value the compiler works out before the script runs:

@mode(host.scripted)

const MAX_RETRIES = 3;           # known at compile time
let name = "Alice";              # inferred as string
let age: int = 30;               # with an explicit type
var attempts = 0;                # mutable

attempts = attempts + 1;
attempts = attempts + 1;

println(i"{$ name $} is {$ age $}");
println(i"attempt {$ attempts $} of {$ MAX_RETRIES $}");

Listing 2-1: The three kinds of binding

Alice is 30
attempt 2 of 3

Start with let. When you read let total = …, you know total never changes after that line, which makes code easier to follow. Reach for var for counters, running totals and values built up in a loop.

Mistake: reassigning a let

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

let total = 0;
for (price in [5, 10, 20]) {
    total = total + price;
}
println(total);
main.lpscr:5:5: error[E0001]: cannot assign to immutable binding 'total'

A running total changes, so it must be a var:

@mode(host.scripted)

var total = 0;
for (price in [5, 10, 20]) {
    total = total + price;
}
println(total);
35

Mistake: a run-time value in a const

const is not another spelling of let. Its initializer must be something the compiler can evaluate: a literal, another const, an operator applied to those, or a call to a const func (Chapter 3).

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

const ARG_COUNT = args().length();
println(ARG_COUNT);
main.lpscr:3:19: error[E_CONST_NOT_EVALUABLE]: the initialiser of `const ARG_COUNT` is not a const-expression — use `let` for a runtime value (E_CONST_NOT_EVALUABLE)

The number of command-line arguments is only known when the script runs. Use let:

@mode(host.scripted)

let ARG_COUNT = args().length();
println(ARG_COUNT);
0

Type annotations

Annotations are optional when there is an initializer to infer from. You need one when there is no initializer, or when the inferred type is not the one you want:

let x = 42;                 # int
let ratio: float = 1.0;     # float
var label: string;          # no initializer: the type is required
label = "ready";
println(label);

A var declared without an initializer must be assigned before it is read. The Language Reference makes reading it earlier a compile-time error (E_USE_BEFORE_ASSIGN, §1-6.2); the 1.25 interpreter does not check this yet and reads an empty value instead, so always assign before you read.

let fixes the name, not the value

A let binding cannot be pointed at a different value, but the value itself may still be changeable:

let queue = [1, 2, 3];
queue.push(4);              # fine: the list is changed, the binding is not
println(queue);
[1, 2, 3, 4]

Writing queue = [4, 5, 6]; would be the cannot assign to immutable binding error from above. Chapters 5 and 8 come back to this for collections and classes.

Integers

The everyday integer type is int, a signed 64-bit integer. Literals can use underscores, and hexadecimal, binary and octal prefixes:

let population = 8_100_000_000;
let mask = 0xFF;            # 255
let flags = 0b1010;         # 10
let perms = 0o755;          # 493
println(population);
println(mask + flags + perms);
8100000000
758

For binary formats and compact storage there are fixed-width types, chosen with a literal suffix:

Signed Unsigned Suffixes
int8, int16, int32, int64 (= int) uint8, uint16, uint32, uint64 127i8, 0xFFFFu16, 42i32, 255u8, …

A literal that does not fit its suffix is a compile error:

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

let level = 300i8;
main.lpscr:3:13: error[E_LITERAL_OVERFLOW]: E_LITERAL_OVERFLOW: integer literal `300i8` does not fit in `int8` (-128..=127) (§1-3.6)

Arithmetic on a fixed-width type that leaves its range throws NumericOverflowError in a script:

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

let brightness: uint8 = 250u8;
let brighter = brightness + 10u8;
println(brighter);
Uncaught NumericOverflowError: arithmetic overflow: 260 does not fit uint8
  at <main> (main.lpscr:4:16)

Listing 2-2: Fixed-width overflow is checked in scripts

Known gap: int overflow wraps

The Language Reference (§1-4.1.1) says int arithmetic is checked as well. In Loup 1.25 it is not: int overflow silently wraps around.

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

let largest = 9_223_372_036_854_775_807;
println(largest + 1);
-9223372036854775808

Until this is fixed, do not rely on an exception to catch an int that grows too large; check the range yourself when it matters.

⚠ Planned. bigint (arbitrary-precision integers, 123n) and decimal (exact base-10 numbers, 19.99d, for money) are specified in §1-4.1.3 and §1-4.1.4. Their literals parse, but arithmetic and printing on them do not work in the 1.25 interpreter. Until they do, keep money in an int count of cents.

Floating-Point Numbers

float is a 64-bit IEEE 754 number; float32 is the 32-bit variant. A literal with a decimal point or an exponent is a float:

let pi = 3.14159;
let small = 1.5e-3;
let sum = 0.1 + 0.2;
println(pi);
println(small);
println(sum);
println(sum == 0.3);
3.14159
0.0015
0.30000000000000004
false

0.1 + 0.2 != 0.3 is how binary floating point works in every language, not a Loup quirk.

One printing quirk is Loup's: a float with no fractional part prints without one. println(3.0) prints 3, and so does i"{$ 3.0 $}". The value is still a float.

Mixing Numeric Types

Loup never converts between the integer and floating-point families behind your back:

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

let items = 7;
let average = items / 2.0;
println(average);
main.lpscr:4:15: error[E_OPERATOR_MISSING]: E_NUMERIC_TYPE_MISMATCH: mixed 'int' and 'float' operands for operator '/' — convert one operand explicitly (`as float` or `as int`)

The message tells you the fix. as converts between numeric types:

@mode(host.scripted)

let items = 7;
let average = (items as float) / 2.0;
println(average);
println(items / 2);
println(items // 2);
println(-7 / 2);
println(-7 // 2);

Listing 2-3: Converting with as, and the two integer divisions

3.5
3
3
-3
-4

For integers, / truncates toward zero and // rounds toward negative infinity; they differ only for negative results. Integer division by zero throws IntegerDivisionError.

as between numeric types truncates a float toward zero (3.9 as int is 3) and throws NumericOverflowError when the target cannot hold the value (300 as uint8). Widening between integer types is automatic: an int8 value can be assigned to an int16 or int binding without as.

Note. The Language Reference also lists conversion functions — int8(x), int32(x), float32(x), float(x) and so on (§1-4.1.2). They are not available in the 1.25 interpreter; use as.

Booleans

bool has two values, true and false. The logical operators are words: and, or, not. and and or stop as soon as the answer is known.

let loggedIn = true;
let isAdmin = false;
println(loggedIn and isAdmin);
println(loggedIn or isAdmin);
println(not isAdmin);
false
true
true

Mistake: using a number as a condition

Loup has no "truthy" values. A condition must be a bool:

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

let unread = 3;
if (unread) {
    println("you have mail");
}
main.lpscr:4:1: error[E0001]: if condition must be 'bool', found 'int'

Say what you mean:

@mode(host.scripted)

let unread = 3;
if (unread > 0) {
    println("you have mail");
}

Characters

char is one Unicode scalar value, written with a c prefix:

let letter: char = c"A";
let smile: char = c"\u{1F600}";
println(letter);
println(smile);
println(letter.toInt());
println(c"7".isDigit());
println(c"q".toUpper());
println(char.of(66));
A
😀
65
true
Q
B

char.of(n) returns an Option[char], because not every number is a valid code point. A char literal must contain exactly one character: c"ab" is E_CHAR_LITERAL_INVALID. And a char is not a string: let letter: char = "A"; is rejected with cannot assign 'string' to binding 'letter' of type 'char'.

Strings

Strings are immutable UTF-8 text in double (or single) quotes. Indexing and slicing work on characters, and indexing gives back a one-character string:

let word = "héllo";
println(word.length());
println(word[1]);
println(word[1:4]);
println(word.toUpperCase());
println(word + ", world");
println(i"{$ word $} has {$ word.length() $} characters");
println("tab:\tquote:\" newline:\nend");
5
é
éll
HÉLLO
héllo, world
héllo has 5 characters
tab:	quote:" newline:
end

Other forms: """…""" for text spanning several lines, and r"…" for a raw string in which backslashes are literal (r"C:\temp\new"). Chapter 12 covers strings, interpolation and template fences in depth.

Bytes

bytes holds raw binary data — file contents, hashes, network packets. Indexing gives each byte as an int:

let header = b"PNG\x0d\x0a";
println(header.length());
println(header[0]);
println(header.toHex());
println(b"hello".toString());
5
80
504e470d0a
hello

Option[T]: No Null

Loup has no null. A value that may be absent has type Option[T], written T? for short. It is either Some(value) or None:

@mode(host.scripted)

func findUser(id: int): string? {
    if (id == 1) {
        return Some("alice");
    }
    return None;
}

let found = findUser(1);
let missing = findUser(2);

println(found?.toUpperCase() ?? "anonymous");
println(missing?.toUpperCase() ?? "anonymous");

match found {
    Some(name) => println(i"found {$ name $}"),
    None => println("no such user"),
}

Listing 2-4: Producing and consuming an Option

ALICE
anonymous
found alice
  • ?. calls the method only when the value is Some, and gives None otherwise.
  • ?? supplies a default for None.
  • match (Chapter 7) handles both cases explicitly.

Mistake: using an Option as if it were the value

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

func findUser(id: int): string? {
    if (id == 1) {
        return Some("alice");
    }
    return None;
}

println(findUser(1).toUpperCase());
main.lpscr:10:9: error[E0001]: type 'Option[string]' has no method 'toUpperCase'

The compiler does not let you forget the None case. Decide what should happen when the user is missing — findUser(1)?.toUpperCase() ?? "anonymous", or a match — and the error goes away. Chapter 10 shows how Option and Result work together for error handling.

Any, as? and Union Types

Any holds a value of any type. You get back to a concrete type with as?, which returns an Option:

@mode(host.scripted)

let settings: list[Any] = [30, "verbose", true];

for (item in settings) {
    let n = item as? int;
    match n {
        Some(seconds) => println(i"timeout: {$ seconds $}s"),
        None => println(i"not a number: {$ item $}"),
    }
}
timeout: 30s
not a number: verbose
not a number: true

Any is useful for data whose shape you only know at run time, such as parsed JSON (Chapter 18). Prefer real types everywhere else: with Any the compiler can no longer check what you do with the value.

Known gap. value as int (without the ?) is specified to throw TypeError when the value is not an int (§1-4.3). In Loup 1.25 it does not check: the value passes through unchanged and fails later, somewhere less obvious. Use as? and handle None.

A union type says a value is one of a few specific types, and is tests which one it is. Inside each branch of the if, the compiler knows the narrower type:

@mode(host.scripted)

func describe(value: string | int): string {
    if (value is int) {
        return i"number {$ value + 1 $}";
    } else {
        return i"text {$ value.toUpperCase() $}";
    }
}

println(describe(41));
println(describe("ok"));
number 42
text OK

Without the is test, a method that only one member type has is rejected:

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

func shout(value: string | int): string {
    return value.toUpperCase();
}

println(shout("ok"));
main.lpscr:4:12: error[E0001]: method 'toUpperCase' does not exist on all types in union 'string | int'; missing on: int. Use 'is' narrowing to call it

The message names the member that lacks the method. Narrow with is, as describe does, or use match (Chapter 7). (The Language Reference names this error E_UNION_METHOD_PARTIAL; the 1.25 compiler reports it under the generic code E0001.)

Type Aliases

type gives an existing type a second name. It documents intent; the alias and the original are interchangeable:

@mode(host.scripted)

from core.math import (round);

type Celsius = float;
type Fahrenheit = float;
type Kelvin = float;

func toFahrenheit(c: Celsius): Fahrenheit {
    return c * 9.0 / 5.0 + 32.0;
}

func toKelvin(c: Celsius): Kelvin {
    return c + 273.15;
}

## Round to one decimal place.
func round1(x: float): float {
    return round(x * 10.0) / 10.0;
}

let readings: list[Celsius] = [-40.0, 0.0, 21.5, 37.0, 100.0];
for (c in readings) {
    println(i"{$ c $} C = {$ round1(toFahrenheit(c)) $} F = {$ round1(toKelvin(c)) $} K");
}

Listing 2-5: A temperature table using type aliases

-40 C = -40 F = 233.2 K
0 C = 32 F = 273.2 K
21.5 C = 70.7 F = 294.7 K
37 C = 98.6 F = 310.2 K
100 C = 212 F = 373.2 K

Notice the float printing quirk again: 0.0 prints as 0 and 212.0 as 212.

Summary

  • let is immutable, var is mutable, and const is a compile-time constant (E_CONST_NOT_EVALUABLE otherwise). Prefer let.
  • int is 64-bit; int8…uint64 are fixed-width, chosen with a suffix, and overflow on them throws NumericOverflowError. int overflow currently wraps.
  • float is IEEE 754 double precision; a whole-number float prints without .0.
  • int and float never mix implicitly: convert with as. / truncates and // floors.
  • Conditions must be bool; there are no truthy values.
  • char, string and bytes hold characters, text and binary data.
  • Option[T] (T?) replaces null; use ?., ?? or match to get at the value.
  • Any with as?, union types with is, and type aliases cover the less common cases. bigint and decimal are planned.

Try It Yourself

  1. Write findPort(name: string): int? that returns Some(80) for "http", Some(443) for "https" and None otherwise, and print findPort("ftp") ?? 21.
  2. Compute the average of [3, 4, 4] as a float. Which conversions do you need, and what does println show for an average of exactly 4?
  3. Add 10u8 to 250u8 in a try block (Chapter 10) and print the exception message.