TCL Wat

 

I was talking about my TCL-writing days with a friend and they very helpfully pointed out that one of my claims here was backward and some of the examples could be clearer. I've corrected the inaccuracies and improved examples throughout. All these gotchas and examples are based on TCL 8.6, and I have no idea if later versions make changes.


At FlightAware, I read and write TCL every day. This means I've run into more than a few edge cases in the language, and have even tracked down a few bugs in TCLlib. Here's a collection of the weirdest things I've encountered.

Empty string is a boolean superposition

TCL's string is command can test whether a value is "true" or "false". Without the -strict flag, an empty string is simultaneously both:

string is true  ""  ;# => 1
string is false ""  ;# => 1

The empty string passes both checks. It is true. It is also false. It exists in boolean superposition.

With -strict, the empty string is instead neither true nor false:

set val ""
if {[string is true -strict $val]} {
    puts "This is true"
} elseif {[string is false -strict $val]} {
    puts "This is false"
} else {
    puts "This is neither true nor false"
}
# => This is neither true nor false

Dict set creates from nothing, chokes on something

The dict set command happily creates a dictionary if the variable doesn't exist yet:

dict set my_dict key value
# my_dict is now: key value

But if the variable already exists as a plain string, it fails with a confusing error:

set my_var "hello"
dict set my_var key value
# => missing value to go with key

The error message "missing value to go with key" makes it sound like you forgot an argument, but the real problem is that dict set tries to interpret "hello" as an existing dictionary, and since the string has an odd number of space-separated strings (in this case, one), it doesn't parse as key-value pairs.

Upvar and pass-by-name

TCL is pass-by-value by default. If you pass a variable to a function, the function gets a copy, and the caller's variable is unchanged. But upvar lets you opt into pass-by-name by passing a variable's name as a string and creating a local alias to the caller's variable:

proc increment {varName} {
    upvar $varName var
    incr var
}

proc swap {a b} {
    upvar $a aVar $b bVar
    set temp $aVar
    set aVar $bVar
    set bVar $temp
}

This is actually pretty useful once you get used to it, but it's definitely not what you'd expect coming from languages where you can't reach into your caller's scope by convention.

Insane string matching

Some TCL commands, like string is, allow matching on shortened or abbreviated strings. While this can lead to "concise" code, it may also introduce unintended and interesting bugs:

string is true true
# => 1
string is tru true
# => 1
string is tr true
# => 1
string is t true
# => 1
string is fa 0
# => 1

If a prefix is ambiguous, TCL at least throws an error: string is a "hello" gives ambiguous class "a": must be alnum, alpha, ascii, .... But unambiguous prefixes silently match, which has no practical benefit and serves only to create bugs. Why would a reasonable person ever want to check if a string "is tr" in production code?

Split nested list, get flat list

When using split on a nested list, the result is a list with members representing the escaped syntax of the nested list(s):

set a {aa bb {cc dd}}
split $a " "
# => {aa bb \{cc dd\}}

Notice that the braces of the nested list {cc dd} have been escaped, resulting in a string that looks like a dict, but is not. This is because under the hood, split operates on the string representation (in C, sort of), not the list structure. This has been the source of a number of real bugs at FlightAware.

Dict loops don't garbage-collect

When using dict with inside a loop, it sets local variables for each key in the current dictionary entry. But when the next iteration has fewer keys, the variables from the previous iteration aren't unset, they linger with their old values:

set data {
    item1 {var1 value1 var2 value2}
    item2 {var2 newvalue2}
}

foreach item {item1 item2} {
    dict with data $item {
        puts "Item: $item, var1: $var1, var2: $var2"
    }
}

Output:

Item: item1, var1: value1, var2: value2
Item: item2, var1: value1, var2: newvalue2

In the second iteration, item2 only defines var2. But var1 still holds value1 from the first iteration. If you're familiar with C, imagine a struct being partially overwritten without zeroing the rest. The variables created by dict with are not scoped to the loop body, and TCL doesn't clean them up between iterations. This has been a real source of bugs at FlightAware.

Comments are not comments

In TCL, # is not special syntax for comments, it's a command! A no-op command, but a command nonetheless. This means it only works in command-name position: the start of a line or after a semicolon.

# this works
set x 5 ;# this also works
set x 5 # this is NOT a comment
# => wrong # args: should be "set varName ?newValue?"

That last line fails because TCL sees #, this, is, etc. as extra arguments to set, not as a comment. The ;# idiom ends up very common in TCL programs precisely because you need the semicolon to start a new command before the # can act as one.

