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
| Type | Values |
|---|---|
i8 i16 i32 i64 i128 | signed integers, two’s complement |
u8 u16 u32 u64 u128 | unsigned integers |
isize usize | integers the width of an address; usize is the type of every length and index |
f32 f64 | IEEE 754 floating point |
bool | true or false |
str | text written in the program, valid UTF-8, copied like a number |
void | the 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:
| Parameter | The caller writes | The function may | Afterwards |
|---|---|---|---|
plain T | f(x) | use its own copy | the caller’s x is unchanged |
resource T | f(v) | own it: keep it, or let it be destroyed | the caller’s v is stale |
ref<T, shared> | f(&x) | read through it | x is unchanged |
ref<T, exclusive> | f(&mut x) | read and write through it | x 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:
| Bound | Satisfied by | Allows |
|---|---|---|
eq | numbers, bool, str, String, and any struct or enum built from those (a key type) | == != |
ordered | the same | also < <= > >= |
number | integers and floats | also arithmetic, and a literal like 0 |
integer | integers | also the bitwise operators and shifts |
clone | all of the above; String, the containers, and any struct or enum of clone parts | clone(&x), an independent copy |
plain | numbers, bool, raw pointers, and arrays and structs of those | moving 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
| Written | Meaning |
|---|---|
i8 … i128, u8 … u128, isize, usize | integers; usize for lengths and indices |
f32, f64, bool, str, void | floats, 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) : R | a 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
| Operators | Notes |
|---|---|
e.f e[i] e(args) e? | postfix |
-e !e ~e &e &mut e *e | unary |
* / % | 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.
| Diagnostic | When | What it means, and the usual fix |
|---|---|---|
diag.syntax-error | static | The text is not a program. The message names what was expected. |
diag.unbound-name | static | Nothing has this name. Check the spelling, declare it, or import std;. |
diag.ambiguous-name | static | Two imports supply the name. Qualify it, m::name. |
diag.name-not-visible | static | The item or field is private to its module. Export it, or go through the module’s functions. |
diag.type-mismatch | static | The 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-parameter | static | auto v = Vec::new(); and the like. Write the type on the left. |
diag.use-of-uninitialized | static | A variable is read on some path before it is written. Assign on every path. |
diag.duplicate-local | static | A name declared twice in one block. Assign to it, or use another name. |
diag.literal-out-of-range | static | A literal does not fit its type, 256: u8. |
diag.arith-overflow | both | +, - or * left the type’s range. Widen, or use wrapping_, saturating_ or checked_. |
diag.div-by-zero | both | Division or remainder by zero. Test first, or checked_div. |
diag.narrowing-overflow | both | narrow to a type the value does not fit. |
diag.index-out-of-bounds | both | An index at or past the length. |
diag.stale-binding | both | A 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-conflict | both | An 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-shared | static | A write through ref<T, shared>. Take ref<T, exclusive>, or put the value in a mutex. |
diag.move-while-aliased, diag.destroy-while-aliased | both | A resource moved or dropped while a reference to it lives. |
diag.reference-escapes-scope | static | A reference to a local returned or stored outside its block. Return the value. |
diag.lifetime-elision-ambiguous | static | A function with two reference parameters returns a reference. Return an owned value. |
diag.read-of-resource | static | A resource where a copy would be made, such as == on two vectors. Borrow it, or use slice_eq. |
diag.move-out-of-field | static | A resource field moved out of a struct that lives on. Move the whole struct, or destructure it. |
diag.overwrite-of-live-resource | both | An assignment over a resource whose destruction matters. overwrite, replace, or drop first. |
diag.non-exhaustive-match | static | A variant has no arm. Add it, or _. |
diag.unreachable-arm | static | An arm no value can reach, such as a repeated literal. |
diag.result-discarded | static | A Result computed and not used. Handle it, or _ = e;. |
diag.propagate-outside-fallible-context | static | ? in a function that does not return a matching Result or Option. |
diag.unwrap-failed | dynamic | unwrap or expect on None or Err. |
diag.assert-failed | dynamic | An assert whose condition was false. |
diag.static-assert-failed | static | A static_assert whose condition was false. |
diag.capture-list-mismatch | static | A closure’s brackets do not list exactly the outer names its body uses. |
diag.unbounded-type-parameter | static | An operator on a T with no bound that gives it. Add : ordered, : number, or pass the operation in. |
diag.spawn-borrow-closure | static | A closure that borrows locals passed to spawn. Use move. |
diag.mutex-reentrant-lock | dynamic | A thread locked a mutex it already holds. |
diag.mutex-poisoned | dynamic | A thread faulted while holding this mutex. |
diag.deadlock, diag.channel-deadlock | dynamic | A wait that nothing can end. |
diag.thread-cancelled | dynamic | The thread was asked to stop; its waiter sees it through try_join. |
diag.trusted-outside-unsafe | static | A raw-pointer or extern operation outside an unsafe block. |
diag.extern-non-ffi-type | static | An extern fn with a type C cannot take. Use scalars, raw pointers and plain structs. |
diag.recursive-type | static | A struct that contains itself directly. Put a Box between. |
diag.no-main | static | No 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.
intandlongdo not mix here, and neither doi32andi64. Every conversion is a named call:widen,narrow,to_float,to_int. There is noint; every integer names its width. - Wrapping arithmetic.
a + bthat 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 isref<T, exclusive>, and a pointer that hands over ownership is just passing the value. Arawptr<T>exists, insideunsafe, 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 droppedResultis a compile error. switch. Amatchon an integer or text does the job, with a_arm, and amatchon 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), neverv.push(x). The mode of every borrow is written where the borrow is taken. - No
muton bindings. Every local can be assigned. What is controlled is who can reach a value, not whether it may change. - No iterators.
foreachwalks the collections the language knows, and library functions such asposition,retainandsort_bytake function values. There is no iterator protocol to implement. - Three text types, spelled differently.
stris a copyable text constant, not a borrowed slice;Stringis owned text;StringViewis Rust’s&str, a borrowed view of aString. - Declarations are type-first.
i32 x = 5;andauto y = x;. Blocks,ifandmatchare expressions, as in Rust, but conditions take parentheses andmatcharms arepattern : 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, andmovecaptures by value. - A fault in a thread is contained. It ends that thread, poisons its
mutexes, and arrives at
try_joinas a value; a plainjoinre-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
CellorRefCell;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.