Tcl Part I · Module 11 · Scope and Namespaces
Why Globals Make a Script Hard to Trust
The previous chapter showed how to reach a global and was fairly positive about it. This one makes the case against, and tries to do it honestly: not with a slogan, but by running code that produces a visibly wrong answer for each of three specific reasons. The target is narrower than globals in general — a value set once at start-up and read everywhere is rarely a problem. What costs you is shared state that more than one procedure writes to, and the three costs are worth being able to name, because recognising them in a script you did not write is most of the work of fixing it.
Foundation11 min readTclPart IScopeGlobalsDesign
Part I · Module 11 · Chapter 11.3 · Why Globals Make a Script Hard to Trust
1. The thing being criticised
Not this:
set ::config [dict create retries 3]
proc retries {} { return [dict get $::config retries] }Set once, read many times. That is a constant with a long name.
This:
set ::state 0
proc step_a {} { global state ; set state [expr {$state + 1}] }
proc step_b {} { global state ; set state [expr {$state * 10}] }Two procedures that both write the same global. Everything below follows from that.
2. Cost one: the answer depends on the order
proc reset {} { set ::state 1 }
proc add_one {} { global state ; set state [expr {$state + 1}] }
proc times_ten {} { global state ; set state [expr {$state * 10}] }
reset ; add_one ; times_ten
puts "add then times : $::state"
reset ; times_ten ; add_one
puts "times then add : $::state"add then times : 20
times then add : 11The same two calls, in a different order, give different answers. That is not a bug in either procedure — both are correct — and it means you cannot understand times_ten by reading times_ten. You have to know everything that ran before it.
3. Cost two: it cannot be checked on its own
Chapter 10.10 §4 called a procedure checkable if you can call it with known inputs and compare the output. A global-driven procedure fails that test:
proc over_limit_global {values} {
global threshold
set n 0
foreach v $values { if {$v > $threshold} { incr n } }
return $n
}
# you cannot just call it
if {[catch {over_limit_global {5 15}} msg]} {
puts "without setup: $msg"
}
# every check needs a setup line
set ::threshold 10
puts "with threshold 10 : [over_limit_global {5 15 20}]"
set ::threshold 18
puts "with threshold 18 : [over_limit_global {5 15 20}]"without setup: can't read "threshold": no such variable
with threshold 10 : 2
with threshold 18 : 1Worse, the two checks are not independent — the second only gives the right answer because the first line reset the global. Leave that line out and it silently reports the previous run's answer.
The parameterised version has neither problem:
proc over_limit {values threshold} {
set n 0
foreach v $values { if {$v > $threshold} { incr n } }
return $n
}
puts "threshold 10 : [over_limit {5 15 20} 10]"
puts "threshold 18 : [over_limit {5 15 20} 18]"threshold 10 : 2
threshold 18 : 1Two calls on adjacent lines, each complete in itself.
4. Cost three: changed where you will not look
The expensive one in real scripts. A procedure reads a global; a completely different procedure writes it; the call that connects them is nowhere near either:
set ::mode "fast"
proc report {} {
global mode
return "running in $mode mode"
}
proc load_defaults {} {
global mode
# buried in forty lines of unrelated setup
set mode "slow"
}
puts "before : [report]"
load_defaults
puts "after : [report]"before : running in fast mode
after : running in slow modeNothing in report suggests its answer can change. Nothing in the call to load_defaults suggests it affects report. The connection exists only in the name mode, and finding it means searching the whole script for that name — which is exactly what you cannot do if the name is something common like count or path.
5. The refactor
The same three procedures with the state passed explicitly:
proc add_one {state} { return [expr {$state + 1}] }
proc times_ten {state} { return [expr {$state * 10}] }
set s 1
set s [add_one $s]
set s [times_ten $s]
puts "add then times : $s"
set s 1
set s [times_ten $s]
set s [add_one $s]
puts "times then add : $s"add then times : 20
times then add : 11The answers still differ — that is arithmetic, not a bug — but now the order is visible on the page. A reader sees the sequence in the four lines in front of them rather than having to know the history of a variable.
That is the real win. Explicit state does not remove order dependence; it makes it local and readable.
6. When the alternative is worse
Honesty requires the counter-case. Threading a value through five layers only so the bottom one can use it is its own problem:
proc level1 {data verbose} { return [level2 $data $verbose] }
proc level2 {data verbose} { return [level3 $data $verbose] }
proc level3 {data verbose} {
if {$verbose} { return "verbose: $data" }
return $data
}
puts [level1 "payload" 1]verbose: payloadverbose is noise in two of those three signatures. For one widely-read flag, a global genuinely is the lesser evil — and Chapter 11.6 offers a better home for it than the global scope.
7. A checklist
When you find a global in a script you did not write:
- Who writes it?
grepfor the name. One writer is usually fine; several is the problem. - Is it written after start-up? If everything happens in one setup block, it is effectively a constant.
- Can the procedures that read it be called twice with different values? If not, the global is also preventing reuse.
- Is the name findable? A long specific name is recoverable;
::countis not.
If one and two are clean, leave it. If several procedures write it mid-run, that is the one to change first — and the change is usually to pass it as a parameter and return the new value, as in section 5.
8. Exercises
Exercise 1. Why do reset ; add_one ; times_ten and reset ; times_ten ; add_one give different results, and why is that not a bug in either procedure?
Exercise 2. What error does over_limit_global {5 15} give with no setup, and what does that tell you about checking it?
Exercise 3. Rewrite over_limit_global so it can be called twice with different limits on adjacent lines.
Exercise 4. Name the three costs of shared mutable globals.
Exercise 5. Someone argues that the parameterised version in section 5 is no better, because the answers still depend on the order. What is the actual improvement?
Exercise 6. Give one case where a global is the better choice, and the question that decides it.
Answers
1. Because both procedures read and write the same variable, so each one's input is whatever the previous one left. Neither procedure is wrong — add_one adds one and times_ten multiplies by ten, correctly, every time. The result depends on the sequence, which is information that lives outside both of them.
2. can't read "threshold": no such variable. It means the procedure cannot be exercised without first arranging global state, so no check of it is self-contained — and two checks in the same script can interfere if one forgets to reset.
3.
proc over_limit {values threshold} {
set n 0
foreach v $values { if {$v > $threshold} { incr n } }
return $n
}
puts [over_limit {5 15 20} 10]
puts [over_limit {5 15 20} 18]2
14. Order dependence — the answer depends on what ran before. Untestability — the procedure cannot be called without arranging state first, and checks interfere. Action at a distance — a value is changed by a procedure nowhere near the one that reads it.
5. The order dependence is still there, because it is arithmetic. What changed is that it is now visible: the sequence is four consecutive lines a reader can see, instead of a property of a variable's history that has to be reconstructed from the whole script.
6. A single flag or configuration value read in many places and written once, where the alternative is adding a parameter to every procedure between the top and the one that uses it. The deciding question is whether the value is written in more than one place: written once, it is a constant; written by several procedures, it is shared mutable state.
9. Remember
- The problem is shared mutable state, not globals in general. Set once and read often is a constant.
- Cost one: order dependence. The same calls in a different order give different answers, and no procedure can be understood alone.
- Cost two: it cannot be checked on its own. Every check needs a setup line, and checks interfere with each other.
- Cost three: action at a distance. A value is changed by a procedure nowhere near the one that reads it, and only the shared name connects them.
- Passing state explicitly does not remove order dependence — it makes it visible on the page instead of in a variable's history.
- Threading a value through many layers is its own cost; for one widely-read flag a global can be the lesser evil.
- The deciding question: is it written in more than one place?
- Give any global you keep a long, specific, greppable name.
Where this fits
Part of the Tcl curriculum.
