c++可變參數模板使用示例源碼解析

前言

我們知道,C++模板能力很強大,比起Java泛型這種語法糖來說,簡直就是降維打擊。而其中,可變參數模板,就是其中一個非常重要的特性。那什麼是可變參數模板,以及為什麼我們需要他?

首先我們考慮一個經典的場景:

我們需要編寫一個函數,來打印變量信息。

比如:

int code = 1;
string msg = "success";
printMsg(code,msg); // 輸出: 1,success

而我們需要打印的參數信息是不確定的,也有可能是下面的情況:

float value = 0.8f;
printMsg(code,msg,"main"); // 輸出: 1,success,main
printMsg(value,code); // 輸出: 0.8,1

printMsg的參數類型、數量都是不確定的,無論是普通模板、還是使用容器,都無法完成這個任務。而可變參數模板,可以非常完美完成這個任務。

可變參數模板,意為該模板的類型與數量都是不確定,能夠接收任意的參數匹配,造就瞭其極高的靈活度。

認識可變模板參數

template<typename T,typename... Args>
void printMsg(T t, Args... args) {}

上述代碼為可變參數模板的例子。首先要瞭解一個概念:模板參數包,函數參數包

typename...表示一個模板參數包類型,在typename後跟瞭三個點 ,Args是一個模板參數包,他可以是0或多種類型的組合。Args...,表示將這個參數包展開,作為函數的形參,args也稱為函數參數包

舉個例子:

// T的類型是 int
// Args的類型是 int、float、string 組成的模板參數包
printMsg(1,2,0.8f,"success");
// 模板會被實例化為此函數原型
void printMsg(int,int,float,string);

對於參數包,我們可以使用sizeof... 來獲取該參數包中有多少個類型。如sizeof...(args); or sizeof...(Args);

那麼,對於這個可變模板參數類型,我們要如何使用它呢?

使用可變模板參數

遞歸法

遞歸法利用的是類型匹配原理,將參數包中的參數,一個個給他分離出來。我們從一個實際的例子來理解他。假如我們要實現前言章節中的printMsg函數,那麼他的實現代碼如下:

template<typename T,typename ...Args>
void printMsg(const T& t, const Args&... args) {
    std::cout << t << ", ";
    printMsg(args...);
}
// 調用
printMsg(1,0.3f,"success");

當我們調用printMsg(1,0.3f,"success")代碼時,模板函數被實例化為:

template<int,float,string>
void printMsg(const int& t, const float& arg1, const string& arg2) {
    std::cout << t << ", ";
    printMsg(arg1, arg2); 
}

代碼中再次遞歸調用瞭printMsg,模板函數被實例化為:

template<float,string>
void printMsg( const float& arg1, const string& arg2) {
    std::cout << t << ", ";
    printMsg(arg2); 
}

發現規律瞭嗎?當我們不斷遞歸調用printMsg時,參數報Args會被一層層解開,並將類型匹配到模板T上,從而將參數包Args中的參數逐一處理。

與此同時,我們也知道一個關鍵點:遞歸需要有終止條件。因此,我們需要在隻剩下一個參數的時候將其終結:

template<typename T>
void printMsg(const T& t) {
    std::cout << t << std::endl;
}

c++在匹配模板時,會優先匹配非可變參數模板,因此非可變參數模板則成為瞭遞歸的終止條件。這樣我們就實現瞭一個函數,能夠接受任意數量、任意類型(支持<<運算符)的參數。

特例化

遞歸法是最為常見的使用可變參數模板的方式。對於參數包來說,除瞭遞歸法,其次就為特例化。舉個例子,還是我們上面的printMsg函數:

template<>
void printMsg(const int& errorCode,const float& strength,const double& value) {
    std::cout << "errorCode:" << errorCode << " strength:" << strength << " value:" << value << std::endl;
}
printMsg(1,0.8f,0.8);

針對<int,float,double>類型的模板做瞭一個特例化,則在我們調用此類型的模板時,會優先匹配特例化。這也是一種處理可變模板參數的方式。

除此之外,還有很多對於可變模板參數的神奇用法,進一步提高他的靈活性。

包拓展

這裡包,指的是函數參數包以及可變模板參數包。前面的例子中已經存在兩個包拓展,但更多的是屬於可變參數模板的語法層面,所以並沒有展開說。比如上面我們提到的代碼:

template<typename T,typename ...Args>
void printMsg(const T& t, const Args&... args) {
    std::cout << t << ", ";
    printMsg(args...);
}
printMsg(1,0.8f,0.8);

這裡有兩個包拓展:

  • 函數的形參,在Args& 之後跟瞭三個點,表示將Args參數包展開,例子中展開後的函數原型是void printMsg(const int&,const float&,const double&);
  • 第二處展開是在遞歸調用時,將函數參數包形參展開args...,例子中展開後為printMsg(0.8f,0.8);

