Effective Standard C++ Library: Explicit Function Template Argument Specification and STL A New Language Feature and Its Impact on Old Programming Techniques
Klaus Kreft and Angelika Langer
http://www.cuj.com/experts/1812/langer.htm?topic=experts
--------------------------------------------------------------------------------
在本篇專欄,我們將講解C++語言關於顯式函數模板參數申明的新特性如何打破在它出現前沒有問題的代碼的。為了避免潛在的問題,需要新的編程慣用法。我們將會研究來自STL的真實世界例子的效果。很多STL實作是在新語言特性被編譯器支援之前構建的,並且一部分實作還未被更新而仍然含有有疑問的函數模板,即外層函數模板用依賴於自動模板參數推導的方式調用內層函數模板。
函數模板的型別參數
函數模板的模板參數顯式申明是一個相對來說比較新的語言特性,它在C++的標準化過程中被增加進來。當你閱讀ARM(Annotated Reference Manual)[注1]的時候,由於它講解的是准標準(pre-standard C++),你將會發現最初沒有辦法告訴編譯器用哪個類型作為模板參數來執行個體化函數模板。在當時,下面這樣的模板是非法的。
template <class T>
T* create();
每個模板參數,比如上面的型別參數T,被要求用於函數模板的[函數]參數類型。否則,編譯器沒法推導模板參數。在上面的例子中,函數沒有任何參數,因此編譯器無法知道用哪個類型來執行個體化函數模板。
新的語言特性
今天,我們能顯式告訴編譯器它必須用哪個類型來執行個體化函數模板。在上面的例子中,我們能用顯式參數申明的文法來調用函數,如下所示:
int n = create<int>();
C++語言將create<int>叫做顯式申明函數模板的參數(explicit specification of a function template argument)。文法與類模板的執行個體化文法相似:模板的名字後面跟著模板參數列表。
即使在編譯器能從函數的實參類型推匯出實際的模板參數,而不需要顯式申明函數模板的參數時,我們也可以跳過自動推導,而用顯式申明代替。這是例子:
template <class T>
void add(T t);
add("log.txt"); // automatic argument deduction
add<string>("log.txt"); // explicit argument specification
這個例子也揭示了自動推導和顯式申明具有不同的效果。自動參數推導將導致執行個體化add<char *>,而顯式參數申明將產生一個不同的函數,即add<string>。
陷阱
新的語言特性被加入,以解決模板參數沒被用於函數參數類型時執行個體化函數模板的問題。它為語言加入了額外的靈活性,但有一個陷阱。以前安全的代碼現在可能有問題了。在顯式函數模板參數申明之前,完全有理由實現一個函數模板,它將自己的參數靠自動參數推導傳給其它函數模板,如下所示:
template <class T>
void inner(T t) ;
template <class T>
void outer(T t)
{ ...
inner(t);
...
}
外層的函數模板將它的函數參數傳給內層函數模板,並且為了調用內層函數模板,它讓編譯器計算模板參數。
現在,有了顯式函數模板參數申明,這是一個有問題的函數模板實現,因為如果外層函數是用參考型別執行個體化的話,就可能造成對象切割問題(object slicing problem,或稱“對象切片問題”)。仍然有理由將參數從一個函數模板傳給另一個函數模板,但現在其安全實現的文法已經不同了。
自動函數模板參數推導vs.顯式函數模板參數申明
讓我們分析上面有問題的例子:
template <class T>
void inner(T t) ;
template <class T>
void outer(T t)
{ ...
inner(t);
...
}
為什麼它現在是危險的,而在顯式參數申明被引入以執行個體化函數模板以前是安全的?這必須提到新特性加入語言所帶來的額外的靈活性。
問題
利用顯式模板參數申明,外層的函數模板可以用一個參考型別來執行個體化,如下所示:
class Base;
class Derived : public Base {};
Base& ref = new Derived;
outer<Base&>(ref);
The generated function outer<Base&> would look like this:
void outer<Base&>(Base& t)
{ ...
inner(t); // calls: void inner<Base>(Base t);
...
}
當它調用內層函數模板時,它依賴於自動模板參數推導,並且編譯器用實值型別Base而不是參考型別Base &來執行個體化內層函數模板。這可能令人驚訝,但可以理解:自動函數參數推導過程包含很多步隱式類型轉換,其中之一是左值到右值的轉換(轉換過程的更多細節在本文後面部分)。結果是函數實參t(一個指向衍生類別對象的基類類型的引用)以傳值的方式從外層函數傳給內層函數。只有衍生類別對象的基類切片對內層函數可見。這被稱為對象切割,並且它是發生於建立基類類型的引用的拷貝時的一個眾所周知的問題。
解決方案
在正確的outer()實現中,我們會將參數t以被接受到的形式傳給內層函數(也就是,當收到傳引用時就傳引用,當收到傳值時就傳值)。這能很容易通過對內層函數的顯式參數申明來實現,如下所示:
template <class T>
void inner(T t) ;
template <class T>
void outer(T t)
{ ...
inner<T>(t);
...
}
產生的外層函數outer<Base &>將用參考型別Base &觸發內層函數模板的執行個體化。
void outer<Base&>(Base& t)
{ ...
inner<Base&>(t); // calls: void inner<Base&>(Base& t);
...
}
函數參數t是以傳引用的方式傳給內層函數的,不會導致對象切割,因為沒有建立拷貝。
評價
在函數模板中的對象切割/切片問題源於模板以基類參考型別來執行個體化的事實。現在,你能明白為什麼outer()和inner()函數模板的天真實現在顯式模板參數申明被加入語言前是安全的:只是因為不可能用參考型別執行個體化函數模板。因為這個簡單的理由,outer()的實現者不需要準備基類類型的引用作為參數的情況。不會有對象切割的危險,因為不會遇到引用。現在,這個限制不存在了,函數模板能夠用任何類型執行個體化,包括參考型別。因此,函數模板的實現者必須準備好正確處理任何類型。
其它可能的解決方案
原則上,外層函數模板的實現者可以使用另外一個不同的方法。也許,他/她不想接受任意類型,並且決定排除用參考型別執行個體化外層函數模板,限定只能用實值型別。這裡是一個可能的實現,它不能用參考型別執行個體化:
template <class T>
void inner(T t) ;
template <class T>
void outer(T t)
{ ...
typedef T& dummy;
inner(t);
...
}
試圖執行個體化outer<Base &>將會失敗,因為引用的引用在C++中不被允許。產生的函數看起來可能是這樣:
oid outer<Base&>(Base& t)
...
typedef Base&& dummy; // error : reference to reference
inner(t);
...
這解決方案的缺點是:我們通常努力於模板的最大適用性,而不是限制它的可用性。除非有一個信服的理由以限定在實值型別,使用顯式參數申明的這種更具靈活性的解決方案更好。
模板參數推導過程中的隱式類型轉換
基於全面,需要指出,對我們的例子的自動參數推導中,左值到右值的轉換不是在推匯出模板參數前所使用的唯一一個隱式類型轉換。
在決定模板參數類型前,編譯器執行下列隱式類型轉換:
l 左值變換
l 修飾字轉換
l 衍生類別到基類的轉換
見《C++ Primer》([注2],P500)對此主題的完備討論。
簡而言之,編譯器削弱了某些類型屬性,例如我們例子中的參考型別的左值屬性。舉例來說,編譯器用實值型別執行個體化函數模板,而不是用相應的參考型別。同樣地,它用指標類型執行個體化函數模板,而不是相應的數群組類型。它去除const修飾,絕不會用const類型執行個體化函數模板,總是用相應的非const類型。
底線是:自動模板參數推導包含類型轉換,並且在編譯器自動決定模板參數時某些類型屬性將丟失。這些類型屬性可以在使用顯式函數模板參數申明時得以保留。
STL泛型演算法
以依賴於模板參數推導的方式調用內層函數模板的函數模板可以在很多STL實作中找到。STL中的所有泛型演算法都是函數模板,並且它們經常在自己的實現中使用其它泛型演算法。remove_if()泛型演算法就是一個例子。這是在流行的STL實作中可能發現的實現:
template <class ForwardIterator, class Predicate>
ForwardIterator remove_if(ForwardIterator first, ForwardIterator last,
Predicate pred) {
first = find_if(first, last, pred);
ForwardIterator next = first;
return first == last ? first : remove_copy_if(++next, last, first, pred);
}
remove_if()演算法調用find_if()和remove_copy_if()。對兩者,remove_if()都依賴自動參數推導。iterator和predicate是被按值傳遞的,而沒有考慮到它們可能以傳引用的方式傳入remove_if()的事實。
在這種情況下,有對象切割的危險嗎?我們經常以傳引用的方式傳遞iterator和predicate嗎?
Iterators。好吧, iterator被標準要求為表現為值語義(value semantics)。iterator類型必須是可拷貝的(copyable);因此,傳值被保證能工作。典型地,iterator類型既不包含許多資料也沒有任何虛函數;因此不大可能對iterator傳引用。
Predicate。對predicate的要求則不同。標準對的Predicate類型的要求被相對地放鬆了。這是來自於C++標準的引述:
Predicate參數被用於每當泛型演算法期望一個functor作用在相應的iterator的反引用上,並返回一個可以與true進行測試的值的時候。換句話說,如果一個泛型演算法接受一個predicate參數pred和iterator參數first,在建構函式中,它應該能正確工作: (pred(*first)){...}。functor對象pred不應該在iterator的反引用上應用任何非const函數。這個functor可以是一個指向函數的指標,或有合適的叫用作業operator()的類型的對象。
用通俗的話說,predicate的類型要麼是一個函數指標類型,要麼是一個functor類型。函數(或對象)必須返回一個能轉換到bool型的傳回值,必須接受一個iterator的反引用能轉換到的類型的參數。另外,predicate絕不能修改容器中的元素。除此之外,標準沒有對predicate類型作任何進一步的要求。注意, preidcate甚至不需要可拷貝。
Predicate與count_if()
對prediacte的這個比較弱的要求確實足夠了。典型地,泛型演算法並不用predicate做太多的事:它僅是用一個容器中元素的引用(通過反引用一個iterator)來調用prediacte。這是個典型的例子,count_if()演算法,展示了泛型演算法如何使用它的predicate:
template <class InputIterator, class Predicate>
typename iterator_traits<InputIterator>::difference_type
count_if(InputIterator first, InputIterator last, Predicate pred) {
typename iterator_traits<InputIterator>::difference_type n = 0;
for ( ; first != last; ++first)
if (pred(*first))
++n;
return n;
}
泛型演算法僅僅調用predicate,提供一個iterator的反引用作為實參,並在條件運算式中使用predicate的傳回值。
Predicate與remove_if()
相對地,本文前面展示的remove_if()演算法的實現對它的predicate的要求比標準允許的多。它以傳值的方式將predicate傳給其它泛型演算法,這首先要求predicate類型是可拷貝的,並且,冒在predicate的基類類型的引用時發生對象切割的危險。
多態的predicate類型
為了舉例說明潛在的對象切割問題,設想一個predicate的繼承體系,有一個抽象基類和很多派生的實體類[注3]。如果想將它用在STL泛型演算法中,那麼你可能試圖用基類的參考型別來執行個體化STL泛型演算法。下面的代碼示範了這個過程:
template <class Container>
void foo(Container& cont,
const predicateBase<typename Container::value_type>& pred)
{
remove_if<typename Container::iterator,
const predicateBase<typename Container::value_type>&>
(cont.begin(),cont.end(),pred);
}
產生的remove_if()函數通過基類的引用來接受predicate,並如我們從remove_if()的實現上看到的,將它以傳值的方式傳給了find_if()和remove_copy_if()--典型的對象切割問題[注4]。
含有資料的Predicate類型
使用predicate的引用的另外一個原因是predicate具有資料成員,並用這些資料成員累積資訊。
考慮一下一個銀行程式,我們有一個銀行帳戶列表,並且需要檢查帳戶餘額是否低於某個界限;如果低於的話,客戶將被從帳戶列表中移除。同時,每當餘額超過一個界限時,客戶的名字被加到一個郵寄列表中。我們可以靠一個合適的predicate用remove_if()完成這個任務,這個predicate建立郵寄列表,並對必須移除的客戶返回true。
只有一個極小的問題:在郵寄列表是predicate的資料成員的情況下,我們如何在執行完泛型演算法後獲得對此郵寄列表的訪問權?當predicate以值傳遞的方式傳給remove_if()時,泛型演算法工作在我們的predicate對象的一個臨時拷貝上,所有累積的資訊都在我們有機會分析前就被丟棄了。因為這個理由,我們以傳引用的方式傳遞它,但然後泛型演算法將它以傳值的方式傳給find_if()和remove_copy_if()?並且破壞了前面使用引用的目的。
總結
有各種不同理由以要求用傳引用的方式將functor傳給泛型演算法。不幸的是,一些標準運行庫的實作建立了引用對象的拷貝,並冒了對象切割的危險,因為它們假設了泛型演算法絕不會用參考型別來執行個體化。
這個STL問題是一個教育性的例子,講述了對語言的一個擴充如何突然需要新的編程慣用法的。今天,我們不能對執行個體化函數模板的模板參數作任何安全的假設。它們可以是參考型別,可以有const修飾字,可以有其它(在自動模板參數推導時不具有的)類型屬性。
當我們將“未知”類型的函數參數傳給內層函數模板時,我們可以用本文所討論過的兩個方法來避免對象切割問題:
l 作限制。如果我們有意於加強對模板的型別參數的限制,那麼我們要文檔化這些限制,並且應該完美地確保模板不會用不合要求的類型來執行個體化。在我們的例子中,一個啞typedef將會對不期望的參考型別導致引用的引用,這就達到了預期的效果。
l 保持中立。通常,我們努力於模板的最大可用性,並儘可能避免任何限制,不丟失任何類型屬性而將函數模板的參數傳遞下去是可能的:只要用顯式函數模板參數申明的方式調用內層函數模板。
引用和附註
[1] Margaret A. Ellis and Bjarne Stroustrup. The Annotated C++ Reference Manual (Addison-Wesley, 1990).
[2] Stan Lippman and Josée Lajoie. The C++ Primer (Addison-Wesley, 1998).
[3] Hierarchies of polymorphic predicate types can be found in practice because the GOF book [5] suggests this kind of implementation for the Strategy pattern. Predicates in STL are typical strategies in the sense of the GOF strategy pattern.
[4] One might argue that use of polymorphic predicate types in conjunction with STL is not a wise thing to do. Generic programming provides enough alternatives (replace run-time by compile-time polymorphism), and there is no need for passing predicate base class references. True, in principle, yet the implementation of remove_if, which relies on automatic function argument deduction, creates a pitfall.
[5]Gamma, Helm, Johnson, Vlissides. Design Patterns (Addison-Wesley, 1995).