The Aela Book

A guide to Aela 2.2.1. It shows each idea in C, C++, Rust or Ada first, and then in Aela. Every Aela example in this book compiles with the current compiler.

For the full reference, ask the compiler: aec docs searches the language docs, and aec doc std lists the standard library.

Foreword

For a long time, programming languages competed to be expressive and ergonomic. They were designed for humans. Before 2020 this made sense, because humans wrote nearly all the code, and languages grew around what humans found natural, flexible, or satisfying.

Today, AI systems write a large share of real software. Sometimes they draft functions, and sometimes whole modules. Humans are starting to act less as typists and more as reviewers, managers, and co-authors.

But as IBM famously said in 1979: "A computer can never be held accountable, therefore a computer must never make a management decision". AI can speed up writing code, but that speed brings added risk.

Aela is designed for this shift by designing for auditability and verifiability.

When machines write more code than humans, the language itself has to provide more guardrails. The fewer ways there are to write something, the easier it is to understand it, check it, and keep it working. Aela follows this idea in its type system, its memory model, and its checks. It is built for a partnership between human oversight and machine-written code, based on constraints, clarity, and behavior you can prove.

Chapter 1. Basics

Hello, World In Other Languages

To start, here is "Hello, World!" in some familiar languages.

hello.c
1#include <stdio.h>
2
3int main(void) {
4 printf("Hello, World!\n");
5 return 0;
6}
hello.cpp
1#include <print>
2
3int main() {
4 std::println("Hello, World!");
5}
hello.rs
1fn main() {
2 println!("Hello, World!");
3}
main.adb
1with Ada.Text_IO; use Ada.Text_IO;
2
3procedure Main is
4begin
5 Put_Line("Hello, World!");
6end Main;

Hello, Aela!

hello.ae
1fn main() -> i32 {
2 println("Hello, World!");
3 return 0;
4}

A function in Aela always writes its return type. If you leave out -> i32, the program does not compile. This version is wrong on purpose, and the compiler rejects it:

Aela
1fn main() {
2 println("Hello, World!");
3}

The compiler stops with error E0361 ("main returns the exit status") and shows the right form, fn main() -> i32. A function must also return a value on every path. If you write -> i32 but forget the return 0;, you get error E0399.

Building and Running

The compiler is called aec. Give it the source file and, if you like, a name for the program with -o:

Shell
1aec hello.ae -o hello
2./hello

The output is:

Text
1Hello, World!

If you leave out -o, the program is named after the file, so aec hello.ae also makes ./hello. Every error the compiler reports has a code, such as E0399. To read more about an error, run aec explain E0399.

Putting Values Into Strings

To print a value, put it inside a string with \( ... ). Any expression can go between the parentheses.

Aela
1fn main() -> i32 {
2 let name = "Aela";
3 let year = 2025;
4 println("Hello, \(name)!"); // Hello, Aela!
5 println("Next year is \(year + 1)."); // Next year is 2026.
6 return 0;
7}

println takes only a string. To print a number on its own, put it in a string too: println("\(year)"). This is the only way to build a string from parts. There is no printf style format string, and you cannot join strings with +. Chapter 2 says more about this.

Comments

Aela
1//! A comment that starts with //! describes the whole file.
2
3/// A comment that starts with /// describes the next declaration.
4/// The command `aec doc` shows these comments.
5fn main() -> i32 {
6 // A normal comment runs to the end of the line.
7 /* A block comment can span
8 more than one line. Block comments do not nest. */
9 println("comments");
10 return 0;
11}

Exit Status and Arguments

Since the result of main is the exit status, a program can report a problem by returning a number other than zero.

status.ae
1fn main() -> i32 {
2 println("Something went wrong.");
3 return 3;
4}
Shell
1aec status.ae -o status
2./status
3echo $?

This prints "Something went wrong." and then 3.

main can also take the program's command line arguments, as an array of strings. The first element, args[0], is the name the program was started with.

greet.ae
1fn main(args: string[]) -> i32 {
2 println("Program: \(args[0])");
3 println("Arguments: \(args.len - 1)");
4 for (let i: i64 in 1..args.len) {
5 println(" \(args[i])");
6 }
7 return 0;
8}
Shell
1aec greet.ae -o greet
2./greet one two

The output is:

Text
1Program: ./greet
2Arguments: 2
3 one
4 two

There are a few more forms of main, for programs that use async code or failures, but these two are all you need for now. A main with some other form, such as one that returns i64, is error E0361. Loops like the for loop above are covered in Chapter 4.

Chapter 2. Variables, Types, and Basic Expressions

Almost every real program needs to store values, compute with them, and print the results. This chapter shows how each language handles:

Aela's rule for first values is simple. A variable that is declared without a value starts at the zero value of its type. There is no way to read memory that was never set. This removes one of the most common bugs in C and C++.

Let's compare.

C

C
1#include <stdio.h>
2
3int main(void) {
4 int a; // not initialized: its value is unknown
5 int b = 2;
6 int c = a + b; // undefined behavior
7
8 printf("c = %d\n", c);
9 return 0;
10}

C++

C++
1#include <print>
2
3int main() {
4 int a; // still not initialized, just like C
5 int b = 2;
6 int c = a + b; // undefined behavior
7
8 std::println("c = {}", c);
9}

Rust

Rust
1fn main() {
2 let a: i32; // declared, but not set
3 let b = 2;
4 let c = a + b; // compile error E0381
5 println!("c = {c}");
6}

Ada

