Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Preface

This book teaches CobaltC to someone who has not written it before. It assumes you have programmed in some language, so that a loop, a function and a type are familiar ideas, and it assumes nothing else. If you know C, many things will look familiar and a few will surprise you; if you know Rust, the ideas will be familiar and the spelling will not. Either way, the book is written to be read in order, each chapter resting on the ones before it.

The first part gets a program running and then builds a small, real one, so that you have seen the whole language once before studying any part of it. The second part takes the language apart, one idea per chapter: values, functions, ownership, borrowing, data types, errors, collections, generics, modules, threads and the boundary with C. The third part builds a larger program and says where to go next. The appendices are for looking things up.

Every complete program in the book has been run. Each one is kept as a file beside the book, with the output it prints, and the project’s tests run all of them under the interpreter and under the compiler every time the language changes. So when the book says a program prints something, or is rejected with a particular message, that is what the tools do today, not what they did when the sentence was written.

A few conventions. Code that is a complete program is shown whole and is followed by what it prints. Code that is only a fragment is marked as such, and you cannot run it on its own. When a program is meant to be rejected, the line at fault carries a comment beginning with a cross, and the message the tools print is shown below the program. All the code uses one brace style, with every opening brace on a line of its own; the language does not care, and the formatter that comes with it, cobfmt, writes this style for you.

The book is part of the CobaltC repository and changes with the language. Where it is silent, the language guide and the specification, both in the same repository, have the answer; the last chapter says how to use them.

Introduction

CobaltC is a small systems programming language. It gives you what C gives you: control over memory, predictable layout, threads, direct calls into C libraries, and programs that compile to native code and run fast. It takes away one thing, the obligation to remember.

When you write C, your program depends on a long list of facts that appear nowhere in its text. This pointer is still valid. That integer cannot overflow here. This buffer was initialised before it was read. Nobody else is writing to that struct right now. This file handle is closed exactly once. The compiler checks none of them. You keep them in your head, and when one slips, the program is wrong in a way that may not show for months.

CobaltC is built around one idea: the facts that correct low-level code depends on should live in the language, not in the programmer’s memory. Every fact of that kind is either represented and checked by the language, or written down by you, in one place, as something you are asserting. There is no third option where a fact is simply assumed.

What that means in practice

Arithmetic is checked. If a + b does not fit its type, the program stops with a named fault instead of wrapping silently. When you want wrapping, you ask for it by name.

Every value that owns something, a buffer, a file, a thread, has exactly one owner. Giving it to someone else is a move, and the old name is no longer usable. When the owner goes out of scope, the thing it owned is released, in a fixed order, whether or not you remembered to release it.

References come in two kinds, shared and exclusive, and the language checks at every use that no exclusive reference is live at the same time as any other reference to the same storage. Most of these checks happen before the program runs. The ones that cannot be decided from the text happen while it runs, and a failure there stops the program with a diagnostic rather than reading freed memory.

A variable is never read before it is written. A reference never outlives the thing it points to. Two threads never race on one location. A value that came from outside the program, from a file or a socket, is bytes until you have validated it, and the language keeps the distinction.

Where the language cannot check a fact, because the fact is about memory it did not allocate or code it did not compile, you assert it inside an unsafe block. That block is a sentence in your program, visible to every reader, and a search for the word unsafe finds every assumption the program makes.

What CobaltC leaves out

A language earns its size by what it refuses. CobaltC has no inheritance, no traits or interfaces of your own, no exceptions, no garbage collector, no implicit conversions, no operator overloading, no macros, and no method-call syntax: you write Vec::push(&mut v, x), never v.push(x). Each absence is a decision, and the ones that matter to a programmer are explained where they come up. The result is a language you can hold in your head after a few days.

Two implementations

CobaltC comes with two tools that share one front end, so they accept and reject exactly the same programs with exactly the same messages.

coby is the interpreter. It runs a program straight from its source, doing every check the language requires on every operation. There is no build step, so it is the quickest way to run a program or try something out, and when the book says a program prints something, coby is what printed it.

cobc is the compiler. It translates a checked program to C and hands that to GCC or Clang, which produces a native executable. The compiled program keeps every run-time check the language requires except the ones the compiler proves can never fail, so it behaves exactly like the interpreted one and runs many times faster. The project’s tests hold the two to that promise on every program in this book.

How the book is arranged

Part I is practical. The next chapter installs the tools and runs a first program. The chapter after it builds a complete small program, a word counter, and uses most of the language once, lightly, so that the detailed chapters have something to refer back to.

Part II explains the language one idea at a time. Chapters 4 and 5 cover values and control flow, which will feel familiar. Chapters 6 and 7, ownership and borrowing, are the heart of the language and the part most worth reading slowly. The remaining chapters cover data types, errors, collections and text, generics and closures, modules, threads, and the boundary with C.

Part III builds a larger program, a command-line to-do list that keeps its data in a file, and then points you at the rest of the documentation.

You can read the whole book in a few evenings. You do not have to: a reader in a hurry can read Part I, then chapters 6 and 7, and look the rest up as they need it.

Hello, CobaltC

This chapter gets the tools onto your machine, runs a first program, and shows what the tools say when a program is wrong. By the end you will know the whole cycle: write, run, read the message, fix.

Getting the tools

CobaltC’s two tools are built from the repository with Cargo, Rust’s build tool. With Rust installed from rustup.rs, and GCC or Clang installed for the compiler to use, this builds everything:

cd impl
cargo build --release --workspace

The executables land in impl/target/release/: coby, the interpreter; cobc, the compiler; and cobfmt, the formatter. Put that directory on your path, or call them by their full path. The repository’s README.md has the details, including how to build on Windows and how to compile for it.

The first program

Save this as hello.cb:

import std;

fn main()
{
    printf("hello, world\n");
}

Run it with the interpreter:

$ coby hello.cb
hello, world

Or compile it and run the executable:

$ cobc -o hello hello.cb
$ ./hello
hello, world

cobc --run hello.cb does both at once and keeps nothing. For trying things out, coby is quicker; for a program you will run many times, cobc makes it fast. They agree on everything, so use whichever is convenient.

Three lines of this program deserve a word each.

import std; brings the standard library into scope. printf lives there, as do Vec, String and almost everything else a program uses. The language itself is small, and a program that does not import std cannot even print. You will write this line at the top of every program.

fn main() declares the function the program starts in. A function is declared with fn, its name, its parameters in parentheses, and its body in braces. This one takes no parameters and returns nothing.

printf("hello, world\n"); writes text to standard output. The function is C’s, and its format string works the same way, with one improvement that you will meet shortly. The \n ends the line; there is no separate function that adds a newline for you.

Values in the output

Here is a program with some values in it:

import std;

fn main()
{
    str name = "Ada";
    i32 year = 1815;
    i32 age = 2026 - year;
    printf("%v was born in %v, %v years ago.\n", name, year, age);
}
Ada was born in 1815, 211 years ago.

Declarations put the type first, as C does: i32 year declares year as a 32-bit signed integer, and str name declares name as text. There are no implicit conversions anywhere in the language, so types are worth getting right from the start; chapter 4 lists them.

%v in a format string prints whatever value it is given, in the way CobaltC writes that kind of value. It works for numbers, text and truth values, and you will use it more than any of C’s specifiers. The format string is checked when the program is compiled: a %d given text, or a %v with no value to print, is an error before the program runs, so a printf can never crash or print garbage.

When a program is wrong

Most of what you learn about CobaltC, you will learn from its messages, so it is worth reading one carefully now. This program has a spelling mistake:

import std;

fn main()
{
    i32 year = 1815;
    printf("%v\n", yaer);             // ✗ a name that was never declared
}

Both tools refuse it with the same message:

error[diag.unbound-name]: nothing named `yaer` is declared here
 --> mistake.cb:6:20
  |
6 |     printf("%v\n", yaer);             // ✗ a name that was never declared
  |                    ^^^^
  = repair: declare it, fix the spelling, or add an `import` (`import std;` for the standard library, `CHG-0033`)

The first line says what is wrong. The word error means the program was rejected before anything ran. In the brackets is the diagnostic’s name, diag.unbound-name. Every diagnostic has a name like this, the same in every version of the tools, and the specification keeps a registry that describes each one. The arrow line gives the file, the line and the column, in the form editors turn into a link; the source line follows with carets under the offending token; and the repair line is the registry’s standard advice.

Not everything can be caught before a program runs. Here is a program whose mistake depends on a value:

import std;

fn twice(i32 x) : i32
{
    x + x
}

fn main()
{
    printf("%v\n", twice(21));
    printf("%v\n", twice(2000000000));    // ✗ 4000000000 does not fit an i32
}
42
fault[diag.arith-overflow]: arithmetic overflow
 --> overflow.cb:5:5
  |
5 |     x + x
  |     ^
  = repair: widen, or use `wrapping_`/`saturating_`/`checked_`

The first call works and prints 42. The second asks for a sum that does not fit in a 32-bit integer. In C the result would wrap around to a negative number and the program would carry on. In CobaltC the program stops at that line with a fault. The word fault instead of error tells you the program was accepted and ran until a check failed while it was running. The rest of the message has the same shape as before.

These two kinds of message, an error before the run and a fault during it, are the only two ways a CobaltC program can be wrong at the language level. The book will often say that a mistake is caught “statically”, meaning before the run, or “dynamically”, meaning during it. The rule is always the same; only the moment it is checked differs, and that moment is fixed by the language, not by how clever the tool is.

Two options help when you are puzzled. coby --explain mistake.cb prints the registry’s whole entry for the diagnostic, with the rule of the language that rejected the program and examples of it. And coby --check prog.cb runs the checks that happen before a run and nothing else, so you can check a program that would otherwise read input or write files.

Keeping it tidy

cobfmt hello.cb rewrites a file in the style this book uses: four-space indentation, every brace on its own line, spaces where they belong. It changes layout and nothing else, and it refuses to write a file whose tokens would change. The language itself ignores whitespace between tokens, so any layout is correct; a formatter just saves arguments.

What you have

You can now write a program, run it two ways, and read what the tools say about it. The next chapter writes a program that does some real work.

A first program: counting words

Before studying the language piece by piece, it helps to see a whole program that does something useful. This one reads text from standard input and prints the words that occur most often. It is sixty lines long and uses a struct, a hash map, a vector, text handling, error handling, references and a sorting function. You are not expected to understand every line yet. Read it for its shape, then read the explanation, and come back to it from later chapters when the details have names.

The program

import std;

// One row of the final table: a word and how often it appeared.
struct Count
{
    String word;
    u32 n;
}

// The order of the table: most frequent first, then alphabetical.
fn more_frequent(ref<Count, shared> a, ref<Count, shared> b) : bool
{
    a.n > b.n || (a.n == b.n && a.word < b.word)
}

// Reads every line of standard input and counts its words.
fn count_words(ref<HashMap<String, u32>, exclusive> counts) : Result<usize, FileError>
{
    usize lines = 0;
    while (Some(line) = read_line()?)
    {
        lines += 1;
        foreach (word in String::split_whitespace(&line))
        {
            String key = String::to_ascii_lower(&String::from_view(word));
            *HashMap::entry(counts, key, 0) += 1;
        }
    }
    Ok(lines)
}

fn main() : u8
{
    HashMap<String, u32> counts = HashMap::new();
    usize lines = match (count_words(&mut counts))
    {
        Ok(n)  : n,
        Err(e) :
        {
            eprintf("wordcount: %s\n", FileError::text(&e));
            return 1;
        },
    };

    Vec<Count> table = Vec::new();
    foreach (word, n in &counts)
    {
        Vec::push(&mut table, Count { .word = String::clone(word), .n = *n });
    }
    Vec::sort_by(&mut table, more_frequent);

    printf("%v lines, %v distinct words\n", lines, Vec::len(&table));
    foreach (i, c in &table)
    {
        if (i == 5)
        {
            break;
        }
        printf("%5d %s\n", c.n, &c.word);
    }
    0
}

Given this input:

The quick brown fox jumps over the lazy dog.
The dog sleeps; the fox does not.
A fox is quick and a dog is lazy, or so the story goes.

it prints:

3 lines, 20 distinct words
    5 the
    3 fox
    2 a
    2 dog
    2 is

Run it yourself with coby wordcount.cb < some-text-file, or compile it and pipe anything you like into the executable.

A struct and a function of two references

The program begins by declaring a type for one row of its output:

struct Count
{
    String word;
    u32 n;
}

A struct is a group of named fields, each with a type. String is owned text, the kind you build at run time; u32 is an unsigned 32-bit integer. Nothing about this struct is special, except that because it contains a String, it owns something, and the language will treat it accordingly: a Count is moved from place to place, never silently copied. Chapter 6 is about that.

Then a function that says which of two rows comes first:

fn more_frequent(ref<Count, shared> a, ref<Count, shared> b) : bool
{
    a.n > b.n || (a.n == b.n && a.word < b.word)
}

The return type comes after a colon. The function’s body is a single expression with no return and no semicolon: in CobaltC, the last expression of a block is the block’s value, and a function’s body is a block. You will write return only to leave early.

The parameters are references. ref<Count, shared> means “a way to reach a Count that someone else owns, for reading only”. The function could have taken two Count values instead, but then each call would have had to hand over its rows, and the caller wants to keep them. Taking a shared reference says, in the signature, that the function looks and does not touch. Field access works through a reference without any extra syntax: a.n is the n of the row a refers to.

Comparing two Strings with < compares their text, byte by byte.

Reading input, and the first error

The next function does the counting:

fn count_words(ref<HashMap<String, u32>, exclusive> counts) : Result<usize, FileError>
{
    usize lines = 0;
    while (Some(line) = read_line()?)
    {
        lines += 1;
        foreach (word in String::split_whitespace(&line))
        {
            String key = String::to_ascii_lower(&String::from_view(word));
            *HashMap::entry(counts, key, 0) += 1;
        }
    }
    Ok(lines)
}

This time the parameter is ref<HashMap<String, u32>, exclusive>: a reference through which the map may be changed. The two kinds of reference are the whole story of chapter 7, and the short version is that any number of shared references may exist at once, but an exclusive one must be alone.

The return type, Result<usize, FileError>, says the function either succeeds with a count of lines or fails with a file error. Reading input can fail, and CobaltC has no exceptions, so a function that can fail says so in its type, and its caller has to deal with it. The last line, Ok(lines), is the success case.

The loop’s condition deserves a slow look. read_line() returns the next line as Ok(Some(line)), the end of input as Ok(None), or a failure as Err(e). The ? after the call takes the Ok apart: if the result is an error, ? returns that error from count_words at once, and the caller gets it; otherwise the expression’s value is the Option inside. Then Some(line) = ... is a pattern: when the option holds a line, the loop body runs with line bound to it, and when it is None the loop ends. One line expresses “for each line until the input ends, and give up on an error”, and nothing is silently ignored.

Inside, String::split_whitespace(&line) gives the words of the line as a vector of views: pieces of the line’s own text, not copies. The loop foreach (word in ...) visits each one. Then two calls turn a view into a lower-case String of its own, and the last line does the counting:

*HashMap::entry(counts, key, 0) += 1;

HashMap::entry finds the value stored under key, inserting 0 first if the key is new, and returns an exclusive reference to it. The * reaches the value the reference points at, and += 1 adds one. Library functions are called by their qualified name, HashMap::entry, String::from_view, always; CobaltC has no method-call syntax, so you can always see which function runs and what it is given.

Handling the result

In main, the call to count_words can fail, so its result is taken apart with match:

usize lines = match (count_words(&mut counts))
{
    Ok(n)  : n,
    Err(e) :
    {
        eprintf("wordcount: %s\n", FileError::text(&e));
        return 1;
    },
};

&mut counts lends the map to the function exclusively for the duration of the call. A match lists one arm per case, each pattern : expression,, and must cover every case; forgetting one is an error before the program runs. Because match is an expression, the whole thing is the initialiser of lines: the Ok arm gives the count, and the Err arm prints a message to standard error and returns from main with exit status 1. main is declared : u8 precisely so that it can return an exit status; the 0 at its end is the success status.

From a map to a sorted table

A hash map keeps its entries in the order they were added, which is not the order we want to print them in, so the program copies the entries into a vector and sorts that:

Vec<Count> table = Vec::new();
foreach (word, n in &counts)
{
    Vec::push(&mut table, Count { .word = String::clone(word), .n = *n });
}
Vec::sort_by(&mut table, more_frequent);

Looping over &counts borrows the map for reading and gives each key and value as references. A struct is built with a literal that names every field, Count { .word = ..., .n = ... }. The key is copied with String::clone because the map still owns the original and a String is never copied without being asked; *n reads the number through its reference. Vec::sort_by takes the function written earlier and sorts the rows by it. A function is a value you can pass like any other.

The printing loop asks for the position as well as the element, foreach (i, c in &table), so that it can stop after five rows. %5d is C’s: the number right-aligned in five columns. A String is printed through a reference, &c.word, which is the language’s way of saying that printf reads it and does not take it.

What you have seen

In sixty lines: a struct, two kinds of reference, a function that returns an error as a value and a ? that passes one up, a pattern in a loop condition, a match that must be complete, a hash map, a vector, owned text and views into it, a function passed as a value, and formatted output. Every one of these has a chapter of its own.

One flaw is worth noticing before moving on. The input’s dog. and dog were counted as different words, because the program splits only at spaces. Chapter 10 has the text functions to fix that, and it is a good first exercise once you get there.

Values, types and variables

This chapter covers the ground every program stands on: how a variable is declared, what types there are, how a number gets its type, what happens when arithmetic does not fit, and how a value is converted from one type to another. Most of it will look familiar from C. The differences are small and deliberate, and they are the ones to attend to.

Declaring a variable

A declaration puts the type first, then the name, then an initialiser:

import std;

