本文展示了笔者在编写C++
程序中遇到的问题和解决方案。文中附有大量有用的代码,这些代码往往都可以不加修改的添加进你自己的函数包中。你可能不能在其他的书上找到这些写法,因为这些都是笔者在大量的实践中和大量bug产生后积累下的经验,目的是:希望读过本文的读者,能够避免在编写程序的过程中重蹈覆辙,从而有效减少bug的产生。
动态内存的使用错误
浅谈new运算符
为什么要尽可能地使用new运算符?
C++
是一门面向对象的程序语言,因而,动态内存的分配与回收也必须与面向对象的特点相适应。为此,C++
里面增加了new和delete两个方便的运算符来允许用户方便地进行内存管理操作。
在面向对象的程序设计思路下,动态内存的分配应该包含两步:内存空间的请求、对象的构造,而动态内存的释放请求也就应该包含两步,对象的析构和内存空间的释放。如果使用C风格的动态内存分配机制来实现面向对象的变成,那么用户必须同时使用malloc(或其他内存请求函数)和用户自定义的构造函数。例如,下面代码中定义了类型S
和相应的构造函数construct_S
、析构函数deconstruct_S
:
1 struct S{
2 int _a;
3 };
4
5 void construct_S( S * s, int a ){
6 s->_a = a;
7 }
8
9 void deconstruct_S( S * s ){
10 s->_a = 0;
11 }
为了在动态内存分配的情形下使用类型S
,用户必须执行下面的语句:
1 S *s = (S *) malloc( sizeof(S) ); // <0>
2 cout << s->_a << endl; // <1> Danger!
3 construct_S( s, 10 ); // <2>
4 cout << s->_a << endl; // <3>
5 deconstruct_S(s); // <4>
6 cout << s->_a << endl; // <5> Danger!
7 free(s); // <6>
需要注意的是,步骤<0>
和<2>
分别完成内存请求和对象构造工作,因而它们是不可分割的,在其之间的语句<1>
破坏了这一点,这可能给程序增加很多风险。同时,<4>
和<6>
分别完成对象的析构和内存空间的释放,因而也是不可分割的。夹在其之间<5>
语句使用了一个已经析构的对象,因而也是危险的!
C++
语言的new和delete两个运算符则完全可以消除这里面的危险性举动,并且使用起来更加方便。例如,用户定义了下面的类型T
:
1 class T{
2 public:
3 int _a = 1;
4 ~T() { }
5 T(int a):_a(a){ }
6 };
那么,new运算符将把内存请求和对象构造合二为一、delete运算符将把对象析构和内存空间的释放合二为一。用户动态地使用类型T
的语句现在变为了:
1 T *t = new T(10);
2 cout << t->_a << endl;
3 delete t;
这不仅仅更加简介,而且完全消除了用户的实现危险举动的可能性。因而,在C++
语言里,应该尽可能地使用new和delete这一对为面向对象程序设计而生的运算符,而要避免使用malloc(或calloc等)和free这一对原始的动态内存管理函数。
什么情况下不能使用new运算符?
C语言最重要的一个优点(也是最大的缺点)就是可以把内存玩出各种花样。有些情况下,如果用户定义的类型不是一个“完全确定”的类型,那么new运算符将对其无能为力。这个时候,malloc就可以派上用场了。下面定义的Buffered_type
是一个经常被使用的例子:
1 struct Buffered_type{
2 int _a;
3 double _b;
4 char _buff[0]; // <1> dynamical buffer
5 };
这里,<1>
处的_buff[0]
仅仅充当一个占位符号的作用,它并不影响Buffered_type
类型的实际大小(静态大小),也就是说,如果对此类型使用new运算符来动态分配内存,那么所分配的内存大小仅仅是一个int
和一个double
所占的大小(另外,考虑对齐带来的额外内存)。但是,用户可以使用malloc来完成下面的精彩操作:
1 Buffered_type *bft = (Buffered_type *) malloc( sizeof(Buffered_type) + 1024 );
2 sprintf( bft->_buff, "A type with changable buffer" );
3 cout << bft->_buff << endl;
4 free(bft);
用户首先分配了一个比Buffered_type
的大小更大一点的一块内存,然后强制地转化为Buffered_type
类型的指针bft
。之后bft->_buff
就成为了一个char
类型的指针,并且正好指向等同于bft的静态大小的内存的下一个位置。因此bft->_buff
就成为了一个跟在静态类型屁股后面的动态缓存区,用户可以拿它来存储一些有用的信息。这样的操作通过new运算符就不可能实现。
正确地处理内存请求中的异常
计算机系统中的内存总是显得不够用,因而,请求一块过大的内存就有可能失败。在C语言中,malloc函数通过返回一个空指针来表明内存请求失败。C++中,new运算符则是会抛出一个异常类bad_alloc
(定义在头文件<new>中)来表明内存请求失败(虽然也可以使用不抛出异常的new运算符)。原则上,任何内存请求都有可能失败,因而,bad_alloc
的抛出总是有可能存在的。倘若这类异常没能被正确地处理,那么terminate()
将会被调用,从而终止程序运行,并且,用户不会得到任何关于这个异常的具体信息(例如,哪个位置发生了异常,什么原因导致了异常,等等)。所以,为了程序的安全性,必须在每次调用new时检查是否有异常抛出:
1 // This is always necessary
2 try{
3 p = new some_type;
4 }catch( const bad_alloc & ){
5 //... handle the exception ... // <1> handle the exception
6 }
<1>
处的异常处理过程通常包含重新请求内存或者放弃请求、报告错误发生的基本信息(位置、原因),重新抛出异常或调用terminate()
来终止程序,等等。然而,内存请求失败往往是程序的硬伤,基本上是无可挽回的,因而这里的异常处理步骤也可以仅仅是报告异常发生的基本信息,然后立即重新抛出异常。所以,我们可以写出下面的函数:
1 template<typename T>
2 T * Alloc( const string & debug_info ){
3 T *p = nullptr;
4 try{ // <1> catch possible bad_alloc if failed to allocate
5 p = new T;
6 }catch( const bad_alloc & ){ // <2> report debug information
7 cerr << debug_info << ": bad allocation!" << endl;
8 throw;
9 }
10 return p;
11 }
函数Alloc
接受一个字符串debug_info
作为参数。用户调用Alloc
时,可以以字符串的形式传入一些有利于debug的信息(例如,调用Alloc
的文件、函数和行标等);当Alloc
捕捉到内存请求异常时,就可以打印出这些信息。<1>
处的try-catch
语句对实现对异常的捕获和处理,而<2>
处catch
块内仅仅是对异常进行报告,然后重新抛出异常。有了这个函数后,动态内存分配请求就可以写为(以int
类型为例)
int *p = Alloc<int>(__FUNCTION__);
这样就避免了每次都写一个长长的try-catch
语句,但同时又能够准确定位bug发生的地点。
有的时候,我们往往希望能够给new运算符提供一些额外的参数来使用相应类型的非默认构造函数来初始化对象,这就需要给我们的Alloc
函数增加一些参数来传递给new运算符。另外,有了Alloc
函数后,我们也需要书写相应的内存释放函数。下面的例子展示了可以传递参数的Alloc
和内存释放函数DeAlloc
:
1 template<typename T, typename... Args>
2 T * Alloc( const string & debug_info, Args &&.. args ){
3 T *p = nullptr;
4 try{
5 p = new T( forward<Args>(args)... ); // <1> transfer the initializing parameters
6 }catch( const bad_alloc & ){
7 cerr << debug_info << ": bad allocation!" << endl;
8 throw;
9 }
10 return p;
11 }
12
13 template<typename T>
14 void DeAlloc( T * &p ){
15 if( p ) delete p;
16 p = nullptr; // <2> nullize pointer p
17 }
上面的Alloc
函数利用了C++
的参数“完美”转发机制,首先声明参数包args
,然后在<1>
处利用标准库的forward
模板函数(定义在头文件<utility>内)实现参数转发。这样,可保持Alloc
接收的参数的类型,完全地传递给new运算符。DeAlloc
函数仅仅是调用了delete运算符来析构对象并释放内存。需要注意的是,形参p的类型是一个引用类型,所以<2>
处能将p置为空指针。将一个无效的指针悬空是必须的、安全的操作。
这里Alloc
和DeAlloc
完全可以安全地替代原生的new和delete运算符。请把这两个函数放进你自己的函数包里,并且总是优先地使用这两个函数而不是new和delete,因为他们能正确地报告异常的位置,并且安全的悬空了释放内存后的指针。有的时候,如果内存的申请比较频繁,Alloc
的const string &
参数可能会降低程序运行的效率,你可以将它改成const char *
,或者取消这个参数(虽然,我极不推荐这样做)。至于这里的模板函数可能带来的代码膨胀问题,你可以忽略掉。
同样的,我们也可以对new [ ]和delete [ ]这两个运算符书写相应的更安全的函数,步骤是类似的:
1 template<typename T>
2 T * AllocArr( long n, const string & debug_info ){ // <1> arg p is long type
3 if( n <= 0 ) throw domain_error( debug_info + ": negtive size is not allowed!" );
4 T *p = nullptr;
5 try{
6 p = new T[ n ]; // <2> use new [] operator
7 }catch( const bad_alloc & ){
8 cerr << debug_info << ": bad allocation!" << endl;
9 throw;
10 }
11 return p;
12 }
13
14 template<typename T>
15 void DeAllocArr( T * &p ){
16 if( p ) delete [] p; // <3> use delete [] operator
17 p = nullptr;
18 }
注意<1>
这里AllocArr
的第一个参数类型是long
而不是size_t
类型(而运算符new [ ]接收的参数类型是size_t
),这样设计是为了避免用户不小心传入一个负的整数进来。因为有的时候,数组大小需要根据运行结果动态决定,倘若用户书写了如下程序:
1 int n;
2 cout << "input the size of array: "; cin >> n;
3 int *p = new int [n]; // <1> Danger: negative n
而这时,使用者不小心输入了一个负数,那么<1>
处的内存请求将会产生灾难性的后果(因为负数转化为无符号数时,往往会得到一个很大的正数)。AllocArr
通过将第一个参数设置为有符号的long
类型,并在函数体内检测输入是否为负数,从而避免了这一问题的发生。和Alloc
不同的是,AllocArr
在<2>
处使用的是new [ ]运算符,因而在<3>
处也要相应地使用delete [ ]来释放。这样,对于数组的动态分配和释放就可以这样调用(以int
类型为例):
1 int *p = AllocArr<int>( 1000, __FUNCTION__ );
2
3 //...use this array...
4
5 DeAllorArr(p);
我们的AllocArr
和DeAllocArr
总是应该成对使用,并且可以替代原生的new [ ]和delete [ ]运算符。请把它们也放入你的函数包里,并尽可能优先使用它们。
I/O相关的错误
文件状态的错误
文件开启时的检查
当你试图开启一个文件时,某些不可预测的因素(例如,写错了文件名,或者权限不够,等等)可能会导致你开启文件失败。这时,如果直接对相应的流进行I/O操作往往会出现意料之外的结果。因此,对流进行I/O操作时,必须保证流处于正常的状态,其对应的文件也必须是开启的。
这里展示一个易犯的错误。例如,你的设备上有一个名为“data.dat”的数据文件,内容只有一行,如下:
1 2 3
现在,你希望读取文件中的三个整数,于是书写了下面的程序:
1 ifstream fin("data.dat"); // <1> open the file
2 int i,j,k;
3 fin >> i >> j >> k;
4 fin.close();
5 cout << i << '\t' << j << '\t' << k << endl;
6 return 0;
其中ifstream
是包含在头文件<fstream>
中的输入文件流类型,用于对文件进行读取。在笔者的屏幕上,正确地输出了文件中的内容。然而,很多情况下,由于文件名太长或者不小心手滑,我们很容易就把文件名写错了。例如你不小心把<1>
这一行行写成了ifstream fin("date.dat");
。现在就完蛋了,在笔者的屏幕上输出了0 1563473512 32764
这三个数。这是因为ifstream
初始化时,找不到错误的名为date.dat
的文件,因此不能成功打开这个文件,因此这个流就处于错误的状态。后续的输入操作>>
都不能成功进行,所以变量i
,j
,k
都会保留原来处在内存中的值。可怕的是,C++
并不会在运行时动态地检查流的状态并给出报错,因而程序继续运行,所以作者就得到了错误的输出。为了避免这个问题,C++
为fstream的流类型提供了一个名为is_open
的类方法,用于检查流是否成功地打开了文件。因此,正确的做法是,在们每次打开文件的时候,都必须检查文件是否成功被打开。例如:
1 ifstream fin("date.dat");
2 if( !fin.is_open() ){
3 cerr << __FUNCTION__ << ": cannot open file " << "data.dat" << endl;
4 cerr << "... check it" << endl;
5 throw runtime_error( "open data.dat failed" );
6 }
7 ... read the data ...
在这里,如果文件没有成功打开,流类型的is_open
方法将返回false
,而用户则得到错误提示,并被抛出一个异常。
虽然关于文件是否成功开启的检查是必须的,但是,如果每次都写这样一个长长的语句,会显得很麻烦。因此,可以书写下面的通用函数来执行检查:
1 void checkInFile(ifstream &fin, const string &func_name, const string &file_name = ""){
2 if( !fin.is_open() ){
3 cerr << func_name << ": cannot open file" << file_name << endl;
4 cerr << "... check it" << endl;
5 throw runtime_error( func_name + ": failed to open file" + file_name );
6 }
7 }
这样的话,今后打开文件时,只需要调用这个函数进行检查即可:
1 string fname = "date.dat";
2 ifstream fin( fname.c_str() ); checkInFile( fin, __FUNCTION__, fname );
按照笔者的经验,每次打开文件时,文件是否成功被打开的检查必须进行。坚持这样的检查可以大大的减少程序出bug的几率。所以,请把上面的函数checkInFile
加入你的函数库里,并且总是调用它。你同时还需要仿造这个函数写一个检查输出流对应的文件是否被成功打开的函数,名为checkOutFile
。
容器类型使用中的常见错误
容器类型简介
相比于C
语言来讲,C++
的标准模板库提供了丰富的容器类型,并且为这些容器提供了高度一致的接口。按笔者个人的理解,C++
的标准容器库具有以下特点:
- 高度的泛型化:通过使用模板编程技术,
C++
的容器库可以存放几乎任意类型的变量。- 一般来讲,只要一个类型具有一个构造函数,它就可以被添加到容器中去。
- 另外,大部分容器和相应的适配器都提供迭代器用于遍历容器中的元素,同时它们也能被使用到基于范围的
for
循环中去。这为程序编写提供了很大的遍历。 - 由于容器接口的统一性,
C++
得以实现一个标准的算法库(定义在头文件<algorithm>
中),用于对容器的查找、排序、分割等问题(关于算法库的具体描述,可以参考cplusplus网站)。
- 高效:
C++
的容器继承了C
语言的优点,其内部使用高效的动态内存技术,实现对变量存储空间的请求。- 为了安全性的考虑,标准库隐藏了容器“增长”时的实现细节,但是效率仍然不会大打折扣(例如,矢量类型
vector
通过每次将容积capacity
扩增到原来的两倍。这样,对于很多次的尾增,也不需要扩容很多次,因而可以保证效率。另外,部分容器类还支持用户自定义的内存增长函数,因此,用户可以根据实际需要选择合适的增长方案;用户还可以通过reserve()
来预留内存以避免重复的增长)。 - 其次,
C++11
版本里,还为容器类型添加了获取其底层实现细节的部分接口,这进一步改善了程序性能和安全的可调节性,以及对纯C语言接口的兼容性(例如,矢量类型vector
有一个data()
方法,可以获取到矢量的首元素指针。这使得用户可以在必要的时候像使用一个C
语言动态数组那样使用矢量类)。
- 为了安全性的考虑,标准库隐藏了容器“增长”时的实现细节,但是效率仍然不会大打折扣(例如,矢量类型
- 安全:标准库容器对于安全的保证相对于其他语言来讲非常独特。
- 例如,对于
vector
类型的push_back()
操作,当用户提供一个右值时借以希望编译器通过移动构造来提升尾增的效率时,标准库也不一定会满足用户的请求。这是因为,如果容器的成员类型的移动构造可能抛出异常的话,那么在移动构造时,就有可能抛出一个异常,而这时构造工作可能尚未完成但是被移动对象的数据已经部分损毁;这样的话,用户即使捕捉到了异常,也没法复用被移动对象的数据了。所以,只有容器成员的移动构造函数被显式的设定为noexcept
时,标准库才会实现移动构造(见[1] Primer 13.6.2)。 - 另一个常见的例子是,标准库的栈类型
stack
的两个方法:top()
和pop()
是分开实现的,pop()
并不返回一个值;而其他语言,例如Python等,pop的同时将返回栈顶元素值。这是因为,如果允许pop()
返回一个值,那么以x=stack_object.pop()
类似的语法获取栈顶元素值时,在pop()
方法内,要先临时地拷贝栈顶元素,删除栈顶元素,然后以一个右值返回并移动给被赋值变量x
。这样,如果移动过程抛出异常,那么用户即使捕捉到这个异常,也没有机会再次获取栈顶元素了。因此标准库强制要求top()
和pop()
分开执行,因为top()
仅仅返回一个引用,是安全的(详见[2] Concurrency 3.2.3)。
- 例如,对于
正是因为标准容器库的容器具有上述优点,用户应该坚持使用这些容器,而不应该显式的使用其低级接口,或是手动的使用new
、malloc
等动态类型管理机制来管理内存。我们下面将指出如果滥用低级接口、或是手动管理内存来实现容器的时候容易发生的错误。另外我们还要指出一些即便是使用标准容器类型也可能会发生的错误。
手动管理的动态数组
为什么要使用手动的动态内存管理
C/C++
支持显式的手动分配内存来在逻辑空间上连续地存储同一类型的元素,我们把这些结构成为手动管理的动态数组。动态数组的最大优点是高效与灵活,这体现在如下的几种情况中:
手动的内存管理是实现容器的唯一底层方法
由于有了C++
标准容器库,我们可能很少需要手动的来管理内存。然而有时候我们不得不自己书写容器类型,这是因为标准库容器要同时兼具高效性、安全性和泛型性,因而必须在这三者之间折中考量。有的时候我们会突出地要求高效性,那么就不得不自己书写容器。此外,标准库容器缺少非线性结构,例如树和图等,我们必须手动书写这些数据结构。要实现一个容器,典型的方法是下面这样(以整形矢量类型为例)
1 class Vector{
2 public:
3 Vector(size_t n): data(new int[n]), size(0){ }
4 Vector(const Vector &) = delete;
5 Vector & operator=(const Vector &) = delete;
6 ~Vector(){ delete [] data; }
7
8 int & operator[](size_t i){
9 return data[i];
10 }
11 void push_back(const int &x){
12 data[size++] = x;
13 }
14 protected:
15 int *data;
16 size_t size;
17 };
在构造函数中这里就包含了内存请求的new
操作,而析构函数中则通过delete
释放了内存。为了安全起见我们还把拷贝构造函数和拷贝赋值运算符设为delete
的。最后,我们还定义了一个尾增操作push_back()
和取成员操作operator[]
。
定位new运算符和内存重用
new
运算符完成对对象所需内存的分配(allocate)和对象的构造工作(construct)。然而,如果我们需要大量的构造、析构动态对象,那么过于频繁地请求内存(典型的情况是,链式/树式/图式结构中节点的内存请求)可能会导致效率的严重下降(通常的操作系统中,new运算符需要通过一次中断来请求内存页(pages),这会使得操作系统陷入内核态,执行请求,然后回到用户态;delete
时也是这样)。为此,最佳的方法是将内存请求和对象构造分开来,复用曾经已经分配的内存。标准库的头文件<memory>
中的allocator
类型提供了这样的操作(见 [1] Primer 12.2.2)。我们这里介绍一个更简单的方法,使用定位(placement)new运算符(包含在头文件<new>
中)来实现内存请求和对象构造的分离(见 [3] Plus 9.2.10和12.5.3)。下面的例子展示了定位new
运算符的使用
1 #include <new>
2 #include <iostream>
3 using namespace std;
4
5 struct buffer_type{
6 char content[20];
7 char *buffer;
8 buffer_type( size_t size_of_buffer = 8 ){
9 buffer = new char [size_of_buffer];
10 }
11 buffer_type(const buffer_type &) = delete;
12 buffer_type & operator=(const buffer_type &) = delete;
13 ~buffer_type(){
14 delete [] buffer;
15 }
16 };
17
18 int main(int argc, char const *argv[]){
19
20 char *p = new char[128];
21 size_t num_used = 0; // <1> counter of used memory
22
23 double *p1 = new(p) double; // <2> a double
24 num_used += sizeof(double);
25
26 int *p2 = new(p + num_used) int[10]; // <3> 10 integers
27 num_used += sizeof(int)*10;
28
29 buffer_type *p3 =
30 new(p + num_used) buffer_type; // <4> a class
31 num_used += sizeof(buffer_type);
32
33 cout << "location of p1: " << p1 << endl;
34 cout << "location of p2: " << p2 << endl;
35 cout << "location of p3: " << p3 << endl;
36 p3->~buffer_type(); // <5> deconstruct buffer_type
37
38 buffer_type *p4 = new(p) buffer_type; // <6> cover the used
39 num_used += sizeof(buffer_type);
40 cout << "location of p4: " << p4 << endl;
41 p4->~buffer_type();
42
43 delete [] p; // <7> free memory only once
44 return 0;
45 }
我们首先定义了一个普通的类型buffer_type
,这是用户经常会定义的类型;它的构造函数里包含了动态内存的分配,所以必须在析构函数里包含相应的动态内存的释放;为了安全起见,我们将buffer_type
的拷贝操作设置为delete的,以避免可能的拷贝举动造成的浅复制和重复的内存释放。
使用定位new运算符首先需要请求一段内存空间,我们在<1>
处使用new
运算符请求了长度为128个字节的内存,并且用一个整数num_used
来表示这些内存被我们使用的情况。定位new
运算符的语法是some_type *p = new (place) some_type
或者some_type *p = new (place) some_type[size]
, 其中some_type
是某个类型,place
是一个指向可用内存的指针(指针的类型是任意的,会被转化为void *
并返回),size
是对象数组大小。和普通的new
运算符不一样,定位new
运算符不分配内存空间,而是只在已有的内存里构造对象。所以,也不应该对定位new运算符构造的对象执行delete工作,而只是应该将对象析构即可。<2><3>
两处,我们分别在已有内存中构造了一个浮点数和10个整数。<4>
处则是构造了一个buffer_type
对象;由于buffer_type
类型存在析构函数,因此使用完毕后(<5>
处)必须手动释放。<6>
处则在已经使用过的内存上重新构造了一个对象,它覆盖掉了原来曾经占用过这里内存的对象。
在笔者的机器上,打印出了
1 location of p1: 0x1c5fc20
2 location of p2: 0x1c5fc28
3 location of p3: 0x1c5fc50
4 location of p4: 0x1c5fc20
由于在笔者电脑上,p1
指向8字节浮点,所以p2
的值比p1
的值多8;同样p3
比p2
的值多了40,表示10个4字节整数;而p4
又回到了p1
的位置。定位new
运算符的使用为C++
内存操作提供了很大的灵活性,但是也带来了巨大的风险。我们之后将提到其中一些。