本文展示了笔者在编写 C++
程序中遇到的问题和解决方案。文中附有大量有用的代码,这些代码往往都可以不加修改的添加进你自己的函数包中。你可能不能在其他的书上找到这些写法,因为这些都是笔者在大量的实践中和大量bug产生后积累下的经验,目的是:希望读过本文的读者,能够避免在编写程序的过程中重蹈覆辙,从而有效减少bug的产生。
动态内存的使用错误
为什么要尽可能地使用new运算符?
C++
是一门面向对象的程序语言,因而,动态内存的分配与回收也必须与面向对象的特点相适应。为此, C++
里面增加了new和delete两个方便的运算符来允许用户方便地进行内存管理操作。
在面向对象的程序设计思路下,动态内存的分配应该包含两步:内存空间的请求、对象的构造,而动态内存的释放请求也就应该包含两步,对象的析构和内存空间的释放。如果使用C风格的动态内存分配机制来实现面向对象的变成,那么用户必须同时使用malloc(或其他内存请求函数)和用户自定义的构造函数。例如,下面代码中定义了类型 S
和相应的构造函数 construct_S
、析构函数 deconstruct_S
:
为了在动态内存分配的情形下使用类型 S
,用户必须执行下面的语句:
需要注意的是,步骤 <0><!--0-->
和 <2><!--2-->
分别完成内存请求和对象构造工作,因而它们是不可分割的,在其之间的语句 <1><!--1-->
破坏了这一点,这可能给程序增加很多风险。同时, <4><!--4-->
和 <6><!--6-->
分别完成对象的析构和内存空间的释放,因而也是不可分割的。夹在其之间 <5><!--5-->
语句使用了一个已经析构的对象,因而也是危险的!
C++
语言的new和delete两个运算符则完全可以消除这里面的危险性举动,并且使用起来更加方便。例如,用户定义了下面的类型 T
:
那么,new运算符将把内存请求和对象构造合二为一、delete运算符将把对象析构和内存空间的释放合二为一。用户动态地使用类型 T
的语句现在变为了:
这不仅仅更加简介,而且完全消除了用户的实现危险举动的可能性。因而,在 C++
语言里,应该尽可能地使用new和delete这一对为面向对象程序设计而生的运算符,而要避免使用malloc(或calloc等)和free这一对原始的动态内存管理函数。
什么情况下不能使用new运算符?
C语言最重要的一个优点(也是最大的缺点)就是可以把内存玩出各种花样。有些情况下,如果用户定义的类型不是一个”完全确定”的类型,那么new运算符将对其无能为力。这个时候,malloc就可以派上用场了。下面定义的 Buffered_type
是一个经常被使用的例子:
这里, <1><!--1-->
处的 _buff[0]
仅仅充当一个占位符号的作用,它并不影响 Buffered_type
类型的实际大小(静态大小),也就是说,如果对此类型使用new运算符来动态分配内存,那么所分配的内存大小仅仅是一个 int
和一个 double
所占的大小(另外,考虑对齐带来的额外内存)。但是,用户可以使用malloc来完成下面的精彩操作:
用户首先分配了一个比 Buffered_type
的大小更大一点的一块内存,然后强制地转化为 Buffered_type
类型的指针 bft
。之后 bft->_buff
就成为了一个 char
类型的指针,并且正好指向等同于bft的静态大小的内存的下一个位置。因此 bft->_buff
就成为了一个跟在静态类型屁股后面的动态缓存区,用户可以拿它来存储一些有用的信息。这样的操作通过new运算符就不可能实现。
正确地处理内存请求中的异常
计算机系统中的内存总是显得不够用,因而,请求一块过大的内存就有可能失败。在C语言中,malloc函数通过返回一个空指针来表明内存请求失败。C++中,new运算符则是会抛出一个异常类 bad_alloc
(定义在头文件
<1><!--1-->
处的异常处理过程通常包含重新请求内存或者放弃请求、报告错误发生的基本信息(位置、原因),重新抛出异常或调用 terminate()
来终止程序,等等。然而,内存请求失败往往是程序的硬伤,基本上是无可挽回的,因而这里的异常处理步骤也可以仅仅是报告异常发生的基本信息,然后立即重新抛出异常。所以,我们可以写出下面的函数:
函数 Alloc
接受一个字符串 debug_info
作为参数。用户调用 Alloc
时,可以以字符串的形式传入一些有利于debug的信息(例如,调用 Alloc
的文件、函数和行标等);当 Alloc
捕捉到内存请求异常时,就可以打印出这些信息。 <1><!--1-->
处的 try-catch
语句对实现对异常的捕获和处理,而 <2><!--2-->
处 catch
块内仅仅是对异常进行报告,然后重新抛出异常。有了这个函数后,动态内存分配请求就可以写为(以 int
类型为例)
这样就避免了每次都写一个长长的 try-catch
语句,但同时又能够准确定位bug发生的地点。
有的时候,我们往往希望能够给new运算符提供一些额外的参数来使用相应类型的非默认构造函数来初始化对象,这就需要给我们的 Alloc
函数增加一些参数来传递给new运算符。另外,有了 Alloc
函数后,我们也需要书写相应的内存释放函数。下面的例子展示了可以传递参数的 Alloc
和内存释放函数 DeAlloc
:
上面的 Alloc
函数利用了 C++
的参数”完美”转发机制,首先声明参数包 args
,然后在 <1><!--1-->
处利用标准库的 forward
模板函数(定义在头文件
这里 Alloc
和 DeAlloc
完全可以安全地替代原生的new和delete运算符。请把这两个函数放进你自己的函数包里,并且总是优先地使用这两个函数而不是new和delete,因为他们能正确地报告异常的位置,并且安全的悬空了释放内存后的指针。有的时候,如果内存的申请比较频繁, Alloc
的 const string &
参数可能会降低程序运行的效率,你可以将它改成 const char *
,或者取消这个参数(虽然,我极不推荐这样做)。至于这里的模板函数可能带来的代码膨胀问题,你可以忽略掉。
同样的,我们也可以对new [ ]和delete [ ]这两个运算符书写相应的更安全的函数,步骤是类似的:
注意 <1><!--1-->
这里 AllocArr
的第一个参数类型是 long
而不是 size_t
类型(而运算符new [ ]接收的参数类型是 size_t
),这样设计是为了避免用户不小心传入一个负的整数进来。因为有的时候,数组大小需要根据运行结果动态决定,倘若用户书写了如下程序:
而这时,使用者不小心输入了一个负数,那么 <1><!--1-->
处的内存请求将会产生灾难性的后果(因为负数转化为无符号数时,往往会得到一个很大的正数)。 AllocArr
通过将第一个参数设置为有符号的 long
类型,并在函数体内检测输入是否为负数,从而避免了这一问题的发生。和 Alloc
不同的是, AllocArr
在 <2><!--2-->
处使用的是new [ ]运算符,因而在 <3><!--3-->
处也要相应地使用delete [ ]来释放。这样,对于数组的动态分配和释放就可以这样调用(以 int
类型为例):
我们的 AllocArr
和 DeAllocArr
总是应该成对使用,并且可以替代原生的new [ ]和delete [ ]运算符。请把它们也放入你的函数包里,并尽可能优先使用它们。
I/O相关的错误
文件开启时的检查
当你试图开启一个文件时,某些不可预测的因素(例如,写错了文件名,或者权限不够,等等)可能会导致你开启文件失败。这时,如果直接对相应的流进行I/O操作往往会出现意料之外的结果。因此,对流进行I/O操作时,必须保证流处于正常的状态,其对应的文件也必须是开启的。
这里展示一个易犯的错误。例如,你的设备上有一个名为”data.dat”的数据文件,内容只有一行,如下:
现在,你希望读取文件中的三个整数,于是书写了下面的程序:
其中 ifstream
是包含在头文件 <fstream></fstream>
中的输入文件流类型,用于对文件进行读取。在笔者的屏幕上,正确地输出了文件中的内容。然而,很多情况下,由于文件名太长或者不小心手滑,我们很容易就把文件名写错了。例如你不小心把 <1><!--1-->
这一行行写成了 ifstream fin("date.dat");
。现在就完蛋了,在笔者的屏幕上输出了 0 1563473512 32764
这三个数。这是因为 ifstream
初始化时,找不到错误的名为 date.dat
的文件,因此不能成功打开这个文件,因此这个流就处于错误的状态。后续的输入操作 >>
都不能成功进行,所以变量 i
, j
, k
都会保留原来处在内存中的值。可怕的是, C++
并不会在运行时动态地检查流的状态并给出报错,因而程序继续运行,所以作者就得到了错误的输出。为了避免这个问题, C++
为fstream的流类型提供了一个名为 is_open
的类方法,用于检查流是否成功地打开了文件。因此,正确的做法是,在们每次打开文件的时候,都必须检查文件是否成功被打开。例如:
在这里,如果文件没有成功打开,流类型的 is_open
方法将返回 false
,而用户则得到错误提示,并被抛出一个异常。
虽然关于文件是否成功开启的检查是必须的,但是,如果每次都写这样一个长长的语句,会显得很麻烦。因此,可以书写下面的通用函数来执行检查:
这样的话,今后打开文件时,只需要调用这个函数进行检查即可:
按照笔者的经验,每次打开文件时,文件是否成功被打开的检查必须进行。坚持这样的检查可以大大的减少程序出bug的几率。所以,请把上面的函数 checkInFile
加入你的函数库里,并且总是调用它。你同时还需要仿造这个函数写一个检查输出流对应的文件是否被成功打开的函数,名为 checkOutFile
。
容器类型使用中的常见错误
相比于 C
语言来讲, C++
的标准模板库提供了丰富的容器类型,并且为这些容器提供了高度一致的接口。按笔者个人的理解, C++
的标准容器库具有以下特点:
- 高度的泛型化:通过使用模板编程技术,
C++
的容器库可以存放几乎任意类型的变量。 - 一般来讲,只要一个类型具有一个构造函数,它就可以被添加到容器中去。
- 另外,大部分容器和相应的适配器都提供迭代器用于遍历容器中的元素,同时它们也能被使用到基于范围的
for
循环中去。这为程序编写提供了很大的遍历。 - 由于容器接口的统一性,
C++
得以实现一个标准的算法库(定义在头文件<algorithm></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++
标准容器库,我们可能很少需要手动的来管理内存。然而有时候我们不得不自己书写容器类型,这是因为标准库容器要同时兼具高效性、安全性和泛型性,因而必须在这三者之间折中考量。有的时候我们会突出地要求高效性,那么就不得不自己书写容器。此外,标准库容器缺少非线性结构,例如树和图等,我们必须手动书写这些数据结构。要实现一个容器,典型的方法是下面这样(以整形矢量类型为例)
在构造函数中这里就包含了内存请求的 new
操作,而析构函数中则通过 delete
释放了内存。为了安全起见我们还把拷贝构造函数和拷贝赋值运算符设为 delete
的。最后,我们还定义了一个尾增操作 push_back()
和取成员操作 operator[]
。
定位new运算符和内存重用
new
运算符完成对对象所需内存的分配(allocate)和对象的构造工作(construct)。然而,如果我们需要大量的构造、析构动态对象,那么过于频繁地请求内存(典型的情况是,链式/树式/图式结构中节点的内存请求)可能会导致效率的严重下降(通常的操作系统中,new运算符需要通过一次中断来请求内存页(pages),这会使得操作系统陷入内核态,执行请求,然后回到用户态; delete
时也是这样)。为此,最佳的方法是将内存请求和对象构造分开来,复用曾经已经分配的内存。标准库的头文件 <memory></memory>
中的 allocator
类型提供了这样的操作(见 [1] Primer 12.2.2)。我们这里介绍一个更简单的方法,使用定位(placement)new运算符(包含在头文件 <new></new>
中)来实现内存请求和对象构造的分离(见 [3] Plus 9.2.10和12.5.3)。下面的例子展示了定位 new
运算符的使用
我们首先定义了一个普通的类型 buffer_type
,这是用户经常会定义的类型;它的构造函数里包含了动态内存的分配,所以必须在析构函数里包含相应的动态内存的释放;为了安全起见,我们将 buffer_type
的拷贝操作设置为delete的,以避免可能的拷贝举动造成的浅复制和重复的内存释放。
使用定位new运算符首先需要请求一段内存空间,我们在 <1><!--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><!--3--><!--2-->
两处,我们分别在已有内存中构造了一个浮点数和10个整数。 <4><!--4-->
处则是构造了一个 buffer_type
对象;由于 buffer_type
类型存在析构函数,因此使用完毕后( <5><!--5-->
处)必须手动释放。 <6><!--6-->
处则在已经使用过的内存上重新构造了一个对象,它覆盖掉了原来曾经占用过这里内存的对象。
在笔者的机器上,打印出了
由于在笔者电脑上, p1
指向8字节浮点,所以 p2
的值比 p1
的值多8;同样 p3
比 p2
的值多了40,表示10个4字节整数;而 p4
又回到了 p1
的位置。定位 new
运算符的使用为 C++
内存操作提供了很大的灵活性,但是也带来了巨大的风险。我们之后将提到其中一些。
Original: https://www.cnblogs.com/ybqjymy/p/16505604.html
Author: 一杯清酒邀明月
Title: C++菜鸟经验:如何有效地避免各种不期而遇的Bug
原创文章受到原创版权保护。转载请注明出处:https://www.johngo689.com/535442/
转载文章受原作者版权保护。转载请注明原作者出处!