C++模板沉思錄(上)

豌豆花下貓發表於2020-07-30

花下貓語: 在我們讀者群裡,最近出現了比較多關於 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,則TTypeOrValue是一個型別;而如果T是B,則TTypeOrValue是一個數。我們稱這種含有模板引數的,無法立即確定語義的名稱為“依賴型名稱”。所謂“依賴”,意即此名稱的確切語義依賴於模板引數的實際型別。

對於依賴型名稱,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::*”時,僅當T是一個類型別時,這一生成才是正確的,合乎語法的;否則,這個函式簽名將根本無法被生成出來,從而進一步的使得編譯器“被迫”選擇那個“最不喜歡的版本”進行呼叫(而不是認為這個“根本無法被生成出來”的模板是一個錯誤)。所以,通過sizeof對__Test的返回值大小進行判定,就能夠達到我們最終的目的了。下面請看使用示例:

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

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

2.4 本章後記

sizeof,作為一個C語言的“入門級”語法,其“永不求值”的特性往往被我們所忽略。本章中,我們充分利用了sizeof的這種“永不求值”的特性,做了很多“表面工程”,僅僅是為了“給sizeof看”;同理,SFINAE技術似乎也只是在“找編譯器的麻煩,拿編譯器尋開心”。但正是這些“表面工程、找麻煩、尋開心”,讓我們得以實現了一些非常不可思議的功能。

3 型別萃取器——Type Traits

Traits,中文翻譯為“特性”,Type Traits,即為“型別的特性”。這是個十分奇怪的翻譯,故很多書籍對這個詞選擇不譯,也有書籍將其翻譯為“型別萃取器”,十分生動形象。

Type Traits的定義較為模糊,其大致代表了這樣的一系列技術:通過一個型別T,取得另一個基於T進行加工後的型別,或對T基於某一標準進行分類,得到分類結果。

本章中,我們以幾個經典的Type Traits應用,來見識一番此技術的精妙。

3.1 為T“新增星號”

第一個例子較為簡單:我們需要得到T的指標型別,即:得到“T *”。此時,只需要將“T *”通過typedef變為Type Traits類的結果即可。請看以下示例:

template <typename T>
struct AddStar { typedef T *Type; };


template <typename T>
struct AddStar<T *> { typedef T *Type; };


int main()
{
    cout << typeid(AddStar<int>::Type).name() << endl;    // int *
    cout << typeid(AddStar<int *>::Type).name() << endl;  // int *
}

這段程式碼十分簡單,但似乎我們寫了兩遍“一模一樣”的程式碼?認真觀察和思考即可發現:特化版本是為了防止一個已經是指標的型別發生“升級”而存在的。如果T已經是一個指標型別,則Type就是T本身,否則,Type才是“T *”。

3.2 為T“去除星號”

上一節,我們實現了一個能夠為T“新增星號”的Traits,這一節,我們將實現一個功能與之相反的Traits:為T“去除星號”。

“簡單!”也許你會想,並很快給出了以下實現:

template <typename T>
struct RemoveStar { typedef T Type; };


template <typename T>
struct RemoveStar<T *> { typedef T Type; };


int main()
{
    cout << typeid(RemoveStar<int>::Type).name() << endl;    // int
    cout << typeid(RemoveStar<int *>::Type).name() << endl;  // int
}

似乎完成了?不幸的是,這一實現並不完美。請看以下示例:

int main()
{
    cout << typeid(RemoveStar<int **>::Type).name() << endl;  // int *,哦不!
}

可以看到,我們的上述實現只能去除一個星號,當傳入一個多級指標時,並不能得到我們想要的結果。

這該如何是好?我們不禁想到:如果能夠實現一個“while迴圈”,就能去除所有的星號了。雖然模板沒有while迴圈,但我們知道:遞迴正是迴圈的等價形式。請看以下示例:

// 遞迴終點,此時T真的不是指標了
template <typename T>
struct RemoveStar { typedef T Type; };


// 當T是指標時,Type應該是T本身(已經去除了一個星號)繼續RemoveStar的結果
template <typename T>
struct RemoveStar<T *> { typedef typename RemoveStar<T>::Type Type; };

上述實現中,當發現T選擇了特化版本(即T本身是指標時),就會遞迴地對T進行去星號,直到T不再選擇特化版本,從而抵達遞迴終點為止。這樣,就能在面對多級指標時,也能夠得到正確的Type。下面請看使用示例:

int main()
{
    cout << typeid(RemoveStar<int **********>::Type).name() << endl;  // int
}

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

