条款20:宁以pass-by-reference-to-const替换pass-by-value

本条款的要点:

1、尽量以pass-by-reference-to-const替换pass-by-value。前者更高效且可以避免切割问题。

2、这条规则并不适用于内建类型及STL中的迭代器和函数对象类型。对于它们,pass-by-value通常更合适。

 

缺省的情况下,C++以by-value方式传递对象至函数,或者获取函数的对象返回值。除非你另外的指定,否则函数参数都是以实际实参的副本为初值,而调用端所获得的也是函数返回值的一个副本。这些副本都是有对象的copy构造函数得到的。这可能使得pass-by-value成为昂贵的操作,同时也可能带来对象的切割问题。

效率的考量

 对于pass-by-reference的效率的问题看下面class的继承体系:

class Person
{
	public:
	Person();
	virtual ~Person();
	...
	private:
	string name;
	string address;
};

class Student:public Person
{
	public:
	Student();
	~Student();
	...
	private:
	string schoolName;
	string schoolAddress;
};

  现在考虑下面代码,其中调用函数validateStudent,后者需要一个Student实参(pass-by-value)并返回它是否有效:

bool validateStudent(Student s);//声明一个函数,函数以by value方式接受学生
Student plato;      //类的对象plato
bool stuIsOK=validateStudent(plato);

  当上述函数被调用时,发生了什么事?

很明显,Student的拷贝构造函数被调用,用plato来初始化参数s。同样明显的是,当 validateStudent返回时,s就会被销毁。所以这个函数的参数传递代价是一次Student的拷贝构造函数的调用和一次Student的析构函数的调用。

但这还不是全部。Student对象内部包含两个string对象,Student对象还要从一个 Person对象继承,Person对象内部又包含两个额外的string对象。最终,以传值方式传递一个Student对象的后果就是引起一次Student的拷贝构造函数的调用,一次Person的拷贝构造函数的调用,以及四次string的拷贝构造函数调用。当Student对象的拷贝被销毁时,每一个构造函数的调用都对应一个析构函数的调用,所以以传值方式传递一个Student的全部代价是六个构造函数和六个析构函数

这是正确和值得的行为。毕竟,你希望全部对象都得到可靠的初始化和销毁。尽管如此,pass by reference-to-const方式会更好:

bool validateStudent(const Student& s);

  这样做非常有效:没有任何构造函数和析构函数被调用,因为没有新的对象被构造

修改后参数声明中的const是非常重要的,原先validateStudent以by-value方式接受一个Student参数,所以调用者知道函数绝不会对它们传入的Student做任何改变,validateStudent只能改变它的副本。现在Student以引用方式传递,同时将它声明为const是必要的,否则调用者必然担心validateStudent改变了它们传入的Student

const Student& s表示不能通过reference s修改传进来的Student对象(并不是说这个传进来的Student对象是read-only的)。类似于const int *a;这样的定义。

对象切割问题

 以传引用方式传递参数还可以避免切割问题(slicing problem)。当一个派生类对象作为一个基类对象被传递(传值方式),基类的拷贝构造函数被调用,而那些使得对象行为像一个派生类对象的特化性质被“切断”了,只剩下一个纯粹的基类对象例如,假设你在一组实现一个图形窗口系统的类上工作:

class Window {
public:
   ...
   std::string name() const; // 返回窗口名称
   virtual void display() const; // 显示窗口及其内容
};

class WindowWithScrollBars: public Window {
public:
   ...
   virtual void display() const;
};

  所有Window对象都有一个名字(name函数),而且所有的窗口都可以显示(display函数)。display为 virtual的事实清楚地告诉你:基类的Window对象的显示方法有可能不同于专门的WindowWithScrollBars对象的显示方法。现在,假设你想写一个函数打印出一个窗口的名字,并随后显示这个窗口。以下是错误示范:

void printNameAndDisplay(Window w) //incorrect! 参数可能被切割
{
   std::cout << w.name();
   w.display();
}

  考虑当你用一个 WindowWithScrollBars 对象调用这个函数时会发生什么:

WindowWithScrollBars wwsb;
printNameAndDisplay(wwsb);

  参数w将被作为一个Window对象构造——它是被传值的,而且使wwsb表现得像一个 WindowWithScrollBars对象的特殊信息都被切割了。在printNameAndDisplay中,全然不顾传递给函数的那个对象的类型,w将始终表现得像一个Window 类的对象(因为其类型是Window)。因此在printNameAndDisplay中调用display将总是调用 Window::display,绝不会是WindowWithScrollBars::display。其实就是派生类对象被强制转换成了基类对象

绕过切割问题的方法就是以passby reference-to-const方式传递w:

void printNameAndDisplay(const Window& w)
{                      // 参数不会被切割
   std::cout << w.name();
   w.display();
}

  现在传进来的窗口是什么类型,w就表现出那种类型

pass-by-value和pass-by-reference-to-const

小对象该pass-by-value还是pass-by-reference-to-const:

(1)一个对象小,并不意味着调用它的拷贝构造函数就是廉价的。很多对象(包括大多数STL容器)内含的东西只比一个指针多一些,但是拷贝这样的对象必须同时拷贝它们指向的每一样东西,那将非常昂贵。即使当小对象有一个廉价的拷贝构造函数,也会存在性能问题。一些编译器对内置类型和用户自定义类型并不一视同仁,即使他们有同样的底层表示。例如,一些编译器拒绝将仅由一个double组成的对象放入一个寄存器(reg)中,即使通常它们非常愿意将一个纯粹的double 放入那里。当这种事发生,你以传引用方式传递这样的对象更好一些,因为编译器理所当然会将一个指针(引用的实现)放入寄存器。

(2)小的用户定义类型不一定是传值的上等候选者的另一个原因是:作为用户定义类型,它的大小常常变化,因为将来可能会变的很大。

结论是:小对象也尽量pass-by-reference-to-const

 

用指针实现引用是非常典型的做法,所以pass by reference实际上通常意味着传递一个指针

由此可以得出结论,如果你有一个内置类型对象(一个int),以传值方式传递它常常比传引用方式更高效;同样的建议也适用于 STL 中的迭代器和函数对象。

因为内置数据类型的参数不存在构造、析构的过程,而复制也非常快,“值传递”和“引用传递”的效率几乎相当。

 

通常情况下,你能合理地假设传值廉价的类型仅有内置类型及STL中的迭代器和函数对象。对其他任何类型,请尽量以pass-by-reference-to-const替换pass-by-value。

 

posted @ 2015-06-15 15:26  stemon  阅读(410)  评论(0编辑  收藏  举报