导读:本期聚焦于梧桐创作的《C++中的std::ranges::view是什么?如何实现惰性求值?》,敬请观看详情。写C++代码时经常需要对容器做过滤、变换等链式操作,传统做法往往要生成一堆中间临时容器,既浪费内存又拖慢速度。std::ranges::view是C++20 ranges库提供的视图机制,它不拥有数据也不拷贝数据,只是在原序列之上套一层轻量抽象,配合管道运算符实现惰性求值,只有真正取值时才执行计算。本文将从view的基本概念讲起,分析它与传统容器拷贝方案的差异,介绍view_interface、range adaptor的工作原理,并通过自定义视图的完整示例展示惰性求值的实现细节,同时提醒使用中的悬垂引用等常见陷阱。

在C++20之前,如果你要对一个vector先过滤再变换,通常的写法是先循环筛选出满足条件的元素存到一个新vector,再遍历这个新vector做平方运算存到另一个vector。整个过程产生了两次容器分配和两次完整遍历,数据量一大,内存和性能开销都很可观。std::ranges::view的出现就是为了解决这个问题:它是一个不拥有元素的轻量序列抽象,所有操作都是惰性的,只有在真正迭代时才逐个计算,整条链路从头到尾只需要一次遍历,不产生任何中间容器。

C++中的std::ranges::view是什么?如何实现惰性求值?

什么是view:不拥有数据的序列抽象

标准库对view的定义是:一个O(1)复杂度可移动构造、可移动赋值的range,且其析构函数不涉及任何O(n)操作。说白了,view就是"借用"别人的数据来看一眼,自己不管理生命周期,拷贝和移动都极其便宜。std::string_view是最早普及这种思想的组件,而ranges把这个概念推广到了所有序列操作上。

view有几个关键特征值得注意。第一,它不拥有底层元素,构造一个filter_view不会拷贝任何元素,只是记录了底层range的引用和一个谓词。第二,视图默认按值传递,复制一个视图的代价近似于复制几个指针。第三,标准库提供了view_interface这个基类,任何满足view概念的类型都可以继承它,免费获得front()back()operator[]size()等便捷成员——前提是底层迭代器支持对应操作。

标准库内置了大量视图类型,常用的包括filter_view(按谓词过滤元素)、transform_view(逐元素变换)、take_view(取前N个)、drop_view(跳过前N个)、 iota_view(生成无限序列)、reverse_view(反转)以及split_view(切分)等。它们既可以单独使用,也可以通过管道运算符|串联,形成声明式的数据处理流水线。

惰性求值是如何实现的

惰性求值的核心在于:视图的构造阶段什么计算都不做,所有真正的工作都推迟到迭代器解引用的那一刻。以transform_view为例,它内部保存了底层range(通常是引用)和一个可调用对象,其迭代器的operator*大致等价于把底层迭代器解引用后套一层函数调用。看一段简化的实现思路:

template<input_range R, callable F>
class my_transform_view {
    R base_;          // 底层range(或引用包装)
    F func_;          // 变换函数
public:
    class iterator {
        iterator_t<R> cur_;   // 包装底层迭代器
        my_transform_view* parent_;
    public:
        decltype(auto) operator*() {
            // 惰性的关键:解引用时才调用变换函数
            return parent_->func_(*cur_);
        }
        iterator& operator++() { ++cur_; return *this; }
        // ...
    };
    iterator begin() { return iterator{ranges::begin(base_), this}; }
    iterator end()   { return iterator{ranges::end(base_), this}; }
};

注意构造函数里没有任何循环,begin()end()也只是返回迭代器。整个流水线的执行时机完全由迭代器的推进决定:每次operator++前进一个元素,每次operator*计算一个结果。这意味着即使面对iota_view(0)这样的无限序列,只要配合views::take(10)截断,程序也能正常终止——这正是惰性求值最有代表性的应用。

管道运算符|本身也只是一个语法糖,它的实现就是把左操作数作为第一个参数传给右操作数对应的适配器工厂,等价于函数调用的左结合嵌套。也就是说v | views::filter(p) | views::take(3)会被展开为类似take_view(filter_view(v, p), 3)的嵌套结构,而展开发生在编译期,不带来任何运行时调度开销。此外,由于整个链条类型在编译期确定,编译器有机会把多层operator*完全内联,最终生成的机器码常常和手写循环一样快。

实际使用示例:对比传统写法

