如何減少編碼過程中的錯誤

 

如何減少編碼過程中的錯誤

 

  2011年08月16日 09:00
 


摘要

我查看了廣爲熟知的即時聊天工具 Miranda IM 的源代碼。該項目的規模非常大,將各種插件全部考慮在內,大約包括 950,000 個 C 和 C++ 代碼行。與其它任何開發時間較長的大型項目相同,它存在很多錯誤和錯字。

簡介

以下是整篇文章的縮簡版,若想閱讀全部內容,請點擊此處

通過檢查不同應用中存在的缺陷,我總結出一些規律。接下來,我會列舉一些在 Miranda IM 中找到的缺陷示例,並嘗試提出相關建議,幫助您避免大量可能在編碼階段發生的錯誤和錯字。

我使用了 PVS-Studio 4.14 分析器來檢查 Miranda IM 程序。Miranda IM 項目的代碼質量非常高,這一點有它的受歡迎程度爲證。我自己也在使用這款聊天工具,對它的質量十分滿意。項目採用支持 3 級警告(/W3)的 Visual Studio 構建而成,不過註釋的數量佔去了整個項目資源的 20%。

1. 避免使用 memset、memcpy、ZeroMemory 和其它類似函數

首先,我想與大家分享一些使用低級函數如 memset、memcpy 和 ZeroMemory 等處理內存時可能出現的錯誤。

我建議您想盡一切方法避免使用這些函數。當然,您也無需完全以此爲準,將所有這些函數全部使用循環來替代。不過,我看到過許多因使用這些函數所犯的錯誤,強烈建議您慎之又慎,只在確實有必要時才使用它們。我認爲,只有以下兩種情況必須使用這些函數:

1) 處理大型陣列時,即當您能夠從優化的函數算法中獲得優於簡單循環的、切實可見的惠益時。

2) 處理大量小型陣列時,此處必須採用低級函數的原因也與性能提升有關。

但是,在所有其它情形中,您最好儘量避免使用上述低級函數。例如,我認爲,在類似 Miranda 這樣的程序中,根本沒有必要使用這些函數,因爲 Miranda 不包括任何資源密集型算法和大型陣列。事實上,使用 memset/memcpy 等函數的唯一原因是編寫短代碼的方便性。然而,這種簡易性具有很大的欺騙性,雖然編寫代碼時能夠節省幾秒鐘的時間,但您可能需要花費幾周的時間來查找由此造成的難以察覺的內存損壞錯誤。下面,讓我們共同分析一下來自 Miranda IM 項目的幾個代碼示例。

V512 A call of the 'memcpy' function will lead to a buffer overflow or underflow. tabsrmm utils.cpp 1080

  1. typedef struct _textrangew   
  2. {   
  3.   CHARRANGE chrg;   
  4.   LPWSTR lpstrText;   
  5. } TEXTRANGEW;   
  6.   
  7. const wchar_t* Utils::extractURLFromRichEdit(...)   
  8. {   
  9.   ...   
  10.   ::CopyMemory(tr.lpstrText, L"mailto:", 7);   
  11.   ...   
  12. }  
typedef struct _textrangew
{
  CHARRANGE chrg;
  LPWSTR lpstrText;
} TEXTRANGEW;

const wchar_t* Utils::extractURLFromRichEdit(...)
{
  ...
  ::CopyMemory(tr.lpstrText, L"mailto:", 7);
  ...
}

這裏只拷貝了整個字符串的一部分。錯誤非常簡單,但切實存在。最有可能的情況是,之前存在一個包括“char”的字符串。後來程序員轉爲使用 Unicode 字符串,但卻忘記了更改常數。

如果您使用專門的函數來拷貝字符串,這種錯誤絕對不會發生。想象一下,如果該代碼示例採用以下方式編寫:

  1. strncpy(tr.lpstrText, "mailto:", 7);  
strncpy(tr.lpstrText, "mailto:", 7);

那麼程序員在轉至 Unicode 字符串時便不需要更改數字 7:

  1. wcsncpy(tr.lpstrText, L"mailto:", 7);  
wcsncpy(tr.lpstrText, L"mailto:", 7);

我並不是說這個代碼就是完美的,只是它比使用 CopyMemory 要好得多。下面請看另一個示例。

V568 It's odd that the argument of sizeof() operator is the '& ImgIndex' expression. clist_modern modern_extraimage.cpp 302

  1. void ExtraImage_SetAllExtraIcons(HWND hwndList,HANDLE hContact)   
  2. {   
  3.   ...   
  4.   char *(ImgIndex[64]);   
  5.   ...   
  6.   memset(&ImgIndex,0,sizeof(&ImgIndex));   
  7.   ...   
  8. }  
