潮.C++11 | 標準後綴,自訂常量,編譯期自訂型別轉換

TJSW
14 min readMar 30, 2019

--

現代 C++ 為了開發更加簡捷且為了效率,增加了很多語法糖,C++ 11 針對隨手最容易打出來的常數、常量也增加了 std::literals、自訂常量 (user-defined literals) 的語法糖,讓開發者除了更有效率開發,舞台也更大。

常量 (字面常量,literal)

雖然可以理解成常數,但還是喜歡叫他常量,畢竟常數"聽"起來只能限定在純數字上面。然而形如 12.87.9e-4u'潮'"RED",這樣一串字可以寫在程式碼裡,而且所代表的意義永遠不會改變的東西也都是 literal。

整數

長得像這樣 195022 (八進位),0x13 (十六進位)。也可以加上後綴表示有號無號或長的短的:271u88l

小數

就是多了個點,有時候多了很像科學記號的東西:9.2f4.3325e+6。同樣也可以通過後綴來表示 float 型別。

字元、字串

很簡單 'a'"velvet"L"File"u'line1:\tnext'。大家最常用但也是常量的一伙,同時可以以一些特別的前綴來表示示寬的還是窄的字元、字串。

以上我想對各位同學來說太簡單了沒什麼好講的,不浪費大家時間。接下來比較特別的是從 C++14 開始,STL 引進了官方制定的後綴函式庫。

std::literals

C++14 開始,官方引進了標準函式庫 (Standard Library) 後綴 / 結尾(suffix)。讓我們可以非常便捷地以接近原生型別 (上面那些東西) 的寫法來表達某些 STL 的物件。以下依照我們親近以及容易理解程度隨意列舉幾樣:

std::string

首先是 std::string。對,就是我們從 C++98 古早年代學校作業就用到爛的 std::string。STL 為他添加的後綴其實就一個字 s。用法也很直接暴力:

using namespace std::string_literals;std::string str = "This is a sentence."s;
std::cout << str << '\n';
// 結果:This is a sentence.

所以其實跟我以前宣告字串沒什麼不一樣?上面這個例子來說的確一樣。但我們看看這個用法:

int main()
{
using namespace std::string_literals;
std::string str1 = "this is a \0\0sentence.";
std::string str2 = "this is a \0\0sentence."s;
std::cout << "str1: " << str1.length() << ", " << str1 << "\n";
std::cout << "str2: " << str2.length() << ", " << str2 << "\n";
// 結果
// str1: 10, this is a
// str2: 21, this is a sentence
}

奇蹟出現啦!加上 s 後綴初始化的 str2 居然跟 str1 不一樣!原因在於以 const char* 初始化 std::string,constructor 很自然地就把參數當作 C-style string 讀到 '\0' 結尾就不讀了。因此 str1 就只剩下被截斷的前段。

然而 s 後綴代表的是:就地直接以前面整套 " " 的內容全部拿來生出一個 std::string

所以上面的 str1str2 兩行宣告是完全不一樣的意思。前者是直接呼叫吃 const char* 的那個 ctor;後者是就地以整段 " " 的內容生成一個 std::string 再指定 (通過 copy ctor) 給 str2

喔有同學問為什麼 str2 印出來明明只有 19 個字,但前面卻是 21?因為 \0 是不可印字元。(fish 2.2.0, terminal.app @ MacOS 10.14.4)

std::complex

這個知道的人就稍微少了一點,但這其實也是從 C++98 就存在的 STL 活化石。他代表的是高一數學的那個複數 (complex number),也就是長得像 a+bi 的那東西,其中 ix² = -1 的其中一個根,ab 都是實數。

講到這應該有同學就猜出來了,標準庫賦予這個活化石的其中一個後綴就是 i。在程式碼中 using namespace std::complex_literals 之後,我們就可以完全像高中數學那樣寫出 5i,-3i 這樣子的字面常量。意涵和前面的 s 後綴類似:就地建構出一個 std::complex 物件。

int main()
{
using namespace std::complex_literals;
std::complex<double> c = 8.0 - 6i;
std::cout << c << '\n'; // (8, -6)
}

std::chrono::duration

相較前兩位就真的比較潮了,是 C++11 標準庫才新增用來表示時間長度的物件。他自身的詳細用法就不展開說了,有興趣的同學可以直接參考 std::chrono::duration - cppreference.com

既然是代表時間長度的物件,那麼標準庫提供的後綴結尾也可以很容易想到跟日常生活中用到的簡稱有關係。

  • 10min 代表十分鐘
  • 3.5h 代表三個半小時
  • 10d 就是 10 號 (一個月裡面的第 10 天)
  • 45s 就是 45 秒

當然不只上面,詳細可以參考 std::literals::chrono_literals - cppreference

