在我初任软件开发者的第一份工作中, 我使用的主要编程语言是 C++。 考虑到那是在金融行业,这在预料之中。
然而,出乎意料的是我们使用的第二门语言。 那是一门我当时闻所未闻、见所未见的语言。
我敢打赌,看这篇文章的大多数人, 要么没听说过这门语言,要么从未用它写过任何代码。 尽管它在 macOS 上默认安装,在某些 Linux 发行版中也是如此。
这门语言就是 Tickle 或 Tcl, 全称是 工具命令语言 (Tool Command Language)。
至今,它仍可能是我学过的最奇怪的编程语言。 我不仅时至今日仍常想起它, 它还启发了我最喜欢的软件之一:Redis 及其分支,如 Valkey。
但它究竟奇在何处? 虽然它有几个奇怪的特性, 但有一个特性让它与我用过的任何其他编程语言都截然不同。
这是因为,在 Tcl 中,一切皆为字符串。
一切皆为字符串
当我说在 Tcl 中一切皆为字符串时, 我的意思是字面上的一切。
这包括你可能预料到的东西, 比如每个类型都是字符串。
但也包括一些你未必会想到的东西, 比如复杂的数据结构, 甚至语言本身的语法。
所有这些,无疑使它成为我学过的最奇怪的语言。
准备工作:安装 Tcl
在我们深入探讨 Tcl 中“一切皆字符串”的理念之前, 让我们先花点时间写一个经典的“Hello World”来理解字符串是如何工作的。
为此,我们首先需要在机器上安装 Tcl 运行时。 如果你使用的是 macOS,它已默认安装。
然而,你会看到一个警告,提示它未来可能不再可用。 而且版本也有些落后。
因此,我建议使用像 Homebrew 这样的工具来安装最新版本。 命令如下:
brew install tcl-tk
安装完成后,我们就可以使用 tclsh 命令了。
它既可以执行 Tcl 脚本,也可以用作一个 REPL (交互式解释器)。
Hello, Tcl
让我们先在 REPL 中尝试一下 Hello World。
我们只需使用 puts 关键字,后面跟上我们想打印的字符串。
puts "Hello, World"
如你所见,非常简单。
虽然 REPL 很适合实验,但编写更复杂的代码就不那么方便了。 所以,让我们看看如何在文件中编写代码。
我们首先创建一个名为 hello.tcl 的新文件。
然后,我们来编写一个脚本,向一个名字问好,而不是简单的 “Hello World”。
我们可以使用 set 命令创建一个新变量。
它接受两个参数:
变量名(这里是 name)和值(我设为 dreams)。
set name "dreams"
[!TIP] 如果这个语法让你想起了 Redis 或 Valkey, 这并非巧合。 Redis 的创造者 Antirez 从 Tcl 中获得了大量灵感,并实现了他自己的 Tcl 解释器。 事实上,Redis 的第一个版本叫做 LMDB,就是用 Tcl 编写的。
现在,我们的 name 变量定义好了。
让我们看看如何在问候字符串中使用它。
我们再次使用 puts 命令。
要引用我们的变量,可以使用 $name 语法,
这与 PHP 和 Perl 等语言类似。
set name "dreams"
puts "Hello, $name"
当我们使用双引号定义字符串时, Tcl 会自动替换这个变量。
现在,如果我用 tclsh hello.tcl 来运行这个文件,
你会看到控制台打印出 Hello, dreams。
字符串的两种形式:引号 vs. 大括号
在 Tcl 中,定义字符串有两种方式。
假设我们想打印出字面上的 $name,
而不是让它被替换成变量的值。
要做到这一点,我们需要使用大括号 {} 而不是引号来定义字符串。
set name "dreams"
puts {$name}
现在,如果我运行这段代码,
你会看到它打印出 $name,而不是 dreams。
{}大括号:创建不会替换变量值的字符串。""双引号:创建会替换变量值的字符串。
这是一个非常重要的区别, 因为正如我之前提到的,在 Tcl 中,一切皆为字符串。
代码本身也是字符串
为了展示我的意思,让我们创建一个新文件并加入以下这行。
我正在设置一个名为 code 的变量,其值为字符串 puts "hello from a string"。
set code {puts "hello from a string"}
如果我打印这一行,你会看到它只是一个字符串。 然而,因为在 Tcl 中一切皆为字符串, 所以这也是可以被执行的有效代码。
我们可以使用 eval 命令来执行它。
set code {puts "hello from a string"}
eval $code
如你所见,这使得 code 字符串被求值和执行。
我们现在打印出了 hello from a string。
这还不是全部。
命令本身,比如 eval 命令,也是字符串。
这意味着我们可以将要执行的命令 eval 存储为一个字符串。
然后我们可以像下面这样使用我们的 command 和 code 变量。
set command "eval"
set code {puts "hello from a string"}
$command $code
如果我执行它, 你会看到它的行为就像普通代码一样。
这使得该语言具有令人难以置信的动态性。
表达式与数字
当涉及到数字和表达式时,Tcl 的处理方式尤其奇特。
这里我有一个简单的字符串,试图将两个数字相加。
为了执行这个表达式,我们不使用 eval,
而是使用 expr 命令,它用于解析数学和布尔表达式。
我将这个命令包装在一对方括号 [] 中,
这告诉语言首先解析这个命令并返回结果。
类似于在 Lisp 等语言中使用括号。
puts [expr {1 + 1}]
如果我现在运行这段代码,
你会看到它打印出字符串表达式解析后的结果 2。
函数也是字符串
除了类型、表达式和命令是字符串之外, 也许更奇怪的是,函数也是字符串。
要在 Tcl 中定义一个函数,你使用 proc 命令,
它是 procedure (过程) 的简写。
然后你定义一个表示参数列表的字符串, 和一个包含你函数体的字符串。 这个函数体将在过程的命令被调用时被求值。
proc say_hello {to} {
puts "Hello, $to"
}
现在,要调用这个函数,
我可以像下面这样引用函数名,并传入参数 world。
say_hello "world"
你会注意到,我再次使用了大括号来定义这些字符串。 原因有二:
- 这更符合惯例,也让代码更具可读性。
- 这与变量替换和字符串求值的时机有关。
没有语句,只有命令
在大多数编程语言中,你通常有所谓的语句 (statements)。
最常见的可能是 if 语句,用于执行分支逻辑。
Tcl 再次有所不同。
虽然它有 if,但它不是一个语句。
相反,它是一个命令,但其工作方式非常相似。
为了展示 if 命令如何工作,
让我们写一个快速的例子,检查一个数字的值是否大于零。
if 命令接受两个参数:
- 一个包含布尔表达式的字符串。
- 一个如果布尔表达式解析为
true将被求值的字符串。
set num 10
if {$num > 0} {
puts "hello"
}
[!NOTE] 在 Tcl 中,布尔值表示为
0(假) 或非零字符串 (真)。
如果我运行这段代码,你会看到 hello 被打印出来。
如果我把 num 改为 0,则什么也不会打印。
while 循环中的陷阱
同样的情况也适用于 while 命令。
让我们创建一个循环,直到 num 的值大于 5。
while 命令接受一个布尔表达式作为第一个参数,
后面跟着循环体。
set num 0
while {$num < 5} {
incr num 1
puts $num
}
如果我运行这段代码,你会看到它按预期工作。
然而,正如我所提到的,
对于 while 命令,使用大括号定义的字符串至关重要。
它允许字符串在每次循环迭代中被求值,
而不是在遇到字符串时 num 的值只被替换一次。
为了展示这一点,如果我像下面这样更改布尔表达式字符串:
- while {$num < 5} {
+ while "$num < 5" {
incr num 1
puts $num
}
你会发现我们陷入了一个无限循环。
这是因为 num 的值已经被替换为命令调用时的值,即 0。
条件变成了 "0 < 5",它永远为真。
通过使用大括号,我们阻止了解析器过早地替换 num。
相反,while 命令在每次循环中都会对表达式进行求值。
因此,条件保持动态,而不是被冻结在它的初始值。
结构化字符串:列表与字典
Tcl 如何处理像字典和列表这样的非标量数据类型,是它的另一个奇特之处。
列表
在 Tcl 中,列表也只是字符串。 它们的结构是每个元素由空格字符分割。
这意味着我们可以像下面这样定义一个列表, 可以使用引号或大括号。
set my_list {a b c}
当然,Tcl 提供了许多可以与结构化列表一起使用的命令:
llength:返回列表中的元素数量。lindex:访问列表中的一个元素。lappend:在列表末尾追加一个元素。linsert:在列表中插入一个元素。lrange:执行列表切片。
你还可以使用 foreach 命令遍历列表中的每个元素。
它接受三个参数:迭代器变量、要迭代的列表,以及将在每个项目上求值的字符串。
set my_list {a b c}
foreach item $my_list {
puts $item
}
这一切仍然只是在使用字符串。
我可以打印出 my_list 的值来证明这一点,
你会看到它只是一个由空格字符分隔的元素列表。
那么问题来了:我们如何在列表中创建一个包含空格的元素? 有两种方法:
- 使用大括号定义列表,然后用引号定义包含空格的元素。
- 使用
list命令动态创建列表。
# 方法 1
set my_list {a "b c" d}
# 方法 2
set my_list [list a "b c" d]
因为列表只是一个结构化的字符串,
这意味着我们可以在字符串内部创建一个子列表。
我们可以像处理二维数组一样,使用 lindex 命令从中提取元素。
字典
在 Tcl 中,字典是一种键值数据结构。 然而,它们被表示为一个结构化的字符串,类似于列表, 每个键值对由一个空格字符分隔。
这里我有一个名为 saiyan 的字典:
set saiyan {name Goku power_level 9001}
与列表类似,字典也有一系列命令来访问和修改它们内部的值,
这些命令通过 dict 前缀访问。
dict get:访问字典中的一个值。dict set:设置一个值。
set saiyan {name Goku power_level 9001}
dict set saiyan power_level 9002
puts $saiyan
运行后,你会看到原始字符串已被修改。
显式作用域
与大多数编程语言不同,Tcl 的作用域是显式的。
这里我有一个脚本。
我在全局定义了一个变量 x。
然后我定义了一个过程 print_x,它试图打印 x 的值。
set x 10
proc print_x {} {
puts $x
}
print_x
如果我尝试运行这段代码,会产生一个错误,
提示 x 变量不可用,即使我已在全局声明了它。
为了让过程能够访问全局变量,
我们需要在过程的作用域内使用 global 命令将其显式标记为全局变量。
set x 10
proc print_x {} {
global x
puts $x
}
print_x
现在,代码就能按预期工作了。
除了全局作用域,Tcl 在按引用传递值时也是显式的。
如果我希望一个函数修改传入的值,
我需要使用 upvar 命令将该变量绑定到本地作用域中的一个变量。
proc increment {var_name} {
upvar $var_name v
incr v
}
set my_num 5
increment my_num
puts $my_num ;# 输出 6
uplevel:攀爬调用栈
如果这还不够奇怪,uplevel 命令让 Tcl 的作用域变得更加离奇。
它允许一个过程攀爬调用栈并在不同的作用域内执行代码。
例如,这里我有一个函数 outer,它调用了 inner 过程。
inner 过程使用 uplevel 1,这意味着它将调用栈上移一层,
然后打印出它在 outer 内部找到的 x 的值。
proc inner {} {
uplevel 1 {puts $x}
}
proc outer {} {
set x 42
inner
}
outer
如果我运行这段代码,你会看到 inner 过程能够访问在 outer 过程中定义的 x 值并打印它。
你可能会想,这个命令存在的意义何在? 答案是它使得创建 DSL (领域特定语言)、执行元编程, 以及更重要的,创建你自己的控制流命令成为可能。
这里,我定义了一个名为 range 的函数,用于遍历一个值范围。
它利用了 upvar 和 uplevel 命令,
提供了与内置 for 命令类似的接口。
proc range {var start end body} {
upvar 1 $var i
for {set i $start} {$i <= $end} {incr i} {
uplevel 1 $body
}
}
range x 1 5 {
puts "x is $x"
}
Tcl 的用武之地
Tcl 的这些奇特之处使它在几个关键领域非常有用。
1. 嵌入到其他应用程序中
Tcl 主要的闪光点是作为一种嵌入式脚本语言。 因为语言和数据是同一回事, 你可以轻松地执行动态脚本, 这在其他编程语言中可能具有挑战性。
这实际上与 Lua 在 Neovim 或游戏引擎中的角色, 以及 Elisp 在配置 Emacs 中的角色非常相似。
2. 使用 Tk 构建 GUI
Tcl 通常与另一个名为 Tk 的包配对, 它是一个 GUI 框架。 这是使用该语言的第二个主要原因。
在很长一段时间里,它是构建跨平台 GUI 应用程序最简单的方法。 我指的不仅仅是那个时代的简单, 即便是以今天的标准来看,它也极其简单。
为了展示这一点,这里我用 Tk 创建了一个非常简单的 GUI 应用程序。 它有三个元素: 一个显示计数的标签,一个将计数加一的按钮,以及一个将计数重置为零的按钮。
# 初始化 GUI
package require Tk
# 变量
set count 0
# UI 元素
label .label -textvariable count
button .plus -text "+1" -command {incr count}
button .reset -text "Reset" -command {set count 0}
# 布局
pack .label .plus .reset -side left -padx 5 -pady 5
要运行它,你只需使用 wish 命令。
虽然这是一个非常简单的例子,
但你可以从屏幕上的代码中看到,构建它非常简单,
总共只用了 26 行代码。
这就是使用 Tk 的主要好处, 它使得为现有应用程序构建简单的、原生外观的 UI 变得极其容易, 而无需使用功能齐全的 UI 框架。
结论
如你所见,所有这些都使 Tcl 成为一门相当迷人, وإن لم تكن غريبة بعض الشيء، لغة.
尽管它可能有点古怪, 尤其是在更现代的背景下, 它仍然是一门我很高兴能学到的语言。