C++ 视图
C++ 里的“视图”不是某一个单独类型,而是一类共同思想:用一个轻量对象描述一段数据或一段计算,不拥有数据,尽量不复制,按需访问。它们把“我想看哪一段、以什么方式看”从“数据本身如何存储”里拆出来。
常见视图有三类:
| 类型 | 标准 | 作用 | 是否拥有元素 | 能否修改元素 |
|---|---|---|---|---|
std::string_view | C++17 | 只读地观察一段字符 | 否 | 否(只读) |
std::span | C++20 | 观察一段连续内存 | 否 | 是(span<T> 可写) |
std::ranges views | C++20/23 | 懒惰组合 range 操作 | 通常否 | 视情况而定 |
视图最大的价值是:少拷贝、接口更宽、组合更自然;最大的坑也正来自这里:它不延长底层数据生命周期。此外,可变视图是一把双刃剑——通过视图修改数据零开销、零复制,但也意味着任何持有视图的代码都能改动原数据,需要谨慎设计接口边界。这篇总结 C++ 中常见视图的知识、使用方式和避坑原则。Ranges 的整体体系可以看 C++ Ranges 完全指南,迭代器基础见 C++ 迭代器与 traits。
视图的核心语义
视图通常具备几个特征:
- 非拥有:只保存指针、迭代器、长度、函数对象等描述信息。
- 轻量可拷贝:复制视图一般只是复制几个字段,不复制元素。
- 按需访问:尤其是 ranges view,只有迭代时才真正执行计算。
- 双向数据通道:视图不只是“看”,可变视图(如
span<T>)还能写——通过视图修改元素,修改直接反映到底层数据,零复制。 - 引用外部数据:底层数据变了,视图看到的结果也会变;反过来,通过可变视图改了数据,原容器也立刻可见。
- 生命周期敏感:底层对象销毁后,视图会悬空。
可以把视图理解成“窗口”——但不是橱窗,而是可以伸手进去的窗口:
1 | |
s 没有自己的 int 数组,它只是看着 nums 的连续内存——但通过 s 可以直接改 nums 里的值。加 const 就变成只读窗口;string_view 天生只读,永远不能通过它修改字符。
std::string_view
std::string_view 的完整用法、陷阱和容器使用见 C++ 字符串。
string_view= 指针 + 长度,不拥有数据、零拷贝。它是最常用的字符串视图,也是理解所有视图类型的起点。
std::span
std::span<T> 定义在 <span>,表示一段连续内存。它可以观察 C 数组、std::array、std::vector、裸指针加长度等连续存储。
用 span 统一连续序列参数
1 | |
传统接口经常写成 const int* data, size_t size,调用者容易传错长度。span 把指针和长度绑在一起,语义更明确。
span<T> 可修改,span<const T> 只读
这是
span与string_view最本质的区别:string_view永远只读,而span<T>是可写的——通过它修改元素,修改直接作用于底层数据,零复制、零间接。
1 | |
一个实际场景——就地缩放图像像素:
1 | |
参数设计时可以按意图选择:
- 只读:
std::span<const T> - 需要修改元素:
std::span<T> - 需要拥有数据:
std::vector<T>、std::array<T, N>或自定义容器
静态长度 std::span<T, N>
span 的第二个模板参数可以指定编译期长度:
1 | |
std::span<T> 是动态长度,运行期保存 size;std::span<T, N> 是静态长度,长度是类型的一部分,通常可以少存一个字段,也能让接口表达更强约束。
span 只适合连续内存
std::list、std::map 这类节点式容器不能转成 span,因为它们的元素不连续:
1 | |
如果函数需要任意可迭代序列,用 range/concept;如果函数需要连续内存,用 span。
Ranges View
C++20 ranges 引入了更广义的 view 概念:一个 range 如果满足轻量、可移动、适合懒惰组合,就可以称为 view。标准库里常通过 std::views::* 构造这些视图,用 | 管道组合,迭代时才真正计算。
1 | |
even_squares 不拥有数据,只保存对 nums 的引用、谓词、函数对象和组合后的迭代逻辑。Ranges View 的具体用法——filter/transform/take/drop/reverse/keys/values 等 adaptor、views::all 与 ref_view/owning_view、subrange、borrowed_range、自定义 view,以及 lambda 捕获引用、长期缓存等生命周期陷阱——都已整理在 C++ Ranges 完全指南,这里不再重复。
view 的生命周期规则
视图最重要的规则只有一句:视图不能比它观察的数据活得更久。
不要返回指向局部变量的 view
1 | |
正确做法是返回拥有者:
1 | |
或者让调用者提供存储:
1 | |
这里返回的 view 指向参数 text 背后的数据,调用者需要保证原数据还活着。
容器重分配会让 view 失效
1 | |
只要底层容器发生会使迭代器、指针、引用失效的操作,依赖它们的 view 也会失效。vector 扩容、string 扩容、容器销毁都是典型情况。
Ranges View 的额外陷阱
Ranges View 还有两个典型生命周期陷阱——lambda 捕获引用局部变量后返回、长期缓存复杂 view——以及 borrowed_range 对悬空迭代器的类型层保护,详见 C++ Ranges 完全指南。
参数设计怎么选
字符串
| 需求 | 推荐类型 |
|---|---|
| 只读,不保存 | std::string_view |
| 需要保存 | std::string |
| 需要修改已有字符串 | std::string& |
| 需要 C API NUL 结尾 | std::string 或 const char* |
1 | |
连续数组
| 需求 | 推荐类型 |
|---|---|
| 只读连续元素 | std::span<const T> |
| 修改连续元素 | std::span<T> |
| 固定长度连续元素 | std::span<T, N> |
| 需要拥有动态长度 | std::vector<T> |
1 | |
任意 range
如果只要求能遍历,不要求连续内存,可以用模板 + ranges concept:
1 | |
如果需要多遍遍历,改用 std::ranges::forward_range;如果需要排序,通常需要 std::ranges::random_access_range 加可写元素。
实战建议
- 函数只读字符串:优先
std::string_view,但不要保存它。 - 函数处理连续内存:优先
std::span<T>/std::span<const T>。 - 数据流水线处理中间阶段:优先
std::views管道。 - 跨作用域保存结果:优先物化成拥有型容器。
- view 的 lambda 捕获:默认按值捕获小对象,避免返回后引用悬空。
- 性能敏感代码:记住 view 是懒惰的,重复遍历可能重复计算。
- 调试复杂管道:把中间 view 拆成几个命名变量,或临时
ranges::to<vector>观察结果。
总结
C++ 视图的关键不是“新容器”,而是“非拥有的观察方式”——而且不只是“看”,还能“改”:
string_view:只读地看一段字符。span:看并且可以改一段连续内存(span<T>可写,span<const T>只读)。- ranges view:看一个经过过滤、转换、截断、组合后的序列(详见 C++ Ranges 完全指南)。
它们让接口更通用、组合更轻、性能更可控;可变视图还让你就地修改数据,省去复制再写回的开销。但代价是你必须认真管理生命周期和可变性边界。判断一个 view 是否安全时,只问三个问题:
- 它指向的数据谁拥有?
- 拥有者是否比 view 活得更久?
- 底层数据在 view 使用期间是否会失效?
如果 view 是可变的,还要问第四个问题:
- 谁有权通过视图修改数据?——用
span<const T>限制只读,用span<T>表达可写意图,让接口自己说话。
这四个问题都答得清楚,view 就是现代 C++ 里非常好用的零开销抽象。







