现代C++容器之span:非拥有的连续视图
这个仓库已经开源!现代化 C++(C++11/14/17/20)从基础到进阶的系统教程都在这里,力争做一条完备的现代 C++ 学习路径!欢迎各位大佬前来参观,喜欢的话点个⭐!
Github 一键直达: git clone https://github.com/Awesome-Embedded-Learning-Studio/Tutorial_AwesomeModernCPP
看看超酷的新网站:https://awesome-embedded-learning-studio.github.io/Tutorial_AwesomeModernCPP/
想找其他伙伴的话在这里!https://github.com/orgs/Awesome-Embedded-Learning-Studio/discussions/9,加入我们!
span 是什么:一个指针加一个长度,仅此而已
std::span 是 C++20 给“一段连续数据”配的标准化视图。它不拥有这段内存,只持有两样东西:一个指针,一个长度。就这么简单——咱们可以把它理解成一个带边界信息的指针,或者 C 里 (ptr, len) 这对参数的正式封装。它不分配、不释放、不拷贝底层数据,拷贝一个 span 就是拷贝那两个字(指针和 size),极其廉价。
std::vector<int> v = {1, 2, 3, 4};
std::span<int> s(v); // s 指向 v 的数据,但不拥有
s.size(); // 4
s[0]; // 1
s.data() == v.data(); // true
它的核心价值在传参:咱们写函数想接受“一段 T 数据”时,用 std::span<const T> 能统一接收 C 数组、std::array、std::vector、(指针, 长度) 等所有连续来源,既不拷贝数据,也不用把函数写成模板。
为什么需要它:指针+长度传参的老毛病
C/C++ 里传“一段内存”给函数,老办法是 void f(T* ptr, std::size_t n)。这招能跑,但毛病不少。长度 n 的单位是元素还是字节,只能靠注释或猜。函数会不会改数据,看 T* 还是 const T* 才知道,容易漏。咱们调用方传错长度,编译器一句话不拦。而且这俩参数得成对传、成对记。span 把指针和长度打包进一个对象,类型(span<const T> vs span<T>)直接表达只读/可写意图,长度跟着对象走,丢不了。
// 老办法:长度单位、只读与否全靠注释
void process_old(const uint8_t* buf, std::size_t n);
// span 办法:类型即语义
void process(std::span<const uint8_t> buf); // 明确:只读,长度内建
void mutate(std::span<uint8_t> buf); // 明确:会改,长度内建
对咱们来说,这比写 template<class C> void process(const C& c) 还省事——不用为每种容器实例化一份,避免编译膨胀。
动态 extent 与静态 extent
咱们先把形态分清:span 有两种,区别在长度是运行时存还是编译期定。std::span<T>(完整写法 std::span<T, std::dynamic_extent>)是动态 extent:长度作为成员存着,运行时任意;std::span<T, N> 是静态 extent:长度 N 编译期定死,不在对象里存。
这个区别会直接体现在 sizeof 上——咱们待会儿跑跑看。动态 extent 要存指针 + size(两个字),静态 extent 只存指针(size 编译期已知,省掉)。日常里动态 extent 更常用(数据长度往往运行时才定),静态 extent 适合咱们提前就知道是 N 个的场合,能省一个字的存储,还能换来一点编译期检查。
int arr[4];
std::span<int, 4> s_fixed(arr); // 只能绑长度 4 的数据
std::span<int> s_dyn(arr); // 任意长度,运行时记 4
接收任意连续来源:array / vector / C 数组 / 指针+长度
span 的构造函数覆盖了几乎所有连续数据来源,咱们把函数参数定成 span,就能一统江湖:
void print(std::span<const int> s);
int buf[] = {0x10, 0x20, 0x30};
std::array<int, 3> a = {1, 2, 3};
std::vector<int> v = {4, 5, 6, 7};
int* p = v.data();
print(buf); // C 数组(自动推 N)
print(a); // std::array
print(v); // std::vector
print({p, 2}); // 指针 + 长度
咱们调用时不用拷贝数据,函数内部也不用为每种容器写重载或模板。注意 span<const T> 表示只读视图;如果要在函数里改数据,用 span<T>(非 const)。
subspan、first、last:零拷贝切片
咱们手里有三件套:subspan(offset, count)、first(n)、last(n),返回的都是新的 span(还是非拥有视图),不拷贝任何数据。这在协议解析、缓冲区处理里特别顺手——把一个大 buffer 切成 header / payload,各自当 span 传下去:
void recv_packet(std::span<uint8_t> buffer)
{
if (buffer.size() < 4) {
return;
}
auto header = buffer.first(4); // 前 4 字节视图
uint16_t len = static_cast<uint16_t>(header[2] | (header[3] << 8));
if (buffer.size() < 4 + len) {
return;
}
auto payload = buffer.subspan(4, len); // 跳过 header 取 payload 视图
// payload 仍是非拥有视图,零拷贝
}
咱们全程没有拷贝任何字节,切出来的 header / payload 都指向原 buffer 内部。
字节视图:as_bytes / as_writable_bytes
处理二进制数据时,咱们常需要把 span<T> 当成原始字节看。std::as_bytes(s) 返回 span<const std::byte>,std::as_writable_bytes(s) 返回 span<std::byte>(仅当 T 非 const 时可用)。这对 CRC、序列化、内存 dump 这类“把结构当字节流”的场景很合适:
std::span<int> data = /* … */;
auto bytes = std::as_bytes(data); // span<const std::byte>,只读字节
// crc(bytes.data(), bytes.size());
咱们注意区分只读和可写:读用 as_bytes,要原地改字节用 as_writable_bytes(且底层 span 必须 non-const)。
生命周期:span 不拥有,悬垂就是未定义行为
span 最大的坑,也是它“非拥有”性质的必然代价:它不管理底层内存的生命周期。底层活多久,span 就最多活多久;底层没了,span 就是悬垂视图,咱们再访问它就是未定义行为。最经典的错误是 span 绑了一个临时对象,然后把它返回出去:
std::span<int> bad()
{
std::vector<int> v = {1, 2, 3};
return v; // v 在函数结束时销毁,返回的 span 立刻悬垂
}
调用方拿到这个 span 再访问,就是访问已释放内存。span 的生命周期不得超过它所指向的数据——只要咱们不把 span 绑到临时量、不把它存得比底层数据久,它就是安全的。
跑跑看:动态 vs 静态 extent 的 sizeof
前面说动态 extent 存两个字、静态 extent 只存指针,咱们跑跑看:
#include <span>
#include <iostream>
int main()
{
int arr[4] = {};
std::span<int> dyn; // 动态 extent:可默认构造(空 span)
std::span<int, 4> fixed(arr); // 静态 extent:必须绑定数据
std::cout << "sizeof(span<int>) = " << sizeof(dyn) << '\\n';
std::cout << "sizeof(span<int,4>) = " << sizeof(fixed) << '\\n';
std::cout << "sizeof(void*) = " << sizeof(void*) << '\\n';
return 0;
}
g++ -std=c++20 -O2 -o /tmp/span_sizeof /tmp/span_sizeof.cpp && /tmp/span_sizeof
sizeof(span<int>) = 16
sizeof(span<int,4>) = 8
sizeof(void*) = 8
(64 位平台,GCC 16.1.1。)动态 extent 是 16 字节(一个 8 字节指针加一个 8 字节 size),静态 extent 只有 8 字节(就一个指针,size 编译期已知,省掉了)。这就是静态 extent 的存储优势——在咱们大量传递 span 的场景(比如嵌入式里满地都是的 buffer 视图),省一半的字是有意义的。
延伸:嵌入式里的 span(DMA / 协议解析)
span 因为轻量、零拷贝、跨容器统一,在嵌入式里几乎是“现代版 buffer 指针”,笔者在这里补几个实战用法(主线之外,按需取用)。DMA 回调把数据放进固定 buffer 后,用 span 切片解析 header / payload,无需拷贝。从 Flash 读数据到缓冲区,可以用 span 切块处理。中断、实时路径里传小段数据,span 拷贝廉价(就两个字)。只要咱们守住“span 不拥有、不超底层生命周期”这条线,它就是裸指针的安全替代。
嗯?感觉跟string_view有点像,怎么分 span 和 string_view?
span 和 string_view 都是“非拥有视图”,分界看元素类型:span<T> 通用于任意元素类型(包括可写、包括 std::byte),string_view 专门给字符序列(只读、带字符串语义)。处理二进制 buffer / 任意类型数据用 span,处理文本用 string_view。把 span 记成“指针加长度的正式封装”就够了:传参统一、切片零拷贝,生命周期得咱们自己管好。
参考资源
- std::span — cppreference
- std::byte — cppreference
- P0122 span 提案 — open-std



