如何使用现代C ++避免错误

C ++的主要问题之一是具有大量构造,这些构造的行为是不确定的,或者对于程序员而言是出乎意料的。在各种项目中使用静态分析器时,我们经常会遇到它们。但是,众所周知,最好的办法是在编译阶段检测错误。让我们看看现代C ++中的哪些技术不仅可以帮助编写简单明了的代码,而且还可以使其更安全,更可靠。

什么是现代C ++?

在C ++ 11发行之后,现代C ++一词变得非常流行。这是什么意思?首先,现代C ++是一组模式和习惯用法,旨在消除老式的“带类C”的缺点,以至于许多C ++程序员已经习惯了,特别是如果他们开始使用C进行编程。C ++ 11看起来更简洁易懂,这非常重要。

人们在谈论现代C ++时通常会想到什么?并行性,编译时计算,RAII,lambda,范围,概念,模块以及标准库的其他同等重要的组件(例如,与文件系统配合使用的API)。这些都是非常酷的现代化,我们期待在下一组标准中看到它们。但是,我想提请注意新标准允许编写更安全的代码的方式。在开发静态分析器时,我们会看到很多变化不定的错误,有时我们不禁会想:“但是在现代C ++中,这是可以避免的”。因此,我建议我们检查PVS-Studio在各种开源项目中发现的几个错误。另外,我们将看到如何修复它们。

自动类型推断

在C ++中,添加了关键字autodecltype。当然,您已经知道它们如何工作。

 

1
2
3
std::map<int, int> m;
auto it = m.find(42);
//C++98: std::map<int, int>::iterator it = m.find(42); 

在不损失代码可读性的情况下,缩短长类型非常方便。但是,这些关键字与模板一起变得相当宽泛:无需使用autodecltype指定返回值的类型。

但是,让我们回到我们的主题。这是一个64位错误的示例:

 

1
2
3
string str = .....;
unsigned n = str.find("ABC");
if (n != string::npos)

在64位应用程序,的值串::非营利组织比的最大值大UINT_MAX,其可以通过可变来表示无符号类型。看来这是auto可以使我们免于此类问题的情况:n变量的类型对我们而言并不重要,主要是它可以容纳string :: find的所有可能值。确实,如果我们用auto重写此示例,则错误消失了:

 

1
2
3
string str = .....;
auto n = str.find("ABC");
if (n != string::npos)

但是,并非一切都那么简单。使用自动并不是万能的,并且有很多与其使用有关的陷阱。例如,您可以编写如下代码:

 

1
2
auto n = 1024 * 1024 * 1024 * 5;
char* buf = new char[n];

自动不会将我们从整数溢出中救出,并且为缓冲区分配的内存将少于5GiB。

当遇到一个非常常见的错误:错误编写的循环时,Auto也没有任何帮助。让我们看一个例子:

 

1
2
3
std::vector<int> bigVector;
for (unsigned i = 0; i < bigVector.size(); ++i)
{ ... }

对于大型数组,此循环变为无限循环。代码中有这样的错误也就不足为奇了:它们在极少数情况下会暴露出来,而这些情况都没有测试。

我们可以用auto重写此片段吗?

 

1
2
3
std::vector<int> bigVector;
for (auto i = 0; i < bigVector.size(); ++i)
{ ... }

不。不仅错误仍然存​​在。情况变得更糟。

如果使用简单类型,则auto的行为会非常糟糕。是的,在最简单的情况下(自动x = y),它可以工作,但是一旦有其他构造,其行为就会变得更加不可预测。更糟糕的是,错误将更加难以发现,因为乍一看变量的类型并不那么明显。幸运的是,对于静态分析仪而言,这不是问题:它们不会感到疲劳,也不会引起注意。但是对于我们来说,作为简单的凡人,最好显式地指定类型。我们还可以使用其他方法来消除缩小的转换,但是稍后我们将进行说明。

危险计数

C ++中的“危险”类型之一是数组。通常,在将其传递给函数时,程序员会忘记将其作为指针传递,并尝试使用sizeof计算元素的数量

 

1
2
3
4
5
6
7
8
9
#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

这种混乱可能是由于在参数中指定了数组的大小而引起的:该数字对编译器没有任何意义,只是对程序员的提示。

问题在于该代码已被编译,程序员不知道有什么不对劲。显而易见的解决方案是使用元编程:

 

1
2
3
4
template < class T, size_t N ><br>constexpr size_t countof( const T (&array)[N] ) {
  return N;
}
countof(iNeighbors); //compile-time error 

如果传递给此函数而不是数组,则会出现编译错误。在C ++ 17中,您可以使用std :: size

