摘要訊息 : C++ 11 中關於字串的兩個新特性.

0. 前言

除了和多執行緒相關的內容之外, Jonny'Blog 已經幾乎將 C++ 11 的內容全部囊括其中. 但是用戶自訂字面值一直是我們沒有講述的內容. 這是因為這個特性我實在是找不到使用的理由, 它的限制太嚴格的了. 直到 C++ 20 為止, 它的限制幾乎沒有什麼大的改變, 這彷彿是專門為 C++ 標準樣板程式庫專門提出的特性. 事實上除了標準樣板程式庫之外, 我也很少在程式庫或者程式設計中看到有人使用這樣的特性, 它甚至比 C++ 11 引入的屬性還要冷門. 另一個內容是原始字串, 這個特性也幾乎沒什麼人使用. 我認為這主要是因為 C++ 中的原始字串比較複雜的原因.

更新紀錄 :

  • 2022 年 6 月 16 日進行第一次更新和修正.

1. 用戶自訂字面值

對於 1ull, 2.0lfu16"123" 類似的字面值來說, 前綴和後綴都代表著這個字面值應該是什麼樣的型別. 但是對於非內建型別, 比如複數型別和分數型別這樣的自訂型別, 我們沒有辦法使用類似的前綴或者後綴來描述它們, 只能通過對應的建構子來建構. 因此, C++ 11 就引入了用戶自訂字面值, 它允許我們使用 12_km 表示 12 千米, 使用 8_h表示 8 小時等. 實質上, 用戶自訂字面值是一個多載函式, 它具有如下形式 : R operator"" user-defined-suffix(parameter-list);. 其中, R 表示回傳型別; user-defined-suffix 是用戶自訂後綴, 之前所說的 _km_h 就是我們自訂的後綴, 不能以數字開頭, 不能存在特殊字元; parameter-list 是這個函式的參數列表. 這個宣告是一個函式宣告, 因此任何可以用於函式上的特性都可以用於這個宣告, 例如 constexpr, inlinenoexcept 標識, 還有屬性等.

支援用戶自訂的字面值有 :

  1. 二進制字面值;
  2. 八進制字面值;
  3. 十進制字面值;
  4. 十六進制字面值;
  5. 字元字面值;
  6. 字串字面值.

其中, 數字字面值指的是前面四個, 它可以是整型的, 也可以是浮點數的, 可以被分隔符 ' 隔開 (分隔符在處理的時候會被忽略, 參考《【C++ 14】使用單引號作為長數字的分隔》), 甚至可以是科學計數的.

現在我們寫一個關於用戶自訂字面值的最簡單的程式 :

constexpr double operator"" _to_double(unsigned long long) noexcept {
    return 1.0;
}

int main(int argc, char *argv[]) {
    double a {123_to_double};       // OK
    int b {123_to_double};      // Error : type 'double' cannot be narrowed to 'int' in initializer list
}

Code 1 中, 我們把任何帶有 _to_double 後綴的數字統一轉換成了 double 型別的 1.0 字面值. 那麼如果將上面的後綴改為類似於 LLull 這種本身就是內建的後綴是否可行呢? 答案是可行的, 不產生任何編碼錯誤 (但是部分編碼器可能會對這個行為擲出編碼警告), 因為我們可以通過自訂來讓字串和字元也可以支援 LLull這種後綴. 但是對於字面值數字, 編碼器永遠不會運作我們自訂的那個函式, 因為內建的後綴永遠優先於用戶自訂的字面值後綴.

對於用戶自訂字面值函式的參數列表, 我們有很嚴格的限定.

非樣板的用戶自訂數字字面值函式的參數列表有且唯有一個參數, 且型別為 unsigned long long 或者 long double, 不可以使用其它任何型別. 因為 unsigned long longlong double 分別是最大的內建整型型別和浮點數型別. 對於負的整數來說, 例如在之前程式碼中的 -123_to_double, 編碼器首先會針對 123_to_double 進行轉換, 然後將負號放置在轉換之後的結果上, 最終的結果為 -1. 對於樣板化的用戶自訂字面值函式, 它不能接受任何參數, 且樣板只能宣告為 template <char ...> 或者 template <typename T, T ...>. 對於 template <char ...>, 任何字面值都會被編碼器拆解為單個字面值, 然後作為樣板引數. 例如對於

#include <iostream>

template <char ...Cs>
constexpr double operator"" _to_double() noexcept {
    std::cout << sizeof...(Cs) << std::endl;
    return 1.0;
}

