[转]http://www.cppblog.com/tiandejian/archive/2007/06/01/ec_20.html
第20条: 尽量使用“引用常量”传参,而不是传值
默认情况下, C++ 为函数传入和传出对象是采用传值方式的(这是由 C 语言继承而来的特征)。除非你明确使用其他方法,函数的形式参数总会通过复制实在参数的副本来创建,并且,函数的调用者得到的也是函数返回值得一个副本。这些副本是由对象的拷贝构造函数创建的。这使得“传值”成为一项代价十分昂贵的操作。请观察下边的示例中类的层次结构:
class Person {
public:
Person(); // 为简化代码省略参数表
virtual ~Person(); // 第 7 条解释了它为什么是虚函数
...
private:
std::string name;
std::string address;
};
class Student: public Person {
public:
Student(); // 再次省略参数表
virtual ~Student();
...
private:
std::string schoolName;
std::string schoolAddress;
};
请观察下面的代码,这里我们调用一个名为 validateStudent 的 函数,通过为这一函数传进一个 Student 类型的参数(传值方式),它将返回这一学生的身份是否合法:
bool validateStudent(Student s);// 通过传值方式接受一个 Student 对象
Student plato; // 柏拉图是苏格拉底的学生
bool platoIsOK = (plato); // 调用这一函数
在这个函数被调用时将会发生些什么呢?
很显然地,在这一时刻,通过调用 Student 的拷贝构造函数,可以将这一函数 的 s 参数初始化为 plato 的值 。同样显然的是, s 在 validateStudent 返回的时候将被销毁。所以这一函数中传参的开销就是调用一次 Student 的拷贝构造函数和一次 Student 的析构函数。
但是上边的分析仅仅是冰山一角。一个 Student 对象包含两个 string 对象,所以每当你构造一个 Student 对象时,你都必须构造两个 string 对象。同时,由于 Student 类是从 Person 类继承而来,所以在每次构造 Student 对象时,你都必须再构造一个 Person 对象。一个 Person 对象又包含两个额外的 string 对象,所以每次对 Person 的构造还要进行额外的两次 string 的构造。最后的结果是,通过传值方式传递一个 Student 对象会引入以下几个操作:调用一次 Student 的拷贝构造函数,调用一次 Person 的拷贝构造函数,调用四次 string 的拷贝构造函数。在 Student 的这一副本被销毁时,相应的每次构造函数调用都对应着一次析构函数的调用。因此我们看到:通过传值方式传递一个 Student 对象总体的开销究竟有多大?调用六次构造函数和六次析构函数!
下面向你介绍正确的方法,这一方法才会使函数拥有期望的行为。毕竟你期望的是所有对象以可靠的方式进行初始化和销毁。然而,如果可以绕过所有这些构造函数和析构函数将是件很惬意的事情。那就是:通过引用常量传递参数:
这样做效率会提高很多:由于不会创建新的对象,所以就不会存在构造函数或析构函数的调用。改进的参数表中的 const 是十分重要的。由于早先版本的 validateStudent 通过传值方式接收 Student 参数,所以调用者了解:无论函数对于传入的 Student 对象进行什么样的操作,都不会对原对象造成任何影响, validateStudent 仅仅会对对象的副本进行修改。而改进版本中 Student 对象是以引用形式传入的,有必要将其声明为 const 的,因为如果不这样,调用者就需要关心传入 validateStudent 的 Student 对象有可能会被修改。
通过引用传参也可以避免“截断问题”。当一个派生类的对象以一个基类对象的形式传递(传值方式)时,基类的拷贝构造函数就会被调用,此时,这一对象的独有特征——使它区别于基类对象的特征会被“截掉”。剩下的只是一个简单的基类对象,这并不奇怪,因为它是由基类构造函数创建的。这肯定不是你想要的。请看下边的示例,假设你正在使用一组类来实现一个图形视窗系统:
class Window {
public:
...
std::string name() const; // return name of window
virtual void display() const; // draw window and contents
};
class WindowWithScrollBars: public Window {
public:
...
virtual void display() const;
};
所有的 Window 对象都有一个名字,可以通过 name 函数取得这个名字,所有的视窗可以被显示出来,可以通过调用 display 实现。 display 是虚函数,这一点告诉你简单基类 Window 的对象与派生出的 WindowWithScrollBars 对象的显示方式是不一样的。(参见第 34 和 36 条)
现在,假设你期望编写一个函数来打印出当前窗口的名字然后显示这一窗口。下面是错误的实现方法:
void printNameAndDisplay(Window w)// 错误 ! 参数传递的对象将被截断。
{
std::cout << w.name();
w.display();
}
考虑一下当你将一个 WindowWithScrollBars 对象传入这个函数时将会发生些什么:
WindowWithScrollBars wwsb;
printNameAndDisplay(wwsb);
参数 w 将被构造——还记得么?它是通过传值方式传入的——就像一个 Window 对象,使 wwsb 具体化的独有信息将被截掉。无论传入函数的对象的具体类型是什么,在 printNameAndDisplay 的内部, w 将总保有一个 Window 类的对象的身份(因为它本身就是一个 Window 的对象)。特别地,在 printNameAndDisplay 内部对 display 的调用总会是 Window::display ,而永远不会是 WindowWithScrollBars::display 。
解决截断问题的方法是:通过引用常量传参:
void printNameAndDisplay(const Window& w)
{ // 工作正常,参数将不会被截断。
std::cout << w.name();
w.display();
}
现在 w 的类型就是传入视窗对象的精确类型。
揭开 C++ 编译器的面纱,你将会发现引用通常情况下是以指针的形式实现的,所以通过引用传递通常意味着实际上是在传递一个指针。因此,如果传递一个内建数据类型的对象(比如 int ),传值会被传递引用更为高效。那么,对于内建数据类型,当你在传值和传递常量引用之间徘徊时,传值方式不失为一个更好的选择。迭代器 和 STL 中的函数对象都是如此,这是因为它们设计的初衷就是更适于传值,这是 C++ 的惯例。实现迭代器和函数对象的人员有责任考虑复制时的效率问题和截断问题。(这就是一个“使用哪种规则,取决于当前使用哪一部份的 C++ ”,参见第 1 条)
内建数据类型体积较小,所以一些人得出这样的结论:所有体积较小的类型都适合使用传值,即使它们是用户自定义的。这是一个不可靠的推理。仅仅通过一个对象体积小并不能判定调用它的拷贝构造函数的代价就很低。许多对象——包括大多数 STL 容器——其中仅仅包含一个指针和很少量的其它内容,但是复制这样的对象的同时,它所指向的所有内容都需要复制。这将会是一件十分昂贵的事情。
即使体积较小的对象的拷贝构造函数不会带来昂贵的开销,它也会引入性能问题。一些编译器对内建数据类型和用户自定义数据类型是分别对待的,即使它们的原始表示方式完全相同。比如说一些编译器很乐意将一个单纯的 double 值放入寄存器中,这是语言的常规,但将仅包含一个 double 值的对象放入寄存器时,编译器就会报错了。当你遇到这种事情时,你可以使用引用传递这类对象,因为编译器此时一定会将指针(引用的具体实现)放入寄存器中。
小型用户自定义数据类型不适用于传值方式还有一个理由,那就是:作为用户自定义类型,它们的大小并不是固定的。现在很小的类型在未来的版本中可能会变得很大,这是因为它的内部实现方式可能会改变。即使是你更改了 C++ 语言的具体实现都可能会影响到类型的大小。比如,在我编写上面的示例的时候,一些对标准库中 string 实现的大小竟然达到了另一些的七倍。
总体上讲,只有内建数据类型、 STL 迭代器和函数对象类型适用于传值方式。对于所有其它的类型,都应该遵循本条款中的建议:使用引用常量传参,而不是传值。
牢记在心
l 尽量使用引用常量传参,而不是传值方式。因为传引用更高效,而且可以避免“截断问题”。
l 对于内建数据类型、 STL 迭代和函数对象类型,这一规则就不适用了,对它们来说通常传值方式更实用。