条款20:宁以pass-by-reference-to-const替换pass-by-value
这一条款在我的工作中倒是直接感受不深,其原因是我所涉及的技术栈基本为 com ,大量(几乎全部)使用指针,但读到这里也是有种拨开云雾的感觉;
一、名词解释与异同
1、pass-by-value
顾名思义,值传递,函数被调用时,实参的值会被复制一份并传递给对应的形参。
一般情况下,C++默认是以值传递的方式向函数传入或者从函数传出对象。除非另外指定,否则函数参数都会以实际参数值的复件(副本)为初值,这些副本都是由对象的拷贝构造函数产出。
值传递的优点包括数据安全性和简单清晰,但缺点是可能会增加额外的内存消耗,尤其是当处理大数据结构时(即 传递影响效率问题)。
2、pass-by-reference-to-const
引用传递,即是指在调用函数时,将实际参数的指针值传递进去。
也可以理解为同值传递,是把实参的指针当成实参传递进去,在里面使用的时候也是通过指针进行地址数据的获取,修改 (敲重点)等等。
引用传递的主要优点是可以避免在函数调用中不必要的数据复制,特别是在处理大型数据结构时,可以显著提高效率并减少内存使用。
二、存在的问题与解决方法
1、传递影响效率问题
此问题是书中的原例,其核心就是围绕值传递传入时发生的拷贝。
例如,现在有一个继承体系:一个基类(Person)与 一个派生类(Student),并且定义一个函数(validateStudent),参数接受一个Student对象(传值调用)。
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;
};
现在我们进行如下执行:
bool validateStudent(Student s);
Student plato; //定义Student对象
bool platoIsOK = validateStudent(plato); //传值调用
当函数被调用时会发生什么?
(1)执行6次构造函数
- plato传入进函数的时候,需要调用1次 Student 的构造函数;
- Student构造函数执行前需要先构造 Person ,因此还会调用1次 Person 类的构造函数;
- Person和Student两个类中共有4个 string 成员变量,因此还需要调用 string 的构造函数4次。
(2)执行6次析构函数
- 当函数执行完成之后,传入 validateStudent 函数中的 plato 副本还需要执行析构函数;
- 同样,执行析构函数的时候分别要对应执行6次析构函数( Student + Person + 4个 string 成员变量)。
这样的执行是肯定正确的,但不代表是最优的,毕竟,如果函数涉及多次调用,那我们又要执行多少次的构造呢?而且,构造函数如果相当复杂,又是一个巨大的开销。
因此,我们要如何绕过这个问题呢?
2、解决方法
原书也是给出了解决方案的,例如 const 引用传递 :
bool validateStudent(const Student& s);
首先,这种方法是没有构造函数或者析构函数被调用,因为没有新的对象被创建,只有指针之间的传递;
其次,const修饰是必要的,因为值传递中的形参可是个拷贝出来的新对象,修改也不会影响原对象,其目的就是获取,而不涉及修改,因此要做到同等目标,const的修饰就能保证数据不被修改;
最后,这种方法也能处理 对象切片/截断问题 。
对象切片/截断:如果将对象直接以传值方式调用,会造成对象的切片/截断问题。这种现象一般发生在函数的参数为基类类型,但是却将派生类对象传递给函数。
这里可以通过原书例子(我拓展了内容)理解:
有一个基类(Window)与一个派生类(WindowWithScrollBars)实现了图形化窗口系统;
class Window
{
public:
std::string name()const
{
return "Window's name\n";
}
virtual void display()const
{
std::cout << "Window's display\n";
}
};
class WindowsWithScrollBars :public Window
{
public:
virtual void display()const
{
std::cout << "WindowsWithScrollBars's display\n";
}
};
假设你实现了一个函数,先打印窗口的名字然后让窗口显示出来。下面是错误示范:
void printNameAndDisplay(Window w) //错误,参数可能会被切割
{
std::cout << w.name();
w.display();
}
当使用一个WindowWithScrollBars对象作为参数调用这个函数就会发生 对象切片/截断问题;
WindowsWithScrollBars wwsb;
printNameAndDisplay(wwsb); //WindowsWithScrollBars对象会被截断
其原因是:
- 参数w将会被构造,它是按值传递的。
- 所以w作为一个 Window 类实例化的对象,是不会包含派生类 WindowWithScrollBars 的特征的。
- 在 printNameAndDispay 的调用中,w的行为总是会像 Window 对象一样(因为他是一个Window类的对象),而不管传入函数的参数类型是什么。
- 特别的,在 printNameAndDisplay 内部对 display 的调用总是会调用 Window::display ,永远不会调用 WindowWithScrollBars::display 。
因此,其正确的方法为使用 const 引用:
void printNameAndDisplay(const Window& w) //很好,参数不会被切割
{
std::cout << w.name();
w.display();
}
int main()
{
WindowsWithScrollBars wwsb;
printNameAndDisplay(wwsb); //传入的就是WindowsWithScrollBars类型的对象
return 0;
}
当然,说那么好,const 引用 方法也是有条件限制的,而这个界限就是 “ 按值传递是否昂贵的 ”。
举个例子,一些编译器拒绝将只含有一个double数值的对象放入缓存中,却很高兴的为一个赤裸裸的double这么做。当这类事情发生的时候,将这些对象按引用传递会更好,因为编译器会将指针(引用的实现)放入缓存中。
据此,请根据情况自行分析,毕竟这也是C++高性能特点的在不同使用者手中不一样的体现之一 。
三、总结
尽量以pass-by-reference-to-const替换pass-by-value。前者通常比较高效,并可避免切割问题
以上规则并不适用于内置类型,以及STL的迭代器和函数对象。对它们而言,传值调用往往比较合适