如果我們使用了 123456_to_double, 那麼編碼器實際上是呼叫了 operator"" _to_double<'1', '2', '3', '4', '5', '6'>(). 上述程式碼中的 sizeof...(Cs) 的值為 6. 對於浮點數, 小數點也會被當成獨立的字元放入樣板引數中, 例如 12.3456_to_double, 上述程式碼中的 sizeof...(Cs) 的值為 7.

字元字面值的自訂, 其參數列表也只能存在一個參數, 不過型別可以從 char, wchar_t, char16_t, char32_tchar8_t (since C++ 20) 中選擇一個. 對於 char 型別, 不可以為其標記 signed char 或者 unsigned char, 字元字面值的用戶自訂函式的參數列表只接受 char. 剩餘的用法和用戶自訂的數字字面值是類似的, 此處不再累贅.

字串字面值也可以自訂, 它的參數列表可以有一個或者兩個參數 : 第一個參數必須是字面值字串的型別, 可以從 const char *, const wchar_t *, const char16_t *, const char32_t *const char8_t * (since C++ 20) 中進行選擇; 第二個參數是可選的, 它被限定為 std::size_t 型別, 用於描述字面值字串不包含終結字元的字串長度.

用戶自訂的字串字面值也可以進行拼接 :

#include <iostream>

using namespace std;
constexpr int operator"" _to_int(const char *, size_t) {
    return 1;
}
constexpr int operator"" _to_int(const char16_t *, size_t) {
    return 0;
}
constexpr int operator"" _to_int(const char32_t *, size_t) {
    return 2;
}
constexpr int operator"" _to_int(const wchar_t *, size_t) {
    return 3;
}
int main(int argc, char *argv[]) {
    cout << "123" "vvvv" "000"_to_int << endl;      // 輸出結果 : 1
    cout << "123"_to_int "vvv"_to_int << endl;      // 輸出結果 : 1
    cout << u"123"_to_int ""_to_int << endl;        // 輸出結果 : 0
    cout << "123"_to_int L""_to_int << endl;       // 輸出結果 : 3
    cout << u"123"_to_int L""_to_int << endl;       // Error : unsupported non-standard concatenation of string literals
    cout << U"123"_to_int L""_to_int << endl;       // Error : unsupported non-standard concatenation of string literals
}

由此, operator"" 成為了一個字面值運算子, 而不僅僅是代表著一個字串.

從上面來看, 大家可能覺得限制僅僅是在運算子多載中的參數罷了. 其它的運算子多載對於參數數量也有限制, 只不過用戶自訂的字面值多載的函式對參數的型別有更多的限制. 但是實際上, 限制還有更多. 大家可以看到, 我自訂的後綴都是以 _ 打頭的. C++ 規定了所有不以 _ 打頭的後綴都被保留, 大家見到不以 _ 打頭的後綴大多是在使用 C++ 標準樣板程式庫的時候. 大家使用用戶自訂字面值的時候, 就是為了方便, 以 _ 打頭不但不好看, 也不方便, 這很不 C++. 還有, 所有以 _ 打頭並且首個字元為大寫的用戶自訂後綴也是被保留的.

對於字串的用戶自訂字面值, 上面說到第二個參數 std::size_t 是可選的. 如果在對於字串多載的字面值運算子中省略掉第二個參數 std::size_t 並不會產生編碼錯誤, 但是實際使用的時候又是另外一回事 :

#include <iostream>

constexpr int operator"" _to_int(const char *) {
    return 1;
}
int main(int argc, char *argv[]) {
    std::cout << ""_to_int << std::endl;        // Error : no matching literal operator for call to 'operator""_to_int' with arguments of types 'const char *' and 'unsigned long', and no matching literal operator template
}

當然, 運算子多載不支援存在預設引數, 字面值運算子也是如此. 但是一般的運算子多載支援使用連結指示, 但是多載的字面值運算子不支援 extern "C" :

enum E {};
extern "C" {
    bool operator+(int a, E b) {
        return false;
    }
    bool operator"" _to_bool(const char *) {        // Error : literal operator must have C++ linkage
        return true;
    }
}
bool operator"" _to_bool(const char *) {        // OK
    return true;
}

現在來了解一個更特殊的情形 :

struct S {
    void func() {}
};
constexpr S operator"" _S(unsigned long long) noexcept {
    return {};
}
int main(int argc, char *argv[]) {
    12_S.func();        // Error : no matching literal operator for call to 'operator""_S.func' with argument of type 'unsigned long long' or 'const char *', and no matching literal operator template
}

