Tcl Part I · Module 10 · Procedures and Reuse
Guard Clauses and Early Return
You have met this shape twice already — flattening nested conditions in Module 8, and skipping uninteresting loop passes in Module 9. At a procedure boundary it becomes something more than a readability technique, because a procedure can leave in two different ways. It can return a neutral value, which tells the caller there was nothing to do, or it can raise an error, which tells the caller it was asked something it could not answer. Those are different contracts, callers handle them differently, and choosing between them for each guard is the part of this that takes judgement rather than typing.
Foundation11 min readTclPart IProceduresGuard clausesValidation
Part I · Module 10 · Chapter 10.7 · Guard Clauses and Early Return
1. The shape
A procedure that validates by nesting puts its real work at the bottom of a staircase:
proc summarise_nested {rows} {
if {[llength $rows] > 0} {
if {[llength $rows] % 2 == 0} {
set n 0
foreach {k v} $rows { incr n }
return "$n entries"
} else {
return "error: not pairs"
}
} else {
return "nothing to report"
}
}The same procedure with guards puts each rejection next to its reason and leaves the work unindented:
proc summarise {rows} {
if {[llength $rows] == 0} { return "nothing to report" }
if {[llength $rows] % 2 != 0} { return "error: not pairs" }
set n 0
foreach {k v} $rows { incr n }
return "$n entries"
}Check they agree before trusting the rewrite — the discipline from Chapter 8.3 §2:
foreach input {{} {a 1 b 2} {a 1 b}} {
set x [summarise_nested $input]
set y [summarise $input]
puts [format "%-12s %-18s %s" "'$input'" $y [expr {$x eq $y ? "(same)" : "DIFFERS"}]]
}'' nothing to report (same)
'a 1 b 2' 2 entries (same)
'a 1 b' error: not pairs (same)2. A guard has two possible exits
This is what is new at a procedure boundary. Each guard must decide: is this a case where the caller can sensibly carry on, or one where it must not?
Return a neutral value when "nothing to do" is a legitimate answer:
proc summarise2 {rows} {
if {[llength $rows] == 0} { return "nothing to report" }
return "[expr {[llength $rows] / 2}] entries"
}
puts [summarise2 {}]nothing to reportRaise when the input is wrong rather than empty:
proc summarise3 {rows} {
if {[llength $rows] % 2 != 0} {
error "rows must be name-value pairs, got [llength $rows] elements"
}
return "[expr {[llength $rows] / 2}] entries"
}
if {[catch {summarise3 {a 1 b}} msg]} {
puts "rejected: $msg"
}rejected: rows must be name-value pairs, got 3 elementsAn empty list is a reasonable thing for a caller to hold; an odd-length pair list is a bug upstream. The first deserves an answer, the second deserves to stop.
3. Order matters
Guards are not an unordered set. Later ones may depend on earlier ones having passed:
proc scale_safe {value factor} {
if {![string is double -strict $value]} { error "value is not a number: '$value'" }
if {![string is double -strict $factor]} { error "factor is not a number: '$factor'" }
if {$factor == 0} { error "factor must not be zero" }
return [expr {$value / $factor}]
}
puts [scale_safe 10 2]
foreach bad {{abc 2} {10 xyz} {10 0}} {
lassign $bad v f
if {[catch {scale_safe $v $f} m]} { puts " rejected: $m" }
}5
rejected: value is not a number: 'abc'
rejected: factor is not a number: 'xyz'
rejected: factor must not be zeroThe zero check has to come after the type checks, because $factor == 0 on the text xyz would be a string comparison rather than the numeric test intended — the behaviour from Chapter 8.3 §5. Reorder them and the third message stops being reachable for some inputs.
4. Guards state the contract Tcl cannot check
Chapter 10.2 showed Tcl checking argument counts for free. It checks nothing else — not types, not ranges, not structure. Guards are where you say the rest:
proc set_width {name width} {
if {$name eq ""} { error "name must not be empty" }
if {![string is integer -strict $width]} { error "width must be an integer, got '$width'" }
if {$width < 1 || $width > 1024} { error "width must be 1..1024, got $width" }
return "$name is $width bits"
}
puts [set_width clk 1]
foreach bad {{"" 8} {clk abc} {clk 0} {clk 99999}} {
lassign $bad n w
if {[catch {set_width $n $w} m]} { puts " rejected: $m" }
}clk is 1 bits
rejected: name must not be empty
rejected: width must be an integer, got 'abc'
rejected: width must be 1..1024, got 0
rejected: width must be 1..1024, got 99999Each message names the rule and the offending value. That is the same quality of diagnostic wrong # args gives, extended to the things Tcl has no way of knowing.
5. Do not over-guard
Validation has a cost: every guard is a line a reader must check, and a line that can itself be wrong. Two rules keep it proportionate.
Guard at the boundary, not everywhere. A procedure that is only ever called by two others in the same file does not need to re-validate what they already checked:
# public entry point — validates
proc process {rows} {
if {[llength $rows] % 2 != 0} { error "rows must be pairs" }
return [process_checked $rows]
}
# internal — trusts its caller
proc process_checked {rows} {
set n 0
foreach {k v} $rows { incr n }
return "$n entries"
}
puts [process {a 1 b 2}]2 entriesDo not guard against things that already fail well. dict get on a missing key already raises a clear, specific error naming the key; wrapping it in a dict exists check that raises a vaguer message makes the diagnostic worse, not better.
6. Guards and the implicit return
One interaction worth naming. Guards make it easy to end up with a path that returns nothing:
proc risky {n} {
if {$n < 0} { return "negative" }
if {$n > 100} { return "too large" }
# falls off the end for 0..100
}
puts "neg : '[risky -1]'"
puts "big : '[risky 200]'"
puts "mid : '[risky 50]'"neg : 'negative'
big : 'too large'
mid : ''The guards handled the exceptional cases and nobody wrote the normal one. This is Chapter 10.5 §4's rule arriving through the back door: with guards at the top, the final return is easy to forget precisely because the body looks finished.
7. A worked example
proc parse_port {spec} {
# guards: reject, with a reason
if {[llength $spec] != 3} {
error "a port spec needs 3 fields, got [llength $spec]: '$spec'"
}
lassign $spec name dir width
if {$name eq ""} { error "port name must not be empty" }
if {$dir ni {in out inout}} { error "bad direction '$dir' for port $name" }
if {![string is integer -strict $width]} { error "width must be an integer for port $name, got '$width'" }
if {$width < 1} { error "width must be at least 1 for port $name, got $width" }
# the work
return [dict create name $name dir $dir width $width bits $width]
}
foreach spec {{clk in 1} {data out 32} {bad sideways 8} {oops in} {x in 0}} {
if {[catch {parse_port $spec} r]} {
puts " rejected: $r"
} else {
puts " ok: [dict get $r name] ([dict get $r dir], [dict get $r width] bits)"
}
} ok: clk (in, 1 bits)
ok: data (out, 32 bits)
rejected: bad direction 'sideways' for port bad
rejected: a port spec needs 3 fields, got 2: 'oops in'
rejected: width must be at least 1 for port x, got 0Five guards, each naming its rule and the offending value, and then four lines of actual work at zero indentation.
8. Exercises
Exercise 1. Rewrite with guards and verify both versions agree:
proc f {l} {
if {[llength $l] > 0} {
return [lindex $l 0]
} else {
return ""
}
}Exercise 2. For each, say return-a-value or raise: (a) an empty list to a summariser; (b) a negative width; (c) a missing optional label; (d) a dictionary with no name key where name is required.
Exercise 3. Why must a type guard come before a range guard?
Exercise 4. What does risky 50 return in section 6, and what is missing?
Exercise 5. Write average guarding against a non-list, an empty list, and a non-numeric element, then returning the mean.
Exercise 6. When is it better not to add a guard?
Answers
1.
proc g {l} {
if {[llength $l] == 0} { return "" }
return [lindex $l 0]
}
foreach input {{} {a b} {x}} {
puts [format "%-8s %s" "'$input'" [expr {[f $input] eq [g $input] ? "same" : "DIFFERS"}]]
}'' same
'a b' same
'x' same2. (a) return a value — an empty input is a legitimate thing to summarise and "nothing to report" is a sensible answer; (b) raise — a negative width is a bug upstream; (c) return a value, using the empty default; (d) raise — a required field is missing, so there is no answer to give.
3. Because a range comparison on a non-numeric value does not fail — Tcl falls back to string comparison, so $w < 1 on the text abc produces a boolean rather than an error and the guard silently answers the wrong question. The type guard establishes the precondition the range guard depends on.
4. It returns the empty string. The normal case — a value between 0 and 100 — has no return at all, so the procedure falls off the end.
5.
proc average {values} {
if {[llength $values] == 0} { error "cannot average an empty list" }
foreach v $values {
if {![string is double -strict $v]} { error "not a number: '$v'" }
}
set total 0.0
foreach v $values { set total [expr {$total + $v}] }
return [expr {$total / [llength $values]}]
}
puts [average {1 2 3 4}]
foreach bad {{} {1 x 3}} {
if {[catch {average $bad} m]} { puts " rejected: $m" }
}2.5
rejected: cannot average an empty list
rejected: not a number: 'x'6. When the thing you would guard against already fails clearly on its own — dict get on a missing key names the key — or when the procedure is internal and its only callers have already validated. A guard that replaces a specific error with a vaguer one makes debugging harder.
9. Remember
- Validate at the top and leave immediately, so the body holds the work rather than the exceptions.
- A guard has two exits: return a neutral value, or raise. Choose per guard.
- Return a value when "nothing to do" is a legitimate answer; raise when the input is wrong.
- Order guards so that each one's precondition is already established — type checks before range or arithmetic checks.
- Guards state the contract Tcl cannot check — types, ranges, structure. Tcl checks only argument counts.
- Use
-stricton everystring isguard, or the empty string passes. - Name the rule and the offending value in each message.
- Guard at boundaries, not everywhere, and not against things that already fail well.
- Remember the final
return— with guards at the top, the normal path is easy to leave unwritten.
Where this fits
Part of the Tcl curriculum.