fn main()
{
    i32 count = 3;                    // the type first, then the name
    f64 ratio = 0.75;
    bool ready = count > 2;
    str label = "items";
    auto doubled = count * 2;         // auto: the type of the initialiser, i32 here

    count = count + 1;                // any variable can be assigned
    printf("%v %v: %v, ratio %v, ready %v\n", doubled, label, count, ratio, ready);

    i32 sign;                         // declared without a value...
    if (ratio < 0.5)
    {
        sign = -1;
    }
    else
    {
        sign = 1;
    }
    printf("sign %v\n", sign);        // ...and assigned on every path before it is read
}
6 items: 4, ratio 0.75, ready true
sign 1

auto asks the language to take the type from the initialiser. It works whenever the initialiser has a type of its own, which is almost always; the exception, an empty container whose element type nothing has fixed yet, comes up in chapter 10.

Every variable can be assigned. There is no mut, no const qualifier on a local, no read-only binding. That may seem a loss if you come from Rust, but CobaltC does not need the distinction for safety: what it controls is not whether a variable may change but who can reach it, and that is the subject of chapter 7. To see whether x can change, look for x = and &mut x in its function, and nowhere else.

A variable declared without a value must be given one on every path before it is read. The check is made before the program runs, from the shape of the code alone:

import std;

fn main()
{
    i32 sign;
    bool negative = false;
    if (negative)
    {
        sign = -1;
    }
    printf("%v\n", sign);             // ✗ no value when `negative` is false
}
error[diag.use-of-uninitialized]: `sign` is read here, but on some path to here it was never given a value; give it one where it is declared (`… sign = …;`) or on every path before this
  --> uninitialised.cb:11:20
   |
11 |     printf("%v\n", sign);             // ✗ no value when `negative` is false
   |                    ^^^^
   = repair: initialize on every path; initialize aggregates whole

The checker does not know that negative is false; it knows only that one path writes sign and one does not. Reading uninitialised storage, which is undefined behaviour in C, cannot happen in a CobaltC program that the tools accept.

A name is declared once per block. A second i32 count further down the same block is an error; to change the variable, assign to it. A block inside may declare its own count, which hides the outer one until the inner block ends, as in C.

The types

TypeValues
i8 i16 i32 i64 i128signed integers, two’s complement
u8 u16 u32 u64 u128unsigned integers
isize usizeintegers the width of an address; usize is the type of every length and index
f32 f64IEEE 754 floating point
booltrue or false
strtext written in the program, valid UTF-8, copied like a number
voidthe type of nothing; a function with no return type returns it

There is no char type and no int: every integer names its width, and a single byte is written b'a', which is a u8 holding the ASCII code of a. Structs, enums, arrays, vectors, references and the rest are built from these and come in later chapters.

Types never convert into each other on their own. i32 and i64 do not mix in an expression, a u8 cannot be passed where an i32 is expected, and an integer is never silently a float. This is the first of the small differences from C, and it removes a whole family of surprises: a value’s type is whatever it was declared to be, everywhere it goes.

How a literal gets its type

A number written in the program has no type of its own until its position gives it one:

import std;

fn main()
{
    auto a = 5;                       // i32: nothing says otherwise
    u8 b = 200;                       // u8: from the declared type
    auto c = 7: u64;                  // u64: from the suffix
    auto d = b + 1;                   // u8: the 1 takes b's type
    u64 ns = 1_000_000_000;           // underscores group digits
    u32 mask = 0xFF00;                // hexadecimal; 0o17 and 0b1010 also work
    auto e = 2.5;                     // f64
    f32 f = 0.5;                      // f32: from the declared type
    u8 comma = b',';                  // a byte: the ASCII code of a comma, 44
    printf("%v %v %v %v %v %v %v %v %v\n", a, b, c, d, ns, mask, e, f, comma);
}
5 200 7 201 1000000000 65280 2.5 0.5 44

The rule, in order: a suffix such as 7: u64 wins; otherwise the expected type at that position, which may be a declared variable type, a parameter type, a field type, or the type of the other operand of the same operator; otherwise the default, i32 for an integer and f64 for a float. A literal that does not fit the type it is given, such as 256 where a u8 is expected, is an error before the program runs.

So the one flexible thing in the type system is a bare literal, and it is flexible only once, at the place it is written. takes_i64(5) is fine; with i32 x = 5; beforehand, takes_i64(x) is not, because x is an i32 for good.

Arithmetic is checked

In C, a + b on integers that overflow wraps round or, for signed types, is undefined. In CobaltC an overflow is a fault: the program stops, as you saw in chapter 2. The same holds for - and *, for division by zero, and for the one division that overflows, the most negative value divided by -1. When both operands are literals the tools catch it before the run; otherwise the check happens as the operation runs.

When you want another behaviour, you ask for it by name:

import std;

fn main()
{
    u8 level = 250;
    printf("%v\n", wrapping_add(level, 10));      // 4: wraps round, on purpose
    printf("%v\n", saturating_add(level, 10));    // 255: stops at the top

    match (checked_add(level, 10))                // None: the sum does not fit
    {
        Some(v) : printf("sum %v\n", v),
        None    : printf("too big for a u8\n"),
    }

    i32 total = 0;
    foreach (i in 1..101: i32)
    {
        total += i;                               // checked: a fault if it ever overflowed
    }
    printf("%v\n", total);
}
4
255
too big for a u8
5050

wrapping_add, wrapping_sub and wrapping_mul wrap round. The saturating_ family stops at the type’s limits. The checked_ family, which also has checked_div, checked_rem and checked_neg, returns an Option: Some(result) when the operation fits and None when it does not, so the caller decides. Chapter 8 explains Option and match; for now, read the match as “if there was a sum, print it, otherwise say so”.

The choice is deliberate. A hash function wants wrapping; a volume control wants saturation; a parser wants to report a number that is too large; and ordinary code, where overflow is a bug, wants to be stopped. Each of these is written differently, so the reader can tell them apart.

Floating-point arithmetic never faults. Division by zero gives an infinity, and the undefined cases give NaN, as IEEE 754 says; NaN compares unequal to everything, itself included.

Shifts, the bitwise operators & | ^ ~, and the comparisons work as in C, with two small exceptions. A shift by the type’s width or more is a fault, not a silent zero. And the bitwise operators bind tighter than comparison, so x & 1 == 0 means (x & 1) == 0, which is what the author almost always intended; in C it means the opposite.

Conversions are explicit

Since nothing converts on its own, there are named functions for each kind of conversion, and each says what it does when the value does not fit:

import std;

fn main()
{
    u8 small = 200;
    i32 wide = widen<i32>(small);                 // always fits: no check needed
    i64 wider = widen<i64>(wide) * 100_000_000;
    printf("%v %v\n", wide, wider);

    usize count = 12;
    i32 n = narrow<i32>(count);                   // checked: faults if it did not fit
    printf("%v\n", n);

    f64 half = to_float<f64>(n) / 2.0;
    i32 back = to_int<i32>(half);                 // toward zero: 6
    printf("%v %v\n", half, back);

    u32 bits = reinterpret<u32>(-1: i32);         // the same bit pattern, read as unsigned
    printf("%v\n", bits);

    u8 byte = narrow<u8>(wider);                  // ✗ 20000000000 does not fit a u8
    printf("%v\n", byte);
}
200 20000000000
12
6.0 6
4294967295
fault[diag.narrowing-overflow]: the value does not fit the narrower type
  --> conversions.cb:21:5
   |
21 |     u8 byte = narrow<u8>(wider);                  // ✗ 20000000000 does not fit a u8
   |     ^^
   = repair: `narrow_wrapping`, or widen the target

The target type is written in angle brackets. widen is accepted only where every value of the source fits the target, so it can never lose anything and never checks; narrow works between any two integer types and faults when the value does not fit; narrow_wrapping keeps the low bits and never faults; checked_narrow returns an Option. to_float and to_int go between integers and floats, to_int truncating toward zero and faulting on an infinity, a NaN or a value out of range. reinterpret keeps the bits and changes their reading, between the signed and unsigned type of one width or between a float and the integer of its width.

Two habits make this painless. Reach for widen first, since a conversion that cannot fail is the common case. And for lengths and indices use usize throughout, so that nothing needs converting until a value meets the outside world.

Constants

A const names a value known before the program runs:

import std;

const u32 WIDTH = 80;
const u32 HEIGHT = 24;
const u32 CELLS = WIDTH * HEIGHT;                 // computed before the program runs

fn main()
{
    static_assert(CELLS == 1920, "the screen has 1920 cells");
    static_assert(sizeof<u32>() == 4);

    u32 used = CELLS / 3;                         // a copy of the value: an ordinary variable
    used += 1;
    printf("%v of %v cells\n", used, CELLS);
}
641 of 1920 cells

A constant’s initialiser may use literals, other constants, operators and a few built-in functions such as sizeof. It is computed when the program is compiled, and an overflow in computing it is an error at the constant. static_assert checks a condition at the same moment, so a program with a false one never runs at all; its condition must be constant too. Both can also be written inside a function, where the constant’s name is scoped like a local’s.

This is const as in Rust or constexpr in C++: a value fixed before the run, not a variable set once. A value that only becomes known while the program runs is an ordinary variable.

Where this leaves you

Declare with the type first, or auto when the initialiser says enough. Expect no conversions and write the one you mean. Expect arithmetic to stop the program rather than wrap, and name the policy when you want another. Use usize for sizes. With that, the next chapter can turn to functions and control flow.

Functions and control flow

CobaltC’s control flow is C’s, trimmed and tidied: if, while, for, and a foreach for walking collections; no switch, no goto, no do. What is new is that blocks, if and match are expressions with values, which changes how you write a lot of ordinary code, and that the end of a block is a precise moment at which things happen.

Functions

import std;

// Parameters are type-first; the return type follows a colon.
fn area(i32 width, i32 height) : i32
{
    width * height                    // the last expression is the result
}

// No return type: the function returns nothing. `return;` leaves early.
fn report(i32 w, i32 h)
{
    if (w <= 0 || h <= 0)
    {
        printf("no area\n");
        return;
    }
    printf("area %v\n", area(w, h));
}

fn factorial(u64 n) : u64
{
    if (n <= 1)
    {
        1
    }
    else
    {
        n * factorial(n - 1)
    }
}

fn main()
{
    report(3, 4);
    report(3, 0);
    printf("%v\n", factorial(20));
}
area 12
no area
2432902008176640000

A function’s body is a block, and a block’s value is its last expression, written without a semicolon. That is how area returns a value with no return, and how factorial returns whichever branch of its if runs. return e still exists, and is the way to leave early; a function whose natural shape is “check something, then do the work” reads best with an early return for the check and a trailing expression for the work.

Functions may be declared in any order and may call themselves. They cannot be nested: a function inside a function is written as a closure, which chapter 11 covers. A function that belongs with a type is written fn Type::name(...) and called as Type::name(...); chapter 8 shows that.

Blocks and if are expressions

import std;

fn main()
{
    i32 n = 7;
    str parity = if (n % 2 == 0) { "even" } else { "odd" };     // if is an expression

    i32 squared =                     // a block is an expression: its last line is its value
    {
        i32 t = n * n;
        t
    };

    i32 clamped = if (n < 0)
    {
        0
    }
    else if (n > 5)
    {
        5
    }
    else
    {
        n
    };

    printf("%v is %v; squared %v; clamped %v\n", n, parity, squared, clamped);
}
7 is odd; squared 49; clamped 5

An if used as a value must have an else, and both branches must have the same type. An if without else is a statement, and so is any if whose branches end in semicolons. Notice the ; after the closing brace when the if or block is the right-hand side of a declaration: the declaration is a statement, and the statement ends there. An if standing on its own needs no semicolon after its brace.

A condition is always in parentheses, and a branch is always in braces, even when it is one line. There is no dangling-else ambiguity and no way to add a second statement to a branch by mistake.

Loops

import std;

fn main()
{
    // while: the condition in parentheses, the body in braces, always.
    u32 n = 27;
    u32 steps = 0;
    while (n != 1)
    {
        n = if (n % 2 == 0) { n / 2 } else { 3 * n + 1 };
        steps += 1;
    }
    printf("27 reaches 1 in %v steps\n", steps);

    // for: C's counted loop.
    for (i32 i = 10; i > 0; i -= 3)
    {
        printf("%v ", i);
    }
    printf("\n");

    // foreach over a range: from the first number up to, not including, the second.
    i32 sum = 0;
    foreach (k in 1..11: i32)
    {
        if (k == 4)
        {
            continue;                 // skip 4
        }
        if (k == 8)
        {
            break;                    // stop before 8
        }
        sum += k;
    }
    printf("sum %v\n", sum);

    // foreach over an array, with the position.
    auto names = ["ada", "lin", "kay"];
    foreach (i, name in names)
    {
        printf("%v: %v\n", i, name);
    }
}
27 reaches 1 in 111 steps
10 7 4 1 
sum 24
0: ada
1: lin
2: kay

while and for are C’s. for (;;) loops until a break; so does while (true), and the language knows that a while (true) with no break never finishes, so a function may end with one and need no value after it.

foreach walks a collection or a range. A range lo..hi counts from lo up to but not including hi, so 0..n runs n times. Its numbers take the bounds’ type, and two bare literals are usize, which is why 1..11: i32 above gives the bound a type: the sum is an i32. A second name before the element gives its position, counted from zero. There is no ++; write += 1.

Chapter 7 says how foreach borrows the collection it walks, and chapter 10 shows it over vectors and maps.

Scopes

A block opens a scope. Everything declared in it is gone when it ends:

import std;

fn main()
{
    i32 x = 1;
    {
        i32 y = x + 1;                // y lives until this block ends
        i32 x = 100;                  // an inner x hides the outer one here
        printf("inner %v %v\n", x, y);
    }
    printf("outer %v\n", x);          // the outer x, untouched
    printf("%v\n", y);                // ✗ y's block has ended
}
error[diag.unbound-name]: nothing named `y` is declared here
  --> scopes.cb:12:20
   |
12 |     printf("%v\n", y);                // ✗ y's block has ended
   |                    ^
   = repair: declare it, fix the spelling, or add an `import` (`import std;` for the standard library, `CHG-0033`)

So far this is only a rule about names. In the next chapter it becomes a rule about things: when a block ends, every value it owns is destroyed, in the reverse of the order it was made, and that moment is where files are closed and memory is freed. A block is therefore a tool, not just a grouping. You will open one to bound how long something lives, and close it to let the thing go.

Evaluation order

Everything is evaluated left to right: the left operand before the right, a function before its arguments, arguments in the order written. && and || stop as soon as the answer is known. The order is part of the language, so a program may rely on it, though readers will thank you for not making them work it out.

The operators and their precedence are listed in appendix A. The only one that differs from C’s habits is the one mentioned in chapter 4: the bitwise operators bind tighter than comparisons.

Ownership

Some values are just data. An integer, a bool, a struct of two integers: copy it and you have two of it, and nothing in the world has changed. Other values stand for something that must be given back exactly once. A block of heap memory, an open file, a lock that is held, a thread that is running: copy one of these and you have two names for one thing, and whichever name releases it first leaves the other dangling.

CobaltC draws this line in the type system. Values of the second kind are called resources, and the language keeps three promises about them. A resource has exactly one owner. It is never copied; it moves. And it is destroyed exactly once, at a moment you can point to in the source, whether or not you remembered to do anything. This chapter is about those three promises. They are the reason there is no garbage collector, no leak, and no use-after-free.

Plain types and resource types

A type is a resource if it is declared with the word resource, or if any part of it is a resource. Everything else is plain.

import std;

struct Point                          // plain: two numbers
{
    i32 x;
    i32 y;
}

struct Path                           // a resource: it contains a Vec
{
    Vec<Point> points;
}

fn main()
{
    Point a = Point { .x = 1, .y = 2 };
    Point b = a;                      // a copy: a and b are two points
    b.x = 10;
    printf("a.x %v, b.x %v\n", a.x, b.x);

    Path p = Path { .points = Vec::new() };
    Vec::push(&mut p.points, a);
    Path q = p;                       // a move: q owns the points now, p is stale
    printf("%v point(s)\n", Vec::len(&q.points));
}
a.x 1, b.x 10
1 point(s)

Vec<T> owns a buffer on the heap, so it is a resource, and Path is a resource because it contains one; nobody had to say so. String, HashMap, a file, a thread handle and a mutex are resources too. Numbers, bool, str, references and plain structs are not.

The same assignment, b = a, means two different things for the two kinds. For the plain Point it copies. For the resource Path it moves: q becomes the owner, and p is left stale, a name with nothing behind it. Nothing was copied, because a copy would have made two owners.

A move leaves the old name stale

Every way of putting a resource somewhere moves it: initialising a variable from it, passing it to a function by value, storing it in a field, returning it. After the move the old name cannot be used:

import std;

fn total(Vec<i32> v) : i32            // takes the vector: it is destroyed when total ends
{
    i32 t = 0;
    foreach (x in v)
    {
        t += x;
    }
    t
}

fn main()
{
    Vec<i32> nums = Vec::new();
    Vec::push(&mut nums, 4);
    Vec::push(&mut nums, 5);
    printf("%v\n", total(nums));      // nums moves into the call
    printf("%v\n", Vec::len(&nums));  // ✗ nums no longer owns anything
}
error[diag.stale-binding]: `nums` was moved or dropped before this use, and holds no value here; give it a new value first (`nums = …;`)
  --> use_after_move.cb:19:30
   |
19 |     printf("%v\n", Vec::len(&nums));  // ✗ nums no longer owns anything
   |                              ^^^^
   = repair: use the current holder after a move, or give the binding a new value first (`x = e`, `spec/11` `[Assign-Reestablish]`); do not keep a reference past its referent's or its holder's scope; re-index after a `Vec` reallocation

The function total took the vector by value, so the call handed it over, and total destroyed it when it finished. That was probably not what the author wanted. If a function only needs to look at a value, it should take a reference to it, ref<Vec<i32>, shared>, and the caller passes &nums; chapter 7 is about exactly this. Taking a resource by value is the right choice when the function is going to keep it: push it into a container, hand it to a thread, store it in a struct it returns.