顯然,使用這樣的Traits是具有潛在的較大代價的。例如上例中,為了去除一個十級指標的星號,編譯器竟然需要例項化出11個類!但好在這一切均發生在編譯期,對執行效率不會產生任何影響。

3.3 尋找“最強大型別”

讓我們繼續討論前言中的Plus函式,以引出本節所要討論的話題。目前我們給出的“最好實現”如下:

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


int main()
{
    cout << Plus(1, 2) << endl;  // 3,正確!
}

但是,只要在上述程式碼中新增一個“.”,就立即發生了問題:

int main()
{
    cout << Plus(1, 2.) << endl;  // 二義性錯誤!T應該是int還是double?
}

上例中,由於Plus模板只使用了單一的一個模板引數,故要求兩個實參的型別必須一致,否則,編譯器就不知道T應該是什麼型別,從而引發二義性錯誤。但顯然,任何的兩種“數”之間都應該是可以做加法的,所以不難想到,我們應該使用兩個而不是一個模板引數,分別作為lhs與rhs的型別,但是,我們立即就遇到了新的問題。請看以下示例:

template <typename T1, typename T2>
/* 這裡應該寫什麼?*/ Plus(T1 lhs, T2 rhs)
{
    return lhs + rhs;
}

應該寫T1?還是T2?顯然都不對。我們應該尋求一種方法,其能夠獲取到T1與T2之間的“更強大型別”,並將此“更強大型別”作為返回值。進一步的,我們可以以此為基礎,實現出一個能夠獲取到任意數量的型別之中的“最強大型別”的方法。

應該怎麼做呢?事實上,這個問題的解決方案,確實是難以想到的。請看以下示例:

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

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


public:

    // 3目運算子表示式的型別就是“更強大型別”
    typedef decltype(true ? __A() : __B()) Type;
};


int main()
{
    cout << typeid(StrongerType<int, char>::Type).name() << endl;    // int
    cout << typeid(StrongerType<int, double>::Type).name() << endl;  // double
}

上例中,我們首先定義了兩個“稻草人函式”,用以分別“獲取”型別為A或B的值“給decltype看”。然後,我們使用了decltype探測三目運算子表示式的型別,不難發現,decltype也具有sizeof的“不對錶達式進行求值”的特性。由於三目運算子表示式從理論上可能返回兩個值中的任意一個,故表示式的型別就是我們所尋求的“更強大型別”。隨後的用例也證實了這一點。

有了獲取兩個型別之間的“更強大型別”的Traits以後,我們不難想到:N個型別之中的“最強大型別”,就是N - 1個型別之中的“最強大型別”與第N個型別之間的“更強大型別”。請看以下示例:

// 原型
// 通過typename StrongerType<Types...>::Type獲取Types...中的“最強大型別”
template <typename... Types>
class StrongerType;


// 只有一個型別
template <typename T>
class StrongerType<T>
{
    // 我自己就是“最強大的”
    typedef T Type;
};


// 只有兩個型別
template <typename A, typename B>
class StrongerType<A, B>
{
private:

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


public:

    // 3目運算子表示式的型別就是“更強大型別”
    typedef decltype(true ? __A() : __B()) Type;
};


// 不止兩個型別
template <typename T, typename... Types>
class StrongerType<T, Types...>
{
public:

    // T和typename StrongerType<Types...>::Type之間的“更強大型別”就是“最強大型別”
    typedef typename StrongerType<T, typename StrongerType<Types...>::Type>::Type Type;
};


int main()
{
    cout << typeid(StrongerType<char, int>::Type).name() << endl;          // int
    cout << typeid(StrongerType<int, double>::Type).name() << endl;        // double
    cout << typeid(StrongerType<char, int, double>::Type).name() << endl;  // double
}

通過遞迴,我們使得所有的型別共同參與了“打擂臺”,這裡的“擂臺”,就是我們已經實現了的StrongerType的雙型別版本,而“打擂臺的最後大贏家”,則正是我們所尋求的“最強大型別”。

有了StrongerType這一Traits後,我們就可以實現上文中的雙型別版本的Plus函式了。請看以下示例:

// Plus函式的返回值應該是T1與T2之間的“更強大型別”
template <typename T1, typename T2>
typename StrongerType<T1, T2>::Type Plus(T1 lhs, T2 rhs)
{
    return lhs + rhs;
}


int main()
{
    Plus(1, 2.);  // 完美!
}

至此,我們“終於”實現了一個最完美的Plus函式。

3.4 本章後記

本章所實現的三個小工具,都是STL的type_traits庫的一部分。值得一提的是我們最後實現的獲取“最強大型別”的工具:這一工具所解決的問題,實際上是一個非常經典的問題,其多次出現在多部著作中。由於decltype(以及可變引數模板)是C++11的產物,故很多較老的書籍對此問題給出了“無解”的結論,或只能給出一些較為牽強的解決方案。

