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:

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:

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.