玩轉C++17 的十個特性

玩轉C++17 的十個特性

C++迭代速度相對來說還是比較慢的,2010年以後,C++的新版本迭代速度有所加快,這一點,從C++標準版本的歷史釋出圖1就可以看出來:

玩轉C++17 的十個特性

筆者將這些特性大體上分為三類:語法糖、效能提升和型別系統。

語法糖

這裡所說的語法糖,並不是嚴格意義上程式語言級別的語法糖,還包括一些能讓程式碼更簡潔更具有可讀性的函式和庫:

結構化繫結

c++17 最便利的語法糖當屬結構化繫結。結構化繫結是指將 array、tuple 或 struct 的成員繫結到一組變數*上的語法,最常用的場景是在遍歷 map/unordered_map 時不用再宣告一箇中間變量了:

// pre c++17for(const auto& kv: map){  const auto& key = kv。first;  const auto& value = kv。second;  // 。。。}// c++17for(const auto& [key, value]: map){  // 。。。}

*: 嚴格來說,結構化繫結的結果並不是變數,c++標準稱之為名字/別名,這也導致它們不允許被 lambda 捕獲,但是 gcc 並沒有遵循 c++標準,所以以下程式碼在 gcc 可以編譯,clang 則編譯不過

for(const auto& [key, value]: map){    [&key, &value]{        std::cout << key << “: ” << value << std::endl;    }();}

在 clang 環境下,可以在 lambda 表示式捕獲時顯式引入一個引用變數透過編譯

for(const auto& [key, value]: map){    [&key = key, &value = value]{        std::cout << key << “: ” << value << std::endl;    }();}

另外這條限制在 c++20 中已經被刪除,所以在 c++20 標準中 gcc 和 clang 都可以捕獲結構化繫結的物件了。

std::tuple 的隱式推導

在 c++17 以前,構造

std::pair/std::tuple

時必須指定資料型別或使用

std::make_pair/std::make_tuple

函式,c++17 為

std::pair/std::tuple

新增了推導規則,可以不再顯示指定型別。

// pre c++17std::pair p1{3。14, “pi”s};auto p1 = std::make_pair(3。14, “pi”s);// c++17std::pair p3{3。14, “pi”s};

if constexpr

if constexpr 語句是編譯期的 if 判斷語句,在 C++17 以前做編譯期的條件判斷往往透過複雜

SFINAE

機制或模版過載實現,甚至嫌麻煩的時候直接放到執行時用 if 判斷,造成效能損耗,if constexpr 大大緩解了這個問題。比如我想實現一個函式將不同型別的輸入轉化為字串,在 c++17 之前需要寫三個函式去實現,而 c++17 只需要一個函式。

// pre c++17template std::string convert(T input){    return std::to_string(input);}// const char*和string進行特殊處理std::string convert(const char* input){    return input;}std::string convert(std::string input){    return input;}

// c++17template std::string convert(T input) {    if constexpr (std::is_same_v ||                  std::is_same_v) {        return input;    } else {        return std::to_string(input);    }}

if 初始化語句

c++17 支援在 if 的判斷語句之前增加一個初始化語句,將僅用於 if 語句內部的變數宣告在 if 內,有助於提升程式碼的可讀性。且對於 lock/iterator 等涉及併發/RAII 的型別更容易保證程式的正確性。

// c++ 17std::map m;std::mutex mx;extern bool shared_flag; // guarded by mxint demo(){    if (auto it = m。find(10); it != m。end()) { return it->second。size(); }    if (char buf[10]; std::fgets(buf, 10, stdin)) { m[0] += buf; }    if (std::lock_guard lock(mx); shared_flag) { unsafe_ping(); shared_flag = false; }    if (int s; int count = ReadBytesWithSignal(&s)) { publish(count); raise(s); }    if (const auto keywords = {“if”, “for”, “while”};        std::ranges::any_of(keywords, [&tok](const char* kw) { return tok == kw; }))    {        std::cerr << “Token must not be a keyword\n”;    }}

效能提升

std::shared_mutex

shared_mutex

是 c++的原生讀寫鎖實現,有共享和獨佔兩種鎖模式,適用於併發高的讀場景下,透過 reader 之前共享鎖來提升效能。在 c++17 之前,只能自己透過獨佔鎖和條件變數自己實現讀寫鎖或使用 c++14 加入的效能較差的

std::shared_timed_mutex

。以下是透過

shared_mutex

實現的執行緒安全計數器:

// c++17class ThreadSafeCounter { public:  ThreadSafeCounter() = default;  // Multiple threads/readers can read the counter‘s value at the same time。  unsigned int get() const {    std::shared_lock lock(mutex_);    return value_;  }  // Only one thread/writer can increment/write the counter’s value。  unsigned int increment() {    std::unique_lock lock(mutex_);    return ++value_;  }  // Only one thread/writer can reset/write the counter‘s value。  void reset() {    std::unique_lock lock(mutex_);    value_ = 0;  } private:  mutable std::shared_mutex mutex_;  unsigned int value_ = 0;};

std::string_view

std::string_view

顧名思義是字串的“檢視”,類成員變數包含兩個部分:字串指標和字串長度,std::string_view 涵蓋了 std::string 的所有隻讀介面。std::string_view 對字串不具有所有權,且相容 std::string 和 const char*兩種型別。

c++17 之前,我們處理只讀字串往往使用

const std::string&

std::string

有兩點效能優勢:

相容兩種字串型別,減少型別轉換和記憶體分配。如果傳入的是明文字串

const char*

const std::string&

需要進行一次記憶體分配,將字串複製到堆上,而

std::string_view

則可以避免。

在處理子串時,

std::string::substr

也需要進行複製和分配記憶體,而

std::string_view::substr

則不需要,在處理大檔案解析時,效能優勢非常明顯。