A name that was moved on only some paths, in one branch of an if say, counts as moved. The rule is checked before the program runs wherever the move is visible in the same function, and at run time in the few cases where it is not. Either way, there are never two owners.

Because a resource cannot be copied, it cannot appear where a copy would be needed. Two vectors cannot be compared with ==, since comparing would copy them both; chapter 10 has slice_eq for that. A resource field cannot be read out of a struct that lives on, since that would leave the struct half-owned; you move the whole struct, or take a reference to the field.

Destruction is automatic and ordered

A resource is destroyed when its owner’s scope ends. Locals are destroyed in the reverse of the order they were declared, a struct’s fields after the struct itself, and a temporary that nothing stored at the end of its statement. This program watches it happen:

import std;

resource struct Tracer
{
    str name;
}

fn Tracer::drop(ref<Tracer, exclusive> self)
{
    printf("  drop %v\n", self.name);
}

fn make(str name) : Tracer
{
    Tracer { .name = name }           // moved out to the caller, not destroyed here
}

fn consume(Tracer t)
{
    printf("  consume has %v\n", t.name);
}                                     // t is destroyed here, by its new owner

fn main()
{
    printf("a block:\n");
    {
        Tracer a = make("a");
        Tracer b = make("b");
        Tracer c = make("c");
    }                                 // c, then b, then a

    printf("an early drop:\n");
    {
        Tracer x = make("x");
        Tracer y = make("y");
        drop(x);                      // now, not at the block's end
        printf("  end of block\n");
    }

    printf("a move:\n");
    {
        Tracer m = make("m");
        consume(m);                   // m's destructor runs inside consume, once
        printf("  back in main\n");
    }

    printf("a temporary:\n");
    make("t");                        // not stored: destroyed at the end of this statement
    printf("done\n");
}
a block:
  drop c
  drop b
  drop a
an early drop:
  drop x
  end of block
  drop y
a move:
  consume has m
  drop m
  back in main
a temporary:
  drop t
done

Tracer is declared resource even though it holds only a str, because it has a destructor: a function named drop, taking an exclusive reference to the value, which the language calls exactly once when the value is destroyed. You never call it yourself. A destructor is where a type closes its file or releases its lock; Vec and String have one that frees their memory. A plain type cannot have a destructor, since a plain value is copied freely and is never destroyed.

drop(x) destroys a resource early. Afterwards x is stale until you give it a new value, which it can take with an ordinary assignment, x = make("z").

A return, a break or a fault leaves scopes the same way as reaching the closing brace, destroying what each scope owns on the way out. That is why a fault, which stops the program, still closes every file the program had open, and why you will never write cleanup code for an early exit.

Overwriting a resource

If c owns a connection and you assign c = another, what happens to the first connection? For a type whose destruction only frees memory, a String or a Vec, the answer is simple: the old value is destroyed and the new one stored. The language calls such types quiet, and lets them be overwritten freely. For a resource whose destruction does something you would want to see, closing a connection, printing a line, the assignment is rejected, and you must say what you mean:

import std;

resource struct Connection
{
    u32 id;
}

fn Connection::drop(ref<Connection, exclusive> self)
{
    printf("closing %v\n", self.id);
}

fn main()
{
    String s = "first";
    s = String::from_str("second");   // fine: a String only frees memory, so it is replaced quietly
    printf("%v\n", &s);

    Connection c = Connection { .id = 1 };
    overwrite(&mut c, Connection { .id = 2 });   // closes 1, then stores 2
    printf("now %v\n", c.id);
}                                     // closes 2
second
closing 1
now 2
closing 2

overwrite(&mut c, v) destroys the old value where you wrote it and stores the new one. replace(&mut c, v) stores the new one and gives you the old one back. Or drop(c) first and assign afterwards. The plain assignment is refused:

import std;

resource struct Connection
{
    u32 id;
}

fn Connection::drop(ref<Connection, exclusive> self)
{
    printf("closing %v\n", self.id);
}

fn main()
{
    Connection c = Connection { .id = 1 };
    c = Connection { .id = 2 };       // ✗ 1 would be closed without a trace
}
error[diag.overwrite-of-live-resource]: `c` still holds a `Connection`, which this assignment would lose
  --> overwrite_rejected.cb:16:5
   |
16 |     c = Connection { .id = 2 };       // ✗ 1 would be closed without a trace
   |     ^
   = repair: `overwrite(&mut place, v)` to put `v` there and destroy the old value (D-0080), `replace(&mut place, v)` to keep the old value, or `drop` the old value first

The shape of the rules

Three promises, then: one owner, moves not copies, destruction once and in order. They cost you two habits. Decide, for every function, whether it keeps what it is given or only looks at it, and write the parameter accordingly. And put a resource in the smallest block that needs it, so that the block’s end releases it.

What they give you is the absence of a whole class of bugs. A resource cannot leak, because every one has an owner whose scope ends. It cannot be freed twice, because it has one owner and one destruction. It cannot be used after it is freed, because the name that owned it is stale the moment it goes. None of this needs a garbage collector, and all of it happens at a moment you can read off the page.

The one thing missing is a way to look at a resource without taking it. That is the next chapter.

Borrowing

The last chapter left a gap. A resource has one owner, and giving it to a function moves it, so how does a function look at a vector without taking it? The answer is a reference: a way to reach a value that someone else owns. References are the mechanism behind most of what makes CobaltC safe, and they are also the part of the language that takes longest to feel natural, so this chapter goes slowly.

The idea underneath is one rule, and it is worth stating before any syntax. At any moment, a value may have any number of readers, or one writer, but never both. Everything else in the chapter is the working-out of that sentence.

Two kinds of reference

import std;

fn length(ref<String, shared> s) : usize     // looks: may read, cannot change
{
    String::len(s)
}

fn shout(ref<String, exclusive> s)           // changes: may read and write
{
    String::push_ascii(s, b'!');
}

fn main()
{
    String greeting = "hello";
    printf("%v\n", length(&greeting));       // lend it for reading
    shout(&mut greeting);                    // lend it for writing
    shout(&mut greeting);
    printf("%v, %v bytes\n", &greeting, length(&greeting));
}
5
hello!!, 7 bytes

&x makes a shared reference to x, of type ref<T, shared>; through it you may read. &mut x makes an exclusive reference, ref<T, exclusive>; through it you may read and write. The word for making one is borrowing: the owner lends the value, and the reference is the loan. The owner keeps owning the value throughout; a reference never destroys what it points at.

The mode is part of the reference’s type, so a function’s signature says exactly what it may do. length takes ref<String, shared>, and the caller knows the string will come back unchanged. shout takes ref<String, exclusive>, and the caller knows it may not. There is no way for length to cheat: writing through a shared reference is rejected before the program runs.

A reference is used with * to reach the value, *r; for a field or an element, r.field and r[i] work directly, as they would on the value itself. References are plain values, copied freely and never destroyed; the thing they point at is what has an owner.

The one rule

import std;

fn main()
{
    i32 x = 1;
    ref<i32, shared> a = &x;
    ref<i32, shared> b = &x;          // any number of readers may exist at once
    printf("%v %v %v\n", x, *a, *b);  // and the owner may still read

    ref<i32, exclusive> w = &mut x;   // an exclusive reference: alone
    *w += 10;
    printf("%v\n", *w);
    printf("%v\n", x);                // w's last use is above, so x is free again
}
1 1 1
11
11

Any number of shared references may coexist, and while they do, the owner may read as well. An exclusive reference must be the only way to reach the value while it lives: no other reference, and no use of the owner’s own name. Here is what happens when that is broken:

import std;

fn main()
{
    i32 x = 1;
    ref<i32, shared> r = &x;
    x = 2;                            // ✗ r still reads x
    printf("%v\n", *r);
}
error[diag.aliasing-conflict]: `r` still borrows `x`, so it cannot be written or borrowed `mut` here; end that borrow first
 --> conflict.cb:7:5
  |
7 |     x = 2;                            // ✗ r still reads x
  |     ^
  = repair: end the conflicting reference (let its holder go out of scope) before this access, or use `shared` on both sides

The write to x conflicts with r, a live reader. The program is rejected before it runs. The same diagnostic covers every case: writing while a reader lives, making a second reference while an exclusive one lives, using the owner’s name while an exclusive reference lives.

Why is a rule this strict worth having? Because the thing it forbids is the root of most memory bugs. Here is the same conflict in the form it usually takes:

import std;

fn main()
{
    Vec<i32> v = Vec::new();
    Vec::push(&mut v, 10);
    ref<i32, shared> first = &v[0];   // points into v's buffer
    Vec::push(&mut v, 20);            // ✗ the push may move the buffer
    printf("%v\n", *first);
}
error[diag.aliasing-conflict]: `first` still borrows `v`, so it cannot be written or borrowed `mut` here; end that borrow first
 --> vec_push_conflict.cb:8:15
  |
8 |     Vec::push(&mut v, 20);            // ✗ the push may move the buffer
  |               ^
  = repair: end the conflicting reference (let its holder go out of scope) before this access, or use `shared` on both sides

first points into the vector’s buffer. The push may have to grow the buffer, which means allocating a new one and freeing the old, after which first would point at freed memory. In C this is a bug that may not show for weeks. Here it cannot be written. The rule does not know about buffers or reallocation; it knows only that Vec::push takes &mut v while first still borrows v, and that is enough.

When a borrow ends

A reference lives as long as something holds it. That makes “end that borrow first” a matter of where the reference is used, and the language is generous about it:

import std;

fn main()
{
    Vec<i32> v = Vec::new();
    Vec::push(&mut v, 10);            // this borrow ends at the semicolon
    Vec::push(&mut v, 20);            // so this one is fine

    {
        ref<i32, exclusive> last = &mut v[1];
        *last = 25;
    }                                 // last ends with its block

    ref<i32, shared> first = &v[0];
    printf("%v\n", *first);           // first's last use: it ends here
    Vec::push(&mut v, 30);            // so v is free again
    printf("%v %v %v\n", v[0], v[1], v[2]);
}
10
10 25 30

Three ways a borrow ends. A reference passed straight to a call, &mut v in Vec::push(&mut v, 10), lives for that statement and is gone at the semicolon; consecutive calls that each borrow the same variable are therefore always fine. A reference kept in a variable ends when that variable’s block ends. And a reference kept in a variable ends early, after the last statement that uses it, so first above is gone before the push that follows its printf. A use inside a loop counts as a use by the whole loop, so a reference used in a loop body lives through the loop.

When the checker complains, then, the fix is one of two things: move the conflicting access below the reference’s last use, or put the reference in a block of its own so that its end is visible. Both make the program clearer.

Borrows are precise

A conflict is about overlap of storage, not about which variable a reference came from. Two exclusive references to different fields of one struct do not overlap and may live at once:

import std;

struct Pair
{
    i32 left;
    i32 right;
}

fn main()
{
    Pair p = Pair { .left = 1, .right = 2 };
    ref<i32, exclusive> l = &mut p.left;
    ref<i32, exclusive> r = &mut p.right;    // a different field: no overlap
    *l += 10;
    *r += 20;
    printf("%v %v\n", p.left, p.right);
}
11 22

The same holds for two slices of different parts of one vector, which chapter 13 uses to let two threads fill one vector at once.

What the checker sees, and what the run checks

So far every conflict was found before the program ran. That holds across calls too. The checker knows what each function does through each reference parameter (whether it reads or writes, and which fields), so a call that hands a function two references that meet is rejected where it is written:

import std;

fn set_and_read(ref<i32, exclusive> a, ref<i32, shared> b) : i32
{
    *a = 1;
    *b
}

fn main()
{
    i32 x = 0;
    i32 y = 0;
    printf("%v\n", set_and_read(&mut x, &y));    // fine: two different variables
    printf("%v\n", set_and_read(&mut x, &x));    // ✗ rejected: both reach x
}
error[diag.aliasing-conflict]: `set_and_read` writes `x` through argument 1, which argument 2 also reaches
  --> call_conflict.cb:14:20
   |
14 |     printf("%v\n", set_and_read(&mut x, &x));    // ✗ rejected: both reach x
   |                    ^^^^^^^^^^^^
   = repair: end the conflicting reference (let its holder go out of scope) before this access, or use `shared` on both sides

set_and_read writes through its first parameter while its second is in use, and both arguments reach x. The program is rejected, and the first call never runs either.

Only the values can decide some questions. Two elements of one array, chosen by indexes the program computes, are the same element only when the indexes are equal:

import std;

fn set_and_read(ref<i32, exclusive> a, ref<i32, shared> b) : i32
{
    *a = 1;
    *b
}

fn main()
{
    array<i32, 3> v = [0, 0, 0];
    usize i = 0;
    while (i < 3)
    {
        usize j = 2 - i;
        printf("%v\n", set_and_read(&mut v[i], &v[j]));    // ✗ at run time when i == j
        i = i + 1;
    }
}
0
fault[diag.aliasing-conflict]: `set_and_read` may write through argument 1, and a live reference reaches the same place: the call is checked when it is made (D-0283)
  --> index_conflict.cb:16:9
   |
16 |         printf("%v\n", set_and_read(&mut v[i], &v[j]));    // ✗ at run time when i == j
   |         ^^^^^^
   = repair: end the conflicting reference (let its holder go out of scope) before this access, or use `shared` on both sides

The first call passes elements 0 and 2, which are different. The second passes element 1 twice: set_and_read may write through a, and b reaches the same element, so the call faults before its body runs. The rule is the same one as before; only the moment it is applied differs. The check is made at the call, from what the function may do with its arguments, so it does not depend on which way the body would have gone on this run.

This is the pattern for the whole language. Every rule is checked. Where the text of the program decides the question, it is decided before the run; where only the values can decide it, such as two indexes or a reference kept inside a data structure, it is decided during the run. A conforming compiler may prove more and leave out checks it has proved unnecessary, which is what cobc does, but it may never accept a program the language rejects or run a check the language does not require.

When nothing is left to the run, because every question of a program is decided by its text, the program is decided, and it keeps no record of its references while it runs: there is nothing such a record could find. cobc builds every decided program that way, and cobc -v says whether it did or names the first line that left a question to the run (D-0284).

Returning a reference

A function may return a reference when it has exactly one reference parameter. The result is understood to point into whatever that parameter pointed into, and the caller is checked accordingly:

import std;

struct Pair
{
    i32 left;
    i32 right;
}

// One reference parameter: the result is understood to point into it.
fn larger(ref<Pair, shared> p) : ref<i32, shared>
{
    if (p.left > p.right) { &p.left } else { &p.right }
}

fn main()
{
    Pair p = Pair { .left = 3, .right = 7 };
    ref<i32, shared> big = larger(&p);
    printf("%v\n", *big);
}
7

After that call, big counts as a borrow of p, exactly as if main had written &p.right itself, and a write to p while big is in use is a conflict. This is the shape of every accessor in the standard library, and it needs no annotation because there is only one parameter the result could come from. A function with two reference parameters may not return a reference at all; the language does not guess, and CobaltC has no lifetime annotations with which to tell it. Return an owned value, or split the function.

A reference to a local cannot leave the function that owns the local:

import std;

fn dangling() : ref<i32, shared>
{
    i32 local = 5;
    &local                            // ✗ local ends when this call returns
}

fn main()
{
    printf("%v\n", *dangling());
}
error[diag.reference-escapes-scope]: the result refers to `local`, which ends when this function returns; a returned reference must come from a reference parameter -- return an owned value instead
 --> escape.cb:6:5
  |
6 |     &local                            // ✗ local ends when this call returns
  |     ^
  = repair: return an owned value, or restructure

The same rule stops a reference from being stored into a variable of an enclosing block. Where an escape is not visible in the text, because the reference was stored through another reference, say, the run-time check takes over: the first use of a reference whose target has ended is a fault. One way or another, a reference never outlives what it points at.

Reading through a shared reference is read-only all the way down

A shared reference gives read access to everything it reaches. If the struct it points at holds an exclusive reference to something else, that reference comes out shared when read through the shared one. There is no path from a shared reference to a write, however many steps it takes. The one exception is a mutex, whose purpose is exactly to allow a change through a shared handle, under a lock; chapter 13 has it.

Choosing a parameter type

Every function parameter is one of four things, and the choice is the function’s contract with its caller:

ParameterThe caller writesThe function mayAfterwards
plain Tf(x)use its own copythe caller’s x is unchanged
resource Tf(v)own it: keep it, or let it be destroyedthe caller’s v is stale
ref<T, shared>f(&x)read through itx is unchanged
ref<T, exclusive>f(&mut x)read and write through itx may have changed

Choose the one that promises the least. A function that only reads takes a shared reference. One that changes a value in place takes an exclusive reference. One that keeps the value, pushing it into a container or handing it to a thread, takes it by value. That decision, made once per function, is most of what there is to borrowing in practice; the checker does the rest.

Structs, enums and match

CobaltC has two ways to build a type from others. A struct holds several values at once, each under a name. An enum holds one of several alternatives, each of which may carry a value. Together they describe almost any data a program has, and match, which takes an enum apart, is the language’s way of making sure every alternative is handled.

Structs

import std;

struct Point
{
    f64 x;
    f64 y;
}

struct Segment
{
    Point from;                       // structs nest by value
    Point to;
}

// A function that belongs with the type: called as Segment::length(...).
fn Segment::length(ref<Segment, shared> s) : f64
{
    f64 dx = s.to.x - s.from.x;
    f64 dy = s.to.y - s.from.y;
    sqrt(dx * dx + dy * dy)
}

fn Point::origin() : Point
{
    Point { .x = 0.0, .y = 0.0 }
}

fn main()
{
    Point a = Point { .x = 3.0, .y = 4.0 };     // every field, once, in any order
    Segment s = Segment { .from = Point::origin(), .to = a };
    s.to.x = 6.0;                                 // nested fields are places
    printf("%v\n", Segment::length(&s));
    printf("%v\n", a == Point { .y = 4.0, .x = 3.0 });   // plain structs compare field by field
}
7.211102550927978
true

