65.9K
CodeProject 正在变化。 阅读更多。
Home

现代化遗留 C++ 代码

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.84/5 (39投票s)

2014年12月15日

CPOL

11分钟阅读

viewsIcon

50929

使用C++11/14现代化遗留C++代码的经验和建议

引言

本文介绍了使用C++11/14现代化遗留C++代码的经验和建议。

我一直在致力于现代化几个不同规模的C++项目,有些较小,有些较大,有些是20年前开始的,代码库大约有4MLOC,其中90%以上是C++(其余是C#)。所有这些项目都是用MFC构建的,有些还使用了ATL,使用了MFC容器(有些是独占的)和90年代的编程风格。

重构这些项目的目的是使它们

  • 更简单:代码越简单,越容易阅读和理解,从而提高可维护性。
  • 更易于维护:使用MFC容器,特别是CPtrArray,在调试期间检查对象时会产生大问题。重构代码的一个重要部分是摆脱void指针和不友好的容器,使其更容易调试。
  • 更健壮:用智能指针替换原始指针和动态分配的内存有助于避免内存泄漏;同样,使用RAII惯用语有助于避免资源泄漏。此外,放弃指针而使用引用或值,代码更不容易出现由于使用null指针而导致的访问冲突等问题。
  • 性能更高:性能提升不是主要驱动因素,但任何性能提升都受欢迎。

标准C++容器

遗留代码的一个主要问题是大量使用C风格数组和MFC容器。当需要容器时,CArray(或其非模板变体,如CDWordArrayCStringArray)是主要选择。有时也会使用CListCMap。但使用最频繁的容器是CPtrArrayCPtrArray存储指向void的指针。最令人烦恼的后果是,在调试期间,你无法检查其内容。唯一的方法是逐个元素检查:找到存储在某个索引处的指针的值,然后在监视窗口中明确添加它并将其转换为适当的指针类型。

这是一个例子。我们考虑以下代码

struct foo
{
   int      a;
   double   b;
   CString  c;

   foo(int a, double b, CString const & c):
      a(a), b(b), c(c)
   {}
};

CPtrArray arr;

arr.Add(new foo(1, 1.0, L"one"));
arr.Add(new foo(2, 2.0, L"two"));
arr.Add(new foo(3, 3.0, L"three"));

当你在调试器中查看它时,你无法直接看到arr的元素,你需要检查它的大小,然后添加arr.m_pData,3(你不能说例如arr.m_pData, arr.m_nSize),然后选择每个指针并将其明确转换为foo*。这对于几个元素可能有效,但如果你有几十个甚至几百个元素怎么办?

还需要提到的是,当使用CPtrArray时,当容器超出作用域需要销毁时,你需要显式地遍历其元素并删除元素指向的每个对象。

for(INT_PTR i = 0; i < arr.GetSize(); ++i)
{
  foo* temp = (foo*)arr.GetAt(i);
  delete temp;
}

通过用std::vector<foo*>替换CPtrArray,你可以立即解决调试器中的这个问题,并且可以轻松检查vector的内容。

std::vector<foo*> vec;
vec.push_back(new foo(1, 1.0, L"one"));
vec.push_back(new foo(2, 2.0, L"two"));
vec.push_back(new foo(3, 3.0, L"three"));

当然,使用std::vector<foo*>时,你仍然需要遍历vector的元素并在vector超出作用域时删除对象。但是使用std::vector而不是CPtrArray只是第一步。根据代码的特定上下文,你可以使用值而不是指针(即std::vector<foo>)或智能指针(std::vector<std::shared_ptr<foo>>)。

std::vector<std::shared_ptr<foo>> vec;
vec.push_back(std::make_shared<foo>(1, 1.0, L"one"));
vec.push_back(std::make_shared<foo>(2, 2.0, L"two"));
vec.push_back(std::make_shared<foo>(3, 3.0, L"three"));

这是另一个标准C++容器可以改进代码的示例。在此版本中,使用C风格的宽字符数组来检索Windows用户的名称。

wchar_t buffer[50];
DWORD size = 50;
GetUserNameW(buffer, &size);

如果缓冲区不够怎么办?以下是 supposed 的改进版本。

wchar_t* buffer = NULL;
DWORD size = 0;
GetUserNameW(buffer, &size);

if(size > 0)
{
  buffer = new wchar_t[size];
  GetUserNameW(buffer, &size);
}

// do something with buffer

delete [] buffer;

这里的问题是内存是显式分配和释放的。如果在为缓冲区分配内存后但在删除它之前发生异常,它将无法正确删除并导致内存泄漏。

更好的替代方法是使用容器,例如std::vector<wchar_t>(保证连续内存)或者在这种特定情况下甚至std::wstring。这些容器使内存的分配和释放对用户透明,如果发生异常,容器对象会在栈展开期间被销毁,当容器销毁时,它会自动释放内存。因此,以下代码是异常安全的。

std::vector<wchar_t> buffer {};
DWORD size {0};
auto ret = GetUserNameW(nullptr, &size);

if(ret == 0 && ERROR_INSUFFICIENT_BUFFER == GetLastError() && size > 0)
{
  buffer.resize(size);
  GetUserNameW(buffer.data(), &size);
}

// do something with buffer

通常,以下标准容器应该替换

  • std::vector 用于 C 风格数组(当大小在运行时已知时)、CArrayCDWordArrayCStringArrayCPtrArray 等,甚至 CList,取决于列表的使用方式
  • std::array 用于 C 风格数组,当大小在编译时已知时
  • std::list 用于 CList
  • std::map 用于 CMap

至于其他标准容器,请根据您的具体需求使用它们。

引用和值优于指针

我遇到过许多指针被过度使用的情况,无论是作为局部变量还是作为函数参数。

这里有一个简单的例子(仅为简化而做的小片段)

void checkString(CString* pstrText)
{
   char ch = 0; // some character to find
   int pos = pstrText->Find(ch);
   if(pos != -1)
      *pstrText = “recompose the text”;
}

因此,这个函数接受一个CString指针,因为在某些情况下,它必须更新string。但另一方面,它没有检查指针的值。你可能会提供一个null指针,然后应用程序就会崩溃。这可以很容易地改变,使其接受一个引用。

void checkString(CString& text)
{
   char ch = 0; // some character to find
   int pos = text.Find(ch);
   if(pos != -1)
      text = “recompose the text”;
}

我遇到过许多函数接受指针而没有正当理由。它们中的大多数都可以改为使用引用或值(现在有移动语义)。但是有些情况下你必须检查接收到的参数是否“有效”。这对于指针很容易,因为你将其与null进行比较。但这对于引用是不可能的。在下面的例子中,我们有一个LogError函数,它将消息记录到指定的目标。但如果没有提供目标,则使用默认目标。将目标作为指针提供,可以更容易地检查它是否有效。

void LogError(ILogTarget* target, std::string const & text)
{
   ILogTarget* t = target != nullptr ? target : &_defaultTarget;

   t->Log(text);
}

尽管这看起来很适合使用指针,但在许多情况下,它可以通过两个重载进行重构:一个接受目标引用,另一个根本不接受目标并使用默认目标。

void LogError(ILogTarget& target, std::string const & text)
{
   target.Log(text);
}

void LogError(std::string const & text)
{
   LogError(_defaultTarget, text);
}

有时,堆对象的指针被用来避免复制对象。在 C++11 之前没有移动语义,所以值只能被复制。将对象放在堆上并传递其指针通过避免不必要的复制来提高性能。然而,有了移动语义后,情况不再如此。重量级对象可以被移动而不是复制。你应该遵循五法则,它规定当一个类型需要实现析构函数、复制构造函数、复制赋值运算符、移动构造函数和移动赋值运算符中的任何一个时,它应该实现所有这些。

智能指针

在某些情况下,指针仍然是必需的。在这些情况下,您应该使用智能指针,而不是原始指针。它们帮助您自动管理对象的生命周期并避免创建内存泄漏。

标准 C++ 库提供了几种智能指针

  • std::shared_ptr:用于必须共享所有权的对象,当指向该对象的最后一个共享指针对象被销毁时,销毁被指向的对象。
  • std::unique_ptr:用于不需要共享所有权的对象,当智能指针被销毁时,销毁被指向的对象(因为它保留了对象的唯一所有权)。
  • std::weak_ptr:持有对由std::shared_ptr管理的对象的一个非拥有引用。

标准库还提供了两个函数,std::make_sharedstd::make_unique,用于以异常安全的方式创建std::shared_ptrstd::unique_ptr

不要使用void指针

这一点已经讨论过了,但我再次将其作为一个独立的问题提出,以强调其重要性。指向void的指针表示原始内存,它可以是任何东西,并且需要显式转换为适当的类型。它很少有用,除非你编写类似 malloc 的东西,否则不应该使用它。编译器不知道它可能实际指向什么类型的对象,也无法阻止你转换为不正确的类型。这也使得调试器无法向你显示指向对象的内容,除非你明确地转换为正确的类型(正如我们上面已经看到的)。

基于范围的for循环

很多时候,当你在一个范围上迭代时,你并不关心索引,甚至可能不关心对象的类型。然而,大多数循环看起来像这样

void foo(CStringArray& arr)
{
   for(INT_PTR i = 0; i < arr.GetSize(); ++i)
   {
      CString str = arr.GetAt(i);
      
      // do something with str
   }
}

void foo(std::vector<CString>& arr)
{
   for(std::vector<CString>::iterator i = arr.begin(); i < arr.end(); ++i)
   {
      CString& str = *i;

      // do something with str
   }
}

许多这样的循环可以以更简单、更易读的方式重写。例如,第二个示例等价于

void bar(std::vector<CString>& arr)
{
   for(auto &str : arr)
   {
      // do something with str
   }
}

这是语法糖。编译器会将其转换为与上面所示类似的循环。但它要求被迭代的对象/容器存在begin()end()函数。因此,如果你尝试使用CStringArray对第一个示例执行相同的操作,你会得到错误

void foo(CStringArray& arr)
{
   for(auto std : arr)
   {
      // do something with str
   }
}
1>error C3312: no callable 'begin' function found for type 'CStringArray'
1>error C3312: no callable 'end' function found for type 'CStringArray' 

这可以通过为MFC容器定义此类函数来解决。以下示例是针对CStringArray的。你可以类似地为许多其他MFC容器定义,但请注意,你应该定义const和非const迭代器类。

template <typename A, typename T>
class CTypeArrayIterator
{
public:
   CTypeArrayIterator(A& collection, INT_PTR const index):
      m_index(index),
      m_collection(collection)
   {
   }

   bool operator!= (CTypeArrayIterator const & other) const
   {
      return m_index != other.m_index;
   }

   T& operator* () const
   {
      return m_collection[m_index];
   }

   CTypeArrayIterator const & operator++ ()
   {
      ++m_index;
      return *this;
   }

private:
   INT_PTR  m_index;
   A&       m_collection;
};

inline CTypeArrayIterator<CStringArray, CString> begin(CStringArray& collection)
{
   return CTypeArrayIterator<CStringArray, CString>(collection, 0);
}

inline CTypeArrayIterator<CStringArray, CString> end(CStringArray& collection)
{
   return CTypeArrayIterator<CStringArray, CString>(collection, collection.GetCount());
}

我创建了一个名为MFC Collection Utilities的开源项目,它使MFC开发人员能够将MFC容器(数组、列表、映射)与基于范围的for循环一起使用。该库需要Visual Studio 2012或更高版本,并支持所有MFC集合(模板和非模板)。NuGet包也可在此处获取。

虚函数说明符

C++不强制您在继承层次结构的派生类中指定virtual关键字来表明一个函数正在重写基类实现。在函数首次声明的类中拥有virtual就足够了。许多开发人员倾向于忽略派生类上的virtual关键字,这使得很难弄清楚,尤其是在大型代码库或大型层次结构中,哪个函数是virtual并实际重写了基类实现。

class foo
{
protected:
  virtual void f();
};
 
class bar : public foo
{
protected:
  void f();
};

当您处理深层继承层次结构(比如超过5层)并且每层有许多类,每个类有许多函数时,如果未指定virtual,仅通过阅读代码很难弄清楚哪些是virtual,哪些不是。您需要向上溯一层,在那里检查,如果找不到答案,再向上溯一层,依此类推,直到到达层次结构的根部。我不得不无数次这样做,我总是希望virtual是强制性的。

C++11为virtual函数引入了两个新的关键字说明符:override,表示一个virtual函数重写了另一个实现;final,表示一个virtual函数不能再被重写。

这些新的virtual说明符具有双重价值。首先,它们允许编译器立即标记错误。对于override,编译器可以检测到签名与virtual函数的基类签名不匹配并发出错误。对于final,它可以检测到派生类正在重新实现一个不应该再被重写的virtual函数。其次,它们提供了更好的代码自文档,我发现这同样重要。

我相信上面的代码应该始终这样写

class foo
{
protected:
  virtual void f();
};
 
class bar : public foo
{
protected:
  virtual void f() override;
};

即使您无法遍历大型遗留代码库并进行更改以在派生类中使用virtualoverridefinal关键字,您也应该始终为自己编写的新代码或您正在维护的代码这样做。

Const正确性

const关键字应用于所有不改变其值的变量,以及所有不改变对象状态的成员函数。这不仅有助于更好地文档化您的代码,还允许编译器立即标记对不可变变量或函数的不正确使用,并使其有机会更好地优化您的代码。它应应用于所有不应更改的变量或函数参数,以及非静态成员函数,以表明它们不应改变(this对象的)状态。

除了const之外,您还应该使用C++11的说明符constexpr,它表示函数或变量的值可以在编译时评估,因此可以在只允许编译时常量表达式的地方使用(例如数组的大小)。

constexpr mat_size(int const rows, int const cols) 
{
   return rows * cols;
}

int matrix[mat_size(10, 5)] = {0};

请注意,constexpr对于对象和类成员函数意味着const,对于所有函数意味着inline

结论

使用C++11特性(和风格)现代化遗留C++代码可以提高性能、健壮性、可读性和可维护性。如果你在大型项目上工作,你可能无法也可能不想改变所有内容,但你可以采取一种策略来重构和现代化关键部分(如果时间和预算允许),然后当你维护代码片段时,边维护边重构。至于新代码,我强烈建议你只用C++11/14/17编写。

历史

  • 2014年12月15日:初始版本
© . All rights reserved.