32、C++20 核心特性:范围(Ranges)库,声明式编程处理集合

说实话,我第一次看到 Ranges 库的时候,心里想的是:「又来了,C++ 又要加新语法了。」

但真正用起来之后,我发现这东西确实不一样。它让处理集合的方式,从「怎么干」变成了「要什么」。说白了,就是声明式编程。

为什么需要 Ranges?

你想想看,以前我们处理一个 vector,要写多少循环?

std::vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
std::vector<int> result;

for (auto x : v) {
    if (x % 2 == 0) {
        result.push_back(x * 2);
    }
}

这段代码没什么问题,但读起来你得在脑子里「模拟执行」一遍。我有个同事,每次看到这种嵌套逻辑就头疼,尤其是循环里套循环的时候。

Ranges 要解决的就是这个问题。它让你用管道操作符 | 把操作串起来,像流水线一样。

auto result = v 
    | std::views::filter([](int x) { return x % 2 == 0; })
    | std::views::transform([](int x) { return x * 2; });

看到区别了吗?代码从左到右读,意思一目了然:先过滤出偶数,再每个乘以2。没有临时变量,没有循环,没有 push_back。

核心思想:Ranges 把「数据」和「操作」分开了。数据是集合,操作是视图(views)。视图不拥有数据,只是对数据的「视角」。

视图(Views)—— 懒求值的核心

Ranges 里最重要的概念就是视图。视图不会复制数据,也不会立即计算。它只是记录了你想要做什么,等到真正需要结果的时候才执行。

我刚开始学的时候,总觉得这有点玄乎。直到我在项目中处理一个 100 万条记录的日志文件,才真正体会到它的好处。

std::vector<std::string> logs = load_huge_log_file(); // 100万条

// 视图:只定义操作,不执行
auto error_logs = logs 
    | std::views::filter([](const std::string& s) { 
        return s.find("ERROR") != std::string::npos; 
    })
    | std::views::take(10);  // 只要前10条

// 真正遍历时才执行
for (const auto& log : error_logs) {
    std::cout << log << "\n";
}

这里有个关键点:take(10) 意味着一旦找到 10 条错误日志,后面的 999990 条根本不会遍历。这在传统写法里,你得手动加 break 才能做到。

小技巧:视图是轻量级的,你可以随意组合。我习惯把常用的视图组合封装成函数,比如 auto get_active_users() 返回一个视图,而不是一个 vector。

常用视图一览

Ranges 提供了几十种视图,但日常开发中常用的就那么几个。我列个表,方便你快速查阅。

视图 作用 示例
filter 按条件过滤 v | filter(pred)
transform 映射转换 v | transform(fn)
take 取前 N 个 v | take(5)
drop 跳过前 N 个 v | drop(5)
reverse 反转顺序 v | reverse
join 展平嵌套集合 v | join
keys 取 map 的 key m | keys
values 取 map 的 value m | values

注意:视图是懒求值的,但如果你把视图赋值给 auto 变量,要小心悬空引用。我曾经在项目里踩过这个坑——视图引用的临时对象被销毁了,结果访问时崩溃。

范围适配器与管道操作

管道操作符 | 是 Ranges 的语法糖。它让代码像 Unix 管道一样,数据从左到右流动。

auto result = numbers 
    | std::views::filter([](int n) { return n > 0; })
    | std::views::transform([](int n) { return n * n; })
    | std::views::take(3);

这段代码的意思是:从 numbers 中取出正数,平方,然后取前 3 个。每一步都是独立的,你可以随意增删或调整顺序。

我个人习惯把这种写法叫做「声明式流水线」。它比传统的 for 循环更容易维护,因为每个操作都是独立的函数对象,可以单独测试。

动作(Actions)—— 就地修改

视图是只读的,不会修改原数据。但有时候我们需要就地修改集合。这时候就要用动作(Actions)。

std::vector<int> v = {5, 2, 8, 1, 9, 3};

// 动作:就地排序并去重
std::ranges::sort(v);
auto [first, last] = std::ranges::unique(v);
v.erase(first, last);

// 用 Ranges 动作的写法(C++23 更完善,但 C++20 已有部分)
// 注意:C++20 的 actions 还不完整,需要等 C++23

嗯,这里要说明一下。C++20 的 Ranges 主要聚焦在视图和算法上。动作(Actions)在 C++23 中才得到完善。但核心思想是一样的:用声明式的方式描述「你要对集合做什么」。

实战:从日志中提取错误信息

我在之前的一个项目中,需要从海量日志中提取特定时间段的错误信息。用传统写法,代码又长又乱。用 Ranges 就清爽多了。

struct LogEntry {
    std::chrono::system_clock::time_point timestamp;
    std::string level;   // "INFO", "WARN", "ERROR"
    std::string message;
};

std::vector<LogEntry> logs = load_logs();

auto recent_errors = logs 
    | std::views::filter([](const LogEntry& e) { 
        return e.level == "ERROR"; 
    })
    | std::views::filter([](const LogEntry& e) { 
        return e.timestamp >= one_hour_ago; 
    })
    | std::views::transform([](const LogEntry& e) { 
        return e.message; 
    })
    | std::views::take(20);

for (const auto& msg : recent_errors) {
    std::cout << msg << "\n";
}

你看,代码读起来就像在描述需求:「给我最近一小时的错误日志,只要消息内容,取前 20 条。」没有索引,没有临时容器,没有 break 判断。

Ranges 知识体系

下面这张图帮你理清 Ranges 的核心概念和关系。

C++20 Ranges 核心知识体系 Ranges 库 视图 (Views) 算法 (Algorithms) 动作 (Actions) filter transform take / drop reverse sort find count copy sort (就地) unique shuffle fill 核心特性:懒求值、管道组合、不拥有数据、声明式风格 视图不修改原数据 | 算法可接受范围 | 动作就地修改

避坑指南

我曾经在项目里犯过一个低级错误。当时我用 std::views::filter 过滤一个临时 vector,结果视图还没用,vector 就被销毁了。

// 错误示例
auto get_evens() {
    std::vector<int> v = {1, 2, 3, 4, 5};
    return v | std::views::filter([](int n) { return n % 2 == 0; });
    // v 在这里被销毁,返回的视图悬空了!
}

// 正确做法:返回 vector 或确保视图引用的对象存活
std::vector<int> get_evens() {
    std::vector<int> v = {1, 2, 3, 4, 5};
    auto filtered = v | std::views::filter([](int n) { return n % 2 == 0; });
    return std::vector<int>(filtered.begin(), filtered.end());
}

嗯,这里要注意:视图不拥有数据,它只是「看」数据。如果数据没了,视图就废了。

什么时候用 Ranges?

我个人觉得,Ranges 最适合的场景是:

  • 数据流水线处理:过滤、映射、排序、截取一条龙
  • 懒加载场景:数据量大,只需要前几条结果
  • 代码可读性要求高:团队协作时,声明式代码更容易理解
  • 避免临时容器:视图不复制数据,节省内存

但也不是所有场景都适合。比如简单的 for 循环,或者性能极其敏感的内层循环,传统写法可能更直接。

总结一下:Ranges 让 C++ 处理集合的方式更接近现代编程语言。它不复杂,但需要转变思维——从「怎么遍历」到「要什么结果」。一旦你习惯了这种声明式写法,就很难回去了。


公众号:蓝海资料掘金营,微信deep3321