程式碼安全性和健壯性:如何在if和assert中做選擇?

sewain發表於2021-02-27

道哥的第 023 篇原創

一、前言

我們在擼程式碼的時候,經常需要對程式碼的安全性進行檢查,例如:

  1. 指標是否為空?
  2. 被除數是否為 0?
  3. 函式呼叫的返回結果是否有效?
  4. 開啟一個檔案是否成功?

對這一類的邊界條件進行檢查的手段,一般都是使用 if 或者 assert 斷言,無論使用哪一個,都可以達到檢查的目的。那麼是否就意味著:這兩者可以隨便使用,想起來哪個就用哪個?

這篇小短文我們就來掰扯掰扯:在不同的場景下,到底是應該用 if,還是應該使用 assert 斷言?

寫這篇文章的時候,我想起了孔乙己老先生的那個問題:茴香豆的“茴”字有幾種寫法?

似乎我們沒有必要來糾結應該怎麼選擇,因為都能夠實現想要的功能。以前我也是這麼想的,但是,現在我不這麼認為

成為技術大牛、拿到更好的offer,也許就在這些細微之間就分出了勝負。

二、assert 斷言

剛才,我問了下旁邊的一位工作 5 年多的嵌入式開發者:if 和 assert 如何選擇?他說:assert 是幹什麼的?!

看來,有必要先簡單說一下 assert 斷言。

assert() 的原型是:

void assert(int expression);

  1. 如果巨集的引數求值結果為非零值,則不做任何操作(no action);
  2. 如果巨集的引數是零值,就列印診斷訊息,然後呼叫abort()。

例如下面的程式碼:

#include <assert.h>
int my_div(int a, int b)
{
    assert(0 != b);
    return a / b;
}
  1. 當 b 不為 0 時,assert 斷言什麼都不做,程式往下執行;
  2. 當 b 為 0 時,assert 斷言就列印錯誤資訊,然後終止程式;

從功能上來說,assert(0 != b); 與下面的程式碼等價:

if (0 == b)
{
    fprintf(stderr, "b is zero...");
    abort();
}

assert 是一個巨集,不是一個函式

在 assert.h 標頭檔案中,有如下定義:

#ifdef NDEBUG
    #define assert(condition) ((void)0)
#else
    #define assert(condition) /*implementation defined*/
#endif

既然是巨集定義,說明是在預處理的時候進行巨集替換。(關於巨集的更多內容,可以看一下這篇文章:提高程式碼逼格的利器:巨集定義-從入門到放棄)。

從上面的定義中可以看到:

  1. 如果定義了巨集 NDEBUG,那麼 assert() 巨集將不做什麼動作,也就是相當於一條空語句: (void)0;,當在 release 階段編譯程式碼的時候,都會在編譯選項中(Makefile)定義這個巨集。
  2. 如果沒有定義巨集 NDEBUG,那麼 assert() 巨集將會把一些檢查程式碼進行替換,我們在開發階段執行 debug 模式編譯時,一般都會遮蔽掉這 NDEBUG 這個巨集。

三、if VS assert

還是以一個程式碼片段來描述問題,以場景化來討論比較容易理解。

// brief: 把兩個短字串拼接成一個字串
char *my_concat(char *str1, char *str2)
{
    int len1 = strlen(str1);
    int len2 = strlen(str2);
    int len3 = len1 + len2;
    char *new_str = (char *)malloc(len3 + 1);
    memset(new_str, 0 len3 + 1);
    sprintf(new_str, "%s%s", str1, str2);
    return new_str;
}

如果一個開發人員寫出上面的程式碼,一定會被領導約談的!它存在下面這些問題:

  1. 沒有對輸入引數進行有效性檢查;
  2. 沒有對 malloc 的結果進行檢查;
  3. sprintf 的效率很低;
  4. ...

1. 使用 if 語句來檢查

char *my_concat(char *str1, char *str2)
{
    if (!str1 || !str2)  // 引數錯誤
        return NULL;
        
    int len1 = strlen(str1);
    int len2 = strlen(str2);
    int len3 = len1 + len2;
    char *new_str = (char *)malloc(len3 + 1);
    
    if (!new_str)   // 申請堆空間失敗
        return NULL;
    
    memset(new_str, 0 len3 + 1);
    sprintf(new_str, "%s%s", str1, str2);
    return new_str;
}

2. 使用 assert 斷言來檢查

char *my_concat(char *str1, char *str2)
{
    // 確保引數正確
    assert(NULL != str1);
    assert(NULL != str2);
    
    int len1 = strlen(str1);
    int len2 = strlen(str2);
    int len3 = len1 + len2;
    char *new_str = (char *)malloc(len3 + 1);
    
    // 確保申請堆空間成功
    assert(NULL != new_str);
    
    memset(new_str, 0 len3 + 1);
    sprintf(new_str, "%s%s", str1, str2);
    return new_str;
}

3. 你喜歡哪一個?

