主要聊一下 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::array
,std::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';
}
front
和 last
用來返回字符串的第一個和最後一個字符。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
被用來存儲一個 integer
和 double
,而通過 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++ 代碼。