来看一个具体例子,从1到20中筛出偶数再求平方,取前5个。传统命令式写法需要一个临时vector和显式循环:

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

int main() {
    namespace rv = std::views;
    auto result = rv::iota(1, 21)
                | rv::filter([](int x) { return x % 2 == 0; })
                | rv::transform([](int x) { return x * x; })
                | rv::take(5);

    for (int v : result) {
        std::cout << v << ' ';   // 输出 4 16 36 64 100
    }
}

这段代码执行时不会先构造包含10个偶数的容器,也不会构造包含10个平方值的容器。filter迭代器在推进时会跳过所有不满足条件的元素,transform在解引用时才做乘法,take的迭代器数够5个就到达末尾。更极端的情况是:如果你构造了整条流水线但从不迭代它,那么任何计算都不会发生,连filter的谓词一次都不会被调用。

除了消费端,标准库还提供了ranges::to(C++23起)把视图物化回容器,写法是result | std::ranges::to<std::vector>()。需要跨函数长期持有结果、或者要对同一份数据反复遍历多次时,物化成容器更划算;反之一次性消费、尤其是数据量大但只需要前几个结果的场景,视图的优势最明显。

自定义一个视图:实现range adaptor

理解原理最好的方式是自己写一个。假设我们要实现一个square_view,把任何整型序列变成其平方序列。通过继承view_interface并实现begin和end即可:

#include <ranges>

template<std::ranges::view V>
class square_view : public std::ranges::view_interface<square_view<V>> {
    V base_;
public:
    explicit square_view(V base) : base_(std::move(base)) {}

    auto begin() { return std::ranges::begin(base_); }
    auto end()   { return std::ranges::end(base_); }

    // 惰性变换放在专门的迭代器里,这里为了演示用另一种方式:
    // 直接返回一个包装迭代器也可以,此处简化为借助transform复用
};

更工程化的做法是仿照标准库写一个完整的包装迭代器,或者直接复用transform_view来组合。如果还想让自己的视图支持管道写法xs | my_square,需要再实现一个适配器闭包对象,标准库为此提供了std::ranges::range_adaptor_closure基类(C++23),继承它之后重载operator()即可自动获得管道能力,省去了手写operator|的样板代码。

编写自定义视图时要时刻牢记view语义:拷贝必须便宜,构造必须不触发元素级计算。如果你的类型在构造时就遍历了底层序列,那它其实是容器而非视图,塞进管道里会破坏整条链的惰性保证,也无法通过std::ranges::view概念的静态检查。

使用视图时的常见陷阱

视图不拥有数据这一点是把双刃剑,最大的坑就是悬垂引用。典型的错误写法是在函数内返回一个基于局部变量的视图:

auto make_data() {
    std::vector<int> local{1, 2, 3, 4};
    // 错误:local在函数返回后销毁,视图指向已释放的内存
    return local | std::views::filter([](int x) { return x > 2; });
}

返回后local被销毁,视图内部的引用随即悬空,访问就是未定义行为。正确做法是返回容器本身,或者返回ranges::to<std::vector>()的结果。同理,链式调用中出现临时容器也要小心,比如get_vector() | views::take(3)在C++23之前可能立即悬垂,C++23通过把纯右值包装为owning_view缓解了部分场景,但左值与右值混合的链条仍需谨慎。

第二个坑是性能预期管理。惰性求值对cache locality并不总是友好:filter_view在稀疏过滤时会不断跳元素,缓存命中率可能低于先把数据紧凑拷贝到连续内存再处理。另外每次迭代operator*都要经过一层函数调用(虽然常被内联),对极简单的循环体来说手写循环有时仍更快。建议在性能敏感的路径上做基准测试,而不是默认视图永远更快。

最后提醒一点:视图是按值传递的,如果把视图存成成员变量或长期持有的对象,必须确保底层range的生命周期覆盖视图的整个使用期。掌握"借用而非拥有"这一核心心智模型,就能避开绝大多数与view相关的问题,真正发挥C++20 ranges在表达力和性能上的双重优势。

C++ ranges view std::ranges 惰性求值修改时间:2026-09-05 09:58:49

免责声明:已尽一切努力确保本网站所含信息的准确性。网站作品多为原创整理与精心创作,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们进行处理Email:chomcom@qq.com。
引用或转载本作品时,请注明当前出处:https://www.ipipp.com/html/20260905/50834.html,基于非商业用途的前提下,欢迎转载或二创本作品。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。