Tcl Part I · Module 11 · Scope and Namespaces
Scope Bugs and How to Spot Them
This module has introduced five ways to reach a variable that is not your own, and each of them has a characteristic way of going wrong. Four failures account for almost all of it in practice, and the uncomfortable fact is that three of the four produce no error message whatsoever — the script runs, finishes, and quietly does the wrong thing. That makes this the one area of Part I where knowing the diagnostic commands genuinely matters, because you cannot wait for Tcl to tell you. This closing chapter takes each failure in turn with its symptom, a reproduction you can run, and the command that exposes it.
Intermediate12 min readTclPart IScopeDebuggingDiagnostics
Part I · Module 11 · Chapter 11.9 · Scope Bugs and How to Spot Them
1. The three diagnostic commands
Before the bugs, the tools. All three answer questions about the frame you are in.
proc show_depth {} {
return [info level]
}
proc show_call {a b} {
return [info level 0]
}
proc show_locals {} {
set p 1
set q 2
return [lsort [info vars]]
}
puts "depth : [show_depth]"
puts "call : [show_call 1 2]"
puts "locals : [show_locals]"depth : 1
call : show_call 1 2
locals : p qinfo level— how deep the call stack is.1means called from the top level.info level 0— this call, as it was written: the procedure name and the actual arguments.info vars— the names visible in this frame, which for a procedure means its locals plus anything it has declared.
And the fourth, which looks into the caller:
proc inspect_caller {} {
return [uplevel 1 {lsort [info vars]}]
}
proc the_caller {} {
set only_mine 1
return [inspect_caller]
}
puts "caller's locals : [the_caller]"caller's locals : only_mineuplevel 1 {info vars} runs the inspection one frame up — exactly the technique from Chapter 11.5, used on itself.
2. Bug one: a procedure cannot see an outer variable
Symptom: can't read "x": no such variable, on a name that is clearly set.
set threshold 10
proc over {values} {
set n 0
foreach v $values { if {$v > $threshold} { incr n } }
return $n
}
over {5 15}can't read "threshold": no such variableDiagnosis. This one is loud, which makes it the easy member of the set. info vars confirms what the frame actually holds:
proc over_debug {values} {
puts " locals here: [lsort [info vars]]"
return 0
}
over_debug {5 15} locals here: valuesNo threshold. The procedure's frame contains its parameter and whatever it set — the rule from Chapter 11.1.
Fix. Pass it as a parameter, which is almost always right, or declare it with global if it genuinely is shared state.
3. Bug two: a missing global, writing only
Symptom: none. The script runs, and a counter never moves.
set count 0
proc bump {} {
incr count ;# no global declaration
}
bump
bump
bump
puts "count is $count"count is 0Three calls, no error, no change. This is Chapter 11.1 §7's asymmetry: because incr creates a missing variable, the procedure made a fresh local each time and threw it away.
Diagnosis. Print the frame's contents and the global's value together:
set count 0
proc bump_debug {} {
incr count
puts " locals: [lsort [info vars]] | local count=$count | global count=$::count"
}
bump_debug
bump_debug locals: count | local count=1 | global count=0
locals: count | local count=1 | global count=0Both lines say the same thing, which is the proof. The local reaches 1 and the global stays 0 — on every call, because each call builds a fresh local and discards it. A counter that reports 1 every time is a local being rebuilt.
Note also that info vars lists count as a local. Seeing the shared name you expected to be global sitting in the procedure's own frame is the clearest single signal of this bug.
Fix. global count as the first line of the procedure.
4. Bug three: a namespace variable declared with global
Symptom: none. Two procedures in the same library disagree about a value.
namespace eval tally {
variable count 0
proc bump {} {
global count ;# wrong command
incr count
return $count
}
proc total {} {
variable count
return $count
}
}
tally::bump
tally::bump
tally::bump
puts "bump says : [tally::bump]"
puts "total says : [tally::total]"bump says : 4
total says : 0bump is counting correctly — into ::count. total is reading ::tally::count, which nobody has touched. Both procedures are internally consistent and the library is broken.
Diagnosis. Print both names:
puts "::count = $::count"
puts "::tally::count = $::tally::count"::count = 4
::tally::count = 0Two variables, one of them a stray global that should not exist at all. info exists ::count returning 1 for a name your library never meant to create is the giveaway.
Fix. variable count instead of global count, as Chapter 11.7 set out.
5. Bug four: an upvar or uplevel level off by one
Symptom: none, usually. The procedure operates on the wrong frame, which often exists and often has nothing useful in it.
proc level_one {} {
set marker "level_one's variable"
level_two
}
proc level_two {} {
return [level_three]
}
proc level_three {} {
return [list \
"level 1: [uplevel 1 {info exists marker}]" \
"level 2: [uplevel 2 {info exists marker}]"]
}
puts [level_one]{level 1: 0} {level 2: 1}From level_three, level 1 is level_two — which has no marker — and level 2 is level_one, which does. A helper that was written assuming it would be called directly, and is later called through one more layer, silently starts looking in the wrong place.
Diagnosis. info level tells you how deep you are, and uplevel N {info vars} tells you what is in each frame:
proc where_am_i {} {
set out "depth [info level]"
append out " | this call: [info level 0]"
append out " | caller's vars: [uplevel 1 {lsort [info vars]}]"
return $out
}
proc wrapper {} {
set wrapper_var 1
return [where_am_i]
}
puts [wrapper]depth 2 | this call: where_am_i | caller's vars: wrapper_varFix. Make the level explicit and documented. If a helper must work at a variable depth, pass the level in as a parameter rather than hard-coding a number that is only correct for one call path.
6. A checklist
When something scope-related is wrong:
- Is there an error? If yes, it is almost certainly bug one — a frame that does not contain the name.
info varsconfirms it in one line. - Does a counter or accumulator stay at its initial value? Bug two. Look for a missing
globalorvariabledeclaration. - Do two procedures in the same library disagree? Bug three. Print both the global and the namespace-qualified name.
- Did someone recently add a wrapper or an extra layer? Bug four. Check every
upvaranduplevellevel on that path. - Still stuck? Put
puts [info level 0]at the top of the suspect procedure andputs [lsort [info vars]]after its declarations. Those two lines answer most of the remaining questions.
7. What Module 11 covered
Nine chapters on where a name lives:
- Two scopes, not a chain — one global, one per procedure call, with no automatic nesting
global, which links a local name to the top-level variable and refuses a name already in use- Why shared mutable globals cost you — order dependence, untestability, action at a distance
upvar, which links to a caller's variable and is the only way to pass an arrayuplevel, which runs a whole script in another frame and is how control-structure-like commands are built- Namespaces, which fix the silent collision Module 10 left you with
variable, and whyglobalin a namespace procedure quietly creates a second copy of your state- Qualified names, and the asymmetry that command lookup falls back while variable lookup does not
- The four scope bugs, three of which are silent
Two threads run through it. The first is the asymmetry first met in Chapter 10.6 and formalised in Chapter 11.8: commands are shared and searched outward; variables are private and never searched outward. Almost every rule in this module is one side of that. The second is that sharing is always an explicit opt-in — global, variable, upvar, uplevel and a qualified name are the only ways, and if none of them appears in a procedure, that procedure cannot touch anything outside itself.
8. What comes next
Module 12 is Files, Directories, and Paths — reading and writing real files, what a channel actually is, why output sometimes goes missing, what happens when a file is not there, and the path commands that let a script work no matter which directory it was started from.
It is the first module in Part I where your script interacts with something outside the interpreter, and that brings a new category of failure: things that were true when you checked and are not true any more.
9. Exercises
Exercise 1. Name the three diagnostic commands from section 1 and what each reports.
Exercise 2. Which of the four bugs produces an error message, and why do the other three not?
Exercise 3. A counter procedure returns 1 every time it is called. What is wrong?
Exercise 4. Two procedures in one namespace disagree about a count. Give the two commands that confirm the cause.
Exercise 5. A helper using upvar 1 worked, then stopped working after a logging wrapper was added. Explain.
Exercise 6. Write a one-line debug statement you could add to any procedure to see both how it was called and what its frame holds.
Answers
1. info level reports the call-stack depth; info level 0 reports the current call as written, with its arguments; info vars reports the names visible in the current frame.
2. Only bug one — a procedure reading a variable its frame does not contain. The other three are silent because each one succeeds at something: a missing global lets a creating command make a local; global in a namespace updates a real global; and a wrong upvar level reaches a frame that genuinely exists. Nothing is invalid, so nothing is reported.
3. A missing global (or variable) declaration. incr is creating a fresh local on every call, incrementing it to 1, and discarding it when the procedure returns.
4.
puts $::count
puts $::thenamespace::countIf one has moved and the other has not, a procedure used global where it needed variable.
5. The wrapper added a frame. upvar 1 from the helper now refers to the wrapper's frame rather than the original caller's, so it links to a variable that does not exist there — and because upvar creates the variable if absent, the helper writes into the wrapper's frame and the result is discarded when the wrapper returns.
6.
puts "[info level 0] | vars: [lsort [info vars]]"10. Remember
info level,info level 0,info vars, anduplevel 1 {info vars}answer almost every scope question.- Bug one — cannot see an outer variable — is the only loud one:
can't read "x": no such variable. - Bug two — a missing
globalwhere the body only writes — is silent, because creating commands make a local instead. - Bug three —
globalwherevariablewas needed — is silent, and leaves a library with two copies of its state. - Bug four — an
upvaroruplevellevel off by one — is silent, because the wrong frame usually exists. - Three of the four produce no error, which is why the diagnostics matter here more than anywhere else in Part I.
- Adding a wrapper shifts every level by one for anything it calls.
- A counter stuck at its initial value means a missing declaration; two procedures disagreeing means
globalversusvariable.
Where this fits
Part of the Tcl curriculum.
