C++菜鸟经验:如何有效地避免各种不期而遇的Bug

本文展示了笔者在编写 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模板函数(定义在头文件

这里 AllocDeAlloc完全可以安全地替代原生的new和delete运算符。请把这两个函数放进你自己的函数包里,并且总是优先地使用这两个函数而不是new和delete,因为他们能正确地报告异常的位置,并且安全的悬空了释放内存后的指针。有的时候,如果内存的申请比较频繁, Allocconst 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类型为例):

我们的 AllocArrDeAllocArr总是应该成对使用,并且可以替代原生的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的文件,因此不能成功打开这个文件,因此这个流就处于错误的状态。后续的输入操作 >>都不能成功进行,所以变量 ijk都会保留原来处在内存中的值。可怕的是, 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)。

正是因为标准容器库的容器具有上述优点,用户应该坚持使用这些容器,而不应该显式的使用其低级接口,或是手动的使用 newmalloc等动态类型管理机制来管理内存。我们下面将指出如果滥用低级接口、或是手动管理内存来实现容器的时候容易发生的错误。另外我们还要指出一些即便是使用标准容器类型也可能会发生的错误。

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;同样 p3p2的值多了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/

转载文章受原作者版权保护。转载请注明原作者出处!

(0)

大家都在看

  • C++11 右值引用和转移语义

    新特性的目的 右值引用 (Rvalue Referene) 是 C++ 新标准 (C++11, 11 代表 2011 年 ) 中引入的新特性 , 它实现了转移语义 (Move Se…

    C++ 2023年5月29日
    080
  • Google C++ 单元测试 GTest

    from : http://www.cnblogs.com/jycboy/p/6057677.html 一、设置一个新的测试项目 在用google test写测试项目之前,需要先编…

    C++ 2023年5月29日
    035
  • C++11 列表初始化

    在我们实际编程中,我们经常会碰到变量初始化的问题,对于不同的变量初始化的手段多种多样,比如说对于一个数组我们可以使用 int arr[] = {1,2,3}的方式初始化,又比如对于…

    C++ 2023年5月29日
    049
  • 腾讯研发类笔试面试试题(C++方向)(转)

    原文转自 https://www.cnblogs.com/freebird92/p/9595244.html 1、C和C++的特点与区别? 答: (1)C语言特点:1.作为一种面向…

    C++ 2023年5月29日
    044
  • c++11 auto 与 decltype 详解

    一. auto简介 编程时候常常需要把表达式的值付给变量,需要在声明变量的时候清楚的知道变量是什么类型。然而做到这一点并非那么容易(特别是模板中),有时候根本做不到。为了解决这个问…

    C++ 2023年5月29日
    061
  • C++ lamda、function、bind使用

    参考资料: lambda 表达式的简单语法如下:[capture] (parameters) -> return value { body } 其中[capture]可以选择…

    C++ 2023年5月29日
    042
  • [C++] const与重载

    下面的两个函数构成重载吗? cpp;gutter:true; void M(int a){} //(1) void M(const int a){} //(2)</p>…

    C++ 2023年5月29日
    083
  • 用C++实现半透明按钮控件(PNG,GDI+)

    使用MFC实现上面的按钮半透明效果能看到父窗口中的内容,上面是效果图(一个是带背景图片的、另一个是不带的)。 控件继承自CWnd类(彩色的部分是窗口的背景图片、按钮是PNG图片,第…

    C++ 2023年5月29日
    080
  • c++ union内存

    看一个例子: 输出结果: 为什么是这样的呢? 因为A是union,所以在内存中存储的格式为: 高地址 ————> 低地址 12…

    C++ 2023年5月29日
    047
  • UML——从类图到C++

    简易软件开发流程 实践中,use case and description、class diagram与sequence diagram三者搭配,几乎是UML项目的基本类型,所以在…

    C++ 2023年5月29日
    076
  • 【面试攻略】C++面试-边锋

    2020-11-26-边锋 1.说说你以前的架构2.C++11特性,好像问到了这个constexpr https://www.jianshu.com/p/5480c4a35d1d3…

    C++ 2023年5月29日
    056
  • 【C/C++】sscanf函数和正则表达式

    此文所有的实验都是基于下面的程序: char str[10]; for (int i = 0; i < 10; i++) str[i] = ‘!’; 执行完后str的值为 s…

    C++ 2023年5月29日
    045
  • 深入理解c++构造函数, 复制构造函数和赋值函数重载(operator=)

    以下代码编译及运行环境均为 Xcode 6.4, LLVM 6.1 with GNU++11 support, Mac OS X 10.10.2 调用时机 看例子 1,2,3,4 …

    C++ 2023年5月29日
    068
  • C++:vector中的resize()函数 VS reserve()函数

    http://www.cplusplus.com/reference/vector/vector/vector/ 写代码的时候无意错用了这两个函数 导致测试的时候,程序运行崩溃 发…

    C++ 2023年5月29日
    053
  • c和c++编译器之gcc和mingw

    三大编译器:gcc,llvm,clang 什么是gcc? gcc 官方网站:https://gcc.gnu.org GCC(GNU Compiler Collection,GNU编…

    C++ 2023年5月29日
    068
  • c++builder调用VC的dll以及VC调用c++builder的dll

    解析__cdecl,__fastcall, __stdcall 的不同:在函数调用过程中,会使用堆栈,这三个表示不同的堆栈调用方式和释放方式。比如说__cdecl,它是标准的c方法…

    C++ 2023年5月29日
    065
亲爱的 Coder【最近整理,可免费获取】👉 最新必读书单  | 👏 面试题下载  | 🌎 免费的AI知识星球