Guardian Language Core Concepts

This page offers concise overviews of the core concepts and constructs of the Guardian language. Where available, you can read more about each topic on its dedicated page.

Suffix Lambdas

Many critical Guardian functions accept a lambda as their final argument. To reduce visual noise and nesting (the latter of which becomes very problematic when using futures and continuations, leading to what we call the Sideways Pyramid of Doom), Guardian provides a special syntactic construct called the suffix lambda.

Suffix lambdas come in two flavors: bounded and unbounded. Bounded suffix lambdas have their bodies delimited by curly braces, as is typical. Unbounded suffix lambdas omit the curly braces; the body of an unbounded suffix lambda continues from an opening colon : to the end of the enclosing scope.

To start, here is a "control" example showing what passing a lambda to a function would look like with more traditional lambda syntax. Note that the current version of Guardian does not support this syntax; lambdas may only appear in suffix form.

// Java-style
foo((arg1, arg2) -> {
    doStuff(arg1);
    doStuff(arg2);   
});

// Cxx-style
foo([&](arg1, arg2) {
    doStuff(arg1);
    doStuff(arg2);
});

// Guardian-style (not supported)
foo(|arg1, arg2| {
    doStuff(arg1);
    doStuff(arg2);
});

We can rewrite this with a bounded suffix lambda as so:

foo() |arg1, arg2| {
    doStuff(arg1);
    doStuff(arg2);
}

And with an unbounded suffix lambda as so:

foo() |arg1, arg2|:
doStuff(arg1);
doStuff(arg2);

Which form to use is largely a matter of context and taste, but here are some guidelines:

Async Functions

Functions can be declared as async to indicate that they return the result of an asynchronous operation. All async functions must return a Fut, though functions not marked async can still return a Fut. The goal of async is to reduce the amount of boilerplate associated with constructing, completing, and returning Futs.

Declaring a function as async has the following effects:

In the following example, the functions addTwo and asyncAddTwo are equivalent:

fn addTwo(input: Fut<int>) -> Fut<int> {
    let result = new Fut<int>();
    input.then() |input| {
        result.complete(input + 2);
    }
    return result;
}

async fn asyncAddTwo(input: Fut<int>) -> Fut<int> {
    input.then() |input|:
    async return input + 2;
}

Return Clauses

To avoid overloading the keyword return and to preserve clarity of intent, Guardian has several kinds of return statements which appear in different contexts:

These map to two primitive kinds of return. return, yield, done, and retry return from the current function or lambda. async return and async done complete the implicit future of the enclosing async function, even from inside a lambda. In a lambda, async return also ends the lambda, which then yields nothing.

Code Path Return Analysis

The compiler checks the code paths of every function and lambda. The body of a function and the body of each suffix lambda are separate temporal scopes, since a lambda may run at a different time (and on a different thread) than the code around it. Each temporal scope is checked on its own, except that an async return inside a lambda can end the enclosing scope as well, as described below.

Returning on all code paths

When deciding whether a block returns, an if without an else and the body of a while or for loop may be skipped, so they don't count as returning on all code paths.

All or nothing

In any temporal scope, either every code path must async return, or none may. An implicit return (described below) counts as an async return here. For example, this is rejected:

async fn calibrated(reading: Fut<int>) -> Fut<int> {
    let checked = reading.then() |r| {
        if r < 0 {
            fetchFallback().then() |fallback|:
            async return fallback;  // This path async returns...
        }
        yield r;                    // Error: ...but this one doesn't.
    };
    checked.then() |r|:
    async return r * 10;
}

If this were allowed, then on a negative reading, checked would have no value to complete with, so the code which depends on it couldn't sensibly run. If execution instead carried on past the inner lambda to yield r, the fallback's async return would race the one after checked. Instead, move the code which depends on the lambda's result into the lambda, so that every code path async returns:

async fn calibrated(reading: Fut<int>) -> Fut<int> {
    reading.then() |r|:
    if r < 0 {
        fetchFallback().then() |fallback|:
        async return fallback;
    }
    async return r * 10;
}

retry doesn't count either way, since a conditional task which retries runs again later.

Implicit returns

A suffix lambda in which every code path async returns is said to async return on all of its code paths. An async return precludes everything that lexically follows it, so the enclosing scope must not carry on after such a lambda. The compiler therefore inserts an implicit return right after the statement containing it. This counts as an async return of the enclosing scope, for the purposes of both of the rules above.

For example, the following:

