非拥有视图与 std::span——不拷贝也能看数据
概念介绍
在 C++ 程序里,我们无时无刻不在和数据打交道,而数据总是要住在某个地方的。std::vector 在堆上分配内存来持有元素,std::string 拥有它自己的字符缓冲区,std::array 在栈上或者对象内部持有固定数量的元素。这些类型有一个共同点:它们拥有数据。当它们被销毁的时候,数据也跟着被释放。我们把这种类型叫做"拥有型类型"(owning types)。
但在很多场景下,我们并不需要拥有一份数据,我们只是想看一眼。比如你有一个 vector<int> 里面存了一百万个整数,你写了一个函数来统计其中的正数个数,你总不能把这100万个整数拷贝一份传给统计函数吧?你只需要告诉函数"数据在那块内存里,长度是这么多,你自己去读就好了"。这就是非拥有视图(non-owning view)的核心思想——我不拥有数据,我只是提供一个观察数据的窗口。
C++17 引入的 std::string_view 就是这个理念的代表作,它本质就是一个 const char* 指针加上一个长度,可以指向任何连续的字符序列——std::string、C 风格字符串、std::array<char, N> 都行——而不需要拷贝任何数据。C++20 进一步把这个理念推广到了通用类型,引入了 std::span<T>,它是一个"指向连续元素序列的非拥有视图",可以看作 std::string_view 的泛化版本。
std::span<T> 的内部结构非常简单:一个指向首元素的指针 T*,加上一个元素数量 size_t。就这两样东西,没有额外的堆分配,没有引用计数,没有原子操作。你可以把它理解为一个"安全的裸指针+长度"组合体——它比裸指针安全,因为你总是知道边界在哪里;它比 vector 轻量,因为它根本不管理内存。
动机
让我们通过一个具体的例子来理解为什么视图如此重要。假设我们在写一个图像处理函数,需要处理存储在连续内存中的像素数据:
// 不好的设计:拷贝数据
void process_pixels(std::vector<uint8_t> pixels); // 按值传递,深拷贝!
// 稍好一点但语义不清
void process_pixels(const std::vector<uint8_t>& pixels); // 只能接受 vector
// 好的设计:视图,零拷贝,接受任何连续内存
void process_pixels(std::span<const uint8_t> pixels);第一个版本是灾难——按值传递 vector 会深拷贝所有元素。第二个版本虽然避免了拷贝,但它只能接受 std::vector,如果你的数据来自 std::array 或者 C 数组,就得先转成 vector。第三个版本用 std::span<const uint8_t> 做视图,零拷贝,而且可以接受任何提供连续内存的容器——vector、array、C 数组统统没问题。
视图还有一个重要的语义优势:它让所有权关系变得清晰。当你看到一个函数接受 std::span<const T> 参数时,你立刻就知道这个函数只是要读取数据,不会修改也不会释放。而如果一个函数接受 std::span<T>(注意没有 const),你就知道它可能要修改数据。这种从类型签名就能推断出的语义信息,对于代码的可读性和可维护性非常有帮助。
最小示例:一个使用 span 的数据处理函数
让我们构建一个完整的示例,展示 std::span 的各种用法和需要注意的地方。
#include <span>
#include <vector>
#include <array>
#include <iostream>
#include <numeric>
// 一个只读视图函数:计算均值
double compute_mean(std::span<const double> data) {
if (data.empty()) return 0.0;
double sum = std::accumulate(data.begin(), data.end(), 0.0);
return sum / static_cast<double>(data.size());
}
// 一个可变视图函数:就地归一化
void normalize_inplace(std::span<double> data) {
double mean = compute_mean(data); // OK: span<double> 可以转为 span<const double>
for (auto& v : data) {
v -= mean;
}
}注意看 normalize_inplace 接受的是 std::span<double>(可变视图),而它内部调用 compute_mean 时传的是 std::span<const double>(只读视图)。这种转换是隐式的、安全的——从"能改的指针"变成"只读的指针"天经地义,但反过来不行。这就像 C++ 里 T* 到 const T* 的隐式转换一样。
现在我们来看看 std::span 可以绑定到哪些东西上:
int main() {
// 绑定到 vector
std::vector<double> vec = {1.0, 2.0, 3.0, 4.0, 5.0};
std::span<const double> sv = vec; // OK
std::cout << compute_mean(sv) << '\n'; // 输出 3.0
// 绑定到 array
std::array<double, 3> arr = {10.0, 20.0, 30.0};
std::cout << compute_mean(arr) << '\n'; // 输出 20.0
// 绑定到 C 数组
double c_arr[] = {100.0, 200.0};
std::cout << compute_mean(c_arr) << '\n'; // 输出 150.0
// 可变视图
std::span<double> mut_view = vec;
normalize_inplace(mut_view);
// 现在 vec 里的值是 {-2, -1, 0, 1, 2}
return 0;
}std::span 还有一个模板版本 std::span<T, N>,其中 N 是编译期已知的元素数量。这种固定大小的 span 可以在编译期检查维度匹配,相当于一个类型安全的 T(&)[N] 引用。不过在大多数场景下,我们用的都是动态大小的 std::span<T>(即 N 为 std::dynamic_extent),因为数据的长度往往在运行时才知道。
⚠️ 使用视图最重要的注意事项是生命周期。视图不拥有数据,所以当数据的拥有者被销毁后,视图就变成了悬空引用(dangling view),继续使用它就是未定义行为。这是视图最危险的陷阱:
std::span<const int> dangerous() {
std::vector<int> temp = {1, 2, 3};
return temp; // temp 在函数返回时被销毁
// 返回的 span 指向已经被释放的内存!
}
auto view = dangerous();
// view 是悬空的!使用它是未定义行为这种错误和返回局部变量的引用一样致命,而且因为 span 看起来像一个值类型(它确实可以廉价拷贝),人们更容易忽视它的生命周期依赖。一个好的实践原则是:视图的生命周期永远不要超过它所指向的数据——最安全的做法是让视图只作为函数参数存在,不要把它存到成员变量或者全局变量里,除非你非常清楚数据的生命周期。
与 edgecv 的关联
edgecv 库中最重要的视图类型是 ImageView<F> 和 ImageMutView<F>,它们就是为图像数据设计的非拥有视图。和 std::span 一样,它们内部只持有一个指针加上维度信息,不拥有任何像素数据:
template <is_pixel_format F> class ImageView {
public:
ImageView(const value_type* data, int width, int height, size_t stride)
: data_(data), width_(width), height_(height), stride_(stride) {}
// ...
private:
const value_type* data_; // 只是一个指针
int width_;
int height_;
size_t stride_;
};ImageView<F> 相当于 std::span<const T> 的图像版本——它只能读取像素数据,不能修改。ImageMutView<F> 相当于 std::span<T>——它可以读写像素数据。而 Image<F> 则是拥有型类型,它在内部持有一个 cv::Mat 来管理实际的像素内存。
Image<F> 提供了 view() 方法来获取 ImageView,mutable_view() 来获取 ImageMutView,就像你可以从 vector 构造 span 一样。这个设计让所有权关系非常清晰:Image 拥有数据,ImageView 只是观察数据。在实际使用中,当你需要把图像传给一个只读算法时,你应该传 ImageView;当你需要就地修改图像时,你应该传 ImageMutView 或直接传 Image 的引用。
在嵌入式场景下,这个区分更加重要。edgecv 的 DmaFrame 结构体代表从 V4L2 摄像头 DMA 缓冲区获取的一帧数据——它不拥有内存,内存属于内核的 DMA 缓冲区。DmaFrame 提供了 as_view<F>() 方法把它转成 ImageView<F>,这样算法函数就可以直接在 DMA 缓冲区上操作,不需要拷贝到用户空间的 Image 里:
// 嵌入式场景下的零拷贝操作
auto frame_result = camera.capture();
if (!frame_result) { /* 处理错误 */ }
auto& frame = frame_result.value();
// 直接在 DMA 缓冲区上创建视图,零拷贝
auto gray_view = frame.as_view<Gray>();
// 或者零拷贝转为 BGR 视图
auto bgr_view = frame.as_view<BGR>();这种设计在资源受限的嵌入式环境中非常关键——你可能只有几 MB 的内存,根本不允许拷贝完整的图像帧。通过视图机制,edgecv 实现了从摄像头采集到算法处理到屏幕输出的全链路零拷贝。理解 std::span 的设计理念,就能立刻理解 ImageView 和 ImageMutView 的工作方式——它们是同一个思想在不同领域的应用。