C++ 的主要问题之一是存在大量其行为未定义或超出程序员预期的结构。我们在各种项目中使用静态分析器时经常遇到这些问题。但是,众所周知,最好的方法是在编译阶段就检测到错误。让我们看看现代 C++ 中的哪些技术有助于编写不仅简单明了,而且更安全、更可靠的代码。
“现代 C++”这个术语在 C++11 发布后变得非常流行。它意味着什么?首先,现代 C++ 是一系列模式和惯用法的集合,旨在消除许多 C++ 程序员习惯的“带类的 C”的缺点,特别是那些从 C 语言开始编程的程序员。C++11 看起来更加简洁易懂,这一点非常重要。
当人们谈论现代 C++ 时,通常会想到什么?并行性、编译期计算、RAII、lambda 表达式、范围 (ranges)、概念 (concepts)、模块以及标准库中其他同样重要的组件(例如,用于处理文件系统的 API)。这些都是非常酷的现代化特性,我们期待在下一批标准中看到它们。然而,我想提请大家注意新标准如何允许编写更安全的代码。在开发静态分析器时,我们看到了大量各种各样的错误,有时我们不禁会想:“但在现代 C++ 中,这是可以避免的”。因此,我建议我们研究一些 PVS-Studio 在各种开源项目中发现的错误。同时,我们也会看看如何修复它们。
C++ 中增加了 auto 和 decltype 关键字。当然,你已经知道它们是如何工作的。
|
|
在不失代码可读性的情况下,缩短长类型非常方便。然而,这些关键字与模板结合使用时,其作用变得相当广泛:使用 auto 和 decltype 就无需指定返回值的类型了。
但让我们回到主题。这是一个64 位错误的例子。
|
|
在一个 64 位应用程序中,string::npos 的值大于 unsigned 类型变量所能表示的最大值 UINT_MAX。这似乎是 auto 可以拯救我们的一个场景:变量 n 的类型对我们来说不重要,关键是它能容纳 string::find 的所有可能值。确实,如果我们用 auto 重写这个例子,错误就消失了。
|
|
但事情并非如此简单。使用 auto 并非万能药,并且它的使用也存在许多陷阱。例如,你可以这样写代码:
|
|
Auto 无法避免整数溢出,并且为缓冲区分配的内存将少于 5GiB。
在处理一个非常常见的错误——循环写得不正确时,Auto 也没有太大帮助。让我们看一个例子。
|
|
对于大尺寸的数组,这个循环会变成无限循环。代码中出现这样的错误并不奇怪:它们只在非常罕见的情况下才会暴露出来,而这些情况通常没有相应的测试覆盖。
我们能用 auto 重写这个片段吗?
|
|
不能。错误不仅依然存在,甚至变得更糟了。
对于简单类型,auto 的表现非常糟糕。是的,在最简单的情况下(auto x = y)它能正常工作,但一旦出现额外的结构,其行为就可能变得更不可预测。更糟糕的是,错误会更难被注意到,因为变量的类型第一眼看上去并不那么明显。幸运的是,这对静态分析器来说不是问题:它们不会疲倦,也不会分心。但对于我们这些凡人来说,最好还是明确指定类型。我们也可以用其他方法来避免窄化转换,但我们稍后会谈到这一点。
在 C++ 中,“危险”的类型之一是数组。程序员在将数组传递给函数时,常常忘记它实际上是以指针形式传递的,并试图用 sizeof 来计算元素数量。
|
|
注意:此代码取自 Source Engine SDK。
PVS-Studio 警告:V511 在 'sizeof (iNeighbors)' 表达式中,sizeof() 运算符返回的是指针的大小,而不是数组的大小。 Vrad_dll disp_vrad.cpp 60
这种混淆可能是因为在参数中指定了数组的大小:这个数字对编译器没有任何意义,只是给程序员的一个提示。
问题在于,这段代码能够编译通过,而程序员却不知道有什么不对劲。显而易见的解决方案是使用元编程。
|
|
如果我们传递给这个函数的不是一个数组,我们就会得到一个编译错误。在 C++17 中,你可以使用 std::size。
在 C++11 中,增加了 std::extent 函数,但它不适合作为 countof,因为它对不合适的类型返回 0。
std::extent<decltype(iNeighbors)>(); //=> 0
不仅 countof 会出错,sizeof 也会。
|
|
注意:此代码取自 Chromium。
PVS-Studio 警告
如你所见,标准的 C++ 数组有很多问题。这就是为什么你应该使用 std::array:在现代 C++ 中,它的 API 类似于 std::vector 和其他容器,并且使用时更难出错。
|
|
另一个错误的来源是简单的 for 循环。你可能会想,“那里能犯什么错?是跟复杂的退出条件有关,还是为了节省代码行数?”不,程序员在最简单的循环中也会犯错。让我们看看项目中的一些片段。
|
|
注意:此代码取自 Haiku 操作系统。
PVS-Studio 警告:V706 可疑的除法:sizeof (kBaudrates) / sizeof (char *)。'kBaudrates' 数组中每个元素的大小不等于除数。 SerialWindow.cpp 162
我们在上一章已经详细研究了这类错误:数组大小又一次被错误地计算了。我们可以通过使用 std::size 轻松修复它。
|
|
但有更好的方法。让我们再看一个片段。
|
|
注意:此代码取自 Shareaza。
PVS-Studio 警告:V547 表达式 'nCharPos >= 0' 总是为真。无符号类型的值总是 >= 0。 BugTrap xmlreader.h 946
这是编写反向循环时的典型错误:程序员忘记了迭代器是无符号类型,导致检查总是返回 true。你可能会想,“怎么会?只有新手和学生才会犯这种错误。我们专业人士不会。”不幸的是,这不完全正确。当然,每个人都明白 (unsigned >= 0) 是 true。那么这种错误从何而来?它们通常是重构的结果。想象一下这种情况:项目从 32 位平台迁移到 64 位。以前使用 int/unsigned 进行索引,现在决定用 size_t/ptrdiff_t 替换它们。但在某个片段中,他们意外地使用了无符号类型而不是有符号类型。
我们该怎么做才能在代码中避免这种情况?有人建议使用有符号类型,就像 C# 或 Qt 那样。或许,这是一种解决方法,但如果我们要处理大量数据,就无法避免使用 size_t。那么在 C++ 中有没有更安全的方式来遍历数组呢?当然有。让我们从最简单的开始:非成员函数。有一些标准函数可以处理集合、数组和 initializer_list;它们的原理你应该很熟悉。
|
|
太好了,现在我们不需要记住正向循环和反向循环的区别了。也不需要考虑我们用的是普通数组还是其他数组——循环在任何情况下都能工作。使用迭代器是避免头疼的好方法,但即便如此也并非总是足够好。最好使用基于范围的 for 循环。
|
|
当然,range-based for 也有一些缺陷:它不允许灵活地管理循环,如果需要更复杂的索引操作,那么 for 对我们帮助不大。但这种情况应该单独研究。我们遇到的情况很简单:需要按相反顺序遍历元素。然而,在这个阶段,已经出现了困难。标准库中没有为range-based for提供额外的类。让我们看看如何实现它。
|
|
在 C++14 中,你可以通过移除 decltype 来简化代码。你可以看到 auto 如何帮助你编写模板函数——reversed_wrapper 对数组和 std::vector 都适用。
现在我们可以将片段重写如下:
|
|
这段代码好在哪里?首先,它非常易读。我们立刻就能看出元素数组是按相反顺序处理的。其次,它更难出错。第三,它适用于任何类型。这比原来的好多了。
你可以在 boost 中使用 boost::adaptors::reverse(arr)。
但让我们回到最初的例子。在那里,数组是通过一对指针和大小来传递的。很明显,我们关于 reversed 的想法对它不起作用。我们该怎么办?使用像 span/array_view 这样的类。在 C++17 中,我们有 string_view,我建议使用它。
|
|
string_view 不拥有字符串,它实际上是 const char* 和长度的一个包装器。这就是为什么在代码示例中,字符串是按值传递,而不是按引用传递。string_view 的一个关键特性是与各种字符串表示形式的兼容性:const char*、std::string 和非空字符结尾的 const char*。
结果,该函数变成了以下形式:
|
|
传递给函数时,重要的是要记住 string_view(const char*) 的构造函数是隐式的,因此我们可以这样写:
Foo(pChars);
而不是这样:
Foo(wstring_view(pChars, nNumChars));
string_view 指向的字符串不需要以空字符结尾,string_view::data 这个名字本身就暗示了这一点,使用时必须记住这一点。当将其值传递给一个期望 C 字符串的 cstdlib 函数时,你可能会得到未定义行为。如果你测试的大多数情况都使用了 std::string 或以空字符结尾的字符串,你很容易会忽略这一点。
让我们暂时离开 C++,思考一下古老的 C 语言。那里的安全性如何?毕竟,那里没有隐式构造函数调用和运算符、类型转换的问题,也没有各种字符串类型的问题。在实践中,错误常常出现在最简单的结构中:最复杂的结构会被仔细审查和调试,因为它们会引起一些疑问。与此同时,程序员却忘记了检查简单的结构。这里有一个来自 C 语言的危险结构示例:
|
|
一个 Linux 内核的例子。PVS-Studio 警告:V556 比较了不同枚举类型的值:switch(ENUM_TYPE_A) { case ENUM_TYPE_B: ... }。 libiscsi.c 3501
请注意 switch-case 中的值:其中一个命名常量取自不同的枚举。当然,在原始代码中,代码量要大得多,可能的取值也更多,错误也就不那么明显了。原因在于枚举的弱类型——它们可以隐式转换为 int,这为错误留下了很大的空间。
在 C++11 中,你可以而且应该使用 enum class:这样的伎俩在那里行不通,错误将在编译阶段显现出来。因此,下面的代码无法编译,而这正是我们所需要的。
|
|
下面的片段与枚举不完全相关,但有类似的症状。
|
|
注意:此代码取自 ReactOS。
是的,errno 的值被声明为宏,这在 C++ 中(在 C 中也是)是不好的做法,但即使程序员使用了 enum,也不会让事情变得更容易。在 enum 的情况下(尤其是在宏的情况下),丢失的比较不会暴露出来。而 enum class 则不会允许这种情况发生,因为它不会隐式转换为 bool。
但回到 C++ 固有的问题上来。其中之一是在需要在多个构造函数中以相同方式初始化对象时显现出来。一个简单的情况:有一个类,两个构造函数,其中一个调用另一个。这看起来很合乎逻辑:公共代码被放进一个单独的方法里——没人喜欢重复代码。陷阱在哪里?
|
|
注意:此代码取自 LibreOffice。
PVS-Studio 警告:V603 对象已创建但未使用。如果你希望调用构造函数,应该使用 'this->Guess::Guess(....)'。 guess.cxx 56
陷阱在于构造函数的调用语法。程序员常常忘记这一点,从而创建了另一个类实例,该实例随后立即被销毁。也就是说,原始实例的初始化并没有发生。当然,有 1001 种方法来修复这个问题。例如,我们可以通过 this 显式调用构造函数,或者把所有东西都放进一个单独的函数中。
|
|
顺便说一句,显式地重复调用构造函数,例如通过 this,是一个危险的游戏,我们需要理解到底发生了什么。使用 Init() 的变体要好得多,也更清晰。对于那些想更好地理解这些“陷阱”细节的人,我建议看看这本书的第 19 章,“如何从一个构造函数正确调用另一个构造函数”。
但最好在这里使用构造函数委托。这样我们就可以用以下方式从一个构造函数显式调用另一个构造函数。
|
|
这类构造函数有一些限制。第一:委托构造函数全权负责对象的初始化。也就是说,你将无法在初始化列表中用它来初始化另一个类字段。
|
|
当然,我们必须确保委托不会造成循环,因为那样将无法退出。不幸的是,这段代码能够编译通过。
|
|
虚函数隐藏了一个潜在的问题:问题在于,在派生类的签名中很容易出错,结果不是覆盖一个函数,而是声明一个新函数。让我们在下面的例子中看看这种情况。
|
|
Derived::Foo 方法无法通过指向 Base 的指针/引用来调用。但这是一个简单的例子,你可能会说没人会犯这样的错误。通常人们会犯以下错误:
注意:此代码取自 MongoDB。
|
|
PVS-Studio 警告:V762 考虑检查虚函数参数。请参见派生类 'DBDirectClient' 和基类 'DBClientBase' 中 'query' 函数的第七个参数。 dbdirectclient.cpp 61
参数很多,而派生类函数中没有最后一个参数。这是两个不同的、不相关的函数。这种错误经常发生在带有默认值的参数上。
在下一个片段中,情况稍微复杂一些。这段代码如果作为 32 位代码编译会正常工作,但在 64 位版本中则不会。最初,在基类中,参数是 DWORD 类型,但后来被修正为 DWORD_PTR。然而,在继承的类中却没有相应地修改。准备好迎接不眠之夜、调试和咖啡吧!
|
|
你还可能以更奇特的方式在签名上出错。你可能会忘记函数的 const 属性,或者某个参数的 const 属性。你可能会忘记基类中的函数不是虚函数。你可能会混淆 signed/unsigned 类型。
在 C++ 中,添加了几个可以规范虚函数覆盖的关键字。Override 将会很有帮助。这段代码根本无法编译。
|
|
使用 NULL 来表示空指针会导致许多意想不到的情况。问题在于 NULL 是一个普通的宏,它展开为 0,而 0 的类型是 int:因此不难理解为什么在这个例子中会选择第二个函数。
|
|
尽管原因很清楚,但这非常不合逻辑。这就是为什么需要 nullptr,它有自己的类型 nullptr_t。因此,在现代 C++ 中我们不能使用 NULL(更不用说 0 了)。
另一个例子:NULL 可以用来与其他整数类型进行比较。假设有一个 WinAPI 函数返回 HRESULT。这个类型与指针没有任何关系,所以它与 NULL 的比较是无意义的。而 nullptr 通过发出一个编译错误强调了这一点,而 NULL 却能正常工作。
|
|
有些情况下,需要传递不定数量的参数。一个典型的例子是格式化输入/输出函数。是的,可以写成不需要可变数量参数的方式,但我认为没有理由放弃这种语法,因为它更方便、更易读。旧的 C++ 标准提供了什么?它们建议使用 va_list。这有什么问题呢?向这样的参数传递错误类型的参数太容易了。或者根本不传递参数。让我们仔细看看这些代码片段。
|
|
注意:此代码取自 Chromium。
PVS-Studio 警告:V510 'AtlTrace' 函数不期望接收类类型变量作为第三个实际参数。 delegate_execute.cc 96
程序员想打印 std::wstring 字符串,但忘记调用 c_str() 方法。因此,wstring 类型将在函数中被解释为 const wchar_t*。当然,这不会有什么好结果。
|
|
注意:此代码取自 Cairo。
PVS-Studio 警告:V576 格式不正确。请考虑检查 'fwprintf' 函数的第三个实际参数。期望的是指向 wchar_t 类型符号字符串的指针。 cairo-win32-surface.c 130
在这个片段中,程序员混淆了字符串格式说明符。问题在于,在 Visual C++ 中,wprintf 的 %s 期望的是 wchar_t*,而 %S 期望的是 char*。有趣的是,这些错误出现在用于错误输出或调试信息的字符串中——这肯定是罕见的情况,所以被忽略了。
|
|
注意:此代码取自 CryEngine 3 SDK。
PVS-Studio 警告:V576 格式不正确。请考虑检查 'sprintf' 函数的第四个实际参数。期望的是有符号整数类型参数。 igame.h 66
整数类型也很容易混淆。特别是当它们的大小依赖于平台时。不过,这里的情况更简单:有符号和无符号类型被混淆了。大数将被打印为负数。
|
|
注意:此代码取自 Word for Windows 1.1a。
PVS-Studio 警告:V576 格式不正确。调用 'printf' 函数时期望的实际参数数量不同。期望:3。实际:1。 dini.c 498
这个例子是在一次考古研究中发现的。这个字符串预设了三个参数,但它们没有被写入。也许程序员打算打印堆栈上的数据,但我们无法假设那里有什么。当然,我们需要明确地传递这些参数。
|
|
注意:此代码取自 ReactOS。
PVS-Studio 警告:V576 格式不正确。请考虑检查 'swprintf' 函数的第三个实际参数。要打印指针的值,应使用 '%p'。 dialogs.cpp 66
一个 64 位错误的例子。指针的大小取决于架构,使用 %u 来表示它是个坏主意。我们应该用什么来代替呢?分析器提示我们正确的答案是 %p。如果指针是为调试而打印,那还好。如果之后试图从缓冲区中读取并使用它,那就更有趣了。
带可变数量参数的函数有什么问题?几乎所有事情都可能出错!你无法检查参数的类型,也无法检查参数的数量。一步走错,就是未定义行为。
幸好有更可靠的替代方案。首先,有可变参数模板 (variadic templates)。借助它们,我们在编译期间就能获得所有传递类型的信息,并可以随心所欲地使用它。举个例子,让我们使用那个 printf,但是是一个更安全的版本。
|
|
当然这只是一个例子:在实践中使用它毫无意义。但在可变参数模板的情况下,你只受限于你的想象力,而不是语言特性。
另一个可以作为传递可变数量参数选项的结构是 std::initializer_list。它不允许你传递不同类型的参数。但如果这已经足够,你可以使用它。
|
|
遍历它也非常方便,因为我们可以使用 begin、end 和基于范围的 for 循环。
窄化转换 (Narrowing casts) 给程序员带来了很多头疼的问题。尤其是在向 64 位架构迁移变得越来越必要的时候。如果你的代码中只有正确的类型,那就很好。但情况并非总是那么乐观:程序员经常使用各种肮脏的技巧,以及一些存储指针的奇特方式。找出所有这样的片段需要消耗大量的咖啡。
|
|
但让我们暂时放下 64 位错误的话题。这里有一个更简单的例子:有两个整数值,程序员想求它们的比率。他是这样做的:
|
|
注意:此代码取自 Source Engine SDK。
PVS-Studio 警告:V636 表达式从 'int' 类型隐式转换为 'float' 类型。考虑使用显式类型转换以避免小数部分丢失。例如:double A = (double)(X) / Y;。 Client (HL2) detailobjectsystem.cpp 1480
不幸的是,无法完全防止这类错误——总会有另一种方式将一种类型隐式转换为另一种。但好消息是 C++11 中的新初始化方法有一个很好的特性:它禁止窄化转换。在这段代码中,错误将在编译阶段发生,并且可以轻松修正。
|
|
在资源和内存管理方面,犯错的方式有很多种。使用的便利性是现代语言的一个重要要求。现代 C++ 也不落后,提供了一系列用于自动控制资源的工具。尽管这类错误是动态分析的核心领域,但有些问题可以通过静态分析来揭示。以下是一些例子:
|
|
注意:此代码取自 Chromium。
PVS-Studio 警告:V554 auto_ptr 使用不正确。用 'new []' 分配的内存将用 'delete' 清理。 interactive_ui_tests accessibility_win_browsertest.cc 171
当然,智能指针的想法并不新鲜:例如,曾经有一个类 std::auto_ptr。我用过去时态谈论它,是因为它在 C++11 中被声明为已弃用,并在 C++17 中被移除。在这个片段中,错误是由不正确使用 auto_ptr 引起的,该类没有针对数组的特化,结果是标准的 delete 将被调用,而不是 delete[]。unique_ptr 取代了 auto_ptr,它有针对数组的特化,能够传递一个将在 delete 之外调用的 deleter 仿函数,并完全支持移动语义。看起来这里不会出什么问题了。
|
|
注意:此代码取自 nana。
PVS-Studio 警告:V554 unique_ptr 使用不正确。用 'new []' 分配的内存将用 'delete' 清理。 text_editor.cpp 3137
事实证明,你还是可以犯完全相同的错误。是的,只要写成 unique_ptr<unsigned[]> 错误就会消失,但即便如此,代码以这种形式也能编译。因此,用这种方式也可能犯错,而且实践表明,如果可能,人们就会这么做。这个代码片段就是证明。因此,在使用 unique_ptr 处理数组时,要格外小心:搬起石头砸自己的脚比想象中要容易得多。或许,按照现代 C++ 的规定,使用 std::vector 会更好?
让我们看另一种类型的事故。
|
|
注意:此代码取自 Unreal Engine 4。
PVS-Studio 警告:V611 内存是使用 'new T[]' 运算符分配的,但却是使用 'delete' 运算符释放的。请检查这段代码。最好使用 'delete [] Code;'。 openglshaders.cpp 1790
即使不使用智能指针,也很容易犯同样的错误:用 new[] 分配的内存通过 delete 释放。
|
|
注意:此代码取自 CxImage。
PVS-Studio 警告:V611 内存是使用 'new' 运算符分配的,但却是使用 'free' 函数释放的。请检查 'ptmp' 变量背后的操作逻辑。 ximalyr.cpp 50
在这个片段中,malloc/free 和 new/delete 被混淆了。这可能发生在重构期间:有一些来自 C 的函数需要被替换,结果就导致了未定义行为 (UB)。
|
|
注意:此代码取自 Fennec Media。
PVS-Studio 警告:V575 空指针被传递到 'free' 函数中。请检查第一个参数。 settings interface.c 3096
这是一个更有趣的例子。有一种做法是在指针被释放后将其置零。有时,程序员甚至会为此编写专门的宏。一方面,这是一个很好的技巧:你可以保护自己免受再次释放内存的风险。但在这里,表达式的顺序被搞混了,因此,free 得到了一个空指针(这没有逃过分析器的注意)。
|
|
但这个问题不仅与内存管理有关,也与资源管理有关。例如,你忘记关闭文件,就像上面的片段一样。在这两种情况下,关键字都是 RAII。智能指针背后也是同样的概念。结合移动语义,RAII 有助于避免大量与内存泄漏相关的 bug。而用这种风格编写的代码可以更直观地识别资源所有权。
作为一个小例子,我将提供一个利用 unique_ptr 功能的 FILE 包装器。
|
|
不过,你可能想要一个功能更强的(语法更易读的)文件操作包装器。是时候记住,在 C++17 中,将添加一个用于处理文件系统的 API——std::filesystem。但如果你对这个解决方案不满意,并且想使用 fread/fwrite 而不是 I/O 流,你可以从 unique_ptr 中获得一些灵感,并编写你自己的 File 类,它将针对你的个人需求进行优化,既方便、可读又安全。
现代 C++ 提供了许多有助于你更安全地编写代码的工具。出现了大量用于编译期评估和检查的结构。你可以切换到更方便的内存和资源管理模型。
但是,没有任何技术或编程范式可以完全保护你免受错误的影响。随着功能的增加,C++ 也会出现新的、特有的 bug。这就是为什么我们不能仅仅依赖一种方法:我们应该始终结合使用代码审查、高质量代码和优秀的工具;这些可以帮助你节省时间和精力,而这些时间和精力可以用在更好的地方。
说到工具,我建议试试 PVS-Studio:我们最近开始开发它的 Linux 版本,你可以看到它的实际效果:它支持任何构建系统,并允许你仅通过构建项目来检查它。对于 Windows 开发者,我们有一个方便的 Visual Studio 插件,你可以试用它的试用版。