C++除錯總結

LyAsano發表於2021-08-03

一、參考:

  本文主要參考《C++程式設計除錯祕笈》一書。

  在編寫C++程式碼時,我們不應該自己捕捉缺陷,而是由編譯器和可執行程式碼為我們做這些事情,該書便提供了這樣的一個思考。作者以“偵錯程式友好”的方式編寫了一些方便安全檢查時所需的巨集程式碼並針對C++程式碼中最為常見的各種錯誤制定了一些規則,並用程式碼實現,使之很容易在執行時捕捉,或者儘可能地在編譯時就捕捉缺陷。


二、C++缺陷來源 

  在C語言中為了追求簡單和速度,產生高效的編譯程式碼,有時候並未考慮一些方便使用者的特性,就會產生一些比較明顯的問題,比如垃圾回收,越界檢查,緩衝區溢位等等

  1. 程式設計師可以建立一定長度的陣列,並可用一個超出陣列邊界的索引值訪問元素

  2. 濫用最多的是指標運算,程式設計師可以把指標運算所產生的任何值作為記憶體地址進行訪問,不管該記憶體是否有效還是能否被訪問,如解引用NULL指標strlen(NULL)將會導致程式崩潰

  3. 程式設計師在執行時使用calloc()malloc()函式動態分配記憶體並使用free()函式負責釋放記憶體。但是如果忘了銷燬,產生了記憶體洩露(分配記憶體後並未被釋放,最終消耗完系統空間),或者不小心銷燬了多次,產生記憶體懸掛(釋放物件後沒有將指標置為NULL而之後又解引用了它,未定義的指標解引用是非常嚴重的)等災難性的問題

  4. sprintf()和某些字串函式在寫入緩衝區時,它們可能會改寫越過緩衝區尾部的記憶體,從而導致不可預料的程式行為;相比對應的安全版本會安靜地在緩衝區結束時截斷,但很可能不是我們所期望的結果,建議多使用C++的stringstringstream
    關於C的字串函式和C++ 的string 、stringstream孰優孰劣還是有爭論的,有空的話可以分析分析

  當然C++語言中也存在一些問題

    1. 友元和多重繼承並不是個很好的思路
    2. 混用了newdelete,其中一個帶方括號和一個不帶方括號,
      一定要使用正確的形式:
A* p_object=new A();
A* p_array=new A[size];
delete p_object;
delete []p_array;

  讀完這本書,感觸還是蠻深的,比如說C++早期的時候主要側重在物件導向的特性方面的設計,後來陸續引入模板、異常處理、名字空間,到現在的C++11引入型別推導、lambda函式、標準程式庫的變更(無序雜湊表、正規表示式、執行緒支援等),體會就是:

  • 語言的設計也是會演化的,它源於不斷髮展中實際的需求,設計什麼樣的特性是有舍有得的。
  • 設計思想和特性決定了它能做什麼事,不能做什麼事,有怎麼的好處也有相應的缺陷。
  • 任何語言都不是silver bullet ,你不能單純說它好壞. 只有當認識清楚語言背後的設計思想、演化史,瞭解各自的特性和缺點,就不會出現遇到具體問題而直接掉入程式語言的坑了

  覺得需要深入瞭解的主題:

  • Unix哲學程式設計藝術(Unix的設計思想是很值得思考和借鑑的)
  • C++語言的設計和演化、Java語言的演化設計史(虛擬機器、設計模式,對比Java和C++的不同點)
  • 計算機程式的構造和解釋,裡面解釋函數語言程式設計語言它是如何工作的(表示一直不理解)
  • Python、Go這兩種語言它有著怎樣不同的設計

 

三、何時捕捉陷阱   

  在編譯時診斷錯誤,有如下規則:

 

  1. 禁止隱式型別轉換:關鍵字explicit宣告一個接受一個引數的建構函式,並禁止使用轉換操作符
  2. 用不同的類表示不同的資料型別
  3. 不要使用單純功能的列舉建立整形常量,而是用它們建立新型別

 

  為什麼呢,下面將一一解釋

 

  A. 假設我們有兩個類A和B,並有一個期望接受一個B型別的引數的函式:

void doSomething(const B& b)

  但是我們不小心向它提供了A型別的物件:

A a(input);
doSomething(a);

  某些情況,這樣的程式碼可通過編譯,原因是它有可能平靜的進行隱式型別轉換:A轉換成B。它可能通過以下兩種方式發生

  1. B類接受含A型別的引數建構函式,它可以隱式地把A轉換為B
