天天看點

C++模闆和模闆特例(防坑指南)

    大家好啊!逗比老師又和大家見面了!今天要給大家分享的是C++中的模闆。不過并不是基礎教程,而是以“避坑”為主。是以呢,可能更适合有一定C++基礎的同學。當然了,如果你正在被這個惡心的C++模闆困擾,那麼,你來對地方了!

    那麼首先,我們舉一個栗子給大家吃(呸~~給大家聽)。假如我們要做的事,是接受一個變量作為圓的半徑,然後傳回圓的面積。(emmm...這裡栗子很逗比,原諒我實在想不出什麼高大上的例子了,大家湊合看看吧,咳咳……)。但是,這個半徑可能是用整數、浮點數、字元串等形式傳進來的,我們要求傳回值類型和傳入的類型一直。這種需求自然是很适合用模闆來完成的,于是,有了以下代碼:

template <typename T>
T aera(const T &radium) {
    return M_PI * radium * radium;
}
           

    隻不過這樣寫有個問題,我傳整型、無符号整型、浮點型都是OK的,但是,傳字元串就出問題了。因為如果T執行個體化為std::string的話,兩個字元串是沒有*運算的。是以,字元串需要單獨适配的。有一種做法就是,為字元串單獨寫一個函數,和這個模闆函數分開,比如這樣:

std::string aera_str(const std::string &radium) {
    double r = std::stod(radium);
    double squ = M_PI * r * r;
    return std::to_string(squ);
}
           

    但是這種解決方法就有點逃避問題了,因為兩個函數的函數名是不一樣的,我們在使用的時候,還必須知道兩個函數的存在,否則,很容易會調用aera<std::string>,甚至有時候還會由于自動類型推導調用了aera<const char *>,而這兩個模闆示例都是會報錯的,因為std::string和const char *類型都是沒有乘法運算的。

    那麼,有沒有一種方法是,我們還是使用aera這個模闆函數,但是,當我們傳入的是字元串的時候,單獨寫一個方法呢?答案是肯定的,那就是使用模闆特例。

    顧名思義,模闆特例就是有别于通用模闆定義的一個特例,它針對于某一個特殊的類型,進行特殊的處理,而沒有在特例中的則使用通用的模闆方法。例如,我們想為std::string類型做一個模闆特例,我們應該這樣做:

template <typename T>
T aera(const T &radium) {
    return M_PI * radium * radium;
}

template <>
std::string aera<std::string>(const std::string &radium) {
    double r = std::stod(radium);
    double squ = M_PI * r * r;
    return std::to_string(squ);
}
           

    效果和上面的aera_str是相同的,但是,我們在調用的時候,就可以直接用aera<std::string>(),當模闆參數是std::string的時候,就會調用下面的函數,而其他情況會調用通用的函數。

    貌似到此我們問題完美解決了。勤快的同學可能已經在自己的IDE上試驗了,确實,這樣做可以解決我們的問題。但是,這種表面上的美麗背後是一些列**疼的坑。

    假如我們這個求aera的功能是要在多個檔案中使用的,我們就應當把它單獨寫到一個頭檔案中,然後,讓需要的子產品去include這個頭檔案。下面的代碼我們将aera函數放在aera.hpp中,然後main.cpp和test.cpp都去調用它:

// aera.hpp
#ifndef aera_h
#define aera_h

#include <cmath>
#include <string>

template <typename T>
T aera(const T &radium) {
    return M_PI * radium * radium;
}

template <>
std::string aera<std::string>(const std::string &radium) {
    double r = std::stod(radium);
    double squ = M_PI * r * r;
    return std::to_string(squ);
}


#endif /* aera_h */
           
// test.cpp
#include "test.hpp"
#include "aera.hpp"

void test() {
    int a1 = aera<int>(5);
    std::string a2 = aera<std::string>("6");
}
           
// main.cpp
#include <iostream>
#include "aera.hpp"

