Tcl 编程/调试
Tcl 本身就是一个很好的老师。不要害怕犯错 - 它通常会给出有用的错误信息。当 tclsh 不带任何参数调用时,它会以交互模式启动并显示 "%" 提示符。用户输入内容并查看结果:结果或错误信息。
尝试交互式地进行独立的测试用例,并在满意后将命令粘贴到编辑器中,可以大大减少调试时间(无需在每次小改动后重启应用程序 - 只需确保它是正确的,然后重启)。
这里有一个带注释的会话记录
% hello invalid command name "hello"
好的,我们应该输入一个命令。虽然看起来不像,但这里有一个
% hi 1 hello 2 hi
交互式 tclsh 试图猜测我们的意思,而 "hi" 是 "history" 命令的明确前缀,我们在这里看到了它的结果。另一个值得记住的命令是 "info"
% info wrong # args: should be "info option ?arg arg ...?"
错误信息告诉我们应该至少有一个选项,并可选地更多参数。
% info option bad option "option": must be args, body, cmdcount, commands, complete, default, exists, functions, globals, hostname, level, library, loaded, locals, nameofexecutable, patchlevel, procs, script, sharedlibextension, tclversion, or vars
另一个有用的错误:"option" 不是选项,而是列出了有效的选项。要获取有关命令的信息,最好键入以下内容
% info commands tell socket subst lremove open eof tkcon_tcl_gets pwd glob list exec pid echo dir auto_load_index time unknown eval lrange tcl_unknown fblocked lsearch gets auto_import case lappend proc break dump variable llength tkcon auto_execok return pkg_mkIndex linsert error bgerror catch clock info split thread_load loadvfs array if idebug fconfigure concat join lreplace source fcopy global switch which auto_qualify update tclPkgUnknown close clear cd for auto_load file append format tkcon_puts alias what read package set unalias pkg_compareExtension binary namespace scan edit trace seek while flush after more vwait uplevel continue foreach lset rename tkcon_gets fileevent regexp tkcon_tcl_puts observe_var tclPkgSetup upvar unset encoding expr load regsub history exit interp puts incr lindex lsort tclLog observe ls less string
哦,我的天,真多... 有多少个?
% llength [info commands] 115
现在来完成一个更实际的任务 - 让 Tcl 计算圆周率的值。
% expr acos(-1) 3.14159265359
嗯... 我们能以更高的精度得到它吗?
% set tcl_precision 17 17 % expr acos(-1) 3.1415926535897931
回到第一次尝试,其中 "hello" 是一个无效命令。让我们创建一个有效的命令
% proc hello {} {puts Hi!}
静默承认。现在测试
% hello Hi!
在 Tcl 中称为 error 的东西实际上更像是其他语言中的 exception - 你可以故意引发错误,也可以 catch 错误。示例
if {$username eq ""} {error "please specify a user name"}
if [catch {open $filename w} fp] { error "$filename is not writable" }
错误的一个原因可能是未定义的命令名称。可以使用它来玩弄,与 catch 一起使用,如下面的多循环 break 示例,当矩阵元素为空时,它会终止两个嵌套循环
if [catch { foreach row $matrix { foreach col $row { if {$col eq ""} throw } } }] {puts "empty matrix element found"}
throw 命令在正常的 Tcl 中不存在,因此它会抛出一个错误,该错误被外部循环周围的 catch 捕获。
Tcl 提供的这个全局变量包含最后一条错误信息和最后一次错误的回溯。一个愚蠢的例子
% proc foo {} {bar x} % proc bar {input} {grill$input} % foo invalid command name "grillx"
% set errorInfo invalid command name "grillx" while executing "grill$input" (procedure "bar" line 1) invoked from within "bar x" (procedure "foo" line 1) invoked from within "foo"
如果还没有发生错误,errorInfo 将包含空字符串。
此外,还有一个 errorCode 变量,它返回一个最多包含三个元素的列表
- 类别(POSIX、ARITH 等)
- 最后一次错误的缩写代码
- 人类可读的错误文本
例子
% open not_existing couldn't open "not_existing": no such file or directory % set errorCode POSIX ENOENT {no such file or directory}
% expr 1/0 divide by zero % set errorCode ARITH DIVZERO {divide by zero}
% foo invalid command name "foo" % set errorCode NONE
要快速了解一些过程是如何调用的,以及何时调用它们,以及它们返回什么,以及何时返回,trace execution 是一个有价值的工具。让我们以以下阶乘函数为例
proc fac x {expr {$x<2? 1 : $x * [fac [incr x -1]]}}
我们需要提供一个处理程序,该处理程序将在不同的参数数量下被调用(进入时两个参数,离开时四个参数)。这里有一个非常简单的处理程序
proc tracer args {puts $args}
现在我们指示解释器跟踪 fac 的 enter 和 leave
trace add execution fac {enter leave} tracer
让我们用 7 的阶乘来测试它
fac 7
这将给出以下输出
{fac 7} enter {fac 6} enter {fac 5} enter {fac 4} enter {fac 3} enter {fac 2} enter {fac 1} enter {fac 1} 0 1 leave {fac 2} 0 2 leave {fac 3} 0 6 leave {fac 4} 0 24 leave {fac 5} 0 120 leave {fac 6} 0 720 leave {fac 7} 0 5040 leave
因此我们可以看到递归如何下降到 1,然后以相反的顺序返回,逐步建立最终的结果。 "leave" 行中出现的第二个词 0 是返回状态,0 代表 TCL_OK。
要找出 proc 的确切工作方式(以及哪里出了问题),你还可以注册命令,这些命令在过程内部的命令被调用之前和之后调用(递归地传递到所有调用的 proc)。你可以为此使用以下 step 和 interact 过程
proc step {name {yesno 1}} { set mode [expr {$yesno? "add" : "remove"}] trace $mode execution $name {enterstep leavestep} interact }
proc interact args { if {[lindex $args end] eq "leavestep"} { puts ==>[lindex $args 2] return } puts -nonewline "$args --" while 1 { puts -nonewline "> " flush stdout gets stdin cmd if {$cmd eq "c" || $cmd eq ""} break catch {uplevel 1 $cmd} res if {[string length $res]} {puts $res} } }
#----------------------------Test case, a simple string reverter: proc sreverse str { set res "" for {set i [string length $str]} {$i > 0} {} { append res [string index $str [incr i -1]] } set res }
#-- Turn on stepping for sreverse: step sreverse sreverse hello
#-- Turn off stepping (you can also type this command from inside interact): step sreverse 0 puts [sreverse Goodbye]
上面的代码在源代码中加载到 tclsh 时,会给出以下记录
{set res {}} enterstep --> ==> {for {set i [string length $str]} {$i > 0} {} { append res [string index $str [incr i -1]] }} enterstep --> {string length hello} enterstep --> ==>5 {set i 5} enterstep --> ==>5 {incr i -1} enterstep --> ==>4 {string index hello 4} enterstep --> ==>o {append res o} enterstep --> ==>o {incr i -1} enterstep --> ==>3 {string index hello 3} enterstep --> ==>l {append res l} enterstep --> ==>ol {incr i -1} enterstep --> ==>2 {string index hello 2} enterstep --> ==>l {append res l} enterstep --> ==>oll {incr i -1} enterstep --> ==>1 {string index hello 1} enterstep --> ==>e {append res e} enterstep --> ==>olle {incr i -1} enterstep --> ==>0 {string index hello 0} enterstep --> ==>h {append res h} enterstep --> ==>olleh ==> {set res} enterstep --> ==>olleh eybdooG
检查出错原因的最简单方法是在出错位置之前插入一个 puts 命令。假设你想查看变量 x 和 y 的值,只需插入
puts x:$x,y:$y
(如果字符串参数不包含空格,则不需要加引号)。输出将发送到 stdout - 你启动脚本的控制台。在 Windows 或 Mac 上,你可能需要添加命令
console show
以获取 Tcl 为你创建的替代控制台,当没有真正的控制台存在时。
如果你想在某些时候查看程序的详细信息,而在其他时候不想查看,你可以定义和重新定义一个 dputs 命令,它要么调用 puts,要么什么也不做
proc d+ {} {proc dputs args {puts $args}} proc d- {} {proc dputs args {}} d+ ;# initially, tracing on... turn off with d-
为了获得更舒适的调试体验,请将上面的 proc interact 添加到你的代码中,并在出错位置之前添加一个调用 interact 的命令。在这样的调试提示符下,一些有用的操作是
info level 0 ;# shows how the current proc was called info level ;# shows how deep you are in the call stack uplevel 1 ... ;# execute the ... command one level up, i.e. in the caller of the current proc set ::errorInfo ;# display the last error message in detail
检查数据是否满足某些条件是编码中的常见操作。绝对不能容忍的条件可以直接抛出一个错误
if {$temperature > 100} {error "ouch... too hot!"}
出错位置在 ::errorInfo 中很明显,如果你编码如下,它看起来会更清楚一些(没有提及错误命令)
if {$temperature > 100} {return -code error "ouch... too hot!"}
如果你不需要手工制作的错误信息,你可以将这些检查分解为一个 assert 命令
proc assert condition { set s "{$condition}" if {![uplevel 1 expr $s]} { return -code error "assertion failed: $condition" } }
用例如下所示
assert {$temperature <= 100}
请注意,条件被反转了 - 因为 "assert" 大致意味着 "认为成立",所以指定了肯定情况,如果它不满足,就会引发错误。
对内部条件(不依赖于外部数据的条件)的测试可以在开发期间使用,当编码人员确信它们是防弹的,总是会成功时,他/她可以在一个地方集中关闭它们,方法是定义
proc assert args {}
这样,断言根本不会被编译成字节码,并且可以保留在源代码中作为一种文档。
如果断言被测试,它只会在代码中的断言位置发生。使用跟踪,还可以只指定一个条件,并在变量的值发生变化时测试它
proc assertt {varName condition} { uplevel 1 [list trace var $varName w "assert $condition ;#"] }
跟踪末尾的 ";#" 会导致在跟踪触发时附加到命令前缀的附加参数 name element op 被忽略为注释。
测试
% assertt list {[llength $list]<10} % set list {1 2 3 4 5 6 7 8} 1 2 3 4 5 6 7 8 % lappend list 9 10 can't set "list": assertion failed: 10<10
错误信息没有那么清晰,因为 [llength $list] 已经在其中被替换了。但我在这个早餐娱乐项目中找不到一个简单的解决方案 - 在 assertt 代码中对 $condition 进行反斜杠处理当然没有帮助。欢迎提出更好的想法。
为了使断言条件更易读,我们可以再对条件加引号一次,即
% assertt list {{[llength $list]<10}}
% set list {1 2 3 4 5 6 7 8}
1 2 3 4 5 6 7 8
% lappend list 9 10
can't set "list": assertion failed: [llength $list]<10
%
在这种情况下,当跟踪触发器触发时,断言的参数为 {[llength $list]<10}。
无论如何,这五行代码给了我们一种边界检查 - 原则上,Tcl 的数据结构大小只受可用虚拟内存的限制,但与少数对可疑变量的 assertt 调用相比,失控循环可能更难调试
assertt aString {[string length $aString]<1024}
或
assertt anArray {[array size anArray] < 1024*1024}
Tcllib 有一个 control::assert,它具有更多功能。
错误总是会发生。越早发现,对于程序员来说就越容易,因此“尽早测试,经常测试”的黄金法则应该真正得到应用。
一个简单的方法是在 Tcl 代码文件中添加自测试。当该文件作为库的一部分被加载时,只有 proc 定义会被执行。但是,如果你直接将该文件提供给 tclsh,则会检测到该事实,并且“e.g.”调用会被执行。如果结果不是预期的,则会在 stdout 上报告;最后,你还会得到一些统计信息。
以下是一个实现和演示“e.g.”的文件。
# PROLOG -- self-test: if this file is sourced at top level: if {[info exists argv0]&&[file tail [info script]] eq [file tail $argv0]} { set Ntest 0; set Nfail 0 proc e.g. {cmd -> expected} { incr ::Ntest catch {uplevel 1 $cmd} res if {$res ne $expected} { puts "$cmd -> $res, expected $expected" incr ::Nfail } } } else {proc e.g. args {}} ;# does nothing, compiles to nothing
##------------- Your code goes here, with e.g. tests following proc sum {a b} {expr {$a+$b}} e.g. {sum 3 4} -> 7
proc mul {a b} {expr {$a*$b}} e.g. {mul 7 6} -> 42
# testing a deliberate error (this way, it passes): e.g. {expr 1/0} -> "divide by zero"
## EPILOG -- show statistics: e.g. {puts "[info script] : tested $::Ntest, failed $::Nfail"} -> ""
在更复杂的 Tcl 软件中,可能会发生一个过程被定义两次,但具有不同的主体和/或参数,从而导致难以追踪的错误。Tcl 命令 proc 本身不会在以现有名称调用时报错。以下是一种添加此功能的方法。在你的代码早期,你可以像这样重载 proc 命令
rename proc _proc _proc proc {name args body} { set ns [uplevel namespace current] if {[info commands $name]!="" || [info commands ${ns}::$name]!=""} { puts stderr "warning: [info script] redefines $name in $ns" } uplevel [list _proc $name $args $body] }
从该文件被加载开始,任何尝试覆盖 proc 名称的行为都会被报告到 stderr(在 Win-wish 上,它会在控制台中以红色显示)。你可以通过在“puts stderr ...”之后添加“exit”来使其非常严格,或者抛出一个错误。
已知功能:带有通配符的 proc 名称会陷入此陷阱,例如
proc * args {expr [join $args *]*1}
将始终导致投诉,因为“*”匹配任何 proc 名称。修复(在 'name' 上进行一些 regsub 魔法)留作练习。
虽然在类 Unix 系统上,标准通道 stdin、stdout 和 stderr 与你从其启动 wish 的终端相同,但 Windows wish 通常没有这些标准通道(并且通常使用双击启动)。为了帮助解决这个问题,添加了一个控制台,它接管了标准通道(stderr 甚至以红色显示,stdin 以蓝色显示)。控制台通常是隐藏的,但可以使用以下命令显示
console show
你也可以使用部分文档化的“console”命令。“console eval <script>” 在管理控制台的 Tcl 解释器中执行给定的脚本。控制台的文本区域实际上是在这个解释器中创建的文本窗口。例如
console eval {.console config -font Times}
将更改控制台的字体为“Times”。由于控制台是一个 Tk 文本窗口,因此你可以在其上使用所有文本窗口命令和选项(例如,更改颜色、绑定...)。
console eval {winfo children .}
告诉你有关控制台窗口的更多信息:它是一个顶层窗口,其子窗口为 .menu、.console(文本)和 .sb(滚动条)。你可以使用以下命令调整整个窗口的大小
console eval {wm geometry . $Wx$H+$X+$Y}
其中 $W 和 $H 是以字符单元为单位的尺寸(默认值为 80x24),但 $X 和 $Y 是以像素为单位的。
以及更多:你甚至可以在控制台中添加窗口 - 尝试
console eval {pack [button .b -text hello -command {puts hello}]}
该按钮出现在文本窗口和滚动条之间,看起来和操作如预期一样。还有一个返回方法:主解释器在控制台解释器中以 consoleinterp 的名称可见。
以下是一个关于如何连接两个 Tcl 进程以使其中一个(称为“调试器”)能够检查和控制另一个(称为“被调试者”)的简单实验。两者都必须运行事件循环(当 Tk 运行时或使用例如 vwait forever 启动时为真)。
由于这是通过套接字连接进行的,因此这两个进程可以位于不同的主机和操作系统上(尽管我到目前为止只测试了本地主机类型)。当然,使用风险自负... :^)
在我的实验中,“被调试者”包含以下代码,除了它自己的
proc remo_server {{port 3456}} { set sock [socket -server remo_accept $port] } proc remo_accept {socket adr port} { fileevent $socket readable [list remo_go $socket] } proc remo_go {sock} { gets $sock line catch {uplevel \#0 $line} res puts $sock $res if [catch {flush $sock}] {close $sock} } remo_server
此版本的“调试器”(remo.tcl)仅在 Windows 的 wish 中运行,因为它需要一个控制台,但你可以修改它以避免这些限制
#!/usr/bin/env wish console show wm withdraw . set remo [socket localhost 3456] fileevent $remo readable "puts \[gets $remo\]" proc r args {puts $::remo [join $args]; flush $::remo} puts "remote connection ready - use r to talk"
现在,你可以从 remo 调用“被调试者”中的任何 Tcl 命令,它在全局范围内执行,因此你可以特别检查(和修改)全局变量。但你也可以动态重新定义 proc,或者做任何你想做的事情...来自 remo 会话的示例,显示两者具有不同的 pid,如何报告错误,以及引用与正常引用不同(需要更多工作)
10 % pid 600 11 % r pid 2556 12 % r wm title . "Under remote control" wrong # args: should be "wm title window ?newTitle?" 13 % r wm title . {"Under remote control"}