在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 = 0int 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++ 特有。这就是为什么我们不能单纯依赖一种方法:我们应该始终结合代码审查、优质代码和良好的工具;这些可以帮助节省你的时间和精力,这些时间和精力可以用在更好的地方。
相关文章:

在C++中,如何避免出现Bug?
C中的主要问题之一是存在大量行为未定义或对程序员来说意外的构造。我们在使用静态分析器检查各种项目时经常会遇到这些问题。但正如我们所知,最佳做法是在编译阶段尽早检测错误。让我们来看看现代C中的一些技术,这些技术不仅帮助编写简单明了的代码&…...

Linux 操作系统 进程(1)
什么是进程 想要了解什么是进程,或者说,为什么会有进程这个概念,我们就需要去了解现代计算机的设计框架(冯诺依曼体系): 计算机从设计之初就以执行程序为核心任务,也就是运算器从内存中读取,也只从内存中…...

clickhouse-v24.1-离线部署
部署版本 数据库版本:24.1.1.2048 jdk版本:jdk8 4个文件(三个ck的包): OpenJDK8U-jdk_x64_linux_hotspot_8u382b05.tar clickhouse-client-24.1.1.2048.x86_64.rpm clickhouse-common-static-24.1.1.2048.x86_64.…...

安卓13删除app 链接库警告弹窗Detected problems with app native
总纲 android13 rom 开发总纲说明 文章目录 1.前言2.问题分析3.代码修改彩蛋1.前言 有些客户的APP,打开首次会弹窗提示窗口, Detected problems with app native libraries (please consult log for detail):,需要删除这个窗口,避免挡住用户APP。而且这个提示有些app是以t…...

第四次北漂----挣个独立游戏的素材钱
第四次北漂,在智联招聘上,有个小公司主动和我联系。面试了下,决定入职了,osg/osgearth的。月薪两万一。 大跌眼镜的是,我入职后,第一天的工作内容就是接手他的工作,三天后他就离职了。 我之所以…...

漫谈设计模式 [12]:模板方法模式
引导性开场 菜鸟:老大,我最近在做一个项目,遇到了点麻烦。我们有很多相似的操作流程,但每个流程的细节又有些不同。我写了很多重复的代码,感觉很乱。你有啥好办法吗? 老鸟:嗯,听起…...

CSS学习10[重点]--浮动、浮动的效果以及内幕特性
CSS布局——浮动 前言一、普通流二、浮动三、什么是浮动?四、浮动的内幕特性总结 前言 CSS盒子布具的三种机制:普通流(标准流)、定位、浮动。 一、普通流 普通流:网页内元素自上而下,从左到右排序。 二、浮动 浮动…...

matlab基本语法
基本语法 变量命名规则 区分大小写长度不超过63位字母开头,可以有字母、下划线和数字组成,但不能使用标点应该简洁明了 命令行窗口 >>>clc 清楚命令窗口 >>> claer all 清理工作区内容 注释 %% 注释符 数据类型 1.数字 11 2…...

【Leetcode152】乘积最大子数组(动态规划)
文章目录 一、题目二、思路三、代码 一、题目 二、思路 (0)读懂题意:题目的“连续”是指位置的连续,而不是说数字的连续,这是个大坑。 (1)确定状态:定义两个状态来记录当前子数组的…...

STM32(十二):DMA直接存储器存取
DMA(Direct Memory Access)直接存储器存取 DMA可以提供外设和存储器或者存储器和存储器之间的高速数据传输,无须CPU干预,节省了CPU的资源。(运行内存SRAM、程序存储器Flash、寄存器) 12个独立可配置的通道&…...

关于我2020年7月至今(2024.9)的“炒股”经历和感受
声明:我远不是一个成熟的投资者(这个名词太大了,我那三瓜两枣似乎完全配不上投资者这三个字,或者“小小散”更加贴切)。本文不构成任何入(股)市的引导或者买卖股票的建议。 “炒股”这个词,相信绝大多数人看来都-是一个贬义词&…...

【Tools】Prompt Engineering简介
摇来摇去摇碎点点的金黄 伸手牵来一片梦的霞光 南方的小巷推开多情的门窗 年轻和我们歌唱 摇来摇去摇着温柔的阳光 轻轻托起一件梦的衣裳 古老的都市每天都改变模样 🎵 方芳《摇太阳》 大模型中的Prompt Engineering是指为了提高大模型在特定任…...

多路转接之select(fd_set介绍,参数详细介绍),实现非阻塞式网络通信
目录 多路转接之select 引入 介绍 fd_set 函数原型 nfds readfds / writefds / exceptfds readfds 总结 fd_set操作接口 timeout timevalue 结构体 传入值 返回值 代码 注意点 -- 调用函数 select的参数填充 获取新连接 注意点 -- 通信时的调用函数 添…...

乐鑫安全制造全流程
主要参考资料: 【乐鑫全球开发者大会】DevCon24 #10 |乐鑫安全制造全流程 乐鑫官方文档Flash加密: https://docs.espressif.com/projects/esp-idf/zh_CN/latest/esp32/security/flash-encryption.html 【ESP32S3】使用 Flash 下载工具完成 Flash 加密功能…...

