C++ 范围循环与 Ranges 库:从 C++11 到 C++23 的演进

C++ 范围循环与 Ranges 库:从 C++11 到 C++23 的演进

前言

如果你写过 C++,一定用过这样的代码:

1
2
3
for (size_t i = 0; i < vec.size(); ++i) {
std::cout << vec[i] << std::endl;
}

或者用迭代器:

1
2
3
for (auto it = vec.begin(); it != vec.end(); ++it) {
std::cout << *it << std::endl;
}

这些写法没错,但不够优雅。C++11 引入的基于范围的 for 循环(Range-based for loop)让遍历容器变得前所未有的简洁。而 C++20 的 Ranges 库 则彻底改变了我们处理序列数据的方式。

本文将带你系统了解 C++ 在“范围”这一主题上的所有重要新特性,从 C++11 到 C++23,一路进化。


一、C++11:基于范围的 for 循环

1.1 基本语法

C++11 引入的基于范围的 for 循环,是传统 for 循环的“更具可读性的等效项”。它的语法非常直观:

1
2
3
for (declaration : range) {
// 循环体
}

其中 range 可以是数组、容器,或者任何提供了 begin()end() 的类型。

示例:

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

int main() {
std::vector<int> numbers = {1, 2, 3, 4, 5};

// 值拷贝
for (int n : numbers) {
std::cout << n << " ";
}
std::cout << std::endl;

// 引用(可修改原值)
for (int& n : numbers) {
n *= 2;
}

// const 引用(只读,避免拷贝)
for (const auto& n : numbers) {
std::cout << n << " ";
}
return 0;
}

1.2 与 auto 结合

auto 是范围 for 循环的最佳搭档,让代码更加简洁:

1
2
3
4
5
std::vector<std::string> words = {"Hello", "World", "C++"};

for (const auto& word : words) {
std::cout << word << std::endl;
}

编译器会自动推导出正确的类型,你无需关心元素的具体类型。

1.3 底层原理

基于范围的 for 循环在底层会被展开为类似这样的代码:

1
2
3
4
5
6
7
8
9
{
auto&& __range = range-initializer;
auto __begin = begin-expr;
auto __end = end-expr;
for ( ; __begin != __end; ++__begin) {
item-declaration = *__begin;
statement
}
}

理解这个展开有助于你明白:range-initializer 中的临时对象的生命周期会被延长至整个循环结束


二、C++17:结构化绑定 + 范围 for

C++17 允许在范围 for 循环的声明部分使用结构化绑定(Structured Binding)。

这意味着你可以直接解构容器中的元素,比如遍历 std::map 时直接拿到 key 和 value:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>
#include <map>
#include <string>

int main() {
std::map<std::string, int> scores = {
{"Alice", 95},
{"Bob", 87},
{"Charlie", 72}
};

// C++17: 结构化绑定直接解构 pair
for (const auto& [name, score] : scores) {
std::cout << name << ": " << score << std::endl;
}
return 0;
}

在 C++17 之前,你只能写成:

1
2
3
for (const auto& pair : scores) {
std::cout << pair.first << ": " << pair.second << std::endl;
}

结构化绑定让代码更加清晰、语义更加直接。

另外,C++17 还放宽了范围 for 循环中 beginend 的类型要求——它们不再必须是相同的类型。这为 Sentinel(哨位)机制奠定了基础,而 Sentinel 正是 C++20 Ranges 库的重要概念之一。


三、C++20:初始化语句

3.1 语法扩展

C++20 在范围 for 循环中引入了初始化语句(Init Statement)。新语法如下:

1
2
3
for (init-statement; declaration : range-initializer) {
// 循环体
}

3.2 使用场景

场景一:限制变量的作用域

在 C++20 之前,如果需要在循环中使用索引,必须在外部声明:

1
2
3
4
5
6
// C++20 之前
size_t i = 0;
for (auto& d : data) {
std::cout << i++ << ' ' << d << '\n';
}
// 循环结束后 i 仍然存在,可能被误用

C++20 可以将 i 的作用域限制在循环内部:

1
2
3
4
5
6
7
8
9
10
11
12
#include <array>
#include <string>

