Control Flow Through Ordinary Protocols
Canonical source:
docs/guide/04-control-flow-through-protocols.mdat locked revisione99b712ecf658a527b1fe82c8beba2c1286d8dc9. This rendered page is non-normative; the applicable Protos specification remains authoritative.
Status: Non-normative programming guide
Normative owners:
spec/semantics/EXECUTION_AND_CONTROL.md,spec/semantics/VALUES_AND_COLLECTIONS.md,spec/semantics/CALLABLES.md, andspec/PROTOS_GRAMMAR.md
The previous chapter showed that Protos has one executable value kind, the Closure, and that method-style behavior is still ordinary Closure invocation. Control flow follows the same design.
The central idea is:
Core control flow is expressed through ordinary messages and lazy Closures, not through a separate callback model or special
whileexecution syntax.
That is visible in conditionals, short-circuit Boolean operations, and loops. The familiar control-flow role comes from the protocol being invoked; the underlying operations remain ordinary lookup, ordinary arguments, and ordinary Closure activation.
Conditional execution is a Boolean protocol
Section titled “Conditional execution is a Boolean protocol”A Boolean value can select a lazy callback with ifTrue or ifFalse:
ready.ifTrue() { start()}
ready.ifFalse() { wait()}The trailing braced form creates a Closure. It does not run the Closure while the call is being written or parsed. The standard Boolean protocol decides whether that callback is invoked.
For the canonical Boolean values, the standard behavior is conceptually:
true.ifTrue(block) -> invoke block(); return its normal resultfalse.ifTrue(block) -> null
true.ifFalse(block) -> nullfalse.ifFalse(block) -> invoke block(); return its normal resultThis gives conditional execution without introducing a second executable value kind for “blocks” or a dedicated conditional callback mechanism.
The selected callback follows ordinary Closure invocation rules. The unselected callback is not invoked.
D050 completes this standard Boolean protocol with a two-way selector:
result: ready.ifTrueIfFalse( () => { start() }, () => { wait() })For canonical Booleans:
true.ifTrueIfFalse(yes, no) -> invoke yes() exactly once; return its exact normal resultfalse.ifTrueIfFalse(yes, no) -> invoke no() exactly once; return its exact normal resultBoth callback-producing argument expressions are evaluated by ordinary call semantics before the Boolean message is invoked. After that evaluation, only the selected callback is validated for callability and invoked; the unselected callback is never invoked. The selected callback receives zero supplied arguments, and its exact normal result is returned unchanged.
This gives an ordinary-protocol spelling for a complete two-branch conditional
without introducing if or else as Core v0.1 syntax.
Boolean negation and two-way selection
Section titled “Boolean negation and two-way selection”Canonical Booleans also provide ordinary zero-argument not():
true.not() -> falsefalse.not() -> trueThe unary operator ! is existing Core syntax whose mandatory lowering is an
ordinary not() message send:
!condition -> condition.not()So these two forms have the same standard meaning:
ready.not()!readyThe standard behavior remains strict: its receiver must be exactly canonical
true or canonical false. There is no truthiness conversion.
and and or are lazy protocol operations too
Section titled “and and or are lazy protocol operations too”Short-circuit Boolean composition uses the same pattern:
hasUser.and() { user.active}
cached.or() { loadFromSource()}For the standard Boolean behavior:
true.and(block) -> invoke block(); require a Boolean result; return itfalse.and(block) -> false
true.or(block) -> truefalse.or(block) -> invoke block(); require a Boolean result; return itThe callback is therefore evaluated only when its result is needed.
This is important when the callback has effects:
attempted: false
alreadyReady.or() { attempted = true probe()}If alreadyReady is canonical true, the callback is not invoked, so
attempted remains false.
Do not mentally translate these forms into an eager function call whose second operand happened to be written later. The Closure is created as an ordinary argument, but its body remains lazy until the selected protocol behavior invokes it.
A trailing Closure is still an ordinary argument
Section titled “A trailing Closure is still an ordinary argument”The convenient form:
condition.ifTrue() { work()}does not introduce special ifTrue grammar.
It is ordinary message/call syntax with a trailing Closure argument. Likewise:
condition.whileTrue() { work()}is an ordinary whileTrue message send whose body argument is the trailing
Closure.
That distinction matters because the normal language rules still apply:
- the receiver is evaluated normally;
- arguments are evaluated using the ordinary call rules;
- creating the trailing Closure does not execute its body;
- lookup decides which selector is invoked;
- invoking the selected callback uses the ordinary Closure activation model.
The selector spelling describes a standard protocol. It is not syntax-level authority that bypasses lookup.
whileTrue is a protocol on a condition Closure
Section titled “whileTrue is a protocol on a condition Closure”A loop is written by making the changing condition itself a Closure:
i: 0
condition: () => i < 4
condition.whileTrue() { i = i + 1}
iThe condition captures i by reference, so every activation observes its
current value.
For the standard inherited Object.whileTrue behavior, the observable cycle is:
validate condition Closure and body Closure | vinvoke condition() with zero arguments | +-- false --> return null | +-- true --> invoke body() with zero arguments | v ignore normal body value | +------> condition() againThis is a pre-test loop: the condition runs before the first possible body activation and before every later iteration.
If the first condition result is false, the body runs zero times and the
whole whileTrue operation returns canonical null.
Why the receiver is a Closure
Section titled “Why the receiver is a Closure”The receiver is not the Boolean result of one already-computed condition. Instead, it is executable code that can recompute the condition:
keepGoing: () => { index < items.size()}
keepGoing.whileTrue() { consume(items[index]) index = index + 1}That choice connects loops directly to the Closure model from chapter 3:
- the condition can capture lexical state;
- the body can capture and modify the same state;
- each activation gets ordinary Closure call semantics;
- no hidden mutable loop-condition object is required.
Thinking of whileTrue as “repeatedly invoke this condition Closure” is much closer
to Protos semantics than thinking of it as a keyword that owns an embedded
expression.
Standard whileTrue has a strict contract
Section titled “Standard whileTrue has a strict contract”The standard behavior is Closure-specific.
Before iteration begins, the selected standard whileTrue behavior requires:
- the original receiver to be a semantic Closure;
- exactly one argument;
- that argument to be a semantic Closure.
The condition and body are then invoked with zero supplied arguments on each reached activation.
That does not bypass normal parameter binding. If a callback declares parameters with defaults, ordinary zero-argument Closure binding rules decide whether that activation succeeds. Binding behavior inside an unreachable body is not pre-executed merely because the body Closure was accepted as the loop argument.
There is no truthiness in the standard loop decision
Section titled “There is no truthiness in the standard loop decision”Each normal condition result must be exactly canonical true or canonical
false.
These are not accepted as loop decisions merely because another language might consider them truthy or falsy:
null01""{}A normal non-Boolean condition result signals the ordinary standard Error rather than being coerced.
A Future returned as the condition result is also not implicitly awaited to
obtain a Boolean. It is simply not a valid normal condition result for the
standard loop protocol.
So code such as this should produce an actual Boolean:
condition: () => index < limitrather than relying on a convention such as “non-null means continue”.
Body results do not become loop results
Section titled “Body results do not become loop results”The normal value returned by each reached body activation is ignored:
i: 0
result: (() => i < 1).whileTrue() { i = i + 1 99}
result === nullThe body can still produce effects, update captured state, call other code, or
signal control transfer. Its normal value, however, is not accumulated and
is not the result of whileTrue.
Normal loop completion always yields canonical null.
Ordinary lookup still matters
Section titled “Ordinary lookup still matters”whileTrue, ifTrue, ifFalse, and, and or are protocol selectors reached
through ordinary lookup. Their names are not reserved control-flow syntax that
automatically wins over object behavior.
For standard whileTrue, Closure values delegate to Object, where the standard
selector is installed. A nearer slot can shadow an inherited selector according
to the ordinary lookup rules.
This means two ideas must remain separate:
selector name "whileTrue" ordinary message name
standard Object.whileTrue behavior the Closure-specific Core protocol described in this chapterIf lookup selects user-defined behavior under the same name, that behavior is ordinary user-defined behavior. The standard Closure receiver/body contract belongs to the standard behavior when that behavior is actually selected.
The same principle is why the grammar does not need to decide whether the
receiver of ifTrue, and, or another protocol selector is “really a Boolean”.
Syntax identifies a message send; runtime lookup and the invoked behavior’s
receiver contract determine what happens.
Errors and non-local returns cross the loop normally
Section titled “Errors and non-local returns cross the loop normally”The loop does not create a second control-transfer universe.
If a reached condition or body signals an Error, that Error propagates through
whileTrue to the surrounding dynamic handling context.
Likewise, a valid non-local return from a reached callback continues toward its captured return home:
findFirst: (limit) => { i: 0
(() => i < limit).whileTrue() { (i == 3).ifTrue() { ^i }
i = i + 1 }
null}When the ^i is reached, control does not perform another body activation or
another condition check first. The non-local return keeps its ordinary meaning
from the Closure model.
Effects completed before the transfer remain completed; whileTrue does not roll
them back.
Suspension composes without replaying completed effects
Section titled “Suspension composes without replaying completed effects”Condition and body Closures can participate in the normal task/suspension model.
When a reached callback suspends and later resumes, the loop continues from the same semantic phase. Already completed callback effects are not supposed to be duplicated merely because execution had to resume.
From a programmer’s point of view, this means asynchronous suspension does not require a different loop syntax or a second loop API. The same protocol composes with the task runtime.
The exact task and Future ownership rules are covered by the concurrency specification and by the later Programming Guide chapter dedicated to Futures and structured concurrency.
A body-returned Future is still just an ignored body value
Section titled “A body-returned Future is still just an ignored body value”One subtle boundary is worth knowing before that later chapter.
If a reached body normally returns a Future, whileTrue does not turn that
normal return into an implicit await:
body() normally returns Future | vwhileTrue ignores that body value | vnext condition activationThe loop itself does not adopt, await, or cancel that Future merely because it was the body’s return value.
Any structured-lifetime relationship created by the task that produced the Future remains governed by the ordinary task-ownership rules. Ignoring the body value does not erase those independent rules.
This is different from a condition returning a Future: a Future is not a valid canonical Boolean condition result, so the standard loop signals Error rather than waiting for it.
Validation and execution happen at different moments
Section titled “Validation and execution happen at different moments”It is useful to distinguish accepting the loop operation from reaching a callback body.
For example, the standard operation validates that its body argument is a Closure before the first condition activation. But code inside that Closure is still lazy:
(() => false).whileTrue() { missingBinding}The body Closure is a valid body value, yet its body is unreachable because the
condition is immediately false. missingBinding is therefore not evaluated
just to preflight the loop.
By contrast, if the condition is true, the body activation is reached and its
ordinary binding/lookups can then fail normally.
The same principle applies to the condition Closure itself: its body executes when the condition activation is reached, not when the Closure value is merely created.
Practical rules to remember
Section titled “Practical rules to remember”- Read control-flow forms as ordinary messages whose lazy work is carried in Closures.
- Remember that trailing Closure syntax supplies an ordinary call argument; it does not create a separate control construct.
- Use
ifTrueorifFalsefor one-branch conditional execution, andifTrueIfFalsewhen exactly one of two lazy callbacks must be selected. - Use
not()or unary!for strict canonical-Boolean negation. - Use
andandorfor lazy Boolean short-circuiting, and return an actual Boolean from a selected callback. - Model a
whileTruecondition as a zero-argument Closure that recomputes a canonical Boolean every time it is invoked. - Do not rely on truthiness or implicit Future awaiting for loop decisions.
- Treat normal body values as discarded; normal loop completion is
null. - Expect Error and valid non-local return to propagate through a reached callback normally.
- Remember that selector names still participate in ordinary lookup and may be shadowed by nearer user-defined behavior.
- Keep task/Future ownership separate from the loop’s ignored body-result rule.
Run the executable conformance examples
Section titled “Run the executable conformance examples”The current conformance corpus contains direct executable evidence for both the D050 Boolean completion and the standard loop behavior discussed here.
Representative Boolean cases include:
../../protos/tests/conformance/boolean/negation-and-receiver.protosdemonstrates!through the mandatorynot()lowering (not operator truecase);../../protos/tests/conformance/boolean/if-true-if-false.protosdemonstrates true-branch selection and exact result propagation (iftrueiffalse true selectedcase), tolerance of an unselected non-invokable value (iftrueiffalse unselected noninvokablecase), and ordinary eager evaluation of callback-producing argument expressions (iftrueiffalse argument expressions eagercase).
Representative loop cases include:
../../protos/tests/conformance/control/while-basic-and-validation.protosdemonstrates repeated pre-test iteration, zero iterations and canonicalnullcompletion, and that a Future condition result is not implicitly awaited;../../protos/tests/conformance/control/while-future-ownership.protosdemonstrates that normal body values are ignored;../../protos/tests/conformance/control/while-binding-and-transfer.protosdemonstrates non-local return across a reached body;../../protos/tests/conformance/control/while-suspension-and-cancellation.protosdemonstrates repeated condition/body suspension without duplicated completed effects.
These files are conformance evidence, not additional normative authority.
Normative references
Section titled “Normative references”For exact behavior, consult:
../../spec/semantics/EXECUTION_AND_CONTROL.mdfor iteration order, strict loop decisions, normal completion, control transfer, suspension, and cancellation composition;../../spec/semantics/VALUES_AND_COLLECTIONS.mdfor canonical Booleans and the standardnot,ifTrue,ifFalse,ifTrueIfFalse,and, andorprotocols;../../spec/semantics/CALLABLES.mdfor Closure receiver domains, ordinaryObject.whileTrueplacement, lookup, extraction, shadowing, and activation;../../spec/PROTOS_GRAMMAR.mdfor ordinary call/message syntax and trailing Closures;../../spec/concurrency/FUTURES_AND_TASKS.mdfor structured task ownership and Future semantics;03-closures-methods-and-receivers.mdfor the Closure, capture, receiver, and non-local-return model this chapter builds on.
Those documents define the language. This chapter explains how their rules fit together while writing control flow in Protos.