在C ++ 11中,函数的std ::程度 加入,但它不适合作为countof,因为它返回0为不适当的类型

std::extent<decltype(iNeighbors)>(); //=> 0

你可以让一个错误不仅countof,但随着的sizeof

 

1
2
3
4
5
6
7
8
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)’表达式返回指针的大小,而不是数组的大小。浏览器已访问link_master.cc 968
  • V512调用’memcpy’函数将导致缓冲区’salt_’下溢。浏览器已访问link_master.cc 968

如您所见,标准C ++数组存在很多问题。这就是为什么您应该使用std :: array的原因:在现代C ++中,其API与std :: vector和其他容器类似,并且使用它时更难出错。

 

1
2
3
4
void Foo(std::array<uint8, 16> array)
{
  array.size(); //=> 16
}

如何简单地犯一个错误

错误的另一个来源是简单的for循环。您可能会想,“您在那里哪里可能会犯错误?这与复杂的退出条件或保存在代码行上有关吗?” 不,程序员会在最简单的循环中出错。让我们看一下项目中的片段:

 

1
2
3
4
5
6
7
8
9
10
11
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轻松修复它:

 

1
2
3
4
5
6
7
8
9
10
const int SerialWindow::kBaudrates[] = { 50, 75, 110, .... };
 
SerialWindow::SerialWindow() : ....
{
  ....
  for(int i = std::size(kBaudrates); --i >= 0;) {
    message->AddInt32("baudrate", kBaudrateConstants[i]); 
    ....
  }
}

但是有更好的方法。让我们再看一个片段。

 

1
2
3
4
5
6
7
8
9
10
11
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’始终为true。无符号类型值始终> =0。BugTrap xmlreader.h 946

这是编写反向循环时的典型错误:程序员忘记了无符号类型的迭代器,而检查始终返回true。您可能会想:“为什么?只有新手和学生会犯这样的错误。我们,专业人士则不会。” 不幸的是,这并非完全正确。当然,每个人都明白,(无符号> = 0) –真实。这些错误从何而来?它们通常是由于重构而发生的。想象一下这种情况:项目从32位平台迁移到64位。以前,将int / unsigned用于索引,并决定将它们替换为size_t / ptrdiff_t。但是在一个片段中,他们不小心使用了无符号类型而不是有符号类型。

我们应该怎么做才能避免这种情况出现在您的代码中?有人建议使用带符号的类型,例如C#或Qt。也许,这可能是一种解决方法,但是如果我们要处理大量数据,则无法避免size_t。还有没有更安全的方法可以在C ++中遍历数组?当然有。让我们从最简单的一个开始:非成员函数。有一些标准函数可以处理集合,数组和initializer_list。他们的原理应该是您熟悉的。

 

1
2
3
4
5
6
char buf[4] = { 'a', 'b', 'c', 'd' };
for (auto it = rbegin(buf);
     it != rend(buf);
     ++it) {
  std::cout << *it;
}

太好了,现在我们不需要记住正向循环和反向循环之间的区别。也无需考虑我们使用的是简单数组还是数组-循环在任何情况下都可以工作。使用迭代器是避免头痛的好方法,但是即使那样也并不总是足够好。最好使用基于范围的for循环

 

1
2
3
4
char buf[4] = { 'a', 'b', 'c', 'd' };
for (auto it : buf) {
  std::cout << it;
}

当然,基于范围的实现存在一些缺陷它不允许灵活地管理循环,并且如果需要使用索引进行更复杂的工作,那么对于我们没有太大帮助。但是,这种情况应单独检查。我们有一个非常简单的情况:我们必须以相反的顺序移动项目。但是,在这一阶段,已经存在困难。标准库中没有用于基于范围的for的其他类。让我们看看如何实现它:

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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一起使用

现在,我们可以按以下方式重写该片段:

 

1
2
3
4
char buf[4] = { 'a', 'b', 'c', 'd' };
for (auto it : reversed(buf)) {
  std::cout << it;
}

这段代码有什么好处?首先,它很容易阅读。我们立即看到元素的数组是相反的顺序。其次,更难犯错误。第三,它适用于任何类型。这比以前要好得多。

您可以在boost中使用boost :: adaptors :: reverse(arr)

但是,让我们回到原始示例。在那里,数组通过一对指针大小传递。显然,我们的反向想法无法解决这个问题。我们该怎么办?使用span / array_view之类的类。在C ++ 17中,我们有string_view,我建议使用:

 

1
2
3
4
5
void Foo(std::string_view s);
std::string str = "abc";
Foo(std::string_view("abc", 3));
Foo("abc");
Foo(str);

