Operators & Control Flow
After this lesson you will be able to read ANY Dart expression and predict its exact value (including tricky cases like 7 << 2 or short-circuit skips), trace what a loop or a switch does step by step on paper before you ever run it, and choose the right control-flow tool (if-chain vs guard clause vs switch expression) for a given problem.
1. Operators
+, -, =). A variable is a labelled box that holds a value. An expression like a + b means "open box a, open box b, add what's inside, hand back the answer" — nothing is stored anywhere unless you use = to put the answer into a box.1.1 Arithmetic operators
Dart's basic arithmetic operators work on int and double (both are kinds of num):
| Operator | Meaning | Example (a=17, b=5) | Result |
|---|---|---|---|
+ | add | a + b | 22 |
- | subtract | a - b | 12 |
* | multiply | a * b | 85 |
/ | divide — always returns a double, even for whole numbers | a / b | 3.4 |
~/ | truncating integer division (throw away the remainder) | a ~/ b | 3 |
% | remainder / modulo | a % b | 2 |
Integer division ~/ throws the fractional part away by cutting toward zero: 17 ~/ 5 is 3 and -17 ~/ 5 is -3 (not -4). The two operators always fit together as a == (a ~/ b) * b + a.remainder(b). Dividing by zero follows different rules: / returns Infinity/NaN, but ~/ and % on ints throw.
The same thing as runnable Dart — every arithmetic operator on a = 17, b = 5, the negative-number cases, a double result, and a native 64-bit wrap-around (the // => lines are the exact output, checked by verify/d06.dart):
Input size → what's feasible: one arithmetic operator is O(1) whether the numbers are 5 or 1018; the only size limit is the 64-bit range (about ±9.22·1018 on native Dart) — a loop doing 108 of them takes about a second.
The same thing as runnable Dart — what /, ~/ and % do when the divisor is zero (the // => lines are the exact output, checked by verify/d06.dart):
% takes the sign of the dividend, so -7 % 3 is -1 there. In Dart, % never returns a negative result when the divisor is non-zero (it is true mathematical modulo) — e.g. -7 % 3 is 2 and 7 % -3 is 1 in Dart. Coming from another language, this trips people up constantly. Verified: see arithmeticDemo and the sign-of-divisor checks in verify/d06.dart.The same thing as runnable Dart — Dart's % next to the Java-style remainder method, the two defining identities, and the circular-index bug it prevents (the // => lines are the exact output, checked by verify/d06.dart):
dart run or a compiled Flutter app) int is a 64-bit two's-complement number. Compiled to JavaScript for the web, Dart's int is emulated using JS's 53-bit-safe doubles, so extremely large integers can behave differently on web vs native. This lesson's examples stay well inside safe ranges on both.1.2 Relational & equality operators — == vs identical
Relational operators (< <= > >=) compare order and return a bool. Equality is subtler: == asks "do these two things have equal value?" — and a class can define exactly what "equal value" means by overriding operator==. identical(a, b) asks a completely different question: "are these two variables pointing at the very same box in memory?"
== true) but they are not the same person (identical false). If you and your reflection in a mirror could talk, you'd be "equal" — but only you yourself are "identical" to you.Rule: whenever a class overrides == it must also override hashCode so that equal objects give equal hash codes (hash-based Set/Map rely on it) — the runnable Dart panel below does both.
The same thing as runnable Dart — relational operators, then == versus identical() on the same three Point variables as the animation (the // => lines are the exact output, checked by verify/d06.dart):
Input size → what's feasible: == on ints is O(1); a user-defined == costs as much as the comparison you write in it (here 2 field checks), and identical() is always O(1) because it compares one reference.
1.3 Logical operators & short-circuit evaluation
The same thing as runnable Dart — the three animated expressions, the null-guard idiom, and a call counter proving the right side was skipped (and that & does not skip) (the // => lines are the exact output, checked by verify/d06.dart):
Input size → what's feasible: short-circuiting saves the right side's cost entirely: in a() && b() with a() false, b() costs 0 steps — even if b() would have taken 106.
if (list != null && list.isNotEmpty). If && didn't skip the right side when the left is false, this would crash on a null list. Never put required side effects (like incrementing a counter) on the right side of &&/|| unless you're sure it's fine for them to be skipped.1.4 Assignment, compound assignment, prefix vs postfix ++
x++ is like a bank teller who hands you your receipt first, and only updates your balance after you've walked away. Prefix ++x updates the balance first, then shows you the new number.| Operator | Meaning |
|---|---|
x += n | same as x = x + n |
x -= n, x *= n, x /= n, x ~/= n, x %= n | same pattern for every arithmetic operator |
x++ / x-- | postfix: use the OLD value in the surrounding expression, then change x |
++x / --x | prefix: change x first, then use the NEW value |
The same thing as runnable Dart — postfix vs prefix, then every compound assignment on one variable (the // => lines are the exact output, checked by verify/d06.dart):
Input size → what's feasible: every x++ or x += n is O(1); a loop that runs it 108 times takes about a second.
1.5 Conditional operator c ? a : b
A compact if/else that produces a value instead of running a branch of statements:
Reads as: "if age >= 18 is true, the whole expression's value is 'adult'; otherwise it's 'minor'." Use it only for simple, short expressions — nesting ? : inside another ? : quickly becomes unreadable.
1.6 Cascade .. and null-shorting cascade ?..
?.. is the same, except if the table doesn't exist, the waiter safely does nothing instead of crashing into it.The same thing as runnable Dart — the same Order built with a cascade, a ?.. on a null receiver, and the equivalent code without a cascade (the // => lines are the exact output, checked by verify/d06.dart):
Input size → what's feasible: a cascade of k calls costs exactly the k calls — it is only a shorter way to write the receiver once, not a faster or slower one.
1.7 Type test operators — is, is!, as
is asks "is this object (also) of this type?" and returns a bool — and inside a branch where it's true, Dart's flow analysis promotes the variable so you can use type-specific members directly. as forcibly casts, throwing a runtime error if the object isn't actually that type.
as throws TypeError at runtime if the cast is wrong. Prefer is plus promotion, or the pattern-matching switch you'll see in section 5, over a bare as whenever you're not 100% certain of the type.2. Precedence & associativity
10 - 3 - 2 means (10 - 3) - 2 = 5, not 10 - (3 - 2) = 9.Type your own expression below (digits and + - * / only, e.g. 2+3*4-8/2) and watch Dart's exact evaluation order, including the int→double promotion that / causes.
Dart's operators from tightest (evaluated first) to loosest (evaluated last) — a simplified table of the ones in this lesson:
| Level | Operators | Grouping |
|---|---|---|
| 1 (tightest) | postfix x++ x--, member access . ?. | left to right |
| 2 | prefix -x !x ++x --x | right to left |
| 3 | * / % ~/ | left to right |
| 4 | + - | left to right |
| 5 | << >> (shifts), then &, ^, | (bitwise, each its own level in that order) | left to right |
| 6 | < <= > >= is is! as | no chaining (a < b < c is an error) |
| 7 | == != | no chaining |
| 8 | &&, then || | left to right |
| 9 | ??, then c ? a : b | ?? left to right, ? : right to left |
| 10 (loosest) | assignments = += -= … | right to left |
Note the difference from C/Java: in Dart the bitwise operators bind tighter than ==, so a & b == 0 means (a & b) == 0. When in doubt, add parentheses — they cost nothing and the next reader will thank you.
The same thing as runnable Dart — the page's expression 2+3*4-8/2 plus the associativity and precedence rules above, each checked (the // => lines are the exact output, checked by verify/d06.dart):
Input size → what's feasible: evaluating an expression with k operators takes k steps; the animation accepts at most 20 characters (at most 7 numbers of up to two digits, so every int result stays below 997 ≈ 9.3·1013, well under 253 ≈ 9·1015, where the animation's JavaScript numbers and Dart's 64-bit ints agree exactly).
3. if / else if / else, guard clauses
Type a score (any integer, including out-of-range ones like -5 or 150) and watch which branch runs and why the others are skipped.
The same thing as runnable Dart — the guard clause and the if / else if chain on eight scores, including both out-of-range ones and both boundaries (75 and 50) (the // => lines are the exact output, checked by verify/d06.dart):
Input size → what's feasible: an if / else-if chain with k branches costs at most k comparisons — O(1) for a fixed grade table, and the same for 107 calls (≈ 5·107 steps).
if / else if test top to bottom and stops at the first one that is true — later conditions are never even checked, even if they would also be true. Order your conditions from most specific to least specific.4. Loops
4.1 for loops — init → condition → body → update
The same thing as runnable Dart — the animated loop, the zero-iteration loop, and a loop whose update steps by −3 (the // => lines are the exact output, checked by verify/d06.dart):
Input size → what's feasible: a loop of n passes costs n × (body cost): n = 106 → about 10-2 s, n = 108 → about 1 s, n = 1010 → minutes (too slow). Nested loops multiply: two loops of 104 = 108 passes.
The same thing as runnable Dart — a formula for the pass count and the code that checks it (the // => lines are the exact output, checked by verify/d06.dart):
for (int i = 5; i <= 1; i++), nothing happens; Dart checks 5 <= 1, finds it false, and skips straight past the loop. Verified as forLoopZeroIterations() in verify/d06.dart — it produces zero trace lines from inside the loop.i++, or you accidentally increment the wrong variable), the loop never stops and your program hangs. Always double check that every path through the body moves the loop closer to its stopping condition.4.2 while and do-while
while checks the condition before every iteration (so it can run zero times). do-while checks it after the body, so the body always runs at least once, even if the condition was already false to begin with.
The same thing as runnable Dart — whileDemo(3) (normal) and whileDemo(0) (edge: the condition is false at once, the body never runs) (the // => lines are the exact output, checked by verify/d06.dart):
The same thing as runnable Dart — doWhileDemo(3) (same output as while) and doWhileDemo(0) (edge: the body still runs once with n=0 before the check discovers the condition is false) (the // => lines are the exact output, checked by verify/d06.dart):
Input size → what's feasible: the loops above run n passes for start = n: n ≤ 108 is fine, n = 1010 is not.
do-while only when the task must happen at least once no matter what — e.g. "show the menu, then keep re-showing it while the user hasn't chosen 'quit'".4.3 for-in and forEach
The same thing as runnable Dart — for-in, forEach and indexed iteration (the // => lines are the exact output, checked by verify/d06.dart):
Input size → what's feasible: iterating a list of n items is O(n) — 106 items ≈ 10-2 s; indexed adds no extra pass, it just pairs each element with its 0-based index.
forEach takes a callback and Dart does not wait for it. If you pass an async callback to forEach, the iterations fire off in overlapping fashion and the loop function returns before any of them finish — that's almost never what you want. Use a plain for-in loop with await inside when you need each iteration to finish before the next one starts.The same thing as runnable Dart — the pitfall and its fix, with the exact order in which the log fills (the // => lines are the exact output, checked by verify/d06.dart):
4.4 break and continue
The same thing as runnable Dart — continue on even numbers and break after 7, over i = 1…10 (the // => lines are the exact output, checked by verify/d06.dart):
continue skips the rest of this iteration's body and jumps to the next one; break exits the loop entirely, right now.
4.5 Labeled break in nested loops
break inside a nested loop only escapes the inner loop — like leaving one room but staying in the building. A labeled break (break outer;) escapes all the way out of the building at once.For contrast, if line 5 said plain break; instead of break outer;, only the inner j loop would stop — the outer i loop would continue on to i=3. The panel below runs both versions side by side, plus a labeled continue:
The same thing as runnable Dart — break outer versus a plain break versus continue next (the // => lines are the exact output, checked by verify/d06.dart):
Input size → what's feasible: nested loops multiply: a 3×3 grid is 9 cells, a 104×104 grid is 108 (≈ 1 s) — a labeled break can cut the work short the moment the answer is found.
4.6 Loop invariants — a preview
i for the k-th time, sum already equals 1 + 2 + ... + (i-1)." Proving a loop invariant is exactly how algorithms like Insertion Sort are proved correct — you'll use this technique heavily in the Algorithms track.The same thing as runnable Dart — the invariant sum == (i−1)·i/2 checked before every pass, and the closed form n(n+1)/2 at the end (Dart is 0-indexed in lists, but this loop is 1-based: i is the number being added) (the // => lines are the exact output, checked by verify/d06.dart):
5. switch — statements & expressions with patterns
switch is like a sorting machine at a post office: a letter drops in, and it's routed down the first chute whose label matches — it never falls into two chutes.5.1 switch statement (Dart 3: no fall-through, no break needed)
Input size → what's feasible: a switch over a fixed set of cases costs a handful of comparisons (O(1)); even 107 calls ≈ 108 steps ≈ 1 s.
break; makes execution "fall through" into the next case by accident. Dart 3 forbids this for any case with a body — each non-empty case automatically stops after running, no break required. You CAN still group several case labels to share one body (like Direction.north and Direction.south above) — that's an intentional combined case, not accidental fall-through. Also note: because Direction is an enum and every value is handled, the Dart compiler checks this switch is exhaustive — if a future teammate adds a fifth direction and forgets to handle it here, the code won't compile until they do.5.2 switch expression with relational & logical patterns
Try a score below (including out-of-range values) and watch Dart test each pattern from top to bottom until one matches:
The same thing as runnable Dart — gradeLabel on twelve scores, including every boundary (−1, 101, 90, 89, 75, 74, 50, 49) (the // => lines are the exact output, checked by verify/d06.dart):
5.3 switch expression with record patterns and when guards
| Call | Matches | Result |
|---|---|---|
bucket((0, 0)) | constant pattern (0, 0) | 'origin' |
bucket((2, 2)) | (var x, var y) when x == y — a guard (when) attached to a variable pattern | 'on diagonal' |
bucket((-3, 5)) | (var x, _) when x < 0 — _ is the wildcard pattern, matching anything without binding a name | 'left half' |
bucket((1, 9)) | none of the above matched, falls through to _ | 'elsewhere' |
switch expression (using =>, no case keyword) must be exhaustive too — the compiler forces you to either cover every possibility or add a final _ catch-all, so you can never accidentally fall off the end with no value.5.4 Patterns — the basics
A pattern describes the shape of a value and, when the value matches, can pull parts of it into new variables. You have already used four kinds: relational (>= 90), logical (||, &&), record patterns ((var x, var y)) and the wildcard _. Three more cover most everyday code: a type pattern (String s), a list pattern ([var first, ...]) and the null constant pattern. Outside a switch you can use them to destructure (var (a, b) = (3, 4);) and with if (value case pattern).
The same thing as runnable Dart — destructuring, a switch over type/list/null patterns, and an if-case (the // => lines are the exact output, checked by verify/d06.dart):
Input size → what's feasible: matching a pattern is O(size of the pattern) — a list pattern [var first, ...] looks only at the first element, so it costs the same for 3 elements or 106.
6. assert
assert is like a smoke detector installed only in the workshop, not in the finished house you sell: while you're building and testing, it screams the moment something is wrong so you can fix it immediately — but it's removed entirely from the shipped product so it costs nothing there.dart run does not check assertions by default — you must pass --enable-asserts for them to run at all. Flutter's flutter run (debug mode) enables them automatically, but a release build strips every assert out completely, so never rely on an assert for anything the program actually needs to keep working correctly — use a real if + throw for that. This is exactly why verify/d06.dart uses if (x != y) throw '...'; instead of assert to check its results.7. How to trace code in your head (and on paper)
Method: (1) write one column per variable that exists, plus a column for what line is executing. (2) Start at the first line. (3) For every statement, write down which variables change and their new value — for a condition, write down true or false explicitly before deciding which branch to take. (4) Never skip a line, even ones that "obviously" do nothing surprising — that's exactly where bugs hide.
Worked example — trace sumOfDigits(129) from the interview bank by hand:
| Line executed | n | sum | condition n > 0 |
|---|---|---|---|
| n = n.abs() → n stays 129 | 129 | 0 | — |
| check condition | 129 | 0 | true |
| sum += n % 10 (129%10=9); n ~/= 10 | 12 | 9 | — |
| check condition | 12 | 9 | true |
| sum += n % 10 (12%10=2); n ~/= 10 | 1 | 11 | — |
| check condition | 1 | 11 | true |
| sum += n % 10 (1%10=1); n ~/= 10 | 0 | 12 | — |
| check condition | 0 | 12 | false → exit loop |
sumOfDigits(129) returns 12 (1+2+9). This is a real, tested value — see sumOfDigits checks in verify/d06.dart. Whenever you're unsure what a piece of code does, build this table before guessing.The same thing as runnable Dart — the same loop, printing one line per pass so you can compare it with the table row by row (the final line is the returned sum) (the // => lines are the exact output, checked by verify/d06.dart):
Input size → what's feasible: tracing by hand is for small inputs (n ≤ ~10 passes); for n = 106 passes you let the computer run it and trust the invariant instead.
Quiz
Interview questions
Solve each one yourself first — write it on paper or in a scratch file — before opening the solution.
Cheat sheet
| Topic | Key fact |
|---|---|
| Arithmetic | / always returns double; ~/ truncates; % is always non-negative in Dart |
| Equality | == = value equality (overridable); identical() = same object in memory |
| Logical | &&/|| short-circuit: the right side may never run |
| ++/-- | postfix returns OLD value then changes; prefix changes then returns NEW value |
| Cascade | .. chains calls on the same receiver; ?.. skips all of them if the receiver is null |
| is / as | is tests + promotes; as casts and can throw |
| Precedence | * / before + -; same-precedence ops run left to right |
| if chain | first true branch wins, top to bottom; guard clauses exit bad cases early |
| for | init once → check condition → body → update → recheck; may run zero times |
| while / do-while | while may run zero times; do-while always runs at least once |
| break / continue | break exits the loop now; continue skips to the next iteration |
| labeled break | break outer; exits every loop labeled up to and including outer: |
| switch (Dart 3) | no accidental fall-through; expressions use => and support patterns + when guards; both forms must be exhaustive |
| assert | no-op unless --enable-asserts (or Flutter debug); stripped from release builds |