作者:
2013年9月2日 (最後更新:2013年9月2日)

Grounded Pointers

得分:3.8/5 (62票)
*****

不久前,我們的一位同事離開了團隊,加入了一家開發嵌入式系統軟體的公司。這沒什麼特別的:在任何公司,人員來來往往都是常事。他們的選擇取決於提供的獎金、便利性以及個人偏好。我們感興趣的是另一件事。我們這位前同事對他現在工作中遇到的程式碼質量深感擔憂。這促使我們寫下了這篇聯合文章。您看,一旦您明白了靜態分析是怎麼回事,您就不想再滿足於“只是程式設計”了。

森林保護區

我發現當今世界出現了一種有趣的現象。當一個軟體開發部門變成一個與公司基本業務領域沒有緊密聯絡的次要實體時,會發生什麼?就會出現一個森林保護區。無論公司的業務領域多麼重要和關鍵(例如,醫療或軍事裝置),總會出現一片小沼澤,新想法在那裡停滯不前,而十年前的技術仍然在使用。

這裡是來自一位在核電站軟體開發部門工作的人的通訊摘錄

然後他說,“我們要 git 幹什麼?你看,我這裡都寫在我的筆記本上了。”

...

你們有版本控制嗎?

2 個人使用 git。其他團隊最多使用編號的 zip 檔案。雖然只有 1 個人使用 zip 檔案,但我對此是確定的。

別害怕。核電站開發的軟體可能有不同的用途,而且硬體安全還沒有被廢除。在那個特定的部門,人們收集和處理統計資料。然而,沼澤化的趨勢相當明顯。我不知道為什麼會發生這種情況,但事實是肯定的。有趣的是,公司越大,沼澤化效應越強烈。

我想指出,大公司的停滯是一個國際現象。國外情況也是如此。有一篇關於這個主題的文章,但我記不起它的標題了。我花了很多時間試圖找到它,但徒勞無功。如果有人知道,請給我連結,以便我釋出。在那篇文章中,一位程式設計師講述了他在某個軍事部門工作的經歷。那自然是極其秘密和官僚的——如此秘密和官僚,以至於他們花了幾個月時間才同意他可以獲得哪個級別的訪問許可權來操作他的電腦。結果,他用記事本寫程式(不編譯),很快就因效率低下而被解僱了。

森林管理員

現在讓我們回到我們的前同事。來到新辦公室後,他感到一種文化衝擊。你看,在花費了大量時間和精力學習和使用靜態分析工具後,看到人們甚至忽略編譯器警告,真是令人痛苦。這就像一個獨立的世界,他們按照自己的規則程式設計,甚至使用自己的術語。他告訴我一些關於它的故事,其中我最喜歡的是當地程式設計師普遍使用的短語“grounded pointers”(接地指標)。看看他們離硬體有多近!

我們很自豪在我們團隊中培養了一位技術嫻熟、關心程式碼質量和可靠性的專家。他沒有默默接受既定狀況,而是努力改進它。

作為開始,他做了以下工作。他研究了編譯器警告,然後使用 Cppcheck 檢查了專案,並考慮在進行一些修復的同時防止典型錯誤。

他的第一步是準備一篇旨在提高團隊程式碼質量的文章。將靜態程式碼分析器引入和整合到開發過程中可能是下一步。當然不會是 PVS-Studio:首先,它們在 Linux 下執行;其次,向此類公司銷售軟體產品非常困難。所以,他目前選擇了 Cppcheck。這個工具非常適合人們開始使用靜態分析方法。

我邀請您閱讀他準備的文章。它題為“你不應該這樣寫程式”。其中許多條目可能看起來像是“船長明顯”風格。然而,這些是該男子試圖解決的實際問題。

你不應該這樣寫程式

第一條

忽略編譯器警告。當警告列表很多時,您可能會輕易錯過最近程式碼中的真正錯誤。這就是為什麼您應該全部處理它們。

第二條

在“if”語句的條件判斷中,給變數賦值而不是測試該值。


if (numb_numbc[i] = -1) { }

在這種情況下,程式碼可以正常編譯,但編譯器會發出警告。正確的程式碼如下所示:


if (numb_numbc[i] == -1) { }

第三條

在標頭檔案中寫“using namespace std;”可能會導致包含該標頭檔案的所有檔案都使用此名稱空間,這反過來可能導致呼叫錯誤函式或發生名稱衝突。

