36、C++20 核心特性:std::span,数组的视图,安全且高效。

聊到 C++20 的 std::span,我第一反应就是:这玩意儿早该有了。你想想看,我们写 C++ 多少年了,传递一个数组或者连续内存块,要么用裸指针+长度,要么用 vector<T>&。前者容易越界,后者又绑死了容器类型。说白了,我们缺一个「不拥有数据、只提供视图」的东西。

std::span 就是干这个的。它不分配内存,不拷贝元素,只是给你一个对连续内存区域的「安全窗口」。我个人习惯把它叫做「数组的眼镜」——戴上它,你看数组的视野清晰了,还不用担心戳到眼睛。

什么是 std::span?

一句话:std::span<T> 是一个轻量级的、非拥有的连续内存视图。它内部只存了两个东西:一个指向首元素的指针,和一个长度。就这么简单。

它支持哪些数据源?

  • 原生数组:int arr[10]
  • std::array
  • std::vector
  • std::string(注意是连续的 char)
  • 任何满足 std::contiguous_iterator 的范围

嗯,这里要注意:std::span 不拥有数据,所以你不能通过它来扩容或删除元素。它就是个「只读/读写」的窗口。

为什么需要它?

我在项目中遇到过这样一个场景:一个函数需要处理来自不同来源的浮点数组。有时候是 std::vector<double>,有时候是 C 风格数组,有时候是 std::array。以前我怎么办?要么写三个重载,要么用 double* + size_t。前者代码膨胀,后者不安全。

std::span 完美解决了这个问题。它统一了接口,而且自带边界检查(如果你用 at()subspan() 的话)。

核心优势:

  • 零开销抽象:编译后就是指针+长度,没有虚函数,没有动态分配
  • 类型安全:知道元素类型,不像 void* 那样裸奔
  • 边界安全:提供 at()subspan() 等安全接口
  • 兼容性好:可以跟 C 接口无缝对接

基本用法

先看一个最简单的例子:

#include <span>
#include <vector>
#include <iostream>

void print_span(std::span<const int> s) {
    for (auto v : s) {
        std::cout << v << " ";
    }
    std::cout << "\n";
}

int main() {
    int arr[] = {1, 2, 3, 4, 5};
    std::vector vec = {10, 20, 30};
    std::array arr2 = {100, 200};

    print_span(arr);   // 原生数组
    print_span(vec);   // vector
    print_span(arr2);  // array
    return 0;
}

你看,同一个函数,三种不同的数据源,完美兼容。这就是 std::span 的魅力。

动态扩展与子视图

std::span 支持两种大小:

  • std::span<T>:动态大小,运行时确定
  • std::span<T, N>:固定大小,编译期确定

我个人更常用动态大小的,灵活。但如果你知道数组大小是编译期常量,固定大小的 span 可以带来更好的优化。

子视图操作也很实用:

void process_subspan(std::span<int> data) {
    // 取前3个元素
    auto first3 = data.first(3);
    // 取最后2个元素
    auto last2 = data.last(2);
    // 从索引1开始取4个元素
    auto middle = data.subspan(1, 4);
}

我曾经在写一个滑动窗口算法时,就用 subspan 来避免拷贝数据。性能提升很明显,因为省掉了大量的内存分配。

避坑指南

嗯,这里我要说几个我踩过的坑:

我曾经犯过的错误:

  • 悬垂引用:std::span 不拥有数据,所以如果原始数据被销毁了,span 就变成了野指针。我曾经在函数里返回了一个指向局部 vectorspan,结果调用方拿到的是垃圾数据。
  • 修改数据源:如果你通过 span 修改了元素,原始容器也会变。这不是 bug,是特性。但如果你没意识到这一点,可能会出问题。
  • 不要存储 span:除非你非常确定原始数据的生命周期。我建议只在函数参数中使用 span,不要把它作为类成员长期持有。

与 C 接口的互操作

这个太实用了。很多时候我们需要调用 C 库函数,它们通常接受 T* + size_tstd::span 提供了 data()size() 方法,可以直接传进去:

// 假设有个 C 函数
void c_process(double* data, size_t len);

void modern_process(std::span<double> data) {
    // 直接传给 C 函数
    c_process(data.data(), data.size());
}

你看,桥接 C 和 C++ 代码变得非常优雅。我个人在重构一个遗留系统时,就用 span 逐步替换了裸指针参数,安全多了。

性能对比

很多人担心 std::span 有性能开销。其实完全不用担心。我们来看一个简单的对比:

方式 传参开销 边界安全 类型安全 灵活性
T* + size_t 两个寄存器
const vector<T>& 一个引用 低(只接受 vector)
std::span<T> 两个寄存器 有(at/subspan) 高(接受多种容器)

说白了,std::span 在性能上和裸指针方案一样快,但安全性和灵活性高得多。这就是我为什么说它是「零开销抽象」的典范。

知识体系图

下面这张图展示了 std::span 在整个 C++ 连续内存视图体系中的位置:

std::span 知识体系 std::span<T> 原生数组 std::vector std::array C 风格指针 first() / last() subspan() data() / size() ⚠ 注意:不拥有数据,小心悬垂引用

总结

std::span 是 C++20 中一个看似简单但极其实用的工具。它解决了 C++ 长期以来的一个痛点:如何安全、高效地传递连续内存的视图。我个人建议,从现在开始,凡是需要传递数组或连续内存块的函数参数,优先考虑 std::span。它会让你的代码更安全、更通用、更现代。

我的建议:

  • std::span<const T> 表示只读视图
  • std::span<T> 表示可读写视图
  • 避免存储 span 作为长期持有的成员
  • 在性能敏感的代码中放心使用,它和裸指针一样快

嗯,关于 std::span 就聊到这里。记住,它不拥有数据,它只是帮你「看」数据。用好了,你的代码会变得更安全、更清晰。

专注资料整理