条款20:宁以pass-by-reference-to-const替换pass-by-value
在默认情况下,C++函数传递参数是继承C的方式,是值传递(pass by value)。这样传递的都是实际实参的副本,这个副本是通过调用复制构造函数来创建的。有时候创建副本代价非常昂贵。例如一下继承体系
class Person{ public: Person(); virtual ~Person(); …… private: std::string name; std::string address; }; class Student:public Person{ public: Student(); ~Student(); …… private: std::string schoolName; std::string schoolAddress; };
现在考虑一个函数validateStudent,它需要一个Student实参,以pass by value方式传递。
bool validateStudent(Student s);//pass by value Student plato; bool platIsOK=validateStudent(plato);
当函数被调用时,copy构造函数会被调用,用plato构造s。在返回时,s会被析构。那么pass by value的代价就是Student的一次构造和一次析构。但是Student构造和析构时又发生了什么?它内部有两个string对象,所以会有两个string对象的构造和析构。Student继承自Person,又加上Person的构造和析构,Person内又有两个string对象,因此还要加上2个string对象的构造和析构。总共是六次构造和六次析构。
pass by value是正确的,但是其效率低下。以pass by reference-to-const方式传递,可以回避所有构造函数和析构函数。
bool validateStudent(const Student& s);
这种方式传递,没有新对象创建,所以自然没有构造和析构函数的调用。参数中,以const修饰是比较重要的,原先的pass by value,原先的值自然不会被修改。现在以pass by reference方式传递,函数validateStudent内使用的对象和传进来的同同一个对象,为了防止在函数内修改,加上const限制。
以pass by reference方式传递,还可以避免对象切割(slicing)问题。一个派生类(derived class)对象以pass by value方式传递,当被视为一个基类对象(base class)时,基类对象的copy构造函数会被调用,此时派生类部分全部被切割掉了,仅仅留下一个base class部分。
在C++编译器的底层,reference往往以指针实现出来,所以pass by reference通常意味着真正传递是指针。但是对于内置类型,pass by value往往比pass by reference更高效。所以在使用STL函数和迭代器时,习惯上都被设计出pass by value。当设计迭代器和函数时,设计者有责任查看哪种传递方式更为高效,是否会有切割问题的影响。这个规则的改变适用于你使用C++的哪一部分。(条款1)
通常,内置类型都比较小,因此有人认小型types都适合pass by value,用户自己定义的class亦然。但是对象小并不意味着copy构造函数代价小。许多对象(包括STL容器),内涵的成员只不过是一两个指针,但是复制这种对象时,要复制指针指向的每一样东西,这个代价很可能十分昂贵。
还有一个理由,某些编译器对待内置类型和用户自定义类型的”态度“截然不同,即使两者有着相同的底层描述。例如,某些编译器拒绝把一个double组成的对象放进缓存器内,但是却乐意在一个正规基础上光秃秃的doubles上这么做。当这种事情发生时,应该以by reference方式传递此对象,因为编译器当然会把指针放进缓存器。
用户自定义的小型types,可能还会发生变化,将来也许会变大,其内部实现可能会改变,所以用户自定义的小型type在使用pass by value时要慎重。
一般情况下,可以假设内置类型和STL迭代器和函数对象以pass by value是代价不昂贵。其他时候最好以pass by reference to const替换掉pass by value。
条款21:必须返回对象时,别妄想返回其reference
在掌握了pass by reference后,刚开始一心一意想把所有pass by value替换为pass by reference。这时往往会犯下一个错误:传递一些reference指向不存在的对象。考虑一个用以表现有理数乘积的class。
class Rational{ public: Rational(int numerator=0, int denominator=1); …… private: int n, d; friend const Rational operator*(const Rational& lhs, const Rational& rhs); };
这个版本的operator*用by value的方式返回其计算结果(对象)。这样返回的代价是一个对象的创建+析构+另一个对象的创建。
一个对象的创建是指,在这个operator*函数中,创建一个新对象来保存结果,之后用这个用这个新对象返回,返回时用它初始化另一个对象。之后这个新对象析构。
但是如果用by reference方式传递就不会有任何代价。但是reference只是名称,代表一个已经存在的对象,任何时候看到reference都应该问自己,它的另一个名称是什么?如果上述operator*返回一个reference,那么它一定指向一个存在的Rational对象。
Rational a(1, 2);// 1/2 Rational b(3, 5);// 3/5 Rational c=a*b;// 3/10
这时返回一个值为3/10的Rational对象。但是这个对象原先并不存在,这时如果返回reference,那么必须在函数operator*内创建这个Rational对象。
函数创建新对象有两种途径,在stack上或在heap上创建。如果定义local变量,那么就在stack上创建
const Rational& operator*(const Rational& lhs, const Rational& rhs) { Rational result(lhs.n* rhs.n, lhs.d* rhs.d); return result; }
上面的做法,没有避免调用构造函数,result像任何对象一样由构造函数构造起来。上面还有一个错误:这个函数返回reference执行result,但是result是个local对象,它在函数退出前被销毁了。
考虑在heap上创建对象
const Rational& operator*(const Rational& lhs, const Rational& rhs) { Rational* result=new Rational(lhs.n* rhs.n, lhs.d* rhs.d); return *result; }
还是要付出一个构造函数的代价,分配的内存将以一个适当的构造函数初始化。现在又面临另一个问题:谁负责给你new出来的对象实施delete?
即使客户使用时谨慎,但是还是有可能造成以下内存泄露:
Rational w,x,y,z; w=x*y*z;
上述语句调用了两次operator*,使用了两次new,也就需要两次delete。但是没办法让使用者进行那些delete操作,因为没有让他们取得operator*返回的reference背后的指针。这会导致内存泄露。
在上面的两种做法中(在stack和在heap上创建对象),都因为operator*返回结果调用构造函数而受到惩罚。我们最初的目标是避免如此的构造函数的调用。还有一个办法避免任何构造函数的调用:让operator*返回一个指向在函数内部定义的static Rational对象:
const Rational& operator*(const Rational& lhs, const Rational& rhs) { static Rational result; result=……; return result; }
暂且不说上述代码在多线程时有什么问题,先看一下下面的调用的有什么问题:
bool operator==(const Rational& lhs, const Rational& rhs);//为比较Rational对象而写的 Rational a, b, c, d; …… if((a*b)==(c*d)) {//乘积相等时, doSomething(); } else//乘积不等时 { doOtherthing(); }
上述表达式(a*b)==(c*d)总是返回true。因为operator*返回的对象都是指向operator*内部定义的static对象。这个对象只有一个,当计算后者时,前者被覆盖。因此永远是两个相同的Rational对象的作比较。
一个必须返回新对象的函数的正确写法,就是让那个函数返回一个新对象。就这么简单。上述operator*正确的写法:
inline const Rational operator*(const Rational& lhs, const Rational& rhs) { return Rational(lhs.n* rhs.n, lhs.d* rhs.d); }
这样,你需要承受operator*返回值的构造和析构成本,但是从长远来看,那只是为了获得正确的行为而付出的一个小小的代价。但万一代价比较恐怖,承受不起,这是别忘了C++和所有编程语言一样,允许编译器实现者施行最优化,用以改善产出代码的效率,却不改变其可观察的行为。如果编译器施行优化,你的程序将保持它们该有的行为,但运行起来比预期更快。
以上可以总结为:在返回一个reference和返回一个object之间抉择时,挑出行为正确的那个。让编译器厂商为你尽可能降低成本吧!