C++ 中为什么要有“ 指针” 和 “ 引用”

推荐阅读
1.《C++语言的设计与演化》
2. <<more effective C++>>

1. 引言

1.1 对象:

C++primer中, 对象的定义:
对象是指一块能存储数据并具有某种类型的内存空间;

一个对象a,它有值和地址&a,

运行程序时,计算机会为该对象分配存储空间,来存储该对象的值,我们通过该对象的地址,来访问存储空间中的值;

指针p也是对象,它同样有地址&p和存储的值p,只不过,p存储的数据类型是数据的地址。

如果我们要以p中存储的数据为地址,来访问对象的值,则要在p前加解引用操作符"",即p。

1.2. 常量和 变量之分:

对象有常量(const)和变量之分,
既然指针本身是对象,那么指针所存储的地址也有常量和变量之分;

指针常量是指,指针这个对象所存储的地址是不可以改变的
而指向常量的指针的意思是,不能通过该指针来改变这个指针所指向的对象。

我们可以把引用理解成变量的别名。定义一个引用的时候,程序把该引用和它的初始值绑定在一起,而不是拷贝它。

计算机必须在声明 引用 r的同时就要对它初始化,并且,r一经声明,就不可以再和其它对象绑定在一起了。

int a,b,*p,&r=a;//正确
r=3;//正确:等价于a=3
int &rr;//出错:引用必须初始化
p=&a;//正确:p中存储a的地址,即p指向a
*p=4;//正确:p中存的是a的地址,对a所对应的存储空间存入值4
p=&b//正确:p可以多次赋值,p存储b的地址

实际上,引用是一个指针常量: 指向的地址不变, 地址指向的内容可以改变;
引用的一个优点是它一定不为空,因此相对于指针,它不用检查它所指对象是否为空,这增加了效率。

1.3 指针

指针:它保存一个值(或 null)的地址,可以检索指向该地址处的值 。

  1. 指针定义时,可以不用初始化;
  2. 指针可以初始化为NULL,
  3. 指针的在初始化后, 其地址值仍可以改变, 用于存储另外一个地址;

1.4 引用

引用是C++语言的基础数据类型,
用于在程序的不同的部分使用两个以上的变量名指向同一块地址,
使得对其中任何一个变量的操作实际上都是对同一地址单元进行的。

引用的特点:

  1. 引用仅是变量的别名,而不是实实在在地定义了一个变量,因此引用本身并不占用内存
    引用,表明上是为变量创建了第二个名字, 它的本质是一个指针,和目标变量的是同一个内存地址.
    声明引用时,目标的存储状态不会改变;

  2. 引用在定义时,必须初始化, 且不能初始化为空;
    只有在引用申明时, “&” 才称作引用符,且放在类型名后面;
    其他时候出现的 “&”, 都是指 取地址操作符;。

  3. 引用只能在初始化的时候引用一次 ,不能更改为转而引用其他变量。

  4. 对引用进行操作,实际上就是对被引用的变量进行操作;

1. C++ 中为什么要有 “指针”

1.1 精准控制了内存

指针可以精准控制了内存中的地址,从而高效率的传递和更改数据;

因为指令集里面有一种间接寻址的方式,抽象出来就是指针了;

  1. 指针有很强的灵活性,可以根据地址进行访问, 从而达到对内存的精准控制;

先说说一般考虑使用指针的情况:需要对同一份数据进行操作。而操作又分为读和写两种。

  1. 如果对数据只是读操作的话,其实将数据复制一份去读和直接读,结果是没什么区别的,但是过程就不一样了,很明显,复制会浪费一些时间,直接读肯定会更快一些,所以,对于只读的情况来说,使用指针是为了更高效的进行数据的传递。

  2. 而如果涉及到对于数据的写操作(包括只写和既写又读),将数据复制一份和直接操作,肯定就有区别了,区别就在于,复制一份的话,副本更改了,原本是不会更改的,所以,对于需要写数据的情况来说,使用指针是为了更改同一份数据。

1.1.1 函数的参数中传递指针 (引用也是一种非空指针)

举例讲来:

变量为了表示数据而生, 指针为了传递数据为生。

