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.

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章