Tcl Part I · Module 8 · Conditions and Control Flow
Nested Conditions, and When to Flatten Them
Conditions nest, and after three levels a reader has to hold three facts in their head at once to understand the line they are looking at. Most of that nesting is accidental — it is two independent tests that happened to get written one inside the other — and it collapses into something shorter that says the same thing. Some of it is not accidental, and flattening it either changes the meaning or breaks outright. This chapter gives you two transformations, a way to check that each one preserved behaviour rather than trusting that it did, and an honest account of when to leave the nesting alone.
Foundation11 min readTclPart IControl flowReadabilityGuard clauses
Part I · Module 8 · Chapter 8.3 · Nested Conditions, and When to Flatten Them
1. Nesting works, and it accumulates
proc describe {ok count} {
if {$ok} {
if {$count > 0} {
return "ready with $count"
} else {
return "ready but empty"
}
} else {
return "not ready"
}
}
foreach pair {{1 5} {1 0} {0 5}} {
lassign $pair ok count
puts [format "ok=%s count=%s -> %s" $ok $count [describe $ok $count]]
}ok=1 count=5 -> ready with 5
ok=1 count=0 -> ready but empty
ok=0 count=5 -> not readyThat is correct and perfectly readable at two levels. The trouble starts at three or four, when the body of the innermost branch is far enough from its condition that you have to scroll to see what is true there.
2. Transformation one: combine with &&
Two nested conditions with nothing between them collapse into one:
# before
proc old {ok count} {
if {$ok} {
if {$count > 0} {
return "go"
}
}
return "stop"
}
# after
proc new {ok count} {
if {$ok && $count > 0} {
return "go"
}
return "stop"
}The important part is not that it looks nicer — it is checking that it behaves identically. Run both over every input that matters:
set same 1
foreach ok {0 1} {
foreach count {-1 0 5} {
set a [old $ok $count]
set b [new $ok $count]
puts [format "ok=%s count=%2s old=%-4s new=%-4s %s" \
$ok $count $a $b [expr {$a eq $b ? "" : "<-- DIFFERS"}]]
if {$a ne $b} { set same 0 }
}
}
puts "identical on every input: $same"ok=0 count=-1 old=stop new=stop
ok=0 count= 0 old=stop new=stop
ok=0 count= 5 old=stop new=stop
ok=1 count=-1 old=stop new=stop
ok=1 count= 0 old=stop new=stop
ok=1 count= 5 old=go new=go
identical on every input: 13. Transformation two: the guard clause
The deeper nesting usually comes from handling the failure cases inside the success path. Turning them into early exits flattens everything:
# before — the real work is three levels in
proc run_old {job} {
if {[dict exists $job name]} {
if {[dict exists $job target]} {
if {[dict get $job target] ne ""} {
return "running [dict get $job name] on [dict get $job target]"
} else {
return "error: empty target"
}
} else {
return "error: no target"
}
} else {
return "error: no name"
}
}
# after — failures first, work last, never indented
proc run_new {job} {
if {![dict exists $job name]} { return "error: no name" }
if {![dict exists $job target]} { return "error: no target" }
if {[dict get $job target] eq ""} { return "error: empty target" }
return "running [dict get $job name] on [dict get $job target]"
}Check them against each other:
set jobs [list \
[dict create name smoke target top] \
[dict create name smoke target ""] \
[dict create name smoke] \
[dict create target top]]
foreach j $jobs {
set a [run_old $j] ; set b [run_new $j]
puts [format "%-28s %s" "'$j'" [expr {$a eq $b ? "same: $a" : "DIFFERS"}]]
}'name smoke target top' same: running smoke on top
'name smoke target {}' same: error: empty target
'name smoke' same: error: no target
'target top' same: error: no nameThe second version has the real work on its last line, at zero indentation, and each failure sits next to the test that detected it. That is the shape to aim for.
4. When nesting is right
Nesting is correct when the inner question only makes sense if the outer one is true:
proc report {cfg} {
if {[dict exists $cfg stages]} {
set stages [dict get $cfg stages]
if {[dict exists $stages parse]} {
return "parse timeout [dict get $stages parse timeout]"
}
return "stages present, no parse stage"
}
return "no stages"
}
puts [report [dict create stages [dict create parse [dict create timeout 60]]]]
puts [report [dict create stages [dict create build [dict create timeout 300]]]]
puts [report [dict create other 1]]parse timeout 60
stages present, no parse stage
no stagesHere the inner dict exists $stages parse cannot be asked until stages has been fetched, which cannot happen until the outer test passed. The nesting reflects a real dependency. Flattening it would mean fetching stages before knowing it exists.
5. The honest limit on &&
&& can replace nesting because it short-circuits — the rule from Chapter 4.5. That is doing real work, and it means the order of the two tests is part of the meaning.
proc safe {d} {
if {[dict exists $d n] && [dict get $d n] > 5} {
return "big"
}
return "small"
}
puts [safe [dict create n 9]]
puts [safe [dict create]]big
smallThe second call did not error, because dict exists was false and && never evaluated the right-hand side. Now swap the two tests:
proc unsafe {d} {
if {[dict get $d n] > 5 && [dict exists $d n]} {
return "big"
}
return "small"
}
puts [unsafe [dict create]]key "n" not known in dictionarySame two tests, same &&, and it fails — because the guard is no longer first. The same thing happens with arithmetic:
proc odd_or_even {v} {
if {$v % 2 == 0 && [string is integer -strict $v]} { return "even" }
return "no"
}
puts [odd_or_even abc]can't use non-numeric string as operand of "%"6. A third option: dispatch
Sometimes the nesting is really a multi-way choice wearing a disguise:
# before
proc classify_old {mode} {
if {$mode eq "fast"} {
return 1
} else {
if {$mode eq "full"} {
return 10
} else {
if {$mode eq "deep"} {
return 100
} else {
return 0
}
}
}
}
# after
proc classify_new {mode} {
if {$mode eq "fast"} {
return 1
} elseif {$mode eq "full"} {
return 10
} elseif {$mode eq "deep"} {
return 100
}
return 0
}
foreach m {fast full deep other} {
puts "$m: [classify_old $m] / [classify_new $m]"
}fast: 1 / 1
full: 10 / 10
deep: 100 / 100
other: 0 / 0elseif exists exactly so that an else containing nothing but another if does not have to be written as nesting. And when every branch compares the same variable against a constant, as here, there is a better form still — that is Chapter 8.4.
7. A practical limit
A rule of thumb that holds up: if you are writing a third level of nesting, stop and look for a guard clause. Almost always one of the outer conditions is a precondition that can be turned into an early return, and almost always the result is shorter and reads top to bottom.
If after that you still need three levels, you probably have genuinely dependent logic, as in section 4 — and three levels is then the honest representation of it.
8. Exercises
Exercise 1. Flatten this into a single if, and say what makes the flattening safe:
if {$enabled} {
if {$count > 0} {
puts go
}
}Exercise 2. Rewrite with guard clauses and verify both versions agree on all four inputs:
proc f {a b} {
if {$a ne ""} {
if {$b ne ""} {
return "$a-$b"
} else {
return "no b"
}
} else {
return "no a"
}
}Exercise 3. Why does if {[dict exists $d n] && [dict get $d n] > 5} survive an empty dictionary while the reversed version does not?
Exercise 4. Give an example where nesting should be kept rather than flattened, and say what makes it so.
Exercise 5. Rewrite this chain as elseif and say how many levels of indentation you removed:
if {$x == 1} { set r a } else { if {$x == 2} { set r b } else { set r c } }Exercise 6. Someone flattens if {$ok} { if {$n > 0} {...} } into if {$ok || $n > 0}. What did they get wrong, and on which inputs does it show?
Answers
1.
if {$enabled && $count > 0} {
puts go
}It is safe because the outer if had no else and nothing between the two conditions, so the only way to reach puts go was for both to be true — which is exactly what && means.
2.
proc g {a b} {
if {$a eq ""} { return "no a" }
if {$b eq ""} { return "no b" }
return "$a-$b"
}
foreach pair {{x y} {x ""} {"" y} {"" ""}} {
lassign $pair a b
puts [format "a='%s' b='%s' %s" $a $b [expr {[f $a $b] eq [g $a $b] ? "same: [g $a $b]" : "DIFFERS"}]]
}a='x' b='y' same: x-y
a='x' b='' same: no b
a='' b='y' same: no a
a='' b='' same: no a3. Because && short-circuits. With the guard first, dict exists $d n is false and the right-hand side is never evaluated. Reversed, dict get $d n runs first on a dictionary that has no such key and raises key "n" not known in dictionary.
4. Any case where the inner test depends on work done by the outer one — for example fetching [dict get $cfg stages] only after [dict exists $cfg stages] has confirmed it is there. Flattening would perform the fetch before establishing it is valid.
5.
if {$x == 1} {
set r a
} elseif {$x == 2} {
set r b
} else {
set r c
}One level. With a longer chain it removes one level per additional case, which is why deeply nested else { if ... } grows so quickly.
6. They used || where the nesting meant "and". Nesting one if inside another requires both conditions; || requires only one. It shows on every input where exactly one is true — ok=1, n=0 and ok=0, n=5 both now run the body, and neither did before.
9. Remember
- Two nested conditions with nothing between them combine with
&&. - Check every restructuring by running both versions over the same inputs and comparing. It is faster than reasoning and it catches the exceptions.
- Guard clauses flatten the common case: test the failures first and return early, so the real work ends up on the last line at zero indentation.
- Keep the nesting when the inner test depends on the outer one having already succeeded — for instance fetching a value only after confirming it exists.
&&replaces nesting because it short-circuits, so the guard must stay first. Reordering can turn a safe test into an error.else { if ... }should beelseif— that is what it is for.- At a third level of nesting, look for a guard clause before writing it.
- Nesting that compares one variable against several constants wants
switch— the next chapter.
Where this fits
Part of the Tcl curriculum.