变量:
一个东西在内存里面,而你想用语言去表示那个东西,就必须找到一个表示它。于是我们用常量或变量去表示内存里的值。
比如,

 int a = 2;  

就是把2这个值,放在了内存中。**(但是你不知道它的位置,如果你有看到整个内存的能力,你有可能发现有一个2在No.300处)**并且你想要在程序中调用它,就必须有一个东西代表它,于是用变量a代表了这块内存中的内容.有了变量,你就可以用他表示一个值。从而你可以使用这个变量。

指针:
如果你只有这一行程序的话,那指针就没有太大的存在必要了。

但是如果你有好几个函数需要读写这个值,那么这时候问题就来了。

int myMoney = 1000;

如果你的账上有1000元,有好几个函数要操作这个值,这时候就会产生两种需求, 读和写。
在函数里修改这个值的时候是应该修改原值呢? 还是不修改原值?

如果使用foo(myMoney)这种形式的话,就会把myMoney代表的内存中的内容“复制”一份到函数栈里,这样你在函数里修改这个值不会对外界有任何影响。
但是,如果你想在函数中对原值进行操作,这时候就不能只传进来内容,而需要传进来一个myMoney的地址,这样,在函数里面就能再程序找到那块地址,把内容修改掉。

所以有了传递地址的需求。为了方便传递地址,所以有了指针,指针也是一个变量,只不过里面存的内容是一个地址。

1.2 指针的使用场景

当然,不使用动态分配而采取原始指针(raw pointer)的用法也很常见,但是大多数情况下动态分配可以取代指针,因此一般情况应该首选动态分配的方法,除非你遇到不得不用指针的情况。

  1. 使用引用语义(reference semantics)的情况。有时你可能需要通过传递对象的指针(不管对象是如何分配的)以便你可以在函数中去访问/修改这个对象的数据(而不是它的一份拷贝),但是在大多数情况下,你应该优先考虑使用引用方式,而不是指针,因为引用就是被设计出来实现这个需求的。注意,采用这种方式,对象生存期依旧在其作用域内自维护。当然,如果通过传递对象拷贝可以满足要求的情况下是不需要使用引用语义。

  2. 使用多态的情况。通过传递对象的指针或引用调用多态函数(根据入参类型不同,会调用不同处理函数)。如果你的设计就是可以传递指针或传递引用,显然,应该优先考虑使用传递引用的方式。

  3. 对于入参对象可选的情况,常见的通过传递空指针表示忽略入参。如果只有一个参数的情况,应该优先考虑使用缺省参数或是对函数进行重载。要不然,你应该优先考虑使用一种可封装此行为的类型,比如 boost::optional或者std::optional

  4. 通过解耦编译类型依赖减少编译时间的情况。使用指针的一个好处在于可以用于前向声名(forward declaration)指向特定类型(如果使用对象类型,则需要定义对象),这种方式可以减少参与编译的文件,从而显著地提高编译效率,具体可以看 Pimpl idiom 用法。

  5. 与C库或C风格的库交互的情况。此时只能够使用指针,这种情况下,你要确保的是指针使用只限定在必要的代码段中。指针可以通过智能指针的转换得到,比如使用智能指针的get成员函数。如果C库操作分配的内存需要你在代码中维护并显式地释放时,可以将指针封装在智能指针中,通过实现 deleter 从而可以有效的地释放对象。

1.3 何时使用动态分配 与指针

问题描述
我在使用 C++ 进行面向对象开发时,我发现一个很让我非常困惑的问题:C++ 中经常出现使用对象指针,而不是直接使用对象本身的代码,比如下面这个例子:

Object *myObject = new Object;

而不是使用:

Object myObject;

要不就是调用对象的方法(比如 testFunc())时不使用这种方式:

myObject.testFunc();

而是得写成这样:

myObject->testFunc();

我不明白代码为什么要写成这种形式,我能想到的是指针方式是直接访问内存,这么写代码可以提高代码效率以及执行速度,是这样的么?

最佳回复来自 Joseph Mansfield

非常不幸,你在代码中遇到这么多的动态内存分配,但这个只能说明有现在有太多不合格的 C++ 程序员。

