1.传值方式
默认情况下,C++以传值方式(by value)传递对象到(或来自)函数。除非你另行指定,否则函数参数都是以实参的副本为初值,而调用端所获得的也是函数返回值的一个副本。这些副本是由对象的拷贝构造函数产生的(原因请参见前面的文章C++中的拷贝构造函数),这可能使得传值方式成为更昂贵的操作。为什么会这样说,请看下面的例子。
1class Person{ 2public: 3 Person(); 4 virtual ~Person(); 5private: 6 std::string name; 7 std::string address; 8}; 9 10class Student: public Person{ 11public: 12 Student(); 13 ~Student(); 14private: 15 std::string schoolName; 16 std::string schoolAddress; 17};
现在考虑以下代码段,其中调用函数validateStudent(),该函数需要一个Student实参(以传值方式)并返回它是否有效:
1bool validateStudent(Student s); // 函数声明 2 3Student CurryCoder; 4 5bool platoIsOK = validateStudent(CurryCoder); // 调用函数,函数以传值方式接收参数
当调用函数validateStudent()时,究竟发生了什么?毫无疑问,Student的拷贝构造函数会被调用,以CurryCoder为蓝本将s进行初始化。同样地,当函数validateStudent()返回具体值时,s会被销毁。因此,对该函数来说,参数的传递成本是一次Student拷贝构造函数的调用,加上一次Student析构函数的调用。
但是,请不要忘记Student对象内还有两个string对象(schoolName和schoolAddress)。所以,每次构造一个Student对象也会构造两个string对象。此外,Student对象继承自Person对象,所以每次构造Student对象也会构造一个Person对象。一个Person对象又有两个string对象在其中,因此每一次Person构造动作又需要承担两个string构造动作。最终结果是:以传值方式传递一个Student对象会导致调用一次Student的拷贝构造函数、一次Person拷贝构造函数、四次string拷贝构造函数。同样地,当函数内的那个Student副本被销毁,每一个构造函数调用动作都需要一个对应的析构函数调用动作。因此,以传值方式传递一个Student对象,总体成本是六次构造函数和六次析构函数。
2.传常引用方式
以上的传值方式我们会发现所有对象都确实能被构造和析构,但是那些构造和拷贝动作太多,太麻烦了。可以使用传常引用方式(by reference to const)来回避所有那些构造和析构函数。
1bool validateStudent(const Student& s); // 函数声明 2 3Student CurryCoder; 4 5bool platoIsOK = validateStudent(CurryCoder); // 调用函数,函数以传常引用方式接收参数
传常引用方式具有两个优点:第一,这种传递方式效率更高。没有任何构造函数和析构函数被调用,因为没有任何新对象被创建。其中,将引用使用const进行修饰很重要。原先函数validateStudent()以传值方式接收一个Student参数,因此调用者知道它们受保护,所以函数内部不会对传入的Student作任何改变;函数validateStudent()只能对其副本进行修改。现在,Student以传引用方式传递,并将它声明为const是十分必要的,因为如果不这样做的话,调用者会担心函数validateStudent()会不会改变它们传入的那个Student。
第二,以传引用方式传递参数也可以避免对象切割(slicing)问题。对象切割:如果有一个函数的参数类型是基类,这时一个子类对象被值传递进这个函数时,参数初始化所调用的构造函数是基类的,子类所衍生的特性就全部被"切割"掉了,在这个函数里你就只剩下一个基类对象。如下例所示:
1// 基类 2class Window{ 3public: 4 // ... 5 std::string name() const; // 返回窗口名称 6 virtual void display() const; // 显示窗口和内容 7}; 8 9// 子类 10class WindowWithScrollBars: public Window{ 11public: 12 // ... 13 virtual void display() const; 14};
从上面的代码段中可以看出,所有Window对象都带有一个名称,可以通过函数name()获得它。所有窗口都可以显示,可以通过函数display()完成它。display()是一个虚函数,这意味着它的在基类Window对象的显示方式与子类WindowWithScrollBars对象的显示方式不同。现在,假设你希望写一个函数打印窗口名称,然后显示该窗口。下面的代码就是错误的,会产生对象切割问题。
1void printNameAndDisplay(Window w){ // 传值方式,参数可能会被切割 2 std::cout << w.name(); 3 w.display(); 4}
当你调用上面的函数printNameAndDisplay()并传递给它一个子类对象(即WindowWithScrollBars对象),会发生什么呢?
1WindowWithScrollBars wwsb; 2printNameAndDisplay(wwsb); // 子类对象的特化信息都会被切除
形参w会被构造成为一个基类对象(Window对象),因为参数是以传值方式传递的,从而造成wwsb作为一个子类对象(WindowWithScrollBars对象)的所有特化信息都会被切除。在函数printNameAndDisplay()内不论传递过来的对象原本是什么类型,参数w就像Window对象。因此,在函数printNameAndDisplay()内调用的总是Window::display(),绝不是WindowWithScrollBars::dispaly()。
解决对象切割问题的方法:以传常引用方式传递w,传进来的窗口是什么类型,w就会表现哪种类型。如下所示:
1void printNameAndDisplay(const Window& w){ // 传常引用方式,参数不会被切割 2 std::cout << w.name(); 3 w.display(); 4}
3.传常引用方式并不能全部替换传值方式
一般来说,C++编译器会把引用作为指针来实现,所以引用传递本质上其实就是指针传递。因此,对于像int这样的内置类型,直接用值传递还是比引用传递要高效。对于STL的迭代器和函数对象,值传递同样也比引用传递高效。
C++由不同的"子语言"组成,每种"子语言"都有自己的高效编程守则,那么在STL和传统的C中,值传递是传递参数更高效的方法。为了遵循这一惯例,STL中的迭代器和函数对象,它们都被设计成值传递更高效,也不用我们去担心对象切割问题。
4.使用值传递还是引用传递与类型大小无关
有些人可能会认为:第一,因为内置类型体积小,所以体积小的对象使用值传递会更高效。这句话并不准确,假如我们定义一个容器类只有一个指针,但这个指针装了许多的对象,那么拷贝这个类的对象就意味着也要拷贝它的指针包含的所有对象,成本也是非常高。
第二,在有些编译器中,内置类型和用户定义类型是被分开处理的。假如你定义一个类,里面只含有一个double数据成员,编译器可能不会把它放在寄存器中。但是,编译器会把同样大小的单个double放进寄存器里,这就会影响速度。如果你不确定编译器到底会怎么做,就使用引用传递。因为引用传递即指针传递,编译器一定会把指针放在寄存器里。
最后,只有对于内置类型、STL的迭代器和函数对象,你才可以认为值传递更高效。对于其它的一切,还是要遵循本文的建议:多用常引用传递,少用值传递。
5.总结
(1) 多用常引用传递,少用值传递。引用传递通常更高效,也能避免对象切割问题。
(2) 以上规则不适用于内置类型、STL的迭代器和函数对象。对它们而言,传值方式往往比较适合。