void ExtraImage_SetAllExtraIcons(HWND hwndList,HANDLE hContact)
{
  ...
  char *(ImgIndex[64]);
  ...
  memset(&ImgIndex,0,sizeof(&ImgIndex));
  ...
}

在這裏,程序員的本意是清空包含 64 個指針的陣列,但結果只會清空第一個項目。

以下爲另一個示例。

V568 It's odd that the argument of sizeof() operator is the '& rowOptTA' expression. clist_modern modern_rowtemplateopt.cpp 258

  1. static ROWCELL* rowOptTA[100];   
  2.   
  3. void rowOptAddContainer(HWND htree, HTREEITEM hti)   
  4. {   
  5.   ...   
  6.   ZeroMemory(rowOptTA,sizeof(&rowOptTA));   
  7.   ...   
  8. }  
static ROWCELL* rowOptTA[100];

void rowOptAddContainer(HWND htree, HTREEITEM hti)
{
  ...
  ZeroMemory(rowOptTA,sizeof(&rowOptTA));
  ...
}

同樣,代碼計算的是指針的大小,而不是陣列的大小。正確的表達式是“sizeof(rowOptTA)”。我建議使用下方的代碼來清除陣列:

  1. const size_t ArraySize = 100;   
  2. static ROWCELL* rowOptTA[ArraySize];   
  3. ...   
  4. std::fill(rowOptTA, rowOptTA + ArraySize, nullptr);  
const size_t ArraySize = 100;
static ROWCELL* rowOptTA[ArraySize];
...
std::fill(rowOptTA, rowOptTA + ArraySize, nullptr);

您是否認爲只有與低級陣列處理操作相關的代碼會出現這種情況?如果是這樣,那您就大錯特錯了。繼續閱讀本文,向喜歡使用 memset 函數的程序員提出警告和批評。

V512 A call of the 'memset' function will lead to a buffer overflow or underflow. clist_modern modern_image_array.cpp 59

  1. static BOOL ImageArray_Alloc(LP_IMAGE_ARRAY_DATA iad, int size)   
  2. {   
  3.   ...   
  4.   memset(&iad->nodes[iad->nodes_allocated_size],    
  5.     (size_grow - iad->nodes_allocated_size) *   
  6.        sizeof(IMAGE_ARRAY_DATA_NODE),   
  7.     0);   
  8.   ...   
  9. }  
static BOOL ImageArray_Alloc(LP_IMAGE_ARRAY_DATA iad, int size)
{
  ...
  memset(&iad->nodes[iad->nodes_allocated_size], 
    (size_grow - iad->nodes_allocated_size) *
       sizeof(IMAGE_ARRAY_DATA_NODE),
    0);
  ...
}

在這個代碼示例中,拷貝數據的大小能夠正確計算出來,但第二個和第三個變元位置顛倒了。結果,沒有項目會被填充。正確的代碼如下:

  1. memset(&iad->nodes[iad->nodes_allocated_size], 0,   
  2.   (size_grow - iad->nodes_allocated_size) *   
  3.      sizeof(IMAGE_ARRAY_DATA_NODE));  
memset(&iad->nodes[iad->nodes_allocated_size], 0,
  (size_grow - iad->nodes_allocated_size) *
     sizeof(IMAGE_ARRAY_DATA_NODE));

我不知道怎樣更好地重新編寫這個代碼片段。更確切的說,如果不更改其它片段和數據結構,根本沒有辦法改進這段代碼。

 

這樣便出現了一個問題,即如何在不使用 memset 的情況下處理 OPENFILENAME 等結構:

  1. OPENFILENAME x;   
  2. memset(&x, 0, sizeof(x));  
OPENFILENAME x;
memset(&x, 0, sizeof(x));

這個問題很容易解決,只需使用以下方法創建一個空結構(emptied structure)即可:

  1. OPENFILENAME x = { 0 };  
OPENFILENAME x = { 0 };

2. 仔細觀察,判斷使用的是帶符號還是無符號類型

乍一想,搞混帶符號類型和無符號類型這種問題似乎根本不會發生。然而,程序員經常會因爲過度低估這個問題而犯下嚴重的錯誤。

大多數情況下,程序員不喜歡查看編譯器關於整型變量和無符號變量對比的警告信息。確實,這種代碼一般不會出錯。所以,程序員通常會禁用這些警告,或者對它們視而不見。或者,他們會採用第三種方法——添加顯式類型轉換,禁止顯示編譯器警告,不去查看詳細信息。

我建議大家從現在起改變這些做法,每次帶符號類型和無符號類型相遇時都仔細分析具體情況。總的來說,要特別注意檢查表達式包括的類型或函數返回的內容。下面列出了幾個相關的示例。