首先宣告一點:以上這 2 種檢查方式,在實際的程式碼中都很常見,從功能上來說似乎也沒有什麼影響。因此,沒有嚴格的錯與對之分,很多都是依賴於每個人的偏好習慣不同而已。

(1) assert 支持者

我作為 my_concat() 函式的實現者,目的是拼接字串,那麼傳入的引數必須是合法有效的,呼叫者需要負責這件事。如果傳入的引數無效,我會表示十分的驚訝!怎麼辦:崩潰給你看!

(2)if 支持者

我寫的 my_concat() 函式十分的健壯,我就預料到呼叫者會亂搞,故意的傳入一些無效引數,來測試我的編碼水平。沒事,來吧,我可以處理任何情況

這兩個派別的理由似乎都很充足!那究竟該如何選擇?難道真的的跟著感覺走嗎?

假設我們嚴格按照常規的流程去開發一個專案:

  1. 在開發階段,編譯選項中不定義 NDEBUG 這個巨集,那麼 assert 就發揮作用;
  2. 專案釋出時,編譯選項中定義了 NDEBUG 換個巨集,那麼 assert 就相當於空語句;

也就是說,只有在 debug 開發階段,用 assert 斷言才能夠正確的檢查到引數無效。而到了 release 階段,assert 不起作用,如果呼叫者傳遞了無效引數,那麼程式只有崩潰的命運了。

這說明什麼問題?是程式碼中存在 bug?還是程式碼寫的不夠健壯?

從我個人的理解上看,這壓根就是單元測試沒有寫好,沒有測出來引數無效的這個 case!

4. assert 的本質

assert 就是為了驗證有效性,它最大作用就是:在開發階段,讓我們的程式儘可能地 crash。每一次的 crash,都意味著程式碼中存在著 bug,需要我們去修正。

當我們寫下一個 assert 斷言的時候,就說明:斷言失敗的這種情況是不可以的,是不被允許的。必須保證斷言成功,程式才能繼續往下執行。

5. if-else 的本質

if-else 語句用於邏輯處理,它是為了處理各種可能出現的情況。就是說:每一個分支都是合理的,是允許出現的,我們都要對這些分支進行處理。

6. 我喜歡的版本

char *my_concat(char *str1, char *str2)
{
    // 引數必須有效
    assert(NULL != str1);
    assert(NULL != str2);
    
    int len1 = strlen(str1);
    int len2 = strlen(str2);
    int len3 = len1 + len2;
    char *new_str = (char *)malloc(len3 + 1);
    
    // 申請堆空間失敗的情況,是可能的,是允許出現的情況。
    if (!new_str)
        return NULL;
    
    memset(new_str, 0 len3 + 1);
    sprintf(new_str, "%s%s", str1, str2);
    return new_str;
}

對於引數而言:我認為傳入的引數必須是有效的,如果出現了無效引數,說明程式碼中存在 bug,不允許出現這樣的情況,必須解決掉。

對於資源分配結果(malloc 函式)而言:我認為資源分配失敗是合理的,是有可能的,是允許出現的,而且我也對這個情況進行了處理。

當然了,並不是說對引數檢查就要使用 assert,主要是根據不同的場景、語義來判斷。例如下面的這個例子:

int g_state;
void get_error_str(bool flag)
{
    if (TRUE == flag)
    {
        g_state = 1;
        assert(1 == g_state); // 確保賦值成功
    }
    else
    {
        g_state = 0;
        assert(0 == g_state); // 確保賦值成功
    }
}

flag 引數代表不同的分支情況,而賦值給 g_state 之後,必須保證賦值結果的正確性,因此使用 assert 斷言。

五、總結

這篇文章分析了 C 語言中比較晦澀、模糊的一個概念,似乎有點虛無縹緲,但是的確又需要我們停下來仔細考慮一下

如果有些場景,實在拿捏不好,我就會問自己一個問題

這種情況是否被允許出現?

不允許:就用 assert 斷言,在開發階段就儘量找出所有的錯誤情況;

允許:就用 if-else,說明這是一個合理的邏輯,需要進行下一步處理。


【原創宣告】

轉載:歡迎轉載,但未經作者同意,必須保留此段宣告,必須在文章中給出原文連線。


不吹噓,不炒作,不浮誇,認真寫好每一篇文章!

歡迎轉發、分享給身邊的技術朋友,道哥在此表示衷心的感謝! 轉發的推薦語已經幫您想好了:

道哥總結的這篇總結文章,寫得很用心,對我的技術提升很有幫助。好東西,要分享!



推薦閱讀

C語言指標-從底層原理到花式技巧,用圖文和程式碼幫你講解透徹
一步步分析-如何用C實現物件導向程式設計
我最喜歡的程式之間通訊方式-訊息匯流排
物聯網閘道器開發:基於MQTT訊息匯流排的設計過程(上)
提高程式碼逼格的利器:巨集定義-從入門到放棄
原來gdb的底層除錯原理這麼簡單
利用C語言中的setjmp和longjmp,來實現異常捕獲和協程
關於加密、證書的那些事
深入LUA指令碼語言,讓你徹底明白除錯原理

相關文章