本篇講解模板特化
------------------------------------------------------------------------------------------------------------
第12章 特化和重載
------------------------------------------------------------------------------------------------------------
前面幾篇博客講解了C++模板如何使一個泛型定義擴展成一寫相關的類家族或者函數家族。但該機制並非適合所有情況,C++通過更多的特化機制具備了許多用特定方式透明替換泛型定義的特性,也即下面介紹的模板特化和函數模板的重載。
12.1 當泛型代碼不再使用的時候
書中提供了一個例子說明泛型代碼有時使用起來不再方便,類似的例子比較容易找,詳見書籍。
12.2 重載函數模板
上面12.1中描述的例子說明兩個同名的函數模板可以同時存在,還可以對它們進行實例化,使它們具有相同的參數類型。下面提供另一個例子:
// details/funcoverload.hpp template <typename T> int f(T) { return 1; } template <typename T> int f(T*) { return 2; }
如果我們用int*來替換第1個模板的T,用int來替換第2個模板的T,那么將會獲得兩個具有相同參數類型(和返回類型)的同名函數。也就是說,不僅是同名模板可以同時存在,同名各自的實例化體也可以同時存在,即使這些實例化體具有相同的參數類型和返回類型。可以如下調用:
// details/funcoverload.cpp #include <iostream> #include "funcoverload.hpp" int main() { std::cout << f<int*>((int*)0) << std::endl; std::cout << f<int>((int*)0) << std::endl; } 程序輸入如下: 1 2
為了說明這一點,讓我們相信地分析調用f<int*>((int*)0)。語法f<int*>說明我們希望用int*來替換模板f的第1個模板參數,而且這種替換並不依賴於模板實參演繹。在這個例子中,有兩個f模板,因此所生成的重載集包含了兩個函數:f<int*>(int*)(生成自第1個模板)和f<int*>(int**)(生成自第2個模板)。然而,調用實參(int*)0的類型是int*,因此它將會和第1個模板生成的函數更好地匹配,最后也就調用這個函數。 類似的分析也可以用於第2個調用。
12.2.1 簽名
只要具有不同的簽名,兩個函數就可以在同一個程序中同時存在。我們對函數簽名的定義如下:
1. 非受限函數的名稱(或者產生自函數模板的這類名稱)。
2. 函數名稱所屬的類作用域或者名字空間作用域:如果函數名稱是具有內部鏈接的,還包括該名稱所在的翻譯單元。
3. 函數的const、volatile或者const volatile限定符(前提是它是一個具有這類限定符的成員函數)。
4. 函數參數的類型(如果這個函數是產生自函數模板的,那么指的是模板參數被替換之前的類型)。
5. 如果這個函數是產生自函數模板,那么包括它的返回類型。
6. 如果這個函數是產生自函數模板,那么包括模板參數和模板實參。
這就意味着:從原則上講,下面的模板和它們的實例化可以在同個程序中同時存在:
template<typename T1, typename T2> void f1(T1, T2); template<typename T1, typename T2> void f1(T2, T1); template<typename T> long f2(T); template<typename T> char f2(T);
然而,如果上面這些模板是在同一個作用域中進行聲明的話,我們可能不能使用某些模板,因為實例化過程可能會導致重載二義性。如:
#include <iostream> template<typename T1, typename T2> void f1(T1, T2) { std::cout << "f1(T1, T2) \n"; } template<typename T1, typename T2> void f1(T2, T1) { std::cout << "f1(T2, T1) \n"; } // 到這里為止一切都是正確的 int main() { f1<char, char>('a', 'b'); // 錯誤:二義性 }
在上面的代碼中,雖然函數f1<T1 = char, T2 = char>(T1, T2)可以和函數f1<T1 = char, T2 = char>(T2, T1)同時存在,但是重新解析規則將不知道應該選擇哪一個函數。因此,只有在這兩個模板出現於不同的翻譯單元時,它們的兩個實例化體才可以在同個程序中同時存在(而且,鏈接器也不應該抱怨說存在重復定義,因為這兩個實例化體的簽名是不同的)。
12.2.2 重載的函數模板的局部排序
template <typename T> int f(T) { return 1; } template <typename T> int f(T*) { return 2; } int main() { std::cout << f(0) << std::endl; std::cout << f((int*)0) << std::endl; }
f(0)這個調用中,重載解析並沒有發揮作用,直接匹配第一個模板;
第2個調用(f((int*)0))中:對於這兩個模板,實參演繹都可以獲得成功,於是獲得兩個函數,即f<int*>(int*)和f<int>(int*)。如果根據原來的重載解析觀點,這兩個函數和實參類型為iint*的調用的匹配程度是一樣的,這也就意味着該調用是二義性的。然而,在這中情況下,還應該考慮重載解析的額外規則:選擇“產生自更加特殊的模板的函數”。因此,第2個模板被認為是更加特殊的模板,從而產生下面的輸出結果:
1
2
12.2.3 正式的排序原則
下面我們將給出一個精確的過程來判斷:在參與重載集的所有函數模板中,某個函數模板是否比另一個函數模板更加特殊。然而,我們應該知道這只是不完整的排序原則:就是說,兩個模板也可能會被認為具有相同的特殊程度。如果重載解析必須在這兩個特殊程度相同的模板中進行選擇,那么將不能做出任何決定,也就是說程序包含了一個二義性錯誤。
假設我們要比較兩個同名的函數模板ft1和ft2,對於給定的函數調用,它們看起來都是可行的。在我們下面的討論中,對於沒有被使用的缺省函數實參和省略號參數,我們將不考慮。接下來,通過如下替換模板參數,我們將為這兩個模板虛構兩份不同的實參類型(如果是轉型函數模板,那么還包括返回類型)列表,其中第1份列表針對第1個模板,第2份列表針對第2個模板。“虛構”的實參列表將這樣地替換每個模板參數:
1. 用唯一的“虛構”類型替換每個模板類型參數;
2. 用唯一的“虛構”類模板替換每個模板的模板參數;
3. 用唯一的適當類型的“虛構”值替換每個非類型參數。
“更加特殊”的判斷:
如果第2個模板針對第1份列表可以進行成功的實參演繹(能夠進行精確的匹配),而第1個模板針對第2份列表的實參演繹以失敗告終,那么我們就稱第1個模板要比第2個模板更加特殊。反之,如果第1個模板針對第2份列表可以進行成功的實參演繹(能夠進行精確的匹配),而第2個模板針對第1份列表的實參演繹失敗,那么我們就稱第2個模板要比第1個模板更加特殊。否則的話(或者是兩個都不能成功演繹,或者是兩個都能成功演繹),我們就稱這兩個模板之間不存在特殊的排序關系。
例子參見書籍。
12.2.4 模板和非模板
函數模板也可以和非模板函數同時重載。當其他的所有條件都是一樣的時候,實際的函數調用將會優先選擇非模板函數。
12.3 顯式特化(全局特化)
備注:
1. 類模板和函數模板都可以被全局特化;
2. 類模板能局部特化,不能被重載;
3. 函數模板能被重載,不能被局部特化。
具有對函數模板進行重載的這種能力,再加上可以利用局部排序規則選擇最佳匹配的函數模板,我們就能夠給泛型實現添加更加特殊的模板,從而可以透明地獲得具有更高效率的代碼。然而,類模板不能被重載;我們可以選擇另一種替換的機制來實現這種透明自定義類模板的能力,那就是顯式特化。C++標准的“顯式特化”概念指的是一種語言特性,我們通常也稱之為全局特化。它為模板提供了一種使模板參數可以被全局替換的實現,而沒有剩下模板參數。事實上,類模板和函數模板都是可以被全局特化的,而且類模板的成員(包括成員函數、嵌入類、靜態成員變量等,它們的定義可以位於類定義的外部)也可以被全局特化。 在一下節,我們將討論局部特化。局部特化和全局特化有些類似,但局部特化並沒有替換所有的模板參數,就是說某些參數化實現仍然保留在模板的(另一種)實現中。另外,在我們的源代碼中,全局特化和局部特化都是顯式的,這也是我們在討論中避免使用顯式特化這個概念的原因。實際上,全局特化和局部特化都沒有引入一個全新的模板或者模板實例。它們只是對原來的泛型(或者非特化)模板中已經隱式聲明的實例提供另一種定義。在概念上,這是一個相對比較重要的現象,也是特化區別於重載模板的關鍵之處。
12.3.1 全局的類模板特化 如下:
template<typename T> class S { public: void info() { std::cout << "generic (S<T>::info() \n)"; } }; template<> class S<void> { public: void msg() { std::cout << "fully specialized (S<void>::msg()) \n"; } };
(1) 我們看到,全局特化的實現不需要與(原來的)泛型實現有任何關聯,這就允許我們可以包含不同名稱的成員函數(info相對msg)。實際上,全局特化只和類模板的名稱有關聯。
(2)另外,指定的模板實參列表必須和相應的模板參數列表一一對應。例如,我們不能用一個非類型值來替換一個模板類型參數。然而,如果模板參數具有缺省模板實參,那么用來替換的模板實參就是可選的(即不是必須的)。
template<typename T> class Types { public: typedef int I; }; template<typename T, typename U = typename Types<T>::I> class S; // (1) template<> class S<void> // (2) { public: void f(); }; template<> class S<char, char>; // (3) template<> class S<char, 0>; // 錯誤:不能用0來替換U int main() { S<int>* pi; // 正確:使用(1),這里不需要定義 S<int> e1; // 錯誤:使用(1),需要定義,但找不到定義 S<void>* pv; // 正確:使用(2) S<void, int> sv; // 正確:使用(2),這里定義是存在的,因為模板特化的第2個參數的缺省類型為int類型 S<void, char> e2; // 錯誤:使用(1),需要定義,但找不到定義 S<char, char> e3; // 錯誤:使用(3),需要定義,但找不到定義 } template<> class S<char, char> // (3)處的定義 { };
如例子中所示,(模板)全局特化的聲明並不一定是定義。另外,當一個全局特化聲明之后,針對該(特化的)模板實參列表的調用,將不再使用模板的泛型定義,而是使用這個全局特化的定義。因此,如果在調用處需要該特化的定義,而在這之前並沒有提供這個定義,那么程序將會出現錯誤。對於類模板特化而言,“前置聲明”類型有時候是很有用的,因為這樣就可以構造相互依賴的類型。另外,以這種方式獲得的全局特化聲明(應該記住它並不是模板聲明)和普通的類聲明是類似的,唯一的區別在於語法以及該特化的聲明必須匹配前面的模板聲明。對於特化聲明而言,因為它並不是模板聲明,所以應該使用(位於類外部)的普通成員定義語法,來定義全局類模板特化的成員(也就是說,不能指定template<>前綴):
template<typename T> class S; template<> class S<char**> { public: void print() const; }; // 下面的定義不能使用template<>前綴 void S<char**>::print() const { std::cout << "pointer to pointer to char \n"; }
我們知道,可以用全局模板特化來代替對應泛型模板的某個實例化體。然而,全局模板特化和由模板生成的實例化版本是不能共存於同一個程序中的,否則會導致編譯期錯誤。遺憾的是,如果在不同的翻譯單元,將很難捕捉到這種錯誤。如下:
// 翻譯單元1 template <typename T> class Danger { public: enum { max = 10 }; }; char buffer[Danger<void>::max]; // 使用了泛型值 extern void clear(char const*); int main() { clear(buffer); } // 翻譯單元2 template<typename T> class Danger; template<> class Danger<void> { public: enum { max = 100 }; }; void clear(char const* buf) { // 可能與原先定義的數組大小不匹配 for(intk = 0; k < Danger<void>::max; ++k) { buf[k] = '\0'; } }
顯然,這個例子是我們經過裁減的。但它也告訴我們:在使用特化的時候,我們需要特別小心,並且確認特化的聲明對泛型模板的所有用戶都是可見的。在實際的應用中,這意味着:在模板聲明所在的頭文件中,特化的聲明通常都應該位於模板聲明的后面。然而,泛型實現也可能來自於外部資源(諸如不能被修改的頭文件);盡管實際很少采用這種方式,但我們可以創建一個包含泛型模板的頭文件,並讓特化聲明位於泛型模板之后,來避免這種“難以發現”的錯誤;實際上,這種做法有時候很有必要的。另外,如果不具有特殊目的的話,我們通常都避免讓模板特化來自於外部資源。
12.3.2 全局的函數模板特化
就語法及其后所蘊涵的原則而言,(顯式的)全局函數模板特化和類模板特化大體上是一致的,唯一的區別在於:函數模板特化引入了重載和實參演繹這兩個概念。借助實參演繹(用實參類型來演繹聲明中給出的參數類型)來確定模板的特殊化版本,那么全局特化就可以不聲明顯式的模板實參。
注意:全局函數模板特化不能包含缺省的實參值。然而,對於基本(即要被特化的)模板所指定的任何缺省實參,顯式特化版本都可以應用這些缺省實參值。如:
template<typename T> int f(T, T x = 42) { return x; } template<> int f(int, int = 35) // 錯誤,不能包含缺省實參值,但如果沒有指定第2個實參,則會使用基本模板的缺省參數值 { return 0; } template<typename T> int g(T, T x = 42) { return x; } template<> int g(int, int y) { return y/2; } int main() { std::cout << g(0) << std::endl; // 正確,輸出21 }
全局特化聲明和普通聲明在許多方面都是很相似的(或者進一步說,可以把它看成一個普通的再次聲明)。尤其是,全局特化聲明的聲明對象並不是一個模板,因此對於非內聯的全局函數模板特化而言,在同個程序中它的定義只能出現一次,然而,我們仍然必須確保:全局函數模板特化的聲明必須緊跟在模板定義的后面,以避免試圖使用一個由模板直接生產的函數。因此,在前面的例子中,通常應該把模板g的聲明放在兩個文件中。接口文件如下所示:
#ifndef TEMPLATE_G_HPP #define TEMPLATE_G_HPP // 模板定義應該放在頭文件中: template<typename T> int g(T, T x = 42) { return x; } // 特化聲明禁止模板進行實例化;但為了避免出現重復定義,就不能把定義放在這里 template<> int g(int, int y); #endif // TEMPLATE_G_HPP // ------------------------------------------ #include "template_g.hpp" template<> int g(int, int y) { return y/2; }
另一種解決方案是把這個特化聲明為內聯函數:在這種情況下,該函數的定義就可以(也應該)放在頭文件中。
12.3.3 全局成員特化
除了成員模板之外,類模板的成員函數和普通的靜態成員變量也可以被全局特化;實現特化的語法會要求給每個外圍類模板加上template<>前綴。如果要對一個成員模板進行特化,也必須加上另一個template<>前綴,來說明該聲明表示的是一個特化。為了說明這些含義,讓我們假設具有下面的聲明:
template<typename T> class Outer // (1) { public: template<typename U> class Inner // (2) { private: static int count; // (3) }; static int code; // (4) void print() const // (5) { std::cout << "generic"; } }; template<typename T> int Outer<T>::code = 6; // (6) template<typename T> template<typename U> int Outer<T>::Inner<U>::count = 7; // (7) template<> class Outer<bool> // (8) { public: template<typename U> class Inner // (9) { private: static int count; // (10) }; void print() const {} // (11) };
在(1)處的泛型模板Outer中,(4)處的code和(5)處print(),這兩個普通成員都具有一個外圍類模板。因此,需要使用一個template<>前綴說明:后面將用一個模板實參集來對它進行全局特化:
template<> int Outer<void>::code = 12; template<> void Outer<void>::print() const { std::cout << "Outer<void>"; }
這些定義將會用於替代類Outer<void>在(4)處和(5)處的泛型定義;但是,類Outer<void>的其他成員仍然默認地產生自(1)處的模板。另外,在提供了上面的聲明之后,就不能再次提供Outer<void>的顯式特化。
類似於全局函數模板特化,我們需要一種可以在不指定定義的前提下(為了避免多處定義),可以聲明類模板普通成員特化的。盡管對於普通類的成員函數和靜態成員變量而言,非定義的類外聲明在C++中是不允許的;但如果是針對類模板的特化成員,該聲明則是合法的。也就是說,前面的定義可以具有如下聲明:
template<> int Outer<void>::code; template<> void Outer<void>::print() const;
細心的讀者可能會發現,全局特化Outer<void>::code的非定義聲明的語法,看起來等同於下面的語法:提供一個能夠用缺省構造函數進行初始化的定義。事實上也正是如此,但這些聲明仍然被解釋為非定義聲明。
因此,如果靜態成員變量的類型是一個只能使用缺省構造函數進行初始化的類型,那么就不能為該靜態成員變量的全局特化提供一個定義:
class DefaultInitOnly { public: DefaultInitOnly(){ } private: DefaultInitOnly(DefaultInitOnly const&); // 不存在拷貝操作,也即“只能使用缺省構造函數進行初始化的類型” }; template<typename T> class Statics { private: static T sm; }; // 下面只是一個聲明 // 不存在可以用來提供一個定義的語法 template<> DefaultInitOnly Statics<DefaultInitOnly>::sm; // 無法使用"sm = xxx"的定義語句,因為拷貝構造函數被聲明為私有的
對於成員模板Outer<T>::Inner,也可以用一個特定的模板實參對它進行特化,而且對於該特化所在的外圍Outer<T>而言,這個特化操作並不會影響Outer<T>相應實例化體的其他成員。另外,由於存在一個外圍模板(也就是Outer<T>),所以我們需要添加一個template<>前綴。最后獲得的代碼大致如下:
template<> template<typename X> class Outer<wchar_t>::Inner { public: static long count; // 成員類型發生了改變 }; template<> template<typename X> long Outer<wchar_t>::Inner<X>::count; // 模板Outer<T>::Inner也可以被全局特化,但只能針對Outer<T>的某個給定實例。而且,我們需要添加兩個template<>前綴:
因為外圍類需要一個template<>前綴,我們所要全局特化的內圍模板也需要一個template<>前綴: template<> template<> class Outer<char>::Inner<wchar_t> { public: enum { count = 1 }; }; // 下面的C++程序是不合法的: // template<> 不能位於模板實參列表的后面 template<typename X> template<> class Outer<X>::Inner<void>; // 錯誤
我們可以將上面這個特化於Outer<bool>的成員模板的特化比較一下。由於Outer<bool>已經在前面全局特化了,所有它的成員模板也就不存在外圍模板,因此我們就只需要一個tempate<>前綴:
template<> class Outer<bool>::Inner<wchar_t> { public: enum { count = 2 }; };
12.4 局部的類模板特化
全局模板特化通常都是很有用的,但有時候我們更希望把類模板特化成一個“針對模板實參”的類家族,而不是針對“一個具體實參列表”的全局特化。如下面的例子:
template<typename T> class List // (1) { public: ... void append(T const&); inline size_t length() const; ... };
對於某個使用這個模板的大項目,它可能會基於多種類型來實例化該模板成員。於是,對於那些沒有進行內聯擴展的成員函數(譬如List<T>::append()),這就可能會明顯增加目標代碼的大小。然而,如果我們從一個更低層次的實現來看,List<int*>::append()的代碼和List<void*>::append()的代碼是完全相同的。也就是說,我們希望可以讓所有的指針List共享同一個實現。盡管我們不能直接用C++來表達這種實現,但我們可以指定所有的指針List都實例化自一個不同的模板定義,從而近似地獲得這種實現:
template<typename T> class List<T*> // (2) { private: List<void*> impl; ... public: ... void append(T* P) { impl.append(p); } size_t length() const { return impl.length(); } ... };
在這種情況下,我們把原來的模板(即(1)處的模板)稱為基本模板,而后一個定義則被稱為局部特化(因為該模板定義所使用的模板實參只是被局部指定)。表示一個局部特化的語法包括:一個模板參數列表聲明(template<...>)和在類模板名稱后面顯式指定的模板實參列表(在我們的例子中是<T*>)。
我們前面的代碼還存在一個問題,因為List<void*>會遞歸地包含一個相同類型的List<void*>成員。為了打破這種無限遞歸,我們可以在這個局部特化前面先提供一個全局特化:
template<> class List<void*> // (3) 解決模板無限遞歸問題 { ... void append(void* p); inline size_t length() const; ... };
這樣,一切才是正確的。因為當進行匹配的時候,全局特化會由於局部特化。於是,指針List的所有成員函數都被委托給List<void*>的實現(通過容易內聯的函數)。針對C++模板備受指責的代碼膨脹的缺點,這也是克服該缺點的有效方法之一。
對於局部特化聲明的參數列表和實參列表,存在一些約束。下面就是一些重要的約束:
1. 局部特化的實參必須和基本模板的相應參數在種類上(可以是類型、非類型或者模板)是匹配的。
2. 全局/局部特化的參數列表不能具有缺省實參;但局部特化仍然可以使用基本類模板的缺省實參;
3. 局部特化的非類型實參只能是非類型值,或者是普通的非類型模板參數;而不能是更復雜的依賴型表達式(諸如2*N,其中N是模板參數)。
4. 局部特化的模板實參列表不能和基本模板的參數列表完全等同(不考慮重新命名)。
下面的例子詳細說明了這些約束:
template<typename T, int I = 3> class S; // 基本模板 template<typename T> class S<int, T>; // 錯誤:參數類型不匹配 template<typename T = int> class S<T, 10>; // 錯誤:不能具有缺省實參 template<int I> class S<int, I*2>; // 錯誤:不能有非類型的表達式 template<typename U, int k> class S<U, K>; // 錯誤:局部特化和基本模板之間沒有本質的區別
每個局部特化(和每個全局特化一樣)都會和基本模板發生關聯。但使用一個模板的時候,編譯器肯定會對基本模板進行查找,但接下來會匹配調用實參和相關特化的實參,然后確定應該選擇哪一個模板實現。如果能夠找到多個匹配的特化,那么將會選擇“最特殊”的特化(和重載函數模板所定義的原則一樣);如果有未能找到“最特殊”的一個特化,即存在幾個特殊程度一樣的特化,那么程序將會包含一個二義性錯誤。
最后,我們應該指出:類模板局部特化的參數個數是可以和基本模板不一樣的;既可以比基本模板多,也可以比基本模板少。
下面提供針對(特定的)成員指針類型的模板特化:
template<typename C> class List<void* C::*> // (4) { public: // 針對指向void*的成員指針的特化 // 除了void*類型之外,每個指向成員指針的指針類型都會使用這個特化 typedef void* C::*ElementType; ... void append(ElementType pm); inline size_t length() const; ... }; template<typename T, typename C> class List<T* C::*> // (5) { private: List<void* C::*> impl; ... public: // 針對任何指向成員指針的指針類型的局部特化 // 除了指向void*的成員指針類型,它在前面已經處理了 // 我們看到這個局部特化具有兩個模板參數 // 然而基本模板卻只有一個參數 typedef T* C::*ElementType; ... void append(ElementType pm) { impl.append((void* C::*)pm); } inline size_t length() const { return impl.length(); } ... };
除了模板參數數量不同之外,我們看到在(4)處定義的公共實現本身也是一個局部特化(對於簡單的指針例子,這里應該是一個全局特化),而所有其他的局部特化((5)處的聲明)都是把實現委托給這個公共實現。顯然,在(4)處的公共實現要比(5)處的實現更加特殊化,因此也就不會出現二義性問題。