class B {
    public:
        B(const A& a);
}

   2.A類具有一個可以將其轉換為B的操作符,以明確的方式提供了轉換方法

class A{
    public//轉換操作符operator type():type可以是基本資料型別,類,結構體
         operator B() const; 
}

  所以針對上述問題,對於所有接受一個引數的建構函式用關鍵字explicit宣告,並且不建議用轉換操作符,這是值得推薦的做法。

  一般而言,隱式轉換的所有可能性都是不好的思路,還記得深入計算機系統第二章講過FreeBSD開源系統曾出現的getpeername的安全漏洞麼,這是由於無符號數和有符號數間的不匹配造成了隱式型別轉換。不過我們還可以用另外一個方法進行轉換

class A{
    public:
         B asB() const; 
}

A a(input);
doSomething(a.asB()); //  顯式轉換

  B. 定義兩個列舉,分別表示一週中的某天及月份,這些常量都是整數。假設我們有一個期望接受一週中的某天作為引數的函式

enum {SUN1,MON=1,TUE,WED,THU,FRI,SAT};
enum {JAN=1,FEB,...,DEC};

void func(int day_of_week);

  因而下面呼叫將不會產生任何警告的情況下通過編譯:func(JAN);

  所以捕捉此類缺陷的辦法就是建立新型別的列舉,直接限定了新型別的列舉範圍,這樣就可以在編譯時判斷是否有錯誤。

typedef enum {SUN1,MON=1,TUE,WED,THU,FRI,SAT} DayofWeek;
typedef enum {JAN=1,FEB,...,DEC} Month;

 


 

四、在執行時遇見錯誤如何處理

  我們把精力集中在執行時的一類錯誤--缺陷。為了捕捉缺陷專門編寫的一段程式碼稱為安全檢查,當其失敗時,就表示發現了缺陷,那如何處理呢,這裡作者提供這樣的一個思路

  定義一個SCPP_ASSERT巨集,永久性的安全檢查,用來捕捉執行時錯誤,並提供與錯誤有關的具體資訊

#scpp_assert.h
#define SCPP_ASSERT(condition,msg) \
    if(!(condition)) {             \
    std:ostringstream s;       \
    s << msg;                  \
SCPP_AssertErrorHandler(__FILE__,__LINE__,s.str().c_str()); \
}

#scpp_assert.cpp
void SCPP_AssertErrorHandler(const char *file_name,
                             unsigned line_no,
                             const char *msg){
//此處適合插入斷點,合適情況下還可向一個日誌檔案寫入相同的資訊
#ifdef SCPP_THROW_EXCEPTION_ON_BUG
     throw scpp::ScppAssertFailedException(file_name,
                                           line_no,msg);
#else
    cerr << msg << "in file "<<file_name << 
                   " #" <<line_no <<endl<<flush;
    exit(1);
#endif
}

#scpp.h
#ifdef SCPP_THROW_EXCEPTION_ON_BUG
#include<exception>

namespace scpp {
    class ScppAssertFailedException :public std::exception {
        private:
            std::string what_;
        public:
            ScppAssertFailedException(const char *file_name,                                      unsigned line_no,   
                                      const char *msg);
            virtual void const char* getwhat() const throw()          { return what_.c_str();}
            virtual ~ScppAssertFailedException() throw() {}
    }

}

