The Loup Native Book — contents
  1. Chapter 1: Getting started
  2. Chapter 2: Native mode overview
  3. Chapter 3: Variables and numerics
  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 ARC
  10. Chapter 10: Interfaces
  11. Chapter 11: Generics
  12. Chapter 12: Error handling
  13. Chapter 13: Modules and packages
  14. Chapter 14: Template fences
  15. Chapter 15: Ownership verbs
  16. Chapter 16: borrow, excl borrow and noescape
  17. Chapter 17: unique ownership
  18. Chapter 18: Lifetimes and regions
  19. Chapter 19: @unsafe and calling C
  20. Chapter 20: Concurrency with OS threads
  21. Chapter 21: Async on native
  22. Chapter 22: Desktop UI
  23. Chapter 23: SIMD and GPU
  24. Chapter 24: Building and distributing
  25. Chapter 25: Memory safety patterns

Chapter 3: Variables and numerics

This chapter covers bindings (let, var, const), Loup's primitive types, numeric literals and conversions, and the part that matters most for native code: what happens when integer arithmetic overflows. Native programs are where overflow rules change, and in Loup 1.25.1 the interpreter and the native code generator do not yet agree with each other or with the specification, so read that section carefully.

let and var

let introduces an immutable binding and var a mutable one. The type annotation is optional when the initializer determines it:

@mode(host.native)

func main(): void {
    let host: string = "localhost";
    var retries = 0;
    retries = retries + 1;
    println(i"{$ host $}: {$ retries $} retry");
}

Listing 3-1: let and var

localhost: 1 retry

let fixes the binding, not necessarily the object behind it: a let bound to a class instance can still call that instance's modifier methods (chapter 9). Reassigning a let is a compile error:

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

func main(): void {
    let host = "localhost";
    host = "example.com";
}
main.lphc:5:5: error[E0001]: cannot assign to immutable binding 'host'

Every binding has one static type for its whole life. Code that reuses a variable for a value of another type, as you might in Python, does not compile:

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

func main(): void {
    var timeout = 30;
    timeout = "30s";
}
main.lphc:5:5: error[E0001]: cannot assign 'string' to 'int'

Use a second binding with a descriptive name instead (let timeoutLabel = i"{$ timeout $}s";).

A var may be declared with a type and assigned later, but a let must be initialized where it is declared. If a value depends on a condition, compute it with an if expression (chapter 5) and bind the result with let.

Primitive types

Type Meaning
bool true or false
int8, int16, int32, int64 Signed integers of 8 to 64 bits; int is int64
uint8, uint16, uint32, uint64 Unsigned integers; uint is uint64 on 64-bit targets
float32, float64 IEEE 754 floating point; float is float64
char One Unicode scalar value (4 bytes)
string Immutable UTF-8 text; s.length() counts scalar values, s[i] is a one-character string
bytes A byte sequence; b[i] is a uint8
bigint, decimal Arbitrary-precision integer and decimal (see below)

Use int and float for ordinary arithmetic. The sized types exist for binary formats, hashing, and data passed to and from other systems.

⚠ Planned. bigint (literal suffix n) and decimal are specified in the Language Reference (§1-4.1.3, §1-4.1.4), and the type names are recognized, but Loup 1.25.1 cannot yet do arithmetic on them, print them, or convert to them: bigint(5) stops loup run with internal interpreter limitation: no builtin named 'bigint'. Until they land, keep money in integer cents and large counts in int.

Numeric literals

let port: int = 8_080;
let mask: uint32 = 0xdead_beef;
let flags: uint8 = 0b1010_1010;
let small: int8 = 127i8;
let ratio: float32 = 2.5f32;
let tolerance = 1.0e-9;
println(i"{$ port $} {$ mask $} {$ flags $} {$ small $} {$ ratio $} {$ tolerance $}");

Listing 3-2: Integer and float literals

8080 3735928559 170 127 2.5 0.000000001

Underscores may separate digits anywhere. A suffix (i8, i32, f32, …) fixes a literal's type when nothing else does; with an annotation, as here, the suffix is redundant.

Arithmetic

Integers support + - * / // % ** and the bitwise operators & | ^ << >>. The two division operators differ for negative numbers: / truncates toward zero and // floors toward negative infinity (Language Reference §1-5.1):