Ada
1with Ada.Text_IO; use Ada.Text_IO;
2
3procedure Main is
4 A : Integer; -- not initialized
5 B : Integer := 2;
6 C : Integer := A + B; -- reads A before it is set
7begin
8 Put_Line("C = " & Integer'Image(C));
9end Main;

Aela

Aela
1fn main() -> i32 {
2 var a: i32; // no value given, so a starts at 0
3 let b: i32 = 2;
4 let c = a + b;
5
6 println("c = \(c)"); // c = 2
7 return 0;
8}

Zero Values

Each type has a zero value. This program prints the zero value of several types.

Aela
1struct Point {
2 x: i64;
3 y: i64;
4}
5
6fn main() -> i32 {
7 var count: i64;
8 var ratio: f64;
9 var done: bool;
10 var name: string;
11 var maybe: i64?;
12 var items: i64[];
13 var p: Point;
14 println("\(count) \(ratio) \(done) [\(name)] \(maybe) \(items) \(p)");
15 // 0 0.0 false [] none [] Point { x: 0, y: 0 }
16 return 0;
17}
Type Zero value
integers 0
floats 0.0
bool false
char the character with code 0
string "", the empty string
arrays such as T[] [], an empty array
optionals such as T? none
structs a struct with every field at its zero value

Aela has no null pointer. A value that may be missing has an optional type, such as i64?, and its zero value is none. A few kinds of values have no zero value at all, such as references and function values. You must always give those a value when you declare them.

let and var

Aela has two ways to declare a variable:

Aela
1fn main() -> i32 {
2 let limit = 3;
3 var total = 0;
4 total = total + 10;
5 total += 5;
6 println("\(limit) \(total)"); // 3 15
7 return 0;
8}

Assigning a new value to a let is a compile error. The compiler rejects this program with error E0413 ("cannot assign to immutable binding 'count'"):

Aela
1fn main() -> i32 {
2 let count = 1;
3 count = 2;
4 return 0;
5}

A good habit is to use let by default, and var only when the value needs to change.

Writing the Type

You can write a variable's type after its name, or you can leave it out. When you leave it out, the variable takes the type of its value. A whole number like 5 is an i64, and a number with a decimal point like 2.5 is an f64.

Aela
1let rate: f64 = 1.5; // a global: it must have a type
2var calls: i64 = 0;
3
4fn main() -> i32 {
5 let a = 5; // i64
6 let b: i32 = 5; // i32, because the type is written
7 let c = 2.5; // f64
8 calls += 1;
9 println("\(a) \(b) \(c) \(rate) \(calls)"); // 5 5 2.5 1.5 1
10 return 0;
11}

Variables declared outside of any function are globals. A global must always have a written type. Leaving it out is error E0303.

One Name, One Meaning

Many languages let you declare a new variable with the same name as one that is already visible. This is called shadowing. Aela does not allow it, because it makes code harder to read. The compiler rejects this program with error E0203 ("shadowing is not allowed"):

Aela
1fn main() -> i32 {
2 let x = 10;
3 if (x > 5) {
4 let x = 20;
5 println("\(x)");
6 }
7 return 0;
8}

Two blocks that come one after the other may each use the same name, because the two names are never visible at the same time.

Basic Types

Type Values
i8 i16 i32 i64 signed integers of 8, 16, 32 and 64 bits
u8 u16 u32 u64 unsigned integers of 8, 16, 32 and 64 bits
i128 u128 integers of 128 bits
f32 f64 floating point numbers
bool true and false
char one Unicode character, such as 'a' or 'é'
string text, stored as UTF-8 bytes, that cannot be changed
Duration a span of time, such as 250ms or 1.5s

Number literals can be written in several ways:

Aela
1fn main() -> i32 {
2 let million = 1_000_000; // _ makes long numbers easier to read
3 let mask: u32 = 0xFF; // hexadecimal
4 let bits = 0b1010; // binary
5 let small = 42u8; // the suffix u8 makes this a u8
6 let tiny = 2.5e-3; // a float with an exponent
7 let wait = 1.5s; // a Duration
8 let letter = 'é'; // a char
9 println("\(million) \(mask) \(bits) \(small) \(tiny) \(wait) \(letter)");
10 // 1000000 255 10 42 0.0025 1.5s é
11 return 0;
12}

A literal must fit the type it is given. let x: u8 = 300; is error E0312, because a u8 holds only 0 to 255.

No Hidden Conversions

In C, an int turns into a long or a double on its own when needed. In Aela, no value ever changes type unless you ask for it. This holds even between two integer types. The compiler rejects this program with error E0301 ("expected i64, found i32"):

Aela
1fn main() -> i32 {
2 let small: i32 = 5;
3 let big: i64 = small;
4 return 0;
5}

The two sides of an operator must also have the same type. Adding an i32 to an i64 is error E0305 ("mixed operand types"):

Aela
1fn main() -> i32 {
2 let a: i32 = 5;
3 let b: i64 = 10;
4 let c = a + b;
5 return 0;
6}

To change a value's type, use as:

Aela
1fn main() -> i32 {
2 let a: i32 = 5;
3 let b: i64 = 10;
4 let c = (a as i64) + b; // 15, an i64
5 let half = 7 as f64 / 2.0; // 3.5
6 let whole = 3.99 as i64; // 3: the fraction is cut off
7 let code = 'A' as u32; // 65
8 println("\(c) \(half) \(whole) \(code)"); // 15 3.5 3 65
9 return 0;
10}

An integer cast never changes a number without telling you. When you cast to an integer type that may not hold the value, such as a smaller one, the value is checked. If it does not fit, the program stops with a trap:

Aela
1fn main() -> i32 {
2 let n: i64 = 1000;
3 let ok = n as i32; // 1000 fits in an i32
4 println("\(ok)"); // 1000
5 let bad = n as u8; // 1000 does not fit in a u8
6 println("\(bad)"); // never reached
7 return 0;
8}

This program prints 1000, then stops with trap: integer out of range for its cast and exit status 1. If you really want only the low bits of the number, you write that out: std::low_bits(n) as u8 gives 232.

Arithmetic

The arithmetic operators are +, -, *, / and %. Multiplication and division happen before addition and subtraction, as in math. Parentheses change the order.

Aela
1fn main() -> i32 {
2 let a = 7;
3 let b = 2;
4 println("\(a + b) \(a - b) \(a * b) \(a / b) \(a % b)"); // 9 5 14 3 1
5 println("\(-7 / 2) \(-7 % 2)"); // -3 -1
6 println("\(2 + 3 * 4) \((2 + 3) * 4)"); // 14 20
7 return 0;
8}

Overflow

In C, if a signed integer gets too big for its type, the behavior is undefined. In Aela, +, - and * check every result. If the result does not fit the type, the program stops.

Aela
1fn main() -> i32 {
2 let big: u8 = 250;
3 let more: u8 = 10;
4 let sum = big + more; // 260 does not fit in a u8
5 println("\(sum)"); // never reached
6 return 0;
7}

This program stops with trap: arithmetic overflow and exit status 1. The trap report also names the function and line where it happened.

Sometimes you want a number to wrap around, as in a hash function or a checksum. For that, Aela has separate operators: +%, -% and *%. They keep the low bits of the result and never stop the program.

Aela
1fn main() -> i32 {
2 let big: u8 = 250;
3 let more: u8 = 10;
4 println("\(big +% more)"); // 4
5 println("\((127 as i8) +% 1)"); // -128
6 return 0;
7}

Because wrapping has its own operators, a reader can tell at a glance where the code expects it. A plain + always means that overflow would be a bug.

Floats

Floats use +, -, * and /, and follow the usual IEEE 754 rules. They never trap. Dividing by zero gives inf, and 0.0 / 0.0 gives NaN. There is no % on floats.

Aela
1fn main() -> i32 {
2 let x = 0.1 + 0.2;
3 let y: f64 = 1.0 / 3.0;
4 println("\(x) \(y)"); // 0.30000000000000004 0.3333333333333333
5 return 0;
6}

Comparisons and Logic

The comparison operators are ==, !=, <, <=, > and >=. Their result is a bool. To combine bool values, use && (and), || (or) and ! (not). && and || stop early when the answer is already known.

Aela
1fn main() -> i32 {
2 let age = 30;
3 let member = true;
4 let adult = age >= 18;
5 let discount = adult && !member;
6 println("\(adult) \(discount) \(age == 30 || age > 100)"); // true false true
7 return 0;
8}

The bit operators are &, |, ^, ~, << and >>. Be careful: as in C, &, | and ^ bind more loosely than comparisons. So flags & 2 == 2 means flags & (2 == 2). In C this compiles and does the wrong thing. In Aela it is a compile error, because it mixes an integer with a bool. The compiler rejects this program with error E0305 and tells you to add parentheses:

Aela
1fn main() -> i32 {
2 let flags = 6;
3 if (flags & 2 == 2) {
4 println("set");
5 }
6 return 0;
7}

The fix is to write (flags & 2) == 2.

Assignment

x = v stores a new value in a var. There are also short forms that combine an operator with assignment: +=, -=, *=, /=, %=, and the same for the bit operators. x += 5 means x = x + 5.

In Aela, an assignment is a statement and has no value. You cannot chain assignments, and you cannot assign inside a condition. The compiler rejects this program with error E0304 ("an assignment has no value"):

Aela
1fn main() -> i32 {
2 var x = 1;
3 var y = 2;
4 y = x = 5;
5 return 0;
6}

This also means the classic C bug if (x = 5), where = was written instead of ==, cannot happen in Aela.

Strings in Expressions

Strings have no operators. You cannot join them with +, and you cannot compare them with ==. Both are error E0309. The compiler rejects this program:

Aela
1fn main() -> i32 {
2 let first = "Ada";
3 let last = "Lovelace";
4 let full = first + " " + last;
5 return 0;
6}

To build a string, use interpolation. To compare two strings, use str::eq from the module std/string. The field len gives a string's length in bytes.

Aela
1import str from "std/string";
2
3fn main() -> i32 {
4 let first = "Ada";
5 let last = "Lovelace";
6 let full = "\(first) \(last)";
7 println(full); // Ada Lovelace
8 println("\(full.len)"); // 12
9 println("\(str::eq(first, "Ada"))"); // true
10 return 0;
11}

Summary

Language A variable declared without a value Reading it
C not initialized undefined behavior
C++ not initialized the same as C
Rust must be set before use compile error
Ada not initialized bounded error
Aela starts at its zero value always gives a known value

Aela also has a few rules that keep expressions predictable:

These rules mean that what you read in the code is what the program does.

Chapter 3. Conditions and Branching

Programs make decisions with control flow. The most common form is the if statement, which runs some code only when a condition is true.

All the languages in this chapter have an if statement, but they have different rules for:

Aela keeps the rules simple. A condition must be a bool, and an assignment cannot be used as a condition.

Let's compare.

C

C
1#include <stdio.h>
2
3int main(void) {
4 int x = 5;
5
6 if (x > 0) {
7 printf("x is positive\n");
8 }
9}

C Gotchas

1. Any number can be a condition

C
1if (x) { /* true when x != 0 */ }

This is short to write, but it means that almost any value can be used as a condition, even when that was not what you meant.

2. Assignment inside a condition

C
1if (x = 5) {
2 printf("Oops!\n");
3}

This sets x to 5 and then checks whether 5 is not zero, so the condition is always true. It is a classic bug.

The language allows it. Most compilers only warn about it when you turn on extra warnings.

C++

C++
1#include <print>
2
3int main() {
4 int x = 5;
5
6 if (x > 0) {
7 std::println("x is positive");
8 }
9}

C++17 added a way to declare a variable inside the if:

C++
1if (int y = x; y > 0) { /* ... */ }

This is useful, but it does not stop the old mistakes.

Rust

Rust
1fn main() {
2 let x = 5;
3
4 if x > 0 {
5 println!("x is positive");
6 }
7}

Ada

Ada
1with Ada.Text_IO; use Ada.Text_IO;
2
3procedure Main is
4 X : Integer := 5;
5begin
6 if X > 0 then
7 Put_Line("x is positive");
8 end if;
9end Main;

In this way Ada is much more like Rust and Aela than like C.

Aela

positive.ae
1fn main() -> i32 {
2 let x = 5;
3
4 if (x > 0) {
5 println("x is positive");
6 }
7
8 return 0;
9}

The condition goes inside parentheses, and the code to run goes inside braces. println is built in, so this program needs no import.

A condition must be a bool

You cannot use a number as a condition. This program does not compile:

Aela
1fn main() -> i32 {
2 let x = 5;
3 if (x) {
4 println("x is not zero");
5 }
6 return 0;
7}

The compiler stops with error[E0301]: a condition must be a bool, found i64. The same error appears if you try to use a string as a condition.

To fix it, write the comparison you mean. Here are the usual checks for a number, a string and an optional value:

Aela
1fn main() -> i32 {
2 let x = 5;
3 if (x != 0) {
4 println("x is not zero");
5 }
6
7 let name = "";
8 if (name.len == 0) {
9 println("no name");
10 }
11
12 let age: i64? = none;
13 if (age == none) {
14 println("no age");
15 }
16 return 0;
17}

Aela has no null. A value that may be missing has an optional type, such as i64?, and it is either a value or none. We will see a better way to check optionals later in this chapter.

An assignment is not a condition

In Aela an assignment is a statement. It does not produce a value, so it cannot be used inside a condition. This program does not compile:

Aela
1fn main() -> i32 {
2 var x = 0;
3 if (x = 5) {
4 println("Oops!");
5 }
6 return 0;
7}

The compiler stops with error[E0304]: an assignment has no value: write it as a statement of its own. The C bug from earlier cannot happen.

Operators in conditions

The comparison operators are ==, !=, <, <=, > and >=. They give a bool. The logical operators are && (and), || (or) and ! (not). They take bool values only.

Aela
1fn main() -> i32 {
2 let age = 30;
3 let member = true;
4 let xs: i64[] = [];
5
6 if (age >= 18 && member) {
7 println("welcome");
8 }
9 if (!member || age < 18) {
10 println("sorry");
11 }
12 if (xs.len > 0 && xs[0] == 1) {
13 println("starts with 1");
14 }
15 return 0;
16}

This prints only welcome.

&& and || stop early. If the left side of && is false, the right side is never run. In the last if, this is what keeps xs[0] from being read when the array is empty.

Both sides of a comparison must have the same type. Aela does not convert between types for you, not even between i32 and i64. Use as to convert, as in (n as i64) > limit.

Strings have no == operator. This program does not compile:

Aela
1fn main() -> i32 {
2 let a = "cat";
3 let b = "cat";
4 if (a == b) {
5 println("same");
6 }
7 return 0;
8}

The compiler stops with error[E0309]: strings have no comparison operators: use str::eq(a, b) or str::cmp(a, b). Use the function str::eq from std/string instead:

Aela
1import str from "std/string";
2
3fn main() -> i32 {
4 let a = "cat";
5 let b = "cat";
6 if (str::eq(a, b)) {
7 println("same"); // same
8 }
9 return 0;
10}

One more thing to watch for. The bit operators &, | and ^ bind more loosely than comparisons, as they do in C. So x & 1 == 0 means x & (1 == 0). In C this compiles and gives a wrong answer. In Aela it is error[E0305], because it mixes an integer and a bool. Write (x & 1) == 0 instead.

Example: A Three-Way Branch

Here is a full "positive, zero or negative" decision. Each else if is tried only when the conditions above it were false, and the final else runs when none of them were true.

three_way.ae
1fn describe(x: i64) -> void {
2 if (x > 0) {
3 println("positive");
4 } else if (x == 0) {
5 println("zero");
6 } else {
7 println("negative");
8 }
9}
10
11fn main() -> i32 {
12 describe(7); // positive
13 describe(0); // zero
14 describe(-3); // negative
15 return 0;
16}

C and C++

The structure is the same, but with the hazards we discussed above.

Rust

The structure is the same, but there are no parentheses around the conditions.

Ada

The structure is the same, but Ada writes elsif and ends the whole statement with end if;.

if Is Not an Expression

In Rust you can write let size = if x > 5 { "big" } else { "small" };. Aela does not allow this. In Aela if is only a statement, and using it as a value is a syntax error (error[E0102]: expected an expression).

When you need a value that depends on a condition, use match, which is the subject of the next section:

Aela
1fn main() -> i32 {
2 let x = 7;
3 let size = match (x > 5) { true => "big", false => "small" };
4 println(size); // big
5 return 0;
6}

match

A match compares one value against a list of patterns. The arms are tried in order, and the first arm that matches is the one that runs. It is like switch in C, but there is no fallthrough from one arm into the next, and the compiler checks that every possible value is handled.

classify.ae
1fn classify(n: i64) -> string {
2 match (n) {
3 0 => { return "zero"; },
4 1 | 2 | 3 => { return "small"; },
5 4..=9 => { return "medium"; },
6 x if x < 0 => { return "negative \(x)"; },
7 _ => { return "large"; }
8 }
9}
10
11fn main() -> i32 {
12 println(classify(0)); // zero
13 println(classify(2)); // small
14 println(classify(7)); // medium
15 println(classify(-4)); // negative -4
16 println(classify(50)); // large
17 return 0;
18}

These are the patterns you can use:

You can add a guard to an arm with if, as in x if x < 0. The arm only matches when the guard is true.

A match can also give a value. Then each arm is an expression instead of a block:

lights.ae
1enum Light { Red, Yellow, Green }
2
3fn main() -> i32 {
4 let light = Light::Yellow;
5 let advice = match (light) {
6 Light::Red => "stop",
7 Light::Yellow => "slow down",
8 Light::Green => "go",
9 };
10 println(advice); // slow down
11
12 let c = 'q';
13 let kind = match (c) { 'a'..='z' => "lower", 'A'..='Z' => "upper", _ => "other" };
14 println(kind); // lower
15
16 let word = "bye";
17 let code = match (word) { "hi" => 1, "bye" => 2, _ => 0 };
18 println("\(code)"); // 2
19 return 0;
20}

Every case must be handled

A match must cover every possible value. For an enum, that means every variant. For a number or a string, it usually means adding a _ arm at the end. An arm with a guard does not count, because the guard might be false.

This program forgets Light::Yellow, so it does not compile:

Aela
1enum Light { Red, Yellow, Green }
2
3fn main() -> i32 {
4 let light = Light::Green;
5 match (light) {
6 Light::Red => { println("stop"); },
7 Light::Green => { println("go"); },
8 }
9 return 0;
10}

The compiler stops with error[E0329]: match does not handle the variant (add an arm, or _ =>): 'Yellow'. This is very helpful when you add a new variant to an enum later. The compiler will point to every match that needs a new arm.

Tuples and structs cannot be used as patterns. Enum variants that carry fields can be matched, and a pattern can give names to their fields.

if (let ...): Checking an Optional

Earlier we checked an optional value with age == none. That tells you whether a value is there, but it does not let you use the value. Aela does not let you compare or do math on an optional directly. For example, age > 18 with age: i64? is error[E0314].

Instead, use if (let ...). If the optional holds a value, the first block runs, and the new name holds that value. If the optional is none, the else block runs.

find.ae
1fn find(xs: &i64[], want: i64) -> i64? {
2 for (let i: i64, let x: i64 in xs) {
3 if (x == want) { return i; }
4 }
5 return none;
6}
7
8fn main() -> i32 {
9 let xs: i64[] = [10, 20, 30];
10
11 if (let i = find(&xs, 20)) {
12 println("found at \(i)"); // found at 1
13 } else {
14 println("missing");
15 }
16
17 if (let i = find(&xs, 99)) {
18 println("found at \(i)");
19 } else {
20 println("missing"); // missing
21 }
22
23 println("\(find(&xs, 99) ?? -1)"); // -1
24 return 0;
25}

The name i can only be used inside the first block, and it cannot be changed. The last line shows the ?? operator. It gives the value if there is one, and the value on its right if there is not.

You can chain these checks with else if (let ...):

Aela
1fn main() -> i32 {
2 let first: string? = none;
3 let second: string? = "bo";
4 if (let a = first) {
5 println("first: \(a)");
6 } else if (let b = second) {
7 println("second: \(b)"); // second: bo
8 } else {
9 println("neither");
10 }
11 return 0;
12}

Rust has the same idea, written if let Some(i) = find(...). C has no optionals, so the usual way is to return a special value such as -1 or NULL and hope that every caller checks for it.

Summary

Language Condition must be a bool? Numbers count as true or false? Assignment allowed in if? Checks that every case is handled?
C No Yes Yes No
C++ No Yes Yes No
Rust Yes No No Yes, in match
Ada Yes No No Yes, in case
Aela Yes No No Yes, in match

Aela's rules stop several kinds of bugs that are common in C and C++, and the syntax still looks familiar.

Chapter 4. Loops

A loop repeats some work until a condition tells it to stop. Every language has some kind of while or for loop, but they differ in how safe and clear they are, and in how much the compiler can check.

This chapter shows ordinary loops first. Proving things about loops comes later, as an advanced topic.

Part 1 — Ordinary Loops

We start with the simplest form, a while loop that counts from 0 to 2.

C

C
1int i = 0;
2
3while (i < 3) {
4 printf("%d\n", i);
5 i = i + 1;
6}

C++

C++ has the same rules, and the same pitfalls, as C.

C++
1int i = 0;
2
3while (i < 3) {
4 std::println("{}", i);
5 i++;
6}

Rust

Rust
1let mut i = 0;
2
3while i < 3 {
4 println!("{i}");
5 i += 1;
6}

Ada

Ada
1with Ada.Text_IO; use Ada.Text_IO;
2
3procedure Main is
4 I : Integer := 0;
5begin
6 while I < 3 loop
7 Put_Line(Integer'Image(I));
8 I := I + 1;
9 end loop;
10end Main;

Aela

Aela follows the same model as Rust and Ada:

count.ae
1fn main() -> i32 {
2 var i = 0;
3
4 while (i < 3) {
5 println("\(i)");
6 i = i + 1;
7 }
8 return 0;
9}

This prints 0, 1 and 2, each on its own line. i is declared with var because the loop changes it. A let cannot be changed. "\(i)" puts the value of i into a string, because println only takes a string.

The rules from the last chapter apply to loops too:

Aela also never gives you a variable full of leftover memory. A variable declared without a value starts at its type's zero value:

Aela
1fn main() -> i32 {
2 var n: i64;
3 var s: string;
4 println("\(n) [\(s)]"); // 0 []
5 return 0;
6}

break and continue

break leaves the loop at once. continue skips the rest of the body and goes on with the next round of the loop. A common pattern is a while (true) loop that ends with a break:

break.ae
1fn main() -> i32 {
2 var i = 0;
3 while (true) {
4 i += 1;
5 if (i % 2 == 0) { continue; }
6 if (i > 7) { break; }
7 print("\(i) ");
8 }
9 println(""); // 1 3 5 7
10
11 for (let row: i64 in 0..3) {
12 for (let col: i64 in 0..3) {
13 if (col > row) { break; }
14 print("\(row)\(col) ");
15 }
16 }
17 println(""); // 00 10 11 20 21 22
18 return 0;
19}

print is like println, but it does not add a new line.

break and continue always act on the innermost loop. In the second part of the example, break ends only the inner loop over col, and the outer loop goes on. Aela has no loop labels, so you cannot break out of two loops at once. If you need that, put the loops in a function and use return. Using break or continue outside a loop is error[E0125].

No while (let ...)

The last chapter showed if (let x = o) for optional values. Rust also has while let, but Aela does not. while (let m = next(n)) is a syntax error (error[E0102]: expected an expression).

Write a while (true) loop with an if (let ...) inside it instead:

doubling.ae
1fn next_double(n: i64) -> i64? {
2 if (n >= 50) { return none; }
3 return n * 2;
4}
5
6fn main() -> i32 {
7 var n = 1;
8 while (true) {
9 if (let m = next_double(n)) {
10 n = m;
11 print("\(n) ");
12 } else {
13 break;
14 }
15 }
16 println(""); // 2 4 8 16 32 64
17 return 0;
18}

Basic for Loops

Now we look at loops over ranges and collections.

C (a range through an index)

C
1for (int i = 0; i < 3; i++) {
2 printf("%d\n", i);
3}

C++

C++
1#include <print>
2#include <vector>
3
4int main() {
5 for (int i : {0, 1, 2}) {
6 std::println("{}", i);
7 }
8}

Rust (iterator style)

Rust
1for i in 0..3 {
2 println!("{i}");
3}

Ada

Ada
1for I in 0 .. 2 loop
2 Put_Line(Integer'Image(I));
3end loop;

Aela

Aela loops over a range like this:

ranges.ae
1fn main() -> i32 {
2 for (let i: i64 in 0..3) {
3 println("\(i)");
4 }
5
6 for (let i: i64 in 1..=3) {
7 print("\(i) ");
8 }
9 println(""); // 1 2 3
10
11 let n = 4;
12 for (let i: i64 in -2..n) {
13 print("\(i) ");
14 }
15 println(""); // -2 -1 0 1 2 3
16
17 for (let i: i64 in 5..5) {
18 println("never printed");
19 }
20 return 0;
21}

The first loop prints 0, 1 and 2, each on its own line.

Arrays

A for loop can also walk over the elements of an array. If you add a second name in front, the first one holds the index:

arrays.ae
1fn main() -> i32 {
2 let names: string[] = ["ada", "bo", "cy"];
3
4 for (let name: string in &names) {
5 println(name);
6 }
7
8 for (let i: i64, let name: string in &names) {
9 println("\(i): \(name)");
10 }
11
12 println("\(names.len)"); // 3
13 return 0;
14}

This prints:

Shell
1ada
2bo
3cy
40: ada
51: bo
62: cy
73

You can write names or &names after in. Either way the loop only reads the elements, and the array is still there after the loop. The element's type must match the array's type. for (let x: i32 in &xs) with xs: i64[] is error[E0301]: the elements are i64, found i32. The index is always an i64.

The loop variable is a read-only view of each element. To change the elements, loop over the indexes, or over references to the elements:

change.ae
1fn main() -> i32 {
2 var xs: i64[] = [1, 2, 3];
3
4 for (let i: i64 in 0..xs.len) {
5 xs[i] = xs[i] * 10;
6 }
7 println("\(xs)"); // [10, 20, 30]
8
9 for (let p: &xs::&i64 in mut &xs) {
10 *p += 1;
11 }
12 println("\(xs)"); // [11, 21, 31]
13 return 0;
14}

In the second loop, mut &xs lends the array to the loop so that it can be changed. Each p is a reference to one element, and *p is the element itself. The type &xs::&i64 means "a reference to an i64 inside xs".

Strings

A string in Aela is a list of bytes in UTF-8. A for loop over a string gives you its bytes, as u8 values. To get characters, use str::chars from std/string:

strings.ae
1import str from "std/string";
2
3fn main() -> i32 {
4 let word = "añb";
5
6 for (let i: i64, let b: u8 in word) {
7 print("\(i):\(b) ");
8 }
9 println(""); // 0:97 1:195 2:177 3:98
10
11 let chars: char[] = str::chars(word);
12 for (let c: char in &chars) {
13 print("[\(c)]");
14 }
15 println(""); // [a][ñ][b]
16 return 0;
17}

The word has three characters, but four bytes, because ñ takes two bytes in UTF-8. Writing for (let c: char in word) is error[E0301], and the message tells you to use str::chars.

Summary of Ordinary Loops

Language Numbers count as true or false? Assignment in a condition? Range loops built in?
C Yes Yes No
C++ Yes Yes Partly
Rust No No Yes
Ada No No Yes
Aela No No Yes

In ordinary use, Aela behaves most like Rust and Ada. Conditions are strict bool values, there are no hidden conversions, and the flow of the program is easy to predict.

Part 2 — Verified Loops (Aela and SPARK Ada)

Now that we have seen ordinary loops, we look at an optional feature. In Aela you can ask the compiler to prove facts about a loop, before the program ever runs.

Both Aela and Ada in SPARK mode can check loops with invariants and variants. An invariant is a fact that must be true every time around the loop. A variant is a number that must get smaller every time around the loop, which proves that the loop ends. This part is advanced, and you can skip it on a first reading.

Why invariants?

An invariant says what is always true about the loop's variables. The checker makes sure that:

  1. it is true when the loop is first reached
  2. each round of the loop keeps it true
  3. it is still true after the loop ends, so the code after the loop can rely on it

A variant proves that the loop really ends. It must never go below zero, and it must get smaller every round.

SPARK Ada example (for reference)

Ada
1while I < N loop
2 pragma Loop_Invariant (0 <= I and then I <= N);
3 pragma Loop_Variant (Decreases => N - I);
4 I := I + 1;
5end loop;

This is the kind of tool used in systems where safety matters most, such as aircraft, medical devices and railways.

Aela example (the same idea, shorter)

In Aela, you write #invariant and #variant lines right before a while or for loop. They describe that loop. A function can also have #requires, which says what must be true when the function is called.

count_up.ae
1#requires 0 <= n;
2fn count_up(n: i64) -> i64 {
3 var i: i64 = 0;
4 #invariant 0 <= i && i <= n;
5 #variant n - i;
6 while (i < n) {
7 i = i + 1;
8 }
9 return i;
10}
11
12fn main() -> i32 {
13 println("\(count_up(3))"); // 3
14 return 0;
15}

These lines are only checked when you build with --verify:

Shell
1aec count_up.ae --verify -o count_up && ./count_up

A normal build without --verify skips them. Either way, nothing extra goes into the program, so the checks cost nothing when it runs.

With --verify, the compiler proves that:

If a proof fails, the program does not compile. For example, #variant i; in the loop above is wrong, because i gets bigger, not smaller. With --verify this is error[E0807]: the loop variant (#variant) is not proven to decrease. An invariant that is false when the loop starts, such as #invariant i < 0;, is error[E0806]: the loop invariant is not proven on entry.

There are limits to what the checker understands. A specification must be made of comparisons joined by &&. Each side may use variables, lengths such as xs.len, constants, + and -. Calls, * and || are not allowed, and using them is error[E0802]. A #variant must be even simpler, such as n - i or xs.len - i. These limits keep the checker fast and predictable.

What --verify checks for you

--verify does more than check the lines you write. It also proves that every array index is in range, and that plain +, - and * cannot overflow.

Here is a loop that adds up an array:

total.ae
1fn total(xs: &i64[]) -> i64 | Overflow {
2 var t: i64 = 0;
3 var i: i64 = 0;
4 #invariant 0 <= i && i <= xs.len;
5 #variant xs.len - i;
6 while (i < xs.len) {
7 (t += xs[i])?;
8 i += 1;
9 }
10 return t;
11}
12
13fn main() -> i32 | Overflow {
14 let xs: i64[] = [1, 2, 3, 4];
15 println("\(total(&xs)?)"); // 10
16 return 0;
17}

With --verify, the compiler proves that:

The sum itself could still overflow if the numbers were very large, and the compiler cannot rule that out without knowing the numbers. So the addition is written (t += xs[i])?. If it overflows, the function fails with Overflow instead of stopping the program. The | Overflow in the function's type says that this can happen, and total(&xs)? in main passes the failure on.

Now suppose we make the classic off-by-one mistake and write <= instead of <:

Aela
1fn total(xs: &i64[]) -> i64 | Overflow {
2 var t: i64 = 0;
3 var i: i64 = 0;
4 while (i <= xs.len) {
5 (t += xs[i])?;
6 i += 1;
7 }
8 return t;
9}
10
11fn main() -> i32 | Overflow {
12 let xs: i64[] = [1, 2, 3, 4];
13 println("\(total(&xs)?)");
14 return 0;
15}

A normal build compiles this, and the program stops at run time with trap: index out of bounds. With --verify, the program does not compile. The error is error[E0809]: an index that may be out of bounds, and it points at xs[i]. The bug is found before the program runs.

A range loop is even simpler, because the compiler already knows the bounds of the counter. This version needs no invariant at all, and it passes --verify too:

Aela
1fn total(xs: &i64[]) -> i64 | Overflow {
2 var t: i64 = 0;
3 for (let i: i64 in 0..xs.len) {
4 (t += xs[i])?;
5 }
6 return t;
7}
8
9fn main() -> i32 | Overflow {
10 let xs: i64[] = [1, 2, 3, 4];
11 println("\(total(&xs)?)"); // 10
12 return 0;
13}

Summary (Verification Features)

Language Loop invariants? Proves loops end? Proofs at compile time?
C No No No
C++ No No No
Rust No No No
Ada with SPARK Yes Yes Yes
Aela Yes Yes Yes

With --verify, Aela can prove facts about loops, as SPARK Ada can. The checker is simpler than SPARK's, but it is built into the compiler, and it costs nothing when the program runs.

Chapter 5. Functions

Functions are the main way to group logic and reuse code. A function takes inputs, called parameters. It can give back a value, called its result. Its body has its own scope, so the names declared inside it are not visible outside.

Languages differ in what a function may do to the values it is given, and in how it reports that something went wrong. This chapter starts with the simplest case, a function that adds two integers, and then looks at those differences.

Part 1. Ordinary Functions

C

C
1#include <stdio.h>
2
3int add(int a, int b) {
4 return a + b;
5}
6
7int main(void) {
8 printf("%d\n", add(2, 3));
9 return 0;
10}

C++

C++ has the same rules as C, and adds overloading, so two functions can share a name if their parameter types differ.

C++
1#include <print>
2
3int add(int a, int b) {
4 return a + b;
5}
6
7int main() {
8 std::println("{}", add(2, 3));
9}

Rust

Rust
1fn add(a: i32, b: i32) -> i32 {
2 a + b
3}
4
5fn main() {
6 println!("{}", add(2, 3));
7}

Ada

Ada
1with Ada.Text_IO; use Ada.Text_IO;
2
3procedure Main is
4 function Add (A, B : Integer) return Integer is
5 begin
6 return A + B;
7 end Add;
8begin
9 Put_Line (Integer'Image (Add (2, 3)));
10end Main;

Aela

add.ae
1fn add(a: i32, b: i32) -> i32 {
2 return a + b;
3}
4
5fn main() -> i32 {
6 println("\(add(2, 3))"); // 5
7 return 0;
8}

The rules for an Aela function are simple and strict.

println takes a string. To print a number, put it inside a string with \( ). Any value can be printed this way.

The compiler checks that every path returns. This program does not compile, because sign(0) would reach the end of the body without a value.

Aela
1fn sign(n: i64) -> string {
2 if (n < 0) { return "negative"; }
3 if (n > 0) { return "positive"; }
4}
5
6fn main() -> i32 {
7 println(sign(4));
8 return 0;
9}

The compiler reports error[E0399]: this function can reach the end of its body without returning a value. Adding return "zero"; at the end fixes it.

The next program does not compile either. The variable x is an i64, and add wants an i32.

Aela
1fn add(a: i32, b: i32) -> i32 {
2 return a + b;
3}
4
5fn main() -> i32 {
6 let x: i64 = 2;
7 println("\(add(x, 3))");
8 return 0;
9}

The error is error[E0301]: expected i32, found i64. If you really want the conversion, write it with as, as in add(x as i32, 3). A conversion with as is checked when the program runs. If the value does not fit in an i32, the program stops with a trap.

Default values and variadic parameters

A parameter can have a default value. A call may then leave it out. The last parameter can also collect any number of extra arguments into an array. You write it with ... before its name.

Aela
1fn greet(name: string, greeting: string = "hello") -> void {
2 println("\(greeting), \(name)");
3}
4
5fn sum(first: i64, ...rest: i64[]) -> i64 {
6 var total = first;
7 for (let x: i64 in rest) { total += x; }
8 return total;
9}
10
11fn main() -> i32 {
12 greet("ada"); // hello, ada
13 greet("bob", "hi"); // hi, bob
14 println("\(sum(1, 2, 3, 4))"); // 10
15 return 0;
16}

Only parameters at the end can be left out. Aela has no named arguments.

Part 2. Passing Data, References and mut &

Languages differ in how a function gets the values passed to it. In C everything is copied, and a function that wants to change the caller's variable must be given a pointer. Nothing in C stops two pointers from pointing at the same thing, or stops a pointer from outliving the value it points to.

Aela has three ways to pass a value.

parameter the call writes what the function gets
p: T f(x) the value itself
p: &T f(&x) a shared reference that can only read x
mut p: &T f(mut &x) an exclusive reference that can change x

When you pass a value itself, numbers are copied. Strings are shared, which is safe because a string can never change. Structs and arrays are moved into the call, so the caller cannot use them afterwards. Chapter 6 shows moves in more detail.

The call site always shows what is going on. If you see mut &x in a call, you know the function may change x. If you see &x, you know it will not.

Aela
1fn total(xs: &i64[]) -> i64 {
2 var t: i64 = 0;
3 for (let x: i64 in xs) { t += x; }
4 return t;
5}
6
7fn add_to_all(mut xs: &i64[], k: i64) -> void {
8 for (let i: i64 in 0..xs.len) { xs[i] += k; }
9}
10
11fn main() -> i32 {
12 var scores: i64[] = [3, 5, 7];
13 println("\(total(&scores))"); // 15
14 add_to_all(mut &scores, 10);
15 println("\(scores)"); // [13, 15, 17]
16 return 0;
17}

Here is the classic "increment" function. Through a reference to a number, *x reads the number and *x = ... writes it.

Aela
1fn increment(mut x: &i64) -> void {
2 *x = *x + 1;
3}
4
5fn main() -> i32 {
6 var value: i64 = 10;
7 increment(mut &value);
8 println("\(value)"); // 11
9 return 0;
10}

The compiler checks both sides of the call.

A variable may have many shared references at once, or exactly one exclusive reference, but never both. This program breaks that rule, because it passes xs to be changed and to be read in the same call.

Aela
1fn copy_into(mut dst: &i64[], src: &i64[]) -> void {
2 for (let x: i64 in src) { dst.push(x); }
3}
4
5fn main() -> i32 {
6 var xs: i64[] = [1, 2];
7 copy_into(mut &xs, &xs);
8 return 0;
9}

The compiler rejects it with error[E0407]: cannot borrow while it is mutably borrowed: 'xs'. In C, the same function would add elements to the array while it is reading it, which can loop forever or read freed memory.

There is one more use of mut on a parameter. Writing mut n: i64 (with no &) makes the parameter a local variable that the function may change. It is the function's own copy, so the caller does not see the change.

Aela
1fn countdown(mut n: i64) -> void {
2 while (n > 0) {
3 print("\(n) ");
4 n -= 1;
5 }
6 println("go");
7}
8
9fn main() -> i32 {
10 let start: i64 = 3;
11 countdown(start); // 3 2 1 go
12 println("\(start)"); // 3
13 return 0;
14}

Without mut, a parameter works like a let and cannot be assigned.

Part 3. Failures as Values

Sometimes a function cannot do its job. The input may be bad, or a file may be missing. Languages handle this in different ways.

In C, a function usually returns a special value such as -1, and sets errno. Nothing forces the caller to check it.

C
1#include <stdio.h>
2#include <stdlib.h>
3
4int main(void) {
5 FILE *f = fopen("missing.txt", "r");
6 if (f == NULL) {
7 perror("fopen");
8 return 1;
9 }
10 fclose(f);
11 return 0;
12}

C++ and many other languages use exceptions. An exception can come out of any call, and nothing in the function's signature says so.

Rust returns a Result, and the ? operator passes an error on to the caller.

Rust
1fn halve(x: i64) -> Result<i64, String> {
2 if x < 0 {
3 return Err(format!("negative: {}", x));
4 }
5 Ok(x / 2)
6}
7
8fn quarter(x: i64) -> Result<i64, String> {
9 Ok(halve(halve(x)?)?)
10}
11
12fn main() {
13 match quarter(-8) {
14 Ok(v) => println!("{}", v),
15 Err(e) => println!("{}", e),
16 }
17}

Aela works in a similar way to Rust. A function lists the failures it can return after its result type, separated by |. A failure is a value with its own type, declared with failure. The function returns one with fail.

Aela
1failure Negative { value: i64; }
2
3fn checked_sqrt(n: i64) -> i64 | Negative {
4 if (n < 0) { fail Negative { value: n }; }
5 var r: i64 = 0;
6 while ((r + 1) * (r + 1) <= n) { r += 1; }
7 return r;
8}
9
10fn sum_of_roots(a: i64, b: i64) -> i64 | Negative {
11 return checked_sqrt(a)? + checked_sqrt(b)?;
12}
13
14fn main() -> i32 {
15 match (sum_of_roots(16, 9)) {
16 v => { println("ok: \(v)"); }, // ok: 7
17 e: Negative => { println("negative: \(e.value)"); }
18 }
19 match (sum_of_roots(16, -4)) {
20 v => { println("ok: \(v)"); },
21 e: Negative => { println("negative: \(e.value)"); } // negative: -4
22 }
23 return 0;
24}

The caller of a function that can fail must handle the failure in one of three ways.

There are no exceptions in Aela, and nothing unwinds the stack. A failure is just a value that a function returns, so you can always see from a signature what can go wrong.

Ignoring a failure is a compile error. This program stores the result without handling it.

Aela
1failure Negative { value: i64; }
2
3fn checked_sqrt(n: i64) -> i64 | Negative {
4 if (n < 0) { fail Negative { value: n }; }
5 return n;
6}
7
8fn main() -> i32 {
9 let r = checked_sqrt(9);
10 println("\(r)");
11 return 0;
12}

The compiler reports error[E0502]: the result of a call that can fail must be handled with ?, match or return.

Using ? also has a rule. The calling function must list the same failure in its own signature. A function declared -> i64 with no failures cannot use ? on checked_sqrt. The compiler reports E0503 there.

A few failures are built into the language. Arithmetic, division and indexing normally stop the program with a trap when something goes wrong. If you add ?, they return a failure instead. For example, (a + b)? fails with Overflow, and (a / b)? fails with DivByZero.

Aela
1fn mean(xs: &i64[]) -> i64 | Overflow | DivByZero {
2 var total: i64 = 0;
3 for (let x: i64 in xs) { (total += x)?; }
4 return (total / xs.len)?;
5}
6
7fn report(xs: &i64[]) -> string {
8 return match (mean(xs)) {
9 v => "mean \(v)",
10 _: Overflow => "too big",
11 _: DivByZero => "no values"
12 };
13}
14
15fn main() -> i32 {
16 let some: i64[] = [4, 8, 9];
17 let empty: i64[] = [];
18 println(report(&some)); // mean 7
19 println(report(&empty)); // no values
20 return 0;
21}

This example uses match as an expression. Each arm gives a value, and _: Overflow means "this failure, without giving it a name".

main may list failures too. If a failure leaves main, the program prints a report and exits with status 70.

Aela
1failure Negative { value: i64; }
2
3fn halve(x: i64) -> i64 | Negative {
4 if (x < 0) { fail Negative { value: x }; }
5 return x / 2;
6}
7
8fn main() -> i32 | Negative {
9 println("\(halve(4)?)");
10 println("\(halve(-3)?)");
11 return 0;
12}

If this file is called halve.ae, the program prints the following.

Text
12
2failure: Negative { value: -3 }
3 at halve (halve.ae:4)
4 at main (halve.ae:10)

For a real bug, where the program should simply stop, use std::trap("message") instead of a failure. Aela has no panic.

Part 4. Pure Functions

A function marked pure fn has no side effects. It cannot print, read or write files, or use a global var. It can only call other pure functions and the builtins that have no effects. It cannot take a mut & parameter, and it cannot fail. The compiler checks all of this, so the word pure is a promise you can rely on.

Aela
1pure fn square(x: i64) -> i64 {
2 return x * x;
3}
4
5pure fn sum_of_squares(a: i64, b: i64) -> i64 {
6 return square(a) + square(b);
7}
8
9fn main() -> i32 {
10 println("\(sum_of_squares(3, 4))"); // 25
11 return 0;
12}

This one does not compile, because a pure function may not print.

Aela
1pure fn square(x: i64) -> i64 {
2 println("squaring");
3 return x * x;
4}
5
6fn main() -> i32 {
7 println("\(square(3))");
8 return 0;
9}

The error is error[E0903]: a pure function can only call pure functions and effect-free builtins: 'println'.

A pure function can still trap, for example on overflow.

Part 5. Function Values and Closures

In C, you can pass a function to another function through a function pointer.

C
1#include <stdio.h>
2
3int twice(int x) { return x * 2; }
4
5int apply(int (*f)(int), int x) {
6 return f(x);
7}
8
9int main(void) {
10 printf("%d\n", apply(twice, 21));
11 return 0;
12}

In Aela, a function is a value. You can pass it, store it in a variable or an array, return it, and call it later. The type of a function value is written like a signature without names, for example fn(i64) -> i64.

Aela
1fn double(x: i64) -> i64 { return x * 2; }
2fn square(x: i64) -> i64 { return x * x; }
3
4fn apply_twice(f: fn(i64) -> i64, x: i64) -> i64 {
5 return f(f(x));
6}
7
8fn main() -> i32 {
9 println("\(apply_twice(double, 3))"); // 12
10 println("\(apply_twice(square, 3))"); // 81
11 let ops: (fn(i64) -> i64)[] = [double, square];
12 for (let op: fn(i64) -> i64 in ops) {
13 print("\(op(5)) ");
14 }
15 println(""); // 10 25
16 return 0;
17}

An array of function values needs parentheses around the function type, as in (fn(i64) -> i64)[]. Without them, the [] would belong to the result type.

You can also write a function where a value is expected. This is a function expression. It can use the variables around it, and then it is called a closure. A function expression may leave out its result type. The compiler then takes it from the first return, or uses void if there is none.

Aela
1fn main() -> i32 {
2 let step: i64 = 3;
3 let add_step = fn(x: i64) -> i64 { return x + step; };
4 println("\(add_step(10))"); // 13
5
6 var log: string = "";
7 let note = fn(word: string) -> { log = "\(log)\(word) "; };
8 note("one");
9 note("two");
10 println(log); // one two
11 return 0;
12}

How a closure sees a variable depends on the variable's type.

A closure may outlive the function that made it. Here each call of tick adds to an array that only the closure can still reach.

Aela
1fn make_counter() -> fn() -> i64 {
2 var hits: i64[] = [];
3 return fn() -> i64 {
4 hits.push(1);
5 return hits.len;
6 };
7}
8
9fn main() -> i32 {
10 let tick = make_counter();
11 tick();
12 tick();
13 println("\(tick())"); // 3
14 return 0;
15}

A closure written directly as an argument, for a function that only calls it, may also change a number around it. The compiler knows that such a closure runs only during that call.

Aela
1fn each(xs: &i64[], f: fn(i64) -> void) -> void {
2 for (let x: i64 in xs) { f(x); }
3}
4
5fn main() -> i32 {
6 let xs: i64[] = [1, 2, 3, 4];
7 var total: i64 = 0;
8 each(&xs, fn(x: i64) -> void { total += x; });
9 println("\(total)"); // 10
10 return 0;
11}

A closure that is stored in a variable cannot do this. This program is rejected with E0435, because bump holds a copy of count.

Aela
1fn main() -> i32 {
2 var count: i64 = 0;
3 let bump = fn() -> { count += 1; };
4 bump();
5 println("\(count)");
6 return 0;
7}

Some functions cannot be used as values. These are generic functions, methods, builtins such as println, and async functions. To pass one of them, wrap it in a small function expression.

Part 6. Verified Functions

Some languages let you state what a function needs and what it promises, and then prove it before the program runs. SPARK, a subset of Ada, is the best known example.

Ada
1function Add_One (N : Integer) return Integer
2 with Pre => N >= 0 and N < 1000,
3 Post => Add_One'Result = N + 1;

The SPARK tools prove that every caller meets the precondition (Pre), and that the function always meets the postcondition (Post).

Aela has a built-in verifier that works in the same spirit. You write specifications on the lines just before a function or a loop.

specification where it goes what it states
#requires before a function what must be true when the function is called
#ensures before a function what is true of result when the function returns
#let before a function a name for a value, used only in specifications
#invariant before a loop what is true each time the loop starts a new round
#variant before a loop a number that stays at 0 or more and gets smaller each round, so the loop ends

The checks run when you build with aec --verify. A normal build does not check the specifications.

Aela
1#requires n >= 0 && n < 1000;
2#ensures result == n + 1;
3fn add_one(n: i64) -> i64 {
4 return n + 1;
5}
6
7#requires 0 <= n && n < 1000000;
8#ensures result == n;
9fn count_up(n: i64) -> i64 {
10 var i: i64 = 0;
11 #invariant 0 <= i && i <= n;
12 #variant n - i;
13 while (i < n) {
14 i += 1;
15 }
16 return i;
17}
18
19fn main() -> i32 {
20 println("\(add_one(41)) \(count_up(5))"); // 42 5
21 return 0;
22}

With aec --verify, the compiler proves the following facts about this program.

The last point is worth noting. With --verify, every plain +, - and * must be proven not to overflow, and every index must be proven to be in range. This is why add_one needs its #requires.

If a call breaks a precondition, the verified build fails. This program builds normally, but it is rejected by aec --verify.

Aela
1#requires n >= 0 && n < 1000;
2fn add_one(n: i64) -> i64 {
3 return n + 1;
4}
5
6fn main() -> i32 {
7 println("\(add_one(5000))");
8 return 0;
9}

The error is error[E0804]: the precondition (#requires) is not proven for this call to 'add_one'.

Here is a loop over an array. #let gives the array's length a name, and (t += xs[i])? turns a possible overflow into a failure instead of something that must be proven impossible.

Aela
1fn total(xs: &i64[]) -> i64 | Overflow {
2 #let size = xs.len;
3 var t: i64 = 0;
4 var i: i64 = 0;
5 #invariant 0 <= i && i <= size;
6 #variant size - i;
7 while (i < xs.len) {
8 (t += xs[i])?;
9 i += 1;
10 }
11 return t;
12}
13
14fn main() -> i32 | Overflow {
15 let xs: i64[] = [1, 2, 3];
16 println("\(total(&xs)?)"); // 6
17 return 0;
18}

The verifier is simpler than SPARK's. It uses one fixed method that always finishes quickly, so whether a program verifies never depends on guesswork. In return, a specification may only compare variables, array lengths and constants, joined with +, - and &&. Multiplication, || and function calls are not allowed in a specification. The compiler rejects them with E0802 instead of quietly ignoring them. With --verify, a recursive function also needs a #variant before it, to prove that the recursion ends.

Summary

Language Types written? How errors are reported Checked side effects Contracts
C Yes special values, errno No No
C++ Yes exceptions, special values No No
Rust Yes Result and ? No No
Ada Yes exceptions No Yes, with SPARK
Aela Yes failures in the signature, ? Yes, with pure fn Yes, linear ones, with --verify

In this chapter we learned the following.

Chapter 6. Types, Structs, and Enums

Up to now, we have focused on control flow and functions. In this chapter, we learn how Aela represents data.

We go in small steps.

  1. Basic types
  2. Structs, which group named fields into one value
  3. Methods, which are functions attached to a struct
  4. Ownership and references, which decide who may use a value and when
  5. Enums, which describe a value that can take one of several forms
  6. Interfaces, which let different structs share a set of methods
  7. Type aliases

Aela borrows ideas from C, C++, Rust and Ada, and tries to keep the rules few and consistent.

1. Basic Types

Aela has the usual primitive types.

type values
i8 i16 i32 i64 signed integers
u8 u16 u32 u64 unsigned integers
f32 f64 floating point numbers
bool true and false
char one Unicode character, such as 'a'
string text, stored as UTF-8 bytes
Aela
1fn main() -> i32 {
2 let i: i32 = 10;
3 let j: i64 = 10;
4 let f: f32 = 1.5;
5 let g: f64 = 1.5;
6 let b: bool = true;
7 let c: char = 'a';
8 let s: string = "hello";
9 let n = 42; // i64, from the value
10 var count: i64; // starts at 0
11 println("\(i) \(j) \(f) \(g) \(b) \(c) \(s) \(n) \(count)");
12 // 10 10 1.5 1.5 true a hello 42 0
13 return 0;
14}

Here are the main rules.

This program does not compile, because an i32 is not an i64.

Aela
1fn main() -> i32 {
2 let small: i32 = 7;
3 let big: i64 = small;
4 return 0;
5}

The error is error[E0301]: expected i64, found i32. You convert with as.

Aela
1fn main() -> i32 {
2 let small: i32 = 7;
3 let big: i64 = small as i64;
4 let r = big * 1000;
5 println("\(r)"); // 7000
6 return 0;
7}

Integer arithmetic is checked. If +, - or * overflows, the program stops with a trap instead of giving a wrong answer. Chapter 5 showed how (a + b)? turns this into a failure instead.

2. Structs

A struct groups several values, called fields, into one value. Each field has a name and a type.

In C, a struct is a plain block of memory. A local struct that you do not fill in holds whatever bytes were there before.

C
1#include <stdio.h>
2
3struct Point {
4 int x;
5 int y;
6};
7
8int main(void) {
9 struct Point p; /* p.x and p.y are not set yet */
10 p.x = 1;
11 printf("%d\n", p.x);
12 return 0;
13}

Rust makes you give every field a value when you create a struct.

Rust
1struct Point {
2 x: i64,
3 y: i64,
4}
5
6fn main() {
7 let p = Point { x: 1, y: 2 };
8 println!("{} {}", p.x, p.y);
9}

In Aela, a struct is declared with struct. Each field ends with a semicolon.

Aela
1struct Point {
2 x: i64;
3 y: i64;
4}
5
6fn main() -> i32 {
7 var p = Point { x: 1, y: 2 };
8 p.x = 10;
9 println("\(p.x) \(p.y)"); // 10 2
10 println("\(p)"); // Point { x: 10, y: 2 }
11 var origin: Point;
12 println("\(origin)"); // Point { x: 0, y: 0 }
13 return 0;
14}

A field can also have a default value, which is used when the field is left out. A literal can copy the fields it does not name from another value of the same type, with ....

Aela
1struct Settings {
2 name: string;
3 retries: i64 = 3;
4 verbose: bool = false;
5}
6
7fn main() -> i32 {
8 let s = Settings { name: "backup" };
9 println("\(s)"); // Settings { name: "backup", retries: 3, verbose: false }
10 let t = Settings { ...s, verbose: true };
11 println("\(t)"); // Settings { name: "backup", retries: 3, verbose: true }
12 return 0;
13}

Aela has no inheritance. To build a bigger type from smaller ones, put one struct inside another as a field.

3. Methods

An impl block gives a struct its methods. A method is a function whose first parameter, the receiver, is the struct it is called on. By habit it is named self.

Aela
1struct Point {
2 x: i64;
3 y: i64;
4}
5
6impl Point {
7 fn length_squared(self: &Point) -> i64 {
8 return self.x * self.x + self.y * self.y;
9 }
10
11 fn move_by(mut self: &Point, dx: i64, dy: i64) -> void {
12 self.x += dx;
13 self.y += dy;
14 }
15}
16
17fn main() -> i32 {
18 var p = Point { x: 3, y: 4 };
19 println("\(p.length_squared())"); // 25
20 p.move_by(1, 1);
21 println("\(p)"); // Point { x: 4, y: 5 }
22 return 0;
23}

The type of the receiver says how the method uses the value.

receiver what the method gets
self: &Point a shared reference, so it can only read
mut self: &Point an exclusive reference, so it can change
self: Point the value itself, which moves into the call

At the call you just write p.move_by(1, 1). The compiler takes the right kind of reference for you, and applies the same rules as for any other reference. For example, you cannot call move_by on a let.

A method call is resolved when the program is compiled, so it costs the same as a plain function call. Aela has no static methods such as Point::new(). Use a plain function to make a new value, or declare a variable without one (var p: Point;) to start at the zero value.

4. Ownership and References

This section explains how Aela keeps memory safe without a garbage collector.

One owner

A struct or an array has exactly one owner. Assigning it to another variable, or passing it to a parameter of type Point, moves it. After a move, the old name can no longer be used.

Aela
1struct Point {
2 x: i64;
3 y: i64;
4}
5
6fn main() -> i32 {
7 let p = Point { x: 1, y: 2 };
8 let q = p; // p moves into q
9 println("\(p.x)");
10 return 0;
11}

This program does not compile. The compiler reports error[E0401]: use of moved value 'p'.

If you want two separate values, make a copy with std::copy. If a function only needs to look at a value, pass it a reference with &, and the caller keeps it.

Aela
1struct Point {
2 x: i64;
3 y: i64;
4}
5
6fn show(p: &Point) -> void {
7 println("(\(p.x), \(p.y))");
8}
9
10fn consume(p: Point) -> i64 {
11 return p.x + p.y;
12}
13
14fn main() -> i32 {
15 let p = Point { x: 1, y: 2 };
16 let q = std::copy(p); // a second, separate Point
17 show(&p); // (1, 2)
18 show(&p); // (1, 2): a borrow does not move p
19 println("\(consume(q))"); // 3: q moves into the call
20 return 0;
21}

When the owner of a struct or array goes out of scope, the compiler frees the value. There is no garbage collector, and you never call free or delete. Structs and arrays do not have reference counts. Strings are different. A string never changes, so it can be shared, and it is freed when the last user lets go of it.

Numbers, bools and chars are always copied, so they never move.

Borrowing

A reference lets you use a value without owning it. This is called borrowing. Chapter 5 showed the two kinds.

A variable may have many shared references, or one exclusive reference, but never both at the same time. While a variable is borrowed, it also cannot be changed or moved through its own name.

Aela
1struct Counter { count: i64; }
2
3fn bump(mut c: &Counter) -> void {
4 c.count += 1;
5}
6
7fn main() -> i32 {
8 var ctr = Counter { count: 0 };
9 let r = &ctr; // a shared borrow that lives to the end of main
10 bump(mut &ctr);
11 println("\(r.count)");
12 return 0;
13}

This program does not compile. The compiler reports error[E0408]: cannot borrow mutably while it is borrowed: 'ctr'. The shared reference r is still in use, so ctr cannot be lent out to be changed.

Where a reference comes from

In C++, a reference into a vector can be left pointing at freed memory when the vector grows.

C++
1#include <iostream>
2#include <string>
3#include <vector>
4
5struct User {
6 int id;
7 std::string name;
8};
9
10int main() {
11 std::vector<User> users = {{1, "ada"}};
12 User& u = users[0];
13 users.push_back({2, "bob"}); // may move every element to new memory
14 std::cout << u.name << "\n"; // u may now refer to freed memory
15}

Rust prevents this with lifetimes, which are names such as 'a that you sometimes write in signatures. Aela prevents it in a different way. Every reference names the variable it borrows from, which is called its root. When a function returns a reference, its result type names the parameter the reference comes from.

Aela
1struct User { id: i64; name: string; }
2
3fn first_user(users: &User[]) -> &users::&User {
4 return &users[0];
5}
6
7fn main() -> i32 {
8 let users: User[] = [User { id: 1, name: "ada" }, User { id: 2, name: "bob" }];
9 let u = first_user(&users); // u borrows from users
10 println(u.name); // ada
11 return 0;
12}

The type &users::&User means "a reference to a User, borrowed from the parameter users". So the caller knows that u borrows from its array. If the program tries to grow the array while u is alive, the compiler rejects it.

Aela
1struct User { id: i64; name: string; }
2
3fn first_user(users: &User[]) -> &users::&User {
4 return &users[0];
5}
6
7fn main() -> i32 {
8 var users: User[] = [User { id: 1, name: "ada" }];
9 let u = first_user(&users);
10 users.push(User { id: 2, name: "bob" });
11 println(u.name);
12 return 0;
13}

The error is error[E0410]: cannot modify a value while it is borrowed: 'users'.

There is nothing to infer and no lifetime names to learn. The checker follows each reference back to its root in one pass over the code. A reference also cannot be stored in a global, and it cannot outlive the block of the variable it borrows from.

Aela also has arenas and regions, for programs that make many small linked objects. Inside a region, new creates an object. They are a more advanced topic and are not covered in this chapter.

5. Enums

An enum describes a value that is exactly one of several named forms, called variants.

Plain enums

An enum whose variants carry no data is a plain enum. It is stored as a small integer. Variants are numbered from 0 in the order they are written, and they can be compared in that order.

Aela
1enum Color { Red, Green, Blue }
2
3fn describe(c: Color) -> string {
4 match (c) {
5 Color::Red => { return "warm"; },
6 Color::Green | Color::Blue => { return "cool"; }
7 }
8}
9
10fn main() -> i32 {
11 let c = Color::Blue;
12 var d: Color; // the first variant, Red
13 println("\(c) \(d) \(c as i64) \(c > d)"); // Blue Red 2 true
14 println(describe(c)); // cool
15 return 0;
16}

An enum value prints as the name of its variant. The zero value of a plain enum is its first variant.

Enums with data

A variant may carry fields. Then the enum is a tagged union, also called a sum type. In C you would build this by hand, with a tag and a union, and nothing stops you from reading the wrong member.

C
1#include <stdio.h>
2
3enum ShapeKind { CIRCLE, RECT };
4
5struct Shape {
6 enum ShapeKind kind;
7 union {
8 double radius;
9 struct { double width, height; } rect;
10 } u;
11};
12
13int main(void) {
14 struct Shape s = { .kind = RECT, .u.rect = { 2.0, 3.0 } };
15 printf("%f\n", s.u.radius); /* compiles, but reads the wrong member */
16 return 0;
17}

Rust has enums with data built in, and so does Aela. Ada has a similar idea, called a variant record.

Rust
1enum Shape {
2 Circle { radius: f64 },
3 Rect { width: f64, height: f64 },
4}
5
6fn area(s: &Shape) -> f64 {
7 match s {
8 Shape::Circle { radius } => 3.14159 * radius * radius,
9 Shape::Rect { width, height } => width * height,
10 }
11}
12
13fn main() {
14 println!("{}", area(&Shape::Rect { width: 2.0, height: 3.0 }));
15}

In Aela, a variant's fields are written like a function's parameters, with a name and a type. A value is created like a struct, with the field names.

Aela
1enum Shape {
2 Circle(radius: f64),
3 Rect(width: f64, height: f64),
4 Empty,
5}
6
7fn area(s: &Shape) -> f64 {
8 match (*s) {
9 Shape::Circle { radius } => { return 3.14159 * radius * radius; },
10 Shape::Rect { width, height } => { return width * height; },
11 Shape::Empty => { return 0.0; }
12 }
13}
14
15fn main() -> i32 {
16 let shapes: Shape[] = [
17 Shape::Circle { radius: 1.0 },
18 Shape::Rect { width: 2.0, height: 3.0 },
19 Shape::Empty,
20 ];
21 for (let s: &shapes::&Shape in &shapes) {
22 println("\(*s) has area \(area(s))");
23 }
24 // Shape::Circle { radius: 1.0 } has area 3.14159
25 // Shape::Rect { width: 2.0, height: 3.0 } has area 6.0
26 // Shape::Empty has area 0.0
27 return 0;
28}

The last rule is very useful when a program grows. If you add a variant, every match that does not handle it stops compiling. Here the Empty arm is missing.

Aela
1enum Shape {
2 Circle(radius: f64),
3 Rect(width: f64, height: f64),
4 Empty,
5}
6
7fn area(s: &Shape) -> f64 {
8 match (*s) {
9 Shape::Circle { radius } => { return 3.14159 * radius * radius; },
10 Shape::Rect { width, height } => { return width * height; }
11 }
12}
13
14fn main() -> i32 { return 0; }

The compiler reports error[E0329]: match does not handle the variant (add an arm, or _ =>): 'Empty'.

When you only care about one variant, if (let ...) is shorter than a match. In a pattern, .. stands for the fields you do not name.

Aela
1enum Shape {
2 Circle(radius: f64),
3 Rect(width: f64, height: f64),
4 Empty,
5}
6
7fn main() -> i32 {
8 let s = Shape::Rect { width: 2.0, height: 5.0 };
9 if (let Shape::Rect { width, .. } = s) {
10 println("a rectangle \(width) wide"); // a rectangle 2.0 wide
11 }
12 return 0;
13}

A variant may hold values of its own enum, so a tree needs nothing special. Here is a small expression tree.

Aela
1enum Expr {
2 Num(value: i64),
3 Add(left: Expr, right: Expr),
4 Mul(left: Expr, right: Expr),
5}
6
7fn eval(e: &Expr) -> i64 {
8 match (*e) {
9 Expr::Num { value } => { return value; },
10 Expr::Add { left, right } => { return eval(&left) + eval(&right); },
11 Expr::Mul { left, right } => { return eval(&left) * eval(&right); }
12 }
13}
14
15fn main() -> i32 {
16 // 1 + 2 * 3
17 let e = Expr::Add {
18 left: Expr::Num { value: 1 },
19 right: Expr::Mul { left: Expr::Num { value: 2 }, right: Expr::Num { value: 3 } },
20 };
21 println("\(eval(&e))"); // 7
22 return 0;
23}

An enum with data owns its fields, just like a struct. It moves, and it is freed with its fields. Enums cannot have methods yet, and there is no == on enums with data. Use match, if (let ...) or the builtin equal to compare them.

6. Interfaces

An interface names a set of methods. A struct that has those methods can say that it implements the interface, with impl Type as Interface. This is close to a trait in Rust.

Aela
1interface Shape {
2 fn area(self: &Self) -> f64;
3 fn name(self: &Self) -> string;
4}
5
6struct Circle { radius: f64; }
7struct Square { side: f64; }
8
9impl Circle as Shape {
10 fn area(self: &Circle) -> f64 { return 3.14159 * self.radius * self.radius; }
11 fn name(self: &Circle) -> string { return "circle"; }
12}
13
14impl Square as Shape {
15 fn area(self: &Square) -> f64 { return self.side * self.side; }
16 fn name(self: &Square) -> string { return "square"; }
17}
18
19fn main() -> i32 {
20 let shapes: Shape[] = [Circle { radius: 1.0 }, Square { side: 2.0 }];
21 for (let s: &shapes::&Shape in &shapes) {
22 println("\(s.name()): \(s.area())");
23 }
24 // circle: 3.14159
25 // square: 4.0
26 return 0;
27}

If a struct leaves out one of the methods, the impl is rejected. For example, an impl Square as Shape with only area gives error[E0365]: Square does not implement Shape: no method name.

An interface can also limit a type parameter of a generic function. Here T: Shape means "any type that implements Shape". Each call is then compiled for the real type, so the method calls need no tag check at all.

Aela
1interface Shape {
2 fn area(self: &Self) -> f64;
3}
4
5struct Square { side: f64; }
6
7impl Square as Shape {
8 fn area(self: &Square) -> f64 { return self.side * self.side; }
9}
10
11fn largest(T: Shape; xs: &T[]) -> f64 {
12 var best: f64 = 0.0;
13 for (let x: &xs::&T in xs) {
14 if (x.area() > best) { best = x.area(); }
15 }
16 return best;
17}
18
19fn main() -> i32 {
20 let squares: Square[] = [Square { side: 2.0 }, Square { side: 3.0 }];
21 println("\(largest(&squares))"); // 9.0
22 return 0;
23}

Only structs can implement an interface for now.

7. Type Aliases

A type alias gives a second name to a type. It does not create a new type, so the alias and the original mix freely.

Aela
1type Meters = f64;
2type Names = string[];
3
4fn total_length(parts: &Meters[]) -> Meters {
5 var t: Meters = 0.0;
6 for (let p: f64 in parts) { t += p; }
7 return t;
8}
9
10fn main() -> i32 {
11 let parts: f64[] = [1.5, 2.0, 0.5];
12 let team: Names = ["ada", "bob"];
13 println("\(total_length(&parts)) \(team.len)"); // 4.0 2
14 return 0;
15}

Aliases make long types easier to read. They are most useful for function types, such as type Handler = fn(i64) -> void;. A type alias can also add bounds to a number. This is called a refinement type, and it is covered in Chapter 10.

8. Putting It Together

This small to-do list uses most of the chapter. It has a struct with methods, an enum with data, a failure, and both kinds of reference.

Aela
1enum Status {
2 Open,
3 Done(note: string),
4}
5
6struct Task {
7 title: string;
8 status: Status;
9}
10
11struct TodoList {
12 tasks: Task[];
13}
14
15failure NoSuchTask { index: i64; }
16
17impl TodoList {
18 fn add(mut self: &TodoList, title: string) -> void {
19 self.tasks.push(Task { title, status: Status::Open });
20 }
21
22 fn finish(mut self: &TodoList, i: i64, note: string) -> void | NoSuchTask {
23 if (i < 0 || i >= self.tasks.len) { fail NoSuchTask { index: i }; }
24 self.tasks[i].status = Status::Done { note };
25 }
26
27 fn open_count(self: &TodoList) -> i64 {
28 var n: i64 = 0;
29 for (let t: &self::&Task in &self.tasks) {
30 match (t.status) {
31 Status::Open => { n += 1; },
32 Status::Done { .. } => {}
33 }
34 }
35 return n;
36 }
37}
38
39fn main() -> i32 | NoSuchTask {
40 var list = TodoList { tasks: [] };
41 list.add("write chapter");
42 list.add("test examples");
43 list.finish(1, "all pass")?;
44 match (list.finish(7, "oops")) {
45 _ => {},
46 e: NoSuchTask => { println("no task \(e.index)"); } // no task 7
47 }
48 println("\(list.open_count()) open"); // 1 open
49 println("\(list.tasks[1])");
50 // Task { title: "test examples", status: Status::Done { note: "all pass" } }
51 return 0;
52}

Summary

Feature C C++ Rust Aela
Fields always set No No Yes Yes, to given values or defaults
Methods No Yes Yes, in impl Yes, in impl
Inheritance No Yes No No
Enums with data By hand std::variant Yes Yes
Every case handled No No Yes Yes
Freeing memory free destructors ownership ownership
Safe references No No Yes, with lifetimes Yes, each reference names its root

In this chapter we learned the following.

Chapter 7. Modules, Imports, and Exports

Most real programs are split into many files. Each language has its own way to let one file use the code in another:

Aela's module system follows a few simple rules:

Let's start with a small program.

1. The Most Basic Import

You do not need an import to print. println is a builtin, and builtins are visible in every file:

Aela
1fn main() -> i32 {
2 println("Hello, Aela!");
3 return 0;
4}

The standard library is different. Its modules must be imported. Here we import the string module, std/string, and give it the local name str:

Aela
1import str from "std/string";
2
3fn main() -> i32 {
4 println(str::to_upper("hello, aela")); // HELLO, AELA
5 return 0;
6}

Here is what this shows:

This is the basic rule of the chapter. A name from another module is only visible if you import it.

2. Exporting from Modules

A module decides which of its names other files may use. It does this with the word export.

Here is a small module in a file named math.ae:

math.ae
1export fn add(a: i32, b: i32) -> i32 {
2 return a + b;
3}
4
5fn double(a: i32) -> i32 {
6 return a * 2;
7}

add is exported, so other files may import it. double is not exported, so only code inside math.ae can call it.

export can go in front of a function, a struct, an enum, a failure, an interface, a type alias, a C function (extern fn) or a global variable.

If another file tries to import double, the program does not compile. This example is meant to fail:

Aela
1import { double } from "math.ae";
2
3fn main() -> i32 {
4 println("\(double(4))");
5 return 0;
6}

The compiler says:

text
1error[E0205]: 'double' is not exported by math.ae (write export before its declaration there)

Other languages make different choices:

3. Importing Specific Names

You can import just the names you need. Put them in braces:

Aela
1import { add } from "math.ae";
2
3fn main() -> i32 {
4 println("\(add(2, 3))"); // 5
5 return 0;
6}

Now add can be used without a prefix. The import line at the top of the file tells the reader exactly where add comes from.

You can also give an imported name a different local name. Write the original name, a colon, and the new name:

Aela
1import { add: plus } from "math.ae";
2
3fn main() -> i32 {
4 println("\(plus(2, 3))"); // 5
5 return 0;
6}

This is useful when two modules export the same name, as we will see in section 7.

The path is relative to the file that has the import. You may leave out the .ae at the end, so "math" also finds math.ae. A file is loaded only once, however many files import it.

4. Module Namespace Imports

Instead of importing single names, you can import the whole module under one name:

Aela
1import math from "math.ae";
2
3fn main() -> i32 {
4 println("\(math::add(2, 3))"); // 5
5 return 0;
6}

Here math is the name of the module in this file, and every export of math.ae is reached as math::name.

This is often the clearest choice when you use many names from one module. Each use shows where the name comes from.

The module::name form works everywhere a name can appear: in calls such as math::add(1, 2), in types such as geo::Point, in struct literals such as geo::Point { x: 0, y: 0 }, and in enum variants such as geo::Color::Red.

Importing a module this way does not make its names visible without the prefix. This example is meant to fail:

Aela
1import math from "math.ae";
2
3fn main() -> i32 {
4 println("\(add(2, 3))");
5 return 0;
6}

The compiler even tells you how to fix it:

text
1error[E0207]: 'add' is declared in math.ae: import it: import { add } from "math.ae";

5. Re-Exporting: The Facade Module

Sometimes a library is spread over many files, but you want users to import it from one place. This is called a facade module. In Aela you build one by importing names and then exporting them again.

Here is the layout:

Shell
1math/
2 index.ae
3 src/
4 add.ae
5 mul.ae
6 sub.ae
7main.ae

Each file in src holds one function:

math/src/add.ae
1export fn add(a: i64, b: i64) -> i64 { return a + b; }
math/src/mul.ae
1export fn mul(a: i64, b: i64) -> i64 { return a * b; }
math/src/sub.ae
1export fn sub(a: i64, b: i64) -> i64 { return a - b; }

The file index.ae is the public face of the library. It imports each function and exports it again with export name;:

math/index.ae
1import { add } from "src/add.ae";
2import { mul } from "src/mul.ae";
3import { sub } from "src/sub.ae";
4
5export add;
6export mul;
7export sub;

The paths in index.ae are relative to index.ae itself, so "src/add.ae" means math/src/add.ae.

Now a program can use the library through index.ae in either style:

main.ae
1import math from "math/index.ae";
2import { add, sub } from "math/index.ae";
3
4fn main() -> i32 {
5 println("\(math::mul(3, 4)) \(add(1, 2)) \(sub(9, 4))"); // 12 3 5
6 return 0;
7}

This pattern has some good points:

In C and C++, a header that includes other headers also passes on their names, but this happens silently. In Rust, pub use does the same job as Aela's export name;.

6. No Glob Imports

Some languages let you bring every name of a module into scope with one line:

Aela has no import of this kind. The module form, import math from "math.ae", keeps every name behind the math:: prefix, so it is not a glob import. If you try to write one, the parser rejects it. This example is meant to fail:

Aela
1import * from "math.ae";
2
3fn main() -> i32 {
4 return 0;
5}
text
1error[E0101]: expected 'identifier'

This rule has a simple benefit. For any name in a file, you can find where it comes from. It is declared in the file, or it is listed in an import line at the top, or it has a module prefix such as math::, or it is a builtin.

7. Importing Types, Constants, and More

Structs, enums, global variables and functions are all exported and imported the same way. Here is a module with a constant, a struct and a function:

geometry.ae
1export let PI: f64 = 3.14159;
2
3export struct Circle {
4 r: f64;
5}
6
7export fn area(c: &Circle) -> f64 {
8 return PI * c.r * c.r;
9}

You can import the names you need:

main.ae
1import { PI, Circle, area } from "geometry.ae";
2
3fn main() -> i32 {
4 let c = Circle { r: 1.0 };
5 println("\(PI) \(area(&c))"); // 3.14159 3.14159
6 return 0;
7}

Or you can import the module and use the prefix:

main2.ae
1import geometry from "geometry.ae";
2
3fn main() -> i32 {
4 let c = geometry::Circle { r: 1.0 };
5 println("\(geometry::PI) \(geometry::area(&c))"); // 3.14159 3.14159
6 return 0;
7}

A global needs a written type, as in let PI: f64 = .... A let global cannot be changed. A var global can be.

Two modules with the same name

Suppose two modules both export a struct named Point:

geo.ae
1export struct Point { x: i64; y: i64; }
grid.ae
1export struct Point { row: i64; col: i64; }

You cannot import both under the name Point. This example is meant to fail:

bad.ae
1import { Point } from "geo.ae";
2import { Point } from "grid.ae";
3
4fn main() -> i32 {
5 return 0;
6}
text
1error[E0202]: duplicate definition of 'Point': two imports of this file give the name to different declarations

The fix is to give one of them a new local name:

main.ae
1import { Point } from "geo.ae";
2import { Point: Cell } from "grid.ae";
3
4fn main() -> i32 {
5 let p = Point { x: 1, y: 2 };
6 let c = Cell { row: 3, col: 4 };
7 println("\(p)"); // geo::Point { x: 1, y: 2 }
8 println("\(c)"); // grid::Point { row: 3, col: 4 }
9 return 0;
10}

When two files declare the same name, Aela prints each one with its module name in front, as you can see in the output. That way you can always tell them apart.

Fields and methods across packages

Inside your own program, every file can use all the fields and methods of your structs. Rules for fields and methods matter when you write a package that other people use. Then a field or method needs export too, or the other package cannot use it. The MODULES page in the Aela docs explains this in detail.

8. Side-by-Side Comparison Table

Feature C C++ Rust Ada Aela
How names enter scope #include #include, using use, and the prelude with and use import
Private by default? No (use static) No (use static or an unnamed namespace) Yes (pub exports) Yes (only the package spec is public) Yes (export exports)
Can re-export? A header can include another A header can include another pub use Yes, with renaming export name;
Glob imports? Yes, #include brings in everything using namespace use x::* use No
Names visible without an import Whatever the headers bring Whatever the headers bring The prelude The package Standard Only the builtins

Aela is closest to Ada in spirit. Both make the public part of a module clear, and both make you name what you use.

9. A Complete Example

Here is a small program built from three files:

Shell
1shapes/
2 circle.ae
3 index.ae
4main.ae

The file circle.ae holds the real code:

shapes/circle.ae
1export let PI: f64 = 3.14159;
2
3export struct Circle {
4 r: f64;
5}
6
7export fn area(c: &Circle) -> f64 {
8 return PI * c.r * c.r;
9}

The file index.ae is the facade. It decides what the shapes library makes public:

shapes/index.ae
1import { PI, Circle, area } from "circle.ae";
2
3export PI;
4export Circle;
5export area;

The program imports the library as one module:

main.ae
1import shapes from "shapes/index.ae";
2
3fn main() -> i32 {
4 let c = shapes::Circle { r: 2.0 };
5 println("pi = \(shapes::PI)"); // pi = 3.14159
6 println("area = \(shapes::area(&c))"); // area = 12.56636
7 return 0;
8}

To build and run it, give the compiler the program's main file. It finds the other files through the imports:

Shell
1aec main.ae -o shapes_demo && ./shapes_demo

Every name in main.ae is easy to trace. shapes comes from the import line, and everything else has the shapes:: prefix or is a builtin like println.

Chapter 7 Summary

In this chapter we learned that:

These rules make it easy to see where every name comes from, even in a large program.

Chapter 8. Vectors, Maps and Strings

So far you have learned how Aela represents values, how functions work, and how code is organized into modules. Now we turn to a practical topic. How do you store and work with collections of data in Aela?

This chapter has four parts:

  1. Arrays. Aela's built-in arrays can grow and shrink, so they do the job of a vector.
  2. Library containers. The module std/containers adds Vec, Map, Set and OrderedMap.
  3. Strings. Text is a built-in type, and the module std/string has the functions to work with it.
  4. Using them together. A short program that counts words.

1. Arrays

You have already seen an Aela array. The main function can receive the program's command-line arguments as an array of strings:

Aela
1fn main(args: string[]) -> i32 {
2 // ...
3 return 0;
4}

string[] is an array of strings. In general, the type T[] is an array of values of type T:

Unlike a C array, an Aela array knows its own length, and it can grow. In other languages you would reach for a library type to get this:

C++
1std::vector<int> v;
2v.push_back(10);
Rust
1let mut v: Vec<i32> = Vec::new();
2v.push(10);

In Aela the built-in array already does this, as section 1.3 shows.

1.1 Printing command-line arguments

Aela
1fn main(args: string[]) -> i32 {
2 let n = args.len;
3 var i = 0;
4 while (i < n) {
5 println("args[\(i)] = \(args[i])");
6 i += 1;
7 }
8 return 0;
9}

If you build this program as ./args and run ./args one two, it prints:

text
1args[0] = ./args
2args[1] = one
3args[2] = two

The first element, args[0], is the name the program was started with.

A for loop is shorter. If you write two variables, the first one is the index and the second one is the element:

Aela
1fn main(args: string[]) -> i32 {
2 for (let i: i64, let a: string in &args) {
3 println("args[\(i)] = \(a)");
4 }
5 return 0;
6}

The & in &args means the loop only reads the array. The array is still there after the loop.

1.2 Arrays of numbers

Here is a function that adds up an array of numbers:

Aela
1fn sum(nums: &i32[]) -> i32 {
2 var total: i32 = 0;
3 for (let n: i32 in nums) {
4 total += n;
5 }
6 return total;
7}
8
9fn main() -> i32 {
10 let xs: i32[] = [3, 4, 5];
11 println("\(sum(&xs))"); // 12
12 return 0;
13}

The parameter type &i32[] means that sum borrows the array. It can read the array, but it does not take it away from the caller. The call writes &xs to match.

1.3 Growing and shrinking

Three builtin methods change the length of an array, and one field reads it:

Aela
1fn main() -> i32 {
2 var xs: i64[] = [];
3 xs.push(10);
4 xs.push(20);
5 xs.push(30);
6 println("\(xs) \(xs.len)"); // [10, 20, 30] 3
7 let last = xs.pop();
8 println("\(last) \(xs)"); // 30 [10, 20]
9 xs[0] = 5;
10 println("\(xs[0]) \(xs.len)"); // 5 2
11 xs.clear();
12 println("\(xs)"); // []
13 return 0;
14}

A few things to notice:

1.4 An array has one owner

An array belongs to one variable at a time. let ys = xs; does not copy the array. It moves it, and after that xs can no longer be used. This example is meant to fail:

Aela
1fn main() -> i32 {
2 var xs: i64[] = [1, 2, 3];
3 let ys = xs;
4 println("\(xs.len)");
5 return 0;
6}
text
1error[E0401]: use of moved value 'xs'

When you really want a second array, ask for a copy with std::copy:

Aela
1fn main() -> i32 {
2 var xs: i64[] = [1, 2, 3];
3 var ys = std::copy(xs);
4 ys.push(4);
5 println("\(xs) \(ys)"); // [1, 2, 3] [1, 2, 3, 4]
6 return 0;
7}

There is also a fixed-length array, written T[N], such as u8[4]. It always has exactly N elements, so push and pop do not work on it.

2. Library Containers

The module std/containers has four types. You import the ones you need:

Aela
1import { Vec, Map, Set, OrderedMap } from "std/containers";
2
3fn main() -> i32 {
4 return 0;
5}

All four are used through methods, such as m.put(k, v) and v.push(x). An empty one is made with an empty struct literal, such as Map {}.

2.1 Vec(T)

A built-in array is enough for most programs. Vec(T) wraps an array and adds useful methods. Its field items is the array itself, so you can still index it and loop over it.

Aela
1import { Vec } from "std/containers";
2
3fn main() -> i32 {
4 var v: Vec(string) = Vec {};
5 v.push("xyz");
6 v.push("abc");
7 v.push("def");
8 println("\(v.len())"); // 3
9 println("\(v.get(0) ?? "?")"); // xyz
10 println("\(v.get(100) == none)"); // true
11 if (let x = v.last()) { println(x); } // def
12 v.insert(0, "first");
13 println("\(v.items)"); // ["first", "xyz", "abc", "def"]
14 println("\(v.contains("abc")) \(v.index_of("abc"))"); // true 2
15 let gone = v.remove(1);
16 println("\(gone) \(v.items)"); // xyz ["first", "abc", "def"]
17 if (let x = v.pop()) { println(x); } // def
18 v.reverse();
19 for (let s: string in &v.items) { print("\(s) "); }
20 println(""); // abc first
21 return 0;
22}

Some of these methods give back an optional value, of type T?. This means the result is either a value or none:

There are two common ways to use an optional. a ?? b gives the value of a, or b if a is none. if (let x = o) { ... } runs the block only when o has a value, and inside the block x is that value.

Other methods include insert, remove, swap, truncate, extend, clear and is_empty. Run aec doc std/containers to see them all.

2.2 Map(K, V)

A map stores values under keys. Here is a map from strings to strings:

Aela
1import { Map } from "std/containers";
2
3fn main() -> i32 {
4 var colors: Map(string, string) = Map {};
5 colors.put("sky", "blue");
6 colors.put("grass", "green");
7 colors.put("sky", "gray"); // replaces "blue"
8
9 println("\(colors.len())"); // 2
10 println(colors.get("sky") ?? "unknown"); // gray
11 println(colors.get("sun") ?? "unknown"); // unknown
12 println("\(colors.has("grass"))"); // true
13
14 if (let c = colors.get("grass")) {
15 println("grass is \(c)"); // grass is green
16 }
17
18 colors.remove("grass");
19 println("\(colors.has("grass"))"); // false
20 return 0;
21}

These are the main methods:

A key can be almost any value: a number, a string, an enum, or even a struct or an array. Aela compares keys by their contents.

A Map has no m[key] syntax. You always use the methods. This example is meant to fail:

Aela
1import { Map } from "std/containers";
2
3fn main() -> i32 {
4 var m: Map(string, string) = Map {};
5 m["k"] = "v";
6 return 0;
7}
text
1error[E0349]: value cannot be indexed

2.3 Looping over a map

A Map keeps its entries in slots. To visit every entry, you loop over the slot numbers from 0 to m.cap(). You skip the empty slots with m.full(s), and you read an entry with m.entry(s). Each entry has a key and a val:

Aela
1import { Map } from "std/containers";
2
3fn main() -> i32 {
4 var ages: Map(string, i64) = Map {};
5 ages.put("ada", 36);
6 ages.put("alan", 41);
7 for (let s: i64 in 0..ages.cap()) {
8 if (ages.full(s)) {
9 let e = ages.entry(s);
10 println("\(e.key) = \(e.val)");
11 }
12 }
13 return 0;
14}

A Map has no order, so the entries may come out in any order.

2.4 OrderedMap(K, V)

When the order matters, use an OrderedMap. It keeps its keys in the order they were added. m.keys() gives the keys as an array, and m.vals() gives the values in the same order:

Aela
1import { OrderedMap } from "std/containers";
2
3fn main() -> i32 {
4 var stock: OrderedMap(string, i64) = OrderedMap {};
5 stock.put("pears", 4);
6 stock.put("apples", 7);
7 stock.put("plums", 2);
8 for (let i: i64, let k: string in stock.keys()) {
9 println("\(k): \(stock.vals()[i])");
10 }
11 // pears: 4
12 // apples: 7
13 // plums: 2
14 return 0;
15}

It also has put, get, has, remove and len, just like Map. A Map is a little faster, so use it when you do not need the order.

2.5 Set(T)

A set holds each value at most once. s.add(x) gives true if x was new, and false if it was already there:

Aela
1import { Set } from "std/containers";
2
3fn main() -> i32 {
4 var seen: Set(string) = Set {};
5 println("\(seen.add("ada"))"); // true
6 println("\(seen.add("ada"))"); // false
7 println("\(seen.has("ada")) \(seen.len())"); // true 1
8 return 0;
9}

3. Strings

string is a built-in type. You do not need an import to make strings, join them with interpolation, or print them. For searching, splitting and trimming, you import std/string.

3.1 String basics

Aela
1import str from "std/string";
2
3fn main() -> i32 {
4 let greeting: string = "héllo";
5 println("\(greeting.len)"); // 6
6 println("\(greeting[0])"); // 104
7 println(greeting[0..1]); // h
8 println(greeting[3..]); // llo
9 println("\(str::eq(greeting, "héllo"))"); // true
10 return 0;
11}

Here is what this shows:

Strings cannot be changed. Every function that seems to change a string really makes a new one.

Two operators that you may expect do not work on strings. There is no ==, so you use str::eq to compare and str::cmp to put strings in order. There is no +, so you join strings with interpolation. This example is meant to fail:

Aela
1fn main() -> i32 {
2 let a = "x";
3 let s = a + "y";
4 return 0;
5}
text
1error[E0309]: strings have no + operator: use interpolation, for example "\(a)\(b)"

3.2 String interpolation

Inside a string in double quotes, \(expr) puts in the value of any expression:

Aela
1fn main() -> i32 {
2 let name = "Aela";
3 let version = 2.1;
4 println("Welcome to \(name) v\(version)!"); // Welcome to Aela v2.1!
5
6 let a = 3;
7 let b = 4;
8 println("\(a) + \(b) = \(a + b)"); // 3 + 4 = 7
9
10 let xs: i64[] = [1, 2, 3];
11 println("xs = \(xs), length \(xs.len)"); // xs = [1, 2, 3], length 3
12
13 let file = "config.ae";
14 let message = "Error:
15 Could not open file: \(file)";
16 println(message);
17 // Error:
18 // Could not open file: config.ae
19 return 0;
20}

The expression inside \( ) is checked by the compiler like any other code. Any value can be put into a string this way: numbers, strings, arrays, structs, optionals and enums.

The last example shows that a string in double quotes may span several lines. The line breaks are kept.

3.3 Raw strings

A string in backticks is a raw string. It keeps every character exactly as written, including backslashes and line breaks. This makes it handy for help text and Windows paths:

Aela
1fn main() -> i32 {
2 let usage = `Usage:
3 foo [flags] [file]
4Options:
5 -h Help
6 -i Interactive mode`;
7 println(usage);
8 let path = `C:\temp\new`;
9 println(path); // C:\temp\new
10 return 0;
11}

Because a raw string keeps every character, interpolation does not work inside it:

Aela
1fn main() -> i32 {
2 let name = "Aela";
3 println(`Hello, \(name)`); // Hello, \(name)
4 return 0;
5}

When you need both line breaks and interpolation, use a string in double quotes, as in section 3.2.

3.4 The std/string module

The module std/string has the functions for working with text. It is usually imported under the name str:

Aela
1import str from "std/string";
2
3fn main() -> i32 {
4 let s = " Hello, World ";
5 let t = str::trim(s);
6 println("[\(t)]"); // [Hello, World]
7 println("[\(str::trim_start(s))]"); // [Hello, World ]
8 println("[\(str::trim_end(s))]"); // [ Hello, World]
9 println(str::to_upper(t)); // HELLO, WORLD
10 println(str::to_lower(t)); // hello, world
11 println(str::slice(t, 0, 5)); // Hello
12 println(str::replace(t, "World", "Aela")); // Hello, Aela
13 println("\(str::contains(t, "World"))"); // true
14 println("\(str::starts_with(t, "Hell"))"); // true
15 if (let at = str::find(t, ",")) {
16 println("comma at \(at)"); // comma at 5
17 }
18 let parts: string[] = str::split("a,b,c", ",");
19 println("\(parts)"); // ["a", "b", "c"]
20 println(str::join(&parts, " + ")); // a + b + c
21 println("\(str::char_count("héllo"))"); // 5
22 return 0;
23}

A few notes:

To turn text into a number, use str::parse_int. It fails if the text is not a whole number, so you handle the failure with match:

Aela
1import str from "std/string";
2
3fn main() -> i32 {
4 let n: i64 = match (str::parse_int("123")) { v => v, e: str::BadInt => -1 };
5 let m: i64 = match (str::parse_int("12ab")) { v => v, e: str::BadInt => -1 };
6 println("\(n) \(m)"); // 123 -1
7 return 0;
8}

The page FAILURES.md in the Aela docs explains failures in detail.

3.5 Building a string piece by piece

When you build a long string out of many small pieces, use str::Builder. You add the pieces one at a time, and at the end to_string() gives you the whole string. This is faster than making a new string for every piece:

Aela
1import str from "std/string";
2
3fn main() -> i32 {
4 var b = str::Builder {};
5 b.add("hello");
6 b.add(" ");
7 b.add("world");
8 b.add_char('!');
9 b.add_int(42);
10 println(b.to_string()); // hello world!42
11 println("\(b.len())"); // 14
12 return 0;
13}

add adds a string, add_char adds one character, and add_int adds a number. len() gives the number of bytes so far.

4. Using Them Together

Let's count the words in a piece of text. We split the text into words with str::fields, count each word in an OrderedMap, and build the result with a Builder. We use an OrderedMap so that the words come out in the order they first appear.

Aela
1import str from "std/string";
2import { OrderedMap } from "std/containers";
3
4fn count_words(text: string) -> OrderedMap(string, i64) {
5 var counts: OrderedMap(string, i64) = OrderedMap {};
6 let words: string[] = str::fields(str::to_lower(text));
7 for (let w: string in &words) {
8 counts.put(w, (counts.get(w) ?? 0) + 1);
9 }
10 return counts;
11}
12
13fn main() -> i32 {
14 let counts = count_words("The cat and the dog and THE bird");
15 var b = str::Builder {};
16 for (let i: i64, let w: string in counts.keys()) {
17 if (i > 0) { b.add(", "); }
18 b.add(w);
19 b.add("=");
20 b.add_int(counts.vals()[i]);
21 }
22 println(b.to_string()); // the=3, cat=1, and=2, dog=1, bird=1
23 return 0;
24}

The line counts.put(w, (counts.get(w) ?? 0) + 1); does the counting. counts.get(w) gives the count so far, or none for a new word. ?? 0 turns none into 0. Then we add 1 and store the new count.

This small program uses every part of the chapter:

Chapter 8 Summary

In this chapter we learned:

Arrays (T[])

Library containers (std/containers)

Strings

These tools are the backbone of practical Aela programs. You use them to read input, store and transform data, and prepare text for output.

Chapter 9. Packages

By now you have written Aela programs that span several files and modules. You have organized code into modules, used containers from the standard library, and learned how imports and exports work.

This is where you will want to start thinking about packages. A package is how a small program grows into a larger piece of software. You will want packages when:

1. What Is a Package?

A package is a directory with a file named index.json in it. This file is the package's manifest. It says:

A typical layout looks like this:

text
1myapp/
2 index.json
3 src/
4 main.ae
5 lexer.ae
6 parser.ae

The compiler builds a package with aec build. The program goes into a build directory inside the package.

If modules are the chapters, a package is the book.

2. Your First Package

Make a directory named hello with two files in it. The manifest is small:

index.json
1{
2 "name": "hello",
3 "version": "0.1.0",
4 "entry": "src/main.ae"
5}

The entry file is an ordinary Aela program:

src/main.ae
1fn main() -> i32 {
2 println("hello from a package");
3 return 0;
4}

Now build and run it from inside the hello directory:

Shell
1aec build
2./build/hello

The program prints hello from a package.

Here is what the manifest's keys mean:

aec build takes the package's directory as an argument, so aec build hello works from the directory above it. It also takes the compiler's usual options, such as --release and --verify.

The manifest is JSON, but it may also have comments (// and /* */) and trailing commas. If a value has the wrong shape, the error message names the exact place in the file.

3. How Other Languages Do It

C has no package system. You share code by writing a header file, and the build tool (a Makefile or a shell command) has to know which files to compile:

geometry.h
1#ifndef GEOMETRY_H
2#define GEOMETRY_H
3
4struct vector2 { long x, y; };
5
6long dot(const struct vector2 *a, const struct vector2 *b);
7
8#endif
Shell
1cc -o app main.c geometry.c

Nothing in the C code says where geometry.c lives or which version of it you need. That knowledge sits in the build files, and it is easy to get wrong.

Rust has Cargo. Each package has a Cargo.toml file, and dependencies usually name a version range:

toml
1[package]
2name = "app"
3version = "0.1.0"
4
5[dependencies]
6geometry = { path = "../geometry" }
7rand = "0.8"

Here rand = "0.8" means "any 0.8.x release". Cargo picks the newest one it can find and writes the choice into a lock file. Node and Python tools work in a similar way.

Aela's manifest plays the same role as Cargo.toml. A dependency in Aela names a directory, and there are no version ranges. You always build against exactly the code in that directory.

4. A Library Package

Let's write a small geometry library as a package of its own. It has two modules.

text
1geometry/
2 index.json
3 src/
4 geometry.ae
5 vector2.ae
index.json
1{
2 "name": "geometry",
3 "version": "0.1.0",
4 "entry": "src/geometry.ae"
5}

The module vector2.ae holds a struct, a method and a function:

src/vector2.ae
1export struct Vector2 {
2 export x: i64;
3 export y: i64;
4}
5
6impl Vector2 {
7 export fn add(self: &Vector2, other: &Vector2) -> Vector2 {
8 return Vector2 { x: self.x + other.x, y: self.y + other.y };
9 }
10}
11
12export fn dot(a: &Vector2, b: &Vector2) -> i64 {
13 return a.x * b.x + a.y * b.y;
14}

The entry file, geometry.ae, is the face of the package. It re-exports the names from vector2.ae and adds one function of its own:

src/geometry.ae
1//! Small 2D geometry helpers.
2import { Vector2, dot } from "vector2.ae";
3
4export Vector2;
5export dot;
6
7/// The squared length of a vector.
8export fn length2(v: &Vector2) -> i64 {
9 return dot(v, v);
10}

A library's entry file does not need a main. Other packages import it.

Notice the word export in front of the fields x and y and in front of the method add. Inside a package, every file can use every field and method of the package's structs. Other packages can only use the fields and methods marked export. Section 6 shows what happens when they try to use the others.

You can read the library's documentation with aec doc. It prints the //! and /// comments next to each exported declaration:

Shell
1aec doc geometry/src/geometry.ae

5. Depending on Another Package

Now write a program that uses the library. Put it next to the geometry directory:

text
1app/
2 index.json
3 src/
4 main.ae
5geometry/
6 ...

The program's manifest lists the dependency. url is the path to the other package's directory, relative to this package:

index.json
1{
2 "name": "app",
3 "version": "0.1.0",
4 "entry": "src/main.ae",
5 "dependencies": [
6 { "name": "geometry", "url": "../geometry" }
7 ]
8}

In the code, the dependency's name is used like a module path. import geometry from "geometry" imports the dependency's entry file under the name geometry:

src/main.ae
1import geometry from "geometry";
2
3fn main() -> i32 {
4 let a = geometry::Vector2 { x: 3, y: 4 };
5 let b = geometry::Vector2 { x: 1, y: 2 };
6 let c = a.add(&b);
7 println("\(c.x) \(c.y)"); // 4 6
8 println("\(geometry::length2(&a))"); // 25
9 return 0;
10}

You can also import single names, just as you do with a file:

src/main.ae
1import { Vector2, length2 } from "geometry";
2
3fn main() -> i32 {
4 let a = Vector2 { x: 3, y: 4 };
5 println("\(length2(&a))"); // 25
6 return 0;
7}

Build it from inside app:

Shell
1aec build
2./build/app

To see what a build will use without building it, run aec manifest. For this package it prints:

text
1target: macos-arm64
2package app 0.1.0 (./)
3 entry: src/main.ae
4 output: build/app
5 depends on geometry (../geometry/)
6dependency geometry 0.1.0 (../geometry/)

The first line is the machine you build for. On Linux it will say linux-x64 or linux-arm64.

A few rules about dependencies:

6. The Package Boundary

A package draws a line around its code. Inside the line, all files can use everything. Outside it, other packages see only what is exported.

This matters most for struct fields. Here is a small counter package. The field count is not exported, so only the counter package itself can read or change it:

index.json
1{ "name": "counter", "entry": "src/counter.ae" }
src/counter.ae
1export struct Counter {
2 count: i64 = 0;
3}
4
5impl Counter {
6 export fn bump(mut self: &Counter) -> void { self.count += 1; }
7 export fn value(self: &Counter) -> i64 { return self.count; }
8}

Another package, named user, depends on it. Its manifest has no entry, so the entry is src/main.ae:

index.json
1{
2 "name": "user",
3 "dependencies": [ { "name": "counter", "url": "../counter" } ]
4}
src/main.ae
1import { Counter } from "counter";
2
3fn main() -> i32 {
4 var c: Counter = Counter {};
5 c.bump();
6 c.bump();
7 println("\(c.value())"); // 2
8 return 0;
9}

The program can make a Counter with Counter {} because every field that is not exported has a default value (= 0). If a hidden field had no default, only the counter package could make new counters (error E0231).

The next version of src/main.ae tries to set the count directly. It is meant to fail, and aec build stops with error E0230:

src/main.ae
1import { Counter } from "counter";
2
3fn main() -> i32 {
4 var c: Counter = Counter {};
5 c.count = 100;
6 println("\(c.value())");
7 return 0;
8}
text
1src/main.ae:5:5: error[E0230]: a field of Counter is not exported by its package: 'count'

So a package can keep promises about its own data. Here, the only way to change a counter is to call bump, and bump adds one each time.

7. Several Programs from One Package

Sometimes one source tree builds more than one program. A command-line tool and a small helper tool might share most of their code. For this, the manifest has products in place of entry:

text
1tools/
2 index.json
3 src/
4 shared.ae
5 hi.ae
6 bye.ae
index.json
1{
2 "name": "tools",
3 "version": "0.1.0",
4 "products": [
5 { "name": "hi", "entry": "src/hi.ae" },
6 { "name": "bye", "entry": "src/bye.ae", "options": [ "--release" ] }
7 ]
8}
src/shared.ae
1export fn greet(word: string) -> void {
2 println("\(word), world");
3}
src/hi.ae
1import { greet } from "shared.ae";
2
3fn main() -> i32 {
4 greet("hi");
5 return 0;
6}
src/bye.ae
1import { greet } from "shared.ae";
2
3fn main() -> i32 {
4 greet("bye");
5 return 0;
6}

aec build builds both programs, build/hi and build/bye. To build only one of them, name it:

Shell
1aec build --product bye

Each product has a name and an entry. It may also have an output path and a list of compiler options, which apply to that product alone. All products share the rest of the manifest, such as the dependencies.

8. C Code in a Package

A package can also include C sources. The compiler builds them with the system's C compiler and links them into the program. Aela code calls a C function after declaring it with extern fn.

text
1fast/
2 index.json
3 c/
4 sum.c
5 src/
6 main.ae
index.json
1{
2 "name": "fast",
3 "sources": { "shared": [ "c/sum.c" ] },
4 "compile": { "flags": { "shared": [ "-O2" ] } }
5}
c/sum.c
1long sum_to(long n) {
2 long t = 0;
3 for (long i = 1; i <= n; i++) t += i;
4 return t;
5}
src/main.ae
1extern fn sum_to(n: i64) -> i64;
2
3fn main() -> i32 {
4 println("\(sum_to(100))"); // 5050
5 return 0;
6}

shared means "on every platform". You can also give lists for one platform only, such as darwin (macOS) or linux. There is a link key for linker flags, such as -lm. The compiler also writes build/compile_commands.json, which editors and C tools such as clangd can read.

A package with no C sources needs no C compiler at all.

Keep in mind that the compiler cannot check C code. Whatever the C function does is trusted. The extern fn line is the place to review carefully.

9. Budgets

Most languages build a program first and let you find out later that it is too large for the device it must run on.

In Aela, a package can state its limits up front. You write them as budgets on a target. A target is a machine you build for. After linking, aec build measures the program, and the build fails if the program is over a budget.

index.json
1{
2 "name": "hello",
3 "version": "0.1.0",
4 "entry": "src/main.ae",
5 "targets": [
6 {
7 "name": "tiny",
8 "arch": "arm64",
9 "board": "linux",
10 "budgets": {
11 "flash_bytes": 65536,
12 "ram_bytes": 32768
13 }
14 }
15 ]
16}

This target builds a Linux program for 64-bit ARM. Build for it by name:

Shell
1aec build --target tiny

The build prints how much of each budget the program uses:

text
1budgets of tiny (build/hello):
2 flash (text + rodata + data): 44204 B (43 KiB) / 64 KiB (67%)
3 RAM (data + bss): 14336 B (14 KiB) / 32 KiB (43%)

The exact sizes depend on the compiler version. If you lower flash_bytes to 16384, the same build fails with error E0221:

text
1budgets of tiny (build/hello):
2error[E0221]: flash (text + rodata + data): 44204 B (43 KiB) / 16 KiB (269%): over budget by 27 KiB
3 RAM (data + bss): 14336 B (14 KiB) / 32 KiB (43%)

The two budgets that are measured today are:

A manifest can also give stack_bytes, heap_bytes and cpu budgets. The compiler reads and checks these values, but it reports them as "not measured yet". The sizes are read from ELF files, the format Linux uses, so a budget is only checked when you build for Linux. For a macOS or Windows program, the build says that the budgets were not checked. Without --target, the build is for your own machine and no budget is checked.

Why would you want limits like this? Because they turn a surprise at the end of a project into a decision at the start. If the build fails, you have to ask useful questions. Do we need this dependency? Can this code be simpler? Is this table larger than it needs to be? A continuous integration server can run the same build and catch the problem on the day it appears.

10. Where a Program Came From

Every program that aec build makes carries a record of what it was built from. The record is stored in a part of the file the program never loads, so it costs no memory at run time. aec provenance prints it:

Shell
1aec provenance build/app

The record names the package, the target, the compiler, and every file the build read, each with its SHA-256 hash. For the app package from section 5, the file lines look like this:

text
1file: 2f72462f1e32eb6e3fe4dc6a2685529cd81752265bbcb387316d43a87dd9f6d3 geometry/index.json
2file: f0c93cb2e26577d6df2d8aeb616ecd8cb0854269a310149b7f94e92a0fd07346 geometry/src/geometry.ae
3file: 7edbea70aba1865675ca92076e03428a1f3e000611938a057970f2041722220e geometry/src/vector2.ae
4file: 1930cc5900733a0f2af40be0fa1c143e4c0dff90791882ee018a4f17ab2cfd0a index.json
5file: 328c4452ab273586cd23a38650ca72e530d0d2a9f938a499c46fa74f49d4432b src/main.ae

The names in the record do not depend on where the build ran. So if two people build the same package with the same compiler, they get the same program, and they can compare the records to prove it. For software that must run for many years, this is very useful. Years later, you can still tell exactly which code is inside a device.

11. What Is Not There Yet

The package system is still growing. These parts are not available today:

The manifest has a few more keys that this chapter did not cover. include and exclude limit which files a package may load. repo records where a package is published. Run aec docs packages to read about them.

Chapter Summary

Chapter 10. Parametric and Value-Aware Types

In Aela, a type can depend on other types, and it can also depend on values that are known when the program is compiled. The compiler and the verifier can then reason about the shapes and sizes of your data before the program ever runs.

You have already used these ideas in earlier chapters, even if we did not name them:

This chapter explains what is going on. There are four parts:

Together they are a small, practical form of what language designers call dependent types. They are aimed at programs that must not fail, not at writing mathematical proofs.

1. Type Parameters: Families of Types

Most typed languages let you write code once and use it with many types. In C++ these are templates, and in Rust they are generics:

C++
1template <typename T, typename U>
2struct Pair {
3 T first;
4 U second;
5};
6
7template <typename T, typename U>
8Pair<U, T> swap_pair(const Pair<T, U>& p) {
9 return Pair<U, T>{p.second, p.first};
10}
Rust
1struct Pair<T, U> {
2 first: T,
3 second: U,
4}
5
6fn swap<T, U>(p: Pair<T, U>) -> Pair<U, T> {
7 Pair { first: p.second, second: p.first }
8}

Here is the same thing in Aela:

Aela
1struct Pair(T, U) {
2 first: T;
3 second: U;
4}
5
6fn swap(T, U; p: Pair(T, U)) -> Pair(U, T) {
7 return Pair { first: std::copy(p.second), second: std::copy(p.first) };
8}
9
10fn main() -> i32 {
11 let p = Pair { first: 42, second: "answer" }; // Pair(i64, string)
12 let q = swap(p); // Pair(string, i64)
13 println("\(q.first) \(q.second)"); // answer 42
14 let r: Pair(bool, f64) = Pair { first: true, second: 1.5 };
15 println("\(r)"); // Pair { first: true, second: 1.5 }
16 return 0;
17}

There are a few things to notice:

A generic struct can have methods. The impl block names the struct with its parameters:

Aela
1struct Stack(T) {
2 items: T[];
3}
4
5impl Stack(T) {
6 fn push1(mut self: &Stack(T), x: T) -> void { self.items.push(x); }
7 fn size(self: &Stack(T)) -> i64 { return self.items.len; }
8 fn top(self: &Stack(T)) -> T? {
9 if (self.items.len == 0) { return none; }
10 return std::copy(self.items[self.items.len - 1]);
11 }
12}
13
14fn main() -> i32 {
15 var words: Stack(string) = Stack { items: [] };
16 words.push1("alpha");
17 words.push1("beta");
18 var numbers: Stack(i64) = Stack { items: [] };
19 numbers.push1(7);
20 println("\(words.size()) \(words.top() ?? "empty")"); // 2 beta
21 println("\(numbers.size()) \(numbers.top() ?? 0)"); // 1 7
22 return 0;
23}

The containers in the standard library are built the same way. Map(K, V) is a generic struct with two type parameters:

Aela
1import { Map } from "std/containers";
2
3fn main() -> i32 {
4 var ages: Map(string, i64) = Map {};
5 ages.put("ada", 36);
6 ages.put("alan", 41);
7 println("\(ages.get("alan") ?? 0) \(ages.len())"); // 41 2
8 return 0;
9}

How generic code is compiled

As in C++ and Rust, the compiler makes a separate copy of a generic function or struct for each set of types that the program uses. Stack(string) and Stack(i64) above become two different structs with their own methods. This means type parameters cost nothing at run time. A Stack(u8) stores its bytes packed together, just as a plain u8[] would.

What you can do with a T

The compiler checks a generic function once, with T unknown. So in the body, a value of type T can only do things that every type can do. You can move it, store it, pass it, return it, copy it with std::copy(x), print it with "\(x)", compare it with std::equal(&a, &b) and hash it with std::hash(&x).

You cannot use operators on a T, not even == or >. The next example is meant to fail. The compiler rejects it with error E0348, because it cannot know that every T has a >:

Aela
1fn bigger(T; a: T, b: T) -> T {
2 if (a > b) { return a; }
3 return b;
4}
5
6fn main() -> i32 { return 0; }
text
1error[E0348]: a type parameter's values can only be moved, stored, passed, returned and printed

C++ templates are different here. C++ checks a template only when you use it, so a mistake shows up deep inside the template, in terms of the type you passed in. Aela, like Rust, checks the generic code itself.

Asking for more: interface constraints

To call methods on a T, say which interface it must implement. T: Shape means "any type that implements Shape". Rust writes the same idea as T: Shape too.

Aela
1interface Shape {
2 fn area(self: &Self) -> i64;
3}
4
5struct Square { side: i64; }
6struct Rect { w: i64; h: i64; }
7
8impl Square as Shape {
9 fn area(self: &Square) -> i64 { return self.side * self.side; }
10}
11
12impl Rect as Shape {
13 fn area(self: &Rect) -> i64 { return self.w * self.h; }
14}
15
16fn largest(T: Shape; xs: &T[]) -> i64 {
17 var best: i64 = 0;
18 for (let x: &xs::&T in xs) {
19 if (x.area() > best) { best = x.area(); }
20 }
21 return best;
22}
23
24fn main() -> i32 {
25 let squares: Square[] = [Square { side: 3 }, Square { side: 5 }];
26 let rects: Rect[] = [Rect { w: 2, h: 10 }, Rect { w: 4, h: 4 }];
27 println("\(largest(&squares)) \(largest(&rects))"); // 25 20
28 return 0;
29}

Each call must pass a T that implements Shape. Because there is one compiled copy per type, x.area() is a direct call to Square's or Rect's method.

What cannot be generic

Not everything can take type parameters:

2. Value Parameters: Types Shaped by Numbers

Now we go one step further. In Aela, a type can also depend on a value that is known when the program is compiled.

The simplest case is the fixed-size array. T[N], where N is a constant, always has exactly N elements:

Aela
1fn main() -> i32 {
2 let key: u8[32]; // exactly 32 bytes, all zero
3 let block: i64[4] = [1, 2, 3, 4]; // exactly 4 elements
4 println("\(key.len) \(block[3])"); // 32 4
5 return 0;
6}

The length is part of the type, so the compiler can hold you to it:

Here is a full program that breaks the rule about push. It is meant to fail with E0344:

Aela
1fn main() -> i32 {
2 var block: i64[4] = [1, 2, 3, 4];
3 block.push(5);
4 return 0;
5}
text
1error[E0344]: i64[4] has a fixed length: push and pop need a growable array (T[])

Be careful with one thing. If the length is not a constant, for example a variable n, then T[n] is a growable array that simply starts with n zero elements. Only a literal number or a value parameter makes a fixed array.

Structs with value parameters

A struct can take value parameters too. You write them with their type, N: u64, so the compiler can tell them apart from type parameters. Then the fields can use them:

Aela
1struct Buf(N: u64) {
2 data: u8[N];
3 used: i64;
4}
5
6fn capacity(N: u64; b: &Buf(N)) -> i64 {
7 return N as i64;
8}
9
10fn sum(N: u64; xs: &i64[N]) -> i64 {
11 var t: i64 = 0;
12 for (let i: i64 in 0..(N as i64)) { t += xs[i]; }
13 return t;
14}
15
16fn main() -> i32 {
17 var small: Buf(16);
18 var large: Buf(1024);
19 println("\(capacity(&small)) \(capacity(&large))"); // 16 1024
20 let xs: i64[5] = [1, 2, 3, 4, 5];
21 println("\(sum(&xs))"); // 15
22 return 0;
23}

Here is what this shows:

Since Buf(16) and Buf(1024) are different types, you cannot mix them. This example is meant to fail with E0301:

Aela
1struct Buf(N: u64) {
2 data: u8[N];
3 used: i64;
4}
5
6fn main() -> i32 {
7 var small: Buf(16);
8 let large: Buf(1024) = small;
9 return 0;
10}
text
1error[E0301]: expected Buf(1024), found Buf(16)

Type parameters and value parameters can be mixed. Here is a ring buffer that holds N values of any type T:

Aela
1struct Ring(T, N: u64) {
2 slots: T[N];
3 pos: i64;
4}
5
6fn put(T, N: u64; mut r: &Ring(T, N), x: T) -> void {
7 r.slots[r.pos] = x;
8 r.pos = (r.pos + 1) % (N as i64);
9}
10
11fn main() -> i32 {
12 var r: Ring(string, 3);
13 put(mut &r, "a");
14 put(mut &r, "b");
15 put(mut &r, "c");
16 put(mut &r, "d"); // wraps around and replaces "a"
17 println("\(r.slots)"); // ["d", "b", "c"]
18 return 0;
19}

How other languages do this

C++ and Rust both have something similar:

C++
1template <std::size_t N>
2struct Buf {
3 unsigned char data[N];
4 long used;
5};
Rust
1struct Buf<const N: usize> {
2 data: [u8; N],
3 used: usize,
4}

In Ada, an array type carries its bounds, and a record can take a discriminant that sets the size of an array inside it.

What Aela adds is the link to the verifier, which you will see in section 4. Because the length of data is N, the verifier can use facts about N to prove that every index is in bounds.

How value parameters are compiled

As with type parameters, a value parameter given a constant gets its own compiled copy. In Buf(16), N is simply the number 16. The array's length is then a constant, and the optimizer can remove bounds checks that it can prove are not needed. A local fixed array can also live in the function's stack frame instead of on the heap.

Limits

3. Refinement Types: Integers with a Range

Ada has a well-known feature called a subtype with a range:

Ada
1subtype Percent is Integer range 0 .. 100;
2
3function Half (P : Percent) return Percent is (P / 2);

A Percent is an Integer, but it may only hold values from 0 to 100. Aela has the same idea, called a refinement type:

Aela
1type Percent = { p: i64 where 0 <= p && p <= 100 };
2
3fn describe(p: Percent) -> string {
4 if (p == 100) { return "full"; }
5 if (p == 0) { return "empty"; }
6 return "\(p)%";
7}
8
9fn main() -> i32 {
10 let level: Percent = 75;
11 println(describe(level)); // 75%
12 println(describe(100)); // full
13 return 0;
14}

Read { p: i64 where 0 <= p && p <= 100 } as "an i64, call it p, where p is between 0 and 100".

The rules are simple:

The range is checked whenever a value is stored as a Percent: as an argument, a return value, an assignment or a starting value. When the value is a constant, the check happens at compile time. This example is meant to fail with E0813:

Aela
1type Percent = { p: i64 where 0 <= p && p <= 100 };
2
3fn main() -> i32 {
4 let level: Percent = 150;
5 return 0;
6}
text
1error[E0813]: the value is outside the range of its refinement type (0 to 100)

When the value is only known at run time, a normal build checks it at run time. This program compiles, but when it runs, it stops with trap: value outside its refinement type, because 200 is not a valid Percent:

Aela
1type Percent = { p: i64 where 0 <= p && p <= 100 };
2
3fn to_percent(x: i64) -> Percent {
4 return x;
5}
6
7fn main() -> i32 {
8 let n: i64 = 40 * 5;
9 println("\(to_percent(n))");
10 return 0;
11}

The verifier can do better, as we will see next.

4. Specifications and the Verifier

A specification is a line that states a fact about your code. Aela has four kinds:

specification where it goes what it says
#requires before a function what must be true when the function is called
#ensures before a function what is true about the result when the function returns
#invariant right before a loop what is true at the start of every pass through the loop
#variant before a loop, or before a recursive function a number that gets smaller each time, so the loop or the recursion ends

A normal build does not check specifications. They are checked when you compile with --verify:

Shell
1aec --verify file.ae -o prog

For a package, use aec build --verify.

With --verify, the compiler also proves the basic safety of every line. Every array index must be proven in bounds (error E0809). Every plain +, -, * and / must be proven not to overflow (error E0810). Every value stored in a refinement type must be proven in its range (error E0813). If the verifier cannot prove something, the program does not compile.

SPARK, the verified form of Ada, works in a similar way. A contract there looks like this:

Ada
1function Add_One (N : Integer) return Integer
2 with Pre => N >= 0 and N < 1000,
3 Post => Add_One'Result = N + 1;

#requires and #ensures

Here is the same function in Aela:

Aela
1#requires 0 <= n && n < 1000;
2#ensures result == n + 1;
3fn add_one(n: i64) -> i64 {
4 return n + 1;
5}
6
7fn main() -> i32 {
8 println("\(add_one(41))"); // 42
9 return 0;
10}

The verifier checks both sides of the promise:

So this call is rejected under --verify with error E0804:

Aela
1#requires 0 <= n && n < 1000;
2#ensures result == n + 1;
3fn add_one(n: i64) -> i64 {
4 return n + 1;
5}
6
7fn main() -> i32 {
8 println("\(add_one(5000))");
9 return 0;
10}
text
1error[E0804]: the precondition (#requires) is not proven for this call to 'add_one'

Without --verify, the same program compiles and prints 5001. Specifications are only checked by the verifier.

If the function broke its own promise, for example with #ensures result == n + 2;, the verifier would report error E0805 at the return.

Value parameters in specifications

Specifications can talk about value parameters. This is where sections 2 and 4 come together:

Aela
1struct Buf(N: u64) {
2 data: u8[N];
3 used: i64;
4}
5
6#requires N > 0;
7fn first(N: u64; b: &Buf(N)) -> u8 {
8 return b.data[0];
9}
10
11fn main() -> i32 {
12 var b: Buf(4);
13 b.data[0] = 42;
14 println("\(first(&b))"); // 42
15 return 0;
16}

The verifier knows that b.data has exactly N elements. With #requires N > 0, it can prove that b.data[0] is in bounds. Without the #requires, it would reject b.data[0] with error E0809, because a Buf(0) has no element 0.

At the call first(&b), the compiler finds N = 4 and proves 4 > 0. A call with an empty buffer is rejected under --verify. This example is meant to fail with E0804:

Aela
1struct Buf(N: u64) {
2 data: u8[N];
3 used: i64;
4}
5
6#requires N > 0;
7fn first(N: u64; b: &Buf(N)) -> u8 {
8 return b.data[0];
9}
10
11fn main() -> i32 {
12 var empty: Buf(0);
13 println("\(first(&empty))");
14 return 0;
15}
text
1error[E0804]: the precondition (#requires) is not proven for this call to 'first'

Without --verify, this program compiles, and it stops with trap: index out of bounds when it runs. The verifier moves that failure from run time to compile time.

The verifier does not need to know N as a number. It can reason about it as a symbol. Here a function with a stronger #requires calls first, and the verifier proves that N >= 2 means N > 0:

Aela
1struct Buf(N: u64) {
2 data: u8[N];
3 used: i64;
4}
5
6#requires N > 0;
7fn first(N: u64; b: &Buf(N)) -> u8 {
8 return b.data[0];
9}
10
11#requires N >= 2;
12fn first_two(N: u64; b: &Buf(N)) -> i64 {
13 let a: u8 = first(b); // proved: N >= 2 means N > 0
14 let c: u8 = b.data[1]; // proved: 1 < N
15 return (a as i64) + (c as i64);
16}
17
18fn main() -> i32 {
19 var b: Buf(8);
20 b.data[0] = 40;
21 b.data[1] = 2;
22 println("\(first_two(&b))"); // 42
23 return 0;
24}

These proofs are made once for every possible N, not once per use.

A plain if also gives the verifier facts. Inside the branch after if (N == 0) { return 0; }, it knows N > 0:

Aela
1fn last(N: u64; xs: &i64[N]) -> i64 {
2 if (N == 0) { return 0; }
3 return xs[(N as i64) - 1]; // proved: N > 0 here, so N - 1 is a valid index
4}
5
6fn main() -> i32 {
7 let xs: i64[3] = [10, 20, 30];
8 let empty: i64[0] = [];
9 println("\(last(&xs)) \(last(&empty))"); // 30 0
10 return 0;
11}

Refinement types and the verifier

Under --verify, a value stored as a refinement type must be proven to be in range. In return, the verifier may rely on the range wherever the value is read.

Here is the to_percent function from section 3, fixed so that the verifier accepts it. The if statements make sure the value is in range, so there is no trap left to happen:

Aela
1type Percent = { p: i64 where 0 <= p && p <= 100 };
2
3fn to_percent(x: i64) -> Percent {
4 if (x < 0) { return 0; }
5 if (x > 100) { return 100; }
6 return x; // proved: 0 <= x <= 100 here
7}
8
9fn half(p: Percent) -> Percent {
10 return p / 2; // proved: the verifier knows 0 <= p <= 100
11}
12
13fn main() -> i32 {
14 let n: i64 = 40 * 5;
15 let p: Percent = to_percent(n);
16 println("\(p) \(half(p))"); // 100 50
17 return 0;
18}

The range also helps with overflow. Under --verify, s * 1000 on a plain i64 is rejected with error E0810, because s could be very large. With a refinement type, the verifier knows the result is small enough:

Aela
1type Small = { s: i64 where -100 <= s && s <= 100 };
2
3fn scale(s: Small) -> i64 {
4 return s * 1000; // proved: the result is between -100000 and 100000
5}
6
7fn main() -> i32 {
8 println("\(scale(42))"); // 42000
9 return 0;
10}

Loops: #invariant and #variant

A loop can carry two specifications, written right before the while or for:

A recursive function can have a #variant too. It must get smaller at each recursive call. Under --verify, every recursive function needs one (error E0808).

Aela
1#requires 0 <= n && n < 1000;
2fn count_up(n: i64) -> i64 {
3 var i: i64 = 0;
4 #invariant 0 <= i && i <= n;
5 #variant n - i;
6 while (i < n) {
7 i += 1;
8 }
9 return i;
10}
11
12#requires n >= 0;
13#variant n;
14fn countdown(n: i64) -> i64 {
15 if (n == 0) { return 0; }
16 return countdown(n - 1);
17}
18
19fn main() -> i32 {
20 println("\(count_up(10)) \(countdown(10))"); // 10 0
21 return 0;
22}

In count_up, the number n - i starts at n, gets one smaller on each pass, and never goes below zero, because the invariant says i <= n. If you wrote #variant i; instead, the verifier would reject it with error E0807, because i gets bigger, not smaller.

What the verifier understands

The verifier uses one fixed method. It is fast and it does not guess, so whether your program compiles never depends on luck. The price is that it only understands simple facts. A specification must be made of comparisons joined by &&. The terms in a comparison may use variables, lengths such as xs.len, constants, + and -.

So products, function calls and || are not allowed. This example is meant to fail under --verify with error E0802:

Aela
1#requires a * b < 100;
2fn area(a: i64, b: i64) -> i64 {
3 return a * b;
4}
5
6fn main() -> i32 { return 0; }
text
1error[E0802]: this specification is beyond the built-in verifier (linear comparisons of variables, lengths and constants)

The usual fix is to give a bound for each variable on its own. That is often stronger than you need, but the verifier can check it:

Aela
1#requires 0 <= a && a < 10 && 0 <= b && b < 10;
2fn area(a: i64, b: i64) -> i64 {
3 return a * b;
4}
5
6fn main() -> i32 {
7 println("\(area(3, 4))"); // 12
8 return 0;
9}

5. What Aela Is Not

Aela takes a small, careful piece of dependent typing. It is enough to state the facts that matter in safety-critical code: sizes, ranges, and the conditions a function needs. But Aela is not:

Systems like Coq, Agda and Lean can prove much deeper things, but they are hard to use for embedded software, and their proofs can take a long and unpredictable time to check. Aela chooses a smaller set of facts that it can always check quickly.

6. What Aela Is

Aela puts several tools together:

With --verify, they let the compiler prove that a program has:

Aela sits in the same family as SPARK/Ada, Dafny and Why3, but with a simpler and more modern way of writing programs.

Value-aware types are not there for elegance. They are there so that the compiler can prove that whole classes of faults cannot happen, faults that are common in embedded systems.

Chapter Summary