C++传参

《Efficient C++》第20条:尽量使用“引用常量”传参,而不是传值

默认情况下, C++ 为函数传入和传出对象是采用传值方式的(这是由 C 语言继承而来的特征)。除非你明确使用其他方法,函数的形式参数总会通过复制实在参数的副本来创建,并且,函数的调用者得到的也是函数返回值得一个副本。这些副本是由对象的拷贝构造函数创建的。这使得“传值”成为一项代价十分昂贵的操作。请观察下边的示例中类的层次结构:

class Person {
public:
  Person();                      // 为简化代码省略参数表
  virtual ~Person();             
  ...
private:
  std::string name;
  std::string address;
};
class Student: public Person {
public:
  Student();                     // 再次省略参数表
  virtual ~Student();
  ... 
private:
  std::string schoolName;
  std::string schoolAddress;
};
请观察下面的代码,这里我们调用一个名为 validateStudent 的 函数,通过为这一函数传进一个 Student 类型的参数(传值方式),它将返回这一学生的身份是否合法:

bool validateStudent(Student s);// 通过传值方式接受一个 Student 对象
Student plato;                  // 柏拉图是苏格拉底的学生
bool platoIsOK = validateStudent(plato);   // 调用这一函数
在这个函数被调用时将会发生些什么呢?
很显然地,在这一时刻,通过调用 Student 的拷贝构造函数,可以将这一函数 的 s 参数初始化为 plato 的值 。同样显然的是, s 在 validateStudent 返回的时候将被销毁。所以这一函数中传参的开销就是调用一次 Student 的拷贝构造函数和一次 Student 的析构函数。但是上边的分析仅仅是冰山一角。一个 Student 对象包含两个 string 对象,所以每当你构造一个 Student 对象时,你都必须构造两个 string 对象。同时,由于 Student 类是从 Person 类继承而来,所以在每次构造 Student 对象时,你都必须再构造一个 Person 对象。一个 Person 对象又包含两个额外的 string 对象,所以每次对 Person 的构造还要进行额外的两次 string 的构造。最后的结果是,通过传值方式传递一个 Student 对象会引入以下几个操作:调用一次 Student 的拷贝构造函数,调用一次 Person 的拷贝构造函数,调用四次 string 的拷贝构造函数。在 Student 的这一副本被销毁时,相应的每次构造函数调用都对应着一次析构函数的调用。因此我们看到:通过传值方式传递一个 Student 对象总体的开销究竟有多大?调用六次构造函数和六次析构函数!

下面向你介绍正确的方法,这一方法才会使函数拥有期望的行为。毕竟你期望的是所有对象以可靠的方式进行初始化和销毁。然而,如果可以绕过所有这些构造函数和析构函数将是件很惬意的事情。那就是:通过引用常量传递参数:

bool (const Student& s);
这样做 效率会提高很多:由于不会创建新的对象,所以就不会存在构造函数或析构函数的调用。改进的参数表中的 const 是十分重要的。由于早先版本的 validateStudent 通过传值方式接收 Student 参数,所以调用者了解:无论函数对于传入的 Student 对象进行什么样的操作,都不会对原对象造成任何影响, validateStudent 仅仅会对对象的副本进行修改。而改进版本中 Student 对象是以引用形式传入的,有必要将其声明为 const 的,因为如果不这样,调用者就需要关心传入 validateStudent 的 Student 对象有可能会被修改。


通过引用传参也可以避免“ 截断问题”。当一个派生类的对象以一个基类对象的形式传递(传值方式)时,基类的拷贝构造函数就会被调用,此时,这一对象的独有特征——使它区别于基类对象的特征会被“截掉”。剩下的只是一个简单的基类对象,这并不奇怪,因为它是由基类构造函数创建的。这肯定不是你想要的。请看下边的示例,假设你正在使用一组类来实现一个图形视窗系统:

class Window {
public:
  ...
  std::string name() const;           // return name of window
  virtual void display() const;       // draw window and contents
};
 
