欢迎光临
我们一直在努力

现代C++容器之span:非拥有的连续视图

现代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
赞(0)
未经允许不得转载:171主机测评 » 现代C++容器之span:非拥有的连续视图
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址