#scpp_assert.cpp
#ifdef SCPP_THROW_EXCEPTION_ON_BUG
namespace scpp {
    ScppAssertFailedException::ScppAssertFailedException(const char *file_name,
                                                         unsigned line_no,
                                                         const char *msg) {
        ostringstream s;
        s << "SCPP Assertion failed with message " << msg <<" in file " <<file_name << " # " << line_no;
        what_=s.str();
    }
}
#endif

  我們可以看到該巨集接受一個條件和一條錯誤資訊。條件為真不執行任何事情,為假時錯誤資訊會輸出到ostringstream中,並且錯誤處理函式將被呼叫。這裡有兩個問題:

  • 問:為什麼要呼叫scpp_assert.cpp檔案中一個單獨AssertErrorHandler函式,而不是在scpp_assert.h檔案的巨集中執行相同的操作
    答:偵錯程式更擅長對函式而不是巨集進行逐步除錯

  • 問:為什麼AssertErrorHandler函式向我們提供了兩種選擇機會,要麼終止程式,要麼丟擲一個異常
    答:在最常見的情況下我們發現第一個缺陷時預設採取的辦法是終止程式,修補缺陷並再次開始,這時候將列印出錯誤資訊並終止程式,即對應沒有定義的SCPP_THROW_EXCEPTION_ON_BUG符號。
    那麼定義了該符號的情況呢,在某些情況下,有部分安全檢查必須保留在程式碼中,即使是在產品模式下。假設有一個持續依次處理大量請求的程式在處理某個請求時安全檢查失敗,終止程式並不是理想的選擇,應該採取的辦法是丟擲一個異常,包含詳細的錯誤資訊並把錯誤資訊記錄在某日誌檔案中,可能還需要傳送郵件或警報,宣佈對當前請求的處理失敗,同時繼續處理髮送其他的請求。因而在scpp_assert.h宣告瞭一個異常類

  • 問:什麼時候編寫安全檢查?
    答:如果我們的想法是等我們編碼好後再回過頭來新增安全檢查,這個計劃可能永遠不糊實施。
    較好的建議是從一開始編寫新函式新類新功能時等具體的程式碼前就應該為它所有的輸入編寫好安全檢查和測試。
    可以看出編寫安全檢查並不困難,它不僅讓你更明確你所要做的工作,更重要的是它會在以後的測試階段得到足夠的回報,這要比你以後回過頭來除錯程式碼要方便得多。

  注:要養成這樣的習慣,單元測試也是類似的思路:編碼的同時編寫好安全檢查和測試,更明確的辦法是當我們開始編寫具體的程式碼前為它的所有輸入編寫安全檢查

  如下類似的程式碼用來測試:

#include <iostream>
#include "scpp_assert.h"

using namespace std;
int main(int argc,char *argv[]) {
    cout << "Hello,SCPP_ASSERT" << endl;

    try {
        double price=100.0 ; //合理價格
        SCPP_ASSERT(0< price && price <=1e6,"Stock price " <<price <<" is out of range "); //條件成立時不執行

        price=-1;
        SCPP_ASSERT(0< price && price <=1e6,"Stock price " <<price <<" is out of range "); //條件不成立時執行並捕獲異常
    } catch (const exception& ex) {
        cerr << "Exception caught in " << _FILE_ << " # "<< _LINE_ << ". "<< endl;
        cerr << ex.what() << endl;
    }
    return 0;
}

//在SCPP_ASSERT巨集中也可使用任何類的物件,只要它定義了<< 操作符,設計和測試如下:
/* Test :
*MyClass obj(inputs);
*SCPP_ASSERT(obj.IsValid(),"Object "<< obj <<" is invalid.");
*/
class MyClass {
public:
    bool IsValid() const ; //物件狀態有效即返回true
    //Implement constructors 、destructors 
private:
    int data;
    friend std::ostream operator << (std::ostream& os ,const MyClass& obj);
}
inline std::ostream operator << (std::ostream& os ,const MyClass& obj) {
    //執行一些任務,按被人理解的格式顯示物件
    os << obj.data;
    return os;
}
/*
* Output : 
* Hello,SCPP_ASSERT
* Exception caught in xxx.cpp #13 .
* SCPP assertion failed with message 'Stock price -1 is out of range ' in file xxx.cpp #13
*/

  問:什麼時候使用它
  答:我們意識到程式碼中可能含有大量的安全檢查,有些是永久性的,有些是臨時性的。為了保持C++程式碼執行的高效性和有效性,在不同執行階段執行不同的策略:

    • 在Debug模式,開啟測試安全檢查,對錯誤進行除錯
    • 在Release模式,開啟測試安全檢查,快速除錯(考慮到1的安全檢查會較慢)
    • 在Release模式下關閉安全檢查,釋出產品

 

  程式碼實現如下:

#scpp_assert.h
#ifdef _DEBUG
#define SCPP_TEST_ASSERT_ON
#endif

#ifdef SCPP_TEST_ASSERT_ON
#define SCPP_TEST_ASSERT(condition,msg) SCPP_ASSERT(condition,msg)
#else
#define SCPP_TEST_ASSERT(condition,msg)

  可以看到SCPP_ASSERT是永久性的安全檢查,SCPP_TEST_ASSERT可以在編譯時開啟

  下面分別就索引越界、編寫一致的比較操作符、未初始化變數指標操作、記憶體洩露等缺陷進行一一處理,用於在程式碼進入產品階段前捕捉各類缺陷。

 

 

 


 

相關文章