欧易

欧易(OKX)

国内用户最喜爱的合约交易所

火币

火币(HTX )

全球知名的比特币交易所

币安

币安(Binance)

全球用户最多的交易所

C++编程调试秘笈:c++的缺陷来自哪里?

2022-10-09 17:40:43 3224

摘要:C++语言是非常独特的。虽然实际上所有的编程语言都从其他语言中吸收了一些思路、语法元素和关键字C++却是吸收了另一种完整的语言,即C语言。事实上, C++语言的创建者Bjarne Stroustrup原先把他的新语言命名为"带类的C"。这意...

C++语言是非常独特的。虽然实际上所有的编程语言都从其他语言中吸收了一些思路、语法元素和关键字C++却是吸收了另一种完整的语言,即C语言。事实上, C++语言的创建者Bjarne Stroustrup原先把他的新语言命名为"带类的C"。这意味着如果我们已经使用了一些C代码,并且由于某种原因(例如科研或贸易)切换到一种面向对象的语言,就不需要在移植代码方面采取任何措施,只要安装新的C+编译器,就可以对旧的C代码进行编译了,并且效果和原先的一模一样。我们甚至会觉得已经完成了从C到C++的转换。最后这种想法虽然距离真相还很远,用真正的C++所编写的代码与C代码看上去存在很明显的区别,但它还是提供了一个逐渐过渡的选项。也就是说,我们可以从现在编译运行的C代码出发,逐渐引入用C++所编写的新代码段,慢慢与它们混合在一起,最终实现到纯C++的切换。因此, C++的层次式设计具有它独特的市场推动力。

但是,其中还是存在一些复杂的地方:随着C的完整语法被新语言完整地吸收,它的设计哲学和存在的问题也同样被吸收。C语言是在1969年~1973年期间由Dennis Rithie在贝尔实验室创建的,其出发点是为了编写Unix操作系统。这项工作的一个伴随成果是诞生了一种高效的高级编程语言(与需要编写每条计算机指令的汇编语言相比)。也就是说,它所产生的编译后的代码应该具有尽可能快的速度。这种新的C语言的其中-项公开原则是,用户不应该为他没有使用到的特性而受到拖累。因此,为了追求高效的编译代码,对于程序员没有提出明确的要求, C就绝对不会加以考虑。C语言是为了速度而不是为了舒适而创建的,这就产生了一些问题。

首先,程序员可以创建一个某种长度的数组,并用一个超出该数组边界的索引值访问一个元素。更容易被滥用的是C的指针运算,程序员可以把指针运算所产生的任何值作为内存地址并对它进行访问,不管这块内存是否应该被访问。(实际上,这两个问题其实是同一个,只不过使用了不同的语法。)

程序员还可以在运行时使用calloc()和malloc()函数动态分配内存,并使用free()函数负责动态内存的销毁。但是,如果忘了销毁或者不小心销毁了多次,其结果可能是灾难性的。

我们将在本书的第二部分深入讨论这些问题中的每一个。需要重视的是, C++在继承整个C时,除了传承它的高效原则,还继承了它的所有问题。因此, C++代码中的部分缺陷就来源于C。但是,故事并没有结束。除了来自于C的问题, C++自身也存在一些问题。例如,大多数人认为友函数和多重继承并不是良好的编程思路。C++具有自己分配内存的方法,它并不是调用像calloc()或malloc()这样的函数,而是使用操作符new, new操作符并不仅仅分配内存,它还创建对象,即调用它们的构造函数。与C的精神相同,使用delete操作符删除动态分配的内存是程序员的责任。现在的情况看起来与C相同:我们分配了一些内存,然后删除它。但是,复杂之处在于C++具有两种不同的new操作符:

在第一种情况下, new操作符创建了一个MyClass类型的对象。在第二种情况下,它创建了一个相同类型的对象数组。与之对应的是, C++具有两种不同的delete操作符:

当然,一旦使用了"带方括号的new"创建对象,就需要使用"带方括号的delete"删除它们。这样就可能导致一种新的错误:混用new和delete ,其中一个带了方括号而另一个没有带方括号。如果出现了这种错误,就会对内存堆产生巨大的破坏。因此,我们可以总结如下: C++的缺陷大部分来源于C,但C++t引入了一些自讨苦吃的新方法。我们将在本书的第二部分讨论这些话题。

2.1 为什么编译器是捕捉缺陷的最好场合

如果在编译时捕捉缺陷与在运行时捕捉缺陷之间进行选择,只要有可能,都应该在编译时捕捉缺陷。这样做的理由有很多。首先,如果一个缺陷是被编译器所检测到的,我们将看到一条文本信息,准确描述了所发生的错误是什么,它是在哪里发生的,发生在哪个文件以及发生在哪一行。(作者在这里可能稍微有点乐观,因为在有些情况下,尤其是在涉及STL时,编译器所产生的错误信息是相当含糊的,需要花费精力才能推断出编译器实际所描述的含义。但是,编译器总是在不断地完善中,大多数情况下它们对问题的描述是相当清晰的。)

