潮.C++20 | Ranges & Views:使用 STL 的全新姿勢

TJSW
14 min readMay 23, 2020

C++20 在 2020 年帶來的四大革新之一,就是翻新大家古時候對 STL 內各種容器搭配 <algorithm> 的操作用法。用全新的角度去理解看待 STL 的容器,以及更直觀和高效地和各種 <algorithm> 內的操作交互搭配。

以前用法怎麼了?

小時候純真美好的 C++ 引進了各種 STL 容器和 <algorithm> 標頭檔,從此開啟了同學們各種模板容器 (vector, list, map, …),字串 (string) 和 ++iterator 的旅程。

想必同學們小時候應該寫過這種作業:

  1. 我們填滿一個 vector<int> 為 1 到 100
  2. 對每個人平方
  3. 最後取出能被 4 整除的數
  4. 印出前 5 個。

夠簡單吧?靈活運用 STL 各種函式的學霸馬上很直覺地寫出了:

int main()
{
std::vector<int> v(100), v1;
std::iota(v.begin(), v.end(), 1);
std::transform(v.begin(), v.end(), v.begin(),
[] (int i) { return i * i; }
);
std::copy_if(v.begin(), v.end(), std::back_inserter(v1),
[] (int i) { return i % 4 == 0; }
);
for (int i = 0; i < 5; i ++) {
std::cout << v1[i] << ' ';
}
std::cout << '\n';
// 4 16 36 64 100
}

四段粗體代碼分別對應了上述四點操作。同時也浮現了古代 STL 容器操作的幾個問題:

  1. 首先容器操作都要求給定容器的起始和終止點,所以上面不斷反覆出現 v.begin(), v.end() 這一對小淘氣,十分礙眼
    當然好處是擁有很大的彈性想操作哪段就操作哪段,但我想大家使用 STL 時九成九都是整段拿來操作的…。
  2. 有些操作函式還需要你給定輸出結果的放置處:例如上面的 std::transform 的第三個參數,以及 std::copy_ifstd::back_inserter(v1)。整個呼叫長得真的十分不好閱讀。
  3. 我們明明是對一串數字做著一連串操作,卻硬生生地被拆開變成一個一個不好閱讀的函數呼叫,還得記住每一個 <algorithm> 操作函式簽名以及每個參數的意涵。
  4. 最後中間還產生了好幾把非我們所需的最終狀態 vector<int>。例如多生了一個 v1,還有那些根本不會被我們印出的第 6 項到第 100 項。

所以 C++ 標準委員會聽到了大家的哀嚎,在 C++20 帶來 Ranges 和 Views一舉解決上面纏身大家數十年(?)的痛苦啦!!

什麼是 Ranges, Views, View Adaptor?

首先當然先來名詞解釋一下

Ranges 範圍

就是一大坨可迭代元素的集合,你開心對內容器改什麼值,做什麼物件都隨你。比如 STL 內大部份的容器都可以視為一個 Range。

比較實際的來說,就是你連續中了好幾期樂透頭獎,然後買下台北市忠孝東路一到七段兩旁所有地和房子。那你開心兩旁怎麼都更拆房子或蓋整排的陶朱隱園都沒人管你。

Views 視圖

代理人的概念,你只能通過這個透鏡去觀看到 Range 內的一大坨元素。不擁有各個元素的生殺大權。我們可以通過不同 Range 的起點和終點生成各種 View。(或是只有起點,下面會提到)

所以 View 其實是一種特別的 Range,標準中要求了 View 的複製、移動、賦值操作複雜度必須為常數時間,例如上一段提到的一對「起點 iterator 和終點 iterator」就是一個 View。

現實角度就是我今天逛街只能 window shopping 看看台北東區的精華地段房子,我只對商業區三段到五段有興趣,那這就是一個 View。我對南港六段到七段旁的好山好水有興趣那又是另個 View。只是都買不起……

View Adaptor 視圖轉接頭 / 適配器

這比起 adaptor 的字面意思「轉接頭」,我認為更貼近網美照用的各種濾鏡或 PS 修圖操作預覽

一個 View adaptor 代表著對 Range 或是 View 內元素操作。比如全部元素平方,或只留下偶數。但 View adaptor 不對原內容更動值,只是通過一連串的濾鏡透鏡去觀賞原資料操作後的結果值。如此一來可以更彈性地組合操作,也避免對開發者沒興趣的項目做計算。

實際來說,就是今天在東區逛街,只對偶數門牌 (南邊) 且高於 20 層樓的房子有興趣。那房仲就只要帶看那幾間大樓就好,其它小房子就不用特別花時間進去看,雖然一樣買不起…

