# 程式除錯指南 :::info 本文章寫給修習「OOP物件導向程式設計」同學們,適用於在 Visual Studio 環境下的偵錯設定,以及一些簡易的除錯工具使用。 撰寫該文章的時候,筆者使用的工具是 Visual Studio 2022,但是 2019 版本的 IDE 應該也適用於該文章。 本文章指包含一些很簡單的除錯技巧,並引申一下程式碼的可測試性,不會有太深入的內容 ::: 在開發過程中,基本的偵錯思路就是使用偵錯工具與日誌,紀錄應用程式的運行時的狀態。本篇文章將會對於幾個常用的除錯功能進行簡介 ## 偵錯工具 & 中斷點 一個最基本的技巧就是單步追蹤(Step Trace),該功能於絕大多數的程式語言開發工具都有提供,諸如C/C++的GDB、LLDB,或是由微軟整合的Visual Studio偵錯工具等。 對於初學者來說,該工具是一個最實用的功能,把程式執行的過程暫停,並逐步執行,並且提供工具檢查中間結果。 ![image](https://hackmd.io/_uploads/ry2cPw_2yx.png) ![image](https://hackmd.io/_uploads/BJ9s_Pu2yx.png) 在行號的左邊,可以使用滑鼠點選,出現的小紅點就是中斷點。中斷點的功能,是當程式以偵錯模式運行時,會於對應的部分暫停,並且開發者選擇後續的操作,例如進入函式或是繼續到下一行。 ![image](https://hackmd.io/_uploads/ryOVFvu21e.png) 在偵錯的頁籤中,可以分別看到 `F5` 開始偵錯以及 `Ctrl + F5`啟動但不偵錯,倘若使用開始偵錯的話,程式就會直接運行到首個中斷點,等待開發者響應。 ![image](https://hackmd.io/_uploads/rkjoYD_h1x.png) 按下 `F5` 開始偵錯的情況,會有一個黃色箭頭停留在中斷點上,此時可以使用幾個操作: - 再按一次 `F5`,會直接運行到下一個中斷點,若只有設定一個中斷點,且該中斷點不可重複進入,則會直接運行到結束結束 - `F10` 不進入函數,以該例子來說,如果運作到第9行時,按下 F10,會直接繼續運作到下一行 For-loop - `F11` 進入該函數,對比 `F10`,就是進入到 `quickSort` 中,並且可以選擇逐步執行 要注意的是,即使使用 `F10` 繼續運作,如果該行的函數調用有被設定中斷點,也會直接進入到中斷點所標記的函數中。以下圖為例,在第9行使用 `F10`,會跳轉到42行的中斷點 ![image](https://hackmd.io/_uploads/S1oxsw_hke.png) 而在下方的儀表板中,會有幾個對於同學們比較實用的工具: ### 區域變數、自動變數 ![image](https://hackmd.io/_uploads/S16qsvd2Jl.png) 最常用到的就是自動變數或是區域變數功能,會直接把當前區塊可以看到的變數內容列印出來,以該例來說,因為已經運行到12行了,可以看到幾個關鍵的變數: - arr:前面宣告的vector,因為這裡已經完成了排序,可以看到內容值是已經排序完成的 - i:當前迴圈中的 i 值 - n:當前迴圈中的 n 值 >[!Tip] 當偵錯模式運作中,可以直接點選「值」的欄位,修改變數的值 ### 監看式 顧名思義,監看式的用途就是可以設定特定的表達式,並根據程式運作的流程動態評估 ![image](https://hackmd.io/_uploads/Hyek6wu21g.png) 只要在「名稱」的地方,輸入對應的表達式,就可以在值的部分看到實時估算的結果 ### 呼叫堆疊 還有一個常用的功能就是呼叫堆疊 ![image](https://hackmd.io/_uploads/rJgkRvOnkx.png) 本例子中,我們補上了 a, b, c 三個函數。並且在 `quickSort` 設定中斷點,因為 `quickSort` 是一個遞迴調用,我們可以觀察一下其呼叫堆疊。 ![image](https://hackmd.io/_uploads/S10DAwdhyl.png) 在第一個斷點時,可以看到我們分別進入到 c, b, a 三個函數 ![image](https://hackmd.io/_uploads/HkNRCvuhyl.png) 當我們執行到 `quickSort` 時,可以看到已經遞迴調用許多次 `quickSort` ,旁邊區域變數則是記錄當前堆疊的變數內容 ![image](https://hackmd.io/_uploads/HkZQ1Ouhkg.png) 可以點選兩下堆疊,會看到旁邊出現一個綠色小箭頭,就可以直接看到那個堆疊中的變數內容 呼叫堆疊對於一些遞迴程式的開發檢查很有用,或者是當你的程式有多個執行路徑時: ```cpp= void func1(condition) { if(condition) func2(); else func3(); } void func2() { // Do something func4(); } void func3() { // Do something func4(); } void func4() { // Do something } ``` 此時我們可以直接把中斷點設定在 `func4`,並觀察是由 `func2` 或是 `func3` 何者呼叫的,就可以簡單理出程式執行的路徑,當然以上例子是為了舉例撰寫的一個很小的情況,實際情況會比該狀況複雜。 ## 輸出入重導向 更詳細的內容可以看[維基百科的介紹](https://en.wikipedia.org/wiki/Redirection_(computing)) 偵錯器的基本用法了解後,接下來我們學習如何調整程式的輸入輸出。 在同學們撰寫作業的時候,有些題目的輸入/輸出會比較複雜,比方說數獨(TS0504)這題 ![image](https://hackmd.io/_uploads/Hy7Fb__3yl.png) 這時候我們可以使用輸出入重導向的功能 ### 輸入重導向 ![image](https://hackmd.io/_uploads/HybTZO_n1g.png) 首先請在右邊專案的資源檔案,右鍵 -> 新增項目,然後新增一個文字檔案(名稱隨意) 然後把範例的輸入填入到剛剛建立的新檔案中 ![image](https://hackmd.io/_uploads/ryOVFvu21e.png) 請在偵錯頁籤,找到最下面的 <...>偵錯屬性 ![image](https://hackmd.io/_uploads/HJdqz_d3ke.png) 找到「偵錯」這欄位,然後在命令引數的部分,填入以下內容: ``` < [你剛剛建立的檔案名稱] ``` 比方說助教剛剛新增的檔案名稱是 `input.txt`,那就輸入 `< input.txt`,且內容如下: ![image](https://hackmd.io/_uploads/S1bGXO_3ke.png) 再次執行程式,就會發現不用再手動從終端機輸入資料,因為此時程式會嘗試從 `input.txt` 輸入。 同學們會發現,終端機會直接開啟後然後就關閉。原因是因為依照作業需求,程式通常讀取到 `EOF` 就會結束運作,而一般我們從終端機輸入時,除非手動使用 `Ctrl + Z` 或是 `Ctrl + D`,才能輸入 EOF 字元到程式中,因此終端機不會直接關閉,而是會繼續等待下個輸入。 但是從檔案讀取時,最後結束的部分會自動輸入 EOF,**不用**在最後面加上諸如 `system("pause")` 或是 `cin.get()` 來故意讓程式停住,直接在 `main` 函數結束前,下一個中斷點,然後使用 `F5` 進行偵錯即可。 ![image](https://hackmd.io/_uploads/H1N7S_u2Jx.png) 如例子所示,因為最後程式會停留在中斷點,此時就可以點開終端機觀察輸出。 我們非常推薦同學在輸出、輸入都很複雜的程式,Ex. TS0503 的 `Student Record` 這種題目使用該作法,因為直接在終端機上進行操作,會導致輸入與輸出都在同一個畫面上,會很不好比較輸出是否跟答案符合。 >[!Tip] 建議的做法 > 把輸入改成由檔案輸入,並下中斷點在 `main` 結束的位置,觀察終端機的輸出 > > 這樣做有幾個好處 > 1. 首先是可以分離輸入和輸出,像前文提到的,像TS0503輸入輸出都很多,都在終端機上print你需要花點時間確認哪行才是程式的輸出 > 2. 在使用中斷點的情況下,如果你有進行 `cin`、`scanf` 之類的操作,會需要跳轉到終端機操作輸入,但是從檔案輸入的情況,程式會自動讀取輸入佇列的內容,不用進行額外操作 > 3. 延續 2. ,當進行偵錯的情況下,可以逐行觀察程式的輸出 > 4. 使用檔案輸入才是符合程式本來預期的情況,因為大部分終止條件都是等待`EOF`,人工進行輸入的話,除非自行按下 `Ctrl + Z` 或是 `Ctrl + D`,否則同學們其實都沒有真正檢查到 `while` 迴圈中讀取是否真的有讀取到`EOF` ### 輸出重導向 而有輸入的重導向,當然也會有輸出的重導向,只需要把 `<` 修改成 `>` 即可。 ``` < [欲當作輸入的檔案] > [欲輸出內容的檔案] ``` 比方說我想用 `input.txt` 當作輸入,並且輸出內容到`output.txt`,在命令引數填入`< input.txt > output.txt` 即可 >[!Warning] 但是要注意,如果使用輸出重導向,終端機就不會輸出內容,因為被導向到檔案中了 在輸出入重導向的使用,要特別注意使用 `加入項目` 來建立文字檔案,原因是因為如果同學是直接先建立好文字檔案,然後在「拉」到資源檔的地方,它的實際路徑可能就不會是專案目錄底下。 比方說我在 `D:\repos\Project1\DebugDemo` 建立我的專案,然後於 `D:\` 建立一個 `test.txt` ![image](https://hackmd.io/_uploads/r1KNY_Onyl.png) ![image](https://hackmd.io/_uploads/BJamt_O3yg.png) 分別比較 `input.txt` 跟 `test.txt` 的相對路徑,可以看到其實 `test.txt` 是位於 `../../..` 的目錄下的,此時如果你在命令引數填入 `< test.txt`,程式是無法運作的,因為在專案的根目錄下,是不存在 `test.txt` 檔案的。 ![image](https://hackmd.io/_uploads/H1T5YOdnJx.png) 解決方法也很簡單 1. 命令引數改成絕對路徑:`< D:\test.txt` 2. 命令引數改成相對路徑(相對於專案根目錄): `< ..\..\..\test.txt` 3. 先移除資源檔底下的`test.txt`,然後把 test.txt 檔案拉到專案根目錄底下,在使用加入現有項目即可 ## 斷言(Assert) :::danger 待撰寫 ::: ## 環境變數 & 前置處理器 :::danger 待撰寫 ::: ## 結論 中斷點是一個最基本且常用的除錯技巧,但是當應用稍微有點規模時,這個做法會極低效,應該配合各種各樣的除錯策略與*好習慣*,比方說單元測試、日誌系統、Code Review,同時需要有 Debug 的素養,比方說撰寫程式的時候,要好好規劃模組的劃分,並假定程式在錯誤時,應該使用的策略。 一個基本的除錯流程如下: 0. **提升自己寫程式的技巧** 1. 了解問題並嘗試重現 - 如果一個錯誤可以穩定重現,才有辦法定位問題可能所在的部分 - 如果該錯誤有時候發生,有時候不會發生,只能靠自己的大腦推敲 2. 查看錯誤訊息 - 先看看編譯器提示你哪裡錯誤, Warning 的議題有哪些 - 要知道一些常見的錯誤可能發生的原因 - Ex. `Out of range`,高機率就是跟 `std::vector` 相關的錯誤,例如你的arr[i] 越界了 - 程式卡住不執行,可能是 input 相關的功能沒讀取到輸入,所以沒有進行下一步,或是某個迴圈無窮執行 3. 增加日誌輸出(程式規模變大時就需要考慮) - 在你認為可能錯誤的部分,可以用 `std::cout` 或是 `printf` 輸出想要觀察的內容 - 別在出現錯誤的時候才加入,一開始就要規劃應該追蹤哪些資訊,**即使**程式尚未出錯 4. 使用中斷點偵錯 5. 修正錯誤並且測試 以上是除錯的流程,該流程從小程式到大應用都是通用的定則,前面提到的「中斷點十分低效」,其意義是「把整個程式一步一步執行,十分低效」。 你應該先定位問題可能發生的地方,然後才針對那幾個可能有錯誤的邏輯中斷。所以第0點的「提升程式技巧」非常重要,因為程式碼有所謂的[Testability](https://en.wikipedia.org/wiki/Software_testability),也就是可測試性。 好測試的程式碼,可以先依照幾個邏輯: 1. 把每個模組的核心功能定義好,並且只做好這個功能 2. 把一個大問題拆解成幾個簡單的小問題 3. 規劃好問題的順序,並且最好輸入輸出可以單獨注入 當然還有依賴注入、依賴反轉、介面隔離等技巧,但這些都可以先忽略不管,一個最核心的原則就是「一個Function 不要做太多、太複雜的事情」 這個對於剛開始接觸的同學是一個比較深入的議題,但會隨著撰寫程式的經驗越來越豐富,也會越來越有程式開發的概念。 > :::spoiler 可測試的程式碼 對於程式開發,無法很簡單的說明如何寫一個好測試的程式,但其實核心邏輯就是「高內聚,低耦合」 - 高內聚性:如果一個類別或函式的成員彼此間緊密相關,並且共同協作來達成一個明確的目標,那麼這個類別或函式就是高內聚的。 - 低內聚性:如果一個類別或函式內部的成員互相之間沒有太多的關聯,且每個成員都做著不同的事情,那麼這個類別或函式就是低內聚的。 耦合度比較難說明,簡單的說就是你的程式會不會「牽一髮而動全身」,比方說加個小功能,要修改一堆檔案,那代表你功能之間的依賴性過高。 舉一個例子,比方說我們開發了一個「會員系統」,那註冊邏輯應該如下: 1. 客戶填寫表單,並提交到伺服器 2. 伺服器檢查資料,並確認符合格式(Ex. Password 需要大小寫英文+數字) 3. 寫入資料庫 4. 寄送二階段認證信給客戶 如果開發階段,註冊後一直沒有收到信件,我們就可以規劃以下的測試流程: 1. 先檢查客戶端送給伺服器端的資料是否正確 - 比方說是不是把 Username 跟 Password 欄位填反了 2. 伺服器是否有收到請求 - 有可能是客戶端送出了,但是伺服器根本沒有收到,此時要檢查網路情況跟Http路由是否註冊正確 3. 送來的資料內容是什麼 - 比方說 Password 內容是 `qwer1234`,但是 `vaildatePassword` 還是回傳 `true` 4. 資料庫是否有運作? - 操作資料庫,看看連線是否正確,是否能寫入資料,連線時的用戶權限是否包含寫入權限 5. 郵件伺服器設定正確嗎? - 網域名對嗎?監聽的 PORT 是否有被占用 逐個環節排除錯誤,最後找出定位。 那耦合度我們繼續使用上述的例子舉例,比方說 `vaildatePassword` 這個功能,如果你這樣寫: ```cpp= boolean vaildatePassword(HttpRequest req) { std::string password = req->getField("Password"); std::regex rule("^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).+$"); return regex_match(password, rule); } ``` 那你的程式耦合度就會非常高,因為我只是想要測試密碼的規則是否正確,但我想要使用 `vaildatePassword` 必須先生成一個 `HttpRequest`,且沒有考慮到如果送過來的請求,沒有 `Password` 怎麼辦。 ```cpp= void handleRequest(HttpRequest req) { // 確認請求的URL、Method等是否正確 // 處理請求內容的格式化等 vaildatePassword(password); // 繼續處理其他的功能 } boolean vaildatePassword(std::string password) { // 可能有其他的操作 std::regex rule("^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).+$"); return regex_match(password, rule); } ``` 可以拆解成兩個階段,分別處理請求、密碼驗證,在處理請求,我們可以添加上日誌,紀錄「誰、從哪裡、送什麼」資料過來,然後在`vaildatePassword`,調整我們的驗證規則,且可以單獨丟入一個字串測試該功能是否正確 :::