V547 Expression 'wParam >= 0' is always true. Unsigned type value is always >= 0. clist_mw cluiframes.c 3140

程序代碼中包括 id2pos 函數,該函數在出錯時會返回數值“-1”。函數的各個方面都沒有問題。但是,在另一部分代碼中,程序員採用以下方式使用了 id2pos 函數的計算結果:

  1. typedef UINT_PTR WPARAM;    
  2. static int id2pos(int id);   
  3. static int nFramescount=0;   
  4.   
  5. INT_PTR CLUIFrameSetFloat(WPARAM wParam,LPARAM lParam)   
  6. {   
  7.   ...   
  8.   wParam=id2pos(wParam);   
  9.   if(wParam>=0&&(int)wParam<nFramescount)   
  10.     if (Frames[wParam].floating)   
  11.   ...   
  12. }  
typedef UINT_PTR WPARAM; 
static int id2pos(int id);
static int nFramescount=0;

INT_PTR CLUIFrameSetFloat(WPARAM wParam,LPARAM lParam)
{
  ...
  wParam=id2pos(wParam);
  if(wParam>=0&&(int)wParam<nFramescount)
    if (Frames[wParam].floating)
  ...
}

這裏的問題是,wParam 變量擁有無符號類型。結果,條件“wParam>=0”始終都是正確的。即使 id2pos 函數返回“-1”,檢查允許值的條件也根本不會發揮作用,導致我們在之後的計算中一直使用負指數。

我幾乎可以確定,一開始的代碼是不同的:

if (wParam>=0 && wParam<nFramescount)

Visual C++ 編譯器生成了“warning C4018:'<' : signed/unsigned mismatch”警告。這個警告正是在 Miranda IM 採用的 3 級警告中所啓用的警告。此時,程序員基本上不會注意到這個片段。他使用了顯式類型轉換來禁止顯示該警告。但是,錯誤並沒有因此而消失,只是隱藏了起來。正確的代碼如下:

if ((INT_PTR)wParam>=0 && (INT_PTR)wParam<nFramescount)

鑑於上述原因,我要提醒大家在遇到相同情況時務必保持警惕。我計算了一下,Miranda IM 中由於帶符號/無符號類型混淆不清而導致條件始終正確或始終錯誤的缺陷有 33 個。

我們接着看下一個示例,我個人非常喜歡這個例子,而且註釋很出色。

V547 Expression 'nOldLength < 0' is always false. Unsigned type value is never < 0. IRC mstring.h 229

  1. void Append( PCXSTR pszSrc, int nLength )   
  2. {   
  3.   ...   
  4.   UINT nOldLength = GetLength();   
  5.   if (nOldLength < 0)   
  6.   {   
  7.     // protects from underflow   
  8.     nOldLength = 0;   
  9.   }   
  10.   ...   
  11. }  
void Append( PCXSTR pszSrc, int nLength )
{
  ...
  UINT nOldLength = GetLength();
  if (nOldLength < 0)
  {
    // protects from underflow
    nOldLength = 0;
  }
  ...
}

我認爲已經沒有必要再進一步解釋這個代碼存在的問題。

當然,程序中出現錯誤並非都是程序員的責任。有些時候,庫開發人員會給我們帶來很大的麻煩(在該示例中是 WinAPI 開發人員)。

  1. #define SRMSGSET_LIMITNAMESLEN_MIN 0   
  2. static INT_PTR CALLBACK DlgProcTabsOptions(...)   
  3. {   
  4.   ...   
  5.   limitLength =   
  6.     GetDlgItemInt(hwndDlg, IDC_LIMITNAMESLEN, NULL, TRUE) >=   
  7.     SRMSGSET_LIMITNAMESLEN_MIN ?   
  8.     GetDlgItemInt(hwndDlg, IDC_LIMITNAMESLEN, NULL, TRUE) :   
  9.     SRMSGSET_LIMITNAMESLEN_MIN;   
  10.   ...   
  11. }  
#define SRMSGSET_LIMITNAMESLEN_MIN 0
static INT_PTR CALLBACK DlgProcTabsOptions(...)
{
  ...
  limitLength =
    GetDlgItemInt(hwndDlg, IDC_LIMITNAMESLEN, NULL, TRUE) >=
    SRMSGSET_LIMITNAMESLEN_MIN ?
    GetDlgItemInt(hwndDlg, IDC_LIMITNAMESLEN, NULL, TRUE) :
    SRMSGSET_LIMITNAMESLEN_MIN;
  ...
}