int main() {
std::array<std::string, 3> data = {"hello", ",", "world"};

for (std::size_t i = 0; auto& d : data) {
std::cout << i++ << ' ' << d << '\n';
}
// i 在这里已经不可访问
return 0;
}

场景二:安全地迭代临时对象

C++20 允许在循环中创建临时对象并迭代:

1
2
3
4
5
6
7
8
9
#include <iostream>
#include <string>

int main() {
for (std::string v = std::to_string(42); auto c : v) {
std::cout << c << '\n';
}
return 0;
}

场景三:避免未定义行为

当迭代的 range 是临时对象的成员时,C++20 的初始化语句可以避免未定义行为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Foo {
public:
const auto& items() const { return data; }
private:
std::vector<const char*> data{"hello", ",", "world"};
};

Foo getFoo() { return Foo(); }

int main() {
// 安全:foo 的生命周期覆盖整个循环
for (auto foo = getFoo(); auto& d : foo.items()) {
std::cout << d << '\n';
}
return 0;
}

如果没有初始化语句,直接对 getFoo().items() 进行迭代,可能会因为临时对象提前销毁而导致未定义行为。


四、C++20:Ranges 库

如果说范围 for 循环是小试牛刀,那么 C++20 的 Ranges 库 就是一场革命。

4.1 核心概念

Ranges 库是算法和迭代器库的扩展与泛化,通过使其可组合且不易出错,从而让功能更强大。

范围(Range):一个可迭代的序列。它不限于标准库容器,还可以是数组、输入输出流、指针区间,甚至是动态生成的序列。

视图(View):范围的轻量级表示,不拥有数据,只是提供一种观察数据的方式。视图的计算是惰性(Lazy)的——只有在实际迭代时才会进行计算。

4.2 范围工厂

Ranges 库提供了一系列范围工厂,用于创建各种范围:

工厂 说明
std::views::empty 空范围
std::views::single 单元素范围
std::views::iota 递增序列(如 0, 1, 2, ...
std::views::repeat 无限重复的序列
std::views::istream 从输入流读取

示例:使用 iota 生成序列

1
2
3
4
5
6
7
8
9
10
11
#include <iostream>
#include <ranges>

int main() {
// 生成 0 到 9 的序列
for (int i : std::views::iota(0, 10)) {
std::cout << i << " ";
}
// 输出: 0 1 2 3 4 5 6 7 8 9
return 0;
}

4.3 范围适配器

范围适配器(Range Adaptor)是 Ranges 库的核心武器,它们可以对范围进行各种变换,并且可以组合成管道(Pipeline)。

常用适配器包括:

适配器 说明
std::views::filter 筛选满足条件的元素
std::views::transform 对每个元素应用变换函数
std::views::take 取前 N 个元素
std::views::drop 丢弃前 N 个元素
std::views::reverse 反转
std::views::keys / values 取 map 的 key 或 value

示例:管道操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>
#include <vector>
#include <ranges>

int main() {
std::vector<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};

// 筛选偶数 → 乘以 2 → 取前 3 个
auto result = numbers
| std::views::filter([](int n) { return n % 2 == 0; })
| std::views::transform([](int n) { return n * 2; })
| std::views::take(3);

for (int n : result) {
std::cout << n << " "; // 输出: 4 8 12
}
return 0;
}

注意:所有这些操作都是惰性的。result 只是一个视图,实际的筛选、变换和截取操作只在 for 循环迭代时才发生。没有中间容器被创建,这带来了显著的性能优势。

4.4 范围算法

C++20 还在 std::ranges 命名空间中提供了算法的新版本,直接接受范围作为参数:

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

int main() {
std::vector<int> numbers = {5, 2, 8, 1, 9, 3};

// 旧方式:需要传递迭代器对
std::sort(numbers.begin(), numbers.end());

// C++20 新方式:直接传递范围
std::ranges::sort(numbers);

// 甚至可以结合视图进行条件排序
auto even = numbers | std::views::filter([](int n) { return n % 2 == 0; });
// 但注意:视图不一定可排序,视具体类型而定

return 0;
}

4.5 关于命名空间

C++20 提供了命名空间别名 std::views = std::ranges::views,所以你既可以写 std::ranges::views::filter,也可以简写为 std::views::filter。本文统一使用 std::views


五、C++23:Ranges 库的进一步扩展

C++20 的 Ranges 库已经很强大,但还有一些“拼图”缺失——比如同时遍历多个容器、获取元素索引等。C++23 填补了这些空白。

5.1 std::views::zip —— 同步遍历多个范围

std::views::zip 是 C++23 引入的范围适配器,它接受一个或多个视图,生成一个视图,其第 i 个元素是由所有视图的第 i 个元素组成的元组。

关键特性

  • 结果视图的长度等于最短的输入范围
  • 元素是引用std::tuple<T&, U&, ...>),修改会反映到原始数据
  • 完美支持结构化绑定
  • 惰性求值,不创建中间容器

示例:同时遍历两个向量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>
#include <vector>
#include <string>
#include <ranges>

int main() {
std::vector<std::string> names = {"Alice", "Bob", "Charlie"};
std::vector<int> scores = {95, 87, 72};

// C++23: 一行搞定,自动在最短处停止
for (const auto& [name, score] : std::views::zip(names, scores)) {
std::cout << name << " scored " << score << std::endl;
}
return 0;
}

对比 C++23 之前,你需要手动管理索引:

1
2
3
4
5
// 旧方式:手动索引,需要确保两个 vector 大小一致
auto size = std::min(names.size(), scores.size());
for (size_t i = 0; i < size; ++i) {
std::cout << names[i] << " scored " << scores[i] << std::endl;
}

zip 也支持两个以上的范围:

1
2
3
4
5
6
7
std::vector<int> ids = {101, 102, 103};
std::vector<std::string> names = {"Alice", "Bob", "Charlie"};
std::vector<double> scores = {95.5, 88.0, 92.3};

for (const auto& [id, name, score] : std::views::zip(ids, names, scores)) {
std::cout << "ID: " << id << ", Name: " << name << ", Score: " << score << std::endl;
}

如果调用 std::views::zip() 而不传入任何参数,它会生成一个空的 tuple 视图。

5.2 std::views::zip_transform —— 一步完成转换

zip_transformzip 的升级版——它接受一个可调用对象,直接将 zipped 的元素转换为结果,而不是产生元组。

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

int main() {
std::vector<double> prices = {10.5, 20.0, 30.75};
std::vector<int> quantities = {3, 5, 2};

// 直接计算总价,无需解包元组
auto totals = std::views::zip_transform(
[](double price, int qty) { return price * qty; },
prices, quantities
);

for (double total : totals) {
std::cout << total << " "; // 输出: 31.5 100 61.5
}
return 0;
}

zip_transform 等价于 zip(r1, r2) | transform(apply(f)),但更高效——避免构造中间的元组。

5.3 std::views::enumerate —— 带索引遍历

enumerate 将范围包装为 {index, value} 对的视图:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>
#include <vector>
#include <ranges>

int main() {
std::vector<std::string> fruits = {"apple", "banana", "cherry"};

for (const auto& [idx, fruit] : std::views::enumerate(fruits)) {
std::cout << idx << ": " << fruit << std::endl;
}
// 输出:
// 0: apple
// 1: banana
// 2: cherry
return 0;
}

每个元素是一个 std::tuple<difference_type, range_reference_t<R>>,在实践中可以用结构化绑定解构为 auto [idx, value]

5.4 std::views::join_with —— 带分隔符的拼接

join_with 将嵌套范围(如 vector<vector<int>>)扁平化,并在每个子范围之间插入指定的分隔符:

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

int main() {
std::vector<std::vector<int>> matrix = {
{1, 2},
{3},
{},
{4, 5}
};

// 用 -1 作为分隔符连接所有子范围
auto joined = matrix | std::views::join_with(-1);

for (int n : joined) {
std::cout << n << " ";
}
// 输出: 1 2 -1 3 -1 -1 4 5
return 0;
}

对于字符串集合也同样适用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <vector>
#include <string>
#include <ranges>
#include <iostream>

int main() {
std::vector<std::string> words = {"Hello", "World", "C++23"};

auto joined = words | std::views::join_with(' ');
for (char c : joined) {
std::cout << c;
}
std::cout << '\n'; // 输出: Hello World C++23
return 0;
}

5.5 std::views::chunk —— 分块处理

chunk 将范围按指定大小分块:

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

int main() {
std::vector<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8};

// 每 3 个一组
auto chunks = numbers | std::views::chunk(3);

for (const auto& chunk : chunks) {
std::cout << "[ ";
for (int n : chunk) {
std::cout << n << " ";
}
std::cout << "] ";
}
// 输出: [ 1 2 3 ] [ 4 5 6 ] [ 7 8 ]
return 0;
}

5.6 std::ranges::to —— 将视图转换为容器

C++23 还引入了 std::ranges::to,可以方便地将视图转换为具体的容器类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <vector>
#include <ranges>
#include <string>

int main() {
auto result = std::views::iota(1, 10)
| std::views::filter([](int n) { return n % 2 == 0; })
| std::views::transform([](int n) { return std::to_string(n); })
| std::ranges::to<std::vector>();

// result 是一个 std::vector<std::string>,包含 {"2", "4", "6", "8"}
return 0;
}

这在需要将视图的惰性计算结果“物化”为实际容器时非常有用。


六、最佳实践与注意事项

6.1 视图的生命周期

视图不拥有数据,只是数据的“观察者”。必须确保原始数据在视图使用期间保持有效

1
2
3
4
5
6
7
8
9
10
11
// ❌ 错误:vec 在视图使用前就被销毁了
auto get_view() {
std::vector<int> vec = {1, 2, 3};
return vec | std::views::filter([](int n) { return n > 1; });
}
// 返回的视图引用了已销毁的 vec!

// ✅ 正确:确保数据生命周期覆盖视图的使用
std::vector<int> vec = {1, 2, 3};
auto view = vec | std::views::filter([](int n) { return n > 1; });
// 在 vec 有效期间使用 view

6.2 避免过度组合

虽然视图组合很优雅,但过长的管道链可能增加编译时间和运行时开销。对于复杂操作,考虑适当拆分:

1
2
3
4
5
6
7
8
9
10
11
// 可能过于复杂
auto result = data
| views::filter(pred1)
| views::transform(func1)
| views::filter(pred2)
| views::transform(func2)
| views::take(n);

// 可以适当拆分,提高可读性
auto filtered = data | views::filter(pred1) | views::transform(func1);
auto result = filtered | views::filter(pred2) | views::transform(func2) | views::take(n);

6.3 选择合适的工具

场景 推荐方案
简单遍历单个容器 范围 for 循环
遍历时需索引 C++23: views::enumerate / C++20: 初始化语句
筛选 + 变换 views::filter + views::transform
同时遍历多个容器 views::zip
扁平化嵌套容器 views::join_with
分块处理 views::chunk
需要实际容器结果 ranges::to<T>()

七、编译器支持一览

特性 标准 GCC Clang MSVC
范围 for 循环 C++11
结构化绑定 C++17
初始化语句 C++20
Ranges 库 C++20 GCC 10+ Clang 13+ MSVC 19.28+
views::zip C++23 GCC 13+ Clang 15+ MSVC 19.34+
views::enumerate C++23 GCC 13+ Clang 15+ MSVC 19.34+
views::join_with C++23 GCC 14+ Clang 17+ MSVC 19.34+
ranges::to C++23 GCC 14+ Clang 17+ MSVC 19.34+

使用前请确认你的编译器版本支持相应特性。


总结

从 C++11 的基于范围的 for 循环,到 C++17 的结构化绑定,再到 C++20 的 Ranges 库和 C++23 的进一步完善,C++ 在“范围”这条路上越走越远:

  • C++11:用 for (auto x : range) 替代了繁琐的索引和迭代器循环
  • C++17:用结构化绑定让 map 等容器的遍历更加优雅
  • C++20:用 Ranges 库引入了视图、管道和范围算法,让数据处理变得声明式
  • C++23:用 zipenumeratejoin_withchunk 填补了最后的拼图

这些特性共同指向一个方向:让 C++ 代码更加简洁、安全、富有表达力。掌握它们,你的 C++ 编程体验将提升到一个全新的层次。


本文代码均可在支持相应 C++ 标准的编译器中编译运行。欢迎在评论区交流讨论!


C++ 范围循环与 Ranges 库:从 C++11 到 C++23 的演进
https://www.psnow.sbs/2026/07/07/C-范围循环与-Ranges-库:从-C-11-到-C-23-的演进/
作者
Psnow
发布于
2026年7月7日
许可协议