C++学习笔记(5)——C++复合类型之指针

C++的内置数据类型包括基本类型和复合类型,基本类型也即我们平时常见的整型和浮点型;复合类型包括数组、结构体和指针等。在前两篇笔记中已经对基本类型和复合类型中的数组和结构体进行了总结,本篇笔记总结指针的相关原理和用法。

一.指针基本原理

指针是看似很神秘的存在,其实仅仅是因为,它所表示的意义不是你直观看到的东西,而是它“背后”的东西,仅此而已。

定义了一个简单变量 int a; 程序就为a分配了内存空间,并使得该空间是可以被追溯的。当为a赋值时:a=10;实际上是在a所在的地址内写入“10”。如果我们想知道a的地址是多少,可以用&(取地址符号)得到a的地址。通常地址会以十六进制的形式表达。

反过来,我们可以通过一个地址,来找到内存中的某个具体位置,然后访问到(得到)该位置的值(允许的话)。这就引申出了指针——指针是一个变量,其存储的值是地址。指针这个名字确实折磨过很多人,这个名字是个好名字,同时也是一个非常不好的名字。说它好,是因为指针这个东西很形象的体现了它的功能:指到某个地方某个位置,非常形象。它不是个好名字是因为它的名字有时候掩盖了它的真实含义。指针是一个其值为地址的变量。(就是一个存储地址的变量)所以,要养成一种条件反射,看到指针首先不是想到他能够指向哪里,而是想到这个变量存放的是一个地址,是这个地址指向哪里。

比如:char类型的变量中存放的是char类型的数据;int变量中存放的是int类型的数据;指针中存放的是一个地址。

为何要用地址去获取一个变量而非直接定义一个变量呢?这要讲到C++的OOP的基本原理。OOP(面向对象)强调的是在运行阶段而不是编译阶段进行决策,这大大增加了程序的灵活度。比如声明一个数组,如果在程序中直接定义一个数组,就必须指定其长度,那么程序将在编译阶段就为其分配好内存,这就是所谓的编译阶段决策。这无形中会造成内存资源的浪费。而OOP的方式是在运行阶段分配所需要的内存,并在使用过后将其释放。而指针通过指向某一变量的地址,而不包含长度信息,通过动态地读取后续地址中的内容来获取一个变量,这就是所谓的运行阶段决策的体现。

二.声明指针

编译器如何知道我们定义的是指针而不是简单变量?C++规定用*(与乘号一样,但C++会根据上下文自动判断是什么)来表示“指向”某地址。那么“*”后面的变量就会被判断为指针变量。声明形式如下:

指向地址的数据类型 * 指针变量名;   int * p;

* 被称为间接值运算符或解除引用运算符,但通常被叫做指针符号(也无可厚非)。而数据类型int表明了p所在地址中存储的变量是以int形式存储的(不同的类型字节数不同)。p是一个指针(地址),我们将它称作指向int的指针,我们甚至可以将int* 理解为同基本类型一样的一种变量类型(也即指针类型)。在别人写的代码中,你会看到很多的写法,实本质就是这样。比如写成int *p_a=&a;,*紧跟着p_a,这更接近指针的声明方式,而且强调了*p_a是一个int类型的值。有些人喜欢写成int* p_a=&a;在这里,*号紧挨着int,因为有人理解为int的指针类型int*。注意仅从原理上来说,声明的格式中可以在三者之间存在多个空格或没有空格,这都符合语法规则。通常,C++程序员更倾向于写成int* p_a的形式,认为int* 是c++的一种复合类型。

三.初始化指针

上面的例子int* p_a=&a;其实就是在为指针进行初始化。从原理来看,我们必须清除指针的初始化是给指针赋值一个地址。这个地址通常是unsigned int型,这很容易被错误的判断为是地址中存放的值。如int* p_a=&a;实质上是将p_a(不是*p_a)初始化为a所在的地址(不是a)。

不初始化指针将非常危险。声明一个基本类型的简单变量时,如果没有初始化,它的值将是编译器编译时所分配的对应内存的值,所以会是乱码或者0或者一些预料不到的值。同样,指针不初始化也将带来问题,而且可能更加危险。如:

int* p_a;

*p_a = 15;

