【高质量代码】如何写出更高质量的C/C++代码(1):内存管理

内存的管理是C/C++开发程序过程中的一个比较麻烦的问题。对于经验不是足够丰富的程序员来说,开发比较复杂的程序的时候几乎肯定会遇到内存管理方面的bug。对C/C++语言以及编译机制深入的理解和养成良好的编程习惯可以尽量减少这类bu*生的几率。

1、C/C++程序运行时内存结构简介

一个典型的C/C++编译的进程所占用的内存空间通常分为5个部分,由低地址到高地址分别为:

  1. 代码段(Code/Text Segment):保存可执行程序运行的二进制代码段。
  2. 数据段(Data Segment):保存进程已经初始化的全局变量0。
  3. BSS段(BSS Segment):保存已经声明,但尚未初始化的全局变量,进程开始后这部分数据初始化为0;静态变量也视为全局变。
  4. 堆(Heap):保存进程动态分配的内存数据。C中使用malloc/calloc/free分配、释放,C++中使用new/delete分配、释放。如果不释放,进程会始终保存这些数据,直到进程退出。
  5. 栈(Stack):主要用于处理函数调用过程中的数据,主要有函数的参数、临时变量和返回地址等。在栈中保存的数据在生命周期结束后会自行释放。栈空间和堆空间按照实际情况确定大小,没有指定的数值。

2、内存的分配方式

通常在编程中常用到的内存分配方式有三种:

(1)从静态存储区分配,在程序编译的时候就已经确定。如全局变量、static变量等。

(2)从栈空间分布,如函数内部的局部变量。此类数据分配效率很高但容量有限。

(3)从堆空间动态分配,在程序运行时由程序员决定申请多少内存并负责释放。这类数据出问题的可能性最高。


3、内存管理中常见错误

实际编程中导致内存错误的情况通常发生于处理堆空间的数据时。主要有以下几种:

(1)使用了分配失败的内存空间。程序中申请内存可能会因为不同原因而失败,而使用申请空间失败的内存地址将会导致进程崩溃。通常,在使用内存空间之前判断指针是否为0可以避免类似问题。如果指向一段内存的指针作为函数的参数,那么可以在函数的入口处使用assert(p!=NULL) 处理,如果p指针为空则会返回错误。如果一段内存通过malloc/new获取,那么使用if(p==null)或if(p=!null)进行预防处理。

(2)分配成功,但是使用了未经过初始化的内存。此时进程可能不会崩溃,但是会导致数据引用错误。因此,创建数组等结构之后,应第一时间进行初始化,哪怕全部设为0。

(3)空间分配、初始化成功,但是读写越界。此类问题在使用for循环处理数组时经常出现,比如以下代码:

int *pArr = (int *)malloc(20);
memset(pArr, 0, 20);
for(int idx = 0;idx <= 20;idx++)
    *(pArr+idx) = idx;
问题经常就出现在for循环中究竟是<还是<=。在该使用<的地方使用了<=,就会导致最后一次循环时内存读写越界。
(4)内存泄露。在堆空间中手动分配的内存没有释放,这部分内存在进程退出之前就会一直存在,如果这部分的代码循环执行,那么很有可能出现系统的内存被耗尽的情况。3和4是新手程序员犯错误比较多的部分,只能在开发时多多留神。

(5)使用了释放的内存。通常会导致这种情况的有:①函数返回了指向栈内存的指针或引用到上层,这会导致上层使用该函数返回的指针/引用时发现指针无效,因为我们想要的数据已经随着函数调用结束而销毁了;②对于一些全局指针,在free或delete之后,没有设为null,产生了“野指针”。在后面继续使用该指针时,通过if(p==null)判断的方法便失效了。解决方法是注意返回指针或引用时,应返回指向全局数据的指针和引用,在释放内存之后第一时间将指针设为null


4、指针和数组的对比

在C/C++中,经常可以使用指针和数组达到相同的目的,因此时常会产生这样的疑惑:即二者是否是等价的?实际上二者由其区别,主要在于:数组在栈空间或者静态存储区创建,数组名对应某一块指定的内存区且不可以指向其他内存区,一旦创建之后,数组的地址和容量在生命周期内固定,只可能改变数组的内容;而指针则没有此限制,可以指向任意类型的地址,因此经常用指针来操作堆空间的动态内存。下面的程序可以反映二者的区别:

char a[] = "hello";
a[0] = 'X';
cout << a << endl;
char *p = " world";
p[0] = 'X';  
cout << p << endl;
在该段程序中,a表示一个数组,这个数组有独立的内存空间并初始化为"hello",因此其内部元素的值可以改变;而指针p指向的是保存在常量文本区的字符串本身,并没有进行一次数据拷贝,因此试图修改常量文本的操作会在运行时导致程序崩溃。

二者另一个区别在于使用sizeof运算符计算内存大小时的结果。对于一个初始化过的数组,使用sizeof运算符得到的是数组的大小;而对于一个指针变量,使用sizeof运算符得到的是指针变量的大小(一般为4),与指向的内存数据大小无关。如以下程序所示:

char a[] = "Hello world";
char *p = a;
cout << sizeof(a) <<endl;//返回12
cout << sizeof(p) <<endl;//返回4
需要注意的一点是,当数组名作为函数的参数传递时,当做指针变量处理。


5、指针作为函数的参数

一个很重要的原则是:不要使用作为参数的指针去申请内存。因为在函数执行时,形参会被重新分配一个与原来不同的指针变量,如果使用这个指针去申请内存,那么不但调用上层不能得到内存空间,函数内部申请到的内存也会因为地址指针丢失无法释放而造成内存泄露。解决此类问题可以通过传递“指向指针的指针”或者将内存地址通过return返回的方法。需要注意的一点是,不要return栈内存空间,因别这部分空间在函数返回时将消亡。


6、“野指针”导致的问题

free和delete将释放参数所指向的内存地址,但是并没有将指向该地址的指针置0,内存被释放后,标识该内存的地址指针变量的值并未改变。此时这个指针值是合法的,但是指向的内容却是非法的,这就造成了“野指针”的产生。如果这个指针变量再次被使用,那么if(p==null)的合法性判断将会失效。

造成“野指针”产生还有两种可能:刚刚定义的指针变量没有被初始化,局部指针变量在刚刚创建时不会设为NULL而是一个随机值;指针变量的生命周期合法,但是指向的对象已消亡,这是指向该对象的指针也将成为野指针。为了杜绝这类情况,需要注意遵守以下原则:

  1. 每一个内存区域被释放后,第一时间将指向该区域的指针赋值为NULL。
  2. 定义一个指针变量是,或者赋给初值NULL,或者直接令其指向一段合法申请到的内存区。
  3. 尽量将指针变量定义在与目标对象/内存一直的声明周期,让其“同生共死”。


7、指针变量同动态内存的关系

C/C++语言其实并不聪明,很多时候并不能理解我们编程时的想法。比如,函数的局部变量在函数结束时消亡,但是在内部申请的内存却不会因为指针变量的消亡而被释放。还有,我们把一段内存区释放,那指向该内存的指针变量依然保持原有值,变成了“野指针”。因此,实际上,这二者并没有直接的联系,编程时一定要分别处理。


8、malloc/free与new/delete

malloc和free是C语言的标准库函数,new和delete是C++的运算符,二者都能实现内存的动态申请和释放。而二者的区别也正反映了两种语言的设计差异:C是更加面向过程的语言,C++则是面向对象的语言,new和delete除了释放内存之外,更多地考虑了面向对象的一些特性。

C++与C的最本质区别之一在于C++定义了类这一概念,并且对对象的产生和消亡定义了构造函数和析构函数,因此new/delete在生成和释放对象时,对调用对象的构造和析构函数进行一些该类的个性化的操作,这是malloc/free力所不能及的。

因此,需要遵照的原则是:为了一个C++对象申请动态内存是,一定要使用new,释放是也一定要用delete,否则会因为构造和析构函数没被调用而产生错误。

对于free函数,如果p为NULL,那么可以多次调用free(p),但是如果p不是NULL,那么连续两次进行free就会使得程序崩溃。这也给了我们另一个理由在释放内存之后马上将指针设为NULL。


9、内存申请失败

通常内存申请失败时,将会返回NULL给指针变量,此时应判断指针是否为NULL,如果的确申请失败,则应该返回错误,或者直接使用exit(n)来结束进程。

上一篇:【原创】遇到一个 rabbitmqctl 无法退出的问题


下一篇:C语言数组和指针的理解_在取地址运算上的操作_指针加减操作_a 和&a 的区别