Evaluation
This specification owns what a checked program does when it is evaluated:
bindings and blocks, functions and anonymous functions, local and top-level
items, calls and method syntax, evaluation order, tail calls, return, if,
match, guard, the meaning of patterns, building and updating records, and
which functions can start a run.
It consumes source forms from grammar.md, types, constructors and
their resolution from types.md, effects from
effects.md, and values from data.md. What a checked
program has been proved to satisfy before it runs, including exhaustiveness and
the discard rules, is checking.md's. Result, ? and faults are
errors.md's; Tool calls are tools.md's; tasks are
concurrency.md's; what a run records and how replay and resume
re-evaluate it are history.md's.
Values and bindings
Values are immutable. There is no var, no assignment and no mutable
reference; state moves forward by passing a new value to the next call.
let pattern = e evaluates e and binds the names in the pattern for the rest
of the enclosing block. A let may carry a type, let plan: Plan = e, which
fixes the type of e for inference (types.md). A later let of the
same name introduces a new binding that shadows the earlier one from that point
on; the earlier value is unchanged, and anything that captured it keeps it.
let _ = e evaluates e and keeps nothing. It is the one way to drop a value,
under checking.md. Dropping a value does not
undo its evaluation: a Tool call whose result is dropped was still made and is
still recorded.
let context = [System(brief)]
let context = context ++ [User(question), Assistant(answer)]
let (kept, dropped) = list.partition(findings, flow(f) = f.open)
let _ = notify.send(summary)Open in playground →Blocks
A block { ... } is a sequence of statements followed by an optional final
expression. A statement is a let, a guard, a local item, or an expression of
type Unit (checking.md). The block evaluates
its statements in order; its value is the value of its final expression, or
() when it has none.
A block is an expression and opens a scope. Names it binds are not visible
after it. A block is not something return can leave; see return.
Functions
A function is declared with flow. Every top-level function writes its
parameter types, result type and effect in full (effects.md). Its
body follows = and is an expression, often a block.
flow describe(item: Item) -> View = render(item)
flow read_batch(refs: List<Text>) -> Batch !tool = {
let documents = list.map(refs, flow(r) = catalog.read(r))
collect(documents)
}Open in playground →Calling a function evaluates the callee and the arguments, binds each parameter to its argument, and evaluates the body. Arguments bind by position. There are no named arguments, no default arguments and no variadic functions; a call passes exactly the parameters declared. A record names whatever needs naming:
web.search(Query { text, deep: true, limit: 10 })Open in playground →A parameter may be written as a pattern. It must always match
(checking.md), and its names are bound as a
let would bind them.
Functions are values
A named function, a @tool function (tools.md) and a constructor
(types.md) are each an ordinary function value: it can be passed,
bound, returned and stored in a record or a list. x ? Rejected passes the
Rejected constructor as a converter, and list.map(requests, workbench.run)
passes a Tool. A function value has no equality, ordering or text
(types.md).
Anonymous functions
An anonymous function is a flow without its name. Its parameter and result
types and its effect may be left to inference (effects.md):
list.map(items, flow(item) = render(item))
task.spawn(flow() = generate(history))
list.fold(items, 0, flow(total, x) = total + x)
list.map(rows, flow(row) = {
let parsed = parse(row)
summarize(parsed)
})Open in playground →Evaluating an anonymous function creates a function value. It does not run the body. The value captures every binding its body mentions from the enclosing scopes, as the value that binding holds when the anonymous function is created; since values are immutable, that is the same value whenever the body later runs.
Calling a function stored in a field
p.run without parentheses reads a field. A function stored in a field is
called by parenthesizing it, (p.run)(x), because p.run(x) is
method syntax. In any call, the callee is evaluated before the
arguments.
Method syntax
v.f(a, b) is a call to the function f in the module that declares the type
of v, with v as its first argument: it means m.f(v, a, b), where m is
that module, under the same visibility as the qualified call. The module is
found from the type, so method syntax needs no import of it. Each built-in type
is declared in its home module in std (stdlib.md), so
items.map(f) calls std's list.map and page.click("#login") calls
browser.click(page, "#login") when browser declares Page.
let recent = context.filter(flow(t) = t.important).take_last(20)Open in playground →There is no pipe operator, and a type gains no methods from any other module.
Local items
A block may declare functions and types. They are visible only inside that block.
- Items declared in one block refer to each other in any order, as top-level items do, so local functions may be mutually recursive.
- A local function may capture only bindings declared above it in the enclosing scopes. A binding declared later in the block is not yet bound when the function could first be called. The rule follows calls: a local function may be called, or used as a value, only where every binding it captures, directly or through the local functions it calls, is already bound. Once it is a value it can be called at any time.
- A local function's types and effect are inferred (effects.md).
- A local type does not use the enclosing function's type parameters. It declares its own.
flow run(input: Input) -> Outcome !tool = {
type Step { name: Text, tries: Int }
flow plan(s: State) -> Outcome !tool = ... execute(next) ...
flow execute(s: State) -> Outcome !tool = ... plan(next) ...
plan(start(input))
}Open in playground →Top-level items
Top-level items of a module refer to each other in any order. Two top-level
items of one module may not have the same name, and neither may two items
declared in the same block. A top-level
let constant is pure: it may call pure functions and makes no Tool call
(effects.md), it uses no task operation, and constants do not
depend on each other in a cycle. A constant therefore has one value, the same
whenever and however often it is computed.
Visibility of items is types.md's; imports and module boundaries are modules.md's.
Evaluation order
Evaluation is left to right, in source order, everywhere. This covers:
- a call's callee, then its arguments, the receiver of method syntax first;
- the elements of list, dict and tuple literals;
- record and constructor fields as written, then a
..base; - the operands of binary operators;
- the holes of text interpolation.
&& and || evaluate their right operand only when the left does not already
decide the result. Every other operator evaluates both operands first.
Tool calls make this order visible: each call is recorded as it is made (history.md), so a reordering would show as a different history. Ordinary computation is deterministic: for the same program, input and outside answers, it produces the same values and makes the same Tool calls in the same order. Which outside answers exist, and how concurrent work interleaves, are tools.md's and concurrency.md's; every choice that is not derivable from the program is recorded.
Tail calls
A tail position is one whose value is the value of the whole function. Exactly these are tail positions:
- the body of a function, named, local or anonymous;
- the final expression of a block in tail position;
- each branch of an
ifin tail position; - each arm body of a
matchin tail position; - the operand of
return, wherever thereturnstands.
No other position is one: not an argument, an operand, the operand of ?, the
subject of a match, the condition of an if or guard, the right side of a
let, or an element of a literal. Parentheses do not change a position.
A call in tail position is a tail call, whatever it calls: the function itself, another top-level function, a local or anonymous function, or any function value. A tail call ends the calling function's activation before the callee's begins, and the callee continues the same call: its result is that call's result. A tail call never grows the stack. A chain of tail calls of any length holds one activation, so a loop written as tail recursion runs in constant stack however many rounds it makes. Tail calls change nothing else: arguments, evaluation order and every recorded fact are those of an ordinary call.
flow count_to(n: Int, total: Int) -> Int =
if n == 0 { total } else { count_to(n - 1, total + 1) } // tail call
flow length(items: List<Item>) -> Int = match items {
[] => 0,
[_, ..rest] => 1 + length(rest), // not a tail call
}Open in playground →Loops are recursion, together with std functions such as list.map,
list.fold and list.filter. There is no loop statement. The language sets no
bound on recursion; a call depth beyond what the host supports is a fault
(errors.md), and which calls count toward it is
execution.md's.
That a tail call continues the same call is what keeps a loop's tasks alive from one round to the next (concurrency.md), and a tail call to a top-level function from the root task is where a checkpoint can be taken (history.md).
return
return e evaluates e and leaves the nearest enclosing function, named,
local or anonymous, with that value. Blocks, if and match are not return
targets. return e is an expression of type Never, so it can stand anywhere
an expression can, such as a match arm:
let plan = match reply {
Ok(p) => p,
Err(problem) => return Failed(problem),
}Open in playground →Inside an anonymous function, return leaves that anonymous function, not the
function that created it. return outside any function body, such as in a
top-level constant, is an error.
if
if c { a } else { b } is an expression. It evaluates the condition, a Bool,
then exactly one branch, and its value is that branch's value. Both branches
have the same type, except that a branch of type Never fits any type
(types.md). else if chains.
else may be left out only when the block's type is Unit or Never. The
if then has type Unit, and a false condition produces (). No value is
silently dropped:
let label = if open { "open" } else { "closed" }
if retries >= 3 { return GaveUp(reason) }Open in playground →match
match s { arms } evaluates its subject once, then tries the arms in source
order. An arm is taken when its pattern matches the subject and, if the arm has
a guard if cond, the guard then evaluates to true with the pattern's
bindings in scope. The first arm taken is the only one evaluated; its body's
value is the value of the match. A guard that evaluates to false passes on
to the next arm.
Every match covers its subject's type, and every arm can be taken by some
value (checking.md), so a checked match
always takes an arm. In every arm, constructors resolve against the subject's
type (types.md).
match (state, event) {
(Idle, Start(job)) => Running(job),
(Running(_), Cancel) => Idle,
(_, _) => state,
}Open in playground →guard
guard states a precondition for the rest of its block.
guard c else { ... }evaluatesc. If it istrue, evaluation continues after theguard; if it isfalse, the else block runs.guard let pattern = e else { ... }evaluateseand matches it against the pattern. If it matches, the pattern's names are bound for the rest of the block; if not, the else block runs and none of them is bound.
The else block has type Never: it must leave, through return or another
expression of type Never, and the checker proves it cannot fall through. A
call in a return there is still a tail call.
guard state.rounds < state.max_rounds else { return OutOfRounds(state.rounds) }
guard let Some(owner) = dict.get(owners, plan.owner) else { return UnknownOwner(plan.owner) }
run(owner, plan)Open in playground →Patterns
One pattern language serves match arms, let, guard let and parameters.
grammar.md fixes how each form is written; this section fixes
what each matches and binds. Where a pattern must always match is
checking.md's; in match and guard let a
pattern may fail, and the form says what happens then.
| Pattern | Matches | Binds |
|---|---|---|
_ |
any value | nothing |
a name, x |
any value | x to the value |
a literal, 0, "done", true |
a value equal to the literal under == (data.md) |
nothing |
a constructor, Some(p), Failed { step, .. } |
a value built by that constructor whose payload matches | what the payload patterns bind |
a record, Finding { severity: Critical, .. } |
a value of that record type whose named fields match | what the field patterns bind |
a tuple, (a, b) |
a tuple whose elements match, position by position | what the element patterns bind |
a list, [], [x], [x, ..rest], [..init, last] |
a list with exactly as many elements as the fixed patterns, or, with a rest, at least as many, whose fixed elements match from the front or the back | the fixed elements' bindings, and the rest as a List |
p as name |
what p matches |
name to the whole value, and what p binds |
p | q |
what p or q matches, tried left to right |
the same names, with the same types, from whichever matched |
A record or named payload pattern lists every field, or ends with .. to
ignore the rest. A field written alone, Answered { value, .. }, matches the
field value and binds it to the name value. Outside the type's module only
public fields can be named, so a pattern there ends with .. when any field is
private (types.md).
Matching a pattern only inspects the value. It evaluates nothing else, and a match that fails binds nothing.
Building and updating records
A record or a constructor with named fields is built by naming every field. A field whose value is a binding of the same name may be written alone, mirroring the pattern shorthand:
Loop { contract, rounds } // Loop { contract: contract, rounds: rounds }Open in playground →T { f: e, ..base } builds a new T from base, with the fields written
replacing those of base. A field may be a dotted path into nested records:
State { config.limit: 3, ..state }
// State { config: Config { limit: 3, ..state.config }, ..state }Open in playground →base is evaluated once, after the written fields. Who may build a record raw
and who may rebuild one with ..base is types.md's.
Entry functions
Any top-level pub flow whose parameter types and result type are all data
types (types.md) can start a run. Since those types are concrete,
an entry has no type parameters. It may be pure or !tool. The language names
no default entry; which function a run starts with is chosen by whoever starts
it (execution.md).
A run's input is one value per parameter, keyed by parameter name and decoded
by tools.md's decoding rules, so an entry's parameter types must be
ones decoding can build, with no opaque part
(tools.md); an input that does not decode refuses
the run before any evaluation. The entry is then called as an ordinary function
in the run's root task, and its result is the run's result, encoded by the same
rules. How a run ends is errors.md's, and what the run records is
history.md's.
pub flow main(ticket: Text, max_rounds: Int) -> Report !tool =
work(ticket, [], 1, max_rounds)Open in playground →Serves foundations: Flow owns a closed evaluation model, Deterministic reconstruction, and A general core, bounded in scope.