4 “壓榨”編譯器——編譯期計算

值也能成為模板引數的一部分,而模板引數是編譯期常量,這二者的結合使得通過模板進行(較複雜的)編譯期計算成為了可能。由於編譯器本就不是“計算器”,故標題中使用了“壓榨”一詞,以表達此技術的“高昂的編譯期代價”以及“較大的侷限性”的特點;同時,合理的利用編譯期計算技術,能夠極大地提高程式的效率,故“壓榨”也有“壓榨效能”之意。

本章中,我們以一小一大兩個示例,來討論編譯期計算這一巧妙技術的應用。

4.1 編譯期計算階乘

編譯期計算階乘是編譯期計算技術的經典案例,許多書籍對此均有討論(往往作為“模板超程式設計”一章的首個案例)。那麼首先,讓我們來看看一個普通的階乘函式的實現:

int Factorial(int N)
{
    return N == 1 ? 1 : N * Factorial(N - 1);
}

這個實現很簡單,這裡就不對其進行詳細討論了。下面,我們來看看如何將這個函式“翻譯”為一個編譯期就進行計算並得到結果的“函式”。請看以下示例:

// 遞迴起點
template <int N>
struct Factorial
{
    static constexpr int Value = N * Factorial<N - 1>::Value;
};


// 遞迴終點
template <>
struct Factorial<1>
{
    static constexpr int Value = 1;
};


int main()
{
    cout << Factorial<4>::Value;  // 編譯期就能獲得結果
}

觀察上述程式碼,不難總結出我們的“翻譯”規則:

  1. 形參N(執行時值)變為了模板引數N(編譯期值)
  2. “N == 1”這樣的“if語句”變為了模板特化
  3. 遞迴變為了創造一個新的模板(Factorial<N - 1>),這也意味著迴圈也可以通過此種方式實現
  4. “return”變為了一個static constexpr變數

上述四點“翻譯”規則幾乎就是編譯期計算的全部技巧了!接下來,就讓我們以一個更復雜的例子來繼續討論這一技術的精彩之處:編譯期分數的實現。

4.2 編譯期分數

分數,由分子和分母組成。有了上一節的鋪墊,我們不難發現:分數正是一個可以使用編譯期計算技術的極佳場合。所以首先,我們需要實現一個編譯期分數類。編譯期分數類的實現非常簡單,我們只需要通過一個“建構函式”將模板引數保留下來,作為靜態資料成員即可。請看以下示例:

template <long long __Numerator, long long __Denominator>
struct Fraction
{
    // “建構函式”
    static constexpr long long Numerator   = __Numerator;
    static constexpr long long Denominator = __Denominator;


    // 將編譯期分數轉為編譯期浮點數
    template <typename T = double>
    static constexpr T Eval() { return static_cast<T>(Numerator) / static_cast<T>(Denominator); }
};


int main()
{
    // 1/2
    typedef Fraction<1, 2> OneTwo;

    // 0.5
    cout << OneTwo::Eval<>();
}

由使用示例可見:編譯期分數的“例項化”只需要一個typedef即可;並且,我們也能通過一個編譯期分數得到一個編譯期浮點數。

讓我們繼續討論下一個問題:如何實現約分和通分?

顯然,約分和通分需要“求得兩個數的最大公約數和最小公倍數”的演算法。所以,我們首先來看看這兩個演算法的“普通”實現:

// 求得兩個數的最大公約數
long long GreatestCommonDivisor(long long lhs, long long rhs)
{
    return rhs == 0 ? lhs : GreatestCommonDivisor(rhs, lhs % rhs);
}


// 求得兩個數的最小公倍數
long long LeastCommonMultiple(long long lhs, long long rhs)
{
    return lhs * rhs / GreatestCommonDivisor(lhs, rhs);
}

根據上一節的“翻譯規則”,我們不難翻譯出以下程式碼:

// 對應於“return rhs == 0 ? ... : GreatestCommonDivisor(rhs, lhs % rhs)”部分
template <long long LHS, long long RHS>
struct __GreatestCommonDivisor
{
    static constexpr long long __Value = __GreatestCommonDivisor<RHS, LHS % RHS>::__Value;
};


// 對應於“return rhs == 0 ? lhs : ...”部分
template <long long LHS>
struct __GreatestCommonDivisor<LHS, 0>
{
    static constexpr long long __Value = LHS;
};