相較古法的實際改變

看完落落長的名詞解釋後,來看上面那個簡單的學校作業在 C++20 的 Ranges & Views 框架之下怎麼寫

#include <ranges>int main()
{
using namespace std::ranges;
for (int i : views::iota(1)
| views::transform( [] (int i) { return i*i; } )
| views::filter( [] (int i) { return i % 4 == 0; } )
| views::take(5)) {
std::cout << i << ' ';
}

std::cout << '\n';
// 4 16 36 64 100
}

輸出完全一樣,但程式碼完全就是鄉巴佬進大都市的概念一樣。來自兩個世界的產物啊!不要懷疑這仍然是 C++。

四段粗體代碼一樣對應著文章開頭的四段操作。但這邊我們可以看到整個代碼簡潔了不知道幾條街,閱讀理解上十分清晰呢。

寫文當下,實作 C++20 標準 ranges 的正式版編譯器只有 gcc 10.1.0,想實驗的同學們先確認準備好環境之後再來玩喔。

Ranges Library 組成內容

我們接下來通過上面代碼出現的各種部件來講講 C++20 Ranges Library 的內容物。

ranges 標頭檔

如上,要使用 C++20 Ranges 相關功能,一行 #include <ranges>

名稱空間

同學們也看到了,所有相關功能都在 std::ranges::views / std::ranges / std::views 之下。

Range Factory 範圍工廠

所謂工廠就是產出特定產品項目。在 Ranges Library 中 Range Factory 系列函式可以生成各種用途的 View。

上面的 std::views::iota(1),代表從整數 1 開始,下一個元素為遞增 1 的 View。可以注意到,這樣 View 並沒有終點,他的終點來自當開發者不再造訪內容元素。也就是說他和隔壁棚 python2 的 xrange 有點像,具有 lazy evaluation 的特性。

而只有起點的 Range 稱為 unbounded range。而繼承了傳統的用法,views::iota 也提供了同時有起點和終點的版本,這樣的 Range 稱為 bounded range。

Range / View 的造訪方法

不多說了,直接上 code 看用法。

int cnt = 0;
auto v1 = std::views::iota(1);
for (auto v1_it = v1.begin(); cnt < 5; cnt ++) {
std::cout << *v1_it++ << ' ';
}
std::cout << '\n';
// 1 2 3 4 5
for (int i : std::views::iota(1, 11)) {
std::cout << i << ' ';
}
std::cout << '\n';
// 1 2 3 4 5 6 7 8 9 10

造訪迭代 Range 有很多種方法:懷舊情懷的同學依然可以選擇 iterator 迭代法;喜歡簡潔語法的同學可以使出 C++11 ranged-base for loop。

Range 物件提供各種同學們在使用 STL 容器相關的函式:包含了 begin(), end(), size(), empty(), data(),滿足同學們的懷舊需求和用途。

View Adaptor 的串接

上面看到用 iterator 加上 cnt 計數變數來限制印出 views::iota(1) 的前五個元素在現代來說語法實在冗餘了。因此 Ranges 把「取前 N 個元素」這件事變為一個濾鏡套用在 View 上。這就是 views::take 做的事。

views::take

using namespace std::ranges;for (int i : views::iota(1) | views::take(5)) {
std::cout << i << ' ';
}
std::cout << '\n';
// 1 2 3 4 5

Range Library 非常聰明地重載了 | 這個運算子,賦予了他「資料流動」的語義。很大一部份也和 bash 命令列的 | pipeline 流水線不謀而合。

views::iota(1) | views::take(5) 串接之後仍然是一個 View,因此我們可以通過 | 不斷地串接各種 View adaptor 達到資料在各種流水線濾鏡之間流動處理,而且還保持了簡潔易讀,這個設計和實作真的很不簡單。

views::transform, views::filter

例如文章開頭我們還可以串接 views::transformviews::filter ,這兩個 View adaptor 分別的作用是

  • views::transform(func) 在程式執行到取值時,將吃進來的 View 中每一項元素套用 func() 操作。
  • views::filter(func) 在程式執行到取值時,將 func() 回傳 true 的元素保留下來。

顯然這個 func 可以是 C++ 中任意的可呼叫物件 (Callable),包含但不限於函式指針, lambda function,std::bind()

using namespace std::ranges;
std::string str{"Hello this article is from tjsw@medium."};
for (char c : str
| views::drop(6) // drop first 6 elements
| views::transform([] (char c) { return std::toupper(c); })
| views::filter([] (char c) { return !std::isspace(c); })) {
std::cout << c;
}
std::cout << '\n';
// THISARTICLEISFROMTJSW@MEDIUM.
std::cout << str << '\n';
// Hello this article is from tjsw@medium.