int main(int argc, const char * argv[]) {
    
    int a1 = aera<int>(7);
    std::string a2 = aera<std::string>("2");
    
    return 0;
}
           

    每一個cpp檔案編譯都是OK的,但是,一旦連結,就會報錯,報了一個重定義的錯誤。它會說,aera<std::string>這個函數被重定義了。什麼?你在逗我嗎?我明明就寫了一份啊。哦!對了,好像我把這個寫到頭檔案裡了,因為引入頭檔案造成了再main.cpp和test.cpp中都有一份定義,于是重定義的。

    道理上說得通,但是我們會發現,如果僅僅把string處理的特例函數注釋掉,保留原來的模闆函數,這樣就是OK可以連結通過的。這又是為什麼呢?不寫模闆特例的話,難道就不會重定義嗎?

    這個問題先放一下,既然是這個特例函數出了問題,那我們讓它單獨定義可不可以呢?做個實驗,把string這個特例函數從頭檔案中拿出來,放到一個cpp檔案中,這樣可行嗎?比如我們定義了aera.cpp,然後現在的代碼是這樣的:

// aera.hpp
#ifndef aera_h
#define aera_h

#include <cmath>
#include <string>

template <typename T>
T aera(const T &radium) {
    return M_PI * radium * radium;
}


#endif /* aera_h */
           
// aera.cpp
#include "aera.hpp"

template <>
std::string aera<std::string>(const std::string &radium) {
    double r = std::stod(radium);
    double squ = M_PI * r * r;
    return std::to_string(squ);
}
           
// test.cpp
#include "test.hpp"
#include "aera.hpp"

void test() {
    int a1 = aera<int>(5);
    std::string a2 = aera<std::string>("6");
}
           
// main.cpp
#include <iostream>
#include "aera.hpp"

int main(int argc, const char * argv[]) {
    
    int a1 = aera<int>(7);
    std::string a2 = aera<std::string>("2");
    
    return 0;
}
           

    這下我們會發現,直接在編譯階段報錯了,而且main.cpp和test.cpp都報了同樣的錯,錯誤的資訊是兩個const char *類型的變量沒有重載"*"運算。

    換句話說,它其實是調用到預設的函數模闆了,沒有找到這個特例。可能是因為沒有找到test.cpp中的函數定義吧,如果我們把這個模闆特例拿到main.cpp或者test.cpp中,那麼,這個檔案就正常了,另外那個檔案就會編譯失敗。但是如果我們兩個檔案都放一份,其實也就相當于之前寫在頭檔案中是一種效果了,也就是在連結時會報重定義錯誤。

    天哪!這玩意原來是個雞肋啊!看似模闆特例可以解決很多問題,怎麼現在看起來這玩意沒辦法用啊!我總不能保證某一個特例隻在一個cpp檔案中使用,其他檔案都不使用吧?是以到這一步,很多小夥伴就受不了了,改去使用最初的定義兩個函數的方法了。

    不過别灰心,今天逗比老師就是來幫大家解決這個問題的!要想知道這個問題怎麼解決,首先我們要先知道為什麼會發生這樣的情況。

    C++是一個靜态類型語言,也就是說,所有的變量類型都是在編譯的時候就确定好的,不能等到運作的時候再确定。是以C++無法支援反射機制,無法支援運作時等特性,同樣,C++也不支援泛型程式設計。“什麼?逗比老師你又逗比了!咱今天不就在講模闆嗎?模闆難道不是泛型嗎?要是C++都不支援泛型,那還會有今天這些事啊?”别急!聽我把話說完。所謂的“泛型程式設計”其實就是在編譯的時候不能确定一些變量的類型,在運作時根據某些因素來決定的。是以,泛型其實已經就是動态類型了。既然C++是靜态類型,那麼一定無法支援泛型。而我們所謂的模闆其實也就是泛型的代替品,讓我在不能使用泛型的情況下,還能完成一些類似于泛型的事情。

    既然是叫“類似”,那麼,肯定還是有本質差別的。即使我們使用模闆,在編譯階段,所有變量的類型也都是确定的。這就意味着,你在寫一個模闆之後,是不能生成對應的機器碼的。因為,編譯器怎麼知道這個模闆以後會被執行個體化成什麼類型呢?又不可能把所有的類型都生成一份,一來是浪費資源,二來自定義類型是未知的,無窮多的,不可能全部提前生成。于是,C++編譯器想了個辦法,就是你在寫這個模闆代碼的時候,不做任何事情(當然,IDE還是會做一些靜态檢查的,隻不過,内容非常有限,很多文法錯誤都沒辦法檢測出來,比如這裡的乘号,到底執行個體化後支不支援乘法,現在不知道)。然後,當你執行個體化這個模闆的時候,再根據你傳入的類型去生成對應的代碼。

    換句話說,在我們的例子當中,這個aera<T>()的實作是不對應任何二進制代碼的,隻有當我們寫了aera<int>()或者aera<std::string>()之後,才會生成對應類型的代碼。怎麼生成呢?就是根據寫模闆的标準來生成。這也就解釋了一個問題,那就是,為什麼所有模闆的代碼都需要寫在同一個頭檔案中,不能把一部分(比如函數實作)放到cpp中。模闆其實我們可以了解成一種特殊的宏,預編譯階段會進行替換,寫宏總不能拆開寫吧?

    那麼,同樣地,我們也就解釋了,為什麼模闆函數寫在頭檔案中,不會發生重定義問題。因為根本沒有代碼,而在每個檔案 實際調用的時候,臨時生成的一個類型(有點像匿名的結構體直接生成了一個變量那種效果),是以生成的代碼都是局部的,對跨檔案的部分不會産生影響。

   可是,為什麼當我們寫模闆特例的時候,就出現問題了呢?是因為,模闆特例本身其實已經不是模闆了,類型既然已經确定了,那麼,它應該成為一個普通函數才對。既然是普通函數,那麼理所應當,聲明放到頭檔案中,實作放到源檔案中。當我們引入這個頭檔案時,就得到了通用模闆的完整定義,以及,模闆特例的函數聲明。這樣編譯器就知道怎麼去做了,如果是特例類型,因為已經有了模闆特例的聲明,那麼,就會到其他cpp檔案中找它的定義,最後連結起來;而如果不是特例類型,就會根據通用模闆單獨生成一個新的局部的類型然後使用。

    是以,我們正确的代碼應該長下面這樣:

