第18章:范围库(Ranges):视图、适配器、管道操作符、延迟求值

说实话,C++20 引入的范围库(Ranges)是我这几年最期待的特性之一。为什么?因为以前写 STL 算法时,那种嵌套迭代器的写法实在太折磨人了。你想想看,一个简单的过滤加转换操作,得写多少层 begin()end()

范围库的出现,说白了就是让我们能用更自然的方式表达数据处理的流水线。它把「数据在哪里」和「怎么处理数据」这两件事分开了。我个人习惯用管道操作符 | 来串联多个操作,读起来就像在描述一个流程,而不是在写一堆嵌套的函数调用。

18.1 视图(View)——不拥有数据,只描述操作

视图是范围库的核心概念。它不拷贝数据,也不修改原始数据,只是描述「我想怎么看这些数据」。嗯,这里要注意:视图是轻量级的,创建视图的开销几乎可以忽略不计。

核心要点:视图不拥有元素,它只是对底层范围的引用或变换。视图的生命周期不能超过它所引用的范围。

我在项目中遇到过这样一个场景:需要从一个包含 10 万个整数的 vector 中取出前 50 个偶数,然后对它们做平方运算。如果用传统方式,我得先拷贝一份数据,过滤,再取前 50 个,最后做变换。这中间产生了多少临时容器?浪费了多少内存?

用范围库的视图,代码就清爽多了:

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

int main() {
    std::vector<int> data = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
    
    // 视图:过滤出偶数,取前3个,然后平方
    auto result = data 
                | std::views::filter([](int n) { return n % 2 == 0; })
                | std::views::take(3)
                | std::views::transform([](int n) { return n * n; });
    
    for (int v : result) {
        std::cout << v << " ";  // 输出:4 16 36
    }
    return 0;
}

看到了吗?没有临时 vector,没有多余的拷贝。整个流水线只在最后遍历时才真正执行计算。这就是延迟求值的威力。

18.2 适配器(Adapter)——视图的构建工具

适配器就是那些能产生视图的函数。它们接收一个范围,返回一个新的视图。标准库提供了很多现成的适配器,比如 filtertransformtakedropreverse 等等。

适配器 作用 示例
filter(pred) 保留满足谓词的元素 v | filter(is_even)
transform(f) 对每个元素应用函数 v | transform(square)
take(n) 取前 n 个元素 v | take(5)
drop(n) 跳过前 n 个元素 v | drop(3)
reverse 反转顺序 v | reverse
keys 取 pair 的第一个元素 map | keys
values 取 pair 的第二个元素 map | values

小技巧:适配器可以组合使用。我经常把 filtertransform 连在一起用,先过滤再变换,效率更高。因为过滤掉不需要的元素后,变换操作的次数就减少了。

18.3 管道操作符(|)——数据流的优雅表达

管道操作符 | 是范围库的语法糖。它把左操作数(一个范围)传递给右操作数(一个适配器),返回一个新的视图。多个 | 可以串联,形成一条处理流水线。

我曾经在代码审查中看到有人这样写:

// 不推荐:嵌套调用,可读性差
auto result = std::views::transform(
    std::views::filter(data, [](int n) { return n > 0; }),
    [](int n) { return n * 2; }
);

这种写法读起来太费劲了。你得从最内层开始往外读,才能理解整个处理流程。用管道操作符改写后:

// 推荐:管道风格,从左到右读
auto result = data 
            | std::views::filter([](int n) { return n > 0; })
            | std::views::transform([](int n) { return n * 2; });

现在读起来就像在说:「从 data 开始,过滤出正数,然后每个数乘以 2」。是不是清晰多了?

18.4 延迟求值(Lazy Evaluation)——只在需要时才计算

延迟求值是范围库最强大的特性之一。视图不会立即执行任何操作,它只是记录下「将来要做什么」。真正的计算发生在遍历视图的时候。

为什么会这样?因为视图的迭代器在每次递增时,才去计算下一个元素的值。这意味着:

  • 节省内存:不需要创建中间容器来存储临时结果
  • 节省计算:如果只取前几个元素,后面的元素根本不会计算
  • 无限范围:可以处理理论上无限长的序列

举个例子,生成斐波那契数列的前 10 个偶数:

#include <ranges>
#include <iostream>

int main() {
    // 生成斐波那契数列的视图(无限长)
    auto fibonacci = std::views::iota(0, 1) 
                   | std::views::transform([](int n) { 
                         static int a = 0, b = 1;
                         int result = a;
                         a = b;
                         b = result + b;
                         return result;
                     });
    
    // 取前 10 个偶数
    auto result = fibonacci 
                | std::views::filter([](int n) { return n % 2 == 0; })
                | std::views::take(10);
    
    for (int v : result) {
        std::cout << v << " ";  // 输出:0 2 8 34 144 610 2584 10946 46368 196418
    }
    return 0;
}

注意:延迟求值虽然好,但也有坑。我曾经在项目中遇到过一个 bug:创建了一个视图后,原始数据被修改了,结果视图遍历时拿到了错误的值。记住,视图不拥有数据,它只是引用。如果原始数据生命周期结束或被修改,视图的行为是未定义的。

18.5 知识体系总览

下面这张图总结了范围库的核心概念和它们之间的关系:

C++ 范围库(Ranges)核心概念 范围(Range) 视图(View) 适配器(Adapter) 管道操作符(|) 不拥有数据 产生视图 串联操作 轻量级,不拷贝数据 filter / transform / take 从左到右读,清晰自然 延迟求值:只在遍历时计算

18.6 实战:组合使用

最后,分享一个我在实际项目中用过的例子。当时需要从一个日志文件中提取所有错误级别的消息,并按时间排序,只保留最近的 100 条:

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

struct LogEntry {
    std::string timestamp;
    std::string level;
    std::string message;
};

int main() {
    std::vector<LogEntry> logs = {
        {"2024-01-01 10:00", "INFO", "Server started"},
        {"2024-01-01 10:01", "ERROR", "Connection timeout"},
        {"2024-01-01 10:02", "WARN", "Disk space low"},
        {"2024-01-01 10:03", "ERROR", "Database connection failed"},
        // ... 更多日志
    };
    
    // 先按时间降序排序(最近的在前面)
    std::ranges::sort(logs, std::greater{}, &LogEntry::timestamp);
    
    // 过滤出 ERROR 级别,取前 100 条
    auto recent_errors = logs 
                       | std::views::filter([](const LogEntry& e) { 
                             return e.level == "ERROR"; 
                         })
                       | std::views::take(100);
    
    for (const auto& entry : recent_errors) {
        std::cout << entry.timestamp << " [" << entry.level << "] " 
                  << entry.message << "\n";
    }
    return 0;
}

这段代码没有创建任何临时容器,没有多余的拷贝。整个处理流程清晰得像在描述业务逻辑。说实话,用惯了范围库之后,再让我回去写传统的迭代器循环,我是不太愿意的。

避坑指南:我曾经在项目中使用 std::views::reverse 时踩过一个坑——它对 std::list 无效,因为 std::list 的迭代器不支持 std::bidirectional_iterator 以外的操作。所以使用前最好确认你的容器类型是否支持所需的迭代器类别。

范围库让 C++ 的函数式编程风格变得更加实用。它把数据处理从「怎么做」提升到了「做什么」的层面。你不需要关心循环怎么写、临时变量怎么管理,只需要描述你想要的结果。这就是我理解的现代 C++ 编程哲学。


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