class WindowWithScrollBars: public Window {
public:
  ...
  virtual void display() const;
};
所有的 Window 对象都有一个名字,可以通过 name 函数取得这个名字,所有的视窗可以被显示出来,可以通过调用 display 实现。 display 是虚函数,这一点告诉你简单基类 Window 的对象与派生出的 WindowWithScrollBars 对象的显示方式是不一样的。
现在,假设你期望编写一个函数来打印出当前窗口的名字然后显示这一窗口。下面是错误的实现方法:

void printNameAndDisplay(Window w)// 错误 ! 参数传递的对象将被截断。
{
  std::cout << w.name();
  w.display();
}
考虑一下当你将一个 WindowWithScrollBars 对象传入这个函数时将会发生些什么:

WindowWithScrollBars wwsb;
 
printNameAndDisplay(wwsb);
参数 w 将被构造——还记得么?它是通过传值方式传入的——就像一个 Window 对象,使 wwsb 具体化的独有信息将被截掉。无论传入函数的对象的具体类型是什么,在 printNameAndDisplay 的内部, w 将总保有一个 Window 类的对象的身份(因为它本身就是一个 Window 的对象)。特别地,在 printNameAndDisplay 内部对 display 的调用总会是 Window::display ,而永远不会是 WindowWithScrollBars::display 。

解决截断问题的方法是引用常量传参:
void printNameAndDisplay(const Window& w)
{                               // 工作正常,参数将不会被截断。
  std::cout << w.name();
  w.display();
}
现在 w 的类型就是传入视窗对象的精确类型。


揭开 C++ 编译器的面纱,你将会发现引用通常情况下是以指针的形式实现的,所以通过引用传递通常意味着实际上是在传递一个指针。
因此,如果传递一个内建数据类型的对象(比如 int ),传值会被传递引用更为高效。那么,对于内建数据类型,当你在传值和传递常量引用之间徘徊时,传值方式不失为一个更好的选择。迭代器 和 STL 中的函数对象都是如此,这是因为它们设计的初衷就是更适于传值,这是 C++ 的惯例。实现迭代器和函数对象的人员有责任考虑复制时的效率问题和截断问题。(这就是一个“使用哪种规则,取决于当前使用哪一部份的 C++ ”,参见第 1 条)

内建数据类型体积较小,所以一些人得出这样的结论:所有体积较小的类型都适合使用传值,即使它们是用户自定义的。这是一个不可靠的推理。仅仅通过一个对象体积小并不能判定调用它的拷贝构造函数的代价就很低。许多对象——包括大多数 STL 容器——其中仅仅包含一个指针和很少量的其它内容,但是复制这样的对象的同时,它所指向的所有内容都需要复制。这将会是一件十分昂贵的事情。

即使体积较小的对象的拷贝构造函数不会带来昂贵的开销,它也会引入性能问题。一些编译器对内建数据类型和用户自定义数据类型是分别对待的,即使它们的原始表示方式完全相同。比如说一些编译器很乐意将一个单纯的 double 值放入寄存器中,这是语言的常规,但将仅包含一个 double 值的对象放入寄存器时,编译器就会报错了。当你遇到这种事情时,你可以使用引用传递这类对象,因为编译器此时一定会将指针(引用的具体实现)放入寄存器中。

小型用户自定义数据类型不适用于传值方式还有一个理由,那就是:作为用户自定义类型,它们的大小并不是固定的。现在很小的类型在未来的版本中可能会变得很大,这是因为它的内部实现方式可能会改变。即使是你更改了 C++ 语言的具体实现都可能会影响到类型的大小。比如,在我编写上面的示例的时候,一些对标准库中 string 实现的大小竟然达到了另一些的七倍。

总体上讲,只有内建数据类型、 STL 迭代器和函数对象类型适用于传值方式。对于所有其它的类型,都应该遵循本条款中的建议:使用引用常量传参,而不是传值。


