理解模板型別推斷(template type deduction)
理解模板型別推斷(template type deduction)
我們往往不能理解一個複雜的系統是如何運作的,但是卻知道這個系統能夠做什麼。C++的模板型別推斷便是如此,把引數傳遞到模板函式往往能讓程式設計師得到滿意的結果,但是卻不能夠比較清晰的描述其中的推斷過程。 模板型別推斷是現代C++中被廣泛使用的關鍵字auto 的基礎 。當在auto上下文中使用模板型別推斷的時候,它不會像應用在模板中那麼直觀,所以理解模板型別推斷是如何在auto中運作的就很重要了。
下面將詳細討論。看下面的虛擬碼:
template<typename T> void f(ParamType param);
通過下面的程式碼呼叫:
f(expr); //callf with some expression
在編譯過程中編譯器會使用expr 推斷兩種型別:一個T的型別,一個是ParamType 。而這兩種型別往往是不一樣的,因為ParamType 通常會包含修飾符,比如const 或者引用。如果一個模板被宣告為下面這個樣子:
template<typename T> void f(const T& param);//ParamType is const T&
通過如下程式碼呼叫:
int x = 0; f(x); //call f with an int
T會被推斷成int,但是ParamType 會被推斷成const int&。
我們很自然的會認為對T的推斷和傳遞到函式的引數的推斷是相同的,上面的例子就是這樣的,引數x的型別為int,T也被推斷成了int型別。 但是往往情況不是這樣子的。對T的型別推斷不僅僅依賴引數expr的型別,也依賴ParamType 的形式。
有三種情況:
- ParamType 是指標或者引用型別,但不是universal reference(這個型別在以後的篇章中會講到,現在只需要明白,這種型別不同於左值引用和右值引用即可。)
- ParamType 是universal reference。
- ParamType 即非指標也非引用。
下面將分別進行舉例,每個例子都從下面的模板宣告和函式呼叫虛擬碼演變而來:
template<typename T> void f(ParamType param); f(expr);
ParamType 是指標或者引用型別
這種情況下的型別推斷會是下面這個樣子:
- 如果expr的型別是引用,忽略 引用部分。
- 然後將expr的型別同ParamType 進行模式匹配來最終決定T。
看下面的例子:
template <typename T> void f(T ¶m);
宣告如下變數:
int x = 27; //x 為int const int cx = x;//cx為const int const int& rx = x;//rx為指向const int的引用
對param和T的推斷如下:
f(x); //T被推斷為int,param的型別被推斷為 int & f(cx);//T被推斷為const int,param的型別被推斷為const int & f(rx);//T被推斷為const int(這裡的引用會忽略),param的型別被推斷為const int &
第二個和第三個函式呼叫中,cx和rx傳遞的是const值,因此T被推斷成const int,產生的引數型別就是const int &,當你向一個引用引數傳遞一個const物件的時候,你不會希望這個值被修改,因此引數應該會被推斷成為指向const的引用。模板型別推斷也是這麼做的,在推斷型別T的時候const會變為型別的一部分。
第三個例子中,rx的型別是引用型別,T卻被推斷為非引用型別。因為型別推斷過程中rx的引用型別會被忽略。
上面的例子只是說明了左值引用引數,對於右值引用引數同樣試用。
如果我們將函式f的引數型別改成cont T&,實參cx和rx的const屬性肯定不會變,但是現在我們將引數宣告成為指向const的引用了,因此沒有必要將const推斷成為T的一部分:
template <typename T> void f(const T ¶m);
宣告的變數不變:
int x = 27; //不變 const int cx = x;//不變 const int& rx = x;//不變
對param和T的推斷如下:
f(x); //T被推斷為int,param的型別被推斷為const int & f(cx);//T被推斷為int,param的型別被推斷為const int & f(rx);//T被推斷為int(引用同樣被忽略) ,param的型別被推斷為const int &
如果param是指標或者指向const的指標,本質上同引用的推斷過程是相同的。
指標和引用作為模板引數在推斷過程中的結果是顯而易見的,下面的例子就隱晦一些了。
ParamType 是一個Universal Reference
這種型別的引數在宣告時形式上同右值引用類似(如果一個函式模板的型別引數為T,將其宣告為Universal Reference寫成TT&&),但是傳遞進來的實參如果為左值,結果同右值引用就不太一樣了(以後會講到)。
Universal Reference的模板型別推斷將會是下面這個樣子:
- 如果expr是一個左值,T和ParamType都會被推斷成左值引用。有點不可思議,首先,這是模板型別推斷中唯一將T推斷為引用的情況;其次,雖然ParamType的宣告使用右值引用語法,但它最終卻被推斷成左值引用。
- 如果expr是一個右值,參考上一節(ParamType 是指標或者引用型別)。
舉個例子:
template <typename T> void f(T &¶m); int x = 27; //不變 const int cx = x;//不變 const int& rx = x;//不變
對param和T的推斷如下:
f(x); //x為左值,因此T為int&,ParamType為 int& f(cx);//cx為左值,因此T為const int&,ParamType也為const int& f(rx);//rx為左值,因此T為const int&,ParamType也為const int& f(27);//27為右值,T為int ,ParamType為int&&
這裡的關鍵點是,模板引數為Universal Reference型別的時候,對於左值和右值的推斷情況是不一樣的。這種情況在模板引數為非Universal Reference型別的時候是不會發生的。
ParamType 既不是指標也不是引用
這種情況也就是所謂的按值傳遞:
template <typename T> void f(T param);//按值傳遞
傳遞到函式f中的實參值會是原來物件的一份拷貝。這決定了如何從expr中推斷T:
- 同情況一類似,如果expr的型別是引用,忽略引用部分。
- 如果expr是const的,同樣將其忽略。如果是volatile的,同樣忽略。
看例子:
int x = 27; //不變 const int cx = x;//不變 const int& rx = x;//不變
對param和T的推斷如下:
f(x); // T為int ParamType為 int f(cx);//同上 f(rx);//同上
可以看到即使cx和rx為const,param也不是const的。因為param只是cx和rx的一份拷貝,所以不論param的型別如何都不會對原值造成影響。不能修改expr並不意味著不能修改expr的拷貝。
注意只有param是by-value的時候,const或者volatile才會被忽略。我們在前面的例子中說明了,如果引數型別為指向const的引用或者指標,型別推斷過程中expr的const屬性會被保留。但是看一下下面的情況,如果expr為指向const物件的const指標,而param的型別為by-value,結果會是什麼樣子的呢:
template <typename T> void f(T param);//按值傳遞 const char * const ptr = "Fun with pointers"; f(ptr);
我們先回憶一下const指標,星號左邊的const(離指標最近)表示指標是const的,不能修改指標的指向,星號右邊的const表示指標指向的字串是const的,不能修改字串的內容。當ptr傳遞給f的時候,指標本身是按值傳遞的 。因為在by-value引數的型別推斷中const屬性會被忽略,因此指標的const也就是星號右邊的const會被忽略,最後推斷出來的引數型別為const char * ptr,也就是可以修改指標指向,不能修改指標所指內容。
陣列引數
上面的三種情況涵蓋了模板型別推斷的大部分情況,但是有另外一種情況不得不說,就是陣列。雖然陣列和指標有時候看上去是可以互換的,造成這種幻覺的一個主要原因是在許多情況下,陣列可以退化為指向第一個陣列元素的指標,正是這種退化下面的程式碼才能編譯通過:
const char name[]="HarlanC";//name的型別為const char[8] const char*ptrToName = name;//陣列退化成指標
雖然指標和陣列的型別不同,但由於陣列退化為指標的規則,上邊的程式碼能夠編譯通過。
如果將陣列傳遞給帶有by-value引數的模板,會發生什麼呢?
template <typename T> void f(T param);//按值傳遞 f(name);
將陣列作為函式引數的語法是合法的。
void myFunc(int param[]);
但是這裡的陣列引數會被當做指標引數來處理,也就是說下面的宣告和上面的宣告是等價的:
void myFunc(int* param); // same function as above
因為陣列引數會被當做指標引數來處理,所以將一個數組傳遞給按值傳遞的模板函式會被推斷為一個指標型別。當呼叫模板函式f的時候,型別引數T會被推斷成const char*:
f(name); // name is array, but T deduced as const char*
雖然函式不能宣告一個真正的陣列引數(即使這麼宣告也會被當做指標來處理),但是能夠將引數宣告為指向陣列的引用。我們將模板函式做如下修改:
template <typename T> void f(T& param);//按引用傳遞
傳遞一個數組實參:
f(name);
這時候會將T推斷成一個真正的陣列型別。這個型別同時包含了陣列的大小,在上面的例子中,T會被推斷成const char [8],而f的引數型別為const char (&)[8]。
使用這種宣告有一個妙用。我們可以建立一個模板來推斷出陣列中包含的元素數量 :
//在編譯期返回陣列大小 , //注意下面的函式引數是沒有名字的 //因為我們只關心陣列的元素數量 template<typename T, std::size_t N> constexpr std::size_t arraySize(T (&)[N]) noexcept { return N; }
將函式返回值宣告成constexpr型別的意味著這個值在編譯期就能夠得到。這樣我們可以在編譯期獲取一個數組的大小,然後宣告另外一個相同大小的陣列:
int keyVals[] = { 1, 3, 7, 9, 11, 22, 35 }; int mappedVals[arraySize(keyVals)];
使用std::array更能夠體現你是一個現代C++程式設計師:
std::array<int, arraySize(keyVals)> mappedVals;
函式引數
陣列不是能夠退化成指標的唯一型別。函式型別也能夠退化為指標,我們所討論的關於陣列的型別推斷過程同樣適用於函式:
void someFunc(int, double); // someFunc是一個函式,型別為void(int, double) template<typename T> void f1(T param); //passed by value template<typename T> void f2(T& param); // passed by ref f1(someFunc); // param 被推斷為 ptr-to-func void (*)(int, double) f2(someFunc); // param 被推斷為ref-to-func void (&)(int, double)
要點總結
- 模板型別推斷會把引用當做非引用來處理,也就是說會把引數的引用屬性忽略掉。
- 當模板引數型別為universal reference 時,進行型別推斷會對左值入參做特殊處理。
- 當模板型別引數為by-value時,const或者volatile會被當做非const或者非volatile處理。
- 當模板型別引數為by-value時,入參為函式或者陣列時會退化為指標。