一、參考:
本文主要參考《C++編程調試秘笈》一書。
在編寫C++代碼時,我們不應該自己捕捉缺陷,而是由編譯器和可執行代碼為我們做這些事情,該書便提供了這樣的一個思考。作者以“調試器友好”的方式編寫了一些方便安全檢查時所需的宏代碼並針對C++代碼中最為常見的各種錯誤制定了一些規則,並用代碼實現,使之很容易在運行時捕捉,或者盡可能地在編譯時就捕捉缺陷。
二、C++缺陷來源
在C語言中為了追求簡單和速度,產生高效的編譯代碼,有時候並未考慮一些方便用戶的特性,就會產生一些比較明顯的問題,比如垃圾回收,越界檢查,緩沖區溢出等等
-
程序員可以創建一定長度的數組,並可用一個超出數組邊界的索引值訪問元素
-
濫用最多的是指針運算,程序員可以把指針運算所產生的任何值作為內存地址進行訪問,不管該內存是否有效還是能否被訪問,如解引用NULL指針
strlen(NULL)將會導致程序崩潰 -
程序員在運行時使用
calloc()和malloc()函數動態分配內存並使用free()函數負責釋放內存。但是如果忘了銷毀,產生了內存泄露(分配內存后並未被釋放,最終消耗完系統空間),或者不小心銷毀了多次,產生內存懸掛(釋放對象后沒有將指針置為NULL而之后又解引用了它,未定義的指針解引用是非常嚴重的)等災難性的問題 -
sprintf()和某些字符串函數在寫入緩沖區時,它們可能會改寫越過緩沖區尾部的內存,從而導致不可預料的程序行為;相比對應的安全版本會安靜地在緩沖區結束時截斷,但很可能不是我們所期望的結果,建議多使用C++的string和stringstream
(關於C的字符串函數和C++ 的string 、stringstream孰優孰劣還是有爭論的,有空的話可以分析分析)
當然C++語言中也存在一些問題
- 友元和多重繼承並不是個很好的思路
- 混用了
new和delete,其中一個帶方括號和一個不帶方括號,
一定要使用正確的形式:
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這兩種語言它有着怎樣不同的設計
三、何時捕捉陷阱
在編譯時診斷錯誤,有如下規則:
- 禁止隱式類型轉換:關鍵字explicit聲明一個接受一個參數的構造函數,並禁止使用轉換操作符
- 用不同的類表示不同的數據類型
- 不要使用單純功能的枚舉創建整形常量,而是用它們創建新類型
原因如下:
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可以在編譯時打開。