A struct literal names every field, with a dot before each name, in any order. There is no way to build a struct with some fields missing: an aggregate is initialised whole, and after that its fields are ordinary places you can assign to. A struct of plain fields is plain, copied on assignment and comparable with ==; a struct with a resource inside is a resource, as chapter 6 said.

A function written fn Type::name(...) is an associated function of the type. It is an ordinary function with a qualified name, called as Segment::length(&s), and nothing about it is special except where it lives. There is no this, no implicit receiver, and no method-call syntax: s.length() does not exist. A function that reads the struct takes ref<Segment, shared> s; one that changes it takes an exclusive reference; one that builds it, like Point::origin, takes whatever it needs and returns the struct.

A struct’s fields are private to the module that declares it unless marked export, which chapter 12 covers. Inside one file, everything can see everything.

Enums

import std;

enum Shape
{
    Circle(f64),                      // a payload: the radius
    Square(f64),                      // the side
    Dot,                              // no payload
}

fn area(Shape s) : f64
{
    match (s)
    {
        Circle(r) : 3.14159 * r * r,  // the payload is bound to r for this arm
        Square(w) : w * w,
        Dot       : 0.0,
    }
}

fn describe(Shape s) : str
{
    match (s)
    {
        Circle(_) : "round",          // _ ignores the payload
        _         : "not round",      // a catch-all arm
    }
}

fn main()
{
    array<Shape, 3> shapes = [Circle(1.0), Square(2.0), Dot];
    foreach (s in shapes)
    {
        printf("%v: %v, area %v\n", s, describe(s), area(s));
    }
}
Circle: round, area 3.14159
Square: not round, area 4.0
Dot: not round, area 0.0

An enum lists its variants. A variant may carry one value, its payload, of any type; Circle(f64) carries a radius, Dot carries nothing. A value of the enum is exactly one variant with its payload, built by naming the variant: Circle(1.0), Dot. Variant names can be used bare, as here, or qualified as Shape::Dot when two enums in scope share a name. Printed with %v, an enum shows its variant’s name.

This is C’s enum and union folded into one safe type, and it is the way to say “one of these” in CobaltC: a token in a parser, a message from a thread, a cell that is empty or full, a result that is a value or an error.

match

match is how an enum is taken apart. It compares the value with each arm’s pattern in order and runs the first that fits. Every arm ends with a comma; an arm’s body is an expression, or a block when it needs statements. Because match is an expression, area above simply is its match.

A match must handle every variant, or end with the catch-all _:

import std;

enum Light
{
    Red,
    Amber,
    Green,
}

fn go(Light l) : bool
{
    match (l)                         // ✗ Amber is not handled
    {
        Red   : false,
        Green : true,
    }
}

fn main()
{
    printf("%v\n", go(Green));
}
error[diag.non-exhaustive-match]: not covered: `Amber`
  --> non_exhaustive.cb:12:5
   |
12 |     match (l)                         // ✗ Amber is not handled
   |     ^^^^^
   = repair: add an arm for the pattern named, or a `_` arm

This check is the reason to prefer an enum over a set of integer codes. Add a variant to the enum, and every match that fails to handle it stops compiling until you decide what it should do there. Use _ sparingly for the same reason: a catch-all arm silently absorbs the variants you add later.

Option: a value or nothing

The standard library’s most used enum has two variants, Some(value) and None. It is how CobaltC says “maybe”: there is no null, so a function that may have nothing to return returns an Option.

import std;

// The position of the first negative number, if there is one.
fn first_negative(ref<Vec<i32>, shared> v) : Option<usize>
{
    foreach (i, x in v)
    {
        if (*x < 0)
        {
            return Some(i);
        }
    }
    None
}

fn main()
{
    Vec<i32> v = Vec::new();
    Vec::push(&mut v, 4);
    Vec::push(&mut v, -2);
    Vec::push(&mut v, 7);

    match (first_negative(&v))
    {
        Some(i) : printf("first negative at %v\n", i),
        None    : printf("none negative\n"),
    }

    if (Some(i) = first_negative(&v))         // a pattern as a condition
    {
        printf("found one: v[%v] is %v\n", i, v[i]);
    }

    Vec<i32> empty = Vec::new();
    printf("%v\n", Option::unwrap_or(first_negative(&empty), 999));   // a default instead
}
first negative at 1
found one: v[1] is -2
999

The if with a pattern, if (Some(i) = e), is a match with one interesting arm: when the pattern fits, the block runs with i bound, and otherwise it is skipped (or an else runs). The same works in a while, as chapter 3’s input loop showed. These are the forms to use when only one case needs work; match is the form that makes you handle every case.

Option::unwrap_or(o, d) gives the value inside, or d if there is none. Option::unwrap(o) gives the value and faults if there is none; use it only where None would be a bug. The next chapter covers these and the ? operator together with Result, the enum for errors.

Patterns

A pattern can be a variant with a pattern for its payload, as deep as needed; a literal; a name, which binds; or _, which ignores:

import std;

fn classify(Result<Option<i32>, str> r) : str
{
    match (r)
    {
        Ok(Some(0)) : "zero",                     // a literal inside a pattern
        Ok(Some(n)) : if (n > 0) { "positive" } else { "negative" },
        Ok(None)    : "nothing",
        Err(_)      : "an error",
    }
}

fn opcode(u8 b) : str
{
    match (b)                         // an integer: literal arms and a catch-all
    {
        0x01 : "load",
        0x02 : "store",
        _    : "unknown",
    }
}

fn is_quit(str command) : bool
{
    match (command)                   // text: literal arms too
    {
        "quit" : true,
        "exit" : true,
        _      : false,
    }
}

fn main()
{
    printf("%v %v %v %v\n", classify(Ok(Some(0))), classify(Ok(Some(-5))), classify(Ok(None)), classify(Err("bad")));
    printf("%v %v\n", opcode(2), opcode(9));
    printf("%v %v\n", is_quit("exit"), is_quit("help"));
}
zero negative nothing an error
store unknown
true false

A match on an integer, a byte or text is CobaltC’s switch. Since literals can never list every integer, such a match needs a _ arm; a bool is covered by true and false. An arm that could never run, such as a literal repeated from an earlier arm or a variant already fully covered, is an error, so a match never has dead arms.

There are no range patterns and no a | b alternatives. For a range, match on the test: match (v >= 10 && v < 15) { true : ..., false : ... }. For several values with one action, give each its own arm and put the action in a function. The language keeps patterns to the four forms above, so there is nothing more to learn about them.

Matching a resource, by value or by reference

When an enum’s payload is a resource, an arm that binds the payload moves it out, and the enum is consumed. To look inside without taking it apart, match a reference to the enum:

import std;

enum Message
{
    Text(String),
    Ping,
}

fn length(ref<Message, shared> m) : usize
{
    match (m)                         // through a reference: the payload is borrowed, not moved
    {
        Text(s) : String::len(s),
        Ping    : 0,
    }
}

fn main()
{
    Message m = Text(String::from_str("hello"));
    printf("%v\n", length(&m));
    printf("%v\n", length(&m));       // m is still here

    match (&mut m)                    // an exclusive reference: change the payload in place
    {
        Text(s) : String::push_ascii(s, b'!'),
        Ping    : {},
    }
    match (m)                         // by value: the Text arm takes the String, and m is consumed
    {
        Text(s) : printf("%v\n", &s),
        Ping    : printf("ping\n"),
    }
}
5
5
hello!

Matching &m binds each payload as a shared reference, and matching &mut m binds it as an exclusive one, through which it can be changed. A parameter that is already a reference, like m in length, binds in its own mode without any &. Matching the value itself, in the last match, moves the String into s for the arm’s duration, after which m is stale. These are the same rules as chapter 7’s, applied to a payload: look through a reference, take by value.

Taking a struct apart

A struct can be taken apart into its fields with the same brace syntax, without dots:

import std;

struct Entry
{
    String key;
    i64 value;
}

fn main()
{
    Entry e = Entry { .key = "answer", .value = 42 };
    Entry { key, value } = e;         // take it apart: key and value are now locals, e is consumed
    printf("%v = %v\n", &key, value);
}
answer = 42

Every field is named, or .. stands for the rest, and field : name binds a field under another name. This is how a struct gives up a resource it holds, since a resource field cannot be moved out of a struct that lives on. The same form works inside a match arm, Some(Entry { key, value }), to take apart a struct that is an enum’s payload.

Two more kinds of aggregate

A fixed-size array, array<T, N>, holds exactly N elements, with N known when the program is compiled; [1, 2, 3] is a literal and [0; 256] is 256 zeros. Indexing is bounds-checked. An array of plain elements is plain, and copied on assignment. Chapter 10 covers arrays alongside vectors and slices, which is where they are mostly used.

A bitstruct packs named runs of bits into one unsigned integer, bitstruct Flags : u8 { carry : 1; zero : 1; pad : 6; }, for file formats, instruction encodings and hardware registers. Its fields read and write like any struct’s, with a check that the value fits the field’s width, and Flags::bits(f) is the whole integer. The language guide has the details; most programs never need one.

Errors

There are exactly two ways for something to go wrong in a CobaltC program, and they never mix. A fault is the language catching a broken rule: an overflow, an index out of range, a reference used after its target ended. It is fatal. An error value is your program reporting that something it expected might fail did fail: a file was missing, the input was malformed. It is data, returned to the caller, who decides what to do. There are no exceptions and nothing to catch. This chapter is about both, and about telling them apart.

A fault unwinds, then stops the program

import std;

resource struct Session
{
    u32 id;
}

fn Session::drop(ref<Session, exclusive> self)
{
    printf("session %v closed\n", self.id);
}

fn average(ref<Vec<u32>, shared> v) : u32
{
    u32 sum = 0;
    foreach (x in v)
    {
        sum += *x;
    }
    sum / narrow<u32>(Vec::len(v))    // ✗ divides by zero when v is empty
}

fn main()
{
    Session s = Session { .id = 7 };
    Vec<u32> empty = Vec::new();
    printf("average %v\n", average(&empty));
    printf("not reached\n");
}
session 7 closed
fault[diag.div-by-zero]: division by zero
  --> fault_unwinds.cb:20:5
   |
20 |     sum / narrow<u32>(Vec::len(v))    // ✗ divides by zero when v is empty
   |     ^^^
   = repair: test first, or `checked_div`

When a check fails, the thread unwinds: every open scope is left in order, each destroying what it owns, exactly as if the function had returned. The session’s destructor ran, so it was closed, and the program then stopped and reported the fault. Nothing in the program can intercept this. The point is that a program which reaches its end has never once broken a checked rule, and a program that breaks one leaves nothing half-done behind it: files are closed, locks released, memory freed.

Every fault has a name, diag.div-by-zero here, and the registry entry that --explain prints. Appendix B lists the ones you are likely to meet.

A fault in a thread you started ends only that thread, and its failure is handed to whoever waits for the thread as a value. Chapter 13 covers that; in a single-threaded program, a fault ends the program.

An error is a value: Result and ?

When failure is part of the job, a function says so in its return type. Result<T, E> is an enum with two variants: Ok(value) and Err(error). The error type is yours to choose, and an enum that names each way the function can fail is usually the right one:

import std;

enum ParseError
{
    Empty,
    BadDigit(usize),                  // where the bad character was
    TooLarge,
}

fn parse_number(str s) : Result<u32, ParseError>
{
    if (str_len(s) == 0)
    {
        return Err(Empty);
    }
    u32 value = 0;
    foreach (i in 0..str_len(s))
    {
        u8 c = str_byte(s, i);
        if (!ascii_is_digit(c))
        {
            return Err(BadDigit(i));
        }
        u32 digit = widen<u32>(c - b'0');
        value = Option::ok_or(checked_mul(value, 10), TooLarge)?;   // None becomes Err(TooLarge)
        value = Option::ok_or(checked_add(value, digit), TooLarge)?;
    }
    Ok(value)
}

// Two parses and a sum: either one's failure is this function's failure.
fn add_numbers(str a, str b) : Result<u32, ParseError>
{
    u32 x = parse_number(a)?;         // Err returns from add_numbers right here
    u32 y = parse_number(b)?;
    Option::ok_or(checked_add(x, y), TooLarge)
}

fn report(str a, str b)
{
    match (add_numbers(a, b))
    {
        Ok(v)            : printf("%v + %v = %v\n", a, b, v),
        Err(Empty)       : printf("%v + %v: empty input\n", a, b),
        Err(BadDigit(i)) : printf("%v + %v: not a digit at position %v\n", a, b, i),
        Err(TooLarge)    : printf("%v + %v: too large for a u32\n", a, b),
    }
}

fn main()
{
    report("1200", "34");
    report("12x4", "1");
    report("7", "");
    report("4000000000", "300000000");
}
1200 + 34 = 1234
12x4 + 1: not a digit at position 2
7 + : empty input
4000000000 + 300000000: too large for a u32

The ? operator is what keeps this readable. Written after an expression of type Result, it takes the Ok value out, and if the expression is an Err it returns that Err from the enclosing function at once. So u32 x = parse_number(a)?; reads “the number, or give up with its error”. The enclosing function must return a Result with the same error type, which is why add_numbers and parse_number share ParseError; where two error types meet, Result::map_err converts one into the other at the boundary.

? works on an Option too, inside a function that returns an Option: Some gives the value, None returns None. Nothing converts between the two on its own, so Option::ok_or(o, e) is how an Option becomes a Result, as the checked arithmetic above shows: checked_mul returns None on overflow, ok_or turns that into Err(TooLarge), and ? returns it.

In report, a match handles every case, with nested patterns reaching inside the Err. The caller of a fallible function always sees the whole set of outcomes, and the compiler holds it to handling them.

An error cannot be dropped by accident

A Result that is computed and not used is an error before the program runs:

import std;

fn save(str name) : Result<void, str>
{
    if (str_len(name) == 0)
    {
        return Err("no name");
    }
    Ok(())
}

fn main()
{
    save("");                         // ✗ the error would vanish unseen
}
error[diag.result-discarded]: this `Result` is discarded, so an error it carries would be lost: handle it (`?`, `match`), or discard it on purpose with `_ = …;`
  --> discarded.cb:14:10
   |
14 |     save("");                         // ✗ the error would vanish unseen
   |          ^^
   = repair: handle the error (`?`, `match`), or discard the value on purpose with `_ = e;`

Handle it with ? or match, or say that you mean to ignore it: _ = save("");. A function that returns nothing but may fail returns Result<void, E>, and Ok(()) is its success value; () is the value of type void.

Three more functions round out the set. Result::unwrap_or(r, d) and Option::unwrap_or(o, d) give the value or a default. Result::unwrap(r), Option::unwrap(o), and expect, which takes a message, give the value or fault. Use the last two only where the other case is impossible by construction, because what they do with it is turn an error into a fault.

Assertions

assert(condition) states something the program relies on at that point, and faults if it is false. A message, formatted as printf formats, says which values were wrong:

import std;

fn median(ref<Vec<i32>, shared> sorted) : i32
{
    assert(Vec::len(sorted) > 0, "median of an empty list");
    sorted[Vec::len(sorted) / 2]
}

fn main()
{
    Vec<i32> v = Vec::new();
    Vec::push(&mut v, 1);
    Vec::push(&mut v, 5);
    Vec::push(&mut v, 9);
    printf("%v\n", median(&v));
    usize limit = 2;
    assert(Vec::len(&v) <= limit, "%v values, but the limit is %v", Vec::len(&v), limit);
    printf("not reached\n");
}
5
fault[diag.assert-failed]: 3 values, but the limit is 2
  --> assert.cb:17:5
   |
17 |     assert(Vec::len(&v) <= limit, "%v values, but the limit is %v", Vec::len(&v), limit);
   |     ^^^^^^
   = repair: the program's own condition names what was assumed; find why it did not hold

The condition is always evaluated; there is no build that leaves assertions out. The message and its arguments are evaluated only when the condition is false, so a passing assertion costs its test and nothing more. static_assert, from chapter 4, is the form for a condition the compiler can evaluate, and it rejects the program instead of stopping it.

Which one to use

The two mechanisms answer two different questions, and the choice is made by asking which question you are answering.

Could this fail when the program is correct? Then the failure is part of the function’s job, and it belongs in the return type. Bad input, a missing file, a full queue, a connection refused: all Result. A value that may be absent for a good reason: Option.

Could this fail only if the program has a bug? Then let the fault happen. An index you computed yourself, arithmetic on values you already checked, a state the code’s own logic should make impossible: an assert where the assumption lives, and otherwise the language’s own checks. Wrapping these in a Result would only pass a bug on to a caller who can do nothing about it.

Two conventions follow. Keep one error type across a chain of calls, so ? flows, and convert at the boundaries. And trust the unwinding: a fault runs your destructors, so a function need not clean up before a check that might fail.

Collections and text

Most programs spend their time in a few data structures: a growable list, a run of elements borrowed from one, text, and a table keyed by something. This chapter covers CobaltC’s versions of each, which live in the standard library, and the printf family that writes them out. All of it follows the ownership and borrowing rules of chapters 6 and 7; what is new is only what the types are and what they are called.

Vec<T>: a growable list

import std;

fn main()
{
    Vec<i32> v = Vec::new();          // empty; the element type comes from the declaration
    Vec::push(&mut v, 30);
    Vec::push(&mut v, 10);
    Vec::push(&mut v, 20);
    printf("%v elements, first %v, last %v\n", Vec::len(&v), v[0], v[$ - 1]);

    v[1] += 5;                        // an element is a place: read it, assign to it
    Vec::sort(&mut v);
    foreach (x in &v)                 // borrow it: x is a reference to each element
    {
        printf("%v ", x);
    }
    printf("\n");

    while (Some(x) = Vec::pop(&mut v))          // take the elements out, last first
    {
        printf("popped %v\n", x);
    }
    printf("%v left\n", Vec::len(&v));
}
3 elements, first 30, last 20
15 20 30 
popped 30
popped 20
popped 15
0 left

A Vec<T> owns a buffer on the heap that grows as elements are pushed. It is a resource: it has one owner, moves rather than copies, and frees its buffer and destroys its elements when it goes out of scope. Vec::new() takes its element type from the declaration on the left, which is why auto v = Vec::new(); is rejected: nothing says what T is.

