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.
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:
runGuarded with an unbounded suffix lambda "looks like" calling lock() on a mutex.
This allows the various stages of a grander operation to appear on the same indentation level.Loop::marchingFor call, then a bounded suffix lambda is preferred.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:
Fut matching its return type. This Fut is called the implicit future
or implicit Fut.async return clause becomes available. async return completes the implicit future with the given value
and returns the implicit future. The value can be omitted if the function returns Fut<void>.return is not allowed. Every code path must async return, either directly or from inside a suffix lambda (see
Code Path Return Analysis). An async return inside a suffix lambda completes the
implicit future when the lambda runs; the implicit future itself is returned to the caller right away.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;
}
To avoid overloading the keyword return and to preserve clarity of intent, Guardian has several kinds of return
statements which appear in different contexts:
return: Returns the given value from a (non-async) function.async return: Completes the implicit future of an async function with the given value and returns the implicit
future.yield: Returns the given value from a lambda.done: Equivalent to yield true.
Used to indicate that a conditional task (i.e., one supplied to runCondition) has
completed successfully,retry: Equivalent to yield false. Used to indicate that a conditional task has failed and must be retried.async done: Equivalent to a combination of done and async return. This is useful when the completion of an async
function is contingent on the completion of a conditional task.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.
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.
return on all code paths.async function must async return on all code paths, including one returning Fut<void>.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.
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.
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:
async return, the lambda containing it must also async return on all of its
code paths, and so on outward, up to the top level of the async function.if, while, or for statement, where there is no place for the
implicit return.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:
value: The type has no mutable state, and can therefore be safely shared.vault: The type has mutable state which the compiler can verify is accessed in a thread-safe manner, and can
therefore be safely shared.unsafe: The type has mutable state but does not make any thread-safety guarantees. These cannot be shared unless
they are "protected" by another mechanism, such as a GuardVar or unique reference.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.
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:
Fut to a function. The callee, in turn, is subject to the must-consume rule.Fut from a function. The caller, in turn, is subject to the must-consume rule.Fut from an entry function. This Future, when completed, will cause the program to terminate.Fut by calling .then().
This will generate another Fut, which is subject to the must-consume rule.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:
signal on a CountdownLatch in all code paths.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.
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.