另一个理由是完整的编译(进行了最终链接)覆盖了程序中的所有代码。如果编译器没有返回错误或警告就可以百分之百地确信程序中不存在编译时可以检测到的错误。但对于运行时测试,就不能做出这样的保证。当代码相当庞大时,很难保证所有可能的分枝都被测试到,也无法保证每一行代码都至少执行1次。即使我们能够保证这一点,仍然不够。同一段代码对于一组输入可能正确地完成任务,但对于另一组输入可能无法正确地工作。因此,通过运行时测试,我们永远无法完全保证对所有东西都进行了测试。

最后还存在时间因素:我们在运行代码之前执行编译,因此如果在编译时捕捉到了错误,就可以节省时间。有些运行时错误是在程序的后期出现的,因此可能要等几分钟甚至几小时的运行之后才会发现一个错误。更糟的是,这种错误很可能是无法复制的,它可能以一种看上去随机的方式,在连续运行时出现并消失。相比之下,在编译时捕捉错误就简单得多!

2.2 怎样用编译器捕捉缺陷

现在我们应该已经坚信,只要有可能,就尽量在编译时捕捉错误。但是,怎样才能实现这个目标呢?让我们观察一对例子。

第一个例子是一个Variant类的故事。曾几何时,一家软件公司编写了一个Excel插件。这是一个文件,被Microsoft Excel打开之后向它添加了一些新功能,可以在Excel单元格中被调用。由于Excel单元格可以包含不同类型的数据,包括整数(例如1 )、浮点数(例如3.1415926535)、日期(例如1/12000)甚至是字符串("This is the house that Jack built") ,因此这家公司开发了一个Variant类,它的行为类似于变色龙,可以包含任意上述数据类型。但是,随后有人提出了一个思路,就是一个Variant对象可以包含另一个Variant对象,甚至可以包含一个Variant类型的vector (即std:: vector<Variant> )。这些开始被使用的Variant对象并不仅仅与Excel进行通信,还与内部代码进行通信。因此,当我们观察函数的签名时:

很显然,完全没有办法理解这个函数期望接受什么类型的数据,以及它将返回什么类型的数据。因此,如果它期望接受一个日期数据,而我们向它传递了一个无法组成日期的字符串,这个错误只能在运行时才能被检测到。正如我们刚才讨论的那样,应该尽量在编译时发现错误。因此,这种方法使我们无法使用编译器通过类型安全轻松地捕捉到错误。这个问题的解决方案将在后面讨论,不过简洁的答案就是用不同的C++类表示不同的数据类型。

上面这个例子是真实的,但有些极端。下面是一个更加典型的情况。假设我们正在处理一些金融数据(例如股票的价格) ,并且为每个值加上对应的时间戳,即这个价格被观察时的日期和时间。那么我们应该怎样对时间进行测量呢?最简单的解决方案是对过去某个时间(例如1/1/1970)以来的秒数进行计数。

有人突然意识到实现了这项功能的函数库所提供的是32位的整数,最大值约为20亿左右。如果超过了这个最大值,就会发生溢出而成为负数。在距离时间轴的起点大约68年之后(即2038年)就会发生这种情况。它所导致的问题与著名的"千年虫”问题相似。为了修正这个问题,可能需要检查相当数量的文件,找到所有这些变量,并把它们的类型更改为int64,后者的长度是64位,能够表示的时间长度是32位整数的40亿倍左右,对于再小心谨慎的人来说都是足够的了。

但是现在又出现了另一个问题。有些程序员使用了int64 num_of_seconds形式,另一些人则使用了int64num_of_millisec形式,还有一些人使用了int64 num_of_microsec形式。编译器绝对没有办法判断出一个接受毫秒时间的函数实际所传递的是表示微秒的时间,反之亦然。当然,我们可以对需要分析的股票价格所处的时间间隔预设一些条件,例如从1990年直到未来的某个时刻(例如3000年) ,然后在运行时增加一项安全检查,确保传递给函数的值必须位于这个时间间隔之内。但是,这将导致许多函数都需要配备这种安全检查,可能需要花费大量的人力。如果有人在将来决定回过头来分析20世纪期间的股票价格又会怎么样呢?

2.3处理类型的正确方式

现在,如果我们创建一个Time类,在内部实现中隐藏了从什么时间开始,以及用什么时间单位(秒、毫秒等)进行测量等细节,上面这些杂七杂八的问题就可以轻松得以避免。这种方法的一个优点是如果我们错误地传递了其他日期数据,而不是传递了时间(现在用Time类型表示) ,编译器马上就能捕捉到这种错误。这种方法的另一个优点是,如果Time类当前是用毫秒实现的,并且以后为了提高精度用微秒表示,我们只需要编辑一个类,修改内部实现的细节,而不会影响其余的代码。