// from https://stackoverflow。com/a/40129046// author: Pavel Davydov// string_view的remove_prefix比const std::string&的快了15倍string remove_prefix(const string &str) {  return str。substr(3);}string_view remove_prefix(string_view str) {  str。remove_prefix(3);  return str;}static void BM_remove_prefix_string(benchmark::State& state) {  std::string example{“asfaghdfgsghasfasg3423rfgasdg”};  while (state。KeepRunning()) {    auto res = remove_prefix(example);    // auto res = remove_prefix(string_view(example)); for string_view    if (res != “aghdfgsghasfasg3423rfgasdg”) {      throw std::runtime_error(“bad op”);    }  }}

std::map/unordered_map try_emplace

在向

std::map/unordered_map

中插入元素時,我們往往使用

emplace

emplace

的操作是如果元素 key 不存在,則插入該元素,否則不插入。但是在元素已存在時,

emplace

仍會構造一次待插入的元素,在判斷不需要插入後,立即將該元素析構,因此進行了一次多餘構造和析構操作。c++17 加入了

try_emplace

,避免了這個問題。同時 try_emplace 在引數列表中將 key 和 value 分開,因此進行原地構造的語法比

emplace

更加簡潔

std::map m;// emplace的原地構造需要使用std::piecewise_construct,因為是直接插入std::pairm。emplace(std::piecewise_construct,           std::forward_as_tuple(“c”),           std::forward_as_tuple(10, ’c‘));// try_emplace可以直接原地構造,因為引數列表中key和value是分開的m。try_emplace(“c”, 10, ’c‘)

同時,c++17 還給

std::map/unordered_map

加入了

insert_or_assign

函式,可以更方便地實現插入或修改語義。

型別系統

c++17 進一步完備了 c++的型別系統,終於加入了眾望所歸的型別擦除容器(

Type Erasure

)和代數資料型別(

Algebraic Data Type

std::any

std::any

是一個可以儲存任何可複製型別的容器,C 語言中通常使用

void*

實現類似的功能,與

void*

相比,

std::any

具有兩點優勢:

std::any

更安全:在型別 T 被轉換成

void*

時,T 的型別資訊就已經丟失了,在轉換回具體型別時程式無法判斷當前的

void*

的型別是否真的是 T,容易帶來安全隱患。而

std::any

會儲存型別資訊,

std::any_cast

是一個安全的型別轉換。

std::any

管理了物件的生命週期,在

std::any

析構時,會將儲存的物件析構,而

void*

則需要手動管理記憶體。

std::any

應當很少是程式設計師的第一選擇,在已知型別的情況下,

std::optional

std::variant

和繼承都是比它更高效、更合理的選擇。只有當對型別完全未知的情況下,才應當使用

std::any

,比如動態型別文字的解析或者業務邏輯的中間層資訊傳遞。

std::optional

std::optional

代表一個可能存在的 T 值,對應 Haskell 中的

Maybe

和 Rust/OCaml 中的

option

,實際上是一種

Sum Type

。常用於可能失敗的函式的返回值中,比如工廠函式。在 C++17 之前,往往使用

T*

作為返回值,如果為

nullptr

則代表函式失敗,否則

T*

指向了真正的返回值。但是這種寫法模糊了所有權,函式的呼叫方無法確定是否應該接管

T*

的記憶體管理,而且

T*

可能為空的假設,如果忘記檢查則會有 SegFault 的風險。

// pre c++17ReturnType* func(const std::string& in) {    ReturnType* ret = new ReturnType;    if (in。size() == 0)        return nullptr;    // 。。。    return ret;}// c++17 更安全和直觀std::optional func(const string& in) {    ReturnType ret;    if (in。size() == 0)        return nullopt;    // 。。。    return ret;}

std::variant

std::variant

代表一個多型別的容器,容器中的值是制定型別的一種,是通用的 Sum Type,對應 Rust 的

enum

。是一種型別安全的

union

,所以也叫做

tagged union

。與

union

相比有兩點優勢:

可以儲存複雜型別,而 union 只能直接儲存基礎的 POD 型別,對於如

std::vector

std::string

就等複雜型別則需要使用者手動管理記憶體。

型別安全,variant 儲存了內部的型別資訊,所以可以進行安全的型別轉換,c++17 之前往往透過

union

+

enum

來實現相同功能。

透過使用

std::variant

,使用者可以實現類似 Rust 的

std::result

,即在函式執行成功時返回結果,在失敗時返回錯誤資訊,上文的例子則可以改成:

std::variant func(const string& in) {    ReturnType ret;    if (in。size() == 0)        return Err{“input is empty”};    // 。。。    return {ret};}

需要注意的是,c++17 只提供了一個庫級別的 variant 實現,沒有對應的

模式匹配(Pattern Matching)

機制,而最接近的

std::visit

又缺少編譯器的最佳化支援,所以在 c++17 中

std::variant

並不好用,跟 Rust 和函式式語言中出神入化的 Sum Type 還相去甚遠,但是已經有許多圍繞

std::variant

的提案被提交給 c++委員會探討,包括模式匹配,

std::expected

等等。

總結一下,c++17 新增的三種類型給 c++帶來了更現代更安全的型別系統,它們對應的使用場景是:

std::any

適用於之前使用

void*

作為通用型別的場景。

std::optional

適用於之前使用

nullptr

代表失敗狀態的場景。

std::variant

適用於之前使用

union

的場景。

總結

以上是筆者在生產環境中最常用的 c++17 特性,除了本文描述的十個特性外,c++17 還添加了如

lambda 值捕獲*this

鉗夾函式 std::clamp()

強制檢查返回值[[nodiscard]]

等非常易用的特性,本文篇幅有限不做贅述,歡迎有興趣的讀者自行探索