魚和熊掌兼得:C++代碼在編譯時完成白盒測試


摘要:如果能夠讓代碼在編譯的時候,自動完成白盒測試,這不是天方夜譚。

白盒測試也叫開發者測試,是對特定代碼函數或模塊所進行的功能測試。當前主流的白盒測試方法是:先針對仿真或者生產環境編譯出可執行文件,然后運行得到測試結果。這種方法有3個問題:

  1. 可能需要專門針對白盒測試額外做一次構建。這是因為仿真環境和實際運行環境可能是不同的硬件平台,而且白盒測試需要額外鏈接一些庫(比如GTest),構建方式和發布版本不一樣。這一方面讓構建需要加入額外動作,另一方面也不容易保證兩套構建工程的一致性,難以確保開發人員每次發布軟件前都通過了白盒測試。
  2. 為了運行白盒測試,必須要搭建運行環境。有些執行機環境資源不太容易獲得(比如嵌入式單板),這就給開發人員隨時隨地開展測試帶來了障礙。
  3. 當代碼發生修改時,需要人為判斷執行哪一部分白盒測試用例。當依賴關系復雜時,這種影響關系分析並不容易。

如果能夠讓代碼在編譯的時候,自動完成白盒測試,則上面3個問題將都不存在。當測試用例沒有通過時,我們希望編譯失敗。這看起來像是天方夜譚,但隨着C++語言的編譯期計算功能越來越成熟,對於相當一部分代碼來說它已不再是幻想。

一個簡單的例子

C++11開始提供了強大的編譯期計算工具:constexpr。在后續的C++14/17/20等版本中,constexpr的功能被不斷的擴展,被稱為“病毒式擴張”的C++特性[1]。這里先看一個獲取字符串長度的constexpr函數(本文中代碼都在C++17環境下編譯運行):

template<typename T, auto E = '\0'>
constexpr size_t StrLen(const T& str) noexcept
{
    size_t i = 0;
    while (str[i] != E) {
        ++i;
    }
    return i;
}

這個函數和C庫函數strlen的主要區別有兩點:一是它泛化了char類型為模板參數;二是它可以在編譯期計算。要注意的是,constexpr函數也可以在運行期作為正常函數調用。

想要測試StrLen,最直接的辦法是用constexpr常量和static_assert:

constexpr const char* g_str = "abc";
static_assert(StrLen(g_str) == 3);

這樣當然行得通,但是這會污染全局名字空間,而且如果函數功能是對入參做修改(不要驚訝,constexpr函數真的可以修改入參,而且是在編譯期),傳入constexpr類型的入參是行不通的。所以好一點的做法是寫成測試函數:

constexpr bool TestStrLen() noexcept
{
    char testStr[] = "abc";  // 並不需要為constexpr
    assert(StrLen(testStr) == 3);  // 不能用static_assert
    testStr[2] = '\0';
    assert(StrLen(testStr) == 2);
    return true;
}

// 為了強制TestStrLen在編譯期執行,必須有這行
constexpr bool DUMB = TestStrLen();

注意在測試代碼中,不需要傳給被測函數constexpr入參,只要整個過程可以在編譯期計算就行了。因此TestStrLen里面可以修改局部變量並檢查結果。另外由於StrLen返回的結果並不是constexpr常量,因此檢查輸出時也不能用static_assertC++17保證了當assert中的條件為true時,它可以在編譯期執行[2],所以assert調用不會影響編譯期計算。

編譯期測試的好處

除了本文開頭所說的3個問題外,編譯期測試還有其他的好處。比如,我們修改一下剛才的測試代碼:

constexpr bool TestStrLen() noexcept
{
    char testStr[] = {'a', 'b', 'c'};  // 少了結束符
    assert(StrLen(testStr) == 3);  // 內部數組越界
    return true;
}

constexpr bool DUMB = TestStrLen();

這段代碼編譯時,會產生以下錯誤:

D:\Work\Source_Codes\MyProgram\VSCode\main.cpp:45:33:   in 'constexpr' expansion of 'TestStrLen()'
D:\Work\Source_Codes\MyProgram\VSCode\main.cpp:41:5:   in 'constexpr' expansion of 'StrLen<char [3]>(((const char (&)[3])(& testStr)))'
D:\Work\Source_Codes\MyProgram\VSCode\main.cpp:45:34: error: array subscript value '3' is outside the bounds of array type 'char [3]'
 constexpr bool DUMB = TestStrLen();
                                  ^

可以看到,如果白盒測試觸發了數組越界,將會使編譯報錯。我們再來嘗試一個空指針:

constexpr bool TestStrLen() noexcept
{
    char* testStr = nullptr;
    assert(StrLen(testStr) == 0);
    return true;
}

constexpr bool DUMB = TestStrLen();

這時編譯器會報錯:

D:\Work\Source_Codes\MyProgram\VSCode\main.cpp:45:33:   in 'constexpr' expansion of 'TestStrLen()'
D:\Work\Source_Codes\MyProgram\VSCode\main.cpp:41:5:   in 'constexpr' expansion of 'StrLen<char*>(((char* const&)(& testStr)))'
D:\Work\Source_Codes\MyProgram\VSCode\main.cpp:45:34: error: dereferencing a null pointer
 constexpr bool DUMB = TestStrLen();
                                  ^

可以看到,編譯期測試能有效的發現數組越界、空指針等問題。這是因為編譯期計算並沒有將代碼翻譯成機器指令運行,而是由編譯器根據C++標准推導表達式結果。任何的未定義行為都會導致編譯錯誤。

如果使用通常的測試方法,則需要使用一些編譯手段或者消毒器等技術來探測這些未定義行為,還不一定能保證探測到。而且相關問題定位起來也會困難得多。

需要注意的是,編譯期測試並不是形式化驗證,測試通過並不表示未定義行為一定不存在。只有用例設計的輸入組合能夠觸發未定義行為時,才會產生編譯錯誤。

編譯期測試框架

上面的測試代碼有個易用性問題:當assert失敗導致測試不通過時,錯誤信息不太友好:

In file included from D:/mingw64/lib/gcc/x86_64-w64-mingw32/8.1.0/include/c++/cassert:44,
                 from D:\Work\Source_Codes\MyProgram\VSCode\main.cpp:19:
D:\Work\Source_Codes\MyProgram\VSCode\main.cpp:45:33:   in 'constexpr' expansion of 'TestStrLen()'
D:\Work\Source_Codes\MyProgram\VSCode\main.cpp:41:5: error: call to non-'constexpr' function 'void _assert(const char*, const char*, unsigned int)'
     assert(StrLen(testStr) == 2);
     ^~~~~~

這個錯誤信息能看得人一頭霧水。其原因是assert在條件為false時,將變身為非constexpr函數,導致編譯器認為不滿足constexpr求值條件。

這當然不是我們想要的。我們希望測試失敗時要提示具體的用例,最好能具體到哪一行校驗失敗。

想要達成這個效果,需要一些技巧。一般的C++編譯器會在類模板的錯誤信息中打印出模板參數。利用這個特點,我們可以把測試失敗的行號作為類模板參數,並強制該模板實例化。

#define ASSERT_RETURN(exp) \
    if (!(exp)) { \
        return __LINE__; \
    }

constexpr uint32_t TestStrLen() noexcept
{
    const char* testStr = "abc";
    ASSERT_RETURN(StrLen(testStr) == 2);  // 失敗時返回行號
    return 0;
}

template<std::uint32_t L>
class TestFailedAtLine {
    static_assert(L == 0);
};

// 模板顯式實例化,強制運行測試用例函數
template class TestFailedAtLine<TestStrLen()>;
當ASSERT_RETURN校驗失敗時,編譯提示信息會是這樣:

D:\Work\Source_Codes\MyProgram\VSCode\main.cpp: In instantiation of 'class TestFailedAtLine<46>':
D:\Work\Source_Codes\MyProgram\VSCode\main.cpp:56:16:   required from here
D:\Work\Source_Codes\MyProgram\VSCode\main.cpp:52:21: error: static assertion failed
     static_assert(L == 0);
                   ~~^~~~

這里TestFailedAtLine<46>告訴了我們第46行的ASSERT_RETURN失敗了。這樣定位問題就方便多了。

