1. 程式人生 > 實用技巧 >C++模板沉思錄(上)

C++模板沉思錄(上)

花下貓語: 在我們讀者群裡,最近出現了比較多關於 C++ 的討論,還興起了一股學習 C++ 的風氣。櫻雨樓小姐姐對 C++ 的模板深有研究,系統地梳理成了一篇近 4 萬字的文章!本文是上篇,分享給大家~

櫻雨樓 | 原創作者

豌豆花下貓 | 編輯

0 論抽象——前言

故事要從一個看起來非常簡單的功能開始:

請計算兩個數的和。

如果你對Python很熟悉,你一定會覺得:“哇!這太簡單了!”,然後寫出以下程式碼:

def Plus(lhs, rhs):

    return lhs + rhs

那麼,C語言又如何呢?你需要面對這樣的問題:

/* 這裡寫什麼?*/ Plus(/* 這裡寫什麼?*/ lhs, /* 這裡寫什麼?*/ rhs)
{
    return lhs + rhs;
}

也許你很快就能想到以下解法中的一些或全部:

  1. 硬編碼為某個特定型別:
int Plus(int lhs, int rhs)
{
    return lhs + rhs;
}

顯然,這不是一個好的方案。因為這樣的Plus函式介面強行的要求兩個實參以及返回值的型別都必須是int,或是能夠發生隱式型別轉換到int的型別。此時,如果實參並不是int型別,其結果往往就是錯誤的。請看以下示例:

int main()
{
    printf("%d\n", Plus(1, 2));          // 3,正確
    printf("%d\n", Plus(1.999, 2.999));  // 仍然是3!
}
  1. 針對不同型別,定義多個函式
int Plusi(int lhs, int rhs)
{
    return lhs + rhs;
}


long Plusl(long lhs, long rhs)
{
    return lhs + rhs;
}


double Plusd(double lhs, double rhs)
{
    return lhs + rhs;
}


// ...

這種方案的缺點也很明顯:其使得程式碼寫起來像“組合語言”(movl,movq,...)。我們需要針對不同的型別呼叫不同名稱的函式(是的,C語言也不支援函式過載),這太可怕了。

  1. 使用巨集
#define Plus(lhs, rhs) (lhs + rhs)

這種方案似乎很不錯,甚至“程式碼看上去和Python一樣”。但正如許許多多的書籍都討論過的那樣,巨集,不僅“拋棄”了型別,甚至“拋棄”了程式碼。是的,巨集不是C語言程式碼,其只是交付於前處理器執行的“複製貼上”的標記。一旦預處理完成,巨集已然不再存在。可想而知,在功能變得複雜後,巨集的缺點將會越來越大:程式碼晦澀,無法除錯,“莫名其妙”的報錯...

看到這裡,也許你會覺得:“哇!C語言真爛!居然連這麼簡單的功能都無法實現!”。但請想一想,為什麼會出現這些問題呢?讓我們回到故事的起點:

請計算兩個數的和。

仔細分析這句話:“請計算...的和”,意味著“加法”語義,這在C語言中可以通過“+”實現(也許你會聯想到組合語言中的加法實現);而“兩個”,則意味著形參的數量是2(也許你會聯想到組合語言中的ESS、ESP、EBP等暫存器);那麼,“數”,意味著什麼語義?C語言中,具有“數”這一語義的型別有十幾種:int、double、unsigned,等等,甚至char也具有“數”的語義。那麼,“加法”和“+”,“兩個”和“形參的數量是2”,以及“數”和int、double、unsigned等等之間的關係是什麼?

是抽象。

高階語言的目的,就是對比其更加低階的語言進行抽象,從而使得我們能夠實現更加高階的功能。抽象,是一種人類的高階思維活動,是一種充滿著智慧的思維活動。組合語言抽象了機器語言,而C語言則進一步抽象了組合語言:其將組合語言中的各種加法指令,抽象成了一個簡單的加號;將各種暫存器操作,抽象成了形參和實參...抽象思維是如此的普遍與自然,以至於我們往往甚至忽略了這種思維的存在。

但是,C語言並沒有針對型別進行抽象的能力,C語言不知道,也沒有能力表達“int和double都是數字”這一語義。而這,直接導致了這個“看起來非常簡單的功能”難以完美的實現。

針對型別的抽象是如此重要,以至於程式語言世界出現了與C語言這樣的“靜態型別語言”完全不一樣的“動態型別語言”。正如開頭所示,在Python這樣的動態型別語言中,我們根本就不需要為每個變數提供型別,從而似乎“從根本上解決了問題”。但是,“出來混,遲早要還的”,這種看似完美的動態型別語言,犧牲的卻是極大的執行時效率!我們不禁陷入了沉思:真的沒有既不損失效率,又能對型別進行抽象的方案了嗎?

正當我們一籌莫展,甚至感到些許絕望之時,C++的模板,為我們照亮了前行的道路。

1 新手村——模板基礎

1.1 函式模板與類模板

模板,即C++中用以實現泛型程式設計思想的語法組分。模板是什麼?一言以蔽之:型別也可以是“變數”的東西。這樣的“東西”,在C++中有二:函式模板和類模板。

通過在普通的函式定義和類定義中前置template <...>,即可定義一個模板,讓我們以上文中的Plus函式進行說明。請看以下示例:

此為函式模板:

template <typename T>
T Plus(T lhs, T rhs)
{
    return lhs + rhs;
}


int main()
{
    cout << Plus(1, 2) << endl;          // 3,正確!
    cout << Plus(1.999, 2.999) << endl;  // 4.998,同樣正確!
}

此為類模板:

template <typename T>
struct Plus
{
    T operator()(T lhs, T rhs)
    {
        return lhs + rhs;
    }
};


int main()
{
    cout << Plus<int>()(1, 2) << endl;             // 3,正確!
    cout << Plus<double>()(1.999, 2.999) << endl;  // 4.998,同樣正確!
}

顯然,模板的出現,使得我們輕而易舉的就實現了型別抽象,並且沒有(像動態型別語言那樣)引入任何因為此種抽象帶來的額外代價。

1.2 模板形參、模板實參與預設值

請看以下示例:

template <typename T>
struct Plus
{
    T operator()(T lhs, T rhs)
    {
        return lhs + rhs;
    }
};


int main()
{
    cout << Plus<int>()(1, 2) << endl;
    cout << Plus<double>()(1.999, 2.999) << endl;
}

上例中,typename T中的T,稱為模板形參;而Plus<int>中的int,則稱為模板實參。在這裡,模板實參是一個型別。

事實上,模板的形參與實參既可以是型別,也可以是值,甚至可以是“模板的模板”;並且,模板形參也可以具有預設值(就和函式形參一樣)。請看以下示例:

template <typename T, int N, template <typename U, typename = allocator<U>> class Container = vector>
class MyArray
{
    Container<T> __data[N];
};


int main()
{
    MyArray<int, 3> _;
}

上例中,我們聲明瞭三個模板引數:

  1. typename T:一個普通的型別引數
  2. int N:一個整型引數
  3. template <typename U, typename = allocator<U>> class Container = vector:一個“模板的模板引數”

什麼叫“模板的模板引數”?這裡需要明確的是:模板、型別和值,是三個完全不一樣的語法組分。模板能夠“創造”型別,而型別能夠“創造”值。請參考以下示例以進行辨析:

vector<int> v;

此例中,vector是一個模板,vector<int>是一個型別,而v是一個值。

所以,一個“模板的模板引數”,就是一個需要提供給其一個模板作為實參的引數。對於上文中的宣告,Container是一個“模板的模板引數”,其需要接受一個模板作為實參 。需要怎樣的模板呢?這個模板應具有兩個模板形參,且第二形參具有預設值allocator<U>;同時,Container具有預設值vector,這正是一個符合要求的模板。這樣,Container在類定義中,便可被當作一個模板使用(就像vector那樣)。

1.3 特化與偏特化

模板,代表了一種泛化的語義。顯然,既然有泛化語義,就應當有特化語義。特化,使得我們能為某些特定的型別專門提供一份特殊實現,以達到某些目的。

特化分為全特化與偏特化。所謂全特化,即一個“披著空空如也的template <>的普通函式或類”,我們還是以上文中的Plus函式為例:

// 不管T是什麼型別,都將使用此定義...
template <typename T>
T Plus(T lhs, T rhs)
{
    return lhs + rhs;
}


// ...但是,當T為int時,將使用此定義
template <>  // 空空如也的template <>
int Plus(int lhs, int rhs)
{
    return lhs + rhs;
}


int main()
{
    Plus(1., 2.);  // 使用泛型版本
    Plus(1, 2);    // 使用特化版本
}

那麼,偏特化又是什麼呢?除了全特化以外的特化,都稱為偏特化。這句話雖然簡短,但意味深長,讓我們來仔細分析一下:首先,“除了全特化以外的...”,代表了template關鍵詞之後的“<>”不能為空,否則就是全特化,這顯而易見;其次,“...的特化”,代表了偏特化也必須是一個特化。什麼叫“是一個特化”呢?只要特化版本比泛型版本更特殊,那麼此版本就是一個特化版本。請看以下示例:

// 泛化版本
template <typename T, typename U>
struct _ {};


// 這個版本的特殊之處在於:僅當兩個型別一樣的時候,才會且一定會使用此版本
template <typename T>
struct _<T, T> {};


// 這個版本的特殊之處在於:僅當兩個型別都是指標的時候,才會且一定會使用此版本
template <typename T, typename U>
struct _<T *, U *> {};


// 這個版本“換湯不換藥”,沒有任何特別之處,所以不是一個特化,而是錯誤的重複定義
template <typename A, typename B>
struct _<A, B> {};

由此可見,“更特殊”是一個十分寬泛的語義,這賦予了模板極大的表意能力,我們將在下面的章節中不斷的見到特化所帶來的各種技巧。

1.4 惰性例項化

函式模板不是函式,而是一個可以生成函式的語法組分;同理,類模板也不是類,而是一個可以生成類的語法組分。我們稱通過函式模板生成函式,或通過類模板生成類的過程為模板例項化。

模板例項化具有一個非常重要的特徵:惰性。這種惰性主要體現在類模板上。請看以下示例:

template <typename T>
struct Test
{
    void Plus(const T &val)  { val + val; }
    void Minus(const T &val) { val - val; }
};


int main()
{
    Test<string>().Plus("abc");
    Test<int>().Minus(0);
}

上例中,Minus函式顯然是不適用於string型別的。也就是說,Test類對於string型別而言,並不是“100%完美的”。當遇到這種情況時,C++的做法十分寬鬆:不完美?不要緊,只要不呼叫那些“不完美的函式”就行了。在編譯器層面,編譯器只會例項化真的被使用的函式,並對其進行語法檢查,而根本不會在意那些根本沒有被用到的函式。也就是說,在上例中,編譯器實際上只例項化出了兩個函式:string版本的Plus,以及int版本的Minus。

在這裡,“懶惰即美德”佔了上風。

1.5 依賴型名稱

在C++中,“::”表達“取得”語義。顯然,“::”既可以取得一個值,也可以取得一個型別。這在非模板場景下是沒有任何問題的,並不會引起接下來即將將要討論的“取得的是一個型別還是一個值”的語義混淆,因為編譯器知道“::”左邊的語法組分的定義。但在模板中,如果“::”左邊的語法組分並不是一個確切型別,而是一個模板引數的話,語義將不再是確定的。請看以下示例:

struct A { typedef int TypeOrValue; };
struct B { static constexpr int TypeOrValue = 0; };


template <typename T>
struct C
{
    T::TypeOrValue;  // 這是什麼?
};

上例中,如果T是A,則T::TypeOrValue是一個型別;而如果T是B,則T::TypeOrValue是一個數。我們稱這種含有模板引數的,無法立即確定語義的名稱為“依賴型名稱”。所謂“依賴”,意即此名稱的確切語義依賴於模板引數的實際型別。

對於依賴型名稱,C++規定:預設情況下,編譯器應認為依賴型名稱不是一個型別;如果需要編譯器將依賴型名稱視為一個型別,則需要前置typename關鍵詞。請看以下示例以進行辨析:

T::TypeOrValue * N;           // T::TypeOrValue是一個值,這是一個乘法表達式
typename T::TypeOrValue * N;  // typename T::TypeOrValue是一個型別,聲明瞭一個這樣型別的指標

1.6 可變引數模板

可變引數模板是C++11引入的一個極為重要的語法。這裡對其進行簡要介紹。

可變引數模板表達了“引數數量,以及每個引數的型別都未知且各不相同”這一語義。如果我們希望實現一個簡單的print函式,其能夠傳入任意數量,且型別互不相同的引數,並依次列印這些引數值,此時就需要使用可變引數模板。

可變引數模板的語法由以下組分構成:

  1. typename...:宣告一個可變引數模板形參
  2. sizeof...:獲取引數包內參數的數量
  3. Pattern...:以某一模式展開引數包

接下來,我們就基於可變引數模板,實現這一print函式。請看以下示例:

// 遞迴終點
void print() {}


// 分解出一個val + 剩下的所有val
// 相當於:void print(const T &val, const Types1 &Args1, const Types2 &Args2, const Types3 &Args3, ...)
template <typename T, typename... Types>
void print(const T &val, const Types &... Args)
{
    // 每次列印一個val
    cout << val << endl;

    // 相當於:print(Args1, Args2, Args3, ...);
    // 遞迴地繼續分解...
    print(Args...);
}


int main()
{
    print(1, 2., '3', "4");
}

上例中,我們實現了一對過載的print函式。第一個print函式是一個空函式,其將在“Args...”是空的時候被呼叫,以作為遞迴終點;而第二個print函式接受一個val以及餘下的所有val作為引數,其將列印val,並使用餘下的所有val繼續遞迴呼叫自己。不難發現,第二版本的print函式具有不斷列印並分解Args的能力,直到Args被完全分解。

2 平淡無奇卻暗藏玄機的語法——sizeof與SFINAE

2.1 sizeof

“sizeof?這有什麼可討論的?”也許你會想。只要你學過C語言,那麼對此必不陌生。那麼為什麼我們還需要為sizeof這一“平淡無奇”的語法單獨安排一節來討論呢?這是因為sizeof有兩個對於泛型程式設計而言極為重要的特性:

  1. sizeof的求值結果是編譯期常量(從而可以作為模板實參使用)
  2. 在任何情況下,sizeof都不會引發對其引數的求值或類似行為(如函式呼叫,甚至函式定義!等),因為並不需要

上述第一點很好理解,因為sizeof所考察的是型別,而型別(當然也包含其所佔用的記憶體大小),一定是一個編譯期就知道的量(因為C++作為一門靜態型別語言,任何的型別都絕不會延遲到執行時才知道,這是動態型別語言才具有的特性),故sizeof的結果是一個編譯期常量也就不足為奇了。

上述第二點意味深長。利用此特性,我們可以實現出一些非常特殊的功能。請看下一節。

2.2 稻草人函式

讓我們以一個問題引出這一節的內容:

如何實現:判定型別A是否能夠基於隱式型別轉換轉為B型別?

乍看之下,這是個十分棘手的問題。此時我們應當思考的是:如何引導(請注意“引導”一詞的含義)編譯器,在A到B的隱式型別轉換可行時,走第一條路,否則,走第二條路?

請看以下示例:

template <typename A, typename B>
class IsCastable
{
private:

    // 定義兩個記憶體大小不一樣的型別,作為“布林值”
    typedef char __True;
    typedef struct { char _[2]; } __False;


    // 稻草人函式
    static A __A();


    // 只要A到B的隱式型別轉換可用,過載確定的結果就是此函式...
    static __True __Test(B);


    // ...否則,過載確定的結果才是此函式(“...”引數的過載確定優先順序低於其他一切可行的過載版本)
    static __False __Test(...);


public:

    // 根據過載確定的結果,就能夠判定出隱式型別轉換是否能夠發生
    static constexpr bool Value = sizeof(__Test(__A())) == sizeof(__True);
};

上例比較複雜,我們依次進行討論。

