跳至主要内容

偵錯工具

Debugger

在撰寫 Python 程式的時候,如果想要知道某個變數的值或是函數執行的結果,常會使用 print() 函數來印出點東西來看看。這樣雖然簡單、直覺,但當專案變複雜的時候,只靠 print() 函數抓問題的效率不太夠。Python 有一個內建的偵錯器叫做 pbd,可以幫助我們更有效率地找出問題。

什麼是偵錯器

偵錯器(Debugger)是一種用來找出程式碼中錯誤的工具。當我們的程式碼出現問題時,我們可以透過偵錯器在程式碼的特定位置設置「中斷點(Breakpoint)」,當 Python 程式走到中斷點時,程式就像被按暫停一樣,然後我們趁這個機會做我們想做的事...我是指檢查變數的值、逐步執行程式碼、找出問題所在。

使用 Pdb 偵錯器

先上一段程式碼:

import math

square = lambda n: math.pow(n, 2)

def calc_numbers(numbers, fn):
data = []
for n in numbers:
data.append(fn(n))

return data


numbers = [1, 4, 5, 0, 9, 5, 2, 7]
print(calc_numbers(numbers, square))

如果各位在基礎篇都學的不錯的話,上面這段程式碼應該難不倒大家,簡單的說就是把某個串列跟函數傳給 calc_numbers() 函數,最後得到一新的串列。

在之前如果我們想要知道 calc_numbers() 函數到底接了什麼參數,或是串列 data 的值是什麼,我們可能會使用 print() 函數:

def calc_numbers(numbers, fn):
data = []
print(data, members) # <-- 在這裡印出來
for n in numbers:
# ... 略...

如果使用 Debugger 的話,可以在想要停下來的地方加上 breakpoint() 函數,像這樣:

def calc_numbers(numbers, fn):
data = []
breakpoint() # <-- 在這裡設定中斷點
for n in numbers:
data.append(fn(n))

return data

這樣待會執行到這行程式的時候就會停下來,像這樣:

$ python demo.py
> /private/tmp/demo.py(8)calc_numbers()
-> for n in numbers:
(Pdb)

這裡看到的 demo.py(8) 表示目前程式停在 demo.py 這個檔案的第 7 行,然後即將執行第 8 行的 for 迴圈,而在前面出現的 (Pdb) 提示字元表示目前我們正在 Debugger 的環境裡。先使用 hhelp 指令看看在 Pdb 裡有哪些指令可以用:

(Pdb) h

Documented commands (type help <topic>):
========================================
EOF c d h list q rv undisplay
a cl debug help ll quit s unt
alias clear disable ignore longlist r source until
args commands display interact n restart step up
b condition down j next return tbreak w
break cont enable jump p retval u whatis
bt continue exit l pp run unalias where

雖然指令看起來好像有點多,但有些指令是同一個,只是完整跟簡寫的差別而已,而且其實常用的指令就只有那幾個...

觀察狀態

我們先用最簡單的 p 指令來印點東西:

(Pdb) p data
[]
(Pdb) p fn
<function <lambda> at 0x1008162a0>

p 指令就是 print 的意思,表示可以把某個值印出來看看。可以看到在這個當下,串列 data 才剛剛被初始化,所以還是空的。使用 llist 指令,可以看到目前所在位置附近的程式碼:

(Pdb) l
3 square = lambda n: math.pow(n, 2)
4
5 def calc_numbers(numbers, fn):
6 data = []
7 breakpoint()
8 -> for n in numbers:
9 data.append(fn(n))
10
11 return data
12
13
(Pdb) l
14 numbers = [1, 4, 5, 0, 9, 5, 2, 7]
15 print(calc_numbers(numbers, square))
[EOF]

l 指令會印出目前所在位置的前後幾行程式碼,如果再執行一次會再往下繼續印,最後一行的 [EOF] 表示已經到底了(End Of File)。如果想再回去看剛剛的程式碼,使用 l 指令的時候加上行號,例如 l 10 就會印出第 10 行附近的程式碼,除了行號也可以使用 . 印出目前所在位置的程式碼。

另一個跟 l 有點像的,是 ll 或是 longlist 指令,這會印出整個函數的程式碼:

(Pdb) ll
5 def calc_numbers(numbers, fn):
6 data = []
7 breakpoint()
8 -> for n in numbers:
9 data.append(fn(n))
10
11 return data

執行程式

Pdb 不是只能讓我們觀察狀態,我們也可以用它來執行程式碼。透過 nnext 指令,可以執行下一行程式碼:

(Pdb) n
> /private/tmp/demo.py(9)calc_numbers()
-> data.append(fn(n))

這表示我們剛剛執行了第 8 行的 for 迴圈,接下來準備執行第 9 行的程式碼。這時可再使用 l 指令檢視一下:

(Pdb) l
4
5 def calc_numbers(numbers, fn):
6 data = []
7 breakpoint()
8 for n in numbers:
9 -> data.append(fn(n))
10
11 return data
12
13
14 numbers = [1, 4, 5, 0, 9, 5, 2, 7]

前面的箭頭表示準備執行第 9 行,猜猜看如果印出 data 會看到什麼:

(Pdb) p data
[]

這時候的 data 是空的,因為我們還沒有執行 data.append(fn(n)) 這行程式碼。如果這時再執行一次 n 指令,會進到下一個迴圈,data 的值就會變的不一樣了:

(Pdb) n
> /private/tmp/demo.py(8)calc_numbers()
-> for n in numbers:
(Pdb) p data
[1.0]

可以看到目前 data 裡面只有一個元素,這是因為我們剛剛執行了 data.append(fn(n)) 這行程式碼,所以 data 裡面有了一個元素。

除了看到目前所在位置可以檢視的變數外,我們也可使用 uup 指令,往上一層看看:

(Pdb) u
> /private/tmp/demo.py(15)<module>()
-> print(calc_numbers(numbers, square))
(Pdb) p data
*** NameError: name 'data' is not defined

因為現在往上一層了,對外面來說 data 變數是不存在的。可以往上也就可以往下一層,是使用 ddown 指令:

(Pdb) d
> /private/tmp/demo.py(8)calc_numbers()
-> for n in numbers:
(Pdb) p data
[1.0]

這樣就又回到原本的位置了。剛才的 n 指令是執行下一行程式碼,如果我想要看看那個 fn(n),也就是傳進去的 square 函數在什麼的話,可使用 sstep 指令:

(Pdb) s
--Call--
> /private/tmp/demo.py(3)<lambda>()
-> square = lambda n: math.pow(n, 2)

可以看到現在箭頭走到 square 這個函數裡了,接著可以繼續 n

(Pdb) n
> /private/tmp/demo.py(3)<lambda>()
-> square = lambda n: math.pow(n, 2)
(Pdb) n
--Return--
> /private/tmp/demo.py(3)<lambda>()->16.0
-> square = lambda n: math.pow(n, 2)
(Pdb) n
> /private/tmp/demo.py(8)calc_numbers()
-> for n in numbers:

n 幾次之後,會看到 --Return--,這表示我們準備要已經離開了 square 函數並且有回傳值 2,再繼續 n 就會回到原本的位置。

因為這裡是一個 for 迴圈,所以執行 n 的時候會一直在迴圈裡轉啊轉的,直到轉完為止。如果我想要快一點跳到某一行,可使用 until 指令並且在後面加上行數:

(Pdb) until 11
> /private/tmp/demo.py(11)calc_numbers()
-> return data
(Pdb) p data
[1.0, 16.0, 25.0, 0.0, 81.0, 25.0, 4.0, 49.0]

這樣 Pdb 會執行到行數大於或等於第 11 行的程式碼,這樣就可以結束這個迴圈,這時候也可以看到 data 串列裡的值也都算完了。

另外兩個常用來控制流程的指令,一個是 ccontinue,這個指令會讓程式一直執行到下一個中斷點,不過因為我們的範例裡只有一個中斷點,所以會直接執行到程式結束為止。另一個指令是 q 或是 quit,這個指令會直接讓 Pdb 結束並且離開程式。

修改變數值

在 Pdb 中除了可以檢視資料,在程式執行過程也可以修改變數的值,這就是 print() 函數做不到的事。一樣的範例,我重新執行一次程式:

$ python demo.py
> /private/tmp/demo.py(8)calc_numbers()
-> for n in numbers:
(Pdb) p data
[]
(Pdb) data = ['Hey']
(Pdb) c
['Hey', 1.0, 16.0, 25.0, 0.0, 81.0, 25.0, 4.0, 49.0]

在 Pdb 裡可以寫任何 Python 的程式碼,也可以直接修改變數的值。這裡我把 data 的值改成 ['Hey'],然後再繼續執行 c 指令執行到程式結束,最後看到 data 的結果就會變的不一樣了。

動態設定中斷點

除了直接在程式碼裡加入 breakpoint() 函數設定中斷點外,我們也可以在 Pdb 執行的過程使用 bbreak 指令動態加入中斷點。我再重新執行一次同樣的範例:

$ python demo.py
> /private/tmp/demo.py(8)calc_numbers()
-> for n in numbers:
(Pdb) b 11
Breakpoint 1 at /private/tmp/demo.py:11

bbreak 指令後面加上數字,表示要在第幾行設定中斷點,例如 b 9 指令就是在第 9 行設定一個中斷點。如果只執行 bbreak 指令沒加任何數字的話,會列出目前動態加了哪些中斷點:

(Pdb) b
Num Type Disp Enb Where
1 breakpoint keep yes at /private/tmp/demo.py:11

以看到目前有哪些中斷點。如果要刪除中斷點,可以使用 clclear 指令:

(Pdb) cl 1
Deleted breakpoint 1 at /private/tmp/demo.py:11
(Pdb) cl
Clear all breaks? y

cl 1 指令就是刪除第 1 個中斷點,如果只執行 clclear 指令的話,會問你是否要刪除所有中斷點,如果輸入 y 就會刪除所有中斷點。

是說,目前的範例都是我們可以修改的,萬一遇到一個不是自己寫的程式碼,例如是第三方套件,或是我們沒有權限在程式碼裡加入 breakpoint() 函數怎麼設定中斷點?我們可以在執行程式的時候,加上 -m pdb 參數,這樣 Python 會在程式執行的時候自動進入 Pdb 模式:

$ python -m pdb demo.py
> /private/tmp/demo.py(1)<module>()
-> import math
(Pdb) l
1 -> import math
2
3 square = lambda n: math.pow(n, 2)
4
5 def calc_numbers(numbers, fn):
6 data = []
7 for n in numbers:
8 data.append(fn(n))
9
10 return data
11

可以看到程式在執行的時候自動進入 Pdb 模式,再搭配剛剛學會的 bbreak 指令動態新增中斷點,就能追到我們想追的地方了,即使是別人寫的第三方套件或網站框架也一樣能追進去!

誰呼叫了這個函數?

一樣先上個範例:

def hi():
print("Hello Kitty")

def hey():
hi()

def hello():
hey()

hello()

hello() 函數呼叫了 hey()hey() 呼叫了 hi(),如果想要知道 hi() 函數是被誰呼叫的,我知道以這個範例來說有點太簡單、太小看各位了,一眼直接就能看出來是誰呼叫的,但總之就當作這是一個練習吧!啟動並進入 Pdb 模式,並且在第 2 行設定一個中斷點,然後使用 ccontinue 指令讓程式執行執行到中斷點:

$ python -m pdb demo.py
> /private/tmp/demo.py(1)<module>()
-> def hi():
(Pdb) b 2
Breakpoint 1 at /private/tmp/demo.py:2
(Pdb) c
> /private/tmp/demo.py(2)hi()
-> print("Hello Kitty")

接下來就是有趣的地方了,我們可以使用 wwhere 指令來查看目前函數呼叫的堆疊是什麼樣子:

(Pdb) w
/Users/kaochenlong/.pyenv/versions/3.12.4/lib/python3.12/bdb.py(606)run()
-> exec(cmd, globals, locals)
<string>(1)<module>()
/private/tmp/demo.py(10)<module>()
-> hello()
/private/tmp/demo.py(8)hello()
-> hey()
/private/tmp/demo.py(5)hey()
-> hi()
> /private/tmp/demo.py(2)hi()
-> print("Hello Kitty")

在上面這個訊息的倒數第二行可以看到就是我們現在這個 hi() 函數,呼叫它的,就是列在它上面的 hey() 函數,再往上是 hello() 函數,最後是全域的 <module>,這樣一路往上追,就能知道 hi() 函數是被誰呼叫的了。

常用指令整理

最後,我用表格整理一下常用的 Pdb 指令:

短指令完整指令說明
hhelp顯示幫助訊息
p印出某個變數的值
llist印出目前所在位置附近的程式碼
lllonglist印出整個函數的程式碼
nnext執行下一行程式碼
sstep進入函數內部
uup往上一層的函數
ddown往下一層的函數
untuntil執行到指定行數
ccontinue繼續執行程式直到下一個中斷點
qquit離開 Pdb 環境
bbreak設定中斷點
clclear清除中斷點
wwhere顯示目前的呼叫堆疊

不知道大家看到這裡,會認為 Debugger 比較好用嗎?還是覺得 print() 函數用的比較習慣?沒關係,Debugger 不是必需品,它只眾多偵錯工具的其中一種,如果你覺得 print() 比較好用就繼續用,我是說真的,不要因為這個章節介紹了 Debugger 就覺得這東西好棒棒,並沒有,你用的順手就好。

我常用近視眼鏡舉例,假設你有近視,也配了一副眼鏡,你認為什麼時候會把眼鏡戴起來?如果近視度數不深或是看近的東西還算看的清楚,自然不需要戴眼鏡;但如果想要看遠方的事物,開始感覺看不清楚了,就會開始找你的眼鏡放在哪裡了。Debugger 也是一樣的道理,你現在用不到它表示你遇到的問題可能還不夠複雜,等哪天你突然發現它好用的時候你自然就會開始用它了(我知道這很像廢話)。

工商服務

想學 Python 嗎?我教你啊 :)

想要成為軟體工程師嗎?這不是條輕鬆的路,除了興趣之外,還需要足夠的決心、設定目標並持續學習,我們的ASTROCamp 軟體工程師培訓營提供專業的前後端課程培訓,幫助你在最短時間內建立正確且扎實的軟體開發技能,有興趣而且不怕吃苦的話不妨來試試看!