[重新理解 C++] TMP(2): variadic template parameter


前言

從這一篇開始要來講 variadic template 的概念
其實從這裡開始才算是真的走進 meta programming 的核心

上集答案 - 編譯路徑選擇

#include <iostream>

template<int n>
struct fib {
    static constexpr int result = (n < 2) ? n : (fib<n - 1>::result + fib<n - 2>::result);
};

int main() {
    std::cout << fib<10>::result << std::endl; // 55
}

為什麼這不會 work 呢

答案很簡單
因為三元運算子是執行期的路徑選擇,跟一般的 if else 一樣任何路徑都會被編譯器視為有可能執行到的部分
所以在這個範例中,n(fib<n - 1>::result + fib<n - 2>::result) 任何情況都是會被完整分析的
當 n 為 1 的時候就會出現 fib<-1> 這種不可能在執行期中執行但是卻被 compiler 試圖推導的的型別
最後 compiler 就會因為無窮遞迴深度而 fail

這也就是特化與篇特化最強大之處
他們是編譯期的路徑選擇
也就是說如果今天你在一個 template function 或 type 中寫出 syntax 合法但是 sementics 違法的代碼
只要這個 template 沒有被編譯期路徑選擇走到

那麼你的代碼依然能通過編譯

比如

#include <iostream>
#include <vector>

void print_vector(const std::vector<int>& v) {
    std::cout << v << std::endl;
}

int main() {
    return 0;
}

我們的 main 裡什麼都沒做 compiler 還是會因為找不到 cout 一個 vector 的 << stream operator 而判你錯

但是如果我們把 int 變成 template type

#include <iostream>
#include <vector>

template<class T>
void print_vector(const std::vector<T>& v) {
    std::cout << v << std::endl;
}

int main() {
    return 0;
}

compiler 就放你過了
因為 print_vector 沒有被呼叫過,同時它是一個 template
compiler 不需要去編譯 template 的內容

這種做賊不被抓到就不算犯法的特性就叫做惰性編譯
本系列另一篇文章也有提到這種特性

另外後面還會介紹另一種就算做賊被抓了,只要同夥活下來了還是不犯法的特性

然而儘管這些特性很強大很好用
但是要完全掌握 compiler 會走哪條路卻是有相當困難的
要花很多時間扛米練習才能減少跟 compiler 玩警察抓小偷的時間

什麼是 variadic template

簡單來說,就是一種 C++ 為了實現不定參數數量函數(或型別)同時保留型別資訊的一種 language feature

什麼意思呢?

這故事要從 printf 開始講
你可曾想過為什麼 printf 要用 %d, %s 這種奇怪的符號來當作字串格式化的定位符號?
一些現代語言的定位符號通常是 "{}"
舉例 python

'hello {}'.format('world')

他可以用 {} 來表示未來會填入的任何東西,包括不論是字串或數字型別
這有顯然的好處:你不需要去記 %d, %s, %f 個別是什麼意思,你全部用 {} 就好了

為什麼 C語言的 printf 不這麼設計呢?
其原因很簡單
因為C語言的 variadic parameter function 是不知道型別的,
他必須從字串中的 s,d,f 去決定要如何處理所得到的變數要怎麼解讀。
也就導致如果你 % 後的標記寫錯了,他就會印出亂碼。

C++ 的 template 挾帶型別資訊,這使得以 {} 表示變數的位置成為可能
但是除此之外,我們還需要不定參數量的介面和解開不定參數的方法。

表示不定參數的介面其實很簡單

#include <string>

template<class... T>
std::string format(const std::string& str, T... value) {
}

int main() {
    std::string str = format("{} {}", "hello", 1); // 希望可以得到 "hello 1"
}

這種 code 是可以編譯通過的
這裡 compiler 會引用到的特性有

  1. 不定數量參數
  2. 型別推導(type deduction)

其中 1 允許你可以放不同數量的參數到 format 上,而 2 則是推定出每個 T 的真實型別
也就是說,在這裡 compiler 實際上生成了這種介面

std::string format(const std::string&, const char*, int);

其中第一個 const std::string& 是來自於你的 function 第一個參數定義
這裡呼叫時傳入的 "{} {}" 觸發 std::string 的建構子
而第二個 const char* 則是由 "hello" 自動推導而來
同理 int 就是由 1 deduce 來的

那剩下的問題就在於我們要怎麼實作 format 的內容
我們要怎麼把 T 解開,列舉所有的 T

head and tail

上回說到有個語言對 list 的基本操作就是取頭和其餘尾巴來達成
儘管其背後的原因不太相同,今天我們也要做類似的事情

上面的 format 其實有點複雜,我們為了描述觀念
簡化一下題目

template<class... T>
void print_all(T... v) {
}

int main() {
    print_all(1, "hello", 2.3); // 輸出 "1,hello,2.3,"
}

(我們連尾部逗號都先不處理了,簡化問題)

利用 function overloading 和頭尾法

template<class T0, class... T>
void print_all(T0 v0, T... v) {
    std::cout << v0 << ',';
    print_all(v...);
}

看到這大概很多人已經知道這是怎麼回事了
我們用 v0 來代表第一個參數,然後 v 就是剩下的參數
print_all 的實作邏輯就很簡單了,
我們只要印出第一個,
然後把剩下的遞迴地傳給自己
這樣就完成 print_all 的實作
...嗎?

這樣會遇到一個問題

print_all(); // compile failed

任何的遞迴都是需要終止條件的
請注意在這裡 v... 的列舉最終會變成空的,所以你需要 print_all() 來完整它

void print_all() {} // 注意要寫在上面,對於下面的 print_all 來說,宣告是要往上找的

template<class T0, class... T>
void print_all(T0 v0, T... v) {
    std::cout << v0 << ',';
    print_all(v...);
}

到這裡我們簡單介紹了 variadic template parameter 的基本處理方式
儘管遠遠不夠,但是實現以 {} 為定位符號的 format 應該是綽綽有餘。

所以 format 的實作就留給讀者有空自行完成
這個東西在網路上有完整的開源 code 可以看,而且也已經進入 C++20 的標準
有興趣的可以自行研究

對於堅持自己寫作業的朋友
建議可以用 stringstream 來解決大部分的字串轉型問題
這樣可以專注在 variadic template 的運用上

Summary

本以為可以多寫一些,但是發現上次作業要解釋的東西也挺多
結果就只能走到皮毛

不過其實本篇寫起來是有點卡的,因為其實慢慢的有點不清楚初學者的弱點
所以如果有問題的話歡迎提問,要在這裡或 FB 都可以

下回會多討論一些 type deduction 的東西

你會發現從型別盒子裡把變數拿出來搞搞再放回去
或放進別的盒子其實是很容易的事情
然而這種概念到別的語言反而高深莫測了

甚至可以當成某種能力的參考標準

實在有趣

#C++ #meta programming







你可能感興趣的文章

【JS上課筆記】非同步(asynchronous)VS. 同步(synchronous)

【JS上課筆記】非同步(asynchronous)VS. 同步(synchronous)

[Math] 平方根(square root)範例

[Math] 平方根(square root)範例

JS30 Day 19 筆記

JS30 Day 19 筆記






留言討論