string_view不拥有该字符串,实际上它是const char *和长度的包装。这就是为什么在代码示例中,字符串是通过值而不是通过引用传递的。string_view的主要功能是与各种字符串表示形式的字符串兼容:const char *std :: string和非空终止的const char *

结果,该函数采用以下形式:

 

1
2
3
4
5
6
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可以为我们提供有关此提示,并且在使用它时必须牢记这一点。将其值从正在等待C字符串的cstdlib传递给函数时,可能会得到未定义的行为。如果在大多数情况下要测试,则很容易错过它,它使用了std :: string或以null终止的字符串。

枚举

让我们离开C ++一下,考虑一下旧的C。安全性如何?毕竟,隐式构造函数调用和运算符或类型转换都没有问题,并且各种类型的字符串都没有问题。实际上,错误通常发生在最简单的结构中:对最复杂的结构进行彻底的检查和调试,因为它们会引起一些疑问。同时,程序员忘记检查简单的结构。这是一个来自C的危险结构示例:

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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

注意切换情况下的值:命名常量之一取自不同的枚举。当然,在原始版本中,会有更多的代码和更多可能的值,并且错误不是那么明显。这样做的原因是枚举类型比较松散-它们可能隐式地转换为int,这为错误留下了很大的空间。

在C ++ 11中,您可以并且应该使用enum类:这样的技巧在那里不起作用,并且错误将在编译阶段显示。结果,以下代码无法编译,这正是我们所需要的:

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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;
}

以下片段与枚举关系不大,但具有相似的症状:

 

1
2
3
4
5
6
7
void adns__querysend_tcp(....) {
  ...
  if (!(errno == EAGAIN || EWOULDBLOCK || 
        errno == EINTR || errno == ENOSPC ||
        errno == ENOBUFS || errno == ENOMEM)) {
  ...
}

注意:此代码取自ReactOS。

是的,errno的值被声明为宏,这在C ++中(也是C语言中)都是不好的做法,但是即使程序员使用enum,也不会使生活变得更轻松。在枚举的情况下(尤其是在宏的情况下),丢失的比较将不会显示出来。同时,枚举类不允许这样做,因为不会隐式转换为bool。

在构造函数中初始化

但是回到本机C ++问题。其中之一揭示了何时需要在多个构造函数中以相同的方式初始化对象。一个简单的情况:有一个类,两个构造函数,其中一个调用另一个。一切看起来都很合乎逻辑:将通用代码放入单独的方法中-没有人喜欢复制代码。有什么陷阱?

 

1
2
3
4
5
6
7
8
9
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

陷阱在于构造函数调用的语法。通常它会被遗忘,程序员会再创建一个类实例,然后立即将其销毁。也就是说,原始实例的初始化没有发生。当然,有1001种方法可以解决此问题。例如,我们可以通过this显式调用构造函数, 或将所有内容放入单独的函数中:

 

1
2
3
4
5
6
7
8
9
10
11
Guess::Guess(const char * guess_str)
{
  this->Guess();
  ....
}
 
Guess::Guess(const char * guess_str)
{
  Init();
  ....
}

顺便说一句,例如,通过构造函数进行的显式重复调用是很危险的游戏,我们需要了解发生了什么。Init()的变体更好,更清晰。对于那些想更好地理解这些“陷阱”细节的人,建议阅读本书第19章“如何从另一个正确调用一个构造函数” 。

但是最好在这里使用构造函数的委托。因此,我们可以通过以下方式从另一个构造函数中显式调用一个构造函数:

 

1
2
3
4
Guess::Guess(const char * guess_str) : Guess()
{
  ....
}

这样的构造函数有几个局限性。首先:委托的构造函数对对象的初始化负全部责任。也就是说,将无法在初始化列表中用它来初始化另一个类字段:

 

1
2
3
4
5
6
Guess::Guess(const char * guess_str)
  : Guess(),           
    m_member(42)
{
  ....
}

当然,我们必须确保委派不会创建循环,因为将不可能退出该循环。不幸的是,该代码被编译了:

 

1
2
3
4
5
6
7
8
9
10
11
Guess::Guess(const char * guess_str)
  : Guess(std::string(guess_str))
{
  ....
}

Guess::Guess(std::string guess_str)
  : Guess(guess_str.c_str())
{
  ....
}

关于虚拟功能

虚函数阻碍了一个潜在的问题:事实是,在派生类的签名中犯一个错误非常简单,因此,不重写某个函数,而是声明一个新函数。在下面的示例中让我们看一下这种情况:

 

1
2
3
4
5
6
class Base {
  virtual void Foo(int x);
}
class Derived : public class Base {
  void Foo(int x, int a = 1);
}

不能通过指向Base的指针/引用来调用Derived :: Foo方法但这是一个简单的示例,您可能会说没有人犯过这样的错误。通常人们会通过以下方式犯错误:

注意:此代码取自MongoDB。

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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);
};