牢记在心

l  尽量使用引用常量传参,而不是传值方式。因为传引用更高效,而且可以避免“截断问题”。

l  对于内建数据类型、 STL 迭代和函数对象类型,这一规则就不适用了,对它们来说通常传值方式更实用。


指针和引用的区别

首先,引用不可以为空,但指针可以为空。引用是对象的别名,引用为空——对象都不存在,怎么可能有别名!故定义一个引用的时候,必 须初始化。因此如果你有一个变量是用于指向另一个对象,但是它可能为空,这时你应该使用指针;如果变量总是指向一个对象,i.e.,你的设计不允许变量为 空,这时你应该使用引用。而声明指针是可以不指向任何对象,也正是因为这个原因,使用指针之前必须做判空操作,而引用就不必。

其次,引用不可以改变指向,对一个对象"至死不渝";但是指针可以改变指向,而指向其它对象。说明:虽然引用不可以改变指向,但是可以改变初始 化对象的内容。例如就++操作而言,对引用的操作直接反应到所指向的对象,而不是改变指向;而对指针的操作,会使指针指向下一个对象,而不是改变所指对象 的内容。

再次,引用的大小是所指向的变量的大小,因为引用只是一个别名而已;指针是指针本身的大小,4个字节。

引用比指针使用起来形式上更漂亮,使用引用指向的内容时可以之间用引用变量名,而不像指针一样要使用*;定义引用的时候也不用像指针一样使用&取址。

最后,引用比指针更安全。由于不存在空引用,并且引用一旦被初始化为指向一个对象,它就不能被改变为另一个对象的引用,因此引用很安全。对于指针 来说,它可以随时指向别的对象,并且可以不被初始化,或为NULL,所以不安全。const 指针虽然不能改变指向,但仍然存在空指针,并且有可能产生野指针(即多个指针指向一块内存,free掉一个指针之后,别的指针就成了野指针)。

总而言之,言而总之——它们的这些差别都可以归结为"指针指向一块内存,它的内容是所指内存的地址;而引用则是某块内存的别名,引用不改变指向。"


const对指针和引用的限定是有差别的


常量指针VS常量引用


常量指针:指向常量的指针,在指针定义语句的类型前加const,表示指向的对象是常量。
定义指向常量的指针只限制指针的间接访问操作,而不能规定指针指向的值本身的操作规定性。

常量指针定义"const int* pointer=&a"告诉编译器,*pointer是常量,不能将*pointer作为左值进行操作。

常量引用:指向常量的引用,在引用定义语句的类型前加const,表示指向的对象是常量。也跟指针一样不能利用引用对指向的变量进行重新赋值操作。


指针常量VS引用常量


在指针定义语句的指针名前加const,表示指针本身是常量。在定义指针常量时必须初始化!而这是引用天生具来的属性,不用再引用指针定义语句的引用名前加const。
指针常量定义"int* const pointer=&b"告诉编译器,pointer是常量,不能作为左值进行操作,但是允许修改间接访问值,即*pointer可以修改。


常量指针常量VS常量引用常量


常量指针常量:指向常量的指针常量,可以定义一个指向常量的指针常量,它必须在定义时初始化。常量指针常量定义"const int* const pointer=&c"告诉编译器,pointer和*pointer都是常量,他们都不能作为左值进行操作。
而就不存在所谓的"常量引用常量",因为跟上面讲的一样引用变量就是引用常量。C++不区分变量的const引用和const变量的引用。程序决不 能给引用本身重新赋值,使他指向另一个变量,因此引用总是const的。如果对引用应用关键字const,起作用就是使其目标称为const变量。即没 有:Const double const& a=1;只有const double& a=1;

总结:有一个规则可以很好的区分const是修饰指针,还是修饰指针指向的数据——画一条垂直穿过指针声明的星号(*),如果const出现在线的左边,指针指向的数据为常量;如果const出现在右边,指针本身为常量。而引用本身与天俱来就是常量,即不可以改变指向。