这里仅仅声明了一个指针,程序只会分配存储指针的内存,没有为p_a初始化,也即表示p_a是一个随机地址。而*p_a = 15;表示强制在这个随机地址上写入15,而这个地址完全可能会被其他变量或程序用到。这就会导致很隐匿,很难追踪的bug。

所以一定要在指针应用“*”运算符前,将指针初始化为一个合理的正确的地址!为了避免这种在应用*时还未给指针正确的分配内存的情况,通常我们为指针初始化为NULL(也即0)。NULL表示不指向任何东西的指针,所以,也就肯定不能够解引用(应用*运算符)了。这点一定要注意,因为连地址都没有,怎么得到不存在的地址中的值呢?所以要是想你的程序健壮,最好是在解引用之前加一个判断是否为NULL指针的步骤。

建议尽量定义了对象之后再定义指向这个对象的指针,对于不清楚的指向哪里的指针,一律初始化为nullptr(C++11)或者NULL(0)。之后再判断是否指向对象再进行相应的操作。除了上述的初始化方式,C++支持直接用数字作为地址进行强制转化:

p_a = (int*)0x0b230000;

通常来讲,我们通过new来初始化指针。

三.new的使用

指针的真正用武之地在于在运行阶段动态分配内存来存储值,进而用指针去访问它。在C语言中用malloc来分配内存,在C++中用new运算符。new将创建一个能够存储某种类型的变量的长度的内存。

int* pn = new int;

这就话中new后面的int告诉new创建一个都够存放int类型的内存块,并返回内存块的首地址,该地址被赋值给了pn。将来我们就可以用*pn访问该内存块中的内容了。

我们把在程序中定义的变量称作静态变量,他们在编译时分配内存,而这些内存是被分配在栈的内存区域。而用new动态分配内存是在堆上或自由存储空间分配内存。不论在哪里,内存空间都存在上限,静态变量一旦编译无法释放内存,而new动态分配的内存可以通过delete动态释放,这就可以达到管理内训的目的。

当需要内存的时候,我们可以使用new来请求内存。当使用完内存的时候,我们需要将内存归还给内存池,使用delete运算符来释放。归还或释放(free)的内存可供程序的其他部分使用。

这里new和delete一定是配对使用的。否则会发送内存泄露(memory leak),会占用内存,是内存无法使用,积累下来,会造成内存不够而使程序终止。同样,不能delete没有new的内存,这是非法的(编译不通过)或者导致运行时崩溃。

使用new和delete的,应遵守以下规则:

  • 不要使用delete来释放不是new分配的内存;

  • 不要使用delete释放同一个内存块两次;

  • 如果使用new[]为数组分配内存,则应使用delete[]来释放;

  • 如果使用new为一个实体分配内存,则应使用delete(没有方括号)来释放;

  • 对空指针应用delete是安全的。

四.指针与数组

如果使用new和delete是分配和释放一个值,则是简单的运用一个内存块。但是对于大型数据(如数组,字符串和结构),才是new真正的用武之地。用new创建的数组被称为动态数组,相对的,直接声明的数组叫做静态数组。

用new创建动态数组只要告诉new元素类型和元素数目即可。必须在等号右边类型名后加上方括号,其中包含元素数目。

例如,要创建 一个包含10个int元素的数组:

int* pData = new int [10];  //get a block of 10 ints

运算符new会返回第一个元素的地址给指针pData。

使用完new分配的内存块时,也应该使用delete来释放它们。其中的方括号表示释放整个数组。

delete[] pData;             //free a dynamic array

pData是指向动态数组中第一个元素的指针,那么如何访问后面的元素呢?只要将指针当作数组名使用,就可以知道其他元素的数值(数值和指针基本等价时C和C++的优点之一,实际上C++内部用指针来处理数组)。也即可以通过pData[i]访问第i个元素。这叫做数组表示法。

还有一种方式是应用指针算术。指针支持加减基本运算,乘除对于指针来说没有任何物理意义。但是要注意,指针+1,代表的是将指针偏移1个内存单元,不是1个字节,这个内存单元跟类型有关,其增加的值等于指向的类型占用的字节数。也即*(pData+1)和pData[1]是等价的。这叫做指针标志法。

反过来,在C++中数组名也被理解为数组第一个元素的地址。这意味着定义一个静态数组,也可以通过指针的方式访问其元素:

int Data[10];

*Data表示第一个元素,*(Data+1)表示第二个元素。

我们可以看到,C++数组和指针几乎可以等价,其原理是因为指针算术的原理和C++内部处理数组的方式。也即:

arraymame[i] becomes *(arrayname + i);

pointername[i] becomes *(pointername + i);

但是数组无法像指针一样执行下面的算法,因为数组名是一个常量:

pointername = pointername + 1;    //valid

arrayname = arrayname + 1;    //not allowed

当使用sizeof时,求指针pw,得到指针的长度(通常为4),求数组名wages,得到的是数组的长度,这种情况下,C++不会将数组名解释为地址。

字符串作为数组的一种,当然也满足上述等价关系。

结构体作为一种自定义变量类型,自然也可以创建动态结构体数组。而结构体的名字在C++内部也被处理为结构体的首地址,原理同上,不再赘述。

五.内存分配方法

讲到指针有必要梳理一下C++的内存分配方法,这将更好的理解指针的意义。C++管理数据内存的方式有三种:自动存储,静态存储和动态存储。

1.自动存储

在函数体内部使用的局部常规变量,这些变量使用自动存储空间,也就是栈,这些变量被称为自动变量(局部变量)。在执行函数时,函数内局部变量的存储单元在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。栈的工作方式是后进先出(LIFO),在程序的工作过程中栈将不断地放大或缩小。

2.静态存储

静态存储是在整个程序执行期间都会存在地存储方式。全局变量和静态变量(用static关键字)和字符串常量被分配到同一块内存中,在以前的C语言中,全局变量又分为初始化的和未初始化的,在C++里面没有这个区分了,他们共同占用同一块内存区。

3.动态存储

new提供了一种更灵活的内存管理方式。它在堆或者自由存储区上分配内存块,像是管理着一个内存池。他们的释放编译器不去管,由我们的应用程序去控制,一般一个new就要对应一个delete。如果程序员没有释放掉,那么在程序结束后,操作系统会自动回收。这也就意味着需要程序员自己管理内存,这将带来一些内存管理问题。new了没有delete会导致内存泄漏问题,极端情况将使得无法创建新的内存空间。尤其在嵌入式开发领域,堆内存有限,内存泄漏将很容易导致程序无法运行。

4.堆和栈

堆和栈究竟有什么区别?主要的区别由以下几点:

管理方式:对于栈来讲,是由编译器自动管理,无需我们手工控制;对于堆来说,释放工作由程序员控制,容易产生memory leak。

空间大小:一般来讲在32位系统下,堆内存可以达到4G的空间,从这个角度来看堆内存几乎是没有什么限制的。但是对于栈来讲,一般都是有一定的空间大小的,例如,在VC6下面,默认的栈空间大小是1M。当然,我们可以修改:

打开工程,依次操作菜单如下:Project->Setting->Link,在Category中选中Output,然后在Reserve中设定堆栈的最大值和commit。

注意:reserve最小值为4Byte;commit是保留在虚拟内存的页文件里面,它设置的较大会使栈开辟较大的值,可能增加内存的开销和启动时间。

碎片问题:对于堆来讲,频繁的new/delete势必会造成内存空间的不连续,从而造成大量的碎片,使程序效率降低。对于栈来讲,则不会存在这个问题,因为栈是先进后出的队列,他们是如此的一一对应,以至于永远都不可能有一个内存块从栈中间弹出,在他弹出之前,在他上面的后进的栈内容已经被弹出,详细的可以参考数据结构。碎片问题导致了C++在嵌入式编程中的缺点十分明显。

生长方向:对于堆来讲,生长方向是向上的,也就是向着内存地址增加的方向;对于栈来讲,它的生长方向是向下的,是向着内存地址减小的方向增长。

分配方式:堆都是动态分配的,没有静态分配的堆。栈有2种分配方式:静态分配和动态分配。静态分配是编译器完成的,比如局部变量的分配。动态分配由alloca函数进行分配,但是栈的动态分配和堆是不同的,他的动态分配是由编译器进行释放,无需我们手工实现。

