Flow Control in EK9
EK9 has the flow control you would expect: if, switch, for, while and do/while. It also has streams and guards, so you need the imperative (traditional) statements less often, and it adds features to the statements themselves.
This article explains why a function or method body has no early returns.
What's Different: EK9's Control Flow Philosophy
Coming from Java, Python, C++ or C#? EK9's control flow will feel restrictive at first. You will reach for
break, continue or return and find the compiler rejects them.
The Missing Statements
EK9 does not have:
- ❌ No
breakstatement - you cannot exit a loop early (E01070) - ❌ No
continuestatement - you cannot skip to the next iteration (E01071) - ❌ No
returnstatement - you cannot exit a function or method early (E01072) - ❌ No switch fallthrough - each case ends by itself
They are not an oversight. The grammar (EK9.g4) recognises these words only to reject them with
their own error; no statement uses them.
Why They're Gone: The Evidence
Each of them is a jump out of the structured flow of a program, and jumps that compile cleanly and do the wrong
thing have a long public record: Dijkstra's "Go To Statement Considered Harmful", the CERT and CWE entries on
a forgotten break, the Linux kernel's fall-through audit and Apple's goto fail.
Design note: why EK9 has no break, continue or return sets
out the record and the argument.
EK9's Alternatives
Each statement EK9 leaves out has a replacement that states its intent and that the compiler can check.
1. Stream Pipelines Replace break/continue
| Traditional (break/continue) | EK9 (Stream Pipeline) |
|---|---|
|
Fails with E01070: 'break' does not exist in EK9 - use stream pipelines with 'head' or guards found <- false for item in items if startsWithA(item) found: true break stdout.println(found) Full source: flowControl_break.ek9 - Download flowControl_break.ek9 |
firstMatch <- cat items | filter by startsWithA | head 1 | collect as List of String found <- not firstMatch.empty() stdout.println(found) Full source: flowControl_first_match.ek9 - Download flowControl_first_match.ek9 |
Why better: the intent is explicit (head 1 means "take the first match"), no loop state
changes, and you cannot break out of the wrong loop.
2. Guards Replace Early Returns
| Early return (rejected) | EK9 (Guard) |
|---|---|
|
Fails with E01072: 'return' does not exist in EK9 - declare return values with '<-' and compiler ensures all paths initialize #!ek9 defines module bad.site.functions.noreturn.keyword defines function sign() as pure -> amount as Integer <- rtn as String? if amount < 0 return "negative" return "not negative" |
//A guard replaces the early return: the body runs only when lookup gives a set value, //otherwise the initial value stands. countryName() as pure -> code as String <- rtn as String: "unknown" if name <- lookup(code) rtn: name Full source: functions_no_return.ek9 - Download functions_no_return.ek9 |
Why better: the compiler checks that the returning value is given a value on every path, so you cannot leave without setting it.
3. Multiple Case Values Replace Switch Fallthrough
| Traditional (fallthrough) | EK9 (Multiple Values) |
|---|---|
switch day
case MONDAY:
case TUESDAY:
case WEDNESDAY:
type = "weekday"
break // ❌ Forget this = fallthrough bug
case SATURDAY:
case SUNDAY:
type = "weekend"
break
|
switch day case Day.MONDAY, Day.TUESDAY, Day.WEDNESDAY, Day.THURSDAY, Day.FRIDAY dayType: "weekday" case Day.SATURDAY, Day.SUNDAY dayType: "weekend" default dayType: "unknown" // Automatic break - can't fall through Full source: flowControl_day_type.ek9 - Download flowControl_day_type.ek9 |
Why better: the values that share a case are listed together, nothing can fall through by accident, and there is less to write.
4. Return Value Declarations Replace return Statements
You declare the returning value in the signature and assign to it in the body. The compiler checks that every path gives it a value (Returning a value: there is no return).
//No initial value: the compiler proves that every path gives it one. sign() as pure -> amount as Integer <- rtn as String? if amount < 0 rtn: "negative" else if amount > 0 rtn: "positive" else rtn: "zero"
Full source: functions_no_return.ek9 - Download functions_no_return.ek9
A path that leaves the returning variable without a value is an error:
Fails with E08050: return value is not always initialised
#!ek9 defines module bad.site.functions.noreturn.missedpath defines function sign() as pure -> amount as Integer <- rtn as String? if amount < 0 rtn: "negative" else if amount > 0 rtn: "positive"
Download functions_no_return_missed_path.ek9
The value can come from a switch, which returns its value through its own <- variable (multiple case values, no fallthrough):
//A switch returns a value through its own '<-' variable. dayKind() as pure -> day as String <- rtn as String: switch day <- kind as String? case "Saturday", "Sunday" kind: "weekend" default kind: "weekday"
Full source: functions_no_return.ek9 - Download functions_no_return.ek9
A stream pipeline replaces the loop that would need continue or break:
//A stream replaces a loop with 'continue' or 'break': 'filter' skips items ('head n' would stop //early), and '>' adds each Integer into the returning variable. sumOfPositives() as pure -> amounts as List of Integer <- rtn as Integer: 0 cat amounts | filter by isPositive > rtn
Full source: functions_no_return.ek9 - Download functions_no_return.ek9
Why better: you cannot forget to return, or return the wrong value by leaving early; the compiler proves every path sets the returning value.
What This Means For You
Getting Started
- At first you will type
breakorreturnand get a compiler error. Compile with the-E3flag for detailed guidance, and read its suggestions. - Then you start to think in streams and guards, and stop writing "wrong loop" and "forgot to return" bugs.
- When it is natural, you rely on the compiler proving that a function sets its returning value on every path.
You do not lose capability: every pattern you used break, continue or
return for has a more explicit alternative that the compiler can check.
Tip: when you want to "break early", ask what you are actually looking for. Then say so with stream
operations: filter or reject to choose, skip to pass over, and
head to stop.
Why EK9 does it this way. Removing a statement removes a whole category of bug by construction, not by
measurement: there is no break to put in the wrong loop, no case that can fall through
(the CERT MSC17-C / CWE-484 defect cannot be written), no path that forgets its returning value, and no
goto fail. The aim is code with fewer bugs whose reviews go faster, because the control flow is
explicit and cannot be misread. Swift, Go, Rust and Kotlin already have no implicit switch fallthrough; EK9
goes further - if it's dangerous, it doesn't exist.
See Also
- Guard Operator Quick Reference - choosing a guard instead of an early return
- Streams and Pipelines - streams instead of break/continue
- Code Quality - the other guarantees EK9 enforces at compile time
- Blog: Why No Early Returns - the technical article
The Controls
With streams and the EK9 operators you need traditional flow control much less, though some cases still require it. Each control below starts with its simplest form and then shows what EK9 adds.
Why EK9 does it this way. EK9 favours a more functional approach to flow control, in the view that it lowers the developer's cognitive load, is simpler and more flexible, and gives less code, more reuse and fewer defects.
EK9 Guard System Quick Reference
One guard system removes the need for hand-written null checks and shortens null-safety code. Each guard does several checks for you, in place of the five to seven lines in the examples below, and the compiler generates the checks with short-circuiting.
| Operator | Name | Checks | Semantics | Example |
|---|---|---|---|---|
| <- | Declaration guard | ✅ Null check ✅ isSet check |
Declare a NEW variable, assign, enter only if it is SET | if name <- getName() |
| :=? | Assign-if-unset | ✅ isSet check (target, before) ✅ Lazy evaluation ✅ isSet check (target, after) |
Fill the variable only if it is unset - the expression is not even evaluated otherwise. The body runs if the variable is SET afterwards (a variable that already had a value keeps it and enters the body) | if cache :=? compute() |
| := then x? | Assignment, then a set-check | ✅ isSet check (written out) | := is a blind assignment: it ALWAYS assigns (an unset result replaces the old value) and checks nothing, so an if needs its own control (E07340 without one). then x? re-points the variable and enters only if the new value is SET |
if fetched := fetch() then fetched? |
The names - one per symbol, used everywhere in EK9:
x?is the set-check.<-is a declaration; in a head it is a declaration guard.:=is an assignment.:=?is assign-if-unset: it fills an unset target.?:is coalesce (Elvis);<?and>?are comparison coalescing.
A guard means "enter only if set", like Swift's if let. There is no ?= operator:
an assignment never yields a value.
Key Insight: the same guard syntax works in if, switch, for, while,
do-while and try, in both statement and expression form. Learn the pattern once and
apply it everywhere. In expression form the guard gates the construct: if it skips the body, the result is the
returning variable's initial value (the initialiser is the guard's "else"). So the returning variable must
be initialised (<- rtn as T: default), or the compiler stops you with
E08050.
A guard over a Boolean tests its truth. In an if or else if, a <-
or :=? head whose variable is declared Boolean enters only when the variable is set
and true. if allowed <- permitted(user) skips the body when permitted returns
false, as it does when the result is unset, so no then allowed is needed.
- A
switchkeeps enter-if-set, because itscase falsemust stay reachable. So do loops,try, and types that only promote to Boolean. - In the later
else ifandelseblocks a<-guard's variable is unset (for a Boolean, unset or false). Reading a non-Boolean one there before giving it a new value is E08270.
Outside a head an assignment never yields a value, so assign and then check:
- To know whether a new value is set:
fetched := fetch()thenok <- fetched?. - To keep the old value when the new one is unset:
fetched := fetch() ?: fetched. - In a
switch,for,tryordo, which have nothen x?gate: assign first and wrap the construct inif fetched?, or use<-when the variable is only needed inside. - Parenthesise a further condition after
then x?, as inthen x? and (x > 10), becauseorbinds looser thanand:then x? and a or bis(x? and a) or b.
:=? on a variable that is always set is rejected (E08095): it can
never assign. :=? always leaves an object in its target - present, maybe unset, never absent - so
where it always runs it initialises a variable declared with no value.
Quick Reference: Choosing the Right Guard Operator
Follow this decision tree:
┌─ First time using this variable? │ └─ YES → Use `<-` (declaration guard) │ • Declares NEW variable │ • Checks if value is SET │ • Example: if name <- getName() │ │ └─ NO → Variable already exists... │ ┌─ Is variable already set with value you want to keep? │ │ (e.g., lazy cache, don't re-compute if already has value) │ │ └─ YES → Use `:=?` (assign-if-unset) │ │ • Checks LEFT side first (is target unset?) │ │ • LAZY EVALUATION - only calls expression if left unset │ │ • Perfect for caching/lazy init patterns │ │ • Example: if cache :=? compute() with cache > 100 │ │ │ └─ NO → Need to update/replace value... │ ┌─ Need to verify new value is valid before using? │ │ (e.g., fetching data that might fail) │ │ └─ YES → Use `:=` then `x?` (assignment, then a set-check) │ │ • Evaluates expression and assigns it ALWAYS │ │ (an unset result replaces the old value) │ │ • Body runs only if the result is SET │ │ • Example: if fetched := fetch() then fetched? and (fetched.isValid()) │ │ │ └─ NO → Use `:=` (blind assignment) │ • No set-check of its own │ • Use when an explicit condition decides │ • Example: if count := getCount() then count > 0
| Scenario | Operator | When to Use |
|---|---|---|
| First use of variable | <- |
Declaration + automatic guard check |
| Lazy cache pattern | :=? |
Don't re-evaluate if already set + guard check |
| Update with validation | := then x? |
Always evaluate and assign, then the explicit set-check |
| Known safe value | := |
No guard (value certain or explicit condition validates) |
Key Principle: use a guard (<-, :=?) whenever you need to check that a value
is SET. Use := (blind assignment) with an explicit condition - then x? when the condition
is that the new value is set.
Why EK9 does it this way. Other languages each give part of this:
- Java:
Optionalchains with.map().filter().orElse() - Kotlin: null-safe operators, but no guard that works the same in every control
- Rust: pattern matching with
if let Some(x) = expr { } - Swift:
if letandguardstatements
EK9 combines them into one system that works the same in every control flow construct. The result is far less guard code than the Java equivalent (see the line counts below), and the checks are written by the compiler, so none can be forgotten.
What EK9 Guards Replace
Each example compares null-safe code in a traditional language with the one line of EK9 that replaces it.
Example 1: Declaration Guard (<-)
| Traditional Java/C++ (3 levels of nesting) | EK9 (1 line) |
|---|---|
value = getOptional();
if (value != null && value.isSet()) {
if (value > 10) {
// body
}
}
|
if value <- getOptional() with value > THRESHOLD stdout.println(`declared ${value}`) Full source: flowControl_guard_forms.ek9 - Download flowControl_guard_forms.ek9 |
Savings: 5 lines → 1 line, 3 levels → 1 level, and the checks are made by the compiler.
Example 2: Re-point and check (:= then x?)
| Traditional (3 levels of nesting) | EK9 (1 line) |
|---|---|
existing = fetchData();
if (existing != null && existing.isSet()) {
if (existing.isValid()) {
// body
}
}
|
if existing := fetchData() then existing? and (existing.isValid()) stdout.println("fetched a valid record") Full source: flowControl_guard_forms.ek9 - Download flowControl_guard_forms.ek9 |
Savings: 5 lines → 1 line, 3 levels → 1 level.
Example 3: Assign-if-unset (:=?)
| Traditional (2 levels of nesting) | EK9 (1 line) |
|---|---|
if (cache == null || !cache.isSet()) {
cache = compute();
}
if (cache != null && cache.isSet()) {
if (cache > 100) {
// body
}
}
|
if cache :=? compute() with cache > CACHE_LIMIT stdout.println(`cached ${cache}`) Full source: flowControl_guard_forms.ek9 - Download flowControl_guard_forms.ek9 |
Savings: 7 lines → 1 line, 2 levels → 1 level.
Bonus: lazy evaluation - compute() is only called if cache is unset.
Guard Operator Mechanics - What Happens Under the Hood
These are the steps the compiler generates for each guard; you write none of them.
Declaration Guard <- with Condition
if value <- getOptional() with value > THRESHOLD stdout.println(`declared ${value}`)
Full source: flowControl_guard_forms.ek9 - Download flowControl_guard_forms.ek9
Compiler-Generated Steps:
- Call
getOptional() - Assign result to
value - NULL CHECK: Is
valuenull? → If yes, skip to else - isSet CHECK: Call
value._isSet()→ If false, skip to else - Short-circuit AND: Evaluate
value > THRESHOLDONLY if isSet was true - Execute body ONLY if all checks passed
Assignment := then a set-check, with Condition
if existing := fetchData() then existing? and (existing.isValid()) stdout.println("fetched a valid record")
Full source: flowControl_guard_forms.ek9 - Download flowControl_guard_forms.ek9
Compiler-Generated Steps:
- Call
fetchData() - ALWAYS assign:
existing = result- an unset result replaces whateverexistingheld - isSet CHECK (written out as
existing?): Callexisting._isSet()→ If false, skip to else - Short-circuit AND: Evaluate
existing.isValid()ONLY ifexistingis set - Execute body ONLY if all checks passed
Assign-if-unset :=? with Condition (Most Complex)
if cache :=? compute() with cache > CACHE_LIMIT stdout.println(`cached ${cache}`)
Full source: flowControl_guard_forms.ek9 - Download flowControl_guard_forms.ek9
Compiler-Generated Steps:
- LEFT SIDE CHECK: Is
cacheunset? → If it is already SET, keep its value and go straight to step 4 - LAZY EVALUATION: ONLY NOW call
compute() - ASSIGN:
cache = result - isSet CHECK: Call
cache._isSet()→ If false, skip to else - Short-circuit AND: Evaluate
cache > CACHE_LIMITONLY ifcacheis set - Execute body ONLY if all checks passed - a cache that already had a value enters the body with it
Note: compute() is never called if cache already has a value. That lazy
evaluation matters for expensive operations.
Key Benefits of EK9 Guards:
- Less code: one line in place of the five to seven lines in the examples above
- Compile-time safety: the compiler writes every check, and none can be bypassed
- No extra runtime cost: the checks are the ones you would otherwise write by hand
- AI-friendly: less code for an AI tool to read and generate
- Lower cognitive load: one pattern to learn, applied everywhere
- No forgotten checks: there are no null checks to forget, because the guard makes them
See also: tri-state semantics, which guards are built on, and the examples throughout this page.
The traditional flow control falls into three groups:
Branching
- If, Else If ,Else - conditional processing based on a boolean expression
- Switch - conditional processing based on a variable's value
- Try - see Exceptions; the flow leaves the current scope block (and should be 'exceptional').
Looping (imperative/procedural)
- For - the traditional for loop, with extras such as collections and iterators
- While - the traditional while loop with a boolean control
- Do While - the traditional do/while loop with a boolean control
Streaming (more functional)
Most of the processing above can also be written in this more functional way.
- For - pipes the values a for loop generates into a stream
- Cat - pipes each element of a collection into a stream
Control examples
Require - Hard Contracts
Before the branching and looping constructs, understand require. It enforces the conditions a
program needs in order to be correct.
Unlike Java, where assertions can be disabled, EK9 requirements are hard requirements. They are
part of the language's safety model and are never removed from production code: every
require generates an instruction that always executes.
Use require for "Design by Contract": pre-conditions, post-conditions and invariants. A failed
requirement halts execution, so the program cannot continue in an invalid state; it cannot be caught.
#!ek9 defines module site.flowControl.require defines function calculateSqrt() -> radicand as Float <- rtn as Float? //This is a pre-condition contract. The function cannot accept a negative value. require radicand >= 0.0 //... function logic rtn: sqrt radicand
Download flowControl_require.ek9
If statements
Before you write an if, look at ternary operators and assign-if-unset; they are often terser. The contrast of if/else and ternary operators covers the common cases, especially variables that may be un-set. When you do need an if, EK9 adds features that give you more control.
Simple If statements
The 'if/else' in its simplest form.
#!ek9 defines module site.flowControl.ifelse defines constant UPPER_LIMIT <- 21 LOWER_LIMIT <- 10 defines function testIfElseInteger() -> first as Integer trigger <- false if first > UPPER_LIMIT trigger: true else if first < LOWER_LIMIT trigger: false else trigger: Boolean() //Just ensure referenced require trigger? testIfElseIntegerAsPure() as pure -> first as Integer trigger <- Boolean() if first > UPPER_LIMIT trigger :=? true else if first < LOWER_LIMIT trigger :=? false //Just ensure referenced require trigger?
Download flowControl_if_else.ek9
In testIfElseInteger the value of trigger depends on the value of first.
testIfElseIntegerAsPure shows the pure keyword, which is stricter about reusing variables and assigning to them. :=? assigns only when the left-hand side is un-set.
Range
A kind of if statement with one specific use: checking whether a value is, or is not, within a range. It is terse for simple cases.
#!ek9 defines module site.flowControl.rangecheck defines constant LIMIT <- 16 OFFSET <- 2 defines function kotlinLikeRangeCheck() -> candidate as Integer lowerBound as Integer upperBound as Integer stdout <- Stdout() isWithin <- candidate in lowerBound ... upperBound stdout.println(`${candidate} is within a range of ${lowerBound} ... ${upperBound}: ${isWithin}`) isNotWithin <- candidate not in LIMIT - OFFSET ... upperBound - 1 stdout.println(`${candidate} is NOT within a range of ${LIMIT - OFFSET} ... ${upperBound - 1}: ${isNotWithin}`)
Download flowControl_range_check.ek9
The start and end of the range can be calculated values or expressions. The check is similar to a ternary or a very simple if expression; where the conditions are more complex, EK9 uses switch.
If statement with boolean logic
Combined boolean logic in an if statement, with alternative syntax and layout.
#!ek9 defines module site.flowControl.simpleif defines constant LOW_LIMIT <- 10 HIGH_LIMIT <- 19 TARGET <- 9 defines function currentTemperature() as pure -> country as String <- temperature as Integer: Integer() if country == "GB" temperature := 20 else if country == "DE" temperature := 41 simpleIf() -> valueToTest as Integer stdout <- Stdout() suitableValue <- String() if valueToTest < LOW_LIMIT stdout.println(`Value ${valueToTest} is less than ${LOW_LIMIT}`) secondValue <- 21 specialCondition <- true if valueToTest < LOW_LIMIT and secondValue > HIGH_LIMIT or specialCondition stdout.println("Test OK") //The same logic as above - but in a different layout. if valueToTest < LOW_LIMIT and secondValue > HIGH_LIMIT or specialCondition stdout.println("Test OK") //Rather than use the keyword 'if' you can use 'when' when valueToTest < LOW_LIMIT stdout.println(`Value ${valueToTest} is less than ${LOW_LIMIT}`) //As you would expect it is possible to chain if and else statements if valueToTest > TARGET suitableValue: "Too High" else if valueToTest < TARGET suitableValue: "Too Low" else suitableValue: "Just Right" stdout.println(suitableValue)
Download flowControl_simple_if.ek9
If statement with assignment/guard and declaration
Like the switch (see later), the if statement takes a guard, an assignment or a declaration as part of its structure.
#!ek9 defines module site.flowControl.assignmentinif defines constant HOT_LIMIT <- 50 WARM_LIMIT <- 40 UNKNOWN_WARMTH <- "Not sure where it might be warm" defines function currentTemperature() as pure -> country as String <- temperature as Integer: Integer() if country == "GB" temperature := 20 else if country == "DE" temperature := 41 assignmentInIf() stdout <- Stdout() //Note that this value is 'unset' in the sense it has no meaningful value selectedTemperature <- Integer() //See if we can find some hot temperature somewhere so in the US we have no idea! because value is unset when selectedTemperature := currentTemperature("US") with selectedTemperature > HOT_LIMIT stdout.println(`Temp of ${selectedTemperature} a little warm in the US`) else when selectedTemperature := currentTemperature("US") with selectedTemperature < HOT_LIMIT stdout.println(`Temp of ${selectedTemperature} not too bad in the US`) else when selectedTemperature := currentTemperature("GB") with selectedTemperature > WARM_LIMIT stdout.println(`Temp of ${selectedTemperature} a little warm in the UK`) else when selectedTemperature := currentTemperature("DE") with selectedTemperature > WARM_LIMIT stdout.println(`Temp of ${selectedTemperature} a little warm in the DE`) else stdout.println(UNKNOWN_WARMTH) assignAndCheckInIf() stdout <- Stdout() selectedTemperature <- Integer() //Here ':=' assigns, 'selectedTemperature?' checks for unset and only then does the further condition run //Also note we can still use 'if' and rather than 'with' use 'then' when selectedTemperature := currentTemperature("US") then selectedTemperature? and (selectedTemperature > HOT_LIMIT) stdout.println(`Temp of ${selectedTemperature} a little warm in the US`) else when selectedTemperature := currentTemperature("US") then selectedTemperature? and (selectedTemperature < HOT_LIMIT) stdout.println(`Temp of ${selectedTemperature} not too bad in the US`) else if selectedTemperature := currentTemperature("GB") then selectedTemperature? and (selectedTemperature > WARM_LIMIT) stdout.println(`Temp of ${selectedTemperature} a little warm in the UK`) else when selectedTemperature := currentTemperature("DE") then selectedTemperature? and (selectedTemperature > WARM_LIMIT) stdout.println(`Temp of ${selectedTemperature} a little warm in the DE`) else stdout.println(UNKNOWN_WARMTH) declarationInIf() stdout <- Stdout() when selectedTemperature <- currentTemperature("US") with selectedTemperature > HOT_LIMIT stdout.println(`Temp of ${selectedTemperature} a little warm in the US`) else when selectedTemperature := currentTemperature("US") then selectedTemperature? and (selectedTemperature < HOT_LIMIT) stdout.println(`Temp of ${selectedTemperature} not too bad in the US`) else if selectedTemperature := currentTemperature("GB") with selectedTemperature > WARM_LIMIT stdout.println(`Temp of ${selectedTemperature} a little warm in the UK`) else when selectedTemperature: currentTemperature("DE") then selectedTemperature > WARM_LIMIT stdout.println(`Temp of ${selectedTemperature} a little warm in the DE`) else stdout.println(UNKNOWN_WARMTH)
Download flowControl_assignment_in_if.ek9
If statement summary
The examples show:
- when in place of if - developer choice
- an assignment := as part of the if statement
- an assignment := followed by the set-check then x? as part of the if statement
- a variable declaration <- as part of the if statement
- the function currentTemperature called only when needed, in an else if
- all the conditions of an if statement ideally on the same line
- where an if statement needs several lines, the lines aligned as above
Note: a variable declared in an if or else if is in scope in the later else if and else blocks (it belongs to the main if scope block). Be sure you want this: it can be confusing and hard to refactor later.
The ':=?' guard, the 'x?' set-check and '<-' need the variable's type to have the '?' is-set operator, so that the guard can evaluate whether the result is set.
An assignment within the if or else if means fewer variables to create and assign before the
if block. With a guard, the condition part is not evaluated if the guard's variable is
isSet == false. A guard over a Boolean (if allowed <- permitted()) enters only when
the value is set and true.
A declaration, assignment or assign-if-unset often reads better with when than with if, but that is your choice. Ternary operators, assignments and guards add a lot to conditional flow control, and the switch statement adds more. Guards are most useful with methods and functions that may return un-set values, because they keep the extra checks simple.
When there are many conditions to consider, use the switch statement/expression.
Switch
The EK9 switch is in some ways like if/else: the order of the cases matters. It supports multiple and varied matches, can return a value like an expression, and, like if, has alternative keywords.
- Over an enumeration it works like a traditional switch, and every enumerated value must have its own case (E07310).
- A switch statement can also be a switch expression that returns a value.
- There must always be a default case (E07320 for a switch statement, E07330 for a switch expression).
Switch on Enumerated Values
This is closest to a switch statement in other languages, but with strict semantics. When the case statements refer directly to enumerated values, all the enumerated values must have a case. A default is still needed for when the value being switched over is un-set.
The example is in 'expression' form; the switch can also be a 'statement'.
defines type <?- This is the enumeration that is used in the example below. It is finite in the number of values it can hold. -?> LimitedEnum A, B, C, D defines function SimpleExhaustiveEnumerationExpressionSwitch() -> selection as LimitedEnum result <- switch selection <- rtn as String? case LimitedEnum.A rtn: "Just A" case LimitedEnum.B rtn: "Just B" case LimitedEnum.C rtn: "Just C" case LimitedEnum.D rtn: "Just D" default rtn: "Selection is not set" require result?
Full source: flowControl_enum_switch.ek9 - Download flowControl_enum_switch.ek9
The example defines a simple 'Enumerated type', LimitedEnum, and a switch expression compares the variable 'selection' with each of its values, returning a value from each case.
Used this way the switch is exhaustive: the compiler reports an error if an enumerated value or the default is missing.
Why EK9 does it this way. This may seem too 'strict', but it catches a common error. When a value is added to the 'Enumerated type', existing code stops compiling, and that forces the developer to assess the processing for the new value.
If you do not want to handle each enumerated value explicitly, make one case an expression:
defines function SimpleNonExhaustiveEnumerationExpressionSwitch() -> selection as LimitedEnum result <- switch selection <- rtn as String? case == LimitedEnum.A rtn: "Just A" default rtn: "Selection is anything but A" require result?
Full source: flowControl_enum_switch.ek9 - Download flowControl_enum_switch.ek9
A case using the == (equality) operator turns off the exhaustive check. Values added to the 'Enumerated type' later go to the default block. This is the less strict switch of many other languages.
Switch form similar to an if expression
A switch expression can be used like an 'if expression':
#!ek9 defines module site.flowControl.giventernary defines function SimpleSwitchExampleWithTernaryAsAnAlternative() -> conditionVariable as Boolean //This is the reason that an ifExpression is not needed. resultValue <- given conditionVariable <- result String? when true result: "Steve" default result: "Not Steve" require resultValue? //But above could just have been a ternary resultValue2 <- conditionVariable <- "Steve" else "Not Steve" require resultValue2?
Download flowControl_given_ternary.ek9
This is why EK9 has no 'if expression': the switch used as an expression does everything one would. For an expression this simple a 'ternary' (also shown) would do. When the 'when true' or default blocks span several lines a ternary cannot be used, and the switch expression is the right choice.
Switch in statement form
A simple switch statement with expressions in its case statements, and the alternative syntax given/when in place of switch/case. It also uses the 'pure' syntax, in which variables are only assigned to when unset (the :=? syntax).
#!ek9 defines module site.flowControl.switchstatement defines function currentTemperature() as pure -> country as String <- temperature as Integer: Integer() if country == "GB" temperature := 20 else if country == "DE" temperature := 41 ASimpleSwitchStatement() as pure -> conditionVariable as Integer multiplier <- 5 //This is what we will vary based on the condition variable resultText1 <- String() switch conditionVariable case < 12 resultText1 :=? "Moderate" case > 10*multiplier resultText1 :=? "Very High" case 25, 26, 27 resultText1 :=? "Slightly High" case currentTemperature("GB"), 21, 22, 23, 24 resultText1 :=? "Perfect" default resultText1 :=? "Not Suitable" require resultText1? resultText2 <- String() //The same switch could have been written using given and when given conditionVariable when < 12 resultText2 :=? "Moderate" when > 10*multiplier resultText2 :=? "Very High" when 25, 26, 27 resultText2 :=? "Slightly High" when currentTemperature("GB"), 21, 22, 23, 24 resultText2 :=? "Perfect" default resultText2 :=? "Not Suitable" require resultText2?
Download flowControl_switch_statement.ek9
A case can match much more than single values, so the order of the cases matters: EK9 takes only the first match. Functions used in a match must have no side effects.
There is no break or yield, and no case 'fall-through'; a case takes several conditions instead.
The EK9 switch has many forms; take care with it, and choose the simplest matching you can.
Switch in expression form
A function in 'pure' form again, this time with the switch returning a value as an expression. A switch expression suits 'pure' code, where a variable is assigned only once. The example also shows more case conditions.
#!ek9 defines module site.flowControl.switchexpression defines function ASwitchAsExpression() as pure -> conditionVariable as String //This is more like a chained if/else with expressions in the <i>case</i> resultText <- switch conditionVariable <- result String? case 'D' result :=? "Inappropriate" case matches /[nN]ame/ result :=? "Perfect" case > "Gandalf" result :=? "Moderate" case < "Charlie" result :=? "Very High" default result :=? "Suitable" require resultText?
Download flowControl_switch_expression.ek9
In ASwitchAsExpression the switch declares a new variable, resultText, and initialises it from the return value of the switch.
String values can be matched by lexical comparison and by regular expression. Again, focus on the order of the cases. In other languages a switch usually matches single values; EK9's range matching does more, but needs more focus to get right.
Switch with a guard/assignment/declaration condition
A guard is like an enclosing 'if statement'. In this example 'temperature' starts 'un-set', so ':=?' (assign-if-unset) fills it from 'currentTemperature'. If that returns 'un-set', 'temperature' stays 'un-set', the switch expression is not evaluated at all, and 'resultText' takes the switch's returning value's initial value (here '<- result String: String()', an un-set String) — the initialiser is the guard's "else". Because a guard can skip the body, the returning value must have an initial value; without one the compiler reports E08050 (return value is not always initialised).
The assignment and declaration forms always process the switch; the declaration is made inside the switch scope.
#!ek9 defines module site.flowControl.switchguards defines constant MODERATE <- "Moderate" VERY_HIGH <- "Very High" SLIGHTLY_HIGH <- "Slightly High" PERFECT <- "Perfect" SUITABLE <- "Suitable" defines function currentTemperature() as pure -> country as String <- temperature as Integer: Integer() if country == "GB" temperature := 20 else if country == "DE" temperature := 41 ASwitchWithGuard() temperature <- Integer() multiplier <- 5 resultText <- switch temperature :=? currentTemperature("GB") with temperature <- result String: String() case < 12 result: MODERATE case > 10*multiplier result: VERY_HIGH case 25, 26, 27 result: SLIGHTLY_HIGH case 21, 22, 23, 24 result: PERFECT default result: SUITABLE require resultText? ASwitchWithAssignment() -> multiplier as Integer temperature <- Integer() resultText <- switch temperature := currentTemperature("GB") with temperature <- result String: String() case < 12 result: MODERATE case > 10*multiplier result: VERY_HIGH case 25, 26, 27 result: SLIGHTLY_HIGH case 21, 22, 23, 24 result: PERFECT default result: SUITABLE require resultText? ASwitchWithDeclaration() -> multiplier as Integer resultText <- switch temperature <- currentTemperature("GB") with temperature <- result String: String() case < 12 result: MODERATE case > 10*multiplier result: VERY_HIGH case 25, 26, 27 result: SLIGHTLY_HIGH case 21, 22, 23, 24 result: PERFECT default result: SUITABLE require resultText? //So this means the 'temperature' defined above is now out of scope and so //can be declared a-fresh and used here temperature <- "Some other value" require temperature?
Download flowControl_switch_guards.ek9
To repeat: an EK9 switch must have a default, and there is no fall through (so no break keyword as in C/C++/C#/Java). Several values per case do the job of fall through. The EK9 switch is much more like an if/else chain than a C, C++ or Java switch.
Try Catch and Exceptions
Try, catch and exceptions are flow control for an error state. Do not use them to control normal flow. An Exception is a form of GOTO (as are break and return, in a slightly more controlled way). See Exceptions/Error Handling for more details.
Why EK9 does it this way. Many languages use exceptions widely, and they spread through every API and force changes to interfaces. EK9 prefers unSet values, Result, and careful, selective use of exceptions. So EK9 code is less likely to throw and more likely to return a value that is unSet or a Result, and the caller must be ready for values that are not set. Throw only in exceptional circumstances, never as a design mechanism for flow control.
For Loops - but with extras
Use a loop to perform the same operations over a set of data. EK9 has no break and no return statement, so you cannot exit a for loop early; that is by design (see the EK9 philosophy).
A for loop runs from end to end. To stop part way through, use a while loop, a streaming for loop or streaming collections.
Standard For Loop
A standard loop over an integer variable:
- The loop variable type is inferred
- '...' gives an inclusive range
- You can give the increment
- It is not limited to Integers: any type with the appropriate operators can be used
#!ek9 defines module site.flowControl.forloops defines function ForLoopExample1 stdout <- Stdout() for i in 1 ... 10 stdout.println(`Value [${i}]`) ForLoopExample2 stdout <- Stdout() for i in 0 ... 10 by 2 stdout.println(`Value [${i}]`) ForLoopExample3 stdout <- Stdout() j <- 2 for i in 0 ... 10 by j stdout.println(`Value [${i}]`) ForLoopExample4 stdout <- Stdout() j <- -2 for i in 10 ... 0 by j stdout.println(`Value [${i}]`) ForLoopAsExpression() result <- for i in 1 ... 10 <- rtn <- 0 rtn += i require result?
Download flowControl_for_loops.ek9
The spaces around '...' are required.
A for loop can also be an expression that returns a value, as in the function 'ForLoopAsExpression'.
For Loop with other types
The for loop works with any type, your own included, that provides the comparison, addition and subtraction operators it needs.
#!ek9 defines module site.flowControl.forfloattime defines function FloatForLoop() stdout <- Stdout() //So you could do a calculation to get this value incrementer <- 6.3 for i in 8.2 ... 30.0 by incrementer stdout.println(`Value [${i}]`) //descending for i in 90.0 ... 70.0 by -5.0 stdout.println(`Value [${i}]`) TimeForLoop() stdout <- Stdout() //From 9-5:30 every half hour start <- Time().startOfDay() + PT9H end <- Time().endOfDay() - PT6H30M thirtyMinutes <- PT30M //Be aware that time loops around. for i in start ... end by thirtyMinutes stdout.println(`Value [${i}]`)
Download flowControl_for_float_time.ek9
So the EK9 for loop:
- can be used with different types
- can count up or down
- infers the loop variable's type; each loop variable is a new copy, so changing it in the body does not change the loop
- takes variables for the start and end of the range and for the increment
- works with your own constructs when they have the right operators
- creates new values of the range's declared type: the counter is a copy of the start and each loop
variable a copy of the counter, so your own range type needs a public copy constructor and must be
concrete (E08272).
first as BaseStep: TenStep(1)ranges over BaseStep and produces BaseSteps - can be used as an expression, returning a value
For Loop with collections
Unlike some object-oriented languages, EK9 does not attach for loop or stream syntax to Collection objects. It traverses the objects in a Collection in two complementary ways: the first is below, the second is streaming collections.
#!ek9 defines module site.flowControl.forcollections defines function CollectionsForLoop() stdout <- Stdout() stdout.println("Strings") for item in ["Alpha", "Beta", "Charlie"] stdout.println(item) stdout.println("Characters") //The alternative is when you already have an iterator moreItems <- ['A', 'B', 'C'] for item in moreItems.iterator() stdout.println(item)
Download flowControl_for_collections.ek9
The loop variable's type is inferred from the Collection or Iterator. The for loop works with any type that has an iterator() method returning an Iterator, or (a 'Duck Type' approach) with any object whose type has these two methods:
- hasNext() - returning a Boolean
- next() - returning a type other than Void
The example loops over a List of String and over the Iterator of a List of Characters.
For Loop assignments, guards and declarations
As with if and switch, a for loop can take an 'assignment', a 'guard' or a 'declaration', which keeps it consistent with the rest of EK9's flow control:
#!ek9 defines module site.flowControl.forguards defines function messagePrefix() <- rtn <- "SomePrefix-" forLoopWithGuard() stdout <- Stdout() prefix as String? for prefix :=? messagePrefix() then i in 1 ... 10 stdout.println(`${prefix}${i}`) forLoopWithAssignment() stdout <- Stdout() prefix as String? for prefix: messagePrefix() then i in 1 ... 10 stdout.println(`${prefix}${i}`) forLoopWithDeclaration() stdout <- Stdout() for prefix <- messagePrefix() then i in 1 ... 10 stdout.println(`${prefix}${i}`) //Just to highlight that 'prefix' in the for loop - was scoped in the for loop only. prefix <- 21 require prefix?
Download flowControl_for_guards.ek9
Streaming 'For'
The values a for loop generates can feed a processing pipeline, here with some filtering.
#!ek9 defines module site.flowControl.timepipeline defines constant LUNCH_START <- 12:00 LUNCH_END <- 14:00 defines function workHours() as pure -> time as Time <- workTime as Boolean: time < LUNCH_START or time > LUNCH_END defines program TimePipeLine() stdout <- Stdout() //From 9-5:30 every half hour start <- Time().startOfDay() + PT9H end <- Time().endOfDay() - PT6H30M thirtyMinutes <- PT30M for i in start ... end by thirtyMinutes | filter by workHours > stdout
Download flowControl_time_pipeline.ek9
Running the program 'TimePipeLine' prints the working half hours:
09:00:00 09:30:00 10:00:00 10:30:00 11:00:00 11:30:00 14:30:00 15:00:00 15:30:00 16:00:00 16:30:00 17:00:00
The for loop generates a sequence of times and pipes them into a pipeline, where the function 'workHours' filters them: only a time for which it returns 'true' goes on to the next stage. So 'stdout' gets only the times before 12:00 and after 14:00.
Without break and return you un-bundle nested loops (always tricky) into pipelines. The logic you might have nested in the for loop moves into a function that you can test, reuse and change easily.
This takes a little time to get used to. Where you would have used an if statement for the working hours, there is a boolean expression (a predicate) in the function 'workHours'. You still write the same expression, plus the function around it, which is more text than an 'if'. But the code has moved from procedural to declarative.
head cuts the stream short: once it has the number of objects you asked for, it shuts the pipeline down, much like break or return. To find a particular value in a collection or a generated list, use a filter to select it and head to stop once one matching value comes through. The value may never come through, if it is not there.
Why EK9 does it this way. The Streaming 'For' is the main reason EK9 has no List Comprehensions. EK9 needs more code than a Python list comprehension, because of the functions, but the code is more readable and fits the EK9 philosophy. It also standardises functional processing: unlike Java, where specific methods are attached to specific 'Functional types', the functional processing syntax is built into the language itself.
Streaming Collections
As with the streaming for, you can send the contents of a collection through a pipeline rather than loop over it.
#!ek9 defines module site.flowControl.collectionspipeline defines program collectionsPipeLine() stdout <- Stdout() items <- ["Alpha", "Beta", "Charlie"] cat items > stdout //The alternative is when you already have an iterator moreItems <- ['A', 'B', 'C'] iter <- moreItems.iterator() cat iter > stdout //An example of 'teeing' content out of a processing stream capturedValues <- List() of String cat ["Alpha", "Beta", "Charlie"], ["Delta", "Echo"] | tee in capturedValues > stdout require capturedValues?
Download flowControl_collections_pipeline.ek9
With the cat command, collection classes need no for/stream syntax of their own, which gives more flexibility and consistency. You 'stream' the contents of a collection, or another source such as an iterator, into a pipeline.
The example does no real processing; it writes the contents to stdout. Part of it streams two 'lists of Strings', uses tee to copy them into a collection for later, and also streams them to stdout. Streams/Pipelines has much more on pipeline processing.
While and Do/While loops
The last two loops are traditional: both depend on a boolean condition. They too take an 'assignment', a 'guard' or a 'declaration', which runs once, when the loop is first reached. If a guard leaves its variable un-set the body never runs; otherwise only the condition is checked again on each iteration. Both can also be expressions that return values.
While loop examples
A while loop over a collection of Characters, using the 'guard', 'assignment' and 'declaration' syntax. The final function, 'whileLoopAsExpression', uses the while loop as an expression with a variable 'declaration'.
#!ek9 defines module site.flowControl.while defines function collectionsWhileLoop() stdout <- Stdout() moreItems <- ['A', 'B', 'C'] stdout.println("Characters while") itemIter <- moreItems.iterator() while itemIter.hasNext() item <- itemIter.next() stdout.println(item) //Now re-point the iter from above to a fresh one, and check it is set while itemIter := moreItems.iterator() then itemIter? and (itemIter.hasNext()) item <- itemIter.next() stdout.println(item) //Same again but with a blind assignment and only the condition while itemIter := moreItems.iterator() then itemIter.hasNext() item <- itemIter.next() stdout.println(item) //This time declare a new iterator, but within the while loop while itemIterX <- moreItems.iterator() then itemIterX.hasNext() item <- itemIterX.next() stdout.println(item) whileLoopAsExpression() limit <- 10 result <- while complete <- false with not complete <- rtn <- 0 rtn++ complete: rtn == limit require result?
Download flowControl_while.ek9
Do/While loop example
As above, except the condition is at the end of the block.
#!ek9 defines module site.flowControl.dowhile defines function collectionsDoWhileLoop() stdout <- Stdout() moreItems <- ['A', 'B', 'C'] itemIter <- moreItems.iterator() if itemIter.hasNext() do item <- itemIter.next() stdout.println(item) while itemIter.hasNext() //The above is a bit clumsy, so lets try this instead //This has the same effect: a declaration guard skips even the first pass when the iterator is unset do freshIter <- moreItems.iterator() item <- freshIter.next() stdout.println(item) while freshIter.hasNext() doLoopAsExpression() limit <- 10 result <- do complete <- false <- rtn <- 0 rtn++ complete: rtn == limit while not complete require result? //Just to show that 'complete' is bound into the do/while scope complete <- "Almost" require complete?
Download flowControl_do_while.ek9
The 'guard' makes the do/while block slightly simpler. The do/while expression is a common programming 'pattern', and the 'declaration' in the do/while makes it more self-contained.
Excluded Control Flow Features
EK9 deliberately excludes several control flow features that are common in other languages. The design note gives the record behind the decision.
Features Not in EK9
| Feature | Why Excluded | EK9 Alternative | Error Code |
|---|---|---|---|
break |
Exits the wrong loop silently; an unstructured jump (Dijkstra, 1968) | Stream pipelines with head |
E01070 |
continue |
Skips the rest of a loop body from the middle of it; an unstructured jump | Stream pipelines with filter/skip |
E01071 |
return |
An early exit from the middle of a function can skip cleanup and hide where the result is set | Return declarations, guards | E01072 |
null |
Tony Hoare's "billion-dollar mistake" (2009); Java's NullPointerException | Tri-state semantics (unset/set) | E01073 |
| Semicolons | Syntax noise | Indentation-based syntax | E01074 |
Instead of break/continue - Use Stream Pipelines
#!ek9 defines module site.flowControl.breakcontinuealternatives defines function isMatch() as pure -> item as String <- rtn as Boolean: item matches /a.*/ isValid() as pure -> item as String <- rtn as Boolean: length item > 0 process() -> item as String Stdout().println(item) //BAD: What you might write in other languages //for item in items // if isMatch(item) // break <- ERROR: E01070 //GOOD: EK9 stream pipeline goodBreakAlternative() -> items as List of String <- result as List of String: cat items | filter by isMatch | head 1 | collect as List of String //BAD: continue in other languages //for item in items // if not isValid(item) // continue <- ERROR: E01071 // process(item) //GOOD: EK9 stream pipeline goodContinueAlternative() -> items as List of String //Filter out invalid items, process the rest valid <- cat items | filter by isValid | collect as List of String for item in valid process(item)
Download flowControl_break_continue_alternatives.ek9
Instead of return - Use Return Declarations
#!ek9 defines module site.flowControl.returnalternative defines function transform() as pure -> payload as String <- result as String: payload.upperCase() //BAD: Early return in other languages //processData() // -> payload as String // <- result as String? // if not payload? // return "" <- ERROR: E01072 //GOOD: Return declaration with default goodReturnAlternative() -> payload as String <- result as String: String() //Default value if payload? result: transform(payload) //Compiler enforces all paths initialize result
Download flowControl_return_alternative.ek9
Instead of null - Use Tri-State Semantics
#!ek9 defines module site.flowControl.nullalternative defines function getName() as pure <- name as String: "Steve" process() as pure -> name as String <- rtn as String: name.upperCase() //BAD: null in other languages //badNullUsage() // name <- null <- ERROR: E01073 //GOOD: Create unset value goodTriStateUsage() name <- String() //Unset require not name? //Guard checks if set if validName <- getName() stdout <- Stdout() stdout.println(process(validName)) //Guaranteed set
Download flowControl_null_alternative.ek9
Summary
EK9 has a wide range of flow control, but deliberately leaves out some traditional syntax, such as break, because it leads to complexity and defects (a bit like GOTO). It adds to the traditional flow control syntax:
- Guard statements
- Assignment statements
- Declaration statements
- Switch, For, While and Do/While in expression form
Choosing a construct:
- Very simple if/else: use ternary operators and assign-if-unset; they are short and expressive.
- Several methods or functions to evaluate: a guard with an if statement can reduce the processing.
- Many chained if else if statements: a switch may be the answer; it is designed to make large chained if/else blocks easier to understand.
- Unique values to switch on: consider a Dictionary/Map. You can associate a function delegate with each key, look the function up and call it. This is often faster and easier to understand.
Next Steps
Exceptions and try blocks show how to handle error flow.