这么说吧,你的两个问题本质上是同个问题。
第一个问题是,应该何时使用动态分配(使用 new 方法)?
第二问题是,什么时候该使用指针?

最先要牢记的重点是,你应该根据实际需求选择合适的方法。
一般来说,使用定义对象的方式比起使用手工动态分配(或new指针)的方式会更加合理以及安全。

动态分配:
你的提问中,所列出的两种分配对象方式的主要区别在于对象的生存期。通过 Object myObject 方式定义对象,对象的生存期是在其作用域内自维护(automatic storage),这个意味着程序离开对象的作用域之后,对象将被自动销毁。当通过 new Object() 方式分配对象时,对象的生存期是动态的,这个意味着若不显式地 detete 对象,对象将一直存在。你应该只在必要的时候使用动态分配对象。换句话说,只要有可能,你应该首选定义可自维护的对象。

这里是两个常见需要动态分配对象的情况:

  1. 分配不限制作用域的对象,对象存储在其特定的内存中,而不是在内存中存储对象的拷贝。如果对象是可以拷贝/移动的,一般情况下你应该选择使用定义对象的方式。

  2. 定义的对象会消耗大量内存, 这时可能会耗尽栈空间。如果我们永远不需要考虑这个问题那该多好(实际大部分情况下,我们真不需要考虑),因为这个本身已经超出 C++ 语言的范畴,但不幸的是,在我们实际的开发过程中却不得不去处理这个问题。

当你确实需要动态分配对象时,应该将对象封装在一个智能指针(smart pointer)或其他提供RAII机制的类型中(类似标准的 container)。智能指针提供动态对象的所有权语义(ownership),具体可以看一下std::unique_ptr 和 std::shared_ptr 这两个例子。如果你使用得当,基本上可以避免自己管理内存(具参见 Rule of Zero)。

2. C++ 中为什么要有 “引用”

C++ 中引用的出现,最初是为了解决, 运算符重载的 问题;

假设没有引用,那么,用指针来operator overloading操作。 

 A operator +(const A *a, const A *_a);

那么使用的时候,&a + &b, 这样看起来不是那样简单明了。
引用解决C++运算符重载后代码美观的问题。

并且引用的 出现带来了 一个指针无法替代的特性: 引用临时对象;
引用在 定义的时候必须就 赋值,并且此后再也无法更改;

2.1 引用的特性

并且在实践中:
使用指针,经常出现一下情况:

  1. 操作空指针
  2. 操作野指针
  3. 不知不觉中, 手动改变了指针的值,随后以为该指针正常,关键本人不知道;

为了设立一种机制, 保证上述三种情况的不发生, “ 引用”这个机制保证避免发生上述三种情况;

引用:

  1. 引用不允许为空 (从而避免了操作空指针)
  2. 引用在定义的时候,就必须初始化(避免了操作 野指针)
  3. 一个引用 ,始终从一而终的指向他 初始化的那个对象 ( 避免了指针的值不会被修改)

2.2 在函数参数中的引用传递

  1. 引用作为函数参数:
    C++ 支持把引用作为参数传给函数, 这比传一般的参数更安全;

//函数参数的传递方式:值传递,地址传递,引用(c++),如何选择呢?

//看是否要修改值,要修改,用指针或者引用,不必修改,则用值传递

//对于指针和引用,看传进来的是不是地址或者时new 出来的,是则选指针,不是则选择引用

(1)传递引用给函数与传递指针的效果是一样的。这时,被调函数的形参就成为原来主调函数中的实参变量或对象的一个别名来使用,所以在被调函数中对形参变量的操作就是对其相应的目标对象(在主调函数中)的操作。

(2)使用引用传递函数的参数,在内存中并没有产生实参的副本,它是直接对实参操作
  而使用一般变量传递函数的参数,当发生函数调用时,需要给形参分配存储单元,形参变量是实参变量的副本;
  如果传递的是对象,还将调用拷贝构造函数。
  因此,当参数传递的数据较大时,用引用比用一般变量传递参数的效率和所占空间都好。