async fn pick(foo: bool, f: Fut<int>) -> Fut<int> {
    if foo {
        f.then() |x|:
        async return x;
    }
    Out::println("not foo");
    f.then() |x|:
    async return x + 100;
}

behaves like the following (for illustration only, since return can't be written in an async function):

async fn pick(foo: bool, f: Fut<int>) -> Fut<int> {
    if foo {
        f.then() |x| {
            async return x;
        }
        return;  // Implicit: the lambda above completes the implicit future, so nothing else may happen here.
    }
    Out::println("not foo");
    f.then() |x| {
        async return x + 100;
    }
    return;      // Implicit.
}

Without the implicit return, pick(true, f) would go on to print not foo, and the two async returns would race.

What the implicit return does depends on the enclosing scope: at the top level of an async function it returns the implicit future, in a lambda it ends the lambda, and in a conditional task it is done, like async done.

This has a few consequences:

Sharing Policy

In Guardian, all types have a property called the sharing policy. This property indicates whether and in which manner a type can be safely shared between multiple threads.

The sharing policies are, in descending order of strictness:

All fundamental types (e.g., int, bool, string) have a sharing policy of value.

The quintessential vault type, GuardVar, is used to protect unsafe types so they can be shared.

Tuple types have their sharing policy determined by the least strict sharing policy of their members.

All class definitions must specify a sharing policy, as in:

value class IntValue {
    val: int;            // This field is immutable and is a value type => value.
}

vault class IntVault {
    val: GuardVar<int>;  // This field has interior mutability protected by a GuardVar => vault.
}

unsafe class IntUnsafe {
    mutable val: int;    // This field has exterior mutability => unsafe.
}

The sharing policy of a class cannot be more strict than those of its members. In addition, if a class has a mutable field, then it must be unsafe.

The Must-Consume Rule

One of Guardian's concerns is the prevention of the following situations:

In this area, Guardian cannot provide any guarantees, because doing so would require solving the halting problem. However, Guardian does its best to prevent the first situation by enforcing the must-consume rule. This rule stipulates that all Futs generated by an asynchronous operation must eventually be consumed. Imagine a Future as a kind of hot potato: if you receive one, you must either eat it or pass it to somebody else. If you don't do one of these things, the potato's scalding heat will burn your hands.

Here are all the ways to consume a Fut:

By itself, this rule would be too restrictive. After all, it's possible for a task that generates a future to make progress by itself in a way that doesn't rely on the future being consumed. For example, if a task signals a latch or performs an async return, those actions advance some other future towards completion, and would therefore render the task's future redundant.

To address this, there is a facet to the must-consume rule called must-consume exemption. These are specific situations that exempt a Fut from the must-consume rule. This exemption can also propagate upwards along a series of nested continuations; the intent here is that any chain of continuations which eventually makes progress in a must-consume-exempt way is itself exempt from the must-consume rule.

Here are all the ways in which a function call can produce a Fut which is exempt from the must-consume rule:

Since nothing consumes an exempt Fut, the runtime makes sure that its failure (see Fut) is still noticed. If its continuations async return, a failure completes the implicit future of the enclosing async function with the same failure. If they signal a latch, a failure ends the program immediately, since otherwise the latch would never be released and the program would hang.

Vectorized Assignment

Guardian supports vectorized assignment, which is a syntax that allows element-wise array operations to be expressed similarly to scalar operations. This syntax is supported for any type that supports indexing with square brackets.

The basic syntax is as follows:

arr[a..b] = foo[a..b] + bar[a+1..b+1] * someFn(baz[-1..]);

This will compile to a loop similar to:

for i in a..b {
   arr[i] = foo[i] + bar[i + 1] * someFn(baz[i - 1]);
}

The right-hand side of the assignment can be an arbitrary expression. The only requirements are that the types must agree, that all ranges must be of the same size, and that the left-hand size be a subscriptable type indexed by a range literal (.. or ..=).

The start and end of each range expression on either side of the assignment may be elided if they can be inferred. For example:

arr[..] = foo[-1..];

Is implied to express:

arr[0..arr.size()] = foo[-1..(arr.size()-1)];

When inferring the start and end of a range expression in the right-hand side of the assignment, the compiler will always try to find agreement with the size of the range on the left-hand side. This differs slightly from the behavior of the slicing syntax in other positions, where the compiler will always assume that an omitted start or end is zero or the size of the container, respectively. The non-uniformity is unfortunate, but both behaviors are literally always incorrect in the opposite contexts, so this compromise was made.

Using vectorized assignment syntax can be slightly more efficient than using a loop because of an optimization that allows the compiler to generate fewer runtime bounds checks.