第四條

比較有符號變數和無符號變數


1
2
3
4
unsigned int BufPos;
std::vector<int> ba;
....
if (BufPos * 2 < ba.size() - 1) { }

請記住,混合使用有符號和無符號變數可能導致

  • 溢位;
  • 出現永遠為真或永遠為假的條件,並因此導致無限迴圈;
  • 可能將大於 INT_MAX 的值寫入有符號變數(它將是負數);
  • 參與加/減/等運算的 int 變數也會變成無符號(因此負值會變成大的正值);
  • 其他意外的好東西

上述程式碼示例無法正確處理“ba”陣列為空的情況。“ba.size() - 1”表示式會計算為一個無符號的 size_t 值。如果陣列為空,表示式將計算為 0xFFFFFFFFu。

第五條

忽略使用常量可能導致難以消除的錯誤被忽視。例如:


1
2
3
4
5
6
void foo(std::string &str)
{
  if (str = "1234")
  {
  }
}

錯誤地使用了“=”運算子而不是“==”。如果“str”變數被宣告為常量,編譯器甚至不會編譯程式碼。

第六條

比較字串指標而不是字串本身。


1
2
3
сhar TypeValue [4];
...
if (TypeValue == "S") {}

即使字串“S”儲存在變數 TypeValue 中,比較也將始終返回“false”。比較字串的正確方法是使用特殊的“strcmp”或“strncmp”函式。

第七條

緩衝區溢位。


memset(prot.ID, 0, sizeof(prot.ID) + 1);

此程式碼可能會清除“prot.ID”之後的幾字節記憶體區域。

不要混淆 sizeof() 和 strlen()。sizeof() 運算子返回專案總位元組大小。strlen() 函式以字元為單位返回字串長度(不計算空終止符)。

第八條

緩衝區下溢。


1
2
3
4
5
6
7
struct myStruct
{
  float x, y, h;
};
myStruct *ptr;
 ....
memset(ptr, 0, sizeof(ptr));

在這種情況下,只有 N 位元組會被清除,而不是整個“*ptr”結構(N 是當前平臺上的指標大小)。正確的方法是使用以下程式碼:


1
2
3
myStruct *ptr;
 ....
memset(ptr, 0, sizeof(*ptr));

第九條

錯誤的表示式。


if (0 < L < 2 * M_PI) { }

編譯器在這裡沒有看到任何錯誤,但表示式沒有意義,因為執行它時您將始終得到“true”或“false”,具體結果取決於比較運算子和邊界條件。編譯器會為這樣的表示式生成警告。此程式碼的正確版本是:


if (0 < L && L < 2 * M_PI) { }

第十條


1
2
3
4
5
unsigned int K;
....
if (K < 0) { }
...
if (K == -1) { }

無符號變數不能小於零。

第十一條

將變數與它永遠無法達到的值進行比較。例如:


1
2
3
short s;
...
If (s==0xaaaa) { }

編譯器對此類情況發出警告。

第十二條

記憶體使用“new”或“malloc”分配,但忘記透過“delete”/“free”相應地釋放。它可能看起來像這樣:


1
2
3
4
5
6
7
void foo()
{
  std::vector<int> *v1 = new std::vector<int>;
  std::vector<int> v2;
  v2->push_back(*v1);
  ...
}

也許之前儲存到“v2”的是指向“std::vector<int>”的指標。現在,由於某些程式碼部分的修改,它不再需要了,並且只儲存了“int”值。同時,為“v1”分配的記憶體沒有被釋放,因為以前不需要。為了修復程式碼,我們應該在函式末尾新增“delete v1”語句,或者使用智慧指標。

甚至更好的是將重構進行到底,將“v1”變成區域性物件,因為您不再需要將其傳遞給任何地方。


1
2
3
4
5
6
7
void foo()
{
  std::vector<int> v1;
  std::vector<int> v2;
  v2->push_back(v1[0]);
  ...
}

第十三條

記憶體透過“new[]”分配,並透過“delete”釋放。或者反之,記憶體透過“new”分配,並透過“delete[]”釋放。

第十四條

使用未初始化的變數。


1
2
3
4
5
6
int sum;
...
for (int i = 0; i < 10; i++)
{
  sum++;
}