我們的本意是通過用戶自訂字面值將 12 轉換為 S 型別的預設值之後, 呼叫類別 S 中的成員函式 func, 然而產生了編碼錯誤. 因此, 我們要在中間增加一個空格 12_S .func(); 或者直接加入括號 (12_S).func() 才行. 通過這個實例, 我們也很顯然可以知道, 使用科學計數法之後 (以 eE 結尾的數字), 也會有類似的問題 :

#include <iostream>

using namespace std;
struct S {
    void func() {}
    int operator-(int) {
        return 0;
    }
};
constexpr S operator"" _e(unsigned long long) noexcept {
    return {};
}
int main(int argc, char *argv[]) {
    cout << fixed;
    cout << 5e-3 << endl;       // 輸出結果 : 0.005000
    cout << 5_e-3 << endl;      // Error : no matching literal operator for call to 'operator""_e-8' with argument of type 'unsigned long long' or 'const char *', and no matching literal operator template
    cout << 5_e -3 << endl;     // 輸出結果 : 0
    cout << (5_e)-3 << endl;        // 輸出結果 : 0
}

還有一個是關於 std::printf 的. 在引入用戶自訂字面值之後, std::printf("%"PRId64"\n",INT64_MIN); 就會產生編碼錯誤, 需要增加一個空格, 修改為 std::printf("%" PRId64"\n",INT64_MIN); 才可以.

上面這麼多限制, 我是真的想不到有什麼使用它的理由, 儘管它看起來很簡潔, 但這是針對 C++ 標準樣板程式庫而言的.

最後, C++ 14, C++ 17 和 C++ 20 在標準樣板程式庫中使用了用戶自訂字面值 :

  • if, iil 後綴分別表示 std::complex<float>, std::complex<double>std::complex<long double> 的字面值 (since C++ 14);
  • h, min, s, ms, usns 後綴分別表示 std::chrono::duration 中小時, 分鐘, 秒, 毫秒, 微秒和納秒的字面值 (since C++ 14);
  • s 後綴表示 std::string 的字面值 (since C++ 14);
  • sv 後綴表示 std::string_view 的字面值 (since C++ 17);
  • yd 後綴表示 std::chrono::yearstd::chrono::day 的字面值 (since C++ 20).

2. 原始字串

如果某個字串中包含了較多需要轉譯的字元, 那麼就可以使用 C++ 11 引入的原始字串, 它的基本形式為 R"()". 其中, 前綴 R 表示了這個字串是一個原始字串, 字串中的括號不能省略, 括號之內的字串是真正的字串. 例如字串 "\\\n\'\"\\\\" 使用原始字串就可以直接寫為 R"(\
'"\\)"
. 記著, 這裡有一個換行. 也就是說, 在一個原始字串中, 如果換一行繼續寫, 那麼確實代表了其中有一個換行字元 :

#include <iostream>

int main(int argc, char *argv[]) {
    std::cout << (string("\\\n\'\"\\\\") == string(R"(\
'"\\)")) << std::endl;      // 輸出 : 1
    std::cout << (string("\\\n\'\"\\\\") == string(R"(\\n'"\\)")) << std::endl;        // 輸出 : 0, \n 在原始字串中被解釋為 '\\' 和 'n' 兩個字元
}

本來字元 )" 是不支援在原始字串中出現的, 如果要出現, 就需要使用分隔符 : R"delimiter()delimiter". 其中, delimiter 就是分隔符. 分隔符可以是任何不包含 "\", 空格, "(" 和 ")" 的字串, 長度不能超過 16 個字元. 因此, 任何滿足上述條件的字串都可以作為分隔符, 不一定必須寫為 delimiter. 例如, R"asd()asd" 和 R"dva++,weq()dva++,weq" 的分割符為 asddva++,weq. 並且,

#include <iostream>

int main(int argc, char *argv[]) {
    std::string a {R"asd()asd"};
    std::string b {R"dva++,weq()dva++,weq"};
    std::cout << (a == b) << std::endl;     // 輸出結果 : 1
}

但是通過 Code 7, 我發現並不需要分隔符, 很可能是現代編碼器會自動判定字面值原始字串到底哪一個 ") 是作為字串結束的標誌. 因此, 對於現代編碼器來說, )" 這兩個字元可以直接出現在原始字串中.

在預設情況下, R"()" 表示型別為 const char [] 的字面值字串. 若要使用其它型別的字元, 可以使用下列前綴 :

  • LR"()" 表示 const wchar_t [] 型別的原始字串;
  • uR"()" 表示 const char16_t [] 型別的原始字串;
  • UR"()" 表示 const char32_t [] 型別的原始字串;
  • u8"()" 表示 const char8_t [] 型別的原始字串 (since C++ 20).