Skip to content
VLSI Mentor

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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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]"
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
depth  : 1
call   : show_call 1 2
locals : p q
  • info level — how deep the call stack is. 1 means 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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]"
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
caller's locals : only_mine

uplevel 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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
set threshold 10

proc over {values} {
    set n 0
    foreach v $values { if {$v > $threshold} { incr n } }
    return $n
}

over {5 15}
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
can't read "threshold": no such variable

Diagnosis. This one is loud, which makes it the easy member of the set. info vars confirms what the frame actually holds:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
proc over_debug {values} {
    puts "  locals here: [lsort [info vars]]"
    return 0
}
over_debug {5 15}
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  locals here: values

No 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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
set count 0

proc bump {} {
    incr count          ;# no global declaration
}

bump
bump
bump

puts "count is $count"
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
count is 0

Three 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
set count 0

proc bump_debug {} {
    incr count
    puts "  locals: [lsort [info vars]] | local count=$count | global count=$::count"
}
bump_debug
bump_debug
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  locals: count | local count=1 | global count=0
  locals: count | local count=1 | global count=0

Both 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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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]"
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
bump says  : 4
total says : 0

bump 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
puts "::count        = $::count"
puts "::tally::count = $::tally::count"
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
::count        = 4
::tally::count = 0

Two 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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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]
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
{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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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]
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
depth 2 | this call: where_am_i | caller's vars: wrapper_var

Fix. 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:

  1. Is there an error? If yes, it is almost certainly bug one — a frame that does not contain the name. info vars confirms it in one line.
  2. Does a counter or accumulator stay at its initial value? Bug two. Look for a missing global or variable declaration.
  3. Do two procedures in the same library disagree? Bug three. Print both the global and the namespace-qualified name.
  4. Did someone recently add a wrapper or an extra layer? Bug four. Check every upvar and uplevel level on that path.
  5. Still stuck? Put puts [info level 0] at the top of the suspect procedure and puts [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 array
  • uplevel, 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 why global in 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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
puts $::count
puts $::thenamespace::count

If 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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
puts "[info level 0] | vars: [lsort [info vars]]"

10. Remember

  • info level, info level 0, info vars, and uplevel 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 global where the body only writes — is silent, because creating commands make a local instead.
  • Bug three — global where variable was needed — is silent, and leaves a library with two copies of its state.
  • Bug four — an upvar or uplevel level 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 global versus variable.

Where this fits

Part of the Tcl curriculum.