C++17,RAII與GSL支持庫

主要聊一下 C++17,順便記錄一下 RAII。說句真心話,只要 C++ 遵照最佳實踐來編碼,還是很省心的,就算它有很多醜陋的地方,你不用它,不看它不就完了。

C++17的一些特性

在if/switch中初始化變量

#include <iostream>

int main(void)
{
    if (auto i = 42; i > 0) {
        std::cout << "Hello World\n";
    }
}

可以保證變量是有效的。

#include <iostream>

int main(void)
{
    switch(auto i = 42) {
        case 42:
            std::cout << "Hello World\n";
            break;
        default:
            break;
    }
}

編譯時優化

下面代碼運行時會優化(移除)if 分支。

#include <iostream>

constexpr const auto val = true;

int main(void)
{
    if (val) {
        std::cout << "Hello World\n";
    }
}

constexpr 在 C++11 時就引入了,但是很多程序員都假定會優化分支,其實並沒有。
C++17 還引入了 constexpr if ,如果編譯時不能優化 if 將會報錯。

#include <iostream>


int main(void)
{
    if constexpr (constexpr const auto i = 42; i > 0) {
        std::cout << "Hello World\n";
    }
}

C++11 中就有這樣的斷言,

#include <iostream>


int main(void)
{
    static_assert(42 == 42, "the answer");
}

C++17 引入了一個新的形式,可以直接這樣使用:

#include <iostream>


int main(void)
{
    static_assert(42 == 42);
}

命名空間

C++17 添加了嵌套命名空間,不再需要換行。在C++17之前,嵌套命名空間需要這樣寫:

#include <iostream>

namespace X
{
    namespace Y
    {
        namespace Z
        {
            auto msg = "Hello World\n";
        }
    }
}

int main(void)
{
    std::cout << X::Y::Z::msg;
}

C++17可以把它寫到同一行中:

#include <iostream>

namespace X::Y::Z
{
    auto msg = "Hello World\n";
}

int main(void)
{
    std::cout << X::Y::Z::msg;
}

結構化綁定

這是很多人最喜歡的 C++17 的新特性。在 C++17 以前,複雜的結構,比如結構體或者 std::pair,可以用於返回值不止一個的函數。但它的語法非常笨重。就像這樣:

#include <utility>
#include <iostream>

std::pair<const char*, int>
give_me_a_pair()
{
    return {"The answer is:", 42};
}

int main(void)
{
    auto p = give_me_a_pair();
    std::cout << std::get<0>(p) << std::get<1>(p) << '\n';
}

在 C++17,結構化綁定(structed bindings)提供了一種將 struct 或者 std::pair 解析獨立字段的方法。

#include <utility>
#include <iostream>

std::pair<const char*, int>
give_me_a_pair()
{
    return {"The answer is:", 42};
}

int main(void)
{
    auto [msg, answer] = give_me_a_pair();
    std::cout << msg << answer << '\n';
}

同樣,可以用於 struct。

#include <iostream>

struct mystruct
{
    const char *msg;
    int answer;
};

mystruct
give_me_a_struct()
{
    return {"The answer is:", 42};
}

int main(void)
{
    auto [msg, answer] = give_me_a_struct();
    std::cout << msg << answer << '\n';
}

內聯變量

C++17 一個比較有爭議的添加就是內聯變量。因爲現在有很多庫只有頭文件,也就是把實現也寫在了頭文件中。可這也意味着會引入全局變量,那麼就可能與正在編寫的代碼衝突。

內聯變量移除了這個問題,就像這樣:

#include <iostream>

inline auto msg = "Hello World\n";

int main(void)
{
    std::cout << msg;
}

庫的變化

string view

C++17 添加了一個 std::string_view{} ,它封裝了字符序列,類似 std::array,可以更加簡單和安全地使用 C 字符串。

#include <iostream>
#include <string_view>

int main(void)
{
    std::string_view str("Hello World\n");
    std::cout << str;
}

std::arraystd::string_view{} 提供了基於 array 的獲取器,就像這樣:

#include <iostream>
#include <string_view>

int main(void)
{
    std::string_view str("Hello World");

    std::cout << str.front() << '\n';
    std::cout << str.back() << '\n';
    std::cout << str.at(1) << '\n';
    std::cout << str.data() << '\n';
}

frontlast 用來返回字符串的第一個和最後一個字符。at() 可以返回在字符串中任意的字符。如果下標超出字符串的長度,則會拋出 std:out_of_range() 異常。data() 直接返回基於 array 的字符串。用這個函數要小心一點,畢竟它是不安全的。