// aera.hpp
#ifndef aera_h
#define aera_h

#include <cmath>
#include <string>

// 通用模闆
template <typename T>
T aera(const T &radium) {
    return M_PI * radium * radium;
}

// 模闆特例函數的聲明
template <>
std::string aera<std::string>(const std::string &radium);

#endif /* aera_h */
           
// aera.cpp
#include "aera.hpp"

// 模闆特例函數的實作
template <>
std::string aera<std::string>(const std::string &radium) {
    double r = std::stod(radium);
    double squ = M_PI * r * r;
    return std::to_string(squ);
}
           
// test.cpp
#include "test.hpp"
#include "aera.hpp"

void test() {
    int a1 = aera<int>(5);
    std::string a2 = aera<std::string>("6");
}
           
// main.cpp
#include <iostream>
#include "aera.hpp"

int main(int argc, const char * argv[]) {
    
    int a1 = aera<int>(7);
    std::string a2 = aera<std::string>("2");
    
    return 0;
}
           

    現在我們的代碼已經可以正常運作了。

    是以使用模闆要避開的坑就是,通用模闆和模闆特例要分别對待。對待通用模闆要把它當做宏一樣去對待,所有的内容都應該放在同一個頭檔案中,讓需要的部分去包含它,在預編譯階段會替換成對應的代碼。而對待模闆特例,要把它當做普通的函數一樣去對待,聲明部分放到頭檔案中,實作部分放到單獨的源檔案中。(也就是說,通用模闆的聲明和模闆特例的聲明是不可以共用的!)

    當然,還有一種不太常遇見的情況就是,我們想定義一個模闆,但是這個模闆并沒有通用的定義,而是隻存在幾個特例。比如說,我寫的這個aera隻想讓它支援double和std::string類型,其他類型都不支援,并且,支援的這兩種類型實作方法還不同。這種時候,可以使用兩個模闆特例加一個公用的模闆聲明。代碼如下:

// aera.hpp
#ifndef aera_h
#define aera_h

#include <cmath>
#include <string>

// 模闆聲明(不含通用模闆)
template <typename T>
T aera(const T &radium);

#endif /* aera_h */
           
// aera.cpp
#include "aera.hpp"

// 模闆特例函數的實作
template <>
std::string aera<std::string>(const std::string &radium) {
    double r = std::stod(radium);
    double squ = M_PI * r * r;
    return std::to_string(squ);
}

