摘要訊息 : 一些 C++ 17 引入的小特性.

0. 前言

C++ 17 引入的絕大多數重要特性我們基本都介紹完了, 可以在 Jonny'Blog 中搜尋 C++ 17 閱覽這些文章. 還有一些小特性也是 C++ 17 引入的, 本文文章將通過羅列的方式介紹一部分特性.

更新紀錄 :

1. 聚合初始化

所謂聚合初始化, 就是對聚合體進行初始化, 到 C++ 11 為止, 這個聚合體包含了 :

  • 陣列;
  • 複合下面要求的類別 (通常為結構體或者等位) :
    • 沒有用戶自訂的建構子;
    • 不存在非靜態私用成員;
    • 成員不可被預設初始化;
    • 沒有虛擬函式;
    • 沒有從基礎類別繼承.

由於這些型別聚合了多個變數, 因此被稱為聚合體 (aggregate). 聚合初始化就是按照一個聚合體中變數宣告的順序, 使用初始化列表對這些變數按宣告順序進行初始化. 不過需要注意的是, 和列表初始化相似, 窄化型別轉化是不被允許的. 上面提到, 聚合體可以包含靜態成員變數, 也可以包含不具名實體, 但是在聚合初始化的時候, 會跳過這些靜態成員和不具名實體, 只按照普通成員變數的順序進行初始化. 如果初始化列表中的引數數量並不足以初始化聚合體中全部的變數, 那麼剩下來的變數會被預設初始化; 但是如果初始化列表中的引數數量超出了一個聚合體中變數的數量, 就會產生編碼錯誤. 最後, 使用聚合初始化的方式對等位進行初始化的時候, 只會初始化第一個非靜態成員變數.

從上面針對聚合體的定義中, 某一些要求其實可以被簡化, 使得聚合初始化可以應用到更多的場景中. C++ 17 提案 P0017R1《Extension to aggregate initialization》就提出解除聚合初始化在非私用基礎類別上的限制. 在建構子的匹配上, 如果建構子沒有被 explicit 標識, 那麼聚合初始化裡的引數會被當作普通的函數風格的初始化, 和用戶自訂的建構子進行匹配. 但是, 如果出現建構子繼承的情況, 聚合初始化就不再可用. 而 P0017R1 就提出了讓聚合初始化按照繼承的順序, 在 public 繼承情況下仍然可用 :

struct S {
    int a;
    S(int a, int) : a {a} {}
};
struct S2 : S {
    char b;
};
struct S3 : S2 {};

struct base1 {
    int a;
};
struct base2 {
    char b {'0'};
};
struct base3 {
    float c {0.1f};
};
struct derived : base1, base2, base3 {
    const char *str;
};

int main(int argc, char *argv[]) {
    S2 a {{1, 2}, 0};       // {1, 2} 匹配 S(int, int); 第二個 0 去初始化 S2 中的成員變數 b
    S3 b {{{0, 0}}};        // {0, 0} 匹配 S(int, int); {{0, 0}} 用於初始化 S2, S2 中的 b 被預設初始化; {{{0, 0}}} 用於初始化 S3
    derived d {{42}, {}, {0.3f}, "hello world!"};       // {42} 用於初始化 base1 中的 a, base2 中的 b 被預設初始化, {0.3f} 用於初始化 base3 中的 c, 而 "hello, world!" 用於初始化 derived 類別中的 str
}

2. 表達式計算順序

我們曾不止一次指出 C++ 的表達式計算順序問題, 特別是類似於 function_call(expr1, expr2, expr3), 其中的 expr1expr2 和 expr3 的計算順序是實作定義行為, 這也就導致了某些情況下我們需要注意這些問題, 以避免資源流失的情況出現. 不過, 這種問題一直是 C++ 討論的重點, 從 C++ 誕生起就有. 然而, 問題不只是被討論這麼簡單, 就算是 C++ 老手, 也可能會被這樣的問題所困擾, 因為不同編碼器產生的結果並不一定相同, 於是就有了下面這個被寫入 C++ 最權威教科書 ("The C++ Programming Language" 4th.) 的錯誤實例 :

std::string s = "but I have heard it works even if you don’t believe in it";
s.replace(0, 4, "").replace(s.find("even"), 4, "only").replace(s.find(" don’t"), 6, "");
assert(s == "I have heard it works only if you believe in it");

上面這個程式碼會不會因為斷言失敗而產生運作時錯誤呢? 在 C++ 17 之前, GCC 和 MSVC 這兩個編碼器都可能產生錯誤. 這是因為上面這段程式碼存在實作定義行為, C++ 標準並沒有嚴格規範計算順序, 從而使得在不同編碼器之下的行為有所不同. 因此, C++ 17 提案 P0145r3《Refining Expression Evaluation Order for Idiomatic C++》提出對 C++ 三十多年來沒有怎麼修改過的表達式計算順序有一個更嚴格的限制 : 對於陣列注標運算, 函式呼叫, 成員訪問, 字尾遞增, 字尾遞減, 位元左移和位元右移, 限制它們的計算順序為從左到右; 對於運算指派 ("+=", "-=", "*=", "/=", "%=", "&=", "|=", "^=", "<<=" 和 ">>=") 和直接指派, 限制它們的計算順序為從右至左. 這樣, 上面提到的問題自然就被解決了.

Tip : 對於本節中提到的 std::string s = "but I have heard it works even if you don’t believe in it"; 計算順序問題, 大家可以參考這個帖子 : https://stackoverflow.com/questions/27158812/does-this-code-from-the-c-programming-language-4th-edition-section-36-3-6-ha.

3. if constexpr

在 C++ 17 之前, 如果我們要在編碼器就決定是否運作函式的某個部分, 那麼就要借助樣板 :

template <bool>
void f_block(...) {
    //...
}
template <>
void f_block<false>(...) {
    //...
}
void f() {
    //...
    f_block<false>();
    //...
    f_block<true>();
    //...
}

由於函式樣板無法偏特製化, 這也就導致了這些函式的參數型別不能是泛型的. 如果我們確實需要泛型, 那麼需要借助類別樣板才可以做到. 為了降低語法的複雜性, C++ 17 提案 P0292R2《constexpr if: A slightly different syntax》引入了 if constexpr 這樣的語法. 其使用方法類似於 if 陳述式, 條件必須是編碼期可計算的常數表達式. 如果判斷表達式的計算為 true, 那麼編碼期就會將接下來的區塊納入需要運作的範圍 :

void f() {
    //...
    if constexpr(expr) {
        // 如果 expr 為 true, 那麼這個區塊的程式碼將被運作; 否則, 會跳過這段程式碼
    }
    //...
}

不過例外的是, 即時條件判斷最終的計算結果為 false, 如果區塊內部存在 static_assert, 且斷言的結果為 false, 那麼同樣會導致編碼錯誤 :

void f() {
    //...
    if constexpr(false) {
        //...
        static_assert(false);       // Error
        //...
    }
    //...
}