跳转至内容

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 捕获。

errorInfo 变量

[编辑 | 编辑源代码]

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 变量

[编辑 | 编辑源代码]

此外,还有一个 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}

现在我们指示解释器跟踪 facenterleave

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)。你可以为此使用以下 stepinteract 过程

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"} -> ""

受保护的 proc

[编辑 | 编辑源代码]

在更复杂的 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 魔法)留作练习。

Windows wish 控制台

[编辑 | 编辑源代码]

虽然在类 Unix 系统上,标准通道 stdinstdoutstderr 与你从其启动 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"}
华夏公益教科书