但如果測試用例有很多個,希望分多個函數寫,還是有些麻煩——因為必須給每個函數配一個模板類(TestFailedAtLine)。如果加用例的時候忘記了寫這個模板類,就會導致用例不會被執行。

一個易用的框架,應該盡可能做到讓用戶添加功能時只改一個地方。想要做到這點並不容易,因為constexpr函數必須要完整定義以后才能被調用。但是利用lambda可以達到效果,其原理是:設計一個函數接受多個lambda對象,並且依次執行這些lambda對象。每一個lambda對象都作為一個測試用例。

// 僅用於中止遞歸
constexpr uint32_t TestExcute() noexcept
{
    return 0;
}

// 執行用例的函數,每一個參數都是待執行的測試用例
template<typename T, typename... F>
constexpr uint32_t TestExcute(T func, F... funcs) noexcept
{
    auto ret = func();
    if (ret != 0) {
        return ret;
    }
    return TestExcute(funcs...);
}

#define ASSERT_RETURN(exp) \
    if (!(exp)) { \
        return __LINE__; \
    }

// 上面的代碼可以放到公共頭文件中,被測試用例cpp文件包含

// 下面的代碼可放到測試cpp文件中,在鏈接時可以跳過該cpp
// 測試用例集,每個用例都是一個lambda對象
constexpr std::uint32_t FAILED_LINE = TestExcute(

    // 常規測試
    []() -> std::uint32_t {
        const char* testStr = "abc";
        ASSERT_RETURN(StrLen(testStr) == 3);
        return 0;
    },

    // 邊界測試,輸入空字符串
    []() -> std::uint32_t {
        ASSERT_RETURN(StrLen("") == 0);
        return 0;
    },

    // 擴展測試,元素為uint16_t類型,以0xFFFF結束
    []() -> std::uint32_t {
        array<uint16_t, 4> a{10, 20, 30, 0xFFFF};
        ASSERT_RETURN((StrLen<decltype(a), 0xFFFF>(a) == 3));
        return 0;
    }

    // 還可以加入更多測試用例……
);

template<std::uint32_t L>
class TestFailedAtLine {
    static_assert(L == 0);
};

// 模板顯式實例化,強制運行測試用例函數
template class TestFailedAtLine<FAILED_LINE>;

在這個測試框架中,想添加或者刪除測試用例,只要在TestExcute函數調用里增刪lambda函數就可以了,其他的地方都不用改。每個新增的測試用例(lambda對象)都會確保被執行到。

測試框架利用了lambda對象的兩個特性:構造函數和operator()成員函數可以隱式的作為constexpr函數。前者確保lambda對象可以作為constexpr入參傳給TestExcute,后者確保編譯期可以調用lambda對象。這兩個特性需要C++17才能完整支持。

如注釋所述,TestExcute和其后的代碼可以單獨放到一個cpp文件中,並且不參與鏈接。但是該文件編譯失敗時,仍然會中止構建過程,達到測試防護效果。其實即使把所有代碼都放到發布版本軟件里去也沒有問題,TestFailedAtLine類型定義不會占用二進制空間,而constexpr的函數和常量因為沒有被使用也會被編譯器優化掉。

我們的測試框架看起來有模有樣了,下面來看一個更復雜些的例子。

更復雜的例子——切割字符串

下面的代碼以空格為分隔符來切割傳入的字符串,每次可獲取一個單詞。很多人喜歡把這種功能設計為傳入string並返回vector<string>,但這在C++中是非常低效的做法。本文的代碼使用string_view,不僅不會產生拷貝字符串和內存分配開銷,還讓代碼功能可以在編譯期進行測試。

class Splitter {
public:
    explicit constexpr Splitter(string_view whole) noexcept : whole(whole) {}

    constexpr string_view NextWord() noexcept
    {
        if (wordEnd == string_view::npos) {
            return "";
        }
        wordBegin = whole.find_first_not_of(' ', wordEnd);
        if (wordBegin == string_view::npos) {
            return "";
        }
        wordEnd = whole.find(' ', wordBegin);
        if (wordEnd == string_view::npos) {
            return whole.substr(wordBegin);
        }
        return whole.substr(wordBegin, wordEnd - wordBegin);
    }

private:
    string_view whole;
    size_t wordBegin{0};
    size_t wordEnd{0};
};

需要說明的是,string_view的拷貝代價很小(內部只保存指針),因此作為函數參數時沒有必要傳引用。另外string_view所代表的字符串不可被修改,因此也沒有必要加const。此外還要注意string_view的結尾並不一定有'\0'結束符,因此它可以用於指向字符串中間的某一段內容,但是切勿將data()返回的指針當做C字符串使用。

對代碼寫編譯期測試用例如下:

// 下面的代碼可放到測試cpp文件中,在鏈接時可以跳過該cpp
// 測試用例集,每個用例都是一個lambda對象
constexpr std::uint32_t FAILED_LINE = TestExcute(

    // 邊界條件,空字符串
    []() -> std::uint32_t {
        Splitter words("");
        ASSERT_RETURN(words.NextWord() == ""sv);
        return 0;
    },

    // 邊界條件,只有空格
    []() -> std::uint32_t {
        Splitter words(" ");
        ASSERT_RETURN(words.NextWord() == ""sv);
        return 0;
    },

    // 只有一個單詞
    []() -> std::uint32_t {
        Splitter words("abc");
        ASSERT_RETURN(words.NextWord() == "abc"sv);
        ASSERT_RETURN(words.NextWord() == ""sv);
        return 0;
    },

    // 多個單詞,單空格分割
    []() -> std::uint32_t {
        Splitter words("C++ compile time computation");
        ASSERT_RETURN(words.NextWord() == "C++"sv);
        ASSERT_RETURN(words.NextWord() == "compile"sv);
        ASSERT_RETURN(words.NextWord() == "time"sv);
        ASSERT_RETURN(words.NextWord() == "computation"sv);
        ASSERT_RETURN(words.NextWord() == ""sv);
        return 0;
    },

    // 多個單詞,含多個連續空格,且首尾有空格
    []() -> std::uint32_t {
        Splitter words(" 0    598  3426    ");
        ASSERT_RETURN(words.NextWord() == "0"sv);
        ASSERT_RETURN(words.NextWord() == "598"sv);
        ASSERT_RETURN(words.NextWord() == "3426"sv);
        ASSERT_RETURN(words.NextWord() == ""sv);
        return 0;
    }
);

可以看到,編譯期的測試用例可以覆蓋相當全面的場景,對於代碼質量保障有很大的好處。

如果后續Splitter類的代碼(或者其依賴的下層代碼)修改了,在增量編譯時,編譯期會自動識別是否需要重新“測試”,確保不會放過修改引入的錯誤。

編譯期測試的當前限制和應用前景

編譯期測試的限制就是C++編譯期計算的限制,主要為只能對constexpr接口進行測試。在C++17中,仍然有很多庫函數不支持constexpr,如大多數泛型算法、需要動態分配內存的所有容器(如std::vector、std::string)等等。這導致當前編譯期計算只能用於很小部分的底層函數。

但是,隨着C++后續版本的到來,編譯期計算的允許范圍會越來越大。剛剛發布的C++20版本已經將大多數的泛型算法改為了constexpr函數,並且還允許operator new、虛函數、std::vector和std::string在編譯期計算[3],這會使得相當大一部分的軟件模塊以后能夠在編譯期進行測試。

說不定,未來C++代碼的測試方法會因此發生革命。

尾注

[1] 稱為“病毒式擴張”是因為constexpr函數要求其調用其他的函數也都是constexpr函數。因此當越來越多的底層函數定義為constexpr時,上層函數也越來越多的被標記為constexpr。這個過程在標准庫的代碼中正在快速的進行。

[2] https://en.cppreference.com/w/cpp/error/assert

[3] 在這個頁面中可以看到當前各編譯器對C++20的支持進展。GCC的最新版本已經能支持虛函數、泛型算法在編譯期的計算了。可惜的是目前還沒有編譯器支持std::vector和std::string的編譯期計算。

本文分享自華為雲社區《讓C++代碼在編譯時完成白盒測試》,原文作者:飛得樂 。

 

點擊關注,第一時間了解華為雲新鮮技術~


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM