提高 C++ 代码的健壮性






4.84/5 (46投票s)
如何编写更能容忍严重错误的 C++ 代码。
引言
当你的 C++ 程序规模增大时,跟踪其组件会变得困难,也更容易引入 bug。因此,认识到应该遵循一些规则来编写更能容忍错误的 এটা非常重要。
本文提供了一些技巧,可以帮助你编写更能容忍严重错误(崩溃)的代码。即使你的程序由于其复杂性和项目团队结构目前不遵循任何编码规则,遵循下面列出的简单规则也可以帮助你避免大多数崩溃情况。
背景
对于我开发的一些软件,我长期使用 一个名为 CrashRpt 的开源错误报告库。CrashRpt 库允许自动提交用户计算机上我的软件安装的错误报告。这样,我的软件中的所有严重错误(异常)都会通过互联网自动报告给我,使我能够改进我的软件,并在每个热修复版本中让用户更满意。
在分析收到的错误报告时,我发现程序崩溃的原因常常可以通过遵循简单的规则轻松避免。例如,有时我会忘记初始化局部变量,而这些变量可能包含垃圾值,导致数组索引溢出。有时,在使用指针变量之前,我没有执行 `NULL` 检查,这会导致访问冲突。
我将我的经验总结在下面的几条编码规则中,这些规则可以帮助你避免此类错误,并使你的软件更稳定(健壮)。
初始化局部变量
未初始化的局部变量是程序崩溃的常见原因。例如,请看以下代码片段
// Define local variables
BOOL bExitResult; // This will be TRUE if the function exits successfully
FILE* f; // Handle to file
TCHAR szBuffer[_MAX_PATH]; // String buffer
// Do something with variables above...
上面的代码片段可能是导致崩溃的潜在原因,因为没有一个局部变量被初始化。当你的代码运行时,这些变量将包含一些垃圾值。例如,`bExitResult` 布尔变量可能包含值 -135913245(本质上不是布尔值)。或者 `szBuffer` 字符串变量可能未以必须的零结尾。因此,初始化局部变量非常重要。
正确的代码应如下所示
// Define local variables
// Initialize function exit code with FALSE to indicate failure assumption
BOOL bExitResult = FALSE;
// This will be TRUE if the function exits successfully
// Initialize file handle with NULL
FILE* f = NULL; // Handle to file
// Initialize string buffer with empty string
TCHAR szBuffer[_MAX_PATH] = _T(""); // String buffer
// Do something with variables above...
注意:有人可能会说,对于一些时间关键的计算,变量初始化可能会有开销,他们说得对。如果你的代码需要尽可能快地执行,你可以自行承担风险跳过变量初始化。
初始化 WinAPI 结构
许多 WinAPI 函数通过 C 结构接收/返回参数。这样的结构,如果初始化不当,可能是导致崩溃的原因。建议使用 `ZeroMemory()` 宏或 `memset()` 函数将结构体清零(这通常会将结构体字段设置为其默认值)。
许多 WinAPI 结构还有一个 `cbSize` 参数,在使用前必须将其初始化为结构体的大小。
以下代码显示了如何初始化 WinAPI 结构
NOTIFYICONDATA nf; // WinAPI structure
memset(&nf,0,sizeof(NOTIFYICONDATA)); // Zero memory
nf.cbSize = sizeof(NOTIFYICONDATA); // Set structure size!
// Initialize other structure members
nf.hWnd = hWndParent;
nf.uID = 0;
nf.uFlags = NIF_ICON | NIF_TIP;
nf.hIcon = ::LoadIcon(NULL, IDI_APPLICATION);
_tcscpy_s(nf.szTip, 128, _T("Popup Tip Text"));
// Add a tray icon
Shell_NotifyIcon(NIM_ADD, &nf);
但是!不要对包含对象作为结构体成员的 C++ 结构体使用 `ZeroMemory()` 或 `memset()`;这可能会损坏其内部状态并导致崩溃。
// Declare a C++ structure
struct ItemInfo
{
// The structure has std::string object inside
std::string sItemName;
int nItemValue;
};
// Init the structure
ItemInfo item;
// Do not use memset()! It can corrupt the structure
// memset(&item, 0, sizeof(ItemInfo));
// Instead use the following
item.sItemName = "item1";
item.nItemValue = 0;
甚至更好的是为你的 C++ 结构体使用构造函数,该构造函数会将成员初始化为默认值
// Declare a C++ structure
struct ItemInfo
{
// Use structure constructor to set members with default values
ItemInfo()
{
sItemName = _T("unknown");
nItemValue = -1;
}
std::string sItemName; // The structure has std::string object inside
int nItemValue;
};
// Init the structure
ItemInfo item;
// Do not use memset()! It can corrupt the structure
// memset(&item, 0, sizeof(ItemInfo));
// Instead use the following
item.sItemName = "item1";
item.nItemValue = 0;
验证函数输入
建议始终验证函数输入参数。例如,如果你的函数是动态链接库的公共 API 的一部分,并且可能被外部客户端调用,那么不能保证外部客户端会传递正确的参数。
例如,让我们看一个假设的 `DrawVehicle()` 函数,它会在一个窗口上以不同的质量绘制一辆跑车。绘制质量 `nDrawingQaulity` 可以从 0(低质量)到 100(高质量)不等。`prcDraw` 中的绘制矩形定义了绘制汽车的位置。
下面是代码。请注意我们在使用输入参数值之前是如何验证它们的。
BOOL DrawVehicle(HWND hWnd, LPRECT prcDraw, int nDrawingQuality)
{
// Check that window is valid
if(!IsWindow(hWnd))
return FALSE;
// Check that drawing rect is valid
if(prcDraw==NULL)
return FALSE;
// Check drawing quality is valid
if(nDrawingQuality<0 || nDrawingQuality>100)
return FALSE;
// Now it's safe to draw the vehicle
// ...
return TRUE;
}
验证指针
不经验证就使用指针非常普遍。我甚至会说这是我软件中导致崩溃的主要原因。
如果你使用指针,请确保它不等于 `NULL`。如果你的代码试图使用 `NULL` 指针,这可能导致访问冲突异常。
CVehicle* pVehicle = GetCurrentVehicle();
// Validate pointer
if(pVehicle==NULL)
{
// Invalid pointer, do not use it!
return FALSE;
}
// Use the pointer
初始化函数输出
如果你的函数创建一个对象并将其作为函数参数返回,建议在函数体开头将指针初始化为 `NULL`。
如果你没有显式初始化输出参数,并且由于函数逻辑中的 bug 而未设置它,调用者可能会使用无效指针,这可能会导致崩溃。
这是一个不正确的代码示例
int CreateVehicle(CVehicle** ppVehicle)
{
if(CanCreateVehicle())
{
*ppVehicle = new CVehicle();
return 1;
}
// If CanCreateVehicle() returns FALSE,
// the pointer to *ppVehcile would never be set!
return 0;
}
正确的代码
int CreateVehicle(CVehicle** ppVehicle)
{
// First initialize the output parameter with NULL
*ppVehicle = NULL;
if(CanCreateVehicle())
{
*ppVehicle = new CVehicle();
return 1;
}
return 0;
}
清理指向已删除对象的指针
在释放(或删除)指针后,将其赋值为 `NULL`。这将有助于确保没有人会尝试重用无效指针。正如你可能猜到的,访问指向已删除对象的指针会导致访问冲突异常。
以下代码示例展示了如何清理指向已删除对象的指针。
// Create object
CVehicle* pVehicle = new CVehicle();
delete pVehicle; // Free pointer
pVehicle = NULL; // Set pointer with NULL
清理已释放的句柄
在释放句柄后,将其赋值为 `NULL`(或零,或某个默认值)。这将有助于确保没有人会尝试重用无效句柄。
以下是如何清理 WinAPI 文件句柄的示例
HANDLE hFile = INVALID_HANDLE_VALUE;
// Open file
hFile = CreateFile(_T("example.dat"), FILE_READ|FILE_WRITE, FILE_OPEN_EXISTING);
if(hFile==INVALID_HANDLE_VALUE)
{
return FALSE; // Error opening file
}
// Do something with file
// Finally, close the handle
if(hFile!=INVALID_HANDLE_VALUE)
{
CloseHandle(hFile); // Close handle to file
hFile = INVALID_HANDLE_VALUE; // Clean up handle
}
以下是如何清理 `FILE*` 句柄的示例
// First init file handle pointer with NULL
FILE* f = NULL;
// Open handle to file
errno_t err = _tfopen_s(_T("example.dat"), _T("rb"));
if(err!=0 || f==NULL)
return FALSE; // Error opening file
// Do something with file
// When finished, close the handle
if(f!=NULL) // Check that handle is valid
{
fclose(f);
f = NULL; // Clean up pointer to handle
}
使用 delete [] 运算符处理数组
如果你使用 `new` 运算符分配单个对象,你应该使用 `delete` 运算符释放它。
但是,如果你使用 `new` 运算符分配对象数组,你应该使用 `delete []` 释放该数组。
// Create an array of objects
CVehicle* paVehicles = new CVehicle[10];
delete [] paVehicles; // Free pointer to array
paVehicles = NULL; // Set pointer with NULL
或
// Create a buffer of bytes
LPBYTE pBuffer = new BYTE[255];
delete [] pBuffer; // Free pointer to array
pBuffer = NULL; // Set pointer with NULL
小心分配内存
有时需要动态分配缓冲区,但缓冲区大小是在运行时确定的。例如,如果你需要将文件读入内存,你会分配一个内存缓冲区,其大小等于文件大小。但在分配缓冲区之前,请确保不使用 `malloc()` 或 `new` 分配 0 字节。将不正确的参数传递给 `malloc()` 会导致 C 运行时错误。
以下代码示例显示了动态缓冲区分配
// Determine what buffer to allocate.
UINT uBufferSize = GetBufferSize();
LPBYTE* pBuffer = NULL; // Init pointer to buffer
// Allocate a buffer only if buffer size > 0
if(uBufferSize>0)
pBuffer = new BYTE[uBufferSize];
有关如何正确分配内存的更多提示,你可以阅读 C 和 C++ 中内存分配的安全编码最佳实践 文章。
谨慎使用断言
断言可以在调试模式下用于检查前置条件和后置条件。但是在 Release 模式下编译程序时,断言会在预处理阶段被移除。因此,仅使用断言不足以验证程序的 S 状态。
不正确的代码
#include <assert.h>
// This function reads a sports car's model from a file
CVehicle* ReadVehicleModelFromFile(LPCTSTR szFileName)
{
CVehicle* pVehicle = NULL; // Pointer to vehicle object
// Check preconditions
assert(szFileName!=NULL); // This will be removed by preprocessor in Release mode!
assert(_tcslen(szFileName)!=0); // This will be removed in Release mode!
// Open the file
FILE* f = _tfopen(szFileName, _T("rt"));
// Create new CVehicle object
pVehicle = new CVehicle();
// Read vehicle model from file
// Check postcondition
assert(pVehicle->GetWheelCount()==4); // This will be removed in Release mode!
// Return pointer to the vehicle object
return pVehicle;
}
如上代码所示,使用断言可以在 Debug 模式下帮助你检查程序状态,但在 Release 模式下,这些检查会消失。因此,除了断言,你还必须使用 `if()` 检查。
正确的代码应如下所示
#include <assert.h>
CVehicle* ReadVehicleModelFromFile(LPCTSTR szFileName, )
{
CVehicle* pVehicle = NULL; // Pointer to vehicle object
// Check preconditions
assert(szFileName!=NULL);
// This will be removed by preprocessor in Release mode!
assert(_tcslen(szFileName)!=0);
// This will be removed in Release mode!
if(szFileName==NULL || _tcslen(szFileName)==0)
return NULL; // Invalid input parameter
// Open the file
FILE* f = _tfopen(szFileName, _T("rt"));
// Create new CVehicle object
pVehicle = new CVehicle();
// Read vehicle model from file
// Check postcondition
assert(pVehicle->GetWheelCount()==4); // This will be removed in Release mode!
if(pVehicle->GetWheelCount()!=4)
{
// Oops... an invalid wheel count was encountered!
delete pVehicle;
pVehicle = NULL;
}
// Return pointer to the vehicle object
return pVehicle;
}
检查函数的返回值
调用函数并假定它会成功是一个常见的错误。调用函数时,建议检查其返回值和输出参数的值。
以下代码连续调用函数。是否继续或退出取决于返回值和输出参数。
HRESULT hres = E_FAIL;
IWbemServices *pSvc = NULL;
IWbemLocator *pLoc = NULL;
hres = CoInitializeSecurity(
NULL,
-1, // COM authentication
NULL, // Authentication services
NULL, // Reserved
RPC_C_AUTHN_LEVEL_DEFAULT, // Default authentication
RPC_C_IMP_LEVEL_IMPERSONATE, // Default Impersonation
NULL, // Authentication info
EOAC_NONE, // Additional capabilities
NULL // Reserved
);
if (FAILED(hres))
{
// Failed to initialize security
if(hres!=RPC_E_TOO_LATE)
return FALSE;
}
hres = CoCreateInstance(
CLSID_WbemLocator,
0,
CLSCTX_INPROC_SERVER,
IID_IWbemLocator, (LPVOID *) &pLoc);
if (FAILED(hres) || !pLoc)
{
// Failed to create IWbemLocator object.
return FALSE;
}
hres = pLoc->ConnectServer(
_bstr_t(L"ROOT\\CIMV2"), // Object path of WMI namespace
NULL, // User name. NULL = current user
NULL, // User password. NULL = current
0, // Locale. NULL indicates current
NULL, // Security flags.
0, // Authority (e.g. Kerberos)
0, // Context object
&pSvc // pointer to IWbemServices proxy
);
if (FAILED(hres) || !pSvc)
{
// Couldn't conect server
if(pLoc) pLoc->Release();
return FALSE;
}
hres = CoSetProxyBlanket(
pSvc, // Indicates the proxy to set
RPC_C_AUTHN_WINNT, // RPC_C_AUTHN_xxx
RPC_C_AUTHZ_NONE, // RPC_C_AUTHZ_xxx
NULL, // Server principal name
RPC_C_AUTHN_LEVEL_CALL, // RPC_C_AUTHN_LEVEL_xxx
RPC_C_IMP_LEVEL_IMPERSONATE, // RPC_C_IMP_LEVEL_xxx
NULL, // client identity
EOAC_NONE // proxy capabilities
);
if (FAILED(hres))
{
// Could not set proxy blanket.
if(pSvc) pSvc->Release();
if(pLoc) pLoc->Release();
return FALSE;
}
使用智能指针
如果你大量使用共享对象的指针(例如,COM 接口),将其包装到智能指针中是一个好习惯。智能指针将负责对象的引用计数,并保护你免于访问已被删除的对象。也就是说,你无需担心控制接口指针的生命周期。
有关智能指针的更多信息,请参阅以下文章:智能指针 - 是什么、为什么、哪个? 和 在 C++ 中实现简单的智能指针。
下面是一个代码示例(来自 MSDN),它使用 ATL 的 `CComPtr` 模板类作为智能指针。
#include <windows.h>
#include <shobjidl.h>
#include <atlbase.h> // Contains the declaration of CComPtr.
int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, PWSTR pCmdLine, int nCmdShow)
{
HRESULT hr = CoInitializeEx(NULL, COINIT_APARTMENTTHREADED |
COINIT_DISABLE_OLE1DDE);
if (SUCCEEDED(hr))
{
CComPtr<IFileOpenDialog> pFileOpen;
// Create the FileOpenDialog object.
hr = pFileOpen.CoCreateInstance(__uuidof(FileOpenDialog));
if (SUCCEEDED(hr))
{
// Show the Open dialog box.
hr = pFileOpen->Show(NULL);
// Get the file name from the dialog box.
if (SUCCEEDED(hr))
{
CComPtr<IShellItem> pItem;
hr = pFileOpen->GetResult(&pItem);
if (SUCCEEDED(hr))
{
PWSTR pszFilePath;
hr = pItem->GetDisplayName(SIGDN_FILESYSPATH, &pszFilePath);
// Display the file name to the user.
if (SUCCEEDED(hr))
{
MessageBox(NULL, pszFilePath, L"File Path", MB_OK);
CoTaskMemFree(pszFilePath);
}
}
// pItem goes out of scope.
}
// pFileOpen goes out of scope.
}
CoUninitialize();
}
return 0;
}
谨慎使用 == 运算符
看以下代码片段
CVehicle* pVehicle = GetCurrentVehicle();
// Validate pointer
if(pVehicle==NULL) // Using == operator to compare pointer with NULL
return FALSE;
// Do something with the pointer
pVehicle->Run();
上面的代码是正确的,并使用了指针验证。但是,假设你输入错误,使用了赋值运算符(`=`)而不是相等运算符(`==`)
CVehicle* pVehicle = GetCurrentVehicle();
// Validate pointer
if(pVehicle=NULL) // Oops! A mistyping here!
return FALSE;
// Do something with the pointer
pVehicle->Run(); // Crash!!!
如上面的代码所示,这种误输入可能是导致愚蠢崩溃的原因。
通过稍微修改指针验证代码(交换相等运算符的左右两边)可以避免这种错误。如果在修改后的代码中输入错误,它将在编译阶段被检测到。
// Validate pointer
if(NULL==pVehicle)
// Exchange left side and right side of the equality operator
return FALSE;
// Validate pointer
if(NULL=pVehicle)
// Oops! A mistyping here! But the compiler returns an error message.
return FALSE;
历史
- 2011 年 7 月 24 日 - 首次发布。