C++20 | std::span 陣列、容器的代理人
在 C++ 裡頭有相當多「容器」。從原生的陣列,到標準庫 STL 的 vector
, array
, list
, queue
, map
, set
, …。有時候我們只是想以檢視的角度去看一個容器,或是其中一段內容,而不需要底下龐大的資料結構支撐其運作,也不想要擁有這個容器內的元素,這就是 C++20 中標準庫引入 span
概念的原由。
同樣概念在 C++17 時代就針對 string
這個「容器」有相對應的解決方案,就是 string_view
字串視圖。
對,他就是個不具資料擁有權的代理 (proxy) 而已。我們今天就來帶各位同學解析一下 span
的用法還有原理。
長怎樣 / 怎麼用
根據 span
的標準提案 (P0122R7) 裡的敘述,
std::span
是一個不具所有權且用來檢視連續資料的一個觀察者 (view)。
他就藏在標頭檔 <span>
裡面。各位同學不是隔壁棚
#include <span>
而他的定義在標準中大概 / 可能 / 可以長這樣子:
template<
class T,
std::size_t Extent = std::dynamic_extent
> class span;
意思就是他其實是一個樣版類別 (template class)。依照我們初始化他的容器內容物型別 (可能還有長度) 去推導。
T
就是你想檢視的容器內容物的型別,比如你今天想檢視的容器是個vector<int>
,那這兒的T
就是int
。Extent
是非型別樣版參數,代表我們想檢視的容器範圍寬度,可以是一個簡單的非負整數或是std::dynamic_extent
(預設),代表動態寬度。大家看到這裡掛了一個預設引數,就知道在標準庫設計中,span
大部份使用情境之下我們是不太需要在意容器檢視範圍的寬度的。- 當然這裡的「動態」當然不是真的可以變長變短,而是指在運行期 (run-time) 初始化
span
時才會知道範圍寬度,編譯期 (compile-time) 還不知道的意思。
包裝 C++ 原生陣列
包裝原生陣列的方法很簡單,就直接把一個原生陣列丟進去 span 的 constructor 創一個 span
物件就好 (C++17 以後,就算是樣版類別,宣告物件時也不用手動丟參數具現化樣版)。如同下面代碼中 main()
裡面的 arr_sp
。
用 span 包裝 C++ 原生陣列的幾種方法
void print_sp(const std::span<int> &sp)
{
std::cout << "Size: " << sp.size() << std::endl;
for (auto & i : sp)
std::cout << i << ' ';
std::cout << std::endl;
}
int main()
{
int arr[] = {0,1,2,3,4,5,6,7,8};
std::span arr_sp{arr};
std::span arr_sp2{arr + 2, arr + 6};
std::span arr_sp3{arr + 3, 4};
std::cout <<arr_sp[2] << std::endl; // 2
print_sp(arr_sp);
// Size: 9
// 0 1 2 3 4 5 6 7 8
print_sp(arr_sp2);
// Size: 4
// 2 3 4 5
print_sp(arr_sp3);
// Size: 4
// 3 4 5 6
}
除了直接包裝整個陣列 (arr_sp
) 以外,我們也可以用錨點的方式構造一個 span
:
- 在
span
的 constructor 給定陣列的起點還有終點的指標。和標準庫的慣例一樣,作用範圍包含起點、不包含終點。代碼中的arr_sp2
。 - 在
span
的 constructor 給定陣列的起點還有長度。代碼中的arr_sp3
。
以 std::span
這個代理人包裝原生陣列之後,不用說,我們依舊可以像古時候一樣以 []
去存取底下的陣列元素。
比較炫炮的是,如同print_sp()
裡的用法:我們可以現代化的 ranged-base for loop 去列舉代理範圍內的元素 ,呼叫 size()
獲得 span
代理的範圍長度,也提供 begin()
/ end()
這對活寶,也就是通過 C++ 中的 iterator 來存取範圍內元素。
特別要注意的是,[]
如果存取越界的話,這個在標準中說是未定義行為,各家編譯器帶的標準庫或是 open source 實作可以各自表述,所以大家還是要小心點不要亂搞 XD。
古時候把陣列傳來傳去總是需要在函式參數多標示個長度,甚至需要描述區段時,還需要多兩個參數標示起點終點,通過 span
樣版把長度吃進去類別裡面,完全省去這層困擾,且幾近零時間成本,程式也變得簡潔好懂多了。
// Before
int read_info_buf(char buf[], int size) {}
char buf[BUF_SIZE];
read_info_buf(buf, BUF_SIZE);
// After
int read_info_buf(std::span<char> s) {}
char buf[BUF_SIZE];
read_info_buf(buf);
包裝 C++ 標準庫 STL 的容器 (Container)
身為 C++20 的新發明,span
肯定可以拿來包 C++ 標準庫裡面那堆容器 (Container) 了是吧?是,也不是。
記得我們前面提到 span
代表的是一連串連續的資料。因此能被 span
包裝的容器自然也就必須是連續的容器 (sequential contiguous container):也就是只有 array
,vector
(除開 vector<bool>
),string
,span
。
補充一下,舉例來說,在 clang 9.0.1/LLVM 的 span
constructor SFINAE 實作方法是判斷
std::data(Container)
是否是 well-formed。- 而且
std::data(Container)
這個函式回傳的指標所指向的型別形成的陣列能否轉型成 span 想包裝的內容物的陣列。
上面看不懂沒關係,重點是怎麼用,其實跟上面包裝原生的 C++ 陣列 87% 像:直接丟容器給 span
。不多說直接上圖
我們從上面代碼的 main() 裡面總結一下 span
幾種包裝 STL 的方法:
- 直接以一個
array
初始化 span。 - 以
array
的begin()
和array
的end()
初始化。 - 以
a_sp
呼叫subspan(2, 2)
得到一個從 2 開始、長度為 2 的span
,並拿來初始化一個新的span
。 - 以一個
vector
(除開vector<bool>
) 初始化span
。 - 以一個
string
初始化span
。
另外也稍微提一下 span 提供的幾個分段函式:
first(3)
: 對一個span
取前 3 個元素成為一個新的span
。last(5)
: 對一個span
取後 5 個元素成為一個新的span
。subspan(2, 2)
: 對一個span
從位置 2 開始,取兩個元素成為一個新的span
。
靜態長度 Static extent
前面稍為提到惹,所謂靜態的長度是指在編譯時期就直接知道想包裝的範圍寬度多長。像是前面舉例的代碼裡頭,span
拿來包裝 C++ 原生陣列,或是包裝 **std::array**
在 constructor 都會推導出 static extent,在這裡我自己把他取名叫靜態 **span**
。
對我們包裝一個容器來說,span
擁有 static extent 帶給我們最大的好處就是可以把代碼寫得更加炫炮。
結合 C++17 Structured Binding
在提案 p1024r3 裡面提到了:既然原生陣列和標準庫的 array
從 C++17 起就支援了 structured binding,那麼能包裝這些東西的 span 也應該要能支援 structured binding,才能達到完整的 array reference 的效用。所以這個提案改動了
- 實作
std::get
對靜態span
的支援。 - 實作
std::tuple_size
和std::tuple_element
對span
的支援。 - 也就是可以通過 structured binding 直接解構一個靜態
**span**
。
按照 C++17 的 structured binding 語法完美套用,無縫接軌。直接上圖
編譯器支援 / Open Source
上面所有代碼都在 MacOS 10.15.2 使用 clang/LLVM 9.0.1 編譯測試過,這篇文章寫作當下幾家編譯器大廠也就 clang 的 libc++ 有釋出 std::span 的實作。GCC 說 libstdc++ 10 版會出,而 MSVC 就…再看看 XD
其實 span
的呼聲一直都有,最早可以追溯到 2012 年的 array reference,只是進標準的進程一直很緩慢就是了,所以網路上也產生了各家實作的開源 span 版本,大家可以不用裝 clang 就簡單上手一下,
- 比如 Microsoft 的 GSL (需要你的編譯器支援 C++14)。
- tcbrindle/span (編譯器支援 C++11 就可)。
- martinmoene/span-lite (C++98)
值得一提的是,隨著 span
的標準進展,除了各種開源實作,另一種 span
的擴展的呼聲和其開源也逐漸冒了出頭,那就是把包裝的連續資料視為多維空間的 mdspan
。有興趣的同學也可以去看看 XD 大概是醬 888