偵錯工具
在撰寫 Python 程式的時候,如果想要知道某個變數的值或是函數執行的結果,常會使用 print()
函數來印出點東西來看看。這樣雖然簡單、直覺,但當專案變複雜的時候,只靠 print()
函數抓問題的效率可能不太夠。Python 有一個內建的偵錯器叫做 pdb
,可以幫助我們更有效率地找出問題。
什麼是偵錯器
偵錯器(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 的環境裡。先使用 h
或 help
指令看看在 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>>
p
指令就是 print
的意思,表示可以把某個值印出來看看。可以看到在這個當下,串列 data
才剛剛被初始化,所以還是空的。使用 l
或 list
指令,可以看到目前所在位置附近的程式碼:
(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 不是只能讓我們觀察狀態,我們也可以用它來執行程式碼。透過 n
或 next
指令,可以執行下一行程式碼:
(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
裡面有了一個元素。
除了看到目前所在位置可以檢視的變數外,我們也可使用 u
或 up
指令,往上一層看看:
(Pdb) u
> /private/tmp/demo.py(15)<module>()
-> print(calc_numbers(numbers, square))
(Pdb) p data
*** NameError: name 'data' is not defined
因為現在往上一層了,對外面來說 data
變數是不存在的。可以往上也就可以往下一層,是使用 d
或 down
指令:
(Pdb) d
> /private/tmp/demo.py(8)calc_numbers()
-> for n in numbers:
(Pdb) p data
[1.0]
這樣就又回到原本的位置了。剛才的 n
指令是執行下一行程式碼,如果我想要看看那個 fn(n)
,也就是傳進去的 square
函數在什麼的話,可使用 s
或 step
指令:
(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
串列裡的值也都算完了。
另外兩個常用來控制流程的指令,一個是 c
或 continue
,這個指令會讓程式一直執行到下一個中斷點,不過因為我們的範例裡只有一個中斷點,所以會直接執行到程式結束為止。另一個指令是 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 執行的過程使用 b
或 break
指令動態加入中斷點。我再重新執行一次同樣的範例:
$ 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
在 b
或 break
指令後面 加上數字,表示要在第幾行設定中斷點,例如 b 9
指令就是在第 9 行設定一個中斷點。如果只執行 b
或 break
指令沒加任何數字的話,會列出目前動態加了哪些中斷點:
(Pdb) b
Num Type Disp Enb Where
1 breakpoint keep yes at /private/tmp/demo.py:11
以看到目前有哪些中斷點。如果要刪除中斷點,可以使用 cl
或 clear
指令:
(Pdb) cl 1
Deleted breakpoint 1 at /private/tmp/demo.py:11
(Pdb) cl
Clear all breaks? y
cl 1
指令就是刪除第 1 個中斷點,如果只執行 cl
或 clear
指令的話,會問你是否要刪除所有中斷點,如果輸入 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 模式,再搭配剛剛學會的 b
或 break
指令動態新增中斷點,就能追到我們想追的地方了,即使是別人寫的第三方套件或網站框架也一樣能追進去!
誰呼叫了這個函數?
一樣先上個範例:
def hi():
print("Hello Kitty")
def hey():
hi()
def hello():
hey()
hello()
hello()
函數呼叫了 hey()
,hey()
呼叫了 hi()
,如果想要知道 hi()
函數是被誰呼叫的,我知道以這個範例來說有點太簡單、太小看各位了,一眼直接就能看出來是誰呼叫的,但總之就當作這是一個練習吧!啟動並進入 Pdb 模式,並且在第 2 行設定一個中斷點,然後使用 c
或 continue
指令讓程式執行執行到中斷點:
$ 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")
接下來就是有趣的地方了,我們可以使用 w
或 where
指令來查看目前函數呼叫的堆疊是什麼樣子:
(Pdb) w
/Users/kaochenlong/.pyenv/versions/3.12.7/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 指令:
短指令 | 完整指令 | 說明 |
---|---|---|
h | help | 顯示幫助訊息 |
p | 無 | 印出某個變數的值 |
l | list | 印出目前所在位置附近的程式碼 |
ll | longlist | 印出整個函數的程式碼 |
n | next | 執行下一行程式碼 |
s | step | 進入函數內部 |
u | up | 往上一層的函數 |
d | down | 往下一層的函數 |
unt | until | 執行到指定行數 |
c | continue | 繼續執行程式直到下一個中斷點 |
q | quit | 離開 Pdb 環境 |
b | break | 設定中斷點 |
cl | clear | 清除中斷點 |
w | where | 顯示目前的呼叫堆疊 |
不知道大家看到這裡,會認為 Debugger 比較好用嗎?還是覺得 print()
函數用的比較習慣?沒關係,Debugger 不是必需品,它只眾多偵錯工具的其中一種,如果你覺得 print()
比較好用就繼續用,我是說真的,不要因為這個章節介紹了 Debugger 就覺得這東西好棒棒,並沒有,你用的順手就好。
我常會用近視眼鏡舉例,假設你有近視,也配了一副眼鏡,你認為什麼時候會把眼鏡戴起來?如果近視度數不深或是看近的東西還算看的清楚,自然不需要戴眼鏡;但如果想要看遠方的事物,開始感覺看不清楚了,就會開始找你的眼鏡放在哪裡了。Debugger 也是一樣的道理,你現在用不到它表示你遇到的問 題可能還不夠複雜,等哪天你突然發現它好用的時候你自然就會開始用它了(我知道這很像廢話)。