咦同學說前面 std::string 已經用掉了 s 結尾了?是,也不是。原因是後面會提到的,std::stringstd::chrono::duration 分別實作的是不同參數簽名的 operator ""s() ,也就是通過字面常量運算子的重載 (literal operator overloading) 避開了衝突。"45"s 才是字串。

自訂常量 (User-defined literals)

講了這麼久終於講到主角了。C++11 讓開發者更有彈性地將任意自己定義的類別都賦予方便的語法糖來表達常量的概念。

比如說我們今天定義了一個叫 KG 的類別來代表……對,公斤。又定義了一個叫 TG 的類別來代表台斤。我們希望可以達到三件事:

  1. 使用 KG 或 TG 時不想要像很具體的物件一樣要透過函數形式 (ctor) 或制式初始化格式 (uniform initialization) 來初始化一個物件。我只是想拿他來當做重量單位使用
  2. 這兩個單位之間要可以自然地換算,而不是人工顯式地在每處呼叫函式換算
  3. 1 台斤等於 0.6 公斤。

利用傳統的 C++ 做到這些事你可能會這樣寫 code…

struct KG
{
KG(double kg) : kg_(kg) {}
double kg_;
};
struct TG
{
TG(double tg) : tg_(tg) {}
TG operator+(const TG& tg) { return tg_ + tg.tg_; }
double tg_;
};
TG KGtoTG(const KG &kg) { return TG(kg.kg_ / 0.6); }
KG TGtoKG(const TG &tg) { return KG(tg.tg_ * 0.6); }
int main()
{
KG a(100), b(160);
TG t(200);
TG t_a = KGtoTG(a);
TG t_b = KGtoTG(b);
TG t_total = t + t_a + t_b;
printf("total in TG: %lf\n", t_total.tg_);
}

我們要做的事就是 1. 宣告三個不盡相同的重量單位。2. 並把 a, b, t 三個重量用台斤表示出來。但這段代碼真的是一目不了然,太多雜亂的訊息混淆我們的原意。

上面我們的兩個目的在 C++ 裡對應到的分別就是自訂常量 (user-defined literals) 和自訂型別轉換 (user-defined conversion)。前者需要實作字面常量運算子,後者需要在你的類別中埋入轉換運算子。

字面常量運算子 (literal operator)

這東西有兩種,先講比較簡單的。既然叫運算子,那就跟平常實作類別的運算子原理是類似的。我們需要實作某個全域函數長得如下:

型別 operator"" _後綴();

上面這個運算子重載我們需要決定的只有三個地方

  1. 型別,就是自定義的類別
  2. 後綴 (ud-suffix) 要叫什麼名字。注意有底線,代表說我們自定義的字面常量後綴肯定要以底線開頭。這是規定,避免你跟原生型別後綴還有標準庫後綴強碰。
  3. 這個運算子的參數。依照你希望後綴前面擺的常量是什麼型別決定,都是一些定死的規定。比如你想要吃小數,那 C++11 規定你函數的參數一定要是一個 long double。又或是你想要吃整數,那你參數一定要是 unsigned long long int。如果你想吃字串,C++11 規定你要寫兩個參數,第一個是 const char*,第二個是 size_t

就這樣子,所以跟著我們的需求來說,我們要分別實作 KG 還有 TG 的字面常量運算子,把一個浮點數就地轉換做出一個重量物件。

TG operator"" _tg(long double n) { return static_cast<double>(n); }
KG operator"" _kg(long double n) { return static_cast<double>(n); }
TG t1 = 1.0_tg;
KG k1 = 2.0_kg;
KG k2 = 3_kg; // compile error: 一定要吃浮點數

自訂型別轉換 (user-defined conversion)

型別轉換從我們小時候學寫程式時就無意間地不斷接觸到了,比如下面:

void mymax(double x, double y);mymax(5, 6.6);  // casting 5 from int to double

由於小時候的粗心大意,再加上編譯器的寬宏大量,這種原生型別的隱式轉換 (implicit conversion) 可說是處處發生。但也只限於原生型別,自定義的類別基本就是吃 compile error 沒什麼好說的。

struct KG {  /*...*/  };
struct TG { /*...*/ };
void go(KG k);TG t = 1.5_tg;
go(t); // error: no viable conversion from TG to KG

除非,這兩件事情其中一件事發生:

  1. TGKG 的 converting constructor。他的觀念跟 copy constructor 很像,只是 copy ctor 是同類別之間才能互抄,converting ctor 是不同型別之間的抄。
  2. TG 內實作了轉換到 KG 的轉換運算子 (conversion operator)。既然是型別轉換,那就會分隱式 (implicit) 和顯式 (explicit)。

所以我們的 TGKG 就應該變成這樣:

struct KG
{
KG(double kg) : kg_(kg) {}
double kg_;
};
struct TG
{
TG(double tg) : tg_(tg) {}
// converting constructor from KG to TG
TG(const KG& kg) : tg_(kg.kg_ / 0.6) {}
// conversion operator from TG to KG
operator KG() const { return tg_ * 0.6; }
TG operator+(const TG& tg) const { return tg_ + tg.tg_; }
double tg_;
};

我們直接在 TG 裡面實作了從 KG 抄進來的 copy ctor。TG 也實作了轉換出去變成 KG 物件的轉換運算子。

看到這裡同學可能會覺得為什麼我不在 KG 實作轉換成 TG 的轉換運算子就好了呢?兩個 struct 形式對仗工整豈不美哉?

答案是沒辦法,我們仔細看看轉換運算子的形式:

operator KG() const { return tg_ * 0.6; }

前面必須掛上要轉換的目的型別,也就是說如果我們要分別各在 KGTG 實作轉成對方的 conversion operator,就會形成人型蜈蚣 (誤)。阿不對,是循環相依 (circular dependency)。

所以我們偉大的公斤台斤轉換計劃就可以這樣寫了:

int main()
{
TG t = 200._tg;
TG t_a = 100._kg;
TG t_b = 160._kg;
TG t_total = t + t_a + t_b;
printf("total in TG: %lf\n", t_total.tg_);
}

美妙,優雅,爽。

所以我說那個編譯期的自訂單位轉換 compile-time user-defined conversion 呢?

編譯期的自訂型別轉換

講到編譯期的手腳,我們又要再度請 constexpr 出馬了。

還不知道 constexpr 在編譯期可以達成怎樣變態程度的計算的同學可以參考我的前兩篇介紹:潮.C++:constexpr潮.C++:constexpr constructor, constexpr operator overloading

所以我們的目標很簡單,就是把我們的 main() 裡面所有的公斤轉台斤,還有 t_total 的加法運算都在編譯期算出來。

可以把我們的目的歸納成幾點程式上的修改:

  1. 自訂字面常量運算子operator ""_() 必須是一個 constexpr function。
  2. KGTG 也分別必須實作 constexpr constructor,也就是編譯時期全部內容可知的物件。
  3. 型別轉換部份,不論是通過 conversion operator 轉換運算或是 copy constructor 來轉換公斤和台斤,這兩個成員函式都必須是 constexpr function。
  4. 最後是 main()TG 加法,我們需要確保 TG::operator+() 也是 constexpr function。

講了這些,代碼改出來其實可以說相當無腦把所有人加上 constexpr 就完事了。

struct KG
{
constexpr KG(double kg) : kg_(kg) {}
double kg_;
};
struct TG
{
constexpr TG(double tg) : tg_(tg) {}
constexpr TG(const KG& kg) : tg_(kg.kg_ / 0.6) {}
constexpr operator KG() const { return tg_ * 0.6; }
constexpr TG operator+(const TG& tg) const {
return tg_ + tg.tg_;
}
double tg_;
};
constexpr TG operator"" _tg(long double n) {
return static_cast<double>(n);
}
constexpr KG operator"" _kg(long double n) {
return static_cast<double>(n);
}
int main()
{
constexpr TG t = 200._tg;
constexpr TG t_a = 100._kg;
constexpr TG t_b = 160._kg;
constexpr TG t_total = t + t_a + t_b;
printf("total in TG: %lf\n", t_total.tg_);
}

最後讓我們編成組合語言來看一下:

.section __TEXT,__const
.p2align 3 ## @_ZZ4mainE1t
__ZZ4mainE1t:
.quad 4641240890982006784 ## double 200
.p2align 3 ## @_ZZ4mainE3t_a
__ZZ4mainE3t_a:
.quad 4640068078579045718 ## double 166.66666666666669
.p2align 3 ## @_ZZ4mainE3t_b
__ZZ4mainE3t_b:
.quad 4643398865803455147 ## double 266.66666666666669
.p2align 3 ## @_ZZ4mainE7t_total
__ZZ4mainE7t_total:
.quad 4648782074733046443 ## double 633.33333333333337

果不其然,main() 裡面的各個台斤還有加法運算全部都算好變成 TEXT 段的常數囉~~。

講完了

自訂常量還有轉換運算真的讓 C++ 的使用者有更多彈性空間,也更簡捷地使用物件來表達清楚語義和邏輯,不只是單純像 auto 那樣子的語法糖簡化語法而已,而是能讓語義更明確 (前提是命名正常…)。再搭配上 constexpr 做到各種編譯期運算,寫 C++ 某種程度已經變成是在白紙上用鉛筆直接寫算式計算各種數字算出結果的感覺了。(什麼爛比喻)

--

--