std::string_view{} 也提供了關於字符串大小的信息:

#include <iostream>
#include <string_view>

int main(void)
{
    std::string_view str("Hello World\n");

    std::cout << str.size() << '\n';
    std::cout << str.max_size() << '\n';
    std::cout << str.empty() << '\n';
}

string_view 還可以通過移除前後的字符來減少字符串視圖(view)的大小:

#include <iostream>
#include <string_view>

int main(void)
{
    std::string_view str("Hello World");

    str.remove_prefix(1);
    str.remove_suffix(1);
    std::cout << str << '\n';
}
// ello Worl 

注意,它並沒有重新分配內存,只是改動了指針。如果需要重新分配內存的(當然,要付出一定的性能代價),則應該使用 std::string{}

也可以用來返回子字符串,就像這樣:

#include <iostream>
#include <string_view>

int main(void)
{
    std::string_view str("Hello World");

    std::cout << str.substr(0, 5) << '\n';
}

還可以比較字符串,類似 strcmp 函數,返回 0 則代表兩個字符串相同。

#include <iostream>
#include <string_view>

int main(void)
{
    std::string_view str("Hello World");

    if (str.compare("Hello World") == 0) {
        std::cout << "Hello World\n";
    }
    std::cout << str.compare("Hello") << '\n';
    std::cout << str.compare("World") << '\n';
}

最後,查找 find 系列函數是這樣的:

#include <iostream>
#include <string_view>

int main(void)
{
    std::string_view str("Hello this is a test of Hello World");

    std::cout << str.find("Hello") << '\n';
    std::cout << str.rfind("Hello") << '\n';
    std::cout << str.find_first_of("Hello") << '\n';
    std::cout << str.find_last_of("Hello") << '\n';
    std::cout << str.find_first_not_of("Hello") << '\n';
    std::cout << str.find_last_not_of("Hello") << '\n';
}
// 0
// 24
// 0
// 33
// 5
// 34
  • find 函數返回第一次出現 Hello 這個字符串的位置,這裏是0。
  • rfind 返回最後一次出現給定的字符串的位置。
  • find_first_of()find_last_of() 查找字符的首次出現和查找字符的最後一次出現的位置。在這個例子中,H 在字符串中,同時它是 msg 的開頭,所以返回 0。o作爲查找字符串的最後一個字符,最後一次是在 msg 的最後一個單詞 World 中。
  • find_first_not_of()find_last_not_of() 和前面的功能相反。

std::any, std::variant, std::optional

std:any{} 能夠存儲任意值,獲取的時候要將 std:any{} 恢復成對應的數據類型,但它能保存類型安全。any{} 內部定義有一個指針,類型變化時就會分配內存。

#include <iostream>
#include <any>

struct mystruct {
    int data;
};

int main(void)
{
    auto myany = std::make_any<int>(42);
    std::cout << std::any_cast<int>(myany) << '\n';

    myany = 4.2;
    std::cout << std::any_cast<double>(myany) << '\n';

    myany = mystruct{42};
    std::cout << std::any_cast<mystruct>(myany).data << '\n';
}
// 42
// 4.2
// 42

在上面的例子中,創建了 std:any{} 並且用 int、double和一個結構體去設置它的值。

std::variant 更像一個類型安全的union。使用標準 C 語言的 union,它是無法在運行時知道存儲的類型,也就是同時存儲 int 和 double 是有問題的。std::variant 它可以避免這個問題,嘗試用不同類型來獲取數據是允許的,因此它是類型安全的。

#include <iostream>
#include <variant>


int main(void)
{
    std::variant<int, double> v = 42;
    std::cout << std::get<int>(v) << '\n';

    v = 4.2;
    std::cout << std::get<double>(v) << '\n';
}

在上面的例子中,std::variant 被用來存儲一個 integerdouble,而通過 std::variant 可以安全地獲取數據。

std::optional 是一個可空的值類型,它要麼含值,要麼不含值。一個指針是可空引用類型,代表着這個指針要麼是無效的要麼是有效的,並且存儲了一個值。爲了創建一個指針值,就必須分配內存或者至少指向一個值。std::optional 是值類型,意味着它不需要分配內存。

#include <iostream>
#include <optional>

class myclass
{
public:
    int val;

    myclass(int v) : val{v}
    {
        std::cout << "constructed\n";
    }
};

int main(void)
{
    std::optional<myclass> o;
    std::cout << "created, but no constructed\n";

    if (o) {
        std::cout << "Attempt #1: " << o->val << '\n';
    }

    o = myclass{42};
    if (o) {
        std::cout << "Attempt #2: " << o->val << '\n';
    }
}
// created, but no constructed
// constructed
// Attempt #2: 42

可以看到,類沒有被構造,直到我們設置了一個有效值。

RAII:資源獲取即初始化

RAII 可以說是 C++ 最重要的慣用法,一個不懂 RAII 的 C++ 程序員不是一個合格的 C++ 程序員。RAII 爲整個 C++ 庫奠定了基礎和設計模式。RAII 背後的想法很簡單。如果分配了資源,則在對象的構造過程中分配該資源,並且在銷燬對象時,將釋放該資源。換句話說,用對象來管理資源。爲此, RAII 利用 C++ 的構造和銷燬功能, 例如:

#include <iostream>

class myclass
{
public:
    myclass()
    {
        std::cout << "Hello from constructor\n";
    }

    ~myclass()
    {
        std::cout << "Hello from destructor\n";
    }
};

int main(void)
{
    myclass c;
}
// Hello from constructor
// Hello from destructor

可以看到,當類被初始化時,類就被構造。不再使用它的時候,它就被銷燬。用這種簡單的概念就可以用來保護一個資源,確保離開時釋放了資源。只要用 RAII ,基本上不會出現內存的錯誤。

#include <iostream>

class myclass
{
    int *ptr;
public:
    myclass():
        ptr{new int(42)}
    { }

    ~myclass()
    {
        delete ptr;
    }

    int get()
    {
        return *ptr;
    }
};

int main(void)
{
    myclass c;
    std::cout << "The answer is: " << c.get() << '\n';
}

可以看到,類創建的時候分配了內存,類銷燬的時候釋放了內存。因此,只要 myclass{} 還在,資源就是可用的,可以安全地訪問資源,因爲只有在 myclass{} 不可見的時候才釋放資源(假設不使用指向類的引用或指針)。內存不會泄漏。如果類可見,則分配的類的內存將有效。一旦類不再可見(即脫離作用域),內存將被釋放,並且不會發生泄漏。

每一個明確的資源配置動作(例如 new)都應該在單一語句中執行,並在該語句中立刻將配置獲得的資源交給 handle 對象(比如 shared_ptr),程序中一般不出現 delete

C++ 的智能指針 std::unique_ptr{}std::shared_ptr{} 就利用了這種設計模式。RAII 適用於任何必須獲取然後釋放的資源,例如:打開文件,就需要在最後關閉文件:

#include <iostream>

class myclass
{
    FILE *m_file;
public:
    myclass(const char *filename) :
        m_file{fopen(filename, "rb")}
    {
        if (m_file == 0) {
            throw std::runtime_error("unable to open file");
        }
    }

    ~myclass()
    {
        fclose(m_file);
        std::clog << "Hello from destructor\n";
    }
};

int main(void)
{
    myclass c("test.txt");

    try {
        myclass c2("does_not_exist.txt");
    } catch (const std::exception &e) {
        std::cout << "exception: " << e.what() << '\n';
    }
}
// exception: unable to open file
// Hello from destructor

需要注意的是,第二個類的析構函數並沒有被調用,因爲在初始化類的時候已經拋出了異常。也就是說,資源的獲取與類本身的初始化直接相關。如果無法安全地構造類,則可以防止未分配的資源被破壞。

RAII 可以說是 C++ 簡單又最強大的功能。

開發準則支持庫(GSL)

C++ 的語法特性實在是太多了,因此實踐過程中許多人只選擇了 C++ 的一部分語言特性進行開發,從而約定了最佳實踐(用什麼、怎麼用、要不要用)。其中一個著名的規範就是CCG (C++ Core Guidelines)。爲了在開發過程中更好地遵守 CCG 的最佳實踐,可以使用 GSL(The Guideline Support Library) 庫。

  • 指針所有權:明確定義誰擁有指針是防止內存泄漏和指針損壞的簡單方式。一般來說,定義所有權的最佳方式使用智能指針 unique_ptr{}sharped_ptr{} 。但有時候,某些情況下是用不了的,這些邊緣情況就可以考慮用 GSL 來處理。
  • 管理期望:GSL 還可以用來定義函數期待的輸入和保證的輸出。
  • 指針算術:指針的算術運算是導致很多內存問題和漏洞的重要原因。GSL 限制指針運算(或者至少只用在測試良好的庫上)可以避免這些問題。