println(-7 / 2);
println(-7 // 2);
println(-7 % 2);
println(2 ** 10);
println(0.1 + 0.2);

Listing 3-3: Division and exponentiation

-3
-4
-1
1024
0.30000000000000004

Integer division by zero throws:

This code throws at run time
func average(total: int, count: int): int {
    return total / count;
}

func main(): void {
    println(average(10, 0));
}
Uncaught IntegerDivisionError: integer division or modulo by zero
  at average (main.lphc:3:12)
  at main (main.lphc:7:13)

Check the divisor first, or catch IntegerDivisionError (chapter 12). Float division by zero follows IEEE 754 and produces an infinity or NaN instead.

Mixing numeric types

Loup does not promote between the integer and floating-point families. Multiplying a float by an int is an error:

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

func main(): void {
    let width = 1.5;
    let columns = 2;
    println(width * columns);
}
main.lphc:6:13: error[E_OPERATOR_MISSING]: E_NUMERIC_TYPE_MISMATCH: mixed 'float' and 'int' operands for operator '*' — convert one operand explicitly (`as float` or `as int`)

The message says what to do. Convert one operand with as:

let width = 1.5;
let columns = 2;
println(width * (columns as float));

Listing 3-4: Converting with as

3

(Whole floats print without a fractional part.) The same rule applies between signed and unsigned integers; the message for int32 + uint32 reads "integer widths do not promote; convert one operand explicitly (as uint32)".

Widening is implicit: an int32 can be assigned to an int64 binding. Narrowing should be explicit, and the specification says let small: int32 = big with big: int64 is a type error. Stage0 1.25.1 does not report it yet, and the interpreter then stores a value that does not fit in an int32. Write big as int32 and make the narrowing visible.

Conversions with as

x as T converts between numeric types. A float converted to an integer is truncated toward zero, and an integer converted to a float is rounded to the nearest representable value. A value that does not fit its target is where the delivery path matters (Language Reference §1-4.1.2, ruling CAST-2): a native binary wraps an integer source into the target's width (300 as uint8 is 44) and saturates a float source, while the interpreter throws:

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

func main(): void {
    let reading = 300;
    let level = reading as uint8;
    println(level);
}
Uncaught NumericOverflowError: conversion overflow: 300 does not fit uint8
  at main (main.lphc:5:17)

Code that must behave the same everywhere should range-check before narrowing. as does not convert to or from char; use c.toInt() and char.of(n) (the compiler's E_CHAR_CAST message lists the alternatives).

Integer overflow

Overflow is the biggest semantic difference between native and interpreted Loup, so it is worth separating what the specification says from what 1.25.1 does.

The rule (Language Reference §1-4.1.1). In host.native, arithmetic on the fixed-width types (int8 … int32, uint8 … uint64) wraps silently, as in C: two's complement for signed types, modulo 2ⁿ for unsigned ones. int (int64) is the exception: it is checked and throws NumericOverflowError. In host.dual, as in scripts, every fixed-width type is checked.

What 1.25.1 does.

Narrow fixed-width types (int32, uint8, …) int / int64
Native binary Wraps (as specified) Wraps (specified: throws)
loup run, any mode Throws NumericOverflowError Wraps (specified: throws)

So a host.native program run with loup run checks its uint32 arithmetic even though the native binary will wrap it, and plain int overflow is silent everywhere:

This code does not do what it should
let bytesSent = 9223372036854775807;
println(bytesSent + 1);
-9223372036854775808

The specification says this should throw. Until it does (roadmap finding F1), do not rely on int overflow being caught; check bounds yourself where a counter could plausibly reach 2⁶³.

Asking for wrapping arithmetic

Hash functions, checksums and random-number generators want wrapping arithmetic. Here is the 32-bit FNV-1a hash, written the obvious way:

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

func fnv1a(data: bytes): uint32 {
    var hash: uint32 = 2166136261;
    for (i in 0..data.length()) {
        hash = hash ^ (data[i] as uint32);
        hash = hash * 16777619;
    }
    return hash;
}

func main(): void {
    println(fnv1a(b"hello"));
}
Uncaught NumericOverflowError: arithmetic overflow: 36342608486479703 does not fit uint32
  at fnv1a (main.lphc:7:16)
  at main (main.lphc:13:13)

A native binary would wrap and print the right hash, but the interpreter checks uint32 arithmetic, and so would a host.dual build. Say what you mean with the @overflowWrapping annotation, which makes all fixed-width arithmetic in one function wrap on every delivery path:

@mode(host.native)

@overflowWrapping
func fnv1a(data: bytes): uint32 {
    var hash: uint32 = 2166136261;
    for (i in 0..data.length()) {
        hash = hash ^ (data[i] as uint32);
        hash = hash * 16777619;
    }
    return hash;
}

func main(): void {
    println(fnv1a(b"hello"));
}

Listing 3-5: FNV-1a with @overflowWrapping

1335831723

(That is 0x4f9f2cab, the reference FNV-1a hash of hello.)

⚠ Planned. The specification also gives every fixed-width type explicit per-operation methods: int32.checkedAdd(a, b) returning Option[int32], saturatingAdd, wrappingAdd, and the Sub/Mul/Div families (Language Reference §1-4.1.1, §3-3.19). Stage0 1.25.1 does not implement them (type 'int32' has no method 'checkedAdd'). loup build --overflow=trap, which should make every fixed-width operation trap, is accepted by the command line, but the native code generator has no trap machinery yet, so a native binary still wraps.

char, string and bytes

A char literal is written c"…". Single quotes are not char literals; '-' is an ordinary string:

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

func main(): void {
    let separator: char = '-';
    println(separator);
}
main.lphc:4:5: error[E0001]: cannot assign 'string' to binding 'separator' of type 'char'
let separator: char = c"-";
let pi: char = c"π";
let star: char = c"\u{2605}";
println(i"{$ separator $} {$ pi $} {$ star $} {$ pi.toInt() $} {$ char.of(65) $}");

let title = "café, π";
println(i"{$ title.length() $} characters, {$ title.byteLength() $} bytes, title[6] = {$ title[6] $}");

let magic: bytes = b"\x7fELF";
println(i"{$ magic.length() $} bytes, first = {$ magic[0] $}, hex = {$ magic.toHex() $}");

Listing 3-6: Characters, strings and bytes

- π ★ 960 A
7 characters, 9 bytes, title[6] = π
4 bytes, first = 127, hex = 7f454c46

Only the \u{…} form of Unicode escape works in strings and char literals. String methods (split, trim, toUpperCase, startsWith, indexOf, replace, …) are covered as they come up; strings are immutable, so each returns a new string.

const

A const is evaluated by the compiler. Its initializer must be a constant expression: literals, arithmetic on literals, other constants, and calls to const func functions.

const MAX_CONNECTIONS: int = 1024;
const TIMEOUT_MS = 30 * 1000;
const BANNER = "server v" + "1.2";

func main(): void {
    println(i"{$ BANNER $}: {$ MAX_CONNECTIONS $} connections, {$ TIMEOUT_MS $} ms");
}

Listing 3-7: Module-level constants

server v1.2: 1024 connections, 30000 ms

A value that is only known at run time cannot be a const:

This code does not compile
const ARGS = runtime.args();

func main(): void {
    println(ARGS);
}
main.lphc:2:14: error[E_CONST_NOT_EVALUABLE]: the initialiser of `const ARGS` is not a const-expression — use `let` for a runtime value (E_CONST_NOT_EVALUABLE)

As the message says, use let for a runtime value, inside main() or the function that needs it. (Module scope in an AOT mode is for declarations and constant initializers; chapter 1 explains why.)

Summary

  • let bindings are immutable and var bindings mutable; every binding keeps one static type, and reassigning a let is an error.
  • int is int64 and float is float64; sized types (int8 … uint64, float32) are for binary data and interop. bigint and decimal are ⚠ planned.
  • Loup never mixes integer and float operands implicitly (E_NUMERIC_TYPE_MISMATCH); convert with as. / truncates and // floors.
  • Out-of-range conversions and overflow depend on the delivery path today: a native binary wraps fixed-width types, loup run throws NumericOverflowError for them, and plain int wraps everywhere although the specification says it should throw.
  • @overflowWrapping makes a function's fixed-width arithmetic wrap on every path; the checkedAdd family and a working --overflow=trap are ⚠ planned.
  • char literals are c"x"; const initializers must be compile-time constants (E_CONST_NOT_EVALUABLE).
  • Chapter 4 moves on to functions.