Indexing, v[i], is bounds-checked, and the index is a usize. Inside the brackets, $ stands for the length, so v[$ - 1] is the last element. A function that reads the vector takes ref<Vec<T>, shared> and is passed &v; one that changes it takes an exclusive reference and is passed &mut v. Chapter 7’s rules apply: a reference to an element lives as long as it is used, and push while one lives is a conflict.

Vec::pop returns an Option<T>, None when the vector is empty, so the loop above takes elements out until there are none. For a vector of resources, pop and remove move the element out; nothing is ever copied.

The library has the rest of what a list needs: insert, remove, swap_remove, clear, truncate, reverse, contains, index_of, position with a test function, sort_by with a comparison function, binary_search, clone for a vector of copyable elements, append to join two, filled and from_fn to build one, and reserve to make room in advance. The language guide lists them all with their signatures.

Three ways to walk a collection

foreach holds its collection in one of three ways, written the same way a function takes a value:

import std;

fn main()
{
    Vec<String> names = Vec::new();
    Vec::push(&mut names, "ada");     // a literal where a String is expected becomes one
    Vec::push(&mut names, "lin");

    foreach (n in &mut names)         // exclusive: change each element in place
    {
        String::push_ascii(n, b'!');
    }
    foreach (i, n in &names)          // shared: read each one, with its position
    {
        printf("%v: %v\n", i, n);
    }
    foreach (n in names)              // by value: each String moves out; names is consumed
    {
        printf("took %v\n", &n);
    }                                 // n is destroyed here, each time round
}
0: ada!
1: lin!
took ada!
took lin!

foreach (x in &c) is the usual form: it borrows the collection for reading, gives each element as a shared reference, and leaves the collection as it was. &mut c gives exclusive references, through which elements are changed in place. The bare form, foreach (x in c), consumes the collection, moving each element out; afterwards c is stale. The loop borrows the whole collection for its whole duration, so a body that pushes to the vector it is walking is rejected, and there is no iterator invalidation to debug.

printf prints what a reference refers to, so printf("%v", n) works with n a reference; anywhere else, *n is the value.

Slices: a run of elements

A slice is a borrowed run of consecutive elements of an array or a vector. It is how a function takes “some numbers” without caring whether they came from an array of 5, a vector of 5000, or the middle of either:

import std;

// Works on an array, a Vec, or any part of either.
fn sum(slice<i64, shared> s) : i64
{
    i64 t = 0;
    foreach (x in s)
    {
        t += *x;
    }
    t
}

fn double_all(slice<i64, exclusive> s)
{
    foreach (x in s)
    {
        *x *= 2;
    }
}

fn main()
{
    array<i64, 5> a = [1, 2, 3, 4, 5];
    printf("%v %v %v\n", sum(&a[0..$]), sum(&a[0..2]), sum(&a[$ - 2..$]));   // all, the first two, the last two

    Vec<i64> v = Vec::from_slice(&a[1..4]);     // a Vec holding 2, 3, 4
    double_all(&mut v[0..$]);
    printf("%v %v\n", sum(&v[0..$]), slice_len(&v[0..$]));
}
15 3 9
18 3

&a[i..j] is the elements from i up to, not including, j, as a slice<T, shared>; &mut a[i..j] is the same for writing. &a[0..$] is the whole thing. A slice is a borrow, so while it lives its source cannot be changed except through it, and it cannot outlive the source. Two slices of one vector conflict only if their ranges overlap, which lets a vector be split between threads.

An array<T, N> is a fixed-size run of N elements, with N written in the type. It lives wherever it is declared, on the stack for a local, and an array of plain elements is plain: assigned by copying, passed by copying. For a list whose size is not known in advance, use a Vec.

Statistics over a slice

Because a slice is how a run of numbers is handed to a function, the library’s statistics take one. std::stats has sum, mean, variance and stddev, their sample forms, median, percentile, minimum, maximum and describe, over a slice of any numeric type:

import std;

fn main()
{
    array<u32, 8> ms = [12, 15, 11, 40, 13, 14, 12, 90];      // response times in milliseconds
    printf("%v requests, %v ms in all\n", slice_len(&ms[0..$]), sum(&ms[0..$]));
    printf("mean %.1f, median %.1f, p95 %.1f\n",
           Option::unwrap(mean(&ms[0..$])),
           Option::unwrap(median(&ms[0..$])),
           Option::unwrap(percentile(&ms[0..$], 95.0)));
    match (describe(&ms[0..4]))                               // the first four only
    {
        Some(s) : printf("first four: %v to %v, stddev %.2f\n", s.minimum, s.maximum, s.stddev),
        None    : printf("no data\n"),
    }
    Vec<f64> empty = Vec::new();
    printf("%v\n", Option::is_none(&mean(&empty[0..$])));
}
8 requests, 207 ms in all
mean 25.9, median 13.5, p95 72.5
first four: 11.0 to 40.0, stddev 11.93
true

sum adds in the slice’s own type and is checked like +. The others convert each value to f64 and return an Option<f64>, None for an empty slice, because having no data is ordinary input and not a bug; the unwrap calls above are safe only because the array is not empty. describe gives the common figures at once as a Summary struct.

Text: str, String and StringView

CobaltC has three text types, and knowing which is which saves most of the confusion a newcomer meets:

import std;

fn main()
{
    str fixed = "hello";              // a text constant: copied like a number, never freed
    String owned = "hello";           // owned text, built at run time, destroyed at scope end
    String::append(&mut owned, ", world");
    String::append(&mut owned, 42);   // append takes anything %v prints
    printf("%v / %v / %v bytes\n", fixed, &owned, String::len(&owned));

    StringView part = &owned[7..12];  // a view: part of the String, without a copy
    printf("[%v] %v\n", part, part == "world");

    String built = sprintf("%v-%v", fixed, 7);          // printf into a new String
    printf("%v\n", &built);
}
hello / hello, world42 / 14 bytes
[world] true
hello-7

A str is text written in the program. It is a plain value, like an integer: copy it, store it in a struct, pass it anywhere, and never think about who owns it. It is valid UTF-8 by construction, because the literal syntax has no way to write anything else.

A String is owned text: a buffer on the heap, grown by append, destroyed when its owner’s scope ends. It is a resource. Where the declared type says String, a literal is enough, String owned = "hello";; elsewhere, String::from_str(t) makes a String from a str value, and that is the one place text is copied without being asked. String::from_utf8 makes one from bytes that came from outside, checking that they are valid UTF-8, and returns a Result.

A StringView is part of a String, borrowed, with no copy: &s[i..j], with byte positions that must fall on character boundaries. It is to a String what a slice is to a vector, and the same borrowing rules apply. Functions that only read text take a StringView throughout the library: a literal is passed as it is, and a String as &s[0..$].

A String prints through a reference, printf("%v", &s), which says that printf reads it and does not take it. sprintf is printf into a new String, and String::appendf adds formatted text to one that exists.

Working with text

The functions a program needs to take text apart are on both String and StringView, and each returns views into the original rather than copies:

import std;

fn main()
{
    String line = "  name = Ada Lovelace ; born = 1815  ";
    StringView trimmed = String::trim(&line);
    printf("[%v]\n", trimmed);

    foreach (field in StringView::split(trimmed, ";"))
    {
        Vec<StringView> kv = StringView::split(field, "=");
        StringView key = StringView::trim(kv[0]);
        StringView value = StringView::trim(kv[1]);
        match (StringView::parse<u32>(value))
        {
            Ok(n)  : printf("%v is the number %v\n", key, n),
            Err(_) : printf("%v is the text \"%v\"\n", key, value),
        }
    }

    printf("%v %v\n", String::contains(&line, "Ada"), String::starts_with(&line, "name"));
    String shouted = String::to_ascii_upper(&line);
    printf("%v\n", String::trim(&shouted));
    printf("%v\n", &String::replace(&line, "Ada", "A."));

    String word = "café";
    printf("%v bytes, %v characters\n", String::len(&word), String::char_count(&word));
}
[name = Ada Lovelace ; born = 1815]
name is the text "Ada Lovelace"
born is the number 1815
true false
NAME = ADA LOVELACE ; BORN = 1815
  name = A. Lovelace ; born = 1815  
5 bytes, 4 characters

split gives a vector of views; trim strips ASCII whitespace from the ends; find, contains, starts_with and ends_with search; replace and join build new strings; lines and split_whitespace do what their names say; parse<T> reads a number of any numeric type, strictly, and returns a Result whose error says what was wrong. chars gives the characters of a string as a vector of one-character views, for the rare program that needs them.

Lengths and positions count bytes, not characters. Most text work never notices, because it splits and searches by whole substrings; when the distinction matters, char_count and chars are there, and the library makes no claim beyond UTF-8: there are no Unicode case tables or normalisation, and ASCII case changes are the only ones provided.

HashMap<K, V> and HashSet<K>

import std;

fn main()
{
    HashMap<String, u32> stock = HashMap::new();
    HashMap::insert(&mut stock, String::from_str("pens"), 12);
    HashMap::insert(&mut stock, String::from_str("ink"), 3);
    *HashMap::entry(&mut stock, String::from_str("paper"), 0) += 500;   // insert 0 if new, then add

    match (HashMap::get_str(&stock, "ink"))       // look up with a literal: no String is built
    {
        Some(n) : printf("ink: %v\n", *n),
        None    : printf("no ink\n"),
    }
    printf("paper? %v  glue? %v\n", HashMap::contains_str(&stock, "paper"), HashMap::contains_str(&stock, "glue"));

    foreach (item, count in &stock)   // in the order they were added
    {
        printf("%-6s %4d\n", item, count);
    }

    HashSet<i32> seen = HashSet::new();
    foreach (x in [3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5])
    {
        HashSet::insert(&mut seen, x);          // false when x was already there
    }
    printf("%v distinct\n", HashSet::len(&seen));
}
ink: 3
paper? true  glue? false
pens     12
ink       3
paper   500
7 distinct

A map owns its keys and values. A key may be an integer, a bool, a str, a String, or a struct or enum built from those; a float is not a key. get returns Some of a reference to the value, or None; entry returns an exclusive reference to the value, inserting a default first if the key is new, which is the one-line way to count things. For a map keyed by String, the _str forms look up with a literal without building a String to ask with.

Entries keep the order they were added, in both tools, so a foreach over a map is deterministic. There is no ordered map; to visit keys in sorted order, copy them into a vector and sort it.

printf

printf is C’s, checked. The format must be a literal, each specifier must match its argument’s type, and the count must agree, or the program is rejected before it runs:

import std;

fn main()
{
    String item = "widget";
    u32 qty = 12;
    f64 price = 3.5;
    printf("%-8s|%5d|%8.2f|%#06x\n", &item, qty, price, 255);
    printf("%v %v %v %v\n", 1.0 / 3.0, 1.0, 1.0e21, true);
    printf("%5.1f%%\n", 99.5);
    eprintf("a message for standard error\n");
}
widget  |   12|    3.50|0x00ff
0.3333333333333333 1.0 1.0e21 true
 99.5%
a message for standard error

%d takes any integer type, with no %ld or %zu to get wrong; %x, %o and %b write hexadecimal, octal and binary; %f, %e and %g take floats; %s takes a str, a &String or a StringView. Flags, widths and precisions are C’s. %v takes any of these and a bool or an enum and writes it the way CobaltC does: a float with as few digits as read back to the same value, and a whole number with .0 so that it still looks like a float. There is no %c; a byte prints as its number.

Standard output is buffered and flushed at every point where it could matter, so printing line by line is fast and a prompt still appears before the program waits for input. eprintf writes to standard error for messages that are not the program’s output.

Reading input and files

Chapter 3 used read_line(), which returns Result<Option<String>, FileError>: a line without its newline, None at the end of input, or an error. read_all() takes the rest of the input as one String. For files, read_file(path) and write_file(path, text) handle a whole file at once, and File::open, File::create and their functions read and write one in pieces; a File is a resource whose destructor closes it. Chapter 15 uses them in a program. The library also has directories, paths, the clock, the environment, random numbers, sorting, networking, and more; the guide’s chapter on the standard library is the reference for all of it.

Generics and closures

Two features let one piece of code serve many cases. A generic function or type takes a type as a parameter, so that Vec<i32> and Vec<String> are one declaration. A closure is a function written inline that can use the variables around it, so that a sorting function can be told how to compare, or a loop body can be handed to another function. Both are deliberately plainer in CobaltC than in most languages, and this chapter says exactly how plain.

Generic functions and types

import std;

struct Pair<T>
{
    T first;
    T second;
}

fn swap_pair<T>(Pair<T> p) : Pair<T>           // any T: a value can be moved around
{
    Pair { first, second } = p;       // take the pair apart (a field cannot be moved out alone)
    Pair { .first = second, .second = first }
}

fn largest<T: ordered>(slice<T, shared> xs) : T   // T: ordered gives it < and >
{
    T best = xs[0];
    foreach (x in xs)
    {
        if (*x > best)
        {
            best = *x;
        }
    }
    best
}

fn total<T: number>(slice<T, shared> xs) : T      // T: number gives it + and a zero
{
    T sum = 0;
    foreach (x in xs)
    {
        sum += *x;
    }
    sum
}

fn main()
{
    Pair<i32> p = swap_pair(Pair { .first = 1, .second = 2 });   // T is i32, from the argument
    printf("%v %v\n", p.first, p.second);
    Pair<String> q = swap_pair(Pair { .first = String::from_str("a"), .second = String::from_str("b") });
    printf("%v %v\n", &q.first, &q.second);

    array<i32, 4> a = [4, -2, 9, 1];
    array<f64, 3> f = [1.5, 2.25, 0.5];
    printf("%v %v %v %v\n", largest(&a[0..$]), largest(&f[0..$]), total(&a[0..$]), total(&f[0..$]));
}
2 1
b a
9 2.25 12 4.25

A type parameter is written in angle brackets after the name, and used like a type in the body. Each use with a different concrete type is a separate copy of the function, compiled as if you had written it out: largest for i32 and largest for f64 are two functions. There is no run-time type information and no indirect call; the cost is one copy per combination of types used.

With no bound, a value of type T can be stored, moved, passed, returned and referenced, and nothing else. swap_pair needs only that. To do more, the parameter needs a bound, and there are six to choose from, each naming a fixed set of built-in types:

BoundSatisfied byAllows
eqnumbers, bool, str, String, and any struct or enum built from those (a key type)== !=
orderedthe samealso < <= > >=
numberintegers and floatsalso arithmetic, and a literal like 0
integerintegersalso the bitwise operators and shifts
cloneall of the above; String, the containers, and any struct or enum of clone partsclone(&x), an independent copy
plainnumbers, bool, raw pointers, and arrays and structs of thosemoving the bytes through raw memory

A function whose body uses an operator its bound does not give is rejected where it is written, not where it is called:

import std;

fn larger<T>(T a, T b) : T
{
    if (a > b) { a } else { b }       // ✗ a bare T cannot be compared
}

fn main()
{
    printf("%v\n", larger(1, 2));
}
error[diag.unbounded-type-parameter]: `>` on a `T` needs a bound: declare `<T: ordered>` (D-0090), or pass the operation in as a `fn` parameter
 --> unbounded.cb:5:9
  |
5 |     if (a > b) { a } else { b }       // ✗ a bare T cannot be compared
  |         ^
  = repair: give `T` the bound the operator needs (`<T: ordered>`, D-0090), or pass the operation in as a `fn` parameter

That is the whole of bounds. There are no traits, no interfaces, and no way to declare a new bound. Your own struct can be clone, by its fields, and it is eq and ordered when it is a key type: built from integers, bool, str, String and other such types, with no float, reference or container in it. Its == compares field by field, and its < is the order Vec::sort gives it, field by field in declaration order (an enum by the order of its variants, then its payload), so a < b holds exactly when sorting puts a first. When generic code needs to combine values of your own types, or order a struct otherwise than by its fields, it takes the operation as a function parameter, which is what Vec::sort_by does and what the last example in this chapter shows. The restriction is a deliberate one. It keeps generic code to plumbing, moving values in and out of containers, and keeps the behaviour of every operator fixed by the language: nothing a program writes changes what < or == means.

Where a type parameter comes from

Inference is local and flows downward only. A type parameter is found from, in order: an explicit argument, identity<i64>(5); the type of an argument in the same call; or the type expected where the call appears, such as the declared type of the variable being initialised. Nothing is ever inferred from how a value is used later:

import std;

fn main()
{
    Vec<u8> bytes = Vec::new();       // T from the declaration
    Vec::push(&mut bytes, 200);       // the literal is a u8, from the Vec's T
    auto v = Vec::new();              // ✗ nothing says what T is
    Vec::push(&mut v, 1);
}
error[diag.cannot-infer-type-parameter]: nothing here fixes `T` of `Vec::new`: write `Vec::new<T>(…)`, or use the result where its type is declared
 --> inference.cb:7:14
  |
7 |     auto v = Vec::new();              // ✗ nothing says what T is
  |              ^^^
  = repair: `name<τ>(…)` or annotate the declaration

The habit this asks for is simple: write the type on the left for empty containers and generic constructors, and use auto for everything whose type the right-hand side already shows. In exchange, the type of every expression can be read from the text around it, without following the value forward through the function.

Closures

A closure is a function written where it is used, with a list of the outer variables it uses in square brackets:

import std;

fn apply_twice(fn(i32) : i32 f, i32 x) : i32
{
    f(f(x))
}

fn main()
{
    i32 k = 10;
    auto add_k = [k](i32 x)           // captures k; the body only reads it
    {
        x + k
    };
    printf("%v %v\n", add_k(5), apply_twice(add_k, 0));

    i32 total = 0;
    {
        auto add_to_total = [total](i32 x)      // the body writes total: an exclusive capture
        {
            total += x;
        };
        add_to_total(3);
        add_to_total(4);
    }                                 // the closure ends here and releases total
    printf("total %v\n", total);

    Vec<i32> v = Vec::new();
    Vec::push(&mut v, 7);
    auto first = move [v]()           // move: v now belongs to the closure
    {
        v[0]
    };
    printf("%v %v\n", first(), first());
}                                     // first is destroyed here, and its Vec with it
15 20
total 7
7 7