指針所有權

C++ 並不區分誰擁有指針(分配和釋放指針)和誰僅能訪問值。例如:

#include <iostream>

void init(int *p)
{
    *p = 0;
}

int main(void)
{
    auto p = new int;
    init(p);
    delete p;
}

上面的代碼雖然很短,但是還是可以看到會有很多潛在的風險。比如,init 如果在方法內釋放了指針,那麼 main 中 delete 便會出現內存重複釋放的問題。換句話說,怎麼確保 delete 的時間是正確的,如果 init 把資源交給了另外的方法,delete 卻提前刪除資源,那麼之後其它方法訪問資源時,又會出現錯誤。

爲了克服這個問題,GSL 提供了一個 gsl::owner<>{} 用於記錄給定的變量是否是指針的所有者。

#include <gsl/gsl>

void init(int *p)
{
    *p = 0;
}

int main(void)
{
    gsl::owner<int *> p = new int;
    init(p);
    delete p;
}

注意:這裏的 gsl 不是 C 語言的科學計算庫。它只是一個純頭文件的支持庫。最簡單的用法就是把頭文件 include 拷到某個目錄下,然後編譯的時候使用 -I 選項就可以了,例如 g++ -std=c++17 xxx.cpp -Ixxx/xxx/include

在上面的代碼中,p 被指定爲指針的所有者,如果 p 不再需要了,那麼它應該釋放內存。上面代碼還有一個問題,init 期待的是一個非 null 的指針。

通常有兩種方法來克服出現 null 指針的問題。第一種,是檢查 nullptr 並且拋出異常。這種解決方法的問題是,你在每個函數都要檢查 nullptr。這些檢查無疑需要成本,而且代碼也顯得比較雜亂。另外一種做法是使用 gsl::not_null<>{},它可以明確地聲明是否可以安全地處理空指針。

#include <gsl/gsl>

gsl::not_null<int *>
test(gsl::not_null<int *> p)
{
    return p;
}

int main(void)
{
    auto p1 = std::make_unique<int>();
    auto p2 = test(gsl::not_null(p1.get()));
}

在上面的代碼中,使用 std::unique_str{} 創建了一個指針,並將它傳遞給 test() 函數。test() 函數不支持空指針,所以它使用 gsl:not_null<>{} 作爲它的參數。反過來,test() 函數返回 gsl:not_null<>{} ,代表函數結果是非 null 的(這也是它爲什麼要求參數是非 null 原因)。

指針運算

指針算法同樣是導致不穩定和漏洞的常見錯誤來源。C++ Core Guidelines 不鼓勵使用指針算術。

int array[10];

auto r1 = array  + 1;
auto r2 = *(array + 1);
auto r3 = array[1];

使用指針算術很容易越界。爲了解決這個問題,GSL 提供了一個 gsl::span{} 類,可以給我們一個安全使用指針的接口,同樣適用於數組。例如:

#define GSL_THROW_ON_CONTRACT_VIOLATION
#include <gsl/gsl>
#include <iostream>

int main(void)
{
    int array[5] = {1, 2, 3, 4, 5};
    auto span = gsl::span(array);

    for (const auto &elem : span) {
        std::clog << elem << '\n';
    }

    for (auto i = 0; i < 5; i++) {
        std::clog << span[i] << '\n';
    }

    try {
        std::clog << span[5] << '\n';
    } catch(const gsl::fail_fast &e) {
        std::cout << "exception: " << e.what() << '\n';
    }
}
// 1
// 2
// 3
// 4
// 5
// 1
// 2
// 3
// 4
// 5
// exception: GSL: Precondition failure at ...

可以看到 gsl::span{} 在 C++ 原生的數組功能上增加了對範圍的檢查,防止越界。在上面的代碼中,試圖訪問越界的下標 5,gsl::span{} 拋出了 gsl::fail_fast{} 異常。需要注意的是 GSL_THROW_ON_CONTRACT_VIOLATION ,它告訴 GSL 拋出異常而不是終止 std::terminate 或者不檢查越界。

gsl::span{} 之上,gsl::span{} 也有一些特定的實現,比如 gsl::cstring_span{}

#include <gsl/gsl>
#include <iostream>

int main(void)
{
    gsl::cstring_span<> str = gsl::ensure_z("Hello World\n");
    std::cout << str.data();

    for (const auto &elem : str) {
        std::clog << elem;
    }
}
// Hello World
// Hello World

