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

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

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

  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類型的入參是行不通的。因此好一點的作法是寫成測試函數:ide

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_assert。C++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();

這時編譯器會報錯:ui

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::vectorstd::string)等等。這致使當前編譯期計算只能用於很小部分的底層函數。

可是,隨着C++後續版本的到來,編譯期計算的容許範圍會愈來愈大。剛剛發佈的C++20版本已經將大多數的泛型算法改成了constexpr函數,而且還容許operator new、虛函數、std::vectorstd::string在編譯期計算[3],這會使得至關大一部分的軟件模塊之後可以在編譯期進行測試。

說不定,將來C++代碼的測試方法會所以發生革命。

尾註

[1] 稱爲「病毒式擴張」是由於constexpr函數要求其調用其餘的函數也都是constexpr函數。所以當愈來愈多的底層函數定義爲constexpr時,上層函數也愈來愈多的被標記爲constexpr。這個過程在標準庫的代碼中正在快速的進行。

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

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

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

 

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

相關文章
相關標籤/搜索