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:
@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:
@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 suffixn) anddecimalare 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)stopsloup runwithinternal interpreter limitation: no builtin named 'bigint'. Until they land, keep money in integer cents and large counts inint.
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:
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:
@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:
@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:
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:
@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)returningOption[int32],saturatingAdd,wrappingAdd, and theSub/Mul/Divfamilies (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:
@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:
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
letbindings are immutable andvarbindings mutable; every binding keeps one static type, and reassigning aletis an error.intisint64andfloatisfloat64; sized types (int8…uint64,float32) are for binary data and interop.bigintanddecimalare ⚠ planned.- Loup never mixes integer and float operands implicitly (
E_NUMERIC_TYPE_MISMATCH); convert withas./truncates and//floors. - Out-of-range conversions and overflow depend on the delivery path today: a native binary wraps fixed-width types,
loup runthrowsNumericOverflowErrorfor them, and plainintwraps everywhere although the specification says it should throw. @overflowWrappingmakes a function's fixed-width arithmetic wrap on every path; thecheckedAddfamily and a working--overflow=trapare ⚠ planned.charliterals arec"x";constinitializers must be compile-time constants (E_CONST_NOT_EVALUABLE).- Chapter 4 moves on to functions.