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
@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).
@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:
@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:
@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.
@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) anddecimal(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 anintcount 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:
@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; useas.
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:
@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 isSome, and givesNoneotherwise.??supplies a default forNone.match(Chapter 7) handles both cases explicitly.
Mistake: using an Option as if it were the value
@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 throwTypeErrorwhen the value is not anint(§1-4.3). In Loup 1.25 it does not check: the value passes through unchanged and fails later, somewhere less obvious. Useas?and handleNone.
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:
@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
letis immutable,varis mutable, andconstis a compile-time constant (E_CONST_NOT_EVALUABLEotherwise). Preferlet.intis 64-bit;int8…uint64are fixed-width, chosen with a suffix, and overflow on them throwsNumericOverflowError.intoverflow currently wraps.floatis IEEE 754 double precision; a whole-numberfloatprints without.0.intandfloatnever mix implicitly: convert withas./truncates and//floors.- Conditions must be
bool; there are no truthy values. char,stringandbyteshold characters, text and binary data.Option[T](T?) replacesnull; use?.,??ormatchto get at the value.Anywithas?, union types withis, andtypealiases cover the less common cases.bigintanddecimalare planned.
Try It Yourself
- Write
findPort(name: string): int?that returnsSome(80)for"http",Some(443)for"https"andNoneotherwise, and printfindPort("ftp") ?? 21. - Compute the average of
[3, 4, 4]as afloat. Which conversions do you need, and what doesprintlnshow for an average of exactly4? - Add
10u8to250u8in atryblock (Chapter 10) and print the exception message.