// 對應於“return lhs * rhs / GreatestCommonDivisor(lhs, rhs)”部分
template <long long LHS, long long RHS>
struct __LeastCommonMultiple
{
    static constexpr long long __Value = LHS * RHS /
        __GreatestCommonDivisor<LHS, RHS>::__Value;
};

有了上面的這兩個工具,我們就能夠實現出通分和約分了。首先,我們可以改進一開始的Fraction類,在“建構函式”中加入“自動約分”功能。請看以下示例:

template <long long __Numerator, long long __Denominator>
struct Fraction
{
    // 具有“自動約分”功能的“建構函式”
    static constexpr long long Numerator = __Numerator /
        __GreatestCommonDivisor<__Numerator, __Denominator>::__Value;

    static constexpr long long Denominator = __Denominator /
        __GreatestCommonDivisor<__Numerator, __Denominator>::__Value;
};


int main()
{
    // 2/4 => 1/2
    typedef Fraction<2, 4> OneTwo;
}

可以看出,我們只需在“建構函式”中新增對分子、分母同時除以其最大公約數的運算,就能夠實現“自動約分”了。

接下來,我們來實現分數的四則運算功能。顯然,分數的四則運算的結果還是一個分數,故我們只需要通過using,將“四則運算模板”與“等價的結果分數模板”連線起來即可實現。請看以下示例:

// FractionAdd其實就是一個特殊的編譯期分數模板
template <typename LHS, typename RHS>
using FractionAdd = Fraction<

    // 將通分後的分子相加
    LHS::Numerator * __CommonPoints<LHS::Denominator, RHS::Denominator>::__LValue +
    RHS::Numerator * __CommonPoints<LHS::Denominator, RHS::Denominator>::__RValue,

    // 通分後的分母
    __LeastCommonMultiple<LHS::Denominator, RHS::Denominator>::__Value

    // 自動約分
>;


// FractionMinus其實也是一個特殊的編譯期分數模板
template <typename LHS, typename RHS>
using FractionMinus = Fraction<

    // 將通分後的分子相減
    LHS::Numerator * __CommonPoints<LHS::Denominator, RHS::Denominator>::__LValue -
    RHS::Numerator * __CommonPoints<LHS::Denominator, RHS::Denominator>::__RValue,

    // 通分後的分母
    __LeastCommonMultiple<LHS::Denominator, RHS::Denominator>::__Value

    // 自動約分
>;


// FractionMultiply其實也是一個特殊的編譯期分數模板
template <typename LHS, typename RHS>
using FractionMultiply = Fraction<

    // 分子與分子相乘
    LHS::Numerator * RHS::Numerator,

    // 分母與分母相乘
    LHS::Denominator * RHS::Denominator

    // 自動約分
>;


// FractionDivide其實也是一個特殊的編譯期分數模板
template <typename LHS, typename RHS>
using FractionDivide = Fraction<

    // 分子與分母相乘
    LHS::Numerator * RHS::Denominator,

    // 分母與分子相乘
    LHS::Denominator * RHS::Numerator

    // 自動約分
>;


int main()
{
    // 1/2
    typedef Fraction<1, 2> OneTwo;

    // 2/3
    typedef Fraction<2, 3> TwoThree;

    // 2/3 + 1/2 => 7/6
    typedef FractionAdd<TwoThree, OneTwo> TwoThreeAddOneTwo;

    // 2/3 - 1/2 => 1/6
    typedef FractionMinus<TwoThree, OneTwo> TwoThreeMinusOneTwo;

    // 2/3 * 1/2 => 1/3
    typedef FractionMultiply<TwoThree, OneTwo> TwoThreeMultiplyOneTwo;

    // 2/3 / 1/2 => 4/3
    typedef FractionDivide<TwoThree, OneTwo> TwoThreeDivideOneTwo;
}

由此可見,所謂的四則運算,實際上就是一個針對Fraction的using(模板不能使用typedef,只能使用using)罷了。

最後,我們實現分數的比大小功能。這非常簡單:只需要先對分母通分,再對分子進行比大小即可。而比大小的結果,就是“比大小模板”的一個資料成員。請看以下示例:

// 這六個模板都進行“先通分,再比較”運算,唯一的區別就在於比較操作符的不同

// “operator==”
template <typename LHS, typename RHS>
struct FractionEqual
{
    static constexpr bool Value =
        LHS::Numerator * __CommonPoints<LHS::Denominator, RHS::Denominator>::__LValue ==
        RHS::Numerator * __CommonPoints<LHS::Denominator, RHS::Denominator>::__RValue;
};