如果忽略表達式特別複雜這個現象,代碼看上去沒有問題。順便提一句,原始示例只有一個代碼行。爲了看起來更清晰,我將它分爲了幾行。不過,編輯問題並不是我們的討論重點。

這段代碼真正的問題是,GetDlgItemInt() 函數不會像程序員所預期的那樣返回“int”,而是返回 UINT。以下爲函數在“WinUser.h”文件中的原型:

  1. WINUSERAPI   
  2. UINT  
  3. WINAPI   
  4. GetDlgItemInt(   
  5.     __in HWND hDlg,   
  6.     __in int nIDDlgItem,   
  7.     __out_opt BOOL *lpTranslated,   
  8.     __in BOOL bSigned);  
WINUSERAPI
UINT
WINAPI
GetDlgItemInt(
    __in HWND hDlg,
    __in int nIDDlgItem,
    __out_opt BOOL *lpTranslated,
    __in BOOL bSigned);

PVS-Studio 生成以下消息:

V547 Expression is always true. Unsigned type value is always >= 0. scriver msgoptions.c 458

事實確實如此。“GetDlgItemInt(hwndDlg, IDC_LIMITNAMESLEN, NULL, TRUE) >= SRMSGSET_LIMITNAMESLEN_MIN”表達式返回的值始終都是“TRUE”。

或許在這個具體示例中,這並不會出錯。不過,您應該明白我真正的意思。請時刻保持謹慎,檢查函數返回的結果。

3. 避免在一個字符串中使用太多的計算

所有程序員都非常清楚一點,並且在談及相關問題時通常會負責任地指出,程序員應儘量編寫簡單、清晰的代碼。然而,事實上,程序員之間似乎存在一種祕密的競爭,他們會爭先使用有趣的語言結構或指針篡改(juggling)等技能編寫最複雜的字符串。

很多時候,當程序員將多個行爲整合至一個代碼行中時,非常可能出現錯誤。他們這樣做只能稍微改進代碼的質量,但卻要冒着很大的出現錯字或忽略部分副作用的風險。分析下方的示例:

V567 Undefined behavior. The 's' variable is modified while being used twice between sequence points. msn ezxml.c 371

  1. short ezxml_internal_dtd(ezxml_root_t root, char *s, size_t len)   
  2. {   
  3.   ...   
  4.   while (*(n = ++s + strspn(s, EZXML_WS)) && *n != '>') {   
  5.   ...   
  6. }  
short ezxml_internal_dtd(ezxml_root_t root, char *s, size_t len)
{
  ...
  while (*(n = ++s + strspn(s, EZXML_WS)) && *n != '>') {
  ...
}

在該示例中,有些行爲沒有經過定義。這個代碼可能能夠長時間準確無誤地執行,但卻沒有辦法保證它遷移至不同編譯器版本或優化交換機後仍會保持相同的行爲模式。編譯器可能會首先計算“++s”,然後再調用函數“strspn(s, EZXML_WS)”。反之亦然,它可能會首先調用函數,然後才增加“s”變量。

下面的示例再次解釋了爲什麼不應該將所有內容全部整合到一個代碼行中。Miranda IM 中的部分執行分支通過“&& 0”等插入符實現禁用/啓用。例如:

  1. if ((1 || altDraw) && ...   
  2. if (g_CluiData.bCurrentAlpha==GoalAlpha &&0)   
  3. if(checkboxWidth && (subindex==-1 ||1)) {  
if ((1 || altDraw) && ...
if (g_CluiData.bCurrentAlpha==GoalAlpha &&0)
if(checkboxWidth && (subindex==-1 ||1)) {

這樣對比過後,事情明朗了很多。現在,假設您看到了下面的代碼片段(我對代碼進行了編輯,最初只有一行):

V560 A part of conditional expression is always false: 0. clist_modern modern_clui.cpp 2979

  1. LRESULT CLUI::OnDrawItem( UINT msg, WPARAM wParam, LPARAM lParam )   
  2. {   
  3.   ...   
  4.   DrawState(dis->hDC,NULL,NULL,(LPARAM)hIcon,0,   
  5.     dis->rcItem.right+dis->rcItem.left-   
  6.     GetSystemMetrics(SM_CXSMICON))/2+dx,   
  7.     (dis->rcItem.bottom+dis->rcItem.top-   
  8.     GetSystemMetrics(SM_CYSMICON))/2+dx,   
  9.     0,0,   
  10.     DST_ICON|   
  11.     (dis->itemState&ODS_INACTIVE&&FALSE?DSS_DISABLED:DSS_NORMAL));   
  12.    ...   
  13. }  
LRESULT CLUI::OnDrawItem( UINT msg, WPARAM wParam, LPARAM lParam )
{
  ...
  DrawState(dis->hDC,NULL,NULL,(LPARAM)hIcon,0,
    dis->rcItem.right+dis->rcItem.left-
    GetSystemMetrics(SM_CXSMICON))/2+dx,
    (dis->rcItem.bottom+dis->rcItem.top-
    GetSystemMetrics(SM_CYSMICON))/2+dx,
    0,0,
    DST_ICON|
    (dis->itemState&ODS_INACTIVE&&FALSE?DSS_DISABLED:DSS_NORMAL));
   ...
}

即使沒有錯誤,要記起並在代碼行中找到“FALSE”的位置仍然比較困難。您找到了嗎?確實不容易,是吧?那麼,如果確實存在錯誤又會怎麼樣呢?只看代碼根本不可能找到錯誤的所在。這種表達式應單獨作爲一行,例如:

  1. UINT uFlags = DST_ICON;   
  2. uFlags |= dis->itemState & ODS_INACTIVE && FALSE ?   
  3.             DSS_DISABLED : DSS_NORMAL;  
UINT uFlags = DST_ICON;
uFlags |= dis->itemState & ODS_INACTIVE && FALSE ?
            DSS_DISABLED : DSS_NORMAL;

如果是我的話,我會不惜增加代碼的長度以讓它更清晰:

  1. UINT uFlags;   
  2. if (dis->itemState & ODS_INACTIVE && (((FALSE))))   
  3.   uFlags = DST_ICON | DSS_DISABLED;   
  4. else    
  5.   uFlags = DST_ICON | DSS_NORMAL;  
UINT uFlags;
if (dis->itemState & ODS_INACTIVE && (((FALSE))))
  uFlags = DST_ICON | DSS_DISABLED;
else 
  uFlags = DST_ICON | DSS_NORMAL;

沒錯,代碼長度是增加了,但它理解起來更容易,很輕鬆便能夠找到“FALSE”。

4. 對齊代碼中一切能夠對齊的內容

代碼對齊能夠減少您打錯字或在進行復制粘貼操作時出錯的可能性。如果還是出現了錯誤,那麼在檢查代碼時將能夠非常輕鬆地找到錯誤。下面讓我們來看一個代碼示例。

V537 Consider reviewing the correctness of 'maxX' item's usage. clist_modern modern_skinengine.cpp 2898

  1. static BOOL ske_DrawTextEffect(...)   
  2. {   
  3.   ...   
  4.   minX=max(0,minX+mcLeftStart-2);   
  5.   minY=max(0,minY+mcTopStart-2);   
  6.   maxX=min((int)width,maxX+mcRightEnd-1);   
  7.   maxY=min((int)height,maxX+mcBottomEnd-1);   
  8.   ...   
  9. }  
static BOOL ske_DrawTextEffect(...)
{
  ...
  minX=max(0,minX+mcLeftStart-2);
  minY=max(0,minY+mcTopStart-2);
  maxX=min((int)width,maxX+mcRightEnd-1);
  maxY=min((int)height,maxX+mcBottomEnd-1);
  ...
}

這只是一個純粹的代碼片段,並沒有引人注意的地方。我們對它編輯一下:

  1. minX = max(0,           minX + mcLeftStart - 2);   
  2. minY = max(0,           minY + mcTopStart  - 2);   
  3. maxX = min((int)width,  maxX + mcRightEnd  - 1);   
  4. maxY = min((int)height, maxX + mcBottomEnd - 1);  
minX = max(0,           minX + mcLeftStart - 2);
minY = max(0,           minY + mcTopStart  - 2);
maxX = min((int)width,  maxX + mcRightEnd  - 1);
maxY = min((int)height, maxX + mcBottomEnd - 1);

這並不是最典型的示例,不過您必須承認現在更容易注意到 maxX 變量使用了兩次,不是嗎?

不過,請不要機械地參照我關於對齊代碼的建議,專門編寫一列列對齊的代碼。首先,編寫和編輯代碼都需要時間。其次,這可能會導致其它錯誤。下面的代碼示例取自 Miranda IM 程序,您會看到處處想着對齊內容也會引發錯誤。

V536 Be advised that the utilized constant value is represented by an octal form. Oct: 037, Dec: 31. msn msn_mime.cpp 192

  1. static const struct _tag_cpltbl   
  2. {   
  3.   unsigned cp;   
  4.   const char* mimecp;   
  5. } cptbl[] =   
  6. {   
  7.   {   037, "IBM037" },    // IBM EBCDIC US-Canada    
  8.   {   437, "IBM437" },    // OEM United States    
  9.   {   500, "IBM500" },    // IBM EBCDIC International    
  10.   {   708, "ASMO-708" },  // Arabic (ASMO 708)    
  11.   ...   
  12. }  
static const struct _tag_cpltbl
{
  unsigned cp;
  const char* mimecp;
} cptbl[] =
{
  {   037, "IBM037" },    // IBM EBCDIC US-Canada 
  {   437, "IBM437" },    // OEM United States 
  {   500, "IBM500" },    // IBM EBCDIC International 
  {   708, "ASMO-708" },  // Arabic (ASMO 708) 
  ...
}

嘗試整齊地按列對齊數據時,您可能很容易放鬆警惕,在原始數字前添加“0”,使得常數變爲八進制數字。

爲了避免不必要的誤會,我重新組織一下我的建議:對齊代碼中所有能夠對齊的內容,但不要通過添加 0 來對齊數字。

5. 請不要多次複製同一代碼行

編程時,複製代碼行是不可避免的。但是,您必須放棄一次性通過剪切板多次插入代碼行,以免出現不必要的錯誤。在大多數情況下,較好的處理辦法是,複製代碼行,對它進行編輯,然後再複製代碼行,再編輯,如此反覆。通過採用這種方式,您比較不容易忘記更改某個代碼行的特定內容或者不容易改錯。下面讓我們來看一個代碼示例:

V525 The code containing the collection of similar blocks. Check items '1316', '1319', '1318', '1323', '1323', '1317', '1321' in lines 954, 955, 956, 957, 958, 959, 960. clist_modern modern_clcopts.cpp 954

  1. static INT_PTR CALLBACK DlgProcTrayOpts(...)   
  2. {   
  3.   ...   
  4.   EnableWindow(GetDlgItem(hwndDlg,IDC_PRIMARYSTATUS),TRUE);   
  5.   EnableWindow(GetDlgItem(hwndDlg,IDC_CYCLETIMESPIN),FALSE);   
  6.   EnableWindow(GetDlgItem(hwndDlg,IDC_CYCLETIME),FALSE);       
  7.   EnableWindow(GetDlgItem(hwndDlg,IDC_ALWAYSPRIMARY),FALSE);   
  8.   EnableWindow(GetDlgItem(hwndDlg,IDC_ALWAYSPRIMARY),FALSE);   
  9.   EnableWindow(GetDlgItem(hwndDlg,IDC_CYCLE),FALSE);   
  10.   EnableWindow(GetDlgItem(hwndDlg,IDC_MULTITRAY),FALSE);   
  11.   ...   
  12. }  
static INT_PTR CALLBACK DlgProcTrayOpts(...)
{
  ...
  EnableWindow(GetDlgItem(hwndDlg,IDC_PRIMARYSTATUS),TRUE);
  EnableWindow(GetDlgItem(hwndDlg,IDC_CYCLETIMESPIN),FALSE);
  EnableWindow(GetDlgItem(hwndDlg,IDC_CYCLETIME),FALSE);    
  EnableWindow(GetDlgItem(hwndDlg,IDC_ALWAYSPRIMARY),FALSE);
  EnableWindow(GetDlgItem(hwndDlg,IDC_ALWAYSPRIMARY),FALSE);
  EnableWindow(GetDlgItem(hwndDlg,IDC_CYCLE),FALSE);
  EnableWindow(GetDlgItem(hwndDlg,IDC_MULTITRAY),FALSE);
  ...
}

最可能的情況是,這裏並不存在真正的錯誤,我們只是連續處理了兩次“IDC_ALWAYSPRIMARY”。然而,這樣複製粘貼代碼行段落很容易出錯。

6. 爲編譯器設置較高的警告等級並使用靜態分析器

對於許多錯誤,沒有適當的建議能夠幫助程序員有效地避免出錯,大都是新手和老牌程序員都會犯的輸入錯誤。

不過,很多這種錯誤在編程階段便能夠檢測出來。首先,可利用編譯器檢測錯誤,此外在夜間運行後,可以使用靜態代碼分析器進行分析並查看報告。

下文列出了幾個可能能夠利用靜態代碼分析器迅速檢測到的錯誤示例:

V560 A part of conditional expression is always true: 0x01000. tabsrmm tools.cpp 1023

  1. #define GC_UNICODE 0x01000   
  2.   
  3. DWORD dwFlags;   
  4.   
  5. UINT CreateGCMenu(...)   
  6. {   
  7.   ...   
  8.   if (iIndex == 1 && si->iType != GCW_SERVER &&   
  9.       !(si->dwFlags && GC_UNICODE)) {   
  10.   ...   
  11. }  
#define GC_UNICODE 0x01000

DWORD dwFlags;

UINT CreateGCMenu(...)
{
  ...
  if (iIndex == 1 && si->iType != GCW_SERVER &&
      !(si->dwFlags && GC_UNICODE)) {
  ...
}

代碼中存在一處輸入錯誤:應使用“&”運算符,但實際寫成了“&&”運算符。我也不知道寫代碼時如何纔能有效地避免這種錯誤。正確的代碼如下:

  1. (si->dwFlags & GC_UNICODE)  
(si->dwFlags & GC_UNICODE)

以下爲另一個示例。

V528 It is odd that pointer to 'char' type is compared with the '\0' value. Probably meant: *str != '\0'. clist_modern modern_skinbutton.cpp 282

V528 It is odd that pointer to 'char' type is compared with the '\0' value. Probably meant: *endstr != '\0'. clist_modern modern_skinbutton.cpp 283

  1. static char *_skipblank(char * str)   
  2. {   
  3.   char * endstr=str+strlen(str);   
  4.   while ((*str==' ' || *str=='\t') && str!='\0') str++;   
  5.   while ((*endstr==' ' || *endstr=='\t') &&   
  6.          endstr!='\0' && endstr<str)   
  7.     endstr--;   
  8.   ...   
  9. }  
static char *_skipblank(char * str)
{
  char * endstr=str+strlen(str);
  while ((*str==' ' || *str=='\t') && str!='\0') str++;
  while ((*endstr==' ' || *endstr=='\t') &&
         endstr!='\0' && endstr<str)
    endstr--;
  ...
}

在指針解參考運算中,程序員漏掉了兩個星號“*”。這可能導致嚴重的後果。這樣的代碼很容易出現非法訪問錯誤。正確的代碼如下:

  1. while ((*str==' ' || *str=='\t') && *str!='\0') str++;   
  2. while ((*endstr==' ' || *endstr=='\t') &&   
  3.        *endstr!='\0' && endstr<str)   
  4.   endstr--;  
while ((*str==' ' || *str=='\t') && *str!='\0') str++;
while ((*endstr==' ' || *endstr=='\t') &&
       *endstr!='\0' && endstr<str)
  endstr--;

對於這種情況,除了使用特殊的代碼檢查工具之外,我沒有辦法給出具體的建議。

以下爲另一個示例。

V514 Dividing sizeof a pointer 'sizeof (text)' by another value. There is a probability of logical error presence. clist_modern modern_cachefuncs.cpp 567

  1. #define SIZEOF(X) (sizeof(X)/sizeof(X[0]))   
  2.   
  3. int Cache_GetLineText(..., LPTSTR text, int text_size, ...)   
  4. {   
  5.   ...   
  6.   tmi.printDateTime(pdnce->hTimeZone, _T("t"), text, SIZEOF(text), 0);   
  7.   ...   
  8. }  
#define SIZEOF(X) (sizeof(X)/sizeof(X[0]))

int Cache_GetLineText(..., LPTSTR text, int text_size, ...)
{
  ...
  tmi.printDateTime(pdnce->hTimeZone, _T("t"), text, SIZEOF(text), 0);
  ...
}

第一眼看去,代碼的各個方面都沒有問題。文本和通過 SIZEOF 宏計算出的文本長度全部傳遞到函數中。實際上,宏的名稱應是 COUNT_OF,不過這並不重要。問題的關鍵在於,我們的目的是計算指針中的字符數量。但是,按照代碼,這裏計算的是“sizeof(LPTSTR) / sizeof(TCHAR)”。人爲檢查很難注意到這些片段,但編譯器和靜態分析器對這種錯誤很敏感。正確的代碼如下:

  1. tmi.printDateTime(pdnce->hTimeZone, _T("t"), text, text_size, 0);  
tmi.printDateTime(pdnce->hTimeZone, _T("t"), text, text_size, 0);

以下爲另一個示例。

V560 A part of conditional expression is always true: 0x29. icqoscar8 fam_03buddy.cpp 632

  1. void CIcqProto::handleUserOffline(BYTE *buf, WORD wLen)   
  2. {   
  3.   ...   
  4.   else if (wTLVType = 0x29 && wTLVLen == sizeof(DWORD))   
  5.   ...   
  6. }  
void CIcqProto::handleUserOffline(BYTE *buf, WORD wLen)
{
  ...
  else if (wTLVType = 0x29 && wTLVLen == sizeof(DWORD))
  ...
}

出現上述情形時,我建議您首先在條件中編寫一個常數。下方的代碼根本不能編譯:

  1. if (0x29 = wTLVType && sizeof(DWORD) == wTLVLen)  
if (0x29 = wTLVType && sizeof(DWORD) == wTLVLen)

但是,許多程序員,也包括我自己在內,並不喜歡這種方式。例如,我會感覺很困惑,因爲我首先希望知道比較的是什麼變量,然後我纔想知道將它與什麼進行比較。

如果程序員不喜歡這種比較方式,他可以依賴編譯器/分析器,或者乾脆冒點風險。

另外,雖然大多數程序員都知道這種錯誤,但出現這種錯誤的情況仍然不少。下面是來自 Miranda IM 程序的另外三個示例,PVS-Studio 分析器生成了 V559 警告:

  1. else if (ft->ft_magic = FT_MAGIC_OSCAR)   
  2. if (ret=0) {return (0);}   
  3. if (Drawing->type=CLCIT_CONTACT)  
else if (ft->ft_magic = FT_MAGIC_OSCAR)
if (ret=0) {return (0);}
if (Drawing->type=CLCIT_CONTACT)

代碼分析器即使不能檢測到錯誤,也能夠幫助您識別代碼中非常可疑的地方。例如,在 Miranda IM 中,指針的作用不僅僅是指針。在某些地方,這種處理方法沒有問題,但在其它地方,這樣做的風險很大。下面的代碼示例對我敲響了警鐘:

V542 Consider inspecting an odd type cast: 'char *' to 'char'. clist_modern modern_toolbar.cpp 586

  1. static void  
  2. sttRegisterToolBarButton(..., char * pszButtonName, ...)   
  3. {   
  4.   ...   
  5.   if ((BYTE)pszButtonName)   
  6.     tbb.tbbFlags=TBBF_FLEXSIZESEPARATOR;   
  7.   else  
  8.     tbb.tbbFlags=TBBF_ISSEPARATOR;   
  9.   ...   
  10. }  
static void
sttRegisterToolBarButton(..., char * pszButtonName, ...)
{
  ...
  if ((BYTE)pszButtonName)
    tbb.tbbFlags=TBBF_FLEXSIZESEPARATOR;
  else
    tbb.tbbFlags=TBBF_ISSEPARATOR;
  ...
}

事實上,我們只是想檢查字符串的地址是不是與 256 不符,但我不是很明白開發人員在條件中究竟想寫些什麼。或許這個片段是正確的,但我很懷疑這一點。

通過進行代碼分析,您可能會發現大量不正確的條件。例如:

V501 There are identical sub-expressions 'user->statusMessage' to the left and to the right of the '&&' operator. jabber jabber_chat.cpp 214

  1. void CJabberProto::GcLogShowInformation(...)   
  2. {   
  3.   ...   
  4.   if (user->statusMessage && user->statusMessage)   
  5.   ...   
  6. }  
void CJabberProto::GcLogShowInformation(...)
{
  ...
  if (user->statusMessage && user->statusMessage)
  ...
}

諸如此類情況還有很多,我可以列出大量其它示例。而且,這沒有原因。我想強調的是,您可以通過進行靜態分析在編碼初期檢測出很多錯誤。

如果靜態分析器在您的程序中只能找到很少的錯誤,或許您會認爲沒有必要使用它,這種想法是錯誤的。不妨這樣考慮,您最後可能需要付出大量的精力,花費好幾個小時的時間調試和更正錯誤,而分析器在編碼早期便能夠發現這些錯誤。

與一次性檢查工具不同,靜態分析在軟件開發領域能夠發揮很大的作用。在測試和單元測試開發過程中,通常會檢測到大量錯誤和錯字。但是,如果您能夠在編碼階段便發現錯誤,就能夠節省大量的時間和精力。想象一下,您調試了兩個小時的程序,只爲找到“for”運算符後面多餘的分號“;”,豈不是太可惜了。通常情況下,如果您能夠花 10 分鐘的時間對在開發過程中發生變更的文件進行靜態分析,便可以避免這種錯誤。

總結

在這篇文章中,我只提出了部分儘量減少 C++ 編程錯誤的建議,還有許多其它想法仍在構思階段。我會嘗試在以後的文章和博客中陸續與大家分享我的觀點。

附言

現在,讀者們閱讀此類文章後都會詢問我們是否已經將發現的錯誤告知應用/庫開發人員,這似乎已經成了一項傳統。在這裏,我便提前回答大家我們有沒有將 bug 報告發送給 Miranda IM 開發人員。

答案是沒有。因爲,這項任務所需要的資源太密集了,我們只列出了一小部分在項目中發現的問題。項目有大約 100 個代碼片段是我沒有辦法確定是否存在錯誤的。不過,我們已經將本文發送給 Miranda IM 寫手,併爲他們提供了 PVS-Studio 分析器的免費版本。如果他們對這個課題感興趣,他們會自己檢查源代碼,修復他們認爲有必要更正的問題。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章