(3)使用指针作为函数的参数虽然也能达到与使用引用的效果,但是,在被调函数中同样要给形参分配存储单元,且需要重复使用"*指针变量名"的形式进行运算,这很容易产生错误且程序的阅读性较差;

另一方面,在主调函数的调用点处,必须用变量的地址作为实参。而引用更容易使用,更清晰。

如果既要利用引用提高程序的效率,又要保护传递给函数的数据不在函数中被改变,就应使用常引用。

2.3 引用作为函数的返回值

(1)以引用返回函数值,定义函数时需要在函数名前加&

(2)用引用返回一个函数值的最大好处是,在内存中不产生被返回值的副本。

引用作为返回值,必须遵守以下规则:

  • 1**.不能返回局部变量的引用。**
    主要原因是局部变量会在函数返回后被销毁,因此被返回的引用就成为了"无所指"的引用,程序会进入未知状态。

  • 2.不能返回函数内部new分配的内存的引用
    例如,被函数返回的引用只是作为一个临时变量出现,而没有被赋予一个实际的变量,那么这个引用所指向的空间(由new分配)就无法释放,造成memory leak。

  • 3.可以返回类成员的引用,但最好是const
    这条原则可以参照Effective C++[1]的Item 30。主要原因是当对象的属性是与某种业务规则(business rule)相关联的时候,其赋值常常与某些其它属性或者对象的状态有关,因此有必要将赋值操作封装在一个业务规则当中。如果其它对象可以获得该属性的非常量引用(或指针),那么对该属性的单纯赋值就会破坏业务规则的完整性。

  • 4.引用与一些操作符的重载:流操作符<<和>>,这两个操作符常常希望被连续使用,例如:cout << “hello” << endl; 因此这两个操作符的返回值应该是一个仍然支持这两个操作符的流引用。

3. 后继: 指针的故事与由来

指令集里面有一种间接寻址的方式,抽象出来就是指针了

我年纪太小,不知道历史上这一切如何上演,所以或有出入,但并不影响解答此问。

0)史前早期的CPU

那个时候没有变量这一说,所有的内存读写都得指定好常数,也就是得把具体的数字(也称为字面量,literal)写死在程序里。

(也许并没有真正的实现)并不如今天的强大,内存读写的指令可能只有

“从常数0x1234地址处读入1字节到寄存器a”,或者“把寄存器b的值写入常数地址0x5678这个地方”。

你如果想清空100字节的内存,而每条指令只能对内存中某一个字节进行写入,那就得写100条指令。随着要处理的数据的膨胀,程序也得跟着膨胀,而这是不可接受的。

1)流程跳转

为了解决这个问题,人们发明了控制流程的指令,比如“如果寄存器c的值为0,则略过下一条指令”和“无条件跳转到首地址为0x9012”的地方,从那个地方继续运行”。注意,**指令本身也是编码成字节存在内存里的,这就是冯诺伊曼机器最优雅的地方。**这时候我们可以写出循环了(这里不展开,感觉没必要)。

2)自修改程序

问题并没有解决,因为就算有了循环,整个程序还是只能写常量,所以如果想要对不同的内存块清零,只能写不同的指令。但是人是很聪明的。利用“指令本身也是编码成字节存在内存里”这个特征,我们可以通过修改内存来修改指令,继而修改行为。

举例来说,在0x3454处存了一条指令,这条指令叫做“把0x5678开始的一字节清零”。
比方说这条指令占用了三个字节,第一个字节告诉CPU这是一条清零指令,后两个字节(0x3455和0x3456)存了一个表示地址的整数,告诉CPU到底要把哪个字节清零。

显然,这个整数会和0x5678有关,而且大多情况就是0x5678。

现在,要是CPU在某处执行了一句“把起始地址为0x3455的那个2字节数自增1”,那实际上0x3454处的指令就变成了“把0x5679开始的一字节清零”!

看到希望了么,我们可以写一个指令,然后不断修改这个指令,再加上流程控制的指令产生一个循环,就能用一段固定长度(注意,代码内容随着执行并不固定)的代码清零任意指定的一段内存块!这叫做“自修改程序”。

3)间接寻址