因此,我们怎样才能在编译时而不是在运行时捕捉类型错误呢?我们首先可以用一个单独的类表示每种类型的数据。我们用int表示整数,用double表示浮点数,用std::string表示文本,用Date表示日期,用Time表示时间,对于其他类型的数据也都用一个单独的类表示。但是,只采用这种做法仍然是不够的。假设我们有两个类Apple和Orange ,并有一个期望接受一个Orange类型的参数的函数:

但是,我们可能不小心向它提供了APPle类型的对象:

在有些情况下,这样的代码可以通过编译,因为C++编译器试图向我们提供帮助。只要可能,它会把Apple平静地转换为Orange,这可能通过以下两种方式发生。

(1)如果Orange类具有一个只接受一个Apple类型的参数的构造函数。

(2)如果Apple类具有一个可以把它转换为Orange的操作符。

当Orange类具有下面这样的定义时,就会发生第一种情况:

它甚至可以像下面这样:

即使在最后这个例子中,构造函数看上去像是具有两个输入,但它也可以只用一个参数就可以被调用,因此它也可以隐式地把Apple转换为Orange,这个问题的解决方案是用关键字explici声明这类构造函数。这种做法可以防止编译器执行自动(隐式)转换,这样我们就可以迫使程序员在期望接受Orange的地方必须使用Orange :

第二个例子需要对应地修改为:

另一种让编译器知道怎么把Apple转换为Orange的方法是提供一个转换操作符:

这个操作符在此处的出现是非同寻常的,说明程序员用一种明确的方式向编译器提供了一种把Apple转换为Orange的方法,它并不是什么错误。因此,对所有接受一个参数的构造函数用关键字explicit进行声明,这是值得推荐的做法。一般而言,隐式转换的所有可能性都是不好的思路。因此,如果想按照上面这个例子一样在Apple类中提供一种把Apple转换为Orange的方法,下面是一种更好的方法:

在这个例子中,为了把Apple转换为Orange,需要采用下面的方式:

另外还有一种方法可以混合不同的数据类型,即使用枚举(enum)。考虑下面这个例子:假设我们定义了下面这两个枚举,分别表示一周中的某天以及月份。

这些常量实际上都是整数(例如,C内置的int类型)。如果我们有一个期望接受一周中的某天作为参数的函数:

下面这个调用将会在不产生任何警告的情况下通过编译:

在运行时,我们能顾采取的措施不多,因为JAN和MON都是与1相等的整数。捕捉这类缺陷的方法是不使用创建整数的“单纯功能”枚举,而是使用创建新类型的枚举:

在这种情况下,期望接受一周中的某天为参数的函数将被声明为:

像下面这样试图用一个Month值调用这个函数:

将会产生编译错误:

这正是我们在这个例子中期待产生的效果。

但是,这种方法具有一个消极因素。在这个例子中,用枚举创建整型常量时,我们可以编写如下的代码:

但是当我们使用枚举创建新类型时,如下面的写法:

就无法通过编译。因此,如果我们需要迭代枚举的值,可以像原来一样使用整数。

当然,任何规则都有例外,有时候程序员有理由编写像Variant这样的类,允许进行隐式类型转换以满足特定的需要。但是,绝大多数时候应该完全避免隐式类型转换,这就允许我们充分利用编译器检查不同变量类型的功能,早期(即在编译时)捕捉潜在的错误。

现在,假设我们已经尽自己所能使用了类型安全。遗憾的是,除了bool和char类型之外,每种类型可能包含的值的数量都是天文数字,通常只有一小部分值是合理的。例如,如果我们使用double类型表示股票的价格,可以很合理地确定股票的价格将在0到10 000之间波动(唯一的例外是Berkshire Hathaway公司的股票,它的主人Warren Buffet显然并不相信把股票价格保持在合理范围内是个好主意,因此他从不对股票进行除权,在本书写作之时这个股票的价格是每股10万美元)。但即使是Berkshire Hathaway这样的股票,它的价格仍然只使用了double类型的很小一部分,因为double的范围高达

,并且还包含了完全不适合表示股票价格的负数。由于大多数类型只有一小部分值是合理的,因此总是存在一些只能在运行时才能诊断的错误。

事实上,C语言的大多数问题,例如指定了越界索引,或通过指针运算不恰当地访问内容,只能在运行时才能得到诊断。由于这个原因,本书的剩余部分主要专注于讨论捕捉运行时错误。

本章所讨论的在编译时诊断错误的规则如下。

1、禁止隐式类型转换:用关键字explicit声明一个接受1个参数的构造函数,并避免使用转换操作

符。

2、用不同的类表示不同的数据类型。

3、不要使用枚举创建整型常量,而是用它们创建新类型。

本文节选自《C++编程调试秘笈》

本书介绍了C++程序员经常犯的一些编程错误,并且给出了可以用来避免这些错误的规则。本书基于C++开发者社群的实践,介绍了如何安全地使用C++库。


专栏

C Primer Plus官方视频解读

作者:异步社区

¥99

5人已购

查看

版权声明:本站所有文章皆是来自互联网,如内容侵权可以联系我们( 微信:bisheco )删除!

相关推荐

友情链接
币圈社群欧易官网