The bracket is the closure’s capture list, and it must name exactly the outer variables the body uses, no more and no fewer. It is documentation that the compiler checks:

import std;

fn main()
{
    i32 a = 1;
    i32 b = 2;
    auto f = [a](i32 x)               // ✗ the body uses b as well
    {
        x + a + b
    };
    printf("%v\n", f(0));
}
error[diag.capture-list-mismatch]: the capture list does not match the names the closure uses
 --> capture_mismatch.cb:7:14
  |
7 |     auto f = [a](i32 x)               // ✗ the body uses b as well
  |              ^
  = repair: add the missing name(s) or remove the unused one(s); the list documents an already-derived fact, it cannot select a different capture set

How each variable is captured is decided by the body, not the list. A variable the body only reads is captured by shared reference; one it writes is captured by exclusive reference. Those references are ordinary borrows with the usual consequences: while add_to_total exists, total is exclusively borrowed and nothing else may touch it, which is why the example puts the closure in a block. The move keyword captures by value instead, moving resources into the closure, which then owns them and destroys them when it is destroyed. A closure that will outlive the current block, or run on another thread, is always a move closure.

A closure’s type is a function type, fn(i32) : i32 for add_k, so it can be passed wherever a function of that signature is expected, stored in a variable or a struct field, and called from there. A named function is a value of the same kind. The result type is taken from the body, or from the fn type the closure is passed as; when neither settles it, write it after the parameters, [](i64 a) : i64 { 0 }.

Functions do not nest, so a helper needed only inside one function is written as a closure. It is also how CobaltC does what other languages do with iterators and lambdas: the library’s position, sort_by, retain and from_fn each take a function value and apply it.

Functions as values

import std;

struct Person
{
    String name;
    u32 age;
}

fn younger(ref<Person, shared> a, ref<Person, shared> b) : bool
{
    a.age < b.age
}

fn main()
{
    Vec<Person> people = Vec::new();
    Vec::push(&mut people, Person { .name = "Ada", .age = 36 });
    Vec::push(&mut people, Person { .name = "Lin", .age = 29 });
    Vec::push(&mut people, Person { .name = "Kay", .age = 41 });

    Vec::sort_by(&mut people, younger);         // a named function as a value
    foreach (p in &people)
    {
        printf("%v %v\n", &p.name, p.age);
    }

    u32 limit = 35;
    Option<usize> i = Vec::position(&people, [limit](ref<Person, shared> p) : bool { p.age > limit });
    printf("first over %v: %v\n", limit, Option::unwrap_or(i, 99));
}
Lin 29
Ada 36
Kay 41
first over 35: 1

Vec::sort_by takes a function of two shared references that says whether the first should come before the second; Vec::position takes a test and returns the index of the first element that passes it. This is the pattern the language offers in place of traits: the generic container does the moving, and the caller supplies the one operation it cannot know. It is less general than a trait system and considerably easier to read, since the operation is visible at the call.

Modules, files and tests

A program of any size needs a way to group its declarations, hide the ones that are nobody else’s business, and spread itself over several files. In CobaltC all three are done by modules, which add no new semantics: every rule about ownership, borrowing and types holds unchanged across a module boundary. This chapter also covers unittest, since tests live beside the code they test, inside its modules.

A module hides what it does not export

import std;

module bank
{
    import std;                       // each module imports what it uses

    export struct Account
    {
        export u32 id;                // visible outside the module
        i64 balance;                  // private: only this module's functions reach it
    }

    export fn Account::open(u32 id, i64 deposit) : Account
    {
        assert(deposit >= 0, "an account opens with a deposit, not a debt");
        Account { .id = id, .balance = deposit }
    }

    export fn Account::balance(ref<Account, shared> a) : i64
    {
        a.balance
    }

    export fn Account::withdraw(ref<Account, exclusive> a, i64 amount) : bool
    {
        if (amount > a.balance)
        {
            return false;             // the invariant: a balance never goes below zero
        }
        a.balance -= fee(amount);
        true
    }

    fn fee(i64 amount) : i64          // private: a helper nobody outside needs
    {
        amount + amount / 100
    }
}

fn main()
{
    bank::Account acct = bank::Account::open(7, 1000);
    printf("account %v holds %v\n", acct.id, bank::Account::balance(&acct));
    printf("withdraw 5000: %v\n", bank::Account::withdraw(&mut acct, 5000));
    printf("withdraw 300: %v\n", bank::Account::withdraw(&mut acct, 300));
    printf("now %v\n", bank::Account::balance(&acct));
}
account 7 holds 1000
withdraw 5000: false
withdraw 300: true
now 697

module name { ... } encloses declarations. Everything in it is private unless marked export: usable from inside the module and from modules nested in it, and from nowhere else. A struct’s fields have their own visibility, field by field, so a module can expose a type while keeping control of what goes inside it. Here balance is private, so the only way to change it is through withdraw, and the module’s promise that a balance never goes negative is kept in one place.

Outside the module, its items are reached by their qualified names, bank::Account::open. A private item reached from outside is rejected:

import std;

module bank
{
    export struct Account
    {
        export u32 id;
        i64 balance;
    }
}

fn main()
{
    bank::Account a = bank::Account { .id = 1, .balance = 50 };   // ✗ balance is private
    printf("%v\n", a.id);
}
error[diag.name-not-visible]: field `balance` of `bank::Account` is not exported
  --> private.cb:14:23
   |
14 |     bank::Account a = bank::Account { .id = 1, .balance = 50 };   // ✗ balance is private
   |                       ^^^^
   = repair: mark `export` or move the reference

There are no read-only fields. A field that others may read but not change is a private field with an exported function that reads it, as balance is. That is also where the module’s invariant is enforced, so the two needs are served by one mechanism.

import

import bank::Account; lets Account be used unqualified in the module that writes it. import bank; lets every exported item of bank be used unqualified. Neither copies or renames anything; they only extend name lookup. That is what import std; does at the top of every program, and it is why each module writes its own import std;: an import acts only in the module that contains it.

A name is looked up in a fixed order: the module’s own declarations first, then its single-name imports, then its module imports, then the enclosing modules. So a program’s own fn max wins over std‘s, which stays reachable as std::max. When two imported modules export the same name, using it unqualified is an error, and qualifying it resolves the question; importing both is fine, so a library that gains an export never breaks a program that does not use it. The tools’ --warn-shadow option notes each name of your program that hides an imported one, for a program that would rather know.

A module in a file of its own

A module’s body can be a separate file: module name "path.cb"; means exactly module name { ... } with that file’s declarations as the body. The file’s own top level is anonymous; the file that declares the module names it. Paths are relative to the file that writes them, so a library moves as a unit.

A library split across files is wired together with one more form: export import, which passes on what a module imports as its own items. A root file declares the parts and re-exports what users should see:

// inventory.cb: the library's root. `import inventory;` brings in what it re-exports.
export module items "items.cb";
export module report "report.cb";

export import inventory::items;       // every exported item of items, as inventory's own
export import inventory::report::print_all;   // one function from report
// items.cb: the body of module `items`. Nothing here names the module;
// the file that declares it does.
import std;

export struct Item
{
    export String name;
    export u32 count;
}

export fn Item::new(str name, u32 count) : Item
{
    Item { .name = String::from_str(name), .count = count }
}

export fn describe(ref<Item, shared> it) : String
{
    sprintf("%v x %v", it.count, &it.name)
}
// report.cb: another module, which uses `items` through the root's declaration.
import std;
import inventory::items;

export fn print_all(ref<Vec<Item>, shared> stock)
{
    u32 total = 0;
    foreach (it in stock)
    {
        printf("  %v\n", &inventory::items::describe(it));   // qualified: `std` exports a `describe` too
        total += it.count;
    }
    printf("  %v items in all\n", total);
}

The program that uses it declares the root as a module and imports it:

import std;

module inventory "inventory/inventory.cb";    // a module whose body is a file
import inventory;

fn main()
{
    Vec<Item> stock = Vec::new();             // inventory::items::Item, through the re-export
    Vec::push(&mut stock, Item::new("bolts", 40));
    Vec::push(&mut stock, Item::new("nuts", 35));
    printf("stock:\n");
    print_all(&stock);
    printf("%v\n", &inventory::items::describe(&stock[0]));   // the full path works too
}
stock:
  40 x bolts
  35 x nuts
  75 items in all
40 x bolts

coby files.cb runs the whole thing and cobc files.cb builds it: the root file names every other file, so there is no project file and no build script. A path that names no file, a file loaded twice under two names, and a cycle of files are each rejected before anything runs.

Tests beside the code

A unittest block, at the top level of a file or a module, is a test. It sees the module’s private items, and it is checked every time the program is, so a test that no longer compiles stops the build; it runs only when asked.

import std;

fn parse_port(StringView s) : Option<u16>
{
    match (StringView::parse<u32>(s))
    {
        Ok(n)  : if (n > 65535) { None } else { Some(narrow<u16>(n)) },
        Err(_) : None,
    }
}

unittest "parse_port accepts the whole range"
{
    assert(parse_port("0") == Some(0));
    assert(parse_port("65535") == Some(65535));
}

unittest "parse_port rejects what is not a port"
{
    assert(parse_port("65536") == None);
    assert(parse_port("x") == None);
    assert(parse_port("") == None);
}

fn main()
{
    printf("%v\n", parse_port("8080") == Some(8080));
}

coby ports.cb runs main as always. coby --unittest ports.cb runs the tests instead, each in a thread of its own, so a failing assert in one is reported and the next still runs:

test ports.cb:12 parse_port accepts the whole range ... ok
test ports.cb:18 parse_port rejects what is not a port ... ok
2 tests: 2 passed, 0 failed

cobc --unittest --run ports.cb does the same with the compiled program. The exit status is 1 when a test failed, so a test run can gate a build, and --unittest-filter TEXT runs only the tests whose names contain TEXT. A file that holds only a library and its tests needs no main.

Organising a program

Start with everything private and export what callers need; a module’s exports are its interface, and a narrow one is easy to keep true. Put one module in one file once a file grows past a screen or two. Give a library a root file that re-exports its public face, so that it is used with one import and can be rearranged inside without touching the programs that use it. And write the tests in the module, where they can see what they test and where a change to the code cannot leave them behind.

Threads

CobaltC’s concurrency is small on purpose. You start a thread and you must wait for it. Data goes to a thread by moving, by lending a reference, or through a channel; data shared by several threads lives in a mutex or an atomic. And the rules of chapter 7 apply unchanged across threads, so a data race is an aliasing conflict like any other, reported rather than suffered. This chapter is about those few pieces and the guarantees around them.

spawn and join

import std;

fn sum_squares(u64 from, u64 to) : u64
{
    u64 total = 0;
    for (u64 i = from; i < to; i += 1)
    {
        total += i * i;
    }
    total
}

fn main()
{
    handle<u64> low = spawn(sum_squares, 0, 500);       // runs on its own thread
    handle<u64> high = spawn(sum_squares, 500, 1000);   // and so does this
    u64 total = join(low) + join(high);                  // wait for each, take its result
    printf("%v\n", total);
    printf("%v\n", sum_squares(0, 1000));                // the same, done here
}
332833500
332833500

spawn(f, args...) runs f(args...) on a new thread and returns a handle<R>, where R is what f returns. join(h) waits for the thread and gives you its result. A handle is a resource: if it goes out of scope without being joined, the thread is joined then and its result discarded, so a thread never outlives the scope that owns its handle. That one rule is what makes everything else in this chapter safe: a reference lent to a thread is a borrow that ends at the join, which is always before the lender’s scope ends.

The compiler runs threads in parallel on separate cores. The interpreter runs them one at a time, taking turns. A program that follows the rules gets the same result from both.

Giving a thread its data

Arguments to spawn are passed exactly as to a call: plain values are copied, resources move, and references are lent. Moving is the simplest:

import std;

fn longest(Vec<String> words) : String        // takes the vector: it moves to the thread
{
    String best = String::new();
    foreach (w in words)
    {
        if (String::len(&w) > String::len(&best))
        {
            best = w;
        }
    }
    best
}

fn main()
{
    Vec<String> words = Vec::new();
    Vec::push(&mut words, "pear");
    Vec::push(&mut words, "banana");
    Vec::push(&mut words, "fig");
    handle<String> h = spawn(longest, words);   // words is gone from here
    String result = join(h);                    // and the String comes back
    printf("%v\n", &result);
}
banana

The vector moves into the thread, which owns it, and the result moves back through join. Nothing is shared, so nothing needs a lock. A move closure does the same job when the thread’s code is better written inline: spawn(move [words]() { ... }). A closure that merely borrows locals cannot be spawned, since the thread might outlive them.

Lending a reference works too, and the borrow lasts until the join:

import std;

fn count_over(ref<Vec<i32>, shared> v, i32 limit) : usize
{
    usize n = 0;
    foreach (x in v)
    {
        if (*x > limit)
        {
            n += 1;
        }
    }
    n
}

fn main()
{
    Vec<i32> v = Vec::from_slice(&[5, 12, 7, 30, 1][0..$]);
    handle<usize> h = spawn(count_over, &v, 6);    // the thread borrows v until join
    Vec::push(&mut v, 99);            // ✗ the thread may still be reading v
    printf("%v\n", join(h));
}
error[diag.aliasing-conflict]: `h` still borrows `v`, so it cannot be written or borrowed `mut` here; end that borrow first
  --> borrow_until_join.cb:20:15
   |
20 |     Vec::push(&mut v, 99);            // ✗ the thread may still be reading v
   |               ^
   = repair: end the conflicting reference (let its holder go out of scope) before this access, or use `shared` on both sides

Whether the thread had finished reading before the push is a matter of timing, and a program whose correctness depends on timing is rejected instead. Move the push after the join and the program is accepted. The same checker that stops a reference being kept across a push in one thread stops it across two; there is no separate theory of data races.

Sharing state: mutex

When several threads must change one value, it goes in a mutex, and the only way to reach the value is to lock it:

import std;

fn worker(ref<mutex<u64>, shared> total, u64 n)
{
    foreach (i in 1..n + 1)
    {
        guard<u64> g = lock(total);   // waits until the mutex is free
        *g += i;
    }                                 // the guard ends here, unlocking
}

fn main()
{
    mutex<u64> total = Mutex::new(0: u64);
    {
        handle<void> a = spawn(worker, &total, 100);
        handle<void> b = spawn(worker, &total, 100);
    }                                 // both handles end here: both threads are joined
    printf("%v\n", *lock(&total));    // 2 * (1 + 2 + ... + 100)
}
10100

lock(&m) returns a guard<T>, an exclusive reference into the mutex that exists as long as the guard does. *g is the value; g.field reaches a field of it. The guard is a resource, and its destruction unlocks the mutex, so the lock is held for exactly the guard’s scope and cannot be forgotten. Another thread’s lock waits. A thread that locks a mutex it already holds faults rather than deadlocking.

A mutex is shared by passing &m, a shared reference, to any number of threads. It is the one place an exclusive path, the guard, is allowed to coexist with shared references to the same object, because the lock serialises the accesses. Keep each guard in the smallest block that needs it; a guard held across a long computation is a lock held that long.

For a single integer shared by threads, a counter, a flag, a running maximum, Atomic<T> from the library does the job without a lock: Atomic::fetch_add, Atomic::load, Atomic::compare_exchange and the rest, each indivisible and each sequentially consistent. State of more than one value still belongs in a mutex.

Passing values: Channel

A channel is a queue between threads. Values move in at one end and out at the other, so a String or a Vec travels between threads with one owner at every moment:

import std;

fn worker(ref<Channel<u64>, shared> jobs, ref<Channel<u64>, shared> results)
{
    while (Some(n) = Channel::recv(jobs))       // None once the channel is closed and empty
    {
        _ = Channel::send(results, n * n);
    }
}

fn main()
{
    Channel<u64> jobs = Channel::new(4);         // holds at most 4 waiting jobs
    Channel<u64> results = Channel::new(100);
    {
        handle<void> a = spawn(worker, &jobs, &results);
        handle<void> b = spawn(worker, &jobs, &results);
        foreach (i in 1..11: u64)
        {
            _ = Channel::send(&jobs, i);         // waits while the channel is full
        }
        Channel::close(&jobs);                   // the workers stop when it runs dry
    }                                            // and are joined here
    Channel::close(&results);
    u64 total = 0;
    while (Some(sq) = Channel::recv(&results))
    {
        total += sq;
    }
    printf("%v\n", total);                       // 1 + 4 + ... + 100, in whatever order they came
}
385

send waits while the channel is full and recv while it is empty; a waiting thread uses no processor time. close says nothing more is coming: what was sent is still received, and then recv gives None, which ends a worker’s loop. This shape, a pool of workers fed by one channel and closed when the work is done, is the common one. send returns a Result, since a value sent after close comes back as an error rather than vanishing; the _ = says the result is not needed here.

When a thread fails

A fault in a thread you started ends that thread, not the program. The thread unwinds, its destructors run, a mutex it held is poisoned so that the next lock of it faults, and its failure becomes a value at the boundary:

import std;

fn handler(usize i) : u64
{
    Vec<u64> v = Vec::filled(3, 7: u64);
    v[i]                              // a bug when i >= 3
}

fn main()
{
    usize served = 0;
    usize failed = 0;
    foreach (i in 0..5)
    {
        match (try_join(spawn(handler, i)))     // the thread's fault, as a value
        {
            Ok(_)  : served += 1,
            Err(f) :
            {
                printf("thread %v failed: %v\n", i, &ThreadFailure::diagnostic(&f));
                failed += 1;
            },
        }
    }
    printf("%v served, %v failed\n", served, failed);
}
thread 3 failed: diag.index-out-of-bounds
thread 4 failed: diag.index-out-of-bounds
3 served, 2 failed

try_join(h) returns Result<R, ThreadFailure>, with the failure’s diagnostic name and its full report available as text. A plain join of a failed thread raises the failure again in the joiner, and so does a handle that ends unjoined, so a program that never looks still fails as a whole, one scope at a time, rather than carrying on in a broken state. This is what lets a server give each connection a thread and survive a bug in one of them.

One exception keeps the guarantee honest. A thread that was lent an exclusive reference may be halfway through changing data that belongs to a thread still running, so its fault ends the program at once, as a fault always did. Lend a thread exclusive access only when its failure should be the program’s; otherwise move the data in.

cancel(&h) asks a thread to stop: its current or next wait ends with a contained failure, which ThreadFailure::is_cancelled recognises. And a wait that can never end, every thread waiting on another, is reported as diag.deadlock rather than hanging.

Finding the timing bugs

A program whose result depends on which thread ran first is easy to write and hard to notice, because the timing you test is rarely the one that goes wrong. coby --explore N prog.cb runs a program N times under different orders of its threads and groups the runs by outcome; more than one outcome means the result depends on timing. Here is a program with a lost update:

import std;

fn bump(ref<mutex<i64>, shared> m)
{
    i64 seen = 0;
    {
        guard<i64> g = lock(m);
        seen = *g;
    }                                 // the lock is released here...
    guard<i64> g = lock(m);
    *g = seen + 1;                    // ...so the other thread may have run in between
}

fn main()
{
    mutex<i64> m = Mutex::new(0: i64);
    {
        handle<void> a = spawn(bump, &m);
        handle<void> b = spawn(bump, &m);
    }
    printf("total %v\n", *lock(&m));
}
explored 40 schedules of explore.cb: 2 distinct outcomes

15 runs, exit 0 -- replay: coby --schedule-seed 1 explore.cb
  | total 1

25 runs, exit 0 -- replay: coby --schedule-seed 2 explore.cb
  | total 2

Each outcome comes with a seed, and coby --schedule-seed S prog.cb replays that run exactly, as often as needed to see why. cobc --explore does the same with the compiled program. The fix here is to hold the lock across the read and the write, which is the rule of thumb for every mutex: a read-then-write under one lock is one operation, and under two it is none.

What is not here

There are no memory orderings weaker than sequential consistency, no condition variables, no thread-local storage and no read-write lock. There are, in the library, Barrier, Semaphore and Once for the common waiting patterns, Atomic::wait and notify for sleeping until a value changes, Arc<T> for a value that several threads own together, and slice_parts for splitting a vector into parts that threads fill at the same time with no lock, since disjoint slices never conflict. The language guide’s chapter on concurrency covers each, with its rules.

Unsafe code and C

Everything so far has been checked by the language. But a systems language has to reach memory it did not allocate and call code it did not compile, and about those the language can check nothing. CobaltC’s answer is not to pretend. The operations it cannot check are allowed only inside an unsafe block, and the block is a written assertion: you are stating, in a place every reader can see, that a fact holds which the language cannot verify. This chapter is about what those operations are, what each one asserts, and what happens when an assertion is false.

unsafe blocks and raw pointers

import std;

fn read_through(rawptr<i32> p) : i32
{
    unsafe
    {
        *p                            // asserted: p addresses a live, initialised i32
    }
}

fn main()
{
    i32 x = 7;
    rawptr<i32> p = rawptr_of(&x);    // taking an address is safe; using it is not
    printf("%v\n", read_through(p));

    array<u8, 3> bytes = [10, 20, 30];
    rawptr<u8> b = reinterpret_ptr<u8>(rawptr_of(&bytes));
    unsafe
    {
        printf("%v\n", *(b + 2));     // pointer offset, in elements
    }
}
7
30

A rawptr<T> is an address and nothing more: no mode, no lifetime, no checks, a plain value copied freely. Getting one is safe, since an address by itself harms nothing; rawptr_of(&x) takes the address of a place, and reinterpret_ptr<U>(p) changes the pointee type without changing the address. Using one, reading or writing *p or offsetting it with p + n, is a trusted operation, and the block around it asserts that p addresses initialised bytes of a T that no live reference reaches and that the offset stays within one allocation.

Outside an unsafe block, a trusted operation is rejected before the program runs:

import std;

fn read_through(rawptr<i32> p) : i32
{
    *p                                // ✗ a trusted operation outside unsafe
}

fn main()
{
    i32 x = 7;
    printf("%v\n", read_through(rawptr_of(&x)));
}
error[diag.trusted-outside-unsafe]: this needs an `unsafe` block
 --> no_unsafe.cb:5:5
  |
5 |     *p                                // ✗ a trusted operation outside unsafe
  |     ^
  = repair: wrap in `unsafe` after verifying the asserted property

An unsafe block changes nothing else. It is an ordinary block, with a value and a scope, and every ordinary access inside it is checked as it would be anywhere. Only the handful of trusted operations become available, and a search for the word unsafe finds every one of them in a program.

Manual allocation

allocate(bytes, align) asks for fresh memory and returns a Result, so calling it is safe: failure is a value. Everything done with the memory afterwards is trusted, and deallocate must be called exactly once with the same size and alignment:

import std;

// A buffer of n zeroed u32s, built from raw memory and handed back as a Vec.
fn zeros(usize n) : Vec<u32>
{
    usize bytes = n * sizeof<u32>();
    rawptr<u8> raw = match (allocate(bytes, alignof<u32>()))   // safe: failure is a value
    {
        Ok(p)  : p,
        Err(_) : { return Vec::new(); },
    };
    rawptr<u32> words = reinterpret_ptr<u32>(raw);
    Vec<u32> out = Vec::new();
    unsafe
    {
        foreach (i in 0..n)
        {
            *(words + narrow<isize>(i)) = 0;     // asserted: inside the allocation
            Vec::push(&mut out, *(words + narrow<isize>(i)));
        }
        deallocate(raw, bytes, alignof<u32>());   // asserted: the allocation above, once
    }
    out
}

fn main()
{
    Vec<u32> v = zeros(4);
    printf("%v zeros, the last is %v\n", Vec::len(&v), v[3]);
}
4 zeros, the last is 0

This is how Vec itself is written: a resource struct holding a raw pointer, a length and a capacity, with a destructor that deallocates and a push that writes through the pointer inside unsafe. The standard library is written in CobaltC, and its unsafe blocks are the implementation’s to keep true, not yours: a program that writes no unsafe of its own has the language’s guarantee without conditions.

Between raw memory and the checked world there is one more operation. reclaim<T>(p) turns the bytes at p into an object of type T that every ordinary rule then applies to; you can borrow it, assign to it, or drop it, and two references obtained this way are checked against each other like any others. The library uses it to hand out references to a vector’s elements. Most programs never need it.

Calling C

A function provided from outside is declared with unsafe extern fn, and called inside unsafe:

import std;

unsafe extern fn sqrt(f64 x) : f64;          // from the C maths library
unsafe extern fn toupper(i32 c) : i32;       // from the C library

fn main()
{
    f64 root = unsafe { sqrt(2.0) };
    assert(root > 1.414 && root < 1.415);     // a claim from outside, checked before it is trusted
    printf("%.6f\n", root);
    printf("%v\n", unsafe { toupper(widen<i32>(b'c')) });   // 'c' -> 'C', as a number
}
1.414214
67

The declaration is marked unsafe because no tool can check that the C function really has that name and those types; if it does not, every call is wrong, so the assertion is made where the declaration is. The call is inside unsafe for what the function does. What comes back is a claim: a genuine value of the declared type whose relationship to anything else, that a length is right, that a pointer is valid, is not established until your code checks it. The assert above is that check in miniature, and it is the pattern for everything that crosses the boundary: bytes from a file are a Vec<u8> until String::from_utf8 validates them, and a count from C is a number until the program compares it with what it asked for.

The types that may cross are integers, floats, bool, void, raw pointers, and plain structs of those, laid out as C lays them out. Anything else is rejected at the declaration:

import std;

unsafe extern fn takes_vec(Vec<i32> v);      // ✗ a Vec cannot cross into C

fn main()
{
}
error[diag.extern-non-ffi-type]: `takes_vec` cannot cross to C: its signature has `Vec<i32>`, a resource; an extern's types are integers, `f32`, `f64`, `bool`, `rawptr<T>` and plain structs of those (D-0252)
 --> bad_extern.cb:3:1
  |
3 | unsafe extern fn takes_vec(Vec<i32> v);      // ✗ a Vec cannot cross into C
  | ^^^^^^
  = repair: pass `rawptr`/scalars, or a struct without resources, enums or bitstructs

A program’s own C code is named with extern "./shapes.c";, which the compiler compiles and links, and an installed library with extern "z";. The whole program still builds with cobc main.cb. The interpreter’s memory is simulated, so it calls C functions that take and return numbers and refuses, with a clear message, those that need a pointer; the compiler passes everything. The language guide shows a complete C file, a CobaltC library that C programs call, and the layout rules for structs that cross.

When an assertion is false

An unsafe block’s assertions are not checked; that is what makes them assertions. If one is false, because *p read bytes that were not a valid T, or a C function wrote past what it was given, the program has left the language’s rules at that step. Everything before it happened exactly as the rules say. From that step on, nothing is promised: the program may carry on, print nonsense, fault or crash, and no guarantee in this book covers what follows.

Two consequences shape how unsafe is used. Keep each block small, so that what it asserts can be read and checked by eye in the place it is written. And keep the number of blocks small, by putting the trusted operations behind a safe function, as zeros and read_through above do, so that the rest of the program, which is most of it, makes no assertions at all and has the language’s guarantee in full.

A larger program: a to-do list

This chapter builds a complete command-line tool and reads it from top to bottom. It keeps a to-do list in a text file: you add tasks, list them, mark them done, and remove them. It is a hundred and eighty lines long and uses the language the way the previous chapters taught it, so the point here is less any one feature than how they fit together: where the errors go, where the modules go, how the file is kept honest, and how the command line is read.

What it does

$ todo --file $TMP/todo.txt list
nothing to do
$ todo --file $TMP/todo.txt add buy milk
added task 1
$ todo --file $TMP/todo.txt add write chapter 15
added task 2
$ todo --file $TMP/todo.txt add call the bank
added task 3
$ todo --file $TMP/todo.txt list
 1. [ ] buy milk
 2. [ ] write chapter 15
 3. [ ] call the bank
$ todo --file $TMP/todo.txt done 2
done: write chapter 15
$ todo --file $TMP/todo.txt remove 1
removed: buy milk
$ todo --file $TMP/todo.txt list
 1. [x] write chapter 15
 2. [ ] call the bank
$ todo --file $TMP/todo.txt done 9
todo: there is no task 9
$ todo --file $TMP/todo.txt done two
todo: 'two' is not a task number
$ todo --file $TMP/todo.txt frobnicate
todo: unknown command 'frobnicate'
$ todo --help
Usage: todo [--file PATH] COMMAND [ARGS]

Options:
  -h, --help       show this help
  -f, --file PATH  the list file (default: todo.txt)

Each of these runs is part of the book’s tests, in that order, against one file in a temporary directory; the error lines go to standard error and the failing runs exit with status 1, or 2 for a usage mistake.

The program

// todo: a to-do list kept in a text file.
//
//   todo [--file PATH] add TEXT...     add a task
//   todo [--file PATH] list            show the tasks
//   todo [--file PATH] done N          mark task N as done
//   todo [--file PATH] remove N        delete task N
//
// The file holds one task per line: "[ ] text" or "[x] text".

import std;

// Every way the program can fail, in one place.
enum TodoError
{
    File(FileError),                  // the list could not be read or written
    BadLine(usize),                   // the file has a line that is not a task
    NoSuchTask(usize),                // a number no task has
    Usage(String),                    // the command line made no sense
}

fn TodoError::text(ref<TodoError, shared> e) : String
{
    match (e)
    {
        File(f)       : String::from_str(FileError::text(f)),
        BadLine(n)    : sprintf("line %v of the list is not a task", *n),
        NoSuchTask(n) : sprintf("there is no task %v", *n),
        Usage(m)      : String::clone(m),
    }
}

// Reading and writing the list file. Nothing else touches the file.
module store
{
    import std;

    export struct Task
    {
        export String text;
        export bool done;
    }

    export fn load(StringView path) : Result<Vec<Task>, TodoError>
    {
        Vec<Task> tasks = Vec::new();
        String text = match (read_file(path))
        {
            Ok(t)         : t,
            Err(NotFound) : { return Ok(tasks); },      // no file yet: an empty list
            Err(e)        : { return Err(File(e)); },
        };
        foreach (i, line in String::lines(&text))
        {
            if (StringView::len(line) == 0)
            {
                continue;
            }
            if (Some(rest) = StringView::strip_prefix(line, "[x] "))
            {
                Vec::push(&mut tasks, Task { .text = String::from_view(rest), .done = true });
            }
            else if (Some(rest) = StringView::strip_prefix(line, "[ ] "))
            {
                Vec::push(&mut tasks, Task { .text = String::from_view(rest), .done = false });
            }
            else
            {
                return Err(BadLine(i + 1));
            }
        }
        Ok(tasks)
    }

    export fn save(StringView path, ref<Vec<Task>, shared> tasks) : Result<void, TodoError>
    {
        String out = String::new();
        foreach (t in tasks)
        {
            String::appendf(&mut out, "%v %v\n", if (t.done) { "[x]" } else { "[ ]" }, &t.text);
        }
        match (write_file(path, &out[0..$]))
        {
            Ok(_)  : Ok(()),
            Err(e) : Err(File(e)),
        }
    }
}

import store::Task;

// The task number given on the command line, checked against the list.
fn task_index(ref<Vec<String>, shared> args, ref<Vec<Task>, shared> tasks) : Result<usize, TodoError>
{
    if (Vec::len(args) != 2)
    {
        return Err(Usage(sprintf("%v takes one task number", &args[0])));
    }
    usize n = match (String::parse<usize>(&args[1]))
    {
        Ok(n)  : n,
        Err(_) : { return Err(Usage(sprintf("'%v' is not a task number", &args[1]))); },
    };
    if (n == 0 || n > Vec::len(tasks))
    {
        return Err(NoSuchTask(n));
    }
    Ok(n - 1)
}

fn run(StringView path, Vec<String> args) : Result<void, TodoError>
{
    Vec<Task> tasks = store::load(path)?;
    match (args[0])
    {
        "add"    :
        {
            if (Vec::len(&args) < 2)
            {
                return Err(Usage(String::from_str("add needs the task's text")));
            }
            Vec::push(&mut tasks, Task { .text = String::join(&args[1..$], " "), .done = false });
            store::save(path, &tasks)?;
            printf("added task %v\n", Vec::len(&tasks));
        },
        "list"   :
        {
            if (Vec::len(&tasks) == 0)
            {
                printf("nothing to do\n");
            }
            foreach (i, t in &tasks)
            {
                printf("%2d. [%v] %v\n", i + 1, if (t.done) { "x" } else { " " }, &t.text);
            }
        },
        "done"   :
        {
            usize i = task_index(&args, &tasks)?;
            tasks[i].done = true;
            store::save(path, &tasks)?;
            printf("done: %v\n", &tasks[i].text);
        },
        "remove" :
        {
            usize i = task_index(&args, &tasks)?;
            Task removed = Vec::remove(&mut tasks, i);
            store::save(path, &tasks)?;
            printf("removed: %v\n", &removed.text);
        },
        _        : { return Err(Usage(sprintf("unknown command '%v'", &args[0]))); },
    }
    Ok(())
}

fn main() : u8
{
    Args a = match (Args::new("todo [--file PATH] COMMAND [ARGS]"))
    {
        Ok(a)  : a,
        Err(_) : { eprintf("todo: an argument is not UTF-8\n"); return 2; },
    };
    bool help = Args::flag(&mut a, "help", "h", "show this help");
    String path = Option::unwrap_or(
        Args::option(&mut a, "file", "f", "PATH", "the list file (default: todo.txt)"),
        String::from_str("todo.txt"),
    );
    Vec<String> rest = match (Args::finish(&mut a))
    {
        Ok(r)  : r,
        Err(m) : { eprintf("todo: %v\n", &m); return 2; },
    };
    if (help || Vec::len(&rest) == 0)
    {
        printf("%v", &Args::help(&a));
        return if (help) { 0 } else { 2 };
    }
    match (run(&path[0..$], rest))
    {
        Ok(_)  : 0,
        Err(e) :
        {
            eprintf("todo: %v\n", &TodoError::text(&e));
            1
        },
    }
}

The errors come first

The program begins by naming every way it can fail, in one enum. Two of the variants wrap failures that come from elsewhere: File carries the library’s FileError, and Usage carries a message built where the mistake was found. The other two are the program’s own. Having one error type means ? flows through the whole program without conversion, and TodoError::text is the single place where an error becomes a sentence for a person. main prints that sentence and chooses an exit status; nothing else in the program prints an error.

The variant File shares its name with a type that std exports, the open-file handle. That is no trouble: where a value is written, File(e) can only mean the variant, since a type is not a value, and where a type is written, File is std’s. The language keeps the two positions apart.

The file lives in a module

Everything that reads or writes the list file is in module store, and the rest of the program sees two functions, load and save, and the Task struct. If the file format changes, this module changes and nothing else does.

load turns a missing file into an empty list, since the first add on a new machine should not fail; every other file error is wrapped and returned. The match on read_file has three arms, and the middle one shows a pattern inside a pattern: Err(NotFound) matches only that variant of the error, and the Err(e) after it catches the rest. Then each line is classified with strip_prefix, which returns the remainder of the line as Some when it begins with the given text. A line that is neither form is an error that names its line number, so a hand-edited file that goes wrong says where.

save builds the whole file in a String with appendf and writes it in one call. Writing the whole file every time is the simplest thing that is correct, and for a to-do list it is also fast enough. The match at the end converts the library’s error into the program’s; Ok(_) : Ok(()) is how a function with nothing to return reports success.