像這樣我們就把一串字串 (std::string) 全轉大寫並去掉空白印出了。然而原容器的內容是不會被改動的。而且,因為我們 drop 了前六個字元,所以前六個字元並不會被花資源去計算他們的大寫或是判斷是否為空白。

從這例子不難發現,同學們也可以通過已經構建好的 STL 容器 (如上面的 std::string) 串接 View,來享受 Ranges Library 的方便語法,讚讚讚。

重複利用 Views Adaptor

所有的 View Adaptor 也都是 C++ 中的一種物件對象而已,(都繼承 view_interface<> 樣板類,再繼承view_base 類),所以我們其實是可以把 View adaptor 的串接存起來當作變數來反覆使用的。

using namespace std::ranges;
std::string str1{"Hello this article is from tjsw@medium."};
std::string str2{"keshi - like i need u"};
auto my_adaptor =
views::transform([] (char c) { return std::toupper(c); })
| views::filter([] (char c) { return !std::isspace(c); });
for (char c : str1 | views::drop(6) | my_adaptor) std::cout << c;
std::cout << '\n';
// THISARTICLEISFROMTJSW@MEDIUM.
for (char c : str2 | my_adaptor) std::cout << c;
std::cout << '\n';
// KESHI-LIKEINEEDU

存起來的變數 my_adaptor 仍然可以和別的 View adaptor 串接起來,使用開發上真的非常有彈性。

講到這邊,大致上基本的元件概念和基本使用方法都差不多了,接下來同場加映帶給同學們 C++ 一直被詬病要求許久的 (不限定於字串的) Split 和 Join 功能。

眾所期待的 Split 和 Join

相較隔壁棚 Python 和 Javascript 早已是標準庫內容並被廣為運用於開發,C++ 的 split 和 join 的提案一直存在著各種爭論,最容易被討論到的就是 split 結果的容器要是什麼?同學們可能直覺:不就vector 嗎。這時候班上就有個搗亂的人站起來大聲說:那為什麼不能是 list?為什麼不能是 set, stack, queue

views::split

Ranges Library 的解法就是,把結果變成含有 View 的 View (意思就是沒有真的計算拆出來形成新的字串),要用的人自己把這一坨 View 內含的元素複製自己的容器裡面,這時候才會真的把字元拆解掉抄出來。

using namespace std::ranges;
std::string str{"Hello this article is from tjsw@medium."};
for (auto word : str | views::split(' ')) {
std::cout << "-> ";
for (char c : word |
views::transform([] (char c) { return std::toupper(c); }) )
std::cout << c;
}
std::cout << '\n';
// -> HELLO-> THIS-> ARTICLE-> IS-> FROM-> TJSW@MEDIUM.

眼尖的同學應該發現了,這個 views::split 並沒有任何型別相關的資訊,也就是我們可以開心地拆解任意的容器。而且我們也可以給定非單一元素做為配對拆解的標記符。

std::vector vec{10, 11, 0, 0, 8, 0, 3, 4, 0, 0, 0, 1, 2, 3};
std::vector pat{0, 0};
for (auto nums : vec | views::split(pat)) {
std::cout << "-> ";
for (int i : nums)
std::cout << i << ' ';
}
std::cout << '\n';
// -> 10 11 -> 8 0 3 4 -> 0 1 2 3

就像這樣我們可以很容易地做到以連續兩個 0 為界限分組。

views::join

就是 split 的反操作而已,把一堆分組融起來。

using namespace std::ranges;
std::vector<std::string> strs{"Netflix", "iQiyi", "Spotify"};
for (char c : strs
| views::join // 特別注意:沒有括號
| views::transform([] (char c) { return std::tolower(c); })) {
std::cout << c;
}
std::cout << '\n';
// netflixiqiyispotify

小結

今天應該涵蓋了基本的 C++20 Ranges Library 用法和觀念。主要傳達給大家的精神就是排除傳統上那坨使用不方便,閱讀也不易的函式。用全新的語法和想法將 STL 容器視為資料流,在不同 adaptor 之間傳送,並且通過 lazy evaluation 的方式節省許多不必要的運算開銷。

事實上在 C++20 標準正式收錄 Ranges 之前,就有許多開源的庫實作了 Ranges,最有名的就是 range-v3 了。其中也實作許多好用的 view adaptors,可惜最後沒有進標準裡面:包含了 parital_sum, cycle, slice, zip 等等。所以下期會給各位同學帶來,如何自己實作一個符合 C++ 標準的 View Adaptor,大家掰掰~

--

--