在 C/C++ 中,變數預設不會初始化為零。有時程式碼似乎執行良好,但事實並非如此——這僅僅是運氣。

第十五條

函式返回區域性物件的引用或指標。


1
2
3
4
5
6
char* CreateName()
{
  char FileName[100];
  ...
  return FileName;
}

離開函式時,“FileName”將指向一個已被釋放的記憶體區域,因為所有區域性物件都在棧上建立,因此無法進一步正確處理。

第十六條

未檢查函式返回值,而它們可能在出錯時返回錯誤程式碼或“-1”。可能會發生函式返回錯誤程式碼,但我們繼續工作而不注意或以任何方式對其做出反應,這將導致程式在某個時刻突然崩潰。此類缺陷需要花費很長時間進行除錯。

第十七條

忽略使用特殊的靜態和動態分析工具,以及建立和使用單元測試。

第十八條

在數學表示式中過於貪婪地新增括號,導致出現以下情況:


D = ns_vsk.bit.D_PN_ml + (int)(ns_vsk.bit.D_PN_st) << 16;

在這種情況下,首先執行加法,然後執行左移。請參閱“C/C++ 中的運算子優先順序”。根據程式邏輯,運算子的執行順序應該是相反的:先移位,然後加法。以下片段中也出現了類似的錯誤:


1
2
3
4
#define A 1
#define B 2
#define TYPE A | B
if (type & TYPE) { }

這裡的錯誤是:程式設計師忘記將 TYPE 宏括在括號中。這導致首先執行“type & A”表示式,然後才執行“(type & A ) | B”表示式。因此,條件始終為真。

第十九條

陣列索引越界。


1
2
3
4
5
int mas[3];
mas[0] = 1;
mas[1] = 2;
mas[2] = 3;
mas[3] = 4;

“mas[3] = 4;”表示式引用了一個不存在的陣列項,因為從“int mas[N]”陣列的宣告可以看出,其項的索引範圍是 [0...N-1]。

第二十條

邏輯運算子“&&”和“||”的優先順序被混淆。 “&&”運算子優先順序更高,所以在這種條件中:


if (A || B && C) { }

“B && C”將首先執行,而表示式的其餘部分將在之後執行。這可能不符合所需的執行邏輯。通常認為邏輯表示式是從左到右執行的。編譯器會為這種可疑的片段生成警告

第二十一條

分配的值在函式外部無效。


1
2
3
4
5
6
7
8
9
10
11
void foo(int *a, int b)
{
  If (b == 10)
  {
    *a = 10;
  }
  else
  {
    a = new int;
  }
}

指標“a”不能被賦予不同的地址值。要做到這一點,您需要這樣宣告函式:


void foo(int *&a, int b) {....}


void foo(int **a, int b) {....}

參考文獻

  1. “足夠多的繩子來射傷自己的腳。C 和 C++ 程式設計規則”。Allen I. Holub;
  2. “C++ 編碼標準:101 條規則、指南和最佳實踐”。Herb Sutter, Andrei Alexandrescu;
  3. “精通 C++”。Steve McConnel;
  4. “C++ 陷阱:避免編碼和設計中的常見問題”。Stephen C. Dewhurst;
  5. “Effective C++:提高程式和設計的 50 個具體方法”。Scott Meyers。

結論

我沒有得出任何具體和重大的結論。我只確定在一個特定地方,軟體開發的狀況開始改善。這令人高興。

另一方面,我感到悲傷的是,許多人甚至沒有聽說過靜態分析。而這些人通常負責嚴肅而重要的事務。程式設計領域發展非常快。結果,那些一直在“埋頭工作”的人未能跟上行業當代趨勢和工具。最終,他們的工作效率遠低於自由職業者以及從事初創公司和小型公司工作的程式設計師。

因此,我們得到了一個奇怪的局面。一位年輕的自由職業者可以做得更好(因為他有知識:TDD、持續整合、靜態分析、版本控制系統等等),而不是一位在俄羅斯鐵路/核電站/...(新增您自己的大型企業變體)工作了 10 年的程式設計師。謝天謝地,情況並非總是如此。但仍然會發生。

為什麼我為此感到悲傷?我希望我們能把 PVS-Studio 賣給他們。但他們甚至沒有絲毫懷疑這類工具的存在和有用性。:)