It gets worse. Since TCL counts braces before it interprets commands (which is reasonable since scope matters), an unbalanced brace inside a comment breaks the parser:

proc broken {} {
    # closing brace } here ends the proc body early
    puts "hello"
}
# => wrong # args: should be "proc name args body"

This is simply infuriating.

Uplevel is OP

The uplevel command executes a script in a caller's scope. This lets you build new control structures (essentially macros) but it also means any function can reach into its caller's environment and modify variables:

proc with_logging {body} {
    puts "--- begin ---"
    uplevel 1 $body
    puts "--- end ---"
}

set x 10
with_logging {
    set x [expr {$x * 2}]
    puts "x is now $x"
}
puts "x after: $x"
# => --- begin ---
# => x is now 20
# => --- end ---
# => x after: 20

The body passed to with_logging runs in the caller's scope, not inside the function. It reads and modifies the caller's x directly. This is how people build DSLs and custom control structures in TCL, and it's genuinely powerful, but it also means any function you call might be silently mutating your local variables.

Lack of static typing

TCL is a dynamically typed language, which means variables don't have a fixed type. A variable can change its type during runtime:

set my_var 42
puts "Variable type: [string is integer $my_var]"
# => Variable type: 1
set my_var "hello"
puts "Variable type: [string is integer $my_var]"
# => Variable type: 0

This can lead to flexibility in development, but may also cause unexpected behavior. Especially when dealing with JSON or other structured data formats. On the surface, this behavior is expected because "everything is a string". But it causes bugs when you understand TCL well enough to know that nothing is a string at the fundamental level.

Subst is not a separate compilation step

In TCL, the subst command is used to perform variable and command substitutions within strings. This can be handy in some situations, but it's important to note that subst is executed at runtime:

set x 10
set y "\$x + 5"
puts "Before substitution: $y"
# => Before substitution: $x + 5
puts "After substitution: [subst $y]"
# => After substitution: 10 + 5

This may be surprising if you're expecting TCL to act like a Lisp, and means that you might miss syntax errors until the code is actually executed. Worse, subst on user-provided input will execute any [command] substitutions embedded in that input.

Expr injection

Without braces, expr substitutes variables and commands before evaluating the expression. This means embedded [commands] in variable values get executed:

set x {[exec rm important_file]}
expr $x + 1   ;# executes rm!
expr {$x + 1} ;# safe, $x treated as data

The braced version treats $x as data within the expression parser, where it fails safely as a non-numeric value. The unbraced version does TCL substitution first, so the [exec ...] runs before expr ever sees it. TCL style guides consider unbraced expr a defect, but it's common enough in real code that this is one of the language's most dangerous foot-guns.

Two ways of catching errors

TCL provides error handling mechanisms via the catch, try, and error commands:

if {[catch {some_command} result]} {
    puts "Error encountered: $result"
}

try {
    some_command
} on error {errorMsg} {
    puts "Error encountered: $errorMsg"
}

The catch command can be used to capture any errors or exceptions that occur within a script. try and error offer more sophisticated error handling, but they're still not as clean as exceptions in other languages.

One particularly tricky aspect is that these error handling mechanisms will also set the errorCode global variable, which can be both useful and a curse to debugging. The global ::errorInfo variable (the stack trace) compounds this further because it persists from previous errors and can mislead you into debugging the wrong call site. When a TCL library function is the source of the error, these globals can leak error state between different parts of your code, making it hard to track down where errors are actually coming from. It's especially problematic when functions use error handling as a way of checking for dictionary key existence (*cough* json library).

Lower-case numbers

The string tolower command attempts to convert a string's characters to lowercase. When encountering mixed or non-alphabetic input, the results may be unexpected:

set number_string "42A_B8C"
string tolower $number_string
# => 42a_b8c

Even though the numbers remain the same, the non-alphabetic characters have been modified. This is technically correct behavior, but it's weird that you can "lowercase" a number. Then again, "everything is a string".

Incr creates variables

The incr command increments a variable. Even if the variable doesn't exist.

incr nonexistent
# nonexistent is now 1
incr another 5
# another is now 5

The same pattern applies to lappend, which creates an empty list if the variable doesn't exist. This is occasionally convenient, but it means a typo in a variable name silently creates a new counter instead of erroring. That makes for a frustrating prod bug.


So there you have it, a collection of TCL weirdness from my time working at FlightAware. Some of these are actually reasonable design decisions when you understand the philosophy behind TCL, but others are just... wat.