// “operator!=”
template <typename LHS, typename RHS>
struct FractionNotEqual
{
    static constexpr bool Value =
        LHS::Numerator * __CommonPoints<LHS::Denominator, RHS::Denominator>::__LValue !=
        RHS::Numerator * __CommonPoints<LHS::Denominator, RHS::Denominator>::__RValue;
};


// “operator<”
template <typename LHS, typename RHS>
struct FractionLess
{
    static constexpr bool Value =
        LHS::Numerator * __CommonPoints<LHS::Denominator, RHS::Denominator>::__LValue <
        RHS::Numerator * __CommonPoints<LHS::Denominator, RHS::Denominator>::__RValue;
};


// “operator<=”
template <typename LHS, typename RHS>
struct FractionLessEqual
{
    static constexpr bool Value =
        LHS::Numerator * __CommonPoints<LHS::Denominator, RHS::Denominator>::__LValue <=
        RHS::Numerator * __CommonPoints<LHS::Denominator, RHS::Denominator>::__RValue;
};


// “operator>”
template <typename LHS, typename RHS>
struct FractionGreater
{
    static constexpr bool Value =
        LHS::Numerator * __CommonPoints<LHS::Denominator, RHS::Denominator>::__LValue >
        RHS::Numerator * __CommonPoints<LHS::Denominator, RHS::Denominator>::__RValue;
};


// “operato>=”
template <typename LHS, typename RHS>
struct FractionGreaterEqual
{
    static constexpr bool Value =
        LHS::Numerator * __CommonPoints<LHS::Denominator, RHS::Denominator>::__LValue >=
        RHS::Numerator * __CommonPoints<LHS::Denominator, RHS::Denominator>::__RValue;
};


int main()
{
    // 1/2
    typedef Fraction<1, 2> OneTwo;

    // 2/3
    typedef Fraction<2, 3> TwoThree;

    // 1/2 == 2/3 => false
    cout << FractionEqual<OneTwo, TwoThree>::Value << endl;

    // 1/2 != 2/3 => true
    cout << FractionNotEqual<OneTwo, TwoThree>::Value << endl;

    // 1/2 < 2/3 => true
    cout << FractionLess<OneTwo, TwoThree>::Value << endl;

    // 1/2 <= 2/3 => true
    cout << FractionLessEqual<OneTwo, TwoThree>::Value << endl;

    // 1/2 > 2/3 => false
    cout << FractionGreater<OneTwo, TwoThree>::Value << endl;

    // 1/2 >= 2/3 => false
    cout << FractionGreaterEqual<OneTwo, TwoThree>::Value << endl;
}

至此,編譯期分數的全部功能就都實現完畢了。不難發現,在編譯期分數的使用過程中,我們全程使用的都是typedef,並沒有真正的構造任何一個分數,一切計算都已經在編譯期完成了。

4.3 本章後記

讀完本章,也許你會恍然大悟:“哦!原來模板也能夠表達形參、if、while、return等語義!”,進而,也許你會有疑問:“那既然這樣,豈不是所有的計算函式都能換成編譯期計算了?”。

很可惜,答案是否定的。

我們通過對編譯期計算這一技術的優缺點進行總結,從而回答這個問題。編譯期計算的目的,是為了完全消除執行時代價,從而在高效能運算場合極大的提高效率;但此技術的缺點也是很多且很明顯的:首先,僅僅為了進行一次編譯期計算,就有可能進行很多次的模板例項化(比如,為了計算10的階乘,就要例項化出10個Factorial類),這是一種極大的潛在的編譯期代價;其次,並不是任何型別的值都能作為模板引數,如浮點數(雖然我們可以使用編譯期分數間接的規避這一限制)、以及任何的類型別值等均不可以,這就使得編譯期計算的應用幾乎被限定在只需要使用整型和布林型別的場合中;最後,“遞迴例項化”在所有的編譯器中都是有最大深度限制的(不過幸運的是,在現代編譯器中,允許的最大深度其實是比較大的)。但即使如此,由於編譯期計算技術使得我們可以進行“搶跑”,在程式還未開始執行時,就計算出各種複雜的結果,從而極大的提升程式的效率,故此技術當然也是瑕不掩瑜的。

注:本文中的部分程式已完整實現於本文作者的Github上,列舉如下:

  1. 編譯期分數:https://github.com/yingyulou/Fraction
  2. print函式:https://github.com/yingyulou/pprint
  3. Tuple:https://github.com/yingyulou/Tuple
  4. 表示式模板:https://github.com/yingyulou/ExprTmpl

本篇完,敬請期待下篇

作者:櫻雨樓,畢業於生物資訊學專業,是一枚 Python/C++/Perl 開發,R 語言黑粉,Github 勾搭 https://github.com/yingyulou

相關文章