〖open-mmlab: MMDetection〗解析文件:configs/_base_/schedules
详细解析三个训练调度文件:schedule_1x.py、schedule_2x.py、schedule_20e.py 在深度学习模型训练过程中,训练调度(Training Schedule)是至关重要的,它决定了模型训练过程中学习率(Learning Rate, LR&…...

Android之Handler是如何保证延迟发送的
目录 核心组件延迟发送消息的工作原理具体步骤1. 创建 Handler:2.发送延迟消息3.消息入队列4.消息出队和处理: 关键点总结 在 Android 中,Handler 是用于在不同线程之间传递和处理消息的工具。它可以用于定时任务、延迟执行任务等。Handler 如何保证延迟发送消息的核…...

定位信标、基站、标签,定位信标是什么
定位信标、基站、标签,定位信标是什么 今天给各位分享定位信标、基站、标签的知识,其中也会对定位信标是什么进行解释,如果能碰巧解决你现在面临的问题,别忘了关注本站,现在开始吧! 怎样做人员定位啊? 〖…...

2024国赛数学建模B题完整分析参考论文38页(含模型和可运行代码)
2024 高教社杯全国大学生数学建模完整分析参考论文 B 题 生产过程中的决策问题 目录 摘要 一、问题重述 二、问题分析 三、 模型假设 四、 模型建立与求解 4.1问题1 4.1.1问题1思路分析 4.1.2问题1模型建立 4.1.3问题1样例代码(仅供参考) 4.…...

Hive是什么?
Apache Hive 是一个基于 Hadoop 的数据仓库工具,用于在 Hadoop 分布式文件系统(HDFS)上管理和查询大规模结构化数据集。Hive 提供了一个类似 SQL 的查询语言,称为 HiveQL,通过这种语言可以在 HDFS 上执行 MapReduce 作…...

计算机网络:http协议
计算机网络:http协议 一、本文内容与前置知识点1. 本文内容2. 前置知识点 二、HTTP协议工作简介1. 特点2. 传输时间分析3. http报文结构 三、HTTP版本迭代1. HTTP1.0和HTTP1.1主要区别2. HTTP1.1和HTTP2主要区别3. HTTPS与HTTP的主要区别 四、参考文献 一、本文内容…...

【stata】自写命令分享dynamic_est,一键生成dynamic effect
1. 命令简介 dynamic_est 是一个用于可视化动态效应(dynamic effect)的工具。它特别适用于事件研究(event study)或双重差分(Difference-in-Differences, DID)分析。通过一句命令即可展示动态效应…...

文心一言 VS 讯飞星火 VS chatgpt (342)-- 算法导论23.2 1题
一、对于同一个输入图,Kruskal算法返回的最小生成树可以不同。这种不同来源于对边进行排序时,对权重相同的边进行的不同处理。证明:对于图G的每棵最小生成树T,都存在一种办法来对G的边进行排序,使得Kruskal算法所返回的…...

部署若依Spring boot项目
nohup和& nohup命令解释 nohup命令:nohup 是 no hang up 的缩写,就是不挂断的意思,但没有后台运行,终端不能标准输入。 nohup :不挂断的运行,注意并没有后台运行的功能,就是指,用nohup运行命令可以使命令永久的执行下去,和用户终端没有关系,注意了nohup没有后台…...

oc打包:权限弹窗无法正常弹出
在遇到编写了权限无法弹出弹窗时,需要查看是不是调用时机不对,这里直接教万能改法。 将权限获取方法编写在applicationDidBecomeActive 进入前台的生命周期接口中,如下: if (@available(iOS 14, *)) {NSLog<...

深入理解RxJava:响应式编程的现代方式
在当今的软件开发世界中,异步编程和事件驱动的架构变得越来越重要。RxJava,作为响应式编程(Reactive Programming)的一个流行库,为Java和Android开发者提供了一种强大的方式来处理异步任务和事件流。本文将深入探讨RxJ…...

Maven 依赖漏洞扫描检查插件 dependency-check-maven 的使用
前言 在现代软件开发中,开源库的使用愈加普遍,然而这些开源库中的漏洞往往会成为潜在的安全风险。如何及时的发现依赖的第三方库是否存在漏洞,就变成很重要了。 本文向大家推荐一款可以进行依赖包漏洞检查的 maven 插件 dependency-check-m…...

2. 下载rknn-toolkit2项目
官网链接: https://github.com/airockchip/rknn-toolkit2 安装好git:[[1. Git的安装]] 下载项目: git clone https://github.com/airockchip/rknn-toolkit2.git或者直接去github下载压缩文件,解压即可。...

xhr、ajax、axois、fetch的区别
一、XMLHttpRequest (XHR)、AJAX、Axios 和 Fetch API 都是用于在不重新加载整个页面的情况下与服务器进行通信的技术和库。它们在处理超时、终止请求、进度反馈等机制上有一些显著的差异。以下是它们的详细比较: 1. XMLHttpRequest (XHR) XMLHttpRequest 是一种浏…...

【HuggingFace Transformers】OpenAIGPTModel源码解析
OpenAIGPTModel源码解析 1. GPT 介绍2. OpenAIGPTModel类 源码解析 说到ChatGPT,大家可能都使用过吧。2022年,ChatGPT的推出引发了广泛的关注和讨论。这款对话生成模型不仅具备了强大的语言理解和生成能力,还能进行非常自然的对话,…...

macOS安装Java和Maven
安装Java Java Downloads | Oracle 官网下载默认说最新的Java22版本,注意这里我们要下载的是Java8,对应的JDK1.8 需要登陆Oracle,没有账号的可以百度下。账号:908344069qq.com 密码:Java_2024 Java8 jdk1.8配置环境变量 open -e ~/.bash_p…...