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++模板的全部內容了。如果同學們還有什麼問題,歡迎留言,歡迎討論!

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章