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

```loup
@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`*

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

```loup,does_not_compile
@mode(host.native)

func main(): void {
    let host = "localhost";
    host = "example.com";
}
```

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

```loup,does_not_compile
@mode(host.native)

func main(): void {
    var timeout = 30;
    timeout = "30s";
}
```

```text
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

```loup
#~ @mode(host.native)
#~ func main(): void {
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*

```text
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):

```loup
#~ @mode(host.native)
#~ func main(): void {
println(-7 / 2);
println(-7 // 2);
println(-7 % 2);
println(2 ** 10);
println(0.1 + 0.2);
#~ }
```

*Listing 3-3: Division and exponentiation*

```text
-3
-4
-1
1024
0.30000000000000004
```

Integer division by zero throws:

```loup,throws
#~ @mode(host.native)
func average(total: int, count: int): int {
    return total / count;
}

func main(): void {
    println(average(10, 0));
}
```

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

```loup,does_not_compile
@mode(host.native)

func main(): void {
    let width = 1.5;
    let columns = 2;
    println(width * columns);
}
```

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

```loup
#~ @mode(host.native)
#~ func main(): void {
let width = 1.5;
let columns = 2;
println(width * (columns as float));
#~ }
```

*Listing 3-4: Converting with `as`*

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

```loup,throws
@mode(host.native)

func main(): void {
    let reading = 300;
    let level = reading as uint8;
    println(level);
}
```

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

```loup,not_desired_behavior
#~ @mode(host.native)
#~ func main(): void {
let bytesSent = 9223372036854775807;
println(bytesSent + 1);
#~ }
```

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

```loup,throws
@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"));
}
```

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

```loup
@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`*

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

```loup,does_not_compile
@mode(host.native)

func main(): void {
    let separator: char = '-';
    println(separator);
}
```

```text
main.lphc:4:5: error[E0001]: cannot assign 'string' to binding 'separator' of type 'char'
```

```loup
#~ @mode(host.native)
#~ func main(): void {
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*

```text
- π ★ 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.

```loup
#~ @mode(host.native)
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*

```text
server v1.2: 1024 connections, 30000 ms
```

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

```loup,does_not_compile
#~ @mode(host.native)
const ARGS = runtime.args();

func main(): void {
    println(ARGS);
}
```

```text
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.
