现代化遗留 C++ 代码






4.84/5 (39投票s)
使用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
(或其非模板变体,如CDWordArray
或CStringArray
)是主要选择。有时也会使用CList
或CMap
。但使用最频繁的容器是CPtrArray
。CPtrArray
存储指向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 风格数组(当大小在运行时已知时)、CArray
、CDWordArray
、CStringArray
、CPtrArray
等,甚至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_shared
和std::make_unique
,用于以异常安全的方式创建std::shared_ptr
和std::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;
};
即使您无法遍历大型遗留代码库并进行更改以在派生类中使用virtual
、override
和final
关键字,您也应该始终为自己编写的新代码或您正在维护的代码这样做。
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日:初始版本