Skip to main content

Chapter 44: Span

C++20 的 std::span 把“连续内存的一段可见范围”提升成一个标准库类型。它来自头文件 <span>,核心状态可以理解为起始地址和元素个数;cppreference 的 std::span 页面 也把它描述为指向连续对象序列的对象。读完本章后,读者应能判断一个函数参数该使用 std::spanstd::vector&std::array& 还是原始指针加长度,并能追踪 span 绑定到底层存储后的生命周期风险。

本章的贯穿材料是一个音频处理函数:调用方可能把样本放在 std::vector<float>std::array<float, N> 或 C 接口给出的 float* 缓冲区中,处理函数只关心一段连续的 float。这个场景里,std::span<float> 表示可修改视图,std::span<const float> 表示只读视图,底层内存的所有权仍由调用方持有。

std::span 的价值在于把“地址”和“长度”绑定到同一个参数里。原始指针参数只能表达起点,长度需要另一个参数配合;容器引用参数又会把接口绑定到某个容器类型。span 处在二者之间:它保留连续内存的低成本访问路径,又让接口脱离具体容器所有者。

本章会按五个问题展开:span 如何表达非拥有视图,dynamic extentstatic extent 如何影响类型,spanvectorarray、raw pointer 的边界在哪里,生命周期风险如何出现,工程代码中怎样把 span 放在合适的位置。

44.1 non-owning contiguous memory view

std::span<T, Extent> 是连续内存视图。这里的“视图”指一个轻量对象,它记录一段已有元素的可见范围,并通过 data()size()、迭代器和下标访问把这段范围暴露给算法。这里的“non-owning”指 span 不分配内存,不构造元素,也不销毁元素;它只引用已经存在的对象序列。

在音频样本处理中,函数的真实需求是“拿到一段连续 float 并逐个修改”。这个需求和样本来自 vectorarray 还是 C 缓冲区没有直接关系。使用 std::span<float> 可以把函数接口收敛到连续范围本身。

#include <algorithm>
#include <array>
#include <cstddef>
#include <span>
#include <vector>

void clamp_samples(std::span<float> samples)
{
for (float& sample : samples) {
sample = std::clamp(sample, -1.0f, 1.0f);
}
}

float peak_value(std::span<const float> samples)
{
float peak = 0.0f;
for (float sample : samples) {
peak = std::max(peak, sample < 0.0f ? -sample : sample);
}
return peak;
}

int main()
{
std::vector<float> frame{-1.2f, 0.1f, 0.8f, 1.4f};
std::array<float, 4> fixed{-0.5f, 0.2f, 0.9f, 1.1f};
float c_buffer[4]{0.3f, -0.7f, 1.6f, 0.0f};

clamp_samples(std::span<float>{frame});
clamp_samples(std::span<float>{fixed});
clamp_samples(std::span<float>{c_buffer});
clamp_samples(std::span<float>{c_buffer, 4});

float peak = peak_value(std::span<const float>{frame});
(void)peak;
}

这段代码展示了 span 的接口角色。clamp_samples 修改元素,所以参数类型是 std::span<float>peak_value 只读取元素,所以参数类型是 std::span<const float>const 放在元素类型上,表达视图透出的元素是否可修改;const std::span<float> 只会限制 span 对象自身重新绑定,仍可能通过它修改元素。

span 对连续性的要求来自它的访问模型。data() 返回底层连续存储的起始地址,size() 返回元素个数,operator[] 按偏移访问元素。std::vectorstd::array 和原生数组满足这个模型;std::liststd::forward_list 这类节点容器的元素分散在不同节点中,不能形成 span 所需的单段连续地址区间。

常见实现形状可以把动态长度 span 看成“指针加长度”。静态长度 span 通常只需要保存指针,因为长度已经写进类型参数。这个形状解释了 span 适合作为函数参数按值传递:复制 span 只复制视图状态,不复制底层元素。标准语义保证的是视图行为和复杂度边界,具体对象布局仍以实现为准;C++23 起所有 std::span 特化被要求为 trivially copyable。

44.2 dynamic extent 与 static extent

Extentstd::span<T, Extent> 的第二个模板参数。std::dynamic_extent 表示长度在运行期保存;具体数字表示长度在编译期成为类型的一部分。二者访问同一类连续内存,但接口表达的约束不同。

动态长度适合外部输入、文件内容、网络包、可变容器和 C API 缓冲区。音频帧通常来自设备或解码器,样本数随配置变化,因此处理入口常写成 std::span<float>,等价于 std::span<float, std::dynamic_extent>

#include <span>

void apply_gain(std::span<float> samples, float gain)
{
for (float& sample : samples) {
sample *= gain;
}
}

这个接口的约束在运行期检查。函数可以读 samples.size() 决定循环边界,也可以拒绝空范围或长度异常的范围。调用方提供的长度进入 span 后,地址和长度保持同步传递,减少了指针参数和长度参数分离后产生的调用错误。

静态长度适合协议头、固定块、SIMD 前置条件、固定维度小数组等场景。长度进入类型后,函数签名本身就表达了调用前提。

#include <algorithm>
#include <array>
#include <span>

void normalize_stereo_frame(std::span<float, 2> left_right)
{
float peak = std::max(left_right[0] < 0.0f ? -left_right[0] : left_right[0],
left_right[1] < 0.0f ? -left_right[1] : left_right[1]);
if (peak > 1.0f) {
left_right[0] /= peak;
left_right[1] /= peak;
}
}

int main()
{
std::array<float, 2> stereo{1.4f, -0.7f};
normalize_stereo_frame(std::span<float, 2>{stereo});
}

std::span<float, 2>std::span<float> 是不同类型。静态长度让模板重载、概念约束和编译期判断能看见长度;动态长度把长度留在对象状态里。工程上应先判断长度是否是接口语义的一部分:如果长度错误代表调用者违反协议,静态长度更清晰;如果长度来自数据本身,动态长度更自然。

静态长度不等于拥有固定数组。std::span<float, 2> 仍然只是视图,底层元素可以来自 std::array<float, 2>、原生数组或某个更大缓冲区的子区间。它把“这次可见范围长度为 2”写进类型,却不接管元素生命周期。

44.3 与 vector、array、raw pointer 的关系

spanvectorarray、raw pointer 的区别应按同一组维度判断:谁拥有元素、长度在哪里、内存是否连续、元素是否可修改、修改底层存储后视图是否仍有效。这个判断能防止把 span 当容器,也能防止继续把所有连续内存接口写成指针加长度。

对象所有权长度信息连续性典型接口角色
std::vector<T>拥有动态元素和容量size() / capacity()元素连续需要增长、缩短、分配和持有数据时使用
std::array<T, N>拥有固定数量元素N 在类型中元素连续表达固定大小对象布局时使用
T*只表达地址需要额外参数或约定由调用方保证跨 C 接口、底层 ABI、可空指针语义明显时使用
std::span<T>引用外部元素size() 在视图对象中要求连续函数只处理一段连续元素时使用

std::vector<T>& 参数会把函数绑定到动态数组容器。函数如果需要调用 push_backresizereserve 或接管容量策略,使用 vector& 是合理选择;函数如果只遍历、读取或原地修改已有元素,span 能让 vectorarray 和 C 缓冲区共用同一实现。

#include <span>
#include <vector>

void append_silence(std::vector<float>& samples, std::size_t count)
{
samples.insert(samples.end(), count, 0.0f);
}

void scale_in_place(std::span<float> samples, float factor)
{
for (float& sample : samples) {
sample *= factor;
}
}

append_silence 需要改变容器长度和可能触发重新分配,所以它接收 std::vector<float>&scale_in_place 只修改已有元素,所以它接收 std::span<float>。这两个签名把“容器管理”和“范围处理”分开,调用方也能从类型上看出函数是否会改变元素数量。

std::array<T, N>& 参数适合表达对象本身具有固定布局。例如一个协议头就是 16 字节,一个颜色向量就是 4 个通道。std::span<T, N> 则适合表达“当前函数需要长度为 N 的连续窗口”。前者强调拥有者类型,后者强调可见范围约束。

raw pointer 仍然有使用位置。C API、系统调用、硬件 DMA、旧代码 ABI 通常以 T* 加长度传递缓冲区。C++ 层可以在边界处立即把它转换成 span,让后续处理代码使用统一范围模型。

extern "C" void read_samples(float* buffer, std::size_t count);

void process_from_c_api(float* buffer, std::size_t count)
{
read_samples(buffer, count);
std::span<float> samples{buffer, count};
clamp_samples(samples);
}

这个转换点的意义在于固定调用边界。C 接口仍保留原始指针形态,C++ 内部逻辑开始使用带长度的范围对象。工程代码应把 raw pointer 的扩散范围压缩在边界层,后续函数通过 span 接收连续内存。

44.4 生命周期问题

span 的生命周期判断分成两层:span 对象自身的生命周期,以及它引用的底层存储生命周期。span 可以被复制、返回和传递,但这些动作只复制视图状态;底层存储仍由 vectorarray、原生数组、内存池或外部系统管理。只要底层存储结束生命周期或被重新分配,已有 span 就可能悬垂。

最直接的错误是返回指向局部对象的 span。局部 vector 在函数返回时析构,元素存储随之释放,返回的 span 只剩下失效地址和旧长度。

#include <span>
#include <vector>

std::span<float> make_bad_samples()
{
std::vector<float> local{0.1f, 0.2f, 0.3f};
return std::span<float>{local};
}

这段代码的问题不在 span 的复制成本,而在所有者 local 的生命周期。判断顺序应从所有者开始:先找底层元素由谁持有,再看这个所有者是否覆盖 span 的使用区间,最后看使用期间是否发生会使指针失效的容器操作。

第二类风险来自 vector 重新分配。span 绑定到 vector 当前存储后,vector::push_backreserveresize 等操作可能改变底层地址。地址改变后,旧 spandata() 仍是原地址。

#include <span>
#include <vector>

void reallocation_risk()
{
std::vector<float> samples{0.1f, 0.2f, 0.3f};
std::span<float> view{samples};

samples.push_back(0.4f);

// view 的可用性取决于 push_back 是否使 samples 重新分配。
// 工程代码应在容量改变后重新创建 view。
view = std::span<float>{samples};
}

这个例子里,push_back 是否重新分配取决于当前容量。稳定写法是在改变容量、长度或所有者存储后重新创建 span,并把旧视图视为失效。对 array 和原生数组来说,元素地址在对象生命周期内稳定;风险集中在对象生命周期结束、数组越界窗口和外部系统释放缓冲区。

第三类风险来自长期保存 span。把 span 存进类成员意味着该类依赖外部所有者存活,并依赖外部所有者的存储不发生失效操作。这样的类型需要把所有者关系写在构造约定或类型设计里;如果对象需要独立持有数据,应存储 vectorarray 或拥有式缓冲区。

class SampleWindow {
public:
explicit SampleWindow(std::span<const float> samples)
: samples_(samples)
{
}

float first() const
{
return samples_.empty() ? 0.0f : samples_[0];
}

private:
std::span<const float> samples_;
};

SampleWindow 的成员 samples_ 使对象变成外部缓冲区的观察者。它适合短生命周期分析窗口、解析上下文和算法临时对象;它不适合作为异步任务、缓存项或跨线程长期数据载体,除非另一个明确所有者覆盖全部使用期。

C++20 ranges 中 std::span 满足 view 和 borrowed range。这个事实说明 span 对象本身轻量,迭代器可以脱离 span 对象继续表示同一底层范围;它不延长底层元素生命周期。生命周期安全仍然由所有者对象、存储失效规则和调用约定共同决定。

44.5 工程使用场景

span 最适合作为连续数据处理函数的参数。函数只需要读取或原地修改一段已有元素时,span 能把接口从容器类型中解耦出来,并让长度信息跟随地址一起进入函数。音频处理、图像像素、网络包、序列化缓冲区、数值数组和临时切片都属于这个范围。

函数参数的可变性应由元素类型表达。std::span<const std::byte> 表示只读字节视图,适合校验、哈希、解析;std::span<std::byte> 表示可写字节视图,适合填充、编码、解密和原地变换。把 span 对象本身设为 const 只能限制重新绑定,不能表达只读元素。

#include <cstddef>
#include <cstdint>
#include <span>

std::uint32_t checksum(std::span<const std::byte> bytes)
{
std::uint32_t value = 0;
for (std::byte byte : bytes) {
value = value * 131u + static_cast<unsigned char>(byte);
}
return value;
}

void zero_fill(std::span<std::byte> bytes)
{
for (std::byte& byte : bytes) {
byte = std::byte{0};
}
}

切片是 span 的另一个核心场景。firstlastsubspan 可以从一个大缓冲区中派生小视图,派生视图仍指向原始存储。这让解析代码可以逐段推进,同时保持零拷贝。

#include <cstddef>
#include <span>

struct PacketParts {
std::span<const std::byte> header;
std::span<const std::byte> payload;
};

PacketParts split_packet(std::span<const std::byte> packet)
{
constexpr std::size_t header_size = 8;
if (packet.size() < header_size) {
return {packet, {}};
}
return {packet.first(header_size), packet.subspan(header_size)};
}

这个 split_packet 没有复制字节,也没有分配内存。它只把同一段外部存储划分成两个可见范围。调用方必须保证 packet 的底层存储覆盖 PacketParts 的使用期;如果需要把结果长期保存,返回拥有式对象或复制必要字节更合适。

C 接口适配时,span 适合放在边界内侧。外部函数仍接受 void*char*std::byte*T* 加长度,C++ 业务层立即封装成 span,后续函数按范围处理。这样可以把空指针、长度单位、字节数和元素数的转换集中处理。

工程判断顺序可以固定为五步。第一步,确认函数是否需要拥有或改变元素数量;需要时选择拥有式容器引用或返回拥有式对象。第二步,确认底层内存是否连续;连续时才考虑 span。第三步,确认元素可变性;只读用 std::span<const T>,可写用 std::span<T>。第四步,确认长度是运行期数据还是编译期协议;前者用动态长度,后者考虑静态长度。第五步,确认所有者生命周期和失效操作;跨越所有者生命周期或容量改变后重新建立视图。

按这个顺序,span 的工程定位很清楚:它是函数边界上的连续范围参数,是切片和零拷贝处理的工具,是 raw pointer 加长度向类型安全范围过渡的桥。它不替代拥有式容器,也不替代生命周期设计;它让已有内存以更明确的范围形式进入算法。

最小自检任务

阅读下面代码,判断 sum_prefixview_after_resizestored_window 三个位置各自应该如何处理 span。要求说明:所有者是谁,长度信息在哪里,哪个操作会影响视图可用性,哪个函数签名更适合表达只读或可写范围。

#include <array>
#include <span>
#include <vector>

float sum_prefix(std::span<const float> samples)
{
float total = 0.0f;
for (float sample : samples.first(2)) {
total += sample;
}
return total;
}

struct StoredWindow {
std::span<const float> view;
};

void demo()
{
std::vector<float> dynamic{1.0f, 2.0f, 3.0f};
std::array<float, 3> fixed{4.0f, 5.0f, 6.0f};

float a = sum_prefix(std::span<const float>{dynamic});
float b = sum_prefix(std::span<const float>{fixed});

std::span<float> view_after_resize{dynamic};
dynamic.push_back(4.0f);

StoredWindow stored_window{std::span<const float>{dynamic}};
(void)a;
(void)b;
(void)view_after_resize;
(void)stored_window;
}

答案要点

sum_prefix 只读取元素,所以 std::span<const float> 合适。它同时接受 vectorarray,长度信息在 spansize() 中;first(2) 要求调用前确认范围至少有 2 个元素,生产代码应在切片前检查 samples.size()

view_after_resize 的所有者是 dynamicdynamic.push_back(4.0f) 可能触发重新分配,旧视图的地址和长度都来自修改前的存储状态。容量或长度变化后应重新创建 std::span<float>{dynamic},后续处理使用新视图。

stored_windowspan 保存进对象成员,表示该对象观察 dynamic 的外部存储。这个设计只适合 StoredWindow 的使用期短于 dynamic 且期间不触发 dynamic 存储失效操作的场景。若 StoredWindow 需要跨函数长期保存数据,应改为持有 std::vector<float> 或复制出稳定缓冲区。

本章知识点总结

  • span 定位std::span 表达一段已有连续元素的可见范围,函数可以通过它脱离具体容器类型。
  • 非拥有视图span 不分配内存、不构造元素、不销毁元素,底层存储仍由外部所有者管理。
  • 连续约束span 依赖 data()size() 描述单段连续地址区间,节点式容器无法直接形成这种视图。
  • 元素 const:只读范围应写成 std::span<const T>const std::span<T> 只限制视图对象重新绑定。
  • 动态长度std::span<T> 在对象状态中保存运行期长度,适合外部输入、可变容器和 C 缓冲区。
  • 静态长度std::span<T, N> 把长度写入类型,适合固定协议、固定窗口和编译期可见的长度约束。
  • 容器边界:函数需要增长、缩短或管理容量时使用容器引用,函数只处理已有连续元素时使用 span
  • 指针边界:raw pointer 适合 C 接口和 ABI 边界,C++ 内部处理应尽早转换成带长度的 span
  • 切片模型firstlastsubspan 派生出的视图仍指向原始存储,切片本身不复制元素。
  • 生命周期起点:判断 span 安全性时先找底层元素所有者,再检查所有者是否覆盖视图使用期。
  • 失效来源vector 的容量变化可能使旧 span 悬垂,容量或长度变化后应重新建立视图。
  • 成员保存:把 span 存入对象成员会建立外部存储依赖,长期保存数据时应使用拥有式对象。
  • 工程顺序:选择 span 前依次检查所有权、连续性、可变性、长度约束和生命周期失效规则。