PVS-Studio警告:V762考虑检查虚函数参数。请参见派生类“ DBDirectClient”中的函数“ query”的第七个参数,以及基类“ DBClientBase”。dbdirectclient.cpp 61

继承者类的功能中有很多参数,没有最后一个参数。这些是不同的,未连接的功能。具有默认值的参数经常会发生这种错误。

在下一个片段中,情况将更加棘手。如果将其编译为32位代码,则此代码将起作用,但在64位版本中将不起作用。最初,在基类中,参数为DWORD类型,但随后将其更正为DWORD_PTR。同时,在继承的类中未进行更改。不眠之夜,调试和喝咖啡,万岁!

 

1
2
3
4
5
6
7
8
9
10
11
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或参数。您可能会忘记基类中的函数不是虚函数。您可以混淆带符号/无符号的类型。

在C ++中,添加了几个关键字,它们可以控制虚拟功能的覆盖。覆盖将有很大帮助。这段代码根本不会编译。

 

1
2
3
4
5
6
7
8
9
10
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;
};

NULL与nullptr

使用NULL指示空指针会导致许多意外情况。事实是NULL是一个正常的宏,它以int类型扩展为0 :这就是为什么不难理解为什么在此示例中选择第二个函数的原因:

 

1
2
3
void Foo(int x, int y, const char *name);
void Foo(int x, int y, int ResourceID);
Foo(1, 2, NULL);

尽管原因很明确,但这是非常不合逻辑的。这就是为什么在nullptr中需要有自己的类型nullptr_t的原因。这就是为什么我们不能在现代C ++中使用NULL(甚至更多的是0)。

另一个示例:NULL可用于与其他整数类型进行比较。假设有一些WinAPI函数返回HRESULT。此类型与指针没有任何关系,因此将其与NULL进行比较是没有意义的。而nullptr发出编译错误,同时强调这NULL作品:

 

1
2
3
if (WinApiFoo(a, b) != NULL)    // That's bad
if (WinApiFoo(a, b) != nullptr) // Hooray,
                                // a compilation error 

va_arg

在某些情况下,有必要传递数量不确定的参数。典型示例-格式化输入/输出的功能。是的,可以这样编写它,即不需要可变数量的参数,但是我认为没有理由放弃此语法,因为它更加方便和易于阅读。旧的C ++标准提供什么?他们建议使用va_list。我们有什么问题?将错误类型的参数传递给这样的参数并非易事。还是不要通过任何论点。让我们仔细看看这些片段。

 

1
2
3
4
5
6
7
8
9
10
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’函数接收类类型的变量作为第三个实际参数。第96章

程序员想打印std :: wstring字符串,但忘了调用方法c_str()。因此类型wstring将在函数中解释为const wchar_t *。当然,这不会有任何好处。

 

1
2
3
4
5
6
7
8
cairo_status_t
_cairo_win32_print_gdi_error (const char *context)
{
  ....
  fwprintf (stderr, L"%s: %S", context,
            (wchar_t *)lpMsgBuf);
  ....
}

注意:此代码取自开罗。

PVS-Studio警告:V576格式错误。考虑检查“ fwprintf”函数的第三个实际参数。预期指向wchar_t类型符号字符串的指针。cairo-win32-surface.c 130

在此片段中,程序员混淆了字符串格式的说明符。问题是,在Visual C ++的wchar_t *,以及%S –字符* 正在等待wprintf%S。有趣的是,这些错误位于用于错误输出或调试信息的字符串中-当然,这些情况很少见,这就是为什么跳过它们的原因。

 

1
2
3
4
5
6
7
8
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”函数的第四个实际参数。SIGNED整数类型参数是预期的。igame.h 66

整数类型也很容易混淆。特别是当它们的大小取决于平台时。但是,这里要简单得多:带符号和无符号类型被混淆了。大数字将被打印为负数。

 

1
2
3
4
5
6
7
8
9
ReadAndDumpLargeSttb(cb,err)
  int     cb;
  int     err;
{
  ....
  printf("\n - %d strings were read, "
         "%d were expected (decimal numbers) -\n");
  ....
}

注意:此代码是从Windows for Windows 1.1a的Word中获取的。

PVS-Studio警告:V576格式错误。调用“ printf”函数时,期望使用不同数量的实际参数。预期:3。当前:1。dini.c 498

