C++專案中的各種坑【2018.9.7】
C++專案中的各種坑
更新時間 2018.9.7
最近做C++專案的時候,踩了許多坑。想著如果能夠將它們記錄下來,整理在案,也算是不錯的總結。於是寫下此篇。
2018.9.7
解引用空指標 在執行期的什麼時候會導致崩潰?
struct S {
int *ptr = nullptr;
int& get() {
return *ptr;
}
};
int main() {
S s;
int& t = s.get();
if (&t) {
// Do something to use t
}
}
這段程式碼會不會崩潰?
理論上,這段程式碼在 get() 就應該崩潰,因為 ptr 是一個 空指標,解引空指標會導致段錯誤。
但是,實際上,在編譯時,由於引用通常會以指標的形式傳遞,所以 s.get() 會將 ptr 傳給 t ,這個時候 t 就是一個對 nullptr 的 int& 引用。
對 &t 求值出來的結果是 nullptr ,if (&t) 的結果為 false,會跳過使用 t 的程式碼,所以, t 可能根本沒有被使用,所以程式執行時並沒有崩潰!
但會有一種崩潰的情況,那就是開啟優化後。
clang 開啟優化後,會認為 t 一定是一個 非空的引用 ,所以 if (&t) 必然為 true ,會將其優化掉,那麼一定會執行使用了 t 的程式碼。這種情況下,程式一定會崩潰!
這種情況下,小專案還好說,如果有許多層傳遞關係,那麼很有可能在十公里外看起來毫無關係的某處崩掉;更何況因為開啟了優化,也增大了除錯的難度。
本人實際測試,gcc (到 8.0)無論是否開啟 -O2 優化,都不會崩潰;而 clang 的 3.6 版本就會由於 -O2 優化而崩潰,不優化不會崩潰。可見 -O2 下,clang 比 gcc 多了個對引用判斷地址的優化……
以上事例(這個是實際專案中產生的)告訴我們,不要隨意地對空指標解引,執行期通常不會直接在解引處崩潰,而是會在幾公里外的某個使用的地方崩潰。
另:對於 if (&t) 這個寫法,clang 是有 Warning 警告的……可見關注 Warning 的重要性……
另2:測試程式碼:
#include <iostream>
using namespace std;
struct S {
int *ptr = nullptr;
int& get() {
return *ptr;
}
};
int main() {
S s;
int& t = s.get();
cout << ((&t) ? "Yes" : "No") << endl;
}
g++ test.cpp -std=c++11 -o test && ./test # No
g++ test.cpp -std=c++11 -O2 -o test && ./test # No
clang++ test.cpp -std=c++11 -o test && ./test # No
clang++ test.cpp -std=c++11 -O2 -o test && ./test # Yes
庫函式編寫,效率具有誤導性(使用者易將O(n)誤認為O(1))導致的效能問題
通常,我們會對一個函式有著潛認識,比如認為容器的 size() 函式具有 O(1) 的效率。
但當庫函式的實現打破這一潛認識,比如一個 size() 函式具有 O(n) 的效率,可能會對庫使用者造成誤導,編寫程式時可能會造成嚴重的效率問題。
比如一個列表,size() 是 O(n) 的,我們誤認為它是 O(1) 的,可能會編寫如下程式碼:
for (size_t i = 0; i < list.size(); i++) {
// Do something
}
如果 size() 是 O(1) 的,那麼整段程式碼是 O(n) 的;但如果 size() 是 O(n) 的,那麼整段程式碼將會變成 O(n ^ 2) ,這會造成嚴重的效能問題。
庫函式的編寫要有著許多考量,在設計時要對應用場景有所估計,如果確實不能達到理想情況,也要用明顯的方式來提醒使用者,這樣才能編寫出一個良好的庫。
2018.7.28
使用 std::swap 而不是臨時變數的賦值進行交換操作
今天測試了一段C++程式碼,生成 第 1000000 的斐波那契數, 使用了 GMP 庫。
mpz_class a = 1, b = 1;
for (int i = 2; i < 1000000; i++) {
mpz_class t = b;
b = a + b;
a = t;
}
發現,有一個類似的程式碼,時間居然是這個的一半。
分析後發現,那段程式碼每次只有一個加法賦值的操作,而我這個有一次加法三次賦值。mpz_class 處理大整數速度還是比較慢的,這兩次賦值就影響了效能。
隨後,改寫如下:
mpz_class a = 1, b = 1;
for (int i = 2; i < 1000000; i++) {
std::swap(a, b);
b = a + b;
}
執行時間不到之前版本的一半。
這是因為 std::swap 對於不同的型別有著相關的優化,專門化的處理自然要比隨便寫的賦值交換要強。
因此,需要交換的場合,要儘量使用 std::swap 。
2018.3.20
面向物件模型,基類需新增 virtual 解構函式
在優化 CVM 時發現,析構 parser 時,有一半記憶體沒有成功釋放。後來發現是 Instruction 基類沒有新增虛解構函式。這可能會導致記憶體洩漏。
class Base
{
public:
virtual ~Base() {} // 不加此行,Class 例項的 data 不能成功釋放。
};
struct Test
{
~Test() { std::printf("%s", "~Test()"); }
};
class Class : public Base
{
public:
Class() : data(new Test()) {}
std::shared<Test> data;
};
2018.1.23
按行讀取文字檔案時, ‘\r’ 在 Win 與 Lin 處理方式的不同
‘\r’ 在 Windows 是作為換行符的一種,因此在 Windows 系統中讀取一行時,’\r’ 會被過濾掉。而 Linux 系統會把 ‘\r’ 作為一個普通的符號來處理。因此當開發跨平臺程式時, ‘\n’ 與 ‘\r’ 等的處理一定要謹慎。
具體來說,Windows在使用 fgets 函式讀取文字檔案時,當遇到 ‘\r\n’ 結尾的一行,會自動忽略 ‘\r’,而 Linux 不會忽略。(如果是以 ‘\r’ 結尾的一行,fgets函式會讀取錯誤。)
2018.1.17
inline 與 連結
// A.h
class C
{
public:
void func();
};
// A.cpp
void C::func() {} // 正確
inline C::func() {} // 會導致連結錯誤
如果使用 A.cpp 生成一個靜態連結庫,那麼使用了 inline 的話,會導致 C::func 未加入符號表中。
inline 的正確用法是在標頭檔案中直接進行定義。
// X.h
inline void func() {}
這樣在連結時不會出現重定義錯誤。
2018.1.5
位域結構體的 size
位域結構體的 size 不能保證。其記憶體結構和成員對齊方式有關。
編譯器: MSVC 和 GCC
輸出: x64
struct A
{
uint8_t a : 2;
uint32_t b : 30;
};
sizeof(A); // MSVC 8, GCC 4
這種情況下,uint8_t 的出現影響了對齊,所以使用位域會出現非預期效果。
解決方法:
struct A
{
uint32_t a : 2;
uint32_t b : 30;
};
sizeof(A); // MSVC 4, GCC 4
只在這兩款編譯器下進行了測試。
標準沒有明確地規定位域的大小計算方式。需要根據具體情況來處理。
2017.12.30
std::string 儲存 ‘\0’
C 語言的char*字串以 \0 作為結尾。
char msg[] = "Hello World!";
msg[5] = '\0';
printf("%s\n", msg);
這樣輸出結果是 Hello 。
但是,這種情況放到 std::string 中就不一樣了。因為 std::stirng 並沒有規定以 \0 結尾。
std::string msgx = "Hello World!";
msgx[5] = '\0';
std::cout << msgx << std::endl;
這樣的輸出結果會帶有 \0, Hello\0World。
如果使用 printf 輸出 std::string,直接使用 c_str 是不行的。
printf("%s\n", msgx.c_str());
這樣不能完整地輸出 msgx。
2017.10.30
for 迴圈的判斷會重新計算
一個比較基礎的問題了,但是不注意可能會踩坑。
int c = 8;
for (int i = 0; i < c; i++) {
c--;
}
for 迴圈不會儲存 c 的值,每次都要計算表示式 (i < c)。
所以如果在迴圈中修改了判斷時引用的變數(或者判斷時呼叫的函式是不純的),那麼需要警惕。(STL容器進行for迴圈時,判斷 end() 恰巧利用了這一點。)
2017.10.19
Linux 下 printf 輸出不正常 (內嵌彙編的坑)
在寫 JitFFI 的時候,為了測試 long double 的傳遞特性,書寫了下面的程式碼:
void print_ld(long double ld) {
printf("%Lf\n", ld);
printf("0x%llX\n", *(uint64_t*)&ld);
printf("0x%llX\n", *((uint64_t*)&ld + 1));
}
void caller(void) {
asm("sub $0x8, %rsp");
asm("push $0x3fff");
asm("mov $0x8000000000000000, %rax");
asm("push %rax");
asm("call print_ld");
asm("add $0x18, %rsp");
}
int main(void)
{
caller();
return 0;
}
正常情況下,caller 函式如下書寫:
void caller()
{
print_ld(1.0);
}
應該會輸出
1.000000
0x8000000000000000
0x3FFF
但是實際上是(本機測試結果):
0.000000
0x8000000000000000
0x3fff
反彙編以後,對比內嵌彙編版本與正常版本,發現 main 的程式碼有點不一樣:
正常版本:
sub $0x8, %rsp
call caller
mov $0x0, eax
add $0x8, %rsp
ret
內嵌彙編於 caller 的版本:
call caller
mov $0x0, eax
ret
因為x64要求在呼叫函式時,%rsp
與16位元組對齊,所以呼叫 print_ld
函式時,內嵌版本會出現對齊錯誤。print_ld
呼叫 第一個 printf
時,錯誤才顯現出來。
以上案例告訴我們,內嵌彙編不要隨便寫。。
2017.10.16
Linux 下死迴圈導致宕機
重構程式碼的時候,重寫了一個帶有迴圈函式。測試時候出現死迴圈導致宕機。
解決辦法:
在沒有把握的情況下,加上assert用於測試。
int JC = 0;
while (true)
{
assert(JC++ > 100000);
}
儲存 std::initializer_list 導致引用失效
儲存 std::initializer_list 可能會出現引用失效的問題。
錯誤示例如下:
class L
{
public:
L(const std::initializer_list<int> &list)
: list(list) {}
private:
std::initializer_list<int> list;
}
解決辦法:不儲存std::initializer_list
隱式轉換導致的各種數值錯誤
錯誤示例:
using byte = uint8_t;
void print(byte v)
{
printf("%d\n", v);
}
print(2333); // Error!
解決辦法:
1. 重視 warning
2. 採取顯式命名的方式:
void print_byte(byte v)
{
printf("%d\n", v);
}
printf 輸出引數不加 \n
printf 輸出引數不加 \n
,大致有兩種錯誤形式。
一種是兩個引數混雜在一起,一種是在Linux下不能即時輸出。
void print(int v)
{
printf("%d", v);
}
print(5);
print(6); // Output : 56
這種混雜在一定情況下可能是我們希望看到的,但是大部分情況都會擾亂視聽,消耗巨大時間排除bug.