C/C++ 踩过的坑和防御式编程
相信你或多或少地用過或者了解過 C/C++,盡管今天越來越少地人直接使用它,但今天軟件世界大多數軟件都構筑于它,包括編譯器和操作系統。因此掌握一些 C/C++ 技能的重要性不言而喻。
這場 Chat 本人將從小處入手,以親身踩過的坑作為示例,講述一下 C++ 的常見的坑,以及其防御方法——防御式編程。主要內容包括:
-
C/C++ 基礎知識簡介
-
C/C++ 常見問題復現示例
-
內存泄露問題排查
-
防御式編程理論
-
防御式編程實踐
大家好,我是林奇思妙想。很高興能夠和各位在Gitchat上交流一些平常用C/C++, 寫 C/C++ 的經驗分享。
為了表明這是一場嚴肅的、有深度的交流,我們先不走偏,先回顧一下經典教材上關于C/C++的基礎知識。盡量保持簡單,點到為止,希望你沒有被嚇走。作為一枚典型猿族,我就話不多說,直接帶領大家入坑(笑~)。
注:所有的程序示例在MS VS2017下調試的。
C/C++ 基礎知識簡介
我們說到 C/C++ 一般指兩部分:
-
C++兼容 C 的部分
-
C++獨有部分
先看一下C/C++共有部分(有基礎的同學可以略過這一部分, 或者可以跟我一起溫習)。
C/C++ 共有部分
常量
常量是指運行時值不能改動的一類“變量”,他們的值是編譯進目標程序中的。
1)立即數
如字面常量 12, 123.5f, "abc".
2)常量對象
const int SIZE_A = 11; const Mat MAT_A(12,22,-1);變量在運行時占有內存地址空間,且它的值可以在運行時被更改
變量
1)普通值變量
float a = 9.1f; Mat mat_a(1,2,-1);2)指針變量 p_a
int *p_a = &a;表達式
表達式是指能被編譯器編譯為指令的語句,通常以“;”結束。
表達式分以下幾種:
1)賦值
int a = 4;2)逗號
a = 9, b = 11;3)判斷
if (a > b){do_something();}4)循環
for(int i=0; i<MAX; i++){do_something(); }5)函數調用
do_something();其它數據結構
結構體等
struct st_a{int x; int y; };通常是 public 的,即沒有封裝性。
C++獨有
類是C++所獨有,也稱對象。通常我們用一個類來表示一類客觀事物的層次、繼承關系。
如下圖所示:
圖 0.1 類的繼承關系
類
1)繼承
繼承就如0.1所示,從父類到子類,越來越具體。
2)封裝
封裝是指對于某一子類,可以控制哪些信息能被外部看到,如控制我們能獲取手機的大小,顏色等屬性,對用戶隱藏手機串號等信息。
3)多態
多態是C++精華所在,但也是C++的難點所在。一些教材常常用高大上的描述把人搞昏。譬如我手邊這本書里面是這樣講多態的(C++程序設計教程——錢能版):
“ 多態是基于類的層次結構的,當指針飄忽不定地可能指向類層次中的上下不同對象時,以指針間訪的形式實施的操作便是表現多態的條件。”
這段文字真是一騎絕塵,不食人間煙火,難免把人帶到小黑屋子——關著。
用通俗的話說,多態是指多個子類有一個共有操作,我們在父類中定義一個統一的抽象虛接口,然后各個子類分別實現。這樣子,運行時,依據子類是什么,動態選擇子類的方法。 這樣子描述,我們又不可避免地走入了經典教材的巨梗——不懂啊! 不如直接看如下代碼吧。
如下代碼所示:
// code start /* B is base class, A -- C is sub-class*/class B {public:virtual void do_sth() = 0;};class A : public B {public:void do_sth() {cout << "- A do_sth()\n"; }};class C : public B {public:void do_sth() {cout << "- C do_sth()\n";}};void do_sth(B *id_b) {id_b->do_sth(); }int main(){A* id_a = new A(); C* id_c = new C(); do_sth(id_a); do_sth(id_c); return 0; }// code end函數運行輸出結果是:
A do_sth()C do_sth()注意其中的粗體代碼。
看“ void dosth(B *idb)” , 我們用的基類指針作為函數的接口參數,但是“dosth(ida); ” 傳遞參數時,我們傳的是A 或者 C 對象的指針! 多態使得調用的接口一致,更利于抽象和簡化。
C/C++ 常見問題復現示例
如果算上最新接觸C到現在,已經有9年。寫過無數的C/C++代碼。有些坑是自己挖的,有些則是語言層面上的“陷阱” 。
坑1
立即數左移越出范圍。先看一段代碼:
assert((1 << 3) == pow(2, 3));assert((1 << 30) == pow(2, 30));assert((1 << 62) == pow(2, 62));先不運行,猜測一下問題在哪一行?
運行結果如下:
Assertion failed: (1 << 62) == pow(2, 62), file我們再進一步看一下他們的值:
cout << pow(2, 62) << endl; cout << (1 << 62) << endl; 4.61169e+18 0可知前者是對的,后者是錯的,在C語言中,左移結果最大是 32 位。
為了驗證,我們再看一下:
cout << pow(2, 62) << endl; cout << (1 << 62) << endl; cout << (4.61169e+18) << endl;運行結果是:
4.61169e+18 0 4.61169e+18符合我們的預期。
坑2
sprintf()越界問題。
char buf[10]; float x = 1/3.0f; sprintf(buf, "cols = %f", x); printf(buf);運行后,buf會越界,出現地址異常!正確的做法是給 buf 一個更大的地址。 但是這類棧溢出在大型的工程中,防不勝防。其實可以在 C++ 中,考慮用更一種更安全的方式。
float f = 1 / 3.0f;ostringstream ss;ss << "num is " << f << endl;cout << ss.str();坑 3
case語句,不打括號。
// 示例代碼K_0 :
int num = 2; switch (num){case 0:do_0(); break; case 1:do_1();do_xx();do_xxx(); do_xxxx();break;default:do_default(); }// code end這段代碼運行起來沒有問題,
邏輯沒有問題,還不至于成坑,但是在實際的大型項目中,一個項目因需求變化可能需要頻繁改動。如上述代碼,有人在"do_xxxx()"后加上另外一個分支。變成:
do_xx();do_xxx(); do_xxxx(); case 2:do_2(); do_2_x(); do_2_xx();break;這個時候邏輯就會出問題。而且這種錯誤很難調試,最好的辦法是預防。 預防的方法也很簡單,給case分支加上大括號。如下:
switch (num){case 0:{ do_0();break;}case 1:{do_1();do_xx();do_xxx();do_xxxx();break;}default:{do_default();}}這種方法看起來很笨,但比后期要發布前出bug, 把班加個昏天黑地要強。
坑4
sizeof()用于一個結構體時其值不是絕對的。與平臺相關,也與編譯指令相關??蠢?#xff1a;
struct pack_struct{char t;uint32_t x; };cout << sizeof(pack_struct);輸出是什么?
有很多同學一看,說這個容易,?char?占用一個字節,?uint32_t?占用 4 個字節,所以共占用 5 個字節。還有同學想到了4字節對齊,說應該是4的倍數,所以應該是8。那正確的結果又是什么呢?
其實答案是:兩者都有可能。與編譯控制有關。要想結果得到“5”, 用下面的:
#pragma pack(1)struct pack_struct{char t;uint32_t x; };#pragma pack()要想得到“8”, 把pack(1)?改成pack(4)。
一般默認的編譯參數,不同的平臺是不一樣的。所以要求我們不能寫硬編碼的代碼。例如下面的代碼將得不到我們想要的結果:
pack_struct arr_pack[8]; char *p_tmp = (char*)arr_pack;pack_struct *pack_idx_1 = (pack_struct*) (p_tmp + 5);示例有點兒繞,我們j是想要通過指針的方式得到數組arr_pack的第一個指針。這里用了硬編碼,寫成了5。這里硬編碼的問題可能是,在其它的平臺上不一定能正確工作!
為了方便移植,應該改寫成:
pack_struct arr_pack[8]; char *p_tmp = (char*)arr_pack; pack_struct *pack_idx_1 = (pack_struct*) (p_tmp + sizeof(pack_struct));坑5
浮點數的比較。某君寫了如下代碼:
float x = 1.333 - 1; cout << x << endl; if (x == 0.333){cout << "x = " << 0.333 << endl; }else{cout << "x != " << 0.333 << endl;}請問一下輸出是什么?
不用想,筆者在這里把它們寫出來,肯定是有坑的,所以輸出結果也是“驚世駭俗”的,輸出為:
0.333 x != 0.333看起來不可思議!!!問題來了,那怎樣才能正確地作浮點數相等判斷呢?也很簡單,一般用偏離一個中心的距離小于某個精度來判斷相等:
if (fabs(f1 - f2) < 預先指定的精度){//...}實際一般推薦用 1e-5 作為精度(看各項目的精度要求啰)。
坑6:模版的使用
首先來看一下一個正常的模版類使用方法:
`// ### main.cpp ###`#include <iostream>using namespace std; template<class T> void disp(T t);template<class T>void disp(T t){cout << t << endl;}int main(){disp(8); disp<float>(8.8f);return 0; }
這個能正常編譯運行,但是把這些模版細節都放到?main.cpp?中,看得很昏。于是我們想要把他們移出main.cpp。
假如我們有一個頭文件test.h?和一個實現文件?test.cpp。
我們把模版的聲明移到test.h,模版的實現移至main.cpp, 此時三個文件內容分別如下:
// ### test.h ###
template<class T> void disp(T t);// ### test.cpp ###template<class T>void disp(T t){cout << t << endl;}
/ ### main.cpp ###
編譯運行,發現Linker出錯:
error LNK2019: unresolved external symbol "void __cdecl disp<int>(int)" (??$disp@H@@YAXH@Z) referenced in function main error LNK2019: unresolved external symbol "void __cdecl disp<float>(float)" (??$disp@M@@YAXM@Z) referenced in function main fatal error LNK1120: 2 unresolved externals -- FAILED.錯誤出現了,我們想知道“為什么”?
這是因為目前C++還不支持模版分開編譯。分開會導致模版函數在具化過程中找不到外部符號。通常地做法是把聲明和實現全部放在頭文件中。下面是正確的版本:
// ### test.h ###
template<class T> void disp(T t);template<class T>void disp(T t){cout << t << endl;}// ### test.cpp ###
// test.cpp file content...// ### main.cpp ###
#include <iostream>using namespace std; int main(){disp(8); disp<float>(8.8f);return 0; }這回終于對了!
坑7:棧被意外修改
看下面這段代碼。你覺得Line 28會異常嗎? 實際結果是”會“!
這個就是我在工作中遇到的一個實際問題,當時一直監控一個變量,這個變量總是莫名其妙被更改了, 最后挖出來罪魁禍首就是一個讀把棧破壞了。結果我的 “N” 成了一個無意義的超大數。
所以一定要嚴格控制好指針的越界行為,編譯器無法知道你的意圖,這個只有靠自己來把控,但也有一些方法來排查和防衛這種問題的發生。這是接下來要講的內容。
內存泄露問題排查
內存泄露問題是大型 C++項目中最棘手的問題之一。有人會想,嘿,反正我內存多,不用擔心用完,讓它去泄露吧, 但我只能告訴你,too young , too simple, sometimes naive。
內存只有有哪怕一個地址的泄露,都可能導致嚴重的宕機事故,特別是在一些重要的嵌入式領域,如醫療,核電,飛行控制器軟件中。
有很多工具可以幫忙排查內存泄露問題。從懷疑內存泄露到證實一般要經過以下的步驟:
懷疑
開機后一切正常,系統運行一段時間后,越來越卡。此時需要排除是不是 CPU 溫度過高,軟件處理數據量是不是變大了。如果都正常,那就著手懷疑是內存泄露問題。
如果在Linux上,可以通過 free -m 命令看到剩余的內存。如果運行長時間后,剩余內存變得越來越小。則大概率是內存泄露。
驗證
下一步可以通過一些工具去協助監控內存的分配和釋放, 如:coverity (力薦,因為是我的前東家新思科技產的,當然也是最好用的,Valgrind(開源,免費)等。
防御式編程理論
講了這么多坑,終于要講一些理論總結了,應該松口氣還是憋口氣呢(笑)?
我們編程最終的目標是讓產品能平穩運行,而且要”不以人的意志為轉移“式地平穩運行。
這其中包括兩點:
-
產品代碼要邏輯正確、完備;
-
代碼能夠讓人讀而知其義,能盡量避免他人犯錯;
防御式編程就是在做到 1 后,再把第 2 條做好。
在這里,一定要看清楚,做到1后再做2。 有些軟件管理可能過分強調防御,在功能,邏輯都還沒有完備時,大搞特搞防御,其實并不可取。其實,本人傾向于當功能雛形做好后,再防御。畢竟過早防御會使得代碼過分臃腫。
說了這么久”防御“,讀者可能已經昏了。那到底什么是防御? 別急,讓我們先來看一個例子。
如取上面的例子N, 防御式編程應該是:
FILE *fp = fopen("main.cpp", "rb"); const int MAX_BUF = 8;char *buf = new char[MAX_BUF];int N = 99; int i_to_read = 1024; assert(MAX_BUF - i_to_read > 0); fread(buf, 1, i_to_read, fp);assert(N == 99); fclose(fp); delete[] buf;assert那一行就是防止別人犯錯,并把錯誤盡早地暴露出來的防御代碼。
防御式編程實踐
其實防御式編程理論遠比這個要深。在實踐中,各聰明的大師們總結了一套規律來嚴防死守內存越界,數組越界,值為負,除數為零, 野指針等。
下面分別說明。
防內存越界。我是懶人,還是取上面的例子:)
assert(MAX_BUF - i_to_read > 0);fread(buf, 1, i_to_read, fp);防數組越界
如下,假如我們要寫一個函數,返回數組的索引為?idx?的元素值。
int get_arr_by_idx(int *arr, int len, int idx);int get_arr_by_idx(int *arr, int len, int idx) {assert(idx <= len - 1);return arr[idx];}防值為負
char buf[9] = 0;int sz_to_set = 4;assert(sz_to_set >= 0);memset(buf, 0xAF, sz_to_set);防被除數為零
double t = 9.9f; for (int i = -2; i < 2; i++) {assert(i != 0);cout << t / i << endl; }野指針
char *buf = NULL; assert(NULL != buf);printf(buf);當然,說了這么多的防御技巧,其實最好的防御是命名。(驚訝的表情)。名命得好,可以讓人”望文生義“。筆者就常常用下面這幾個命名:
- idx?: 表元素索引, 是大于等于 0 的;
- sz, len?: 表元素長度;
- p_xx?: 表示 xx 的一個指針;
- 全大寫:?表示常量;
- m_xx :?表示成員變量;
當然,關于命名上筆者不是能手,想成為能手,就去讀一些成熟的優秀的開源框架中的命名。 相信我,你會受益良多的。推薦boost, Linux Kernel, Google Protobuf.
上面說了這么多, 如果你有幸從中學到一些,一定要告訴筆者,讓筆者欣慰欣慰。
最后, 謝謝你的閱讀。
林奇思妙想 于 深圳
2017/11/24
總結
以上是生活随笔為你收集整理的C/C++ 踩过的坑和防御式编程的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: VS2010 VC++ 编译出错 ---
- 下一篇: C、C++不定参数的使用