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.