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.