Computation blocks - IS4Code/Sona GitHub Wiki

Computation blocks offer a general way to extend the capability of statements and expressions within a particular scope, by interpreting these operations using a particular workflow. Depending on the design of such a workflow (represented by a pre-existing builder object), simple code may express complex semantics such as asynchronous continuations, parallelism, option binding, or any other sort of monadic operation.

There are several special statements whose use is facilitated in computation blocks, namely:

  • follow ‒ generalized "await". Also available in combination with individual other statements as a pseudo-operator.
  • yield ‒ outputs a value out from the computation. Also related to yield return and yield break.
  • with ‒ used to "enter" a computation block, using its argument as the new builder. Also in statements follow with and yield with.
  • The builder may also define any other operations, which are exposed as custom unqualified statements, and is also responsible for interpreting statements like return, try, while, etc.

Computation modes

Computations operate in one of two modes, single-valued or sequence-like, and may utilize either the default (global) builder, or any existing object, given as the argument to with.

The single-valued mode is the initial mode of any isolated block of code (a package, function, inline statement, etc.), and has the following properties:

  • return is supported in all positions, stopping execution when used and providing the final value of the block.
  • If no return is encountered by the end of the block, an implicit return is provided with an empty value (either unit or none when inside an optional function).
  • yield break is supported only as the final statement in the block and prevents the implicit return.

The sequence-like mode is enabled by {} and [] used for collection construction, after with.., and in iterator functions:

  • yield break is supported everywhere to terminate the sequence. Its use is implicit at the end of the block.
  • return is not supported. To actually use the builder's implementation, yield return must be used instead, but it does not stop the execution.
  • If no custom builder is provided, the block results in an object of type .. (a sequence), lazily executed.

Importantly, the mode of execution itself does not affect whether return, yield, or follow is available in the first place, as this is what the builder object decides. By default (or after with() or with..()) the default (global) builder is used, which defines follow for a few common types, such as:

  • For asynchronous operations, it pauses the current thread until a value is produced.
  • For option-like objects, it unwraps the value or throws an exception if there is none.
  • For sequences, it expects a single element and retrieves it.

Note that these operations (implemented by calling Sona.Runtime.Computations.global.ReturnFrom) are meant to be used only for exposing values from computations in top-level code, as the side effects may otherwise be undesirable.

follow

The follow keyword is usable both as a single statement, and as a part of some additional statements, to "unwrap" its sole argument in a way determined completely by the workflow previously entered via with. This generally corresponds to evaluating any operations left to be performed by the argument, waiting for their result, or exiting early if no result is present.

The name of this keyword is modelled after "await" in other languages, but meant to denote an action that is deliberately vague in terms of spatial, temporal, or existential definiteness. Unlike "unwrap" in some languages, follow "always succeeds" since situations that may be considered "exceptional" (such as a value not being present) are nonetheless valid (and handled by the builder).

There is no guarantee that any remaining code after follow executes immediately or even at most once. This is because the code is internally wrapped in a separate function and given to the Bind operation on the builder object, together with the argument of follow, leaving it to the builder to decide when and how many times the function shall be called.

Syntax

follow_statement:
  'follow' expression;

follow_discard_statement:
  'follow' member_expression '!';

follow_expression:
  'follow' unary_expression;

Each version of follow is used for a different purpose:

  • As a simple statement, the unwrapped value must be of type unit. This is analogous to an asynchronous operation with no direct result, or an option type indicating the presence of a value but not necessarily the value itself, and so on.
  • When the statement is followed by ! (if operators are necessary, the expression must be in parentheses), the result of the operation is explicitly discarded, even if it is non-unit.
  • As an expression, this pseudo-operator is usable only:
    • As the immediate argument of return, yield, yield return, or continue.
    • As the immediately assigned value in an assignment statement, variable declaration, if let or while let. If multiple variables are declared in a single statement, all of their initialisation expressions must use follow.
    • As an element in a collection construction with a builder.
    • As the condition in if, while, or repeat…until, the tested value in switch, and the collection in for…in. In most of these situations, the support is just syntactic sugar over assigning the value first to a variable and then using it.

Example

The most common usage of computation blocks is for asynchronous programming. The monadic bind operation invoked via follow is generally called "await" in such contexts, and represents scheduling the next piece of code to be executed only when another asynchronous operation completes:

function messages()
  with async

  echo "Start"
  follow Async.Sleep(1000)
  echo "After 1 second"
  follow Async.Sleep(2000)
  echo "After 2 seconds"
end

The async workflow supports controlling many aspects of the asynchronous operation, including lazy execution and cancellation. By default, objects created via async need to be explicitly executed:

let t = messages()
// No execution yet
Async.StartImmediate(t)
// "Start" shown

with

The with statement (not to be confused with the with operator or the with pattern) is used to transform the current scope into an isolated computation block. This causes the use of follow and yield (and possibly other custom operations usable as statements) to be translated into method calls on a builder object, retrieved by evaluating the expression after with.

Syntax

with_statement:
  ('follow' | 'yield')? 'with' '..'? (expression | '(' ')');

The argument of with affects the (mode)[#computation-modes] in which the statements after it operate ‒ if .. is used, the rest of the block is treated as a sequence of value, with no final value (return is disabled). If () is used, the default builder is used, switching to the immediate implementation of follow, and interpreting yield as creating simple sequences (type ..).

The permitted usage of this statement depends on its actual form (with, follow with, etc.):

  • Without any other modifier prior to with, the whole block starting from the with statement up until the end of the scope becomes the result of the outer block, as if return was called instead of with. This object corresponds to the operations performed in the block, or their result, in a manner defined by the workflow. Additionally, since there is no requirement that the code following with is executed at the point the result is created, it is not possible to have any additional statements following the whole block containing the with statement, unless the inner block is properly terminated. A with block also cannot be in a try block (or an implicit try created by use) because its effect cannot be observed from within the workflow. To bypass these restrictions and make returning explicit, return inline do with can be used to open a new isolated block as the returned expression, or one of the other combining keywords can be used.
  • The follow with statement combines both follow and with into a statement that can serve as a shorthand for follow inline do with, i.e. executes the rest of the block in the requested workflow, but continues the original code when it finishes. This has the benefit of exposing any side effects and flow control from inside of the computation block to the outside ‒ using return, break, or continue affects the enclosing blocks naturally. Use of this statement is possible only when the outer workflow supports follow on the wrapper type of the inner workflow, and safe only if it guarantees the code will be executed fully before execution moves to the remaining code.
  • The yield with statement is a combination of yield.. and with, i.e. it executes yield.. in the outer computation, taking the object produced by the inner computation. It has otherwise the same guarantees and requirements as follow with.