后来的事情就很简单了。编写这种自修改程序极容易出错,因为稍微改错一个地方指令就全改乱了(比如在上例中如果把0x3454中的数字给改了,就完全变成了另一条指令),所以人们再次发明新的工具,叫间接寻址。

所以有了这样的指令“把寄存器a存的数字当成地址,取出该地址处的字节放到寄存器b里”和“把寄存器a存的数字当成地址,把寄存器b的字节写入到该地址处”。

回到自修改程序的那个例子里,
我们可以把“把0x5678开始的一字节清零”换成:

“把寄存器b设为0”
“把0x5678这个数字写到0x1234处”
“读入0x1234的数字到寄存器a”

“把寄存器a存的数字(此处即0x5678)当成地址,把寄存器b(此处为0)的字节写入到该地址处”

这样实现虽然看起来费事,但是总算得到了等价的功能。

另外,我们以后只需要读写0x1234这个内存就能达到改变行为的目的,而不要冒险去修改指令本身了。

换言之,指令本身被固化,其行为更加稳定。今天,代码虽然也在内存里,也编码成了一个个字节,但是一般不和数据放在一起,而且一般执行的时候是只读的。

假设程序员无限聪明(当然这种好事从来就没有发生过:),写的代码从来不出错,那么间接寻址是没有必要的,因为直接写自修改程序就行了。间接寻址没有增加任何新的功能,这点不像跳转指令。

4)指针

现在可能解释指针就很简单了,指针就是间接寻址例子里面那个0x1234的内存块。它存了一片地址,而指针解引用(比如*p)就对应的是间接寻址读写的指令了。所以说到最后,“为什么要有指针的”就可以化成“为什么要有间接寻址”,问题基本等价于“为什么不直接使用自修改程序”了

------------------------------------- 分割线 --------------------------------

刚接触c的时候也觉得指针有卵用,我要用哪个变量的时候直接写名字不就行了,比如将a的值加一,直接a+1不就好了吗。

第一次觉得指针这东西有点意思是学习数组的时候,原来遍历数组只需要给下标加一再循环就行了,而不用a1如何a2如何,题主应该知道,其实数组下标就是一种指针,给下标加一,其实就是给指针的值加一,即指向下一个地址,这就省去了很多功夫。

第二次觉得指针有趣,是在调用子函数的时候,由于c语言是按值传送的,所以在子函数中对传入参数的任何操作在返回时都无效了,要想在子函数中对参数操作,只能传入参数的指针,才能操作参数,这也是为什么传入参数为数组时,对参数的操作会影响数组,因为数组就是一种指针。而要修改指针的值时,甚至需要二级指针,或者将指针的地址传入。

第三次是彻底爱上指针,是学习链表时,被那种结构体通过指针环环相连的美感彻底征服,在茫茫内存海中,是指针将我们彼此相连,无论我被分配到了哪里,你都能通过指针找到我,这是我真正觉得自己理解了指针。

当需要自己管理内存。

C/C++这类编程语言目标是更高的效率,所以在设计上更接近计算机本身而不是开发者这方面,很多时候他们更多的考虑到计算机有什么而不是人需要什么,指针是计算机储存物理地址的变量,如果你想得到更高的效率,一定要通过指针来自己管理内存。

假设C/C++没有指针的概念,你会发现没办法分配和释放内存上面的数据了。但是其他没有指针的编程语言,提供了自己的内存管理机制也就是所谓的GC,指针还在,不过是系统帮你维护了而已。这些高级语言的开发者可以忽略如何分配释放管理内存的工作,把更多的精力放在如何涉及逻辑本身上面,这是一个很不错的进步。但是记得,这样做牺牲了相应的效率。结论是,指针的存在啊是为了让用户可以管理内存。如果系统接管了对内存的管理,那么语言层面就把指针封装成引用提供给用户。但是,事实上,不论你用还是不用,指针还在那里默默的为你服务。

reference

  1. https://www.zhihu.com/question/26623283
  2. https://zhuanlan.zhihu.com/p/54066512
  3. https://www.zhihu.com/question/37608201
  4. https://www.cnblogs.com/curo0119/p/8530038.html
  • 11
    点赞
  • 37
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值