分配效率:栈是机器系统提供的数据结构,计算机会在底层对栈提供支持:分配专门的寄存器存放栈的地址,压栈出栈都有专门的指令执行,这就决定了栈的效率比较高。堆则是C/C++函数库提供的,它的机制是很复杂的,例如为了分配一块内存,库函数会按照一定的算法(具体的算法可以参考数据结构/操作系统)在堆内存中搜索可用的足够大小的空间,如果没有足够大小的空间(可能是由于内存碎片太多),就有可能调用系统功能去增加程序数据段的内存空间,这样就有机会分到足够大小的内存,然后进行返回。显然,堆的效率比栈要低得多。

从这里我们可以看到,堆和栈相比,由于大量new/delete的使用,容易造成大量的内存碎片;由于没有专门的系统支持,效率很低;由于可能引发用户态和核心态的切换,内存的申请,代价变得更加昂贵。所以栈在程序中是应用最广泛的,就算是函数的调用也利用栈去完成,函数调用过程中的参数,返回地址,EBP和局部变量都采用栈的方式存放。所以,我们推荐大家尽量用栈,而不是用堆。

虽然栈有如此众多的好处,但是由于和堆相比不是那么灵活,有时候分配大量的内存空间,还是用堆好一些。

无论是堆还是栈,都要防止越界现象的发生(除非你是故意使其越界),因为越界的结果要么是程序崩溃,要么是摧毁程序的堆、栈结构,产生以想不到的结果,就算是在你的程序运行过程中,没有发生上面的问题,你还是要小心,说不定什么时候就崩掉,那时候debug可是相当困难的:)

5.常见的内存错误

发生内存错误是件非常麻烦的事情。编译器不能自动发现这些错误,通常是在程序运行时才能捕捉到。而这些错误大多没有明显的症状,时隐时现,增加了改错的难度。有时用户怒气冲冲地把你找来,程序却没有发生任何问题,你一走,错误又发作了。常见的内存错误及其对策如下:

内存分配未成功,却使用了它。

编程新手常犯这种错误,因为他们没有意识到内存分配会不成功。常用解决办法是,在使用内存之前检查指针是否为NULL。如果指针p是函数的参数,那么在函数的入口处用assert(p!=NULL)进行检查。如果是用malloc或new来申请内存,应该用if(p==NULL)或if(p!=NULL)进行防错处理。

内存分配虽然成功,但是尚未初始化就引用它。

犯这种错误主要有两个起因:一是没有初始化的观念;二是误以为内存的缺省初值全为零,导致引用初值错误(例如数组)。内存的缺省初值究竟是什么并没有统一的标准,尽管有些时候为零值,我们宁可信其无不可信其有。所以无论用何种方式创建数组,都别忘了赋初值,即便是赋零值也不可省略,不要嫌麻烦。

内存分配成功并且已经初始化,但操作越过了内存的边界。

例如在使用数组时经常发生下标“多1”或者“少1”的操作。特别是在for循环语句中,循环次数很容易搞错,导致数组操作越界。

忘记了释放内存,造成内存泄露。

含有这种错误的函数每被调用一次就丢失一块内存。刚开始时系统的内存充足,你看不到错误。终有一次程序突然死掉,系统出现提示:内存耗尽。动态内存的申请与释放必须配对,程序中malloc与free的使用次数一定要相同,否则肯定有错误(new/delete同理)。

释放了内存却继续使用它。

三种情况:

1)程序中的对象调用关系过于复杂,实在难以搞清楚某个对象究竟是否已经释放了内存,此时应该重新设计数据结构,从根本上解决对象管理的混乱局面。

2)函数的return语句写错了,注意不要返回指向“栈内存”的“指针”或者“引用”,因为该内存在函数体结束时被自动销毁。

3)使用free或delete释放了内存后,没有将指针设置为NULL。导致产生“野指针”。

应该遵循的规则

【规则1】 用malloc或new申请内存之后,应该立即检查指针值是否为NULL。防止使用指针值为NULL的内存。

【规则2】 不要忘记为数组和动态内存赋初值。防止将未被初始化的内存作为右值使用。

【规则3】 避免数组或指针的下标越界,特别要当心发生“多1”或者“少1”操作。

【规则4】 动态内存的申请与释放必须配对,防止内存泄漏。

【规则5】 用free或delete释放了内存之后,立即将指针设置为NULL,防止产生“野指针”。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

bjtuwayne

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值