首先,我們聲明瞭兩個大小不同的型別,作為假想的“布林值”。也許你會有疑問,這裡為什麼不使用int或double之類的型別作為False?這是由於C語言並未規定“int、double必須比char大”,故為了“強行滿足標準”(你完全可以認為這是某種“教條主義或形式主義”),這裡採用了“兩個char一定比一個char大一倍”這一簡單道理,定義了False。

然後,我們聲明瞭一個所謂的“稻草人函式”,這個看似毫無意義的函式甚至沒有函式體(因為並不需要,且接下來的兩個函式也沒有函式體,與此函式同理)。這個函式唯一的目的就是“獲得”一個A型別的值“給sizeof看”。由於sizeof的不求值特性,此函式也就不需要(我們也無法提供)函式體了。那麼,為什麼不直接使用形如“T()”這樣的寫法,而需要宣告一個“稻草人函式”呢?我想,不用我說你就已經明白原因了:這是因為並不是所有的T都具有預設建構函式,而如果T沒有預設建構函式,那麼“T()”就是錯誤的。

接下來是最關鍵的部分,我們聲明瞭一對過載函式,這兩個函式的區別有二:

  1. 返回值不同,一個是sizeof的結果為1的值,而另一個是sizeof的結果為2的值
  2. 形參不同,一個是B,一個是“...”

也就是說,如果我們給這一對過載函式傳入一個A型別的值時,由於“...”引數的過載確定優先順序低於其他一切可行的過載版本,只要A到B的隱式型別轉換能夠發生,過載確定的結果就一定是呼叫第一個版本的函式,返回值為__True;否則,只有當A到B的隱式型別轉換真的不可行時,編譯器才會“被迫”選擇那個編譯器“最不喜歡的版本”,從而使得返回值為__False。返回值的不同,就能夠直接體現在sizeof的結果不同上。所以,只需要判定sizeof(__Test(__A()))是多少,就能夠達到我們最終的目的了。下面請看使用示例:

int main()
{
    cout << IsCastable<int, double>::Value << endl;  // true
    cout << IsCastable<int, string>::Value << endl;  // false
}

可以看出,輸出結果完全符合我們的預期。

2.3 SFINAE

SFINAE(Substitution Failure Is Not An Error,替換失敗並非錯誤)是一個高階模板技巧。首先,讓我們來分析這一拗口的詞語:“替換失敗並非錯誤”。

什麼是“替換”?這裡的替換,實際上指的正是模板例項化;也就是說,當模板例項化失敗時,編譯器並不認為這是一個錯誤。這句話看上去似乎莫名其妙,也許你會有疑問:那怎麼樣才認為是一個錯誤?我們又為什麼要討論一個“錯誤的東西”呢?讓我們以一個問題引出這一技巧的意義:

如何判定一個型別是否是一個類型別?

“哇!這個問題似乎比上一個問題更難啊!”也許你會這麼想。不過有了上一個問題的鋪墊,這裡我們依然要思考的是:一個類型別,有什麼獨一無二的東西是非類型別所沒有的?(這樣我們似乎就能讓編譯器在“喜歡和不喜歡”之間做出抉擇)

也許你將恍然大悟:類的成員指標。

請看以下示例:

template <typename T>
class IsClass
{
private:

    // 定義兩個記憶體大小不一樣的型別,作為“布林值”
    typedef char __True;
    typedef struct { char _[2]; } __False;


    // 僅當T是一個類型別時,“int T::*”才是存在的,從而這個泛型函式的例項化才是可行的
    // 否則,就將觸發SFINAE
    template <typename U>
    static __True __Test(int U::*);


    // 僅當觸發SFINAE時,編譯器才會“被迫”選擇這個版本
    template <typename U>
    static __False __Test(...);


public:

    // 根據過載確定的結果,就能夠判定出T是否為類型別
    static constexpr bool Value = sizeof(__Test<T>(0)) == sizeof(__True);
};

同樣,我們首先定義了兩個記憶體大小一定不一樣的型別,作為假想的“布林值”。然後,我們聲明瞭兩個過載模板,其分別以兩個“布林值”作為返回值。這裡的關鍵在於,過載模板的引數,一個是類成員指標,另一個是“...”。顯然,當編譯器拿到一個T,並準備生成一個“T: