C++ 里的“视图”不是某一个单独类型,而是一类共同思想:用一个轻量对象描述一段数据或一段计算,不拥有数据,尽量不复制,按需访问。它们把“我想看哪一段、以什么方式看”从“数据本身如何存储”里拆出来。

常见视图有三类:

类型标准作用是否拥有元素能否修改元素
std::string_viewC++17只读地观察一段字符否(只读)
std::spanC++20观察一段连续内存span<T> 可写)
std::ranges viewsC++20/23懒惰组合 range 操作通常否视情况而定

视图最大的价值是:少拷贝、接口更宽、组合更自然;最大的坑也正来自这里:它不延长底层数据生命周期。此外,可变视图是一把双刃剑——通过视图修改数据零开销、零复制,但也意味着任何持有视图的代码都能改动原数据,需要谨慎设计接口边界。这篇总结 C++ 中常见视图的知识、使用方式和避坑原则。Ranges 的整体体系可以看 C++ Ranges 完全指南,迭代器基础见 C++ 迭代器与 traits

视图的核心语义

视图通常具备几个特征:

  • 非拥有:只保存指针、迭代器、长度、函数对象等描述信息。
  • 轻量可拷贝:复制视图一般只是复制几个字段,不复制元素。
  • 按需访问:尤其是 ranges view,只有迭代时才真正执行计算。
  • 双向数据通道:视图不只是“看”,可变视图(如 span<T>)还能——通过视图修改元素,修改直接反映到底层数据,零复制。
  • 引用外部数据:底层数据变了,视图看到的结果也会变;反过来,通过可变视图改了数据,原容器也立刻可见。
  • 生命周期敏感:底层对象销毁后,视图会悬空。

可以把视图理解成“窗口”——但不是橱窗,而是可以伸手进去的窗口

1
2
3
4
5
6
7
8
9
10
11
std::vector<int> nums = {1, 2, 3, 4, 5};
std::span<int> s{nums}; // window over nums

s[0] = 42; // modifies nums[0] directly!

// 对比只读视图
std::span<const int> cs{nums}; // read-only window
// cs[0] = 10; // error: cannot assign to const int

std::string_view sv = "hello";
// sv[0] = 'H'; // error: string_view is always const

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::arraystd::vector、裸指针加长度等连续存储。

span 统一连续序列参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <array>
#include <span>
#include <vector>

int sum(std::span<const int> values) {
int total = 0;
for (int value : values) {
total += value;
}
return total;
}

int main() {
int raw[] = {1, 2, 3};
std::array arr = {4, 5, 6};
std::vector vec = {7, 8, 9};

sum(raw);
sum(arr);
sum(vec);
}

传统接口经常写成 const int* data, size_t size,调用者容易传错长度。span 把指针和长度绑在一起,语义更明确。

span<T> 可修改,span<const T> 只读

这是 spanstring_view 最本质的区别string_view 永远只读,而 span<T> 是可写的——通过它修改元素,修改直接作用于底层数据,零复制、零间接。

1
2
3
4
5
6
7
8
9
10
11
void normalize(std::span<double> xs) {
for (double& x : xs) {
x /= 100.0; // 直接改底层容器中的值
}
}

void print_all(std::span<const double> xs) {
for (double x : xs) {
std::println("{}", x);
}
}

一个实际场景——就地缩放图像像素:

1
2
3
4
5
6
7
8
void brighten(std::span<uint8_t> pixels, int delta) {
for (uint8_t& p : pixels) {
p = static_cast<uint8_t>(std::min(255, p + delta));
}
}

std::vector<uint8_t> image{30, 60, 90, 120};
brighten(image, 50); // image 现在是 {80, 110, 140, 170}

参数设计时可以按意图选择:

  • 只读:std::span<const T>
  • 需要修改元素:std::span<T>
  • 需要拥有数据:std::vector<T>std::array<T, N> 或自定义容器

静态长度 std::span<T, N>

span 的第二个模板参数可以指定编译期长度:

1
2
3
4
5
6
void multiply_3x3(std::span<const float, 9> matrix) {
// exactly 9 elements
}

std::array<float, 9> m{};
multiply_3x3(m);

std::span<T> 是动态长度,运行期保存 size;std::span<T, N> 是静态长度,长度是类型的一部分,通常可以少存一个字段,也能让接口表达更强约束。

span 只适合连续内存

std::liststd::map 这类节点式容器不能转成 span,因为它们的元素不连续:

1
2
std::list<int> xs = {1, 2, 3};
// std::span<int> s{xs}; // error

如果函数需要任意可迭代序列,用 range/concept;如果函数需要连续内存,用 span

Ranges View

C++20 ranges 引入了更广义的 view 概念:一个 range 如果满足轻量、可移动、适合懒惰组合,就可以称为 view。标准库里常通过 std::views::* 构造这些视图,用 | 管道组合,迭代时才真正计算。

1
2
3
auto even_squares = nums
| std::views::filter([](int n) { return n % 2 == 0; })
| std::views::transform([](int n) { return n * n; });

even_squares 不拥有数据,只保存对 nums 的引用、谓词、函数对象和组合后的迭代逻辑。Ranges View 的具体用法——filter/transform/take/drop/reverse/keys/values 等 adaptor、views::allref_view/owning_viewsubrangeborrowed_range、自定义 view,以及 lambda 捕获引用、长期缓存等生命周期陷阱——都已整理在 C++ Ranges 完全指南,这里不再重复。

view 的生命周期规则

视图最重要的规则只有一句:视图不能比它观察的数据活得更久

不要返回指向局部变量的 view

1
2
3
4
std::string_view bad_name() {
std::string name = "Alice";
return name; // dangling
}

正确做法是返回拥有者:

1
2
3
std::string make_name() {
return "Alice";
}

或者让调用者提供存储:

1
2
3
4
std::string_view first_word(std::string_view text) {
auto pos = text.find(' ');
return text.substr(0, pos);
}

这里返回的 view 指向参数 text 背后的数据,调用者需要保证原数据还活着。

容器重分配会让 view 失效

1
2
3
4
5
std::vector<int> nums = {1, 2, 3};
std::span<int> s{nums};

nums.push_back(4); // may reallocate
s[0] = 10; // may be dangling

只要底层容器发生会使迭代器、指针、引用失效的操作,依赖它们的 view 也会失效。vector 扩容、string 扩容、容器销毁都是典型情况。

Ranges View 的额外陷阱

Ranges View 还有两个典型生命周期陷阱——lambda 捕获引用局部变量后返回、长期缓存复杂 view——以及 borrowed_range 对悬空迭代器的类型层保护,详见 C++ Ranges 完全指南

参数设计怎么选

字符串

需求推荐类型
只读,不保存std::string_view
需要保存std::string
需要修改已有字符串std::string&
需要 C API NUL 结尾std::stringconst char*
1
2
3
4
5
6
7
8
9
void parse(std::string_view text);       // read only

class Token {
public:
explicit Token(std::string text) : text_(std::move(text)) {}

private:
std::string text_; // owns
};

连续数组

需求推荐类型
只读连续元素std::span<const T>
修改连续元素std::span<T>
固定长度连续元素std::span<T, N>
需要拥有动态长度std::vector<T>
1
2
void write_samples(std::span<float> samples);
void analyze_samples(std::span<const float> samples);

任意 range

如果只要求能遍历,不要求连续内存,可以用模板 + ranges concept:

1
2
3
4
5
6
template <std::ranges::input_range R>
void print_all(R&& range) {
for (auto&& value : range) {
std::println("{}", value);
}
}

如果需要多遍遍历,改用 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 是否安全时,只问三个问题:

  1. 它指向的数据谁拥有?
  2. 拥有者是否比 view 活得更久?
  3. 底层数据在 view 使用期间是否会失效?

如果 view 是可变的,还要问第四个问题:

  1. 谁有权通过视图修改数据?——用 span<const T> 限制只读,用 span<T> 表达可写意图,让接口自己说话。

这四个问题都答得清楚,view 就是现代 C++ 里非常好用的零开销抽象。