The module writes import std; for itself, because an import acts only in the module that contains it. The TodoError variants, by contrast, need no import inside store: a bare variant name is looked up in the enclosing modules, and File and BadLine are found at the root.

Commands are a match on text

run loads the list, does one command, and saves if anything changed. The command is the first argument, and a match on it with text patterns is the whole dispatch: four commands and a catch-all for anything else. Each arm is a block, because each has statements to run; the match as a whole is a statement and run finishes with Ok(()).

Two commands take a task number, and task_index checks it once for both: the right number of arguments, a number at all, and a number some task has. It returns the zero-based index that the vector wants, and ? at each call site returns its error unchanged. The people-facing numbers start at 1, which is the kind of small fact worth keeping in exactly one function.

add joins the remaining arguments with spaces, so todo add buy milk needs no quotes. remove uses Vec::remove, which hands back the removed Task so that its text can be printed after it has left the list. Notice that run takes args by value: it is the last user of the vector, and taking ownership lets it slice and index the vector freely, with no caller to give it back to.

The command line

main uses the library’s Args to read the options. Each flag and option call declares an option, takes it out of the arguments, and adds a line to the help text, so the help is never out of date with the options. Args::finish returns what is left, the command and its arguments, or an error naming an option nobody declared. With no command at all, the program prints its usage and exits with 2, the conventional status for a usage mistake; --help prints the same text and exits with 0, because the user asked for it.

The last match is where the program’s one error type pays off. Whatever went wrong, anywhere in run or in store, arrives here as a TodoError, is printed once, and becomes exit status 1.

What to notice

The program has no unsafe block, so every guarantee in this book holds of all of it. It has no exceptions and no error codes, and yet every failure is handled: the compiler would have rejected a Result that was dropped, a match with a missing arm, or a use of the list after it was moved. It has no memory management, and yet every String and Vec is freed at a moment you could point to. And it is organised so that each kind of knowledge, the file format, the numbering, the error messages, the command-line syntax, is in one place.

Some things to try. Add a clear command that removes every task that is done. Add a --all flag to list that hides done tasks unless it is given. Make add refuse an empty task. Each of these touches one or two places, which is the test of whether a program is well arranged.

Where to go from here

You now know the language. What remains is practice, and knowing where to look things up. The CobaltC repository has several kinds of documentation, each for a different need, and this chapter says which is which.

The language guide

htmlguide/index.html is the reference. It has one section per chapter of the specification, numbered the same way, each with a complete account of the rules and a worked example of every one, and a listing of every module and item of the standard library with its signature and a line about what it does. When this book says a library has a function and does not show it, the guide shows it. When you want to know exactly what a rule says, not just what it means, the guide says so and names the rule. Its examples are tested in the same way as this book’s.

The guide also has three chapters this book does not attempt: one on performance, measured and explained, for a reader who wants to know what a check costs and when the compiler can leave it out; one for C programmers, on error handling at length and on reading the C that cobc generates; and one for Rust programmers, on where the two languages agree and where they part.

The specification

spec/ is the authority. It is written as rules over an abstract machine, in a notation its first chapter defines, and it is what both tools implement. A rule is terse and complete; the prose around it explains. For most questions the guide is enough, but when two readings of a sentence in the guide seem possible, the specification settles it, and coby --explain prints the rule behind any diagnostic. spec/OVERVIEW.md is a prose account of the whole model, the state, how references are formed and end, the one conflict check, what is decided before a run and what during it, for a reader who wants the machinery rather than the rules one at a time.

The specification’s decision records, under spec/decisions/, say why. Every feature that was added, declined or changed has one, with the problem, the options considered and the reasons. When something in the language seems odd, there is usually a decision record that explains what the alternative would have cost.

The showcases

showcase/ holds dozens of small, complete programs and one library, each chosen to show something particular to CobaltC: a bytecode interpreter whose every failure is a value, a tree in an arena with no pointers, a parallel sort, a TLS client, a tiny shell. Its README describes them all. They are tested under both tools, so each one is current, and they are the best place to see how the language is used at a hundred to five hundred lines.

The tools

coby prog.cb runs; cobc -o prog prog.cb builds; cobfmt prog.cb formats. Beyond those: --check runs only the checks made before a run, --explain adds the registry’s entry to a diagnostic, --unittest runs the tests, --explore N runs a threaded program under many schedules, and --warn-shadow notes names of your program that hide imported ones. For the compiler, --keep-c leaves the generated C beside the executable, -v shows the exact C compiler command, and -g -O0 makes a build you can step through in a debugger at the level of your own source lines. Each tool’s --help lists the rest.

A way to proceed

Write something. A program of a few hundred lines that does a job you actually want done teaches more than any chapter, and the compiler will tell you, precisely, each time you reach for a habit from another language that does not fit here. The habits that matter are few: decide what each function keeps and what it only looks at; keep borrows short and visible; put failures in return types and leave bugs to the checks; use an enum wherever a value is one of several things. When a diagnostic surprises you, read it to the end, then --explain it, and the reason is usually on the page.

Appendix A: The language on one page

A reference card for the syntax the book has used. The language guide’s syntax chapter has the complete grammar and its tie-break rules.

Declarations

import std;                                 // the standard library's names
import m::item;                             // one name, unqualified
module m { items }                          // a module; items are private unless exported
module m "path.cb";                         // the same, with a file's items as the body
export import m::sub;                       // pass on what sub exports as m's own

fn name(T1 a, T2 b) : R { body }            // a function; the last expression is the result
fn name(T1 a) { body }                      // returns nothing
fn name<T: bound>(T x) : T { body }         // generic; bounds: eq ordered number integer clone plain
fn Type::name(ref<Type, shared> self) : R   // associated: called as Type::name(&v)
fn Type::drop(ref<Type, exclusive> self)    // the destructor; the language calls it
unsafe extern fn name(rawptr<u8> p) : i32;  // a C function
extern "./file.c";                          // C code to link

struct S { T1 a; export T2 b; }             // fields are private unless exported
resource struct S { ... }                   // a type with a destructor, moved not copied
enum E { A(T), B, C }                       // variants, with or without a payload
enum E : u8 { A = 1, B }                    // with numeric codes
bitstruct F : u8 { x : 1; pad : 7; }        // named bits of one integer
const T NAME = e;                           // a value known when the program is compiled
unittest "name" { ... }                     // a test; coby --unittest runs it

Types

WrittenMeaning
i8 … i128, u8 … u128, isize, usizeintegers; usize for lengths and indices
f32, f64, bool, str, voidfloats, truth, text constants, nothing
ref<T, shared>, ref<T, exclusive>references, made with &x and &mut x
array<T, N>N elements, N fixed when compiled
slice<T, shared>, slice<T, exclusive>a borrowed run, &a[i..j], &mut a[i..j]
Vec<T>, String, StringView, HashMap<K, V>, HashSet<K>the library’s collections and text
Option<T>, Result<T, E>Some(v) / None; Ok(v) / Err(e)
fn(T1, T2) : Ra function or closure value
handle<T>, mutex<T>, guard<T>, Channel<T>, Atomic<T>threads and what they share
rawptr<T>an address, used only inside unsafe

Statements

T x = e;        auto x = e;        T x;           // declare; `auto` takes e's type
x = e;          x += e;                           // assign; also -= *= /= %= &= |= ^= <<= >>=
S { a, b } = e;                                   // take a struct apart into locals
if (c) { ... } else if (c2) { ... } else { ... }
if (Some(x) = e) { ... }                          // a pattern as a condition; also while
while (c) { ... }       for (init; c; step) { ... }
foreach (x in &c) { ... }                         // also &mut c, c (consumes), and lo..hi
foreach (i, x in &c) { ... }                      // with the position
break;    continue;    return e;    return;
match (e) { A(x) : e1, B : e2, _ : e3, }          // every arm ends with a comma
unsafe { ... }
drop(x);                                          // destroy early
_ = e;                                            // discard a value on purpose

Expressions

42   7: u64   2.5   b'a'   "text"   b"bytes"   true   ()
S { .a = 1, .b = 2 }      [1, 2, 3]      [0; 256]      Some(5)      E::A(1)
x.f      a[i]      a[$ - 1]      f(x)      Type::name(&v)      name<T>(x)
&x      &mut x      *r
e?                                                // Ok(v) gives v; Err(e) returns it
if (c) { 1 } else { 2 }      { stmts; value }      match (e) { ... }
[a, b](i32 x) { a + b + x }      move [v]() { Vec::len(&v) }

Operators, tightest first

OperatorsNotes
e.f e[i] e(args) e?postfix
-e !e ~e &e &mut e *eunary
* / %checked; division by zero faults
+ -checked
<< >>the amount is a u32 below the width
&bitwise and
^bitwise xor
``
== != < <= > >=not chainable; bind looser than & `
&&short-circuit
`
= += …right-associative; the left side is a place

Conversions and arithmetic policies

widen<U>(x)          never fails; only to a type that holds every value
narrow<U>(x)         checked; faults when x does not fit
narrow_wrapping<U>(x)   keeps the low bits
checked_narrow<U>(x)    Option<U>
to_float<U>(x)       to_int<U>(x)       reinterpret<U>(x)
wrapping_add  wrapping_sub  wrapping_mul
saturating_add  saturating_sub  saturating_mul
checked_add  checked_sub  checked_mul  checked_div  checked_rem  checked_neg   -> Option
min_value<T>()  max_value<T>()  sizeof<T>()  alignof<T>()

Running

coby prog.cb [args]            run with the interpreter
cobc -o prog prog.cb           compile; cobc --run prog.cb compiles and runs
coby --check prog.cb           only the checks made before a run
coby --explain prog.cb         add the registry's entry to a diagnostic
coby --unittest prog.cb        run the tests
coby --explore 50 prog.cb      run a threaded program under 50 schedules
cobfmt prog.cb                 format in place

Appendix B: Diagnostics you will meet

Every diagnostic has a stable name. Static means the program is rejected before it runs; dynamic means a check failed while it ran and the program stopped; both means the same rule is applied statically where the text shows the problem and dynamically where only the values can. coby --explain prog.cb prints the full registry entry for any of them, and the specification’s registry under spec/registry/ lists them all. These are the ones a newcomer sees most.

DiagnosticWhenWhat it means, and the usual fix
diag.syntax-errorstaticThe text is not a program. The message names what was expected.
diag.unbound-namestaticNothing has this name. Check the spelling, declare it, or import std;.
diag.ambiguous-namestaticTwo imports supply the name. Qualify it, m::name.
diag.name-not-visiblestaticThe item or field is private to its module. Export it, or go through the module’s functions.
diag.type-mismatchstaticThe types differ and nothing converts. Convert with widen, narrow or to_float, or fix the type. Also a printf specifier that does not match its argument.
diag.cannot-infer-type-parameterstaticauto v = Vec::new(); and the like. Write the type on the left.
diag.use-of-uninitializedstaticA variable is read on some path before it is written. Assign on every path.
diag.duplicate-localstaticA name declared twice in one block. Assign to it, or use another name.
diag.literal-out-of-rangestaticA literal does not fit its type, 256: u8.
diag.arith-overflowboth+, - or * left the type’s range. Widen, or use wrapping_, saturating_ or checked_.
diag.div-by-zerobothDivision or remainder by zero. Test first, or checked_div.
diag.narrowing-overflowbothnarrow to a type the value does not fit.
diag.index-out-of-boundsbothAn index at or past the length.
diag.stale-bindingbothA name used after its value moved or was dropped, or a reference used after its target ended. Use the new owner, or give the name a new value.
diag.aliasing-conflictbothAn access while a conflicting reference is live: a write under a reader, a second reference beside an exclusive one, a push while an element reference lives, a spawner using what it lent a thread. End the earlier borrow first.
diag.write-through-sharedstaticA write through ref<T, shared>. Take ref<T, exclusive>, or put the value in a mutex.
diag.move-while-aliased, diag.destroy-while-aliasedbothA resource moved or dropped while a reference to it lives.
diag.reference-escapes-scopestaticA reference to a local returned or stored outside its block. Return the value.
diag.lifetime-elision-ambiguousstaticA function with two reference parameters returns a reference. Return an owned value.
diag.read-of-resourcestaticA resource where a copy would be made, such as == on two vectors. Borrow it, or use slice_eq.
diag.move-out-of-fieldstaticA resource field moved out of a struct that lives on. Move the whole struct, or destructure it.
diag.overwrite-of-live-resourcebothAn assignment over a resource whose destruction matters. overwrite, replace, or drop first.
diag.non-exhaustive-matchstaticA variant has no arm. Add it, or _.
diag.unreachable-armstaticAn arm no value can reach, such as a repeated literal.
diag.result-discardedstaticA Result computed and not used. Handle it, or _ = e;.
diag.propagate-outside-fallible-contextstatic? in a function that does not return a matching Result or Option.
diag.unwrap-faileddynamicunwrap or expect on None or Err.
diag.assert-faileddynamicAn assert whose condition was false.
diag.static-assert-failedstaticA static_assert whose condition was false.
diag.capture-list-mismatchstaticA closure’s brackets do not list exactly the outer names its body uses.
diag.unbounded-type-parameterstaticAn operator on a T with no bound that gives it. Add : ordered, : number, or pass the operation in.
diag.spawn-borrow-closurestaticA closure that borrows locals passed to spawn. Use move.
diag.mutex-reentrant-lockdynamicA thread locked a mutex it already holds.
diag.mutex-poisoneddynamicA thread faulted while holding this mutex.
diag.deadlock, diag.channel-deadlockdynamicA wait that nothing can end.
diag.thread-cancelleddynamicThe thread was asked to stop; its waiter sees it through try_join.
diag.trusted-outside-unsafestaticA raw-pointer or extern operation outside an unsafe block.
diag.extern-non-ffi-typestaticAn extern fn with a type C cannot take. Use scalars, raw pointers and plain structs.
diag.recursive-typestaticA struct that contains itself directly. Put a Box between.
diag.no-mainstaticNo fn main() at the root, or one with parameters or the wrong return type.

A message beginning internal error in coby: or cobc: is a bug in the tool, not in your program. It exits with status 4; please report it with the program that caused it.

Appendix C: For readers who know C or Rust

The language guide has a full chapter for each. This appendix is the short version: the habits to keep and the ones to drop.

Coming from C

Keep: type-first declarations, if/while/for with parentheses and braces, printf with its format strings, structs with designated initialisers, // and /* */ comments, and the feel of knowing where your memory is.

Drop these habits, because the compiler will refuse them:

  • Implicit conversion. int and long do not mix here, and neither do i32 and i64. Every conversion is a named call: widen, narrow, to_float, to_int. There is no int; every integer names its width.
  • Wrapping arithmetic. a + b that does not fit is a fault, not a wrap. Ask for wrapping by name when a hash function wants it.
  • Null. There is no null pointer in safe code. A value that may be absent is an Option<T>, and the compiler makes you check it.
  • Pointers for everything. A pointer that only lets a callee look is ref<T, shared>, one that lets it change is ref<T, exclusive>, and a pointer that hands over ownership is just passing the value. A rawptr<T> exists, inside unsafe, for the few places that need an address and nothing else.
  • free. Nothing is freed by hand. A value that owns memory is a resource, and its owner’s scope end frees it. There is no leak and no double free because there is no way to write either.
  • Error codes. A function that can fail returns Result<T, E>, and the caller cannot forget to check it; a dropped Result is a compile error.
  • switch. A match on an integer or text does the job, with a _ arm, and a match on an enum must cover every variant.
  • Uninitialised variables. A variable must be assigned on every path before it is read; the check is static.
  • x & 1 == 0. In CobaltC this means (x & 1) == 0, as you meant.

What you gain is the compiler keeping the facts you used to keep in your head. And cobc --keep-c shows you the C your program became, with every check as an explicit line.

Coming from Rust

Keep: ownership, moves, borrowing with two modes, enums with payloads, match, Option and Result with ?, generics with bounds, closures with captures, unsafe as the marked boundary, threads whose data is moved or lent, and the overall conviction that the compiler should prove things.

The differences are mostly about how far the checking is pushed to compile time, and about spelling:

  • Borrow checking is static where it can be and dynamic where it cannot. Rust rejects every program it cannot prove safe. CobaltC rejects every program whose text shows a conflict, and checks the rest while it runs, faulting at the first conflicting access. The accepted set is fixed by the language, not by the cleverness of the checker, and there are no lifetime annotations: a function returning a reference must have exactly one reference parameter.
  • No traits. There are six built-in bounds and no way to declare another. Generic code that needs an operation on your type takes it as a function parameter.
  • No method-call syntax. Vec::push(&mut v, x), never v.push(x). The mode of every borrow is written where the borrow is taken.
  • No mut on bindings. Every local can be assigned. What is controlled is who can reach a value, not whether it may change.
  • No iterators. foreach walks the collections the language knows, and library functions such as position, retain and sort_by take function values. There is no iterator protocol to implement.
  • Three text types, spelled differently. str is a copyable text constant, not a borrowed slice; String is owned text; StringView is Rust’s &str, a borrowed view of a String.
  • Declarations are type-first. i32 x = 5; and auto y = x;. Blocks, if and match are expressions, as in Rust, but conditions take parentheses and match arms are pattern : expression,.
  • Overflow always checks. In Rust it depends on the build profile. Here it is a fault in every build, and the wrapping and checked families are the only way to get another behaviour.
  • A closure’s capture list is written and checked. [a, b](i32 x) { ... } must name exactly the outer variables the body uses; how each is captured is decided by the body, and move captures by value.
  • A fault in a thread is contained. It ends that thread, poisons its mutexes, and arrives at try_join as a value; a plain join re-raises it. Much like a panic, with the policy fixed by the language rather than the library.
  • Interior mutability is a mutex or an atomic, and nothing else. There is no Cell or RefCell; Rc<T> gives shared read access and a mutex inside it gives mutation.

Rust programmers tend to find CobaltC easy to read and briefly annoying to write, until the qualified calls and the written borrows become automatic. The guide’s Rust chapter goes through each of these at length, with the reasons.