gsl::cstring_span{} 是一個包含標準 C 風格字符串的 gsl::span{}。使用 gsl::ensure_z() 函數來確保字符串由一個 null 字符結尾。

契約(contracts)

C++ 契約爲用戶提供了一種方法來說明函數期望輸入的內容,以及該函數確保輸出的內容。具體來說,C ++ 契約記錄了API的作者和API的用戶之間的契約,它還提供了該契約的編譯時和運行時驗證。

未來的 C++ 將會有內置的契約,但現在,可以用 GSL 提供一個基於庫的實現。主要是 Expects()Ensures() 這兩個宏。

#define GSL_THROW_ON_CONTRACT_VIOLATION
#include <gsl/gsl>
#include <iostream>

int main(void)
{
    try {
        Expects(false);
    } catch (const gsl::fail_fast &e) {
        std::cout << "exception: " << e.what() << '\n';
    }
}
// exception: GSL: Precondition failure at cpp17_01.cpp: 8

在上面的代碼中,使用 Expects() 宏並傳入 false。看起來很像 C 語言的 assert() 函數。但不像 assert()Expects()false時如果不是調試的時候就會執行 std::terminate() 來終止程序。

Ensures() 宏和 Expects() 是一樣的,只不過它約束的是輸出而不是輸入。

#define GSL_THROW_ON_CONTRACT_VIOLATION
#include <gsl/gsl>
#include <iostream>

int test(int i)
{
    Expects(i >= 0 && i < 41);
    i++;

    Ensures(i < 42);
    return i;
}

int main(void)
{
    test(0);

    try {
        test(42);
    } catch (const gsl::fail_fast &e) {
        std::cout << "exception: " << e.what() << '\n';
    }
}
// exception: GSL: Precondition failure at cpp17_01.cpp: 7

一些有用的工具

GSL 也提供給了一些有用的工具來幫助創建具有可靠性和可讀性的代碼。例如 gsl::finally{} API:

#define concat1(a,b) a ## b
#define concat2(a,b) concat1(a,b)
#define ___ concat2(dont_care, __COUNTER__)

#include <gsl/gsl>
#include <iostream>

int main(void)
{
    auto ___ = gsl::finally([]{
        std::cout << "Hello World\n";
    });
}
// Hello World

gsl::finally{} 通過 C++ 析構函數的機制,提供了一種簡單的方式在函數退出之前執行代碼。對於需要在函數退出前執行清理過程非常有用。更有用的地方是對於存在異常的時候,一旦代碼中有異常,一些清理的代碼可能就忘記了,但是隻要 gsl::finally{} 是在異常前面的定義的,發生異常後,仍然會執行相應的代碼。

在上面的代碼中,還包含了一個宏,允許使用 __ 定義 gsl::finally{} 的名字。使用 gsl::finally{} 必須存儲 gsl::finally{} 對象才能在退出函數的時候執行析構函數。那就必須給 gsl::finally{} 起一個名字,但這非常笨重,也沒有意義。顯然,不會有代碼去調用 gsl::finally{} 對象。這個宏提供了一個簡單方式去表達了“我不關心變量的名字”。

GSL 還提供了 gsl::narrow<>()gsl::narrow_cast<>(),例如:

#include <gsl/gsl>
#include <iostream>

int main(void)
{
    uint64_t val = 42;

    auto val1 = gsl::narrow<uint32_t>(val);
    auto val2 = gsl::narrow_cast<uint32_t>(val);
}

這兩個函數和 static_cast<>() 一樣,只不過 gsl::narrow<>() 會檢查溢出。gsl::narrow_cast<>() 則只是 static_cast<> 的同義詞。它們都表明一個整數的縮小正在發生,簡單地說就是把分配給數字的內存空間縮小了。

#define GSL_THROW_ON_CONTRACT_VIOLATION
#include <gsl/gsl>
#include <iostream>

int main(void)
{
    uint64_t val = 0xFFFFFFFFFFFFFFFF;
    try {
        gsl::narrow<uint32_t>(val);
    } catch(...) {
        std::cout << "narrow failed\n";
    }
}
// narrow failed

在上面的代碼中,嘗試將 64 位整數轉化到 32 位,在 gsl::narrow{} 的溢出檢查中拋出了異常。

小結

把 C++ 17、RAII、GSL放到一起,無非是希望能寫出更加優雅、健壯的 C++ 代碼。

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