在C++中,如何避免出现Bug?
C++中的主要问题之一是存在大量行为未定义或对程序员来说意外的构造。我们在使用静态分析器检查各种项目时经常会遇到这些问题。但正如我们所知,最佳做法是在编译阶段尽早检测错误。让我们来看看现代C++中的一些技术,这些技术不仅帮助编写简单明了的代码,还能使代码更加安全可靠。
1、什么是现代C++?
“现代C++”这一术语在C++11发布后变得非常流行。那么它是什么意思呢?首先,现代C++是一套模式和惯用法,旨在消除老式“带类的C”中的缺点,特别是对于那些从C语言起步的C++程序员来说。C++11看起来更加简洁明了,这一点非常重要。
当人们谈论现代C++时,通常会想到什么?并行编程、编译时计算、RAII、lambda表达式、范围、概念、模块以及标准库中的其他同样重要的组件(例如,文件系统操作API)。这些都是非常酷的现代化改进,我们期待在下一套标准中看到它们。然而,我想特别关注新标准如何帮助编写更安全的代码。在开发静态分析器时,我们看到许多各种各样的错误,有时我们忍不住会想:“但在现代C++中,这些问题本可以避免。”因此,我建议我们检查一下PVS-Studio在各种开源项目中发现的一些错误,并看看如何修复这些错误。
2、自动类型推断
在C++中,引入了关键字 auto
和 decltype
。当然,你已经知道它们是如何工作的。
std::map<int, int> m;
auto it = m.find(42);
//C++98: std::map<int, int>::iterator it = m.find(42);
这非常方便,可以缩短长类型,同时不影响代码的可读性。然而,这些关键字与模板一起变得相当广泛:使用 auto
和 decltype
不需要指定返回值的类型。
但让我们回到主题。这是一个 64位错误的示例
:
string str = .....;
unsigned n = str.find("ABC");
if (n != string::npos)
在64位应用程序中,std::string::npos
的值大于 UINT_MAX
(无符号类型变量能够表示的最大值)。这看起来是一个 auto
可以解决的问题的例子:n
变量的类型对我们来说并不重要,主要的是它能够容纳 std::string::find
的所有可能值。
事实上,如果我们使用 auto
重写这个示例,错误就会消失:
string str = .....;
auto n = str.find("ABC");
if (n != string::npos)
但并非所有事情都这么简单。使用auto
并不是万灵药,它的使用有很多陷阱。例如,你可以这样写代码:
auto n = 1024 * 1024 * 1024 * 5;
char* buf = new char[n];
auto
无法解决整数溢出问题,而且分配的缓冲区内存会少于5GiB。
auto
在处理一个非常常见的错误时也帮不上太大忙:写错的循环。让我们来看一个例子:
std::vector<int> bigVector;
for (unsigned i = 0; i < bigVector.size(); ++i)
{ ... }
对于大型数组,这种循环会变成一个无限循环。这种错误在代码中并不少见:它们在非常罕见的情况下暴露出来,而这些情况通常没有测试。
我们可以使用 auto
重写这个代码片段吗?
std::vector<int> bigVector;
for (auto i = 0; i < bigVector.size(); ++i)
{ ... }
不能。错误不仅仍然存在,而且变得更糟了。
在简单类型的情况下,auto
的行为很糟糕。是的,在最简单的情况下(例如 auto x = y
),它能正常工作,但一旦有额外的构造,行为可能变得更加不可预测。更糟糕的是,错误会变得更难发现,因为变量的类型一开始并不明显。幸运的是,这对于静态分析器来说不是问题:它们不会感到疲倦,也不会失去注意力。但对我们这些普通人来说,最好还是显式地指定类型。我们还可以通过其他方法避免窄化转换,但我们稍后会讨论这些方法。
3、危险的 countof
在 C++ 中,“危险”的类型之一是数组。程序员经常在将数组传递给函数时忘记它是作为指针传递的,并尝试使用 sizeof
来计算元素的数量。
#define RTL_NUMBER_OF_V1(A) (sizeof(A)/sizeof((A)[0]))
#define _ARRAYSIZE(A) RTL_NUMBER_OF_V1(A)
int GetAllNeighbors( const CCoreDispInfo *pDisp,
int iNeighbors[512] ) {
....
if ( nNeighbors < _ARRAYSIZE( iNeighbors ) )
iNeighbors[nNeighbors++] = pCorner->m_Neighbors[i];
....
}
注意:这段代码摘自 Source Engine SDK。
PVS-Studio 警告:V511 sizeof()
运算符返回的是指针的大小,而不是数组的大小,这在 sizeof (iNeighbors)
表达式中出现。Vrad_dll disp_vrad.cpp 60
这种混淆可能是因为在参数中指定了数组的大小:这个数字对编译器没有意义,仅仅是对程序员的提示。
问题在于这段代码被编译了,而程序员可能不知道其中存在问题。显而易见的解决方案是使用元编程:
template <class T, size_t N>
constexpr size_t countof(const T (&array)[N]) {
return N;
}
countof(iNeighbors); // 编译时错误
如果传递给这个函数的不是数组,我们会得到编译错误。在 C++17 中,可以使用 std::size
。
在 C++11 中,std::extent
函数被引入,但它不适合作为 countof
,因为它对不适当的类型返回 0。
std::extent<decltype(iNeighbors)>(); // => 0
你不仅会在 countof
中犯错,也可能在 sizeof
中出现错误:
VisitedLinkMaster::TableBuilder::TableBuilder(
VisitedLinkMaster* master,
const uint8 salt[LINK_SALT_LENGTH])
: master_(master),
success_(true) {
fingerprints_.reserve(4096);
memcpy(salt_, salt, sizeof(salt));
}
注意:这段代码摘自 Chromium。
PVS-Studio 警告:
V511 sizeof()
运算符返回的是指针的大小,而不是数组的大小,这在 sizeof (salt)
表达式中出现。browser visitedlink_master.cc 968
V512 memcpy
函数的调用将导致 salt_
缓冲区的下溢。browser visitedlink_master.cc 968
正如你所见,标准 C++ 数组有很多问题。这就是为什么你应该使用 std::array
的原因:在现代 C++ 中,它的 API 类似于 std::vector
和其他容器,并且在使用时更难出错。
void Foo(std::array<uint8, 16> array)
{
array.size(); // => 16
}
4、如何在一个简单的for
中犯错误
另一个错误来源是简单的 for
循环。你可能会想,“在哪里会出错呢?是和复杂的退出条件或节省代码行数有关吗?”不,程序员在最简单的循环中也会犯错误。让我们来看一下项目中的代码片段:
const int SerialWindow::kBaudrates[] = { 50, 75, 110, .... };
SerialWindow::SerialWindow() : ....
{
....
for(int i = sizeof(kBaudrates) / sizeof(char*); --i >= 0;)
{
message->AddInt32("baudrate", kBaudrateConstants[i]);
....
}
}
注:这段代码取自 Haiku 操作系统。
PVS-Studio 警告:V706 可疑的除法:sizeof (kBaudrates) / sizeof (char *)
。kBaudrates
数组中每个元素的大小与除数不相等。SerialWindow.cpp 162
我们在前面的章节中详细检查过这种错误:数组大小没有正确计算。我们可以通过使用 std::size
来轻松修复它:
const int SerialWindow::kBaudrates[] = { 50, 75, 110, .... };
SerialWindow::SerialWindow() : ....
{
....
for(int i = std::size(kBaudrates); --i >= 0;) {
message->AddInt32("baudrate", kBaudrateConstants[i]);
....
}
}
但是有一个更好的方法。让我们再看一个片段。
inline void CXmlReader::CXmlInputStream::UnsafePutCharsBack(
const TCHAR* pChars, size_t nNumChars)
{
if (nNumChars > 0)
{
for (size_t nCharPos = nNumChars - 1;nCharPos >= 0;--nCharPos)
UnsafePutCharBack(pChars[nCharPos]);
}
}
注:这段代码取自 Shareaza。
PVS-Studio 警告:V547 表达式 nCharPos >= 0
始终为真。无符号类型的值总是大于等于 0。BugTrap xmlreader.h 946
这是编写反向循环时的典型错误:程序员忘记了无符号类型的迭代器检查总是返回真。你可能会想,“怎么会这样?只有新手和学生才会犯这样的错误。我们专业人员不会。”不幸的是,这并不完全正确。当然,每个人都知道 (unsigned >= 0
) 的结果为真。这样的错误通常在哪里出现?它们常常是在重构的过程中发生的。假设项目从 32 位平台迁移到 64 位。之前使用了 int/unsigned 进行索引,后来决定将它们替换为 size_t/ptrdiff_t
。但在某个片段中,他们不小心使用了无符号类型而不是有符号类型。
为了避免这种情况,你的代码中可以采取什么措施?有些人建议使用有符号类型,例如C#
或 Qt
中的方式。也许,这是一种解决方案,但如果我们要处理大量数据,那么就无法避免使用 size_t
。有没有更安全的方式在 C++
中迭代数组?当然有。我们从最简单的方法开始:非成员函数。标准库中有用于处理集合、数组和 initializer_list 的标准函数,它们的原理应该对你来说很熟悉。
char buf[4] = { 'a', 'b', 'c', 'd' };
for (auto it = rbegin(buf);
it != rend(buf);
++it) {
std::cout << *it;
}
很好,现在我们不需要记住直接循环和反向循环之间的区别了。也不必考虑我们使用的是简单数组还是数组——循环在任何情况下都会有效。使用迭代器是一种避免麻烦的好方法,但即便如此,有时也不够理想。最佳的做法是使用基于范围的 for
循环:
char buf[4] = { 'a', 'b', 'c', 'd' };
for (auto it : buf) {
std::cout << it;
}
当然,基于范围的 for
循环也有一些缺陷:它不允许灵活地管理循环,如果需要对索引进行更复杂的操作,那么这种 for
循环帮助不大。但这种情况应该另行讨论。我们现在面对的是一个比较简单的情况:我们需要以反向顺序遍历元素。然而,在这个阶段,已经出现了一些困难。标准库中没有额外的类来支持基于范围的 for
循环。我们来看看如何实现它:
template <typename T>
struct reversed_wrapper {
const T& _v;
reversed_wrapper (const T& v) : _v(v) {}
auto begin() -> decltype(rbegin(_v))
{
return rbegin(_v);
}
auto end() -> decltype(rend(_v))
{
return rend(_v);
}
};
template <typename T>
reversed_wrapper<T> reversed(const T& v)
{
return reversed_wrapper<T>(v);
}
在 C++14 中,你可以通过去掉 decltype
来简化代码。你可以看到 auto
如何帮助你编写模板函数——reversed_wrapper
将同时适用于数组和 std::vector
。
现在我们可以将代码片段重写如下:
char buf[4] = { 'a', 'b', 'c', 'd' };
for (auto it : reversed(buf)) {
std::cout << it;
}
这段代码有什么好处呢?首先,它非常易于阅读。我们立即可以看到元素数组是以反向顺序排列的。其次,出错的可能性较小。第三,它适用于任何类型。这比之前的做法要好得多。
在 Boost 中,你可以使用 boost::adaptors::reverse(arr)
。
但让我们回到最初的例子。那里,数组是通过一对指针大小传递的。显然,我们的 reversed
方法对于这种情况是不适用的。我们应该怎么做?使用像 span
/array_view
这样的类。在 C++17 中,我们有 string_view
,我建议使用它:
void Foo(std::string_view s);
std::string str = "abc";
Foo(std::string_view("abc", 3));
Foo("abc");
Foo(str);
std::string_view
不拥有字符串,实际上它是一个 const char*
和长度的包装器。这就是为什么在代码示例中,字符串是通过值传递的,而不是通过引用传递的。string_view
的一个关键特性是它与各种字符串表示方式的兼容性:const char*
、std::string
和非空终止的 const char*
。
因此,函数的形式如下:
inline void CXmlReader::CXmlInputStream::UnsafePutCharsBack(
std::wstring_view chars)
{
for (wchar_t ch : reversed(chars))
UnsafePutCharBack(ch);
}
在将值传递给函数时,需要记住 string_view(const char*)
的构造函数是隐式的,因此我们可以像这样写:
Foo(pChars);
而不是这样:
Foo(wstring_view(pChars, nNumChars));
string_view
指向的字符串不需要是以 null
结尾的,这个名字 string_view::data
就暗示了这一点。在使用 string_view
时必须记住这一点。当将其值传递给一个期望 C 字符串的 cstdlib
函数时,可能会出现未定义的行为。如果在大多数测试用例中使用的是 std::string
或以 null
结尾的字符串,这种问题可能会被忽略。
5、枚举
让我们暂时抛开 C++,来看看老旧的 C 语言。那么 C 语言的安全性如何呢?毕竟,它没有隐式构造函数调用和操作符,也没有类型转换的问题,也没有各种类型字符串的问题。在实际应用中,错误往往发生在最简单的构造中:最复杂的构造因为引起怀疑而经过仔细审查和调试。与此同时,程序员们往往会忘记检查简单的构造。以下是一个来自 C 语言的危险结构的例子:
enum iscsi_param {
....
ISCSI_PARAM_CONN_PORT,
ISCSI_PARAM_CONN_ADDRESS,
....
};
enum iscsi_host_param {
....
ISCSI_HOST_PARAM_IPADDRESS,
....
};
int iscsi_conn_get_addr_param(....,
enum iscsi_param param, ....)
{
....
switch (param) {
case ISCSI_PARAM_CONN_ADDRESS:
case ISCSI_HOST_PARAM_IPADDRESS:
....
}
return len;
}
这是一个 Linux 内核的例子。PVS-Studio 警告:V556 不同枚举类型的值进行比较:switch(ENUM_TYPE_A) { case ENUM_TYPE_B: … }。libiscsi.c 第 3501 行。
请注意 switch-case 中的值:其中一个命名常量来自不同的枚举。在原始代码中,当然有更多的代码和可能的值,错误并不那么明显。这是因为枚举的类型松散——它们可能会隐式地转换为 int,这留下了很多错误的空间。
在 C++11 中,您可以并且应该使用 enum class:这样的技巧在那里行不通,错误会在编译阶段显示出来。结果,以下代码无法编译,这正是我们所需要的:
enum class ISCSI_PARAM {
....
CONN_PORT,
CONN_ADDRESS,
....
};
enum class ISCSI_HOST {
....
PARAM_IPADDRESS,
....
};
int iscsi_conn_get_addr_param(....,
ISCSI_PARAM param, ....)
{
....
switch (param) {
case ISCSI_PARAM::CONN_ADDRESS:
case ISCSI_HOST::PARAM_IPADDRESS:
....
}
return len;
}
以下片段与枚举不完全相关,但具有类似的症状:
void adns__querysend_tcp(....) {
...
if (!(errno == EAGAIN || EWOULDBLOCK ||
errno == EINTR || errno == ENOSPC ||
errno == ENOBUFS || errno == ENOMEM)) {
...
}
注意:这段代码来自 ReactOS。
是的,errno 的值被声明为宏,这在 C++ 中是不好的做法(在 C 中也是如此),但即使程序员使用了枚举,也不会更容易解决这个问题。失去的比较在枚举中不会显现出来(特别是在宏的情况下)。同时,使用 enum class 不会允许这种情况,因为不会有隐式转换为 bool。
6 、构造函数中的初始化
回到原生 C++ 的问题。其中一个问题在于当需要在多个构造函数中以相同的方式初始化对象时会显现出来。一个简单的情况是:有一个类,两个构造函数,其中一个调用另一个。这看起来很合逻辑:将公共代码放入一个单独的方法中——没有人喜欢重复代码。那么,陷阱是什么呢?
Guess::Guess() {
language_str = DEFAULT_LANGUAGE;
country_str = DEFAULT_COUNTRY;
encoding_str = DEFAULT_ENCODING;
}
Guess::Guess(const char* guess_str) {
Guess();
....
}
注意:这段代码来自 LibreOffice。
PVS-Studio 警告:V603 对象被创建了但没有被使用。如果您希望调用构造函数,应使用 this->Guess::Guess(....)
。guess.cxx 第 56 行。
问题在于构造函数调用的语法。经常会忘记这一点,程序员会创建一个额外的类实例,然后立即销毁它。也就是说,原始实例的初始化没有发生。当然,有很多方法可以解决这个问题。例如,我们可以通过 this
显式调用构造函数,或者将所有内容放入一个单独的函数中:
Guess::Guess(const char * guess_str)
{
this->Guess();
....
}
Guess::Guess(const char * guess_str)
{
Init();
....
}
顺便提一下,显式地重复调用构造函数,例如通过 this
,是一种危险的做法,我们需要了解发生了什么。使用 Init()
的变体更好且更清晰。
但在这里,最好使用构造函数的委托。这样我们可以以以下方式显式地从一个构造函数调用另一个构造函数:
Guess::Guess(const char * guess_str) : Guess()
{
....
}
这种构造函数有几个限制。首先:委托构造函数对对象的初始化负有全部责任。也就是说,它无法在初始化列表中初始化另一个类字段:
Guess::Guess(const char * guess_str)
: Guess(),
m_member(42)
{
....
}
当然,我们必须确保委托不会创建循环,否则将无法退出。遗憾的是,这段代码会被编译:
Guess::Guess(const char * guess_str)
: Guess(std::string(guess_str))
{
....
}
Guess::Guess(std::string guess_str)
: Guess(guess_str.c_str())
{
....
}
7、关于虚函数
虚函数会带来潜在的问题:派生类中函数签名错误很容易发生,结果可能不会重写函数,而是声明了一个新函数。我们来看以下例子:
class Base {
virtual void Foo(int x);
}
class Derived : public Base {
void Foo(int x, int a = 1);
}
通过指向 Base
的指针或引用无法调用 Derived::Foo
。不过这是一个简单的例子,你可能会说没人会犯这样的错误。通常,人们会以以下方式出错:
class DBClientBase : .... {
public:
virtual auto_ptr<DBClientCursor> query(
const string &ns,
Query query,
int nToReturn = 0
int nToSkip = 0,
const BSONObj *fieldsToReturn = 0,
int queryOptions = 0,
int batchSize = 0 );
};
class DBDirectClient : public DBClientBase {
public:
virtual auto_ptr<DBClientCursor> query(
const string &ns,
Query query,
int nToReturn = 0,
int nToSkip = 0,
const BSONObj *fieldsToReturn = 0,
int queryOptions = 0);
};
注意:这段代码取自 MongoDB。
PVS-Studio 警告:V762 请检查虚函数参数。请查看派生类 DBDirectClient
和基类 DBClientBase
中函数 query
的第七个参数。文件 dbdirectclient.cpp
第 61 行。
函数的参数很多,并且在继承类的函数中没有最后一个参数。这些是不同的、不相关的函数。这样的错误经常发生在具有默认值的参数上。
在下面的代码片段中,情况会更加复杂。如果编译为 32 位代码,这段代码会正常工作,但在 64 位版本中则无法正常工作。最初,在基类中,参数是 DWORD
类型,但后来被更正为 DWORD_PTR
。同时,继承类中的参数没有相应地更改。愿不眠的夜晚、调试和咖啡长存!
class CWnd : public CCmdTarget {
....
virtual void WinHelp(DWORD_PTR dwData, UINT nCmd = HELP_CONTEXT);
....
};
class CFrameWnd : public CWnd { .... };
class CFrameWndEx : public CFrameWnd {
....
virtual void WinHelp(DWORD dwData, UINT nCmd = HELP_CONTEXT);
....
};
你可以以更为离奇的方式犯错,比如忘记函数的 const
限定符或参数的 const
限定符,或者忽略基类函数是否为虚函数,或者混淆有符号与无符号类型。
在 C++ 中,添加了几个关键字来规范虚函数的重写。override
关键字将大有帮助。这样,代码将无法编译。
class DBDirectClient : public DBClientBase {
public:
virtual auto_ptr<DBClientCursor> query(
const string &ns,
Query query,
int nToReturn = 0,
int nToSkip = 0,
const BSONObj *fieldsToReturn = 0,
int queryOptions = 0) override;
};
8、NULL vs nullptr
使用 NULL
来表示空指针可能会导致许多意想不到的情况。NULL
实际上是一个普通的宏,它展开成 0
,其类型为 int
。这就是为什么在以下示例中选择第二个函数的原因:
void Foo(int x, int y, const char *name);
void Foo(int x, int y, int ResourceID);
Foo(1, 2, NULL);
虽然原因很清楚,但这种情况很不符合逻辑。这也是为什么需要 nullptr
的原因,nullptr
具有自己的类型 nullptr_t
。这就是为什么在现代 C++ 中不能使用 NULL
(更不要说 0
)。
另一个例子是:NULL
可以与其他整数类型进行比较。假设有一个 WinAPI 函数返回 HRESULT
。该类型与指针无关,因此与 NULL
的比较毫无意义。nullptr
通过引发编译错误来强调这一点,而 NULL
则不会:
if (WinApiFoo(a, b) != NULL) // 不好
if (WinApiFoo(a, b) != nullptr) // 好, 编译错误
9、va_arg
在某些情况下,需要传递不确定数量的参数。一个典型的例子是格式化输入/输出函数。虽然可以编写成不需要可变数量参数的方式,但我认为没有理由放弃这种语法,因为它更方便且更易于阅读。旧的 C++ 标准提供了什么?它们建议使用 va_list
。这会带来哪些问题?对于这种参数,传递错误类型的参数并不那么容易,也有可能完全没有传递参数。让我们仔细看看这些片段。
typedef std::wstring string16;
const base::string16& relaunch_flags() const;
int RelaunchChrome(const DelegateExecuteOperation& operation)
{
AtlTrace("Relaunching [%ls] with flags [%s]\n",
operation.mutex().c_str(),
operation.relaunch_flags());
....
}
注意:这段代码取自 Chromium。
PVS-Studio 警告:V510 AtlTrace
函数不应接收类类型变量作为第三个实际参数。delegate_execute.cc
第 96 行
程序员想打印 std::wstring
字符串,但忘记调用 c_str()
方法。因此,wstring
类型在函数中会被解释为 const wchar_t*
。当然,这样做是无济于事的。
cairo_status_t
_cairo_win32_print_gdi_error (const char *context)
{
....
fwprintf (stderr, L"%s: %S", context,
(wchar_t *)lpMsgBuf);
....
}
注意:这段代码取自 Cairo。
PVS-Studio 警告:V576 格式不正确。请检查 fwprintf
函数的第三个实际参数。期望的是指向 wchar_t
类型符号的字符串指针。cairo-win32-surface.c
第 130 行
在这段代码中,程序员混淆了字符串格式说明符。问题在于,在 Visual C++ 中,wchar_t*
和 %S
都期望 wprintf
的 %s
格式说明符。值得注意的是,这些错误出现在用于错误输出或调试信息的字符串中——这些是较少见的情况,因此被忽略了。
static void GetNameForFile(
const char* baseFileName,
const uint32 fileIdx,
char outputName[512] )
{
assert(baseFileName != NULL);
sprintf( outputName, "%s_%d", baseFileName, fileIdx );
}
注意:这段代码取自 CryEngine 3 SDK。
PVS-Studio 警告:V576 格式不正确。请检查 sprintf
函数的第四个实际参数。期望的是有符号整数类型参数。igame.h
第 66 行
整数类型也很容易混淆,尤其是当它们的大小依赖于平台时。然而,在这里情况要简单得多:有符号和无符号类型被混淆了。大的数字将被打印为负数。
ReadAndDumpLargeSttb(cb,err)
int cb;
int err;
{
....
printf("\n - %d strings were read, "
"%d were expected (decimal numbers) -\n");
....
}
注意:这段代码取自 Word for Windows 1.1a。
PVS-Studio 警告:V576 格式不正确。调用 printf
函数时实际参数的数量不匹配。期望:3 个。实际:1 个。dini.c
第 498 行
这个字符串预期有三个参数,但实际没有提供。可能程序员打算打印栈上的数据,但我们不能假设栈上有什么内容。显然,我们需要显式地传递这些参数。
BOOL CALLBACK EnumPickIconResourceProc(
HMODULE hModule, LPCWSTR lpszType,
LPWSTR lpszName, LONG_PTR lParam)
{
....
swprintf(szName, L"%u", lpszName);
....
}
注意:这段代码取自 ReactOS。
PVS-Studio 警告:V576 格式不正确。请检查 swprintf
函数的第三个实际参数。打印指针的值应该使用 %p
。dialogs.cpp
第 66 行
这是一个 64 位错误的示例。指针的大小依赖于架构,使用 %u
来打印指针是不合适的。我们应该使用什么呢?分析器提示正确的格式符是 %p
。如果指针用于调试时打印,这是非常有用的。如果之后尝试从缓冲区读取它并使用它,那就更有意思了。
对于具有可变参数的函数,几乎一切都有可能出错!你无法检查参数的类型或数量。稍有偏差,就会出现未定义行为。
幸运的是,现在有更可靠的替代方案。首先,变参模板就是其中之一。借助变参模板,我们可以在编译期间获取所有传递类型的信息,并按需使用它。举个例子,我们可以使用一个更安全的 printf
:
void printf(const char* s) {
std::cout << s;
}
template<typename T, typename... Args>
void printf(const char* s, T value, Args... args) {
while (s && *s) {
if (*s=='%' && *++s!='%') {
std::cout << value;
return printf(++s, args...);
}
std::cout << *s++;
}
}
当然,这只是一个示例:在实际应用中,其使用意义不大。但在变参模板的情况下,你的限制仅限于你的想象力,而不是语言特性。
另一种可以用来传递可变参数的构造是 std::initializer_list
。它不允许传递不同类型的参数,但如果这足够,你可以这样使用:
void Foo(std::initializer_list<int> a);
Foo({1, 2, 3, 4, 5});
它也很方便遍历,因为我们可以使用 begin
、end
和范围 for
循环。
10、窄化转换
窄化转换给程序员的生活带来了很多麻烦。特别是当迁移到 64 位架构变得更加必要时,这种问题显得尤为突出。理想情况下,代码中应该只有正确的类型。但是实际情况往往不是如此:程序员常常使用各种“黑科技”和一些奇特的方法来存储指针。找到这些代码片段需要消耗大量的时间和精力:
char* ptr = ...;
int n = (int)ptr;
....
ptr = (char*) n;
不过,我们暂时不讨论 64 位错误。这儿有一个更简单的例子:程序员想要找出两个整数值的比例。这样做的代码如下:
virtual int GetMappingWidth( ) = 0;
virtual int GetMappingHeight( ) = 0;
void CDetailObjectSystem::LevelInitPreEntity()
{
....
float flRatio = pMat->GetMappingWidth() / pMat->GetMappingHeight();
....
}
注意:这段代码取自 Source Engine SDK。
PVS-Studio 警告:V636 表达式被隐式地从 ‘int’ 类型转换为 ‘float’ 类型。请考虑使用显式类型转换以避免丢失小数部分。示例:double A = (double)(X) / Y;
。客户端 (HL2) detailobjectsystem.cpp 1480
不幸的是,无法完全防止这种错误——总会有某种方式隐式地将一种类型转换为另一种类型。但是好消息是,C++11 引入的新初始化方法具有一个很好的特性:它禁止狭义转换。在这种代码中,错误将在编译阶段被发现,可以轻松地加以修正。
float flRatio { pMat->GetMappingWidth() / pMat->GetMappingHeight() };
11、没有消息就是好消息
管理资源和内存的错误方式有很多种。现代语言在工作便利性方面有很高的要求。现代 C++ 也不落后,提供了多种自动资源控制工具。尽管这些错误在动态分析中很常见,但有些问题可以通过静态分析来发现。以下是其中一些问题的示例:
void AccessibleContainsAccessible(....)
{
auto_ptr<VARIANT> child_array(
new VARIANT[child_count]);
...
}
注意:这段代码取自 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
代替调用,并且完全支持移动语义。看起来这里似乎没有什么问题。
void text_editor::_m_draw_string(....) const
{
....
std::unique_ptr<unsigned> pxbuf_ptr(
new unsigned[len]);
....
}
注意:这段代码取自 nana。
PVS-Studio 警告:V554 错误使用了 unique_ptr
。使用 new []
分配的内存将通过 delete
清理。text_editor.cpp 3137
结果发现,其实你也会犯同样的错误。是的,只需写 unique_ptr<unsigned[]>
,错误就会消失,但代码在这种形式下仍然能编译。因此,这种方式也可能出错,实践表明,只要可能,人们就会这样做。这段代码就是证明。因此,使用 unique_ptr
管理数组时,务必小心:比想象中更容易出错。也许使用 std::vector
会更符合现代 C++ 的规范?
我们来看另一个事故类型。
template<class TOpenGLStage>
static FString GetShaderStageSource(TOpenGLStage* Shader)
{
....
ANSICHAR* Code = new ANSICHAR[Len + 1];
glGetShaderSource(Shaders[i], Len + 1, &Len, Code);
Source += Code;
delete Code;
....
}
注意:这段代码取自 Unreal Engine 4。
PVS-Studio 警告:V611 内存是使用 new T[]
操作符分配的,但却使用 delete
操作符释放。请考虑检查这段代码。最好使用 delete[] Code;
。openglshaders.cpp 1790
没有智能指针时,同样的错误也很容易出现:使用 new[]
分配的内存通过 delete
释放。
bool CxImage::LayerCreate(int32_t position)
{
....
CxImage** ptmp = new CxImage*[info.nNumLayers + 1];
....
free(ptmp);
....
}
注意:这段代码取自 CxImage。
PVS-Studio 警告:V611 内存是使用 new
操作符分配的,但却使用 free
函数释放。请考虑检查 ptmp
变量背后的操作逻辑。ximalyr.cpp 50
在这个片段中,malloc/free
和 new/delete
被混用。这可能发生在重构过程中:C 语言的函数需要被替换,结果导致了未定义行为。
int settings_proc_language_packs(....)
{
....
if(mem_files) {
mem_files = 0;
sys_mem_free(mem_files);
}
....
}
注意:这段代码取自 Fennec Media。
PVS-Studio 警告:V575 空指针被传递给 free
函数。检查第一个参数。settings interface.c 3096
这是一个更有趣的例子。有一种做法是在释放内存后将指针置为零。程序员有时甚至会为此编写特殊的宏。从某种程度上来说,这是一个很好的技术:你可以防止对同一块内存的再次释放。但是在这里,表达式的顺序被搞错了,因此 free
得到了一个空指针(这一点被分析器注意到了)。
ETOOLS_API int __stdcall ogg_enc(....) {
format = open_audio_file(in, &enc_opts);
if (!format) {
fclose(in);
return 0;
};
out = fopen(out_fn, "wb");
if (out == NULL) {
fclose(out);
return 0;
}
}
但这个问题不仅仅涉及内存管理,还涉及资源管理。例如,你可能忘记关闭文件,如上面的代码片段所示。在这两种情况下,RAII 关键字概念都适用。这一概念也支持智能指针。结合移动语义,RAII 有助于避免许多与内存泄漏相关的 bugs。以这种风格编写的代码可以更直观地识别资源所有权。
作为一个小例子,我将提供一个基于 unique_ptr
的 FILE
封装器:
auto deleter = [](FILE* f) {fclose(f);};
std::unique_ptr<FILE, decltype(deleter)> p(fopen("1.txt", "w"), deleter);
尽管如此,你可能会希望有一个更具函数式的封装来处理文件(具有更易读的语法)。值得记住的是,在 C++17 中,将添加一个用于处理文件系统的 API —— std::filesystem
。但是,如果你对这个决定不满意,并且希望使用 fread
/fwrite
而不是 i/o 流,你可以从 unique_ptr
中获得一些灵感,编写自己的 File
类,这样可以根据你的个人需求进行优化,使其更方便、可读和安全。
结果是什么呢?现代 C++ 提供了许多工具,帮助你更安全地编写代码。许多用于编译时评估和检查的构造也已出现。你可以切换到更方便的内存和资源管理模型。
但没有任何技术或编程范式可以完全保护你免于错误。与功能的增加相伴随,C++ 也会引入新的 bugs,这些 bugs 可能只有 C++ 特有。这就是为什么我们不能单纯依赖一种方法:我们应该始终结合代码审查、优质代码和良好的工具;这些可以帮助节省你的时间和精力,这些时间和精力可以用在更好的地方。