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).