0%

[Effective C++ 笔记]条款 20. 尽量使用常值引用传递代替值传递

简介

  • 尽量以常值引用传递(pass-by-reference to const)代替值传递(pass by value);前者比较高效并且可以避免对象切割问题
  • 对内置类型、STL 迭代器和函数对象使用值传递

值传递函数的缺点

在 C++ 中默认的参数传递方式是值传递,即在传递参数过程中会调用需要传递传递的参数的构造函数(具体来说是 copy 构造函数)创建一份副本并传入函数中,这在某种情况下是比较昂贵的操作,考虑一下例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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 schoolAdress;
};

接下来考虑运行以下代码:

1
2
3
4
5
// 测试值传递参数的函数
bool validateStudent(Student s);

Student plato;
bool platoIsOK = validateStudent(plato);

在调用 validateStudent() 时,由于该函数采用值传递方法,因此在调用函数时会调用一次 Student 的拷贝构造函数。假如在 StudentPerson 中的构造函数和析构函数中添加输出语句方便观察,运行输出如下(实际代码见 ex1):

1
2
3
4
5
6
7
8
Person constructed.
Student constructed.
Person constructed by copy.
Student constructed by copy.
Student destroyed.
Person destroyed.
Student destroyed.
Person destroyed.

可以发现,在运行过程,PersonStudent 的构造函数各被调用两次,一次是默认构造函数另一次是拷贝构造函数。但除此之外还有考虑 StudentPerson 中的成员变量,同样为了方便演示,我们在其中添加一个自定类 Age 并在其构造函数中添加相应输出(实际代码见 ex2):

1
2
3
4
5
6
7
8
9
10
11
12
class Age {
public:
Age() { std::cout << "Age constructed.\n"; }
Age(const Age& other) : age(other.age) {
std::cout << "Age constructed by copied.\n";
}

~Age() { std::cout << "Age destroyed.\n"; }

private:
int age = 0;
};
1
2
3
4
5
6
7
8
9
10
11
12
Age constructed.
Person constructed.
Student constructed.
Age constructed by copied.
Person constructed by copy.
Student constructed by copy.
Student destroyed.
Person destroyed.
Age destroyed.
Student destroyed.
Person destroyed.
Age destroyed.

可以发现 Person 中的 Age 对象同样通过拷贝构造函数产生了一个副本。因此,在调用 ValidateStudent() 中,包括 std::string 在内实际上调用了 7 次构造函数 (Person 一次, Student 一次, Age 一次, std::string 四次)以及 7 次析构函数。因此函数参数值传递是非常昂贵的操作。

常值引用传递 (pass by reference to const) 的优点

减少不必要的开销

在以上的例子中,实际上我们并不需要对传入参数进行修改操作(从函数名可以看出来),因此在这种情况下为了避免拷贝构造 Student 的开销,我们应该使用常值引用传递,在上面的例子中,只需要把 ValidateStudent() 改成:

1
bool validateStudent(const Student& s);

运行输出如下:

1
2
3
4
5
6
Age constructed.
Person constructed.
Student constructed.
Student destroyed.
Person destroyed.
Age destroyed.

可以发现只有初始化 plato 时调用了构造函数,调用函数没有产生任何额外开销,同时因为我们限定了传递的时候引用参数被声明为 const,因此也不用担心函数会对外部变量进行修改。

避免对象切割问题

上述的用法只是说明了常值引用传递可以减少值传递的开销,他们实际的效果是一样的。但实际上值传递在有些场合可能会造成问题。考虑以下例子:

1
2
3
4
5
6
7
8
9
class Window {
public:
std::string name() const;
virtual void display() const;
};

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

上述例子中,WindowWIthScrolledBarWindow 的子类并且有一个 display() 虚函数。假如我们通过以下函数来进行对窗口的 display() 的操作:

1
2
3
4
5
6
7
8
void printNameAndDisplay(Window w) {
std::cout << w.name() << std::endl;
w.display();
}

...
WindowWithScrolledBar w;
printNameAndDisplay(w);

通常情况我们会希望 printNameAndDisplay() 可以随着我们传入参数的类型不同调用不同的 display() 函数(多态)。但是如果通过值传递的方法的话我们会得到如下输出(在 display() 中加入输出语句,具体代码见 ex4):

1
2
Window
Called by Window object.

可以发现 wWindowWithScrolledBar 性质完全没体现出来,这是因为值传递的过程中由于我们声明的类型是 Window 所以实质上无论是 Window 的什么子类,拷贝过程是调用了一次 Window 的拷贝构造函数构造了一个 Window 对象,因此子类的性质就不存在了。解决的方法也很简单,通过常值引用传递即可:

1
2
3
4
void printNameAndDisplay(const Window& w) {
std::cout << w.name() << std::endl;
w.display();
}

同样的调用方法输出结果:

1
2
Window
Called by WindowWithScrolledBar object.

使用常值引用传递的场合

大部分 C++ 编译器底层对引用的实现是基于指针,因此传递引用实际上是传递指针,因此如果要传递的参数是内置类型(如 int),那么值传递的效率可能会比引用传递的效率稍高一些。另外,STL 的迭代器和函数对象通常涉及为值传递。因此我们在使用时需要自己注意是否会出现上述的对象切割问题。

另外,有部分人认为使用值传递还是常值引用传递只跟对象尺寸大小有关,即对 double 对象使用值传递,对一个内部只包含一个 double 的自定义类对象也使用值传递。这个结论并不一定可靠,有以下几个论点:

  • 有一些自定义类内部可能只含有指针(尺寸并不大)但是拷贝函数的开销并不低,因为同时还要拷贝指针指向的对象
  • 编译器在处理内置类型和自定义类的处理会有所不同,哪怕他们尺寸可能是一样的
  • 我们需要考虑到以后自定义类有可能会被扩充的情况

因此通常而言,只推荐对内置类型、STL 迭代器以及函数对象使用值传递,其他情况可能使用常值引用传递

结论

  • 尽量以常值引用传递(pass-by-reference to const)代替值传递(pass by value);前者比较高效并且可以避免对象切割问题
  • 对内置类型、STL 迭代器和函数对象使用值传递

其他