从概念上讲。指针从本质上讲就是存放变量地址的一个变量,在逻辑上是独立的,它可以被改变,包括其所指向的地址的改变和其指向的地址中所存放的数据的改变。
而引用是一个别名,它在逻辑上不是独立的,它的存在具有依附性,所以引用必须在一开始就被初始化,而且其引用的对象在其整个生命周期中是不能被改变的(自始至终只能依附于同一个变量)。
在C++中,指针和引用经常用于函数的参数传递,然而,指针传递参数和引用传递参数是有本质上的不同的:
指针传递参数本质上是值传递的方式,它所传递的是一个地址值。值传递过程中,被调函数的形式参数作为被调函数的局部变量处理,即在栈中开辟了内存空间以存放由主调函数放进来的实参的值,从而成为了实参的一个副本。值传递的特点是被调函数对形式参数的任何操作都是作为局部变量进行,不会影响主调函数的实参变量的值。(这里是在说实参指针本身的地址值不会变
而在引用传递过程中,被调函数的形式参数虽然也作为局部变量在栈中开辟了内存空间,但是这时存放的是由主调函数放进来的实参变量的地址。被调函数对形参的任何操作都被处理成间接寻址,即通过栈中存放的地址访问主调函数中的实参变量。正因为如此,被调函数对形参做的任何操作都影响了主调函数中的实参变量。
引用传递和指针传递是不同的,虽然它们都是在被调函数栈空间上的一个局部变量,但是任何对于引用参数的处理都会通过一个间接寻址的方式操作到主调函数中的相关变量。而对于指针传递的参数,如果改变被调函数中的指针地址,它将影响不到主调函数的相关变量(即不能改变指针本身,指针所指向的变量值是可以修改的;而引用时可以修改地址值的!)。如果想通过指针参数传递来改变主调函数中的相关变量,那就得使用指向指针的指针,或者指针引用。


指针和引用的编译器行为

#include<iostream>

using namespace std;

int main(int argc, char** argv)

{

int i=1;

int& ref=i;

int x=ref;

cout<<"x is "<<x<<endl;

 
ref=2;

int* p=&i;

cout<<"ref = "<<ref<<", i = "<<i<<endl;

}
上面的代码用g++ test.c编译之后,然后反汇编objdump -d a.out,得到main函数的一段汇编代码如下:

08048714 <main>:

8048714: 55    push %ebp

8048715: 89 e5   mov %esp,%ebp

8048717: 83 e4 f0        and $0xfffffff0,%esp//为main函数的参数argc、argv保留位置

804871a: 56            push %esi

804871b: 53            push %ebx

804871c: 83 ec 28        sub $0x28,%esp

804871f: c7 44 24 1c 01 00 00 movl $0x1,0x1c(%esp) //将0x1存到esp寄存器中,即int i=1

8048726: 00

8048727: 8d 44 24 1c  lea 0x1c(%esp),%eax// esp寄存器里的变量i的地址传给eax

804872b: 89 44 24 18    mov %eax,0x18(%esp)//将寄存器eax中的内容(i的地址)传给寄存器中的变量ref,即int& ref=i

804872f: 8b 44 24 18        mov 0x18(%esp),%eax//将寄存器esp中的ref传给eax,即i的地址

8048733: 8b 00        mov (%eax),%eax//以寄存器eax中的值作为地址,取出值给eax 8048735: 89 44 24 14        mov %eax,0x14(%esp) //将寄存器eax中的值传给寄存器esp中的x,即x=ref

8048739: c7 44 24 04 00 89 04     movl $0x8048900,0x4(%esp)

8048740: 08

8048741: c7 04 24 40 a0 04 08    movl $0x804a040,(%esp)

8048748: e8 cb fe ff ff    call 8048618 <_ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKc@plt>

804874d: 8b 54 24 14    mov 0x14(%esp),%edx

8048751: 89 54 24 04        mov %edx,0x4(%esp)

8048755: 89 04 24        mov %eax,(%esp)

8048758: e8 5b fe ff ff    call 80485b8 <_ZNSolsEi@plt>

804875d: c7 44 24 04 38 86 04    movl $0x8048638,0x4(%esp)

8048764: 08

8048765: 89 04 24        mov %eax,(%esp)

8048768: e8 bb fe ff ff    call 8048628 <_ZNSolsEPFRSoS_E@plt>//从8048739~8048768这些行就是执行"cout<<"x is "<<x<<endl;"

804876d: 8b 44 24 18    mov 0x18(%esp),%eax//将寄存器esp中的ref传到eax中

8048771: c7 00 02 00 00 00    movl $0x2,(%eax) //将0x2存到eax寄存器中

8048777: 8d 44 24 1c        lea 0x1c(%esp),%eax// esp寄存器里的变量i的地址传给eax

804877b: 89 44 24 10    mov %eax,0x10(%esp) //将寄存器eax中的内容(即i的地址)传到寄存器esp中的p

804877f: 8b 5c 24 1c        mov 0x1c(%esp),%ebx

8048783: 8b 44 24 18    mov 0x18(%esp),%eax

8048787: 8b 30        mov (%eax),%esi

8048789: c7 44 24 04 06 89 04    movl $0x8048906,0x4(%esp)

8048790: 08

8048791: c7 04 24 40 a0 04 08    movl $0x804a040,(%esp)

8048798: e8 7b fe ff ff    call 8048618 <_ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKc@plt>

804879d: 89 74 24 04    mov %esi,0x4(%esp)

80487a1: 89 04 24        mov %eax,(%esp)

80487a4: e8 0f fe ff ff    call 80485b8 <_ZNSolsEi@plt>

80487a9: c7 44 24 04 0d 89 04    movl $0x804890d,0x4(%esp)

80487b0: 08

80487b1: 89 04 24        mov %eax,(%esp)

80487b4: e8 5f fe ff ff     call 8048618 <_ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKc@plt>

80487b9: 89 5c 24 04        mov %ebx,0x4(%esp)

80487bd: 89 04 24        mov %eax,(%esp)

80487c0: e8 f3 fd ff ff    call 80485b8 <_ZNSolsEi@plt>

80487c5: c7 44 24 04 38 86 04    movl $0x8048638,0x4(%esp)

80487cc: 08

80487cd: 89 04 24        mov %eax,(%esp)

80487d0: e8 53 fe ff ff    call 8048628 <_ZNSolsEPFRSoS_E@plt>//这些行就是执行"cout<<"ref = "<<ref<<", i = "<<i<<endl;"

80487d5: b8 00 00 00 00    mov $0x0,%eax

80487da: 83 c4 28        add $0x28,%esp

80487dd: 5b            pop %ebx

80487de: 5e            pop %esi

80487df: 89 ec        mov %ebp,%esp

80487e1: 5d            pop %ebp

80487e2: c3            ret

从汇编代码可以看出实际上指针和引用在编译器中的实现是一样的:

  • 引用int& ref=i; 

8048727: 8d 44 24 1c        lea 0x1c(%esp),%eax// esp寄存器里的变量i的地址传给eax

804872b: 89 44 24 18    mov %eax,0x18(%esp)//将寄存器eax中的内容(i的地址)传给寄存器中的变量ref,即int& ref=i

  • 指针int* p=&i;

8048777: 8d 44 24 1c        lea 0x1c(%esp),%eax// esp寄存器里的变量i的地址传给eax

804877b: 89 44 24 10    mov %eax,0x10(%esp) //将寄存器eax中的内容(即i的地址)传到寄存器esp中的p

虽然指针和引用最终在编译中的实现是一样的,但是引用的形式大大方便了使用也更安全。有人说:"引用只是一个别名,不会占内存空间?"通过这个事实我们可以揭穿这个谎言!实际上引用也是占内存空间的。




  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值