C++20 的 std::span 應用整理
寫函式常常卡一個小問題:「一段連續的資料」要用什麼型別收? 收 vector 的話 C array 進不來, 收指標加長度又退回 C 的寫法。std::span 就是來收這件事的, 一個不擁有資料的輕量視圖。這篇整理基本用法、切片、extent, 跟幾個容易踩的坑。
一個介面收所有連續資料
以前同一份邏輯,常常得為不同容器各開一個入口:
// 以前:擇一,然後另外兩種呼叫端就不開心double avg(const std::vector<double>& v);double avg(const double* p, std::size_t n);template <std::size_t N> double avg(const std::array<double, N>& a);
// 現在:一個就好double avg(std::span<const double> v);std::span<T> 本體就只有指標加長度兩個欄位, 指著別人的記憶體,自己不配置也不釋放。資料只要是連續的就能直接傳。
基本用法
#include <span>。C array、std::array、vector、指標加長度,全部餵得進去:
#include <span>
void print_all(std::span<const int> s) { for (int x : s) std::print("{} ", x);}
int arr[] = {1, 2, 3};std::array a = {4, 5, 6};std::vector v = {7, 8, 9};
print_all(arr); // C arrayprint_all(a); // std::arrayprint_all(v); // vectorprint_all({v.data() + 1, 2}); // 指標 + 長度 → 8 9介面長得跟容器很像,size()、empty()、front()、back()、data()、begin()/end() 該有的都有,range-based for 跟 <algorithm> 直接用。
它是 view,不是容器
span 不擁有資料,複製它就是複製一個指標跟一個長度,所以傳參數直接傳值就好,不用寫 const std::span<T>&。 反過來,透過 span 改元素,改到的就是原本那塊記憶體:
void fill_zero(std::span<int> s) { for (int& x : s) x = 0;}
std::vector v = {1, 2, 3};fill_zero(v); // v 變成 {0, 0, 0}可以把它當成比較好用的 (T*, size_t): 迭代器什麼的都配好了,但東西終究是別人的,它只是借來用。
坑:唯讀要寫 span<const T>
const std::span<int> 跟 std::span<const int> 不是同一回事, 規則跟指標一模一樣(int* const vs const int*):
void f(std::span<const int> s) { // s[0] = 1; // 編譯錯誤:元素唯讀}
void g(const std::span<int> s) { s[0] = 1; // 可以編!const 的是 span 本身,不是元素}const 加在 span 上只是說這個 view 本身不能改指向, 元素照樣可以寫。所以不打算改資料的介面,就寫 span<const T>,順便連 const 的容器都吃得進來。
切片:first / last / subspan
切一段出來不會複製資料,切完還是 span:
std::vector v = {0, 1, 2, 3, 4, 5};std::span s{v};
s.first(3); // {0, 1, 2}s.last(2); // {4, 5}s.subspan(2); // {2, 3, 4, 5} 從 index 2 到底s.subspan(1, 3); // {1, 2, 3} 從 index 1 取 3 個sliding window、分段處理這種寫起來都蠻順的。 切出來的 span 一樣指著原資料,改了就是改本體:
// 固定大小的視窗掃過去for (std::size_t i = 0; i + 3 <= s.size(); ++i) process(s.subspan(i, 3));
// 只排序前半段,動的是 v 本人std::ranges::sort(s.first(s.size() / 2));編譯期長度:static extent
span 其實還有第二個 template 參數:std::span<T, N>。 預設是 std::dynamic_extent,長度存在執行期; 把 N 寫死之後長度就進到型別裡,適合那種「一定要剛好 N 個」的介面:
double dot3(std::span<const double, 3> a, std::span<const double, 3> b) { return a[0]*b[0] + a[1]*b[1] + a[2]*b[2];}
double u[] = {1, 2, 3}, w[] = {4, 5, 6};dot3(u, w); // OK:C array 長度編譯期就知道
std::vector v = {1.0, 2.0, 3.0};// dot3(v, w); // 編譯錯誤:vector 長度是執行期的dot3(std::span{v}.first<3>(), w); // 明確保證「就是 3 個」CTAD 有個小地方要留意:std::span s{arr} 餵 C array, 推出來是 span<int, 3>(static extent), 餵 vector 才是 dynamic。static extent 少存一個長度、 編譯器也能多做點假設,不過日常介面用 dynamic 就夠了。
as_bytes:用 byte 視角看資料
做 IO、hash、序列化常常要拿一塊資料的原始 bytes, 以前都是 reinterpret_cast 自由發揮,現在有標準寫法:
std::vector<float> v = {1.5f, 2.5f};
std::span<const std::byte> raw = std::as_bytes(std::span{v});// raw.size() == v.size() * sizeof(float)
auto writable = std::as_writable_bytes(std::span{v}); // span<std::byte>坑:沒有 bounds check,也沒有所有權
s[i] 越界是 UB,而且 C++20/23 連 at() 都沒有,C++26 才補上。index 是外面來的就自己先檢查 size()。
更常見的是 dangling。span 不會幫資料續命, 跟 string_view 是同一類問題:
std::span<int> bad() { std::vector v = {1, 2, 3}; return v; // v 出 scope 就死了,回傳的 span 懸空}
std::vector v = {1, 2, 3};std::span s{v};v.push_back(4); // 可能 reallocate,s 整個失效用法上跟 string_view 同一套:當參數往下傳很安全,存成成員或回傳出去就要想清楚, 得確定底層資料活得比它久、而且不會搬家。
跟 string_view 怎麼分
string_view 概念上就是字串特化的唯讀 span, 多了 find、substr、starts_with 這些字串操作,但永遠唯讀。 分法很簡單:字串用 string_view, 其他連續記憶體用 span, 真的要原地改字元的少數場合,才輪得到 span<char>。