Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.