template <>
double aera<double>(const double &radium) {
    return M_PI * radium * radium;
}
           

    在這種情況下,編譯器發現模闆聲明是空的,就會知道,這個模闆類型不含有通用實作,都是用特例來完成的,于是,就會按照普通函數的方式來編譯了。注意!隻有在通用模闆是空的情況下,才可以省略模闆特例的聲明,否則,一旦省略了,就會在預編譯階段按照通用方式來生成代碼,而不是去找實際模闆特例對應的函數了。

    哦, 當然了,如果你在aera.hpp中還是把兩個模闆特例的聲明給寫上了,那也沒什麼問題。反倒是這樣還更有利于代碼可讀性。那麼,在這種通用模闆為空的這種用法中,其實并不是用到了模闆的特性,倒更像是寫了一組重載函數而已,隻不過把類型給顯示寫出來了。不過還是有一點差別,就是重載函數各是各的聲明,而純特例的模闆函數可以共用一個聲明。(這裡有一點要注意,純特例的模闆中,每個模闆的特例聲明可寫可不寫,但是,即便所有的模闆特例聲明都寫出來了,仍然需要保留那個空的通用模闆聲明,否則将會編譯報錯。可以把這個空的通用模闆聲明了解成一個函數簇,有了這個函數簇才能有裡面的函數。)

    坑的地方已經給大家講解完畢了,相信大家使用的時候可以避開這些坑了。不過模闆文法确實比較奇怪,而且還分個通用和特例兩種情況,寫的時候确實容易把人搞暈,接下來逗比老師就給大家展示一些執行個體,模闆函數的例子上面已經有了,下面是一些包括模闆類、模闆成員函數以及它們的特例的寫法,供參考:

    模闆類、成員模闆函數,以及特例的寫法示例

// example.hpp
#ifndef example_hpp
#define example_hpp

// 通用模闆
template <typename T>
class Example {
public:
    // 模闆類中的普通函數
    void test();
    // 模闆類中的模闆函數(嵌套的模闆函數)
    template <typename S>
    S test2();
};

// 通用模闆的函數實作(可以寫到類外,但是不能寫到别的檔案去)
template <typename S> // 類型名可以與之前的不同,但是需要對應
void Example<S>::test() {
}

// 通用模闆中的模闆函數(同樣,不能寫到别的檔案)
template <typename T>
template <typename S>
S Example<T>::test2() {
    return 0;
}

// 模闆特例聲明(相當于普通類)
template <>
class Example<int> {
// 這裡甚至都可以寫和通用模闆毫無關系的實作
public:
    // 一個普通函數(相當于普通成員函數)
    void another_test();
    // 模闆特例中的模闆函數(相當于普通的模闆函數)
    template <typename T>
    void test2();
};

// 模闆函數就必須在目前檔案裡定義
template <typename T>
void Example<int>::test2() {
}

#endif /* example_hpp */
           
// example.cpp
#include "example.hpp"

// 模闆特例函數實作
// 注意這裡,類是個模闆,但是函數不是,是以這裡不需要加template <>
void Example<int>::another_test() {
}
           

    總之就是把持住一點,隻要是模闆,就要像宏一樣寫在同一個檔案中,如果是特例,就要像普通函數(或者類)一樣,聲明寫在頭檔案中,實作寫在源檔案中。

    在以前,我們隻能把一個模闆執行個體進行重命名,比如:

// 重命名一個模闆執行個體
typedef std::vector<int> Vec_int;
           

    但是對于模闆類型,或者不完全實體化的模闆類型就不可以了。不過在C++11以後,就可行了,例如下面的例子:

// 重命名一個模闆類型
template <typename T>
using Vec = std::vector<T>;

// 重命名一個部分執行個體化的模闆類型
template <typename T>
using Map = std::map<int, T>;
           

    好啦!以上就是關于C++模闆的全部内容了。如果同學們還有什麼問題,歡迎留言,歡迎讨論!

【本文為原創文章,歸逗比老師全權擁有,允許轉發,但請在轉發時注明“轉發”字樣并注明原作者。請勿惡意複制或篡改本文的全部或部分内容。】

繼續閱讀