跳轉至內容

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"}
華夏公益教科書