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.return, the implicit future is
implicitly returned.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>.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.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:
async return.signal on a CountdownLatch in all code paths.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.