在一项考古研究中发现的例子。该字符串的前提是三个参数,但未编写。也许程序员打算将数据打印在堆栈上,但是我们无法假设堆栈中的内容。当然,我们需要显式传递这些参数。

 

1
2
3
4
5
6
7
8
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,但更安全的一种:

 

1
2
3
4
5
6
7
8
9
10
11
12
13
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。它不允许您传递不同类型的参数。但是,如果这足够了,您可以使用它:

 

1
2
void Foo(std::initializer_list<int> a);
Foo({1, 2, 3, 4, 5});

它也非常方便穿越它,因为我们可以用开始结束和范围

缩小

狭窄的演员阵容在程序员的生活中引起了很多头痛。尤其是当迁移到64位体系结构变得更加必要时。如果您的代码中只有正确的类型,那就非常好。但这还不是那么积极:程序员经常使用各种肮脏的技巧,以及一些奢侈的指针存储方式。花了很多咖啡才能找到所有这些碎片:

1
2
3
4
char* ptr = ...;
int n = (int)ptr;
....
ptr = (char*) n;

但是,让我们暂时离开64位错误的话题。这是一个简单的示例:有两个整数值,程序员希望找到它们的比率。这是通过以下方式完成的:

 

1
2
3
4
5
6
7
8
9
10
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中的新初始化方法具有一个不错的功能:它禁止缩小转换。在此代码中,错误将在编译阶段发生,并且可以很容易地纠正。

 

1
2
float flRatio { pMat->GetMappingWidth() /
                pMat->GetMappingHeight() };

没有消息就是好消息

在资源和内存管理中有很多错误的方法。工作便利是现代语言的重要要求。现代C ++紧随其后,并提供了许多用于自动控制资源的工具。尽管此类错误是动态分析的核心,但在静态分析的帮助下仍可以发现一些问题。这里是其中的一些:

1
2
3
4
5
6
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,它具有数组的专用性并具有传递将被调用而不是deletedeleter函子的功能并且完全支持move语义。似乎这里什么都不会出错。

 

1
2
3
4
5
6
7
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时要格外小心:用脚射击自己比看起来容易得多。也许最好按照现代C ++的规定使用std :: vector

让我们看看另一种事故。

 

1
2
3
4
5
6
7
8
9
10
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;
  ....
}

注意:此代码取自虚幻引擎4。

PVS-Studio警告:V611使用“ new T []”运算符分配了内存,但使用“ delete”运算符释放了内存。考虑检查此代码。最好使用“ delete []代码;”。openglshaders.cpp 1790

没有智能指针也很容易犯同样的错误:通过delete []释放了用new []分配的内存

 

1
2
3
4
5
6
7
8
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 / freenew / delete混在一起了。这可能在重构期间发生:需要替换C中的函数,因此,我们有了UB。

 

1
2
3
4
5
6
7
8
9
int settings_proc_language_packs(....)
{
  ....
  if(mem_files) {
    mem_files = 0;
    sys_mem_free(mem_files);
  }
  ....
}

注意:此代码取自Fennec Media。

PVS-Studio警告:V575空指针将传递到“免费”功能。检查第一个参数。设置界面。c 3096

这是一个更有趣的例子。有一种做法是在释放指针后将其清零。有时,程序员甚至为此编写特殊的宏。一方面,这是一项很棒的技术:您可以保护自己免受其他内存版本的侵害。但是在这里,表达顺序混乱了,因此,free得到了一个空指针(这没有引起分析者的注意)。

 

1
2
3
4
5
6
7
8
9
10
11
12
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有助于避免许多与内存泄漏有关的错误。以这种风格编写的代码可以更直观地识别资源所有权。

作为一个小示例,我将使用unique_ptr的功能为FILE提供包装器:

 

1
2
3
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 并编写自己的文件,将会为您的个人需求进行优化,方便快捷,可读性和安全性。

结果是什么?

现代C ++提供了许多工具,可帮助您更安全地编写代码。已经出现了许多用于编译时评估和检查的构造。您可以切换到更方便的内存和资源管理模型。

但是没有一种技术或编程范例可以完全保护您免受错误的侵害。连同功能一起,C ++还获得了新的错误,这仅是它所特有的。这就是为什么我们不能仅依靠一种方法的原因:我们应该始终结合使用代码审查,质量代码和体面的工具;这可以帮助您节省时间和能量饮料,两者都可以更好地使用。

说到工具,我建议尝试使用PVS-Studio:我们最近开始使用它的Linux版本,您可以看到它的运行情况:它支持任何构建系统,并允许您仅通过构建它来检查项目。对于Windows开发人员,我们为Visual Studio提供了一个方便的插件,您可以尝试将其作为试用版

标签

发表评论