25、C++20 范围库(Ranges):视图与动作、管道操作符、与 STL 算法的对比、惰性求值

说实话,C++20 的范围库是我这几年用得最爽的特性之一。以前写 STL 算法,总觉得哪里别扭——明明是想表达「过滤、转换、取前 N 个」,结果代码却写成了一堆嵌套的 begin()end()。Ranges 的出现,说白了就是让代码更贴近人的思维,而不是机器的思维。

我个人习惯把 Ranges 理解为「给 STL 算法穿上了组合的外衣」。它没有发明全新的算法,而是提供了一套组合机制,让算法可以像管道一样串联起来。嗯,咱们今天就来好好聊聊这个。

C++20 范围库(Ranges)核心知识体系 std::ranges 视图(Views) 动作(Actions) 管道操作符 | 惰性求值 与 STL 算法对比:声明式 vs 命令式

视图(Views):不拥有数据,只描述变换

视图是 Ranges 的核心概念之一。它不拷贝数据,也不修改数据,只是「描述」如何查看数据。你想想看,这就像给数据戴上了一副滤镜——数据本身没变,但你看到的是经过筛选或转换后的样子。

我在项目中遇到过这样一个场景:有一个很大的 vector<Employee>,需要找出所有工龄超过 5 年的员工,然后只取他们的名字。以前我会写一个循环,再建一个临时 vector。现在用视图,一行搞定:

#include <ranges>
#include <vector>
#include <string>
#include <iostream>

struct Employee {
    std::string name;
    int years_of_service;
};

int main() {
    std::vector<Employee> employees = {
        {"Alice", 7}, {"Bob", 3}, {"Charlie", 10}, {"Diana", 2}
    };

    // 视图:过滤 + 转换,不产生临时容器
    auto senior_names = employees
                      | std::views::filter([](const Employee& e) {
                            return e.years_of_service > 5;
                        })
                      | std::views::transform([](const Employee& e) {
                            return e.name;
                        });

    for (const auto& name : senior_names) {
        std::cout << name << " ";
    }
    // 输出:Alice Charlie
}

关键点:视图是轻量级的。它不分配内存,不拷贝元素。遍历时,每个元素「流过」视图链,被实时处理。

动作(Actions):真正动手修改容器

视图是「只看不动」,那动作就是「真刀真枪」地改数据了。动作会直接修改容器本身,比如排序、去重、删除等。

我记得有一次写日志处理程序,需要把一批乱序的日志按时间戳排序,然后去掉重复条目。用动作写起来特别自然:

#include <ranges>
#include <vector>
#include <algorithm>
#include <iostream>

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

    // 动作:排序 + 去重,直接修改 data
    data |= std::ranges::actions::sort
         |  std::ranges::actions::unique;

    for (int x : data) {
        std::cout << x << " ";
    }
    // 输出:1 2 3 5 8
}

注意:动作会修改原始容器。如果你不想破坏原始数据,请先拷贝一份。我曾经在代码审查时看到有人直接用动作修改了共享数据,结果引发了诡异的并发 bug——嗯,教训深刻。

管道操作符 |:组合的艺术

管道操作符 | 是 Ranges 最优雅的设计之一。它把数据流和处理步骤清晰地分开了。你从左到右读代码,就是数据流动的方向。

我个人觉得,这比嵌套函数调用好理解太多了。你对比一下:

传统 STL 写法 Ranges 管道写法
std::vector<int> v2;
std::copy_if(v1.begin(), v1.end(),
    std::back_inserter(v2),
    [](int x){ return x % 2 == 0; });
std::transform(v2.begin(), v2.end(),
    v2.begin(),
    [](int x){ return x * 2; });
auto result = v1
    | std::views::filter([](int x){ return x % 2 == 0; })
    | std::views::transform([](int x){ return x * 2; });

看到区别了吗?传统写法里,你得手动管理中间容器 v2,还得小心翼翼地维护迭代器范围。管道写法直接说「过滤偶数,然后翻倍」,代码就是你的意图。

惰性求值:不等到用的时候,绝不干活

这是 Ranges 最让我惊喜的特性。视图是惰性的——你构建了一个视图链,它并不会立即执行任何操作。只有当你开始遍历(比如用 for 循环,或者传给 std::ranges::for_each)时,计算才真正发生。

为什么会这样?因为视图本质上是一个「配方」,而不是「成品」。你想想看,如果你要做一个蛋糕,视图就是食谱,而遍历才是真正动手做蛋糕的过程。

我在项目中用这个特性优化过一个性能问题。当时有个数据处理流水线,需要对百万级的数据做多层过滤和转换。如果用传统方式,每一步都会生成一个临时容器,内存开销巨大。换成视图后,数据流就像水管一样,每个元素依次流过所有处理步骤,内存占用几乎为零。

#include <ranges>
#include <vector>
#include <iostream>

int main() {
    std::vector<int> big_data(1000000);
    // 假设 big_data 已填充数据

    // 视图链:惰性,不执行任何实际计算
    auto pipeline = big_data
                  | std::views::filter([](int x) { return x > 0; })
                  | std::views::transform([](int x) { return x * 2; })
                  | std::views::take(10);

    // 只有在这里遍历时,才真正执行过滤和转换
    for (int val : pipeline) {
        std::cout << val << " ";
    }
}

小技巧:如果你只需要前 N 个结果,用 views::take 可以提前终止遍历。配合惰性求值,性能提升非常明显。我曾经把一个 O(n) 空间复杂度的流水线降到了 O(1)。

与 STL 算法的对比:声明式 vs 命令式

说白了,STL 算法是「命令式」的——你得告诉它每一步怎么做:从哪里开始、到哪里结束、结果放哪里。Ranges 是「声明式」的——你只需要描述你想要什么,细节由库帮你处理。

我整理了一个对比表,方便你快速理解:

维度 传统 STL 算法 C++20 Ranges
组合方式 嵌套调用,代码从内向外读 管道操作符,代码从左向右读
中间容器 通常需要手动创建 视图自动处理,无需中间容器
求值时机 立即求值 惰性求值(视图)
迭代器 需要显式传入 begin/end 直接传入容器或范围
可读性 中等,嵌套多时难读 高,流水线式表达

不过我得说一句公道话:Ranges 并不是要取代 STL 算法。对于简单的单步操作,传统算法依然简洁高效。Ranges 的优势在于组合——当你需要把多个操作串联起来时,它的威力才真正体现出来。

嗯,关于 Ranges 的核心内容就聊到这里。视图让你「只看不动」,动作让你「真改数据」,管道操作符让组合变得优雅,惰性求值则带来了性能上的惊喜。我个人建议,新项目里可以大胆用 Ranges,老项目里可以逐步引入——先从简单的视图替换开始,慢慢你就会爱上这种写法。


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