在涉及到函數調用、函數聲明時,都需要用到上面這兩個包拓展語法。但我們會發現並沒有什麼可以操作的空間,他更多就是一個可變模板函數的固定語法。但除此之外,包拓展可以有一個更加神奇的操作。

還是上面的例子,但是這裡我們需要對打印的數據進行一輪過濾,對int數據超過99、float數據超過0.9進行預警報告,其他數據不做處理。那麼這個怎麼處理呢?

理論上說,我們需要對每個參數包中的每個數據進行處理,那我們可以在遞歸中,判斷T的類型,再根據不同的類型進行處理。這種方式是可行的,但c++提供瞭更加好用的另一種方式。看下面的代碼:

template<typename T>
const T& filterParam(const T& t) { return t; }
template<>
const int& fileterParam(const int& t) {
    if (t > 99) { onWarnReport(); }
    return t;
}
template<>
const float& fileterParam(const float& t) {
    if (float > 0.9) { onWarnReport(); }
    return t;
}
template<typename... Args>
void printMsgPlug(const Args&... args) {
    printMsg(filterParam(args)...);  //關鍵代碼
}
printMsgPlus(1,0,3f,1.8f);

可以看到我們的關鍵代碼在於printMsg(filterParam(args)...);這一行,他等價於printMsg(filterParam(1),filterParam(0.3f) ,filterParam(1.8f)); 三個小點移動到瞭函數調用的後面,即可以實現這樣的效果。

這種方式的優點在於,他可以將過濾相關的邏輯,抽離到另外一個函數中去單獨處理,利用模板的特性對數據進行統一或者單獨處理。而且,使用typeId判斷類型的方式並不總是可靠的,這種方式會更加穩定。

此外,針對雙重過濾的方式,包拓展的解決方案也會更加優雅。假如,我們在打印數據之前,需要對數據進行一次轉換,之後再對轉換結果進行過濾判斷是否需要預警報告。那麼我們的偽代碼可以是如下:

template<typename T>
T filterParam(const T& t) {
    T result = convertParam(t);
    if()...
    return result;
}
template<typename T>
T convertParam(const T& t) {...}
template<typename... Args>
void printMsgPlug(const Args&... args) {
    printMsg(filterParam(args)...);  //關鍵代碼
}

而如果使用遞歸結合typeid的方式,可能就需要更多個switch進行類型匹配嵌套解決,且其結果總是不可靠的。

最後,並不是所有可變模板函數,都能使用遞歸去解決問題。例如我們需要一個能夠構建unique_ptr的函數,他的簡化版可以是這樣的:

template<typename T, typename... Args>
std::unique_ptr<T> make_unique(Args&... args) {
    return std::unique_ptr<T>(new T(fileterParam(args)...));
}

這個寫法是不夠完善的,但是方便我們理解。這個時候,如果我們需要對參數進行過濾,那麼遞歸的方式,就無法在這裡使用瞭,而必須使用包拓展。

完美轉發

完美轉發在可變模板中非常常見,他的作用在於保持原始的數據類型。參考我們上面的make_unique函數,在移除fileterParam函數之後,,我們希望,傳給make_unique函數的數據,能夠原封不動地,傳遞給T的構造函數。那麼他的實現如下:

template<typename T, typename... Args>
std::unique_ptr<T> make_unique(Args&&... args) {
    return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}
  • Args&& 表示通用引用,他能接收左值引用,也可以接收右值引用。
  • std::forward 表示保持參數的原始類型。因為我們知道,右值引用本身是左值,所以我們需要將其轉為右值傳遞給構造函數。

這樣,我們就能夠原封不動地將數據傳遞給構造函數,而不修改數據類型。這部分類型屬於右值與引用的范疇,這裡不詳細展開解析。

但是對於可變模板來說,這裡有一個關鍵需要註意一下:通用引用的本身,是 引用類型。假如我們傳遞瞭一個int類型進來,那麼轉化之後就變成瞭int&。此時如果我們使用Args類型去做模板匹配,很容易發生匹配失敗的問題,會提示int&無法匹配到int類型,需要多加註意一下。要解決這個問題也比較簡單,將其引用類型移除即可。在c++11中,可以使用以下代碼移除所有的修飾與引用,保持基礎的數據類型:

template<typename T>
using remove_cvRef = typename std::remove_cv<typename std::remove_reference<T>::type>::type;
std::vector<decltype(remove_cvRef<T>)> v;

在匹配模板的時候,可以使用decltype來獲取移除後的類型進行匹配。

總結

可變參數模板在實際的使用中,更多還是結合完美轉發來使用,實現對象的統一構造或者接口調用封裝等。可變參數的存在,使得模板接口的靈活度提升瞭一個檔次,如果你在實際開發中遇到類似的需求,不妨使用一下,會給你帶來驚喜的。

以上就是c++可變參數模板使用示例源碼解析的詳細內容,更多關於c++可變參數模板的資料請關註WalkonNet其它相關文章!

推薦閱讀: