你的C++最佳實踐該刷新了

“C makes it easy to shoot yourself in the foot; C++ makes it harder, but when you do it blows your whole leg off”
-- Bjarne Stroustrup's FAQ

使用C++的過程中要小心防備各種陷阱,這些陷阱不只是因為C++語言自身的復雜性,也是因為C++要解決的問題領域的復雜性。這導致C++是一門既要精通語法特征,還要能熟練使用各種最佳實踐的語言。這些最佳實踐能保證你在優雅的使用C++,并幫助你規避各種意料之外的陷阱。

自從C++11推出以后,C++的版本升級就進入了快車道。如今C++14,C++17標準都已發布,C++20也基本敲定,C++已然是一門嶄新的語言(modern C++)。新的C++標準不僅提供了更多強大的能力,也解決了很多歷史遺留問題和不足。面對語言提供的更多選擇,C++程序員們需要與時俱進,學習新的語法特性,還要同步刷新自己的C++最佳編碼實踐。這些新的實踐可以幫你寫出更簡潔易讀的代碼,提高代碼的安全性,甚至可以低成本的收獲更高的運行時效率。

以下是一些常用的modern C++最佳實踐,看看你的C++技能是否還在與時俱進中。

  • 盡可能的使用auto代替顯式類型聲明

曾經我們的最佳實踐是不要使用匈牙利命名,避免將變量的類型信息在變量名中重復。

如今我們更進一步:在變量聲明的時候最好連類型也不要寫出,而是盡量依賴編譯器的自動類型推導。

這不僅能讓你不必寫出typename std::iterator_trait<T>::value_type it這樣的晦澀代碼,還能避免你寫出很多錯誤或者低效的代碼。

auto依賴于初始值進行類型推斷,所以強制你定義變量時必須進行初始化,這將會避免很多使用未初始化變量所帶來的悲劇。

auto x = 0 // Must be initialized as defined

下面的auto則避免了手寫類型不匹配時在循環過程中產生的大量臨時對象的開銷。

std::unordered_map<std::string, int> m;
for (const auto&p : m) {
  ...
}

在C++14中,lambda的形參類型可以使用auto,這樣可以把同一個lambda表達式復用在更多的地方(如不同元素類型容器的算法中)。

auto filter = [](const auto& item) {
    ...
}

在C++14中,普通函數或者模板函數的返回值可以使用auto進行自動推導,這極大的簡化了模板函數的寫法。

template <typename A, typename B>
auto do_something(const A& a, const B& b)
{
  return a.do_something(b);
}

記住,盡可能的使用auto,可以讓代碼更簡單、安全和高效。

  • 盡量使用統一初始化

我們有個一直都很有用的最佳實踐是“變量定義時即初始化”,現在補充一下初始化的方式:“盡量使用統一初始化”。

曾經C++在不同場合下可以分別使用()=進行變量初始化。

int x = 0;
int y(0);

class Foo {
public:
    Foo(int value) : v(value){
    }
private:    
    int v = 0; // 這里不能寫作 int v(0)
};

Foo f1(5);
Foo f2(f1);

C++11引入了統一初始化:采用{}為變量進行初始化。所以上述各種寫法可以統一為:

int x{0};

class Foo {
public:
    Foo(int value) : v{value}{
    }
private:    
    int v{0};
};

Foo f{5};
Foo f2{f1};

另外統一初始化還可以為容器初始化:

std::vector<int> v{1, 2, 3};

遺憾的是在C++11版本中,當統一初始化應用于auto時,auto x{3}會被推導為std::initializer_list類型,所以在以C++11標準為主流的社區中,大家還都習慣于優先使用傳統的()或者=進行初始化,然后選擇性的使用{}

如今C++17修復了這一問題。

auto x1{ 1, 2, 3 }; // error: not a single element
auto x2 = { 1, 2, 3 }; // decltype(x2) is std::initializer_list<int>
auto x3{ 3 }; // decltype(x3) is int
auto x4{ 3.0 }; // decltype(x4) is double

所以如果你的編譯器已經支持C++17,現在則可以大膽的使用統一初始化了,這可以減少我們被各種不同初始化方式所帶來的腦細胞損耗。

  • 盡可能使用constexpr

曾經我們說“盡可能多使用const關鍵字”,今天我們同樣說“盡可能多使用constexpr關鍵字”。

雖然constexprconst看起來很像,但其實它們并無直接關系。
const承諾的是對狀態的不修改,而constexpr承諾的是對狀態的計算可以發生在編譯期。

在編譯期進行計算,有諸多好處。除了可以把編譯期計算結果應用于數組定義、模板參數中,還可以把計算結果放置在只讀內存中,這對于嵌入式系統開發來說是非常重要的語言特性。

constexpr定義的函數,不僅可以發生在編譯期,也能發生在運行期,這取決于調用它的語境。

constexpr int pow(int base, int exp) noexcept {
    auto result = 1;
    for (int i = 0; i < exp; ++i) {
        result *= base;
    }
    return result;
}

上面的pow函數既可以用在編譯時int arr[pow(2, 3)],也可以用于運行時std::cout << pow(2, 3)
由于C++14放寬了對constexpr的限制,所以pow的寫法和普通函數是一樣的(C++11中需要靠遞歸實現)。

constexpr不僅可以消除編譯期和運行期的代碼重復,它也是提高系統運行時性能的一種手段。雖然這種運行時效率是用編譯時效率換來的,但是大多程序都是一次編譯多次運行。因此,和const關鍵字一樣,如果有可能使用constexpr,就使用它。

  • 掌握三法則五法則,但是盡可能應用零法則

熟悉C++98的程序員都知道經典的C++三法則,即“若某個類需要用戶自定義的析構函數、拷貝構造函數或賦值運算符,則它幾乎肯定三者全部都需要自定義”。

class StringWrapper final {
public:
    StringWrapper(const char* s) {
        if (s == nullptr) return;

        std::size_t n = std::strlen(s) + 1;
        cstring = new char[n];
        std::memcpy(cstring, s, n);         
    }
    ~StringWrapper() {
        if (cstring != nullptr) delete[] cstring;
    }
private:
    char* cstring{nullptr};
};

以上是一個實現非常拙劣的字符串封裝類,它違反了三法則,自定義了析構函數,但是沒有對應的自定義拷貝構造函數和賦值運算符。
這將導致下面代碼中s2使用編譯器默認生成的拷貝構造函數,對s1進行淺拷貝。于是s2和s1共享了同一個指針地址,當s1或者s2中有一個被析構,另一個對象將會持有一個失效指針,這往往是系統災難的開始。

StringWrapper s1{"hello"};
StringWrapper s2{s1};

所以謹記三法則的程序員會為StringWrapper同時定義拷貝構造函數和賦值運算符。

class StringWrapper final {
public:
    StringWrapper(const char* s) {
        init(s);
    }
    StringWrapper(const StringWrapper& other)
    { 
        init(other.cstring);
    }
    StringWrapper& operator=(const StringWrapper& other)
    {
        if(this != &other) {
            delete[] cstring;
            cstring = nullptr;
            init(other.cstring);
        }
        return *this;
    }    
    ~StringWrapper() {
        if (cstring != nullptr) delete[] cstring;
    }
private:
    void init(const char* s)
    {
        if (s == nullptr) return;
        std::size_t n = std::strlen(s) + 1;
        cstring = new char[n];
        std::memcpy(cstring, s, n);
    }

    char* cstring{nullptr};
};

而C++11引入了移動語義!用戶定義的析構函數、拷貝構造函數或賦值運算符會阻止移動構造函數和移動賦值運算符的隱式定義,所以任何想要移動語義的類必須定義全部五個特殊成員函數。

class StringWrapper final {
public:
    StringWrapper(const char* s) {
        init(s);
    }
    StringWrapper(const StringWrapper& other)
    { 
        init(other.cstring);
    }
    StringWrapper(StringWrapper&& other) noexcept
    : cstring(std::exchange(other.cstring, nullptr)){
    }
    StringWrapper& operator=(const StringWrapper& other)
    {
        if(this != &other) {
            delete[] cstring;
            cstring = nullptr;
            init(other.cstring);
        }
        return *this;
    }
    StringWrapper& operator=(StringWrapper&& other) noexcept
    {
        std::swap(cstring, other.cstring);
        other.cstring = nullptr;
        return *this;
    }    
    ~StringWrapper() {
        if (cstring != nullptr) delete[] cstring;
    }
private:
    void init(const char* s)
    {
        if (s == nullptr) return;
        std::size_t n = std::strlen(s) + 1;
        cstring = new char[n];
        std::memcpy(cstring, s, n);
    }

    char* cstring{nullptr};
};

事實上想要全部正確的實現析構函數、拷貝構造函數、賦值運算符、移動構造函數和移動賦值運算符是需要花費一番心思的,上述例子中就隱藏著好幾處缺陷。這就是為何C++核心規范中提出了C.20: If you can avoid defining default operations, do,也就是我們說的零法則。具體實施的辦法是:在實現你的類的時候,最好不要自定義析構函數、拷貝構造函數、賦值構造函數、移動構造函數和移動賦值函數,取而代之用C++智能指針和標準庫中的類來管理資源。

所以針對上述例子,直接使用std::string類,或者可以采用標準庫的容器輔助定義:

class StringWrapper final {
public:
    StringWrapper(const char* s) {
        if (s == nullptr) return;
        std::size_t n = std::strlen(s) + 1;
        data.resize(n)
        for (int i = 0; i < n; i++) {
            data[i] = s[i];
        }
    }
private:
    std::vector<char> data;
};

因此,你應當熟悉modern C++的五法則,但是實踐的時候盡量遵循零法則。

  • 在某些必須拷貝的情況下,考慮用傳值代替傳遞引用

曾經C++的最佳實踐告訴我們,“為了避免函數傳參時的對象拷貝開銷,盡量選擇傳遞引用,最好是傳遞const引用”。

C++11引入移動語義后,這一規則在某些時候需要做些調整:如果拷貝不能避免,那么為了能夠統一代碼,或者保證異常安全,優先考慮用傳值代替傳遞引用。

class ResourceWrapper final {
public: 
    ResourceWrapper(const std::size_t size)
    : resource {new char[size]}, size {size} {
    }
    ~ResourceWrapper() {
        if (resource != nullptr) {
            delete [] resource;
        }
    }
    ResourceWrapper(const ResourceWrapper& other)
    : ResourceWrapper{other.size} {
        std::copy(other.resource, other.resource + other.size, resource);
    }
    ResourceWrapper(ResourceWrapper&& other) noexcept {
        swap(other);
    }
    ResourceWrapper& operator=(ResourceWrapper other) {
        swap(other);
        return *this;
    }

private:
    void swap(ResourceWrapper& other) noexcept {
        std::swap(resource, other.resource);
        std::swap(size, other.size);
    }

    char* resource{nullptr};
    std::size_t size;    
};

上面的代碼中,operator=函數采用按值傳遞參數,不僅統一了普通賦值和移動賦值函數的實現,而且還保證了異常安全性(先用臨時對象統一申請內存,申請成功后才會進行swap)。

誠然,是否應用這一規則和場景有關。但是當你實現的函數既要處理左值也要處理右值,而處理左值時不可避免的要拷貝,這時請考慮設計成傳值是否是個更好的選擇。

  • 使用nullptr而非0NULL

曾經的最佳實踐告訴我們,“不要直接使用0作為指針的空值”,所以每個C++項目都會封裝自己的NULL實現。

當初C++11帶來了標準的空指針nullptr,就是為了結束各種千奇百怪的NULL實現。

NULL的最大問題在于每種實現的類型都不一樣,有的是int,有的是double,還有的是enum,這不僅導致了各種參數傳遞時出現的轉型問題,還會影響模板的類型推演。

nullptr的類型是確定的std::nullptr_t,并且實現了向每種兼容類型的隱式轉換。它的安全性和兼容性要比過去的實現都好。

現在你需要做的是將原來的NULL定義做下修改,例如將#define NULL int(0)改為#define NULL nullptr,然后讓編譯器幫你發現代碼中的潛在錯誤并進行修改。

  • 為覆寫的虛函數加上override關鍵字

曾經,當子類中覆寫的虛函數的方法名不小心拼寫錯誤的時候,如果父類中又提供了默認實現,將會導致嚴重的邏輯錯誤。而這般嚴重的錯誤往往只能等到程序運行后才能被發現。

最終C++11為此提供了override關鍵字。所以你應該毫不猶豫地在每個被你覆寫的虛函數后面加上override,然后讓編譯器幫你檢查虛函數覆寫的錯誤,將問題在程序發布前徹底解決掉。

  • 保持持續學習和實踐

前面介紹了一些日常比較容易用到的modern C++最佳實踐,當然還有很多,包括一些高級語法的或者和標準庫有關的實踐,鑒于篇幅這里就不再介紹了。

C++的設計哲學是能更好的解決現實中存在的問題,新的語法的引入都是由現實問題驅動出來的。那些曾經不能解決的、或者解決得不夠優雅的問題,今天在新的C++標準中很可能都有了更好的解決方案。你應該保持持續學習,時常看看曾經棘手的問題,是否已經有了更優解。

在寫這篇文章的時候我看到C++17的通過構造函數進行類模板參數類型推導(CTAD),內心就不禁一陣喜悅,這個特性可以讓我曾經構造的一個Promise斷言庫的DSL寫的更加的精煉。

// Example of CTAD

template <typename T = float>
struct Container {
  T val;
  Container() : v() {}
  Container(T v) : v(v) {}
  // ...
};

Container c1{ 1 }; // Container<int>
Container c2; // Container<float>

除了不斷關注C++的特性演進外,還需要經常檢查自己的編譯器,在可能的情況下更新編譯器版本。盡量使用新的語法,寫出更簡潔、安全和高效的代碼。(C++各種編譯器版本和語法特性的對照表

作為一名合格的程序員,我們以這樣一個最佳實踐結束:保持持續學習和實踐

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,333評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,491評論 3 416
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,263評論 0 374
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,946評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,708評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,186評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,255評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,409評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,939評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,774評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,976評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,518評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,209評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,641評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,872評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,650評論 3 391
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,958評論 2 373