流式处理-与迭代相对的现代编程范式

炒鸡长的文章,如果感兴趣的话,可以直接看目录。 如果有错误的话,不要骂我,请指出来,菜菜捞捞≥﹏≤。

流引言

在很多时候 我们都希望由某些数据集合体一个一个地 按某个性质导出一些部分的数据 并且导出后的数据不再重复出现 然后我们通过这些单元的数据拼凑其这个数据集合体本身的性质 或是直接加以利用

比如流式读取一个文件/套接字 或是在小根堆获取数据

这里体现了一种核心概念 即流 (Stream)。我们对数据的操作 就像在河边取水 我们只关心在当前位置能取到的那一部分水 (得到的数据) 而毫不关心河流的全貌 也不在意已经流逝的河水(已操作过的数据)

虽然用河流作比喻可能会认为数据集合体是变化的 而流是不变的 但实际上相反 为了保证多流一数据源的协同性 以及流的独立性 一般数据集合体是不变化的 而流自身的变化反映变动
更贴合性质的概念是迭代器 迭代器在数据集合上运动 指向其一个元素

总而言之 流式体现了一个这样的概念

  • 只在意得到的值
  • 不在意将来得到的值 过去得到的值 也不在意在哪里得到值

然而 在计算机科学中 我们又希望从这种受限的访问中获得有关顺序或性质的洞见 看起来有点矛盾 但是这恰恰是"流"的微妙之处 尽管我们不直接操控数据源 但在流式操作中 我们通常还:

  • 在意得到值的顺序和性质 它可以侧面反映数据集的性质

比如:

  • 对于流式读取文件/套接字 为了能拼凑出正确的内容 我们强依赖于流是按原始顺序生成的
  • 在某些缓存淘汰算法中 如果我们希望算法具有"近时性"(LRU) 就要求淘汰决策所依赖的数据流是符合时间先后顺序的 如果希望具有"频率性"(LFU) 则流需要能反映出元素的访问频率
  • 关于随机数流 可能会认为它基本没有性质 实际上从这个视角而言 随机数流有一个性质就是元素是随机的 这作为它的许多算法的实现的基础 比如蒙特卡洛算法

流的构成

一个流式操作通常需要有三个部分

  • 数据源 可以是广义的数据容器 或者是由当前的多个状态生成的数据
  • 流/迭代器/管道 沟通数据源和访问端的接口 具有上述描述的流的特性 只对外提供访问下一个元素的接口
  • 访问端 接受数据的部分

数据源由于其特性 可以是有限的(比如是广义的数据容器) 也可以是无限的 (比如由当前状态生成)

这里比较抽象 但是应该比较合理的概念是作为无限流的经典实现 由当前的多个状态生成的数据

  • 这个状态可以是记录某个函数/数列的自变量 比如斐波那契数列 从而我们可以追踪自变量 且每次访问递增自变量构造无限流
  • 这个状态也可以是随机函数所需求的状态 比如热随机需求的 访问流那一刻的 某个地方的热力学属性

这里 对于流式访问的接口实现 也可以说核心实现 计算机科学内通常有三种概念 迭代器 流 或者是管道 它们通常在不同的地方常用 比如管道通常在并发内常用 迭代器通常在容器操作内常用 流通常在IO常用

  • 迭代器 (Iterator):更侧重于一种"访问机制"。它是容器(数据源)提供的一种标准化的、逐个遍历其元素的方式。可以说,迭代器是实现"流"概念的一种最常见的具体工具。
  • 流 (Stream):更侧重于一种"数据序列"的抽象概念,这个序列不一定预先存在于内存中,可以是动态生成的。它强调的是数据像水流一样被处理的过程。
  • 管道 (Pipeline):更多用于并发和操作系统语境,强调的是将一个进程/操作的输出直接作为另一个进程/操作的输入,形成一个处理链。它与流的"可组合性"高度相关。

但是它们描述的整体都是流这个概念 为了实现这个概念 流/迭代器/管道的理念通常如下

  • 基本的流概念的实现内的流可以是单向性的 流只能朝着一个方向流动 不能逆向流动 不能改变 即流数据无法回溯 作为流概念的扩展 流可以实现逆向流动的支持 这取决于数据源 实现这样的流取决于数据源支不支持回溯性
  • 状态性 流内通常有一个状态表述自己在数据源的位置(流读取操作不应该改变数据源 而应该使用接口自身标识) 这个状态可以是索引 自变量等 有些情形下流并不需要记录状态 比如由某些外在接口获取的状态生成数据时 比如热随机 操作系统内置随机等 此时流很薄 只是接口的体现
  • 基本的流概念的实现内的流可以是一次性的 流用完即销毁 作为流的扩展 数据源可以支持多次创建流

数据源的回溯性的判断的比较常见的方式是判断数据源是否持有之前流迭代过的数据 如果持有 则可能 可以回溯 如果不可以 比如通过外在接口获取随机数 但是数据源每次迭代不记录的情况下 流是绝对不可以回溯

流的性质

那么 流式处理相比直接完全掌握容器而言 具有一些行为的缺失(相比直接操作没那么自由 比如无法随机访问) 那么 相对于某些方面的缺失 流式带给了我们什么很好的特性呢

惰性求值

有一个十分明显 也十分强大的特性是 流式天生支持惰性求值 我们不需要完全计算 完全读取整个数据集 而是逐个读取 所以对于流式处理 值只有在需要时才被产生 它均摊了大数据的成本 而且不需要考虑读取的一堆数据在特定情境下的命中率

对于大数据集或者无穷数据源 这意味着巨大的计算成本都被摊销到每一次获取元素的对单个元素的计算中 从而大大节省了计算成本和IO成本

比如流式处理的经典场合 流媒体(Streaming Media) 媒体通常十分巨大 而通过流式访问的方式来获取媒体 并实时显示获取到得信息 可以大大降低对网络的IO负担 这是当前的流媒体应用成立的基础 我们不需要完全缓存媒体到本地再播放 而是流式传输 使得流媒体的实时性很强

惰性求值的黑暗面-非fail-fast

惰性求值具有非常好的性质 但是并没有完全完美的事物 相比贪婪求值 惰性求值也有一些很独特的问题 比如非fail-fast

比如我们的流算法构建错误 比如drop_while()的谓词永真 并将其用作无限流 这样这个程序一定会死循环 程序会一直尝试略过谓词返回真的元素 而谓词永真

但是我们并没办法在编译时或流创建时就知道这个问题 因为流和对应的转化操作都是惰性求值的 在创建流之后 由于我们不取出元素 操作实际上不执行 操作会在实际取出元素或物化时执行 只有在那个时候 我们才能发觉死循环问题 而可能离原来的流创建步骤很远了

内存效率高

流式访问不要求数据完全读取到内存内 数据是逐一慢慢读取到内存内的 这带来了极大的内存节省 特别是对于数据较为大的场合 但是由于流的特性 流的随机访问通常不太友好或是不支持 这通常在某些场合也会产生效率的损失 因此在某些场合 要权衡二者的优劣

比如为了节省内存 巨大的(甚至可能比环境内内存还大)高分辨率视频都是以流的方式播放的 这使得整个文件不用加载到内存内 降低了环境的硬件要求

良好的组合性

流描述了一个类似于管道的概念 从流概念本身的性质而言 流本身可以作为另一个流的数据源 这使得多个流可以拼在一起 就和管道可以拼在一起一样(功能可以不同 比如管道可以有阀门 仪表) 同时 流作为一种概念的约束 为兼容这种组合性的流与面向流的算法的编写提供了模板

从以上的两个角度而言 流具有相当良好的可组合性 这种可组合性可以产生一种全新的 FP(函数式编程)风格的 优美的编程范式 声明式数据流编程

为什么要强调是FP(函数式编程风格) 这和OOP(面向对象编程风格)有关联吗

实际上 在OOP范式下 将一堆流拼合起来 甚至违反OOP理念以及OOP中的LOD法则(你对拼合起来的所有流结构都构成依赖关系 这在OOP内是一种强耦合)

潜在的性能损失

尽管声明式数据流风格带来了无与伦比的可读性 组合性和强大的惰性求值特性 但这些优雅的抽象并非没有代价 在某些场景下 尤其是在对性能要求极致的"热点路径"(Hot Path)中 流式处理可能会引入一些不容忽视的性能开销

  • 流使用到的语法糖(如Lambda表达式)具有一些潜在的内存分配产生的性能损失 特别是在捕获外部变量时 编译器可能会为其生成一个小的闭包对象
  • 流式管线中的每一个中间操作 如map filter都意味着对每个元素执行一次函数调用 尽管现代编译器与JIT擅长内联这些调用 但在复杂的调用链中开销依然可能累积 相比之下 一个简单的for循环其内部逻辑能被编译器完全展平 几乎没有额外的调用开销
  • 相比紧凑的for循环 流操作的抽象会限制编译器进行某些深层次优化 比如传统的for循环对编译器极为友好 易于被进行循环展开或自动向量化(SIMD)等优化 而流的间接性使得编译器很难"看穿"整个操作链

但是这些部分带来的性能损失在大部分时候远远小于流操作带来的代码易读性 在代码设计和性能做权衡是一直以来的课题

支持无穷

由于流不关注全貌 只关注现在和元素 所以流可以支持数据源为无限的场合 也是在数据源为无限的情况下 最为优美的应对方式之一

而对于流算法 也就是严格而言的流本身作为另一个流的数据源的 并且同时只关注现在的流包装器 或者说另一个带有功能的管道而言 由于它们都不关注全貌 都只关注现在和元素 它们整体也是都支持无穷的

声明式数据流风格编程

流的优美特性赋予它们强大的 可以和其他流拼在一起的特性 即存在一种统一的优美方法 为流扩展性质

同时 流作为计算机科学内极其常见的被广泛使用的概念 面相流的扩展通常极具有通用性

因此 将对流的操作通过类似管道的拼接过程的比较优美自然方式进行一些预置的包装 可以产生泛用性和优美性都很强的代码单元

由于我们只是拼接自带的 或是内置的流 所以相比我们手动实现特定算法 这一般具有如下特性

  • 天然惰性求值 除了部分处理需要贪婪求值
  • 概念自然 流拼接的概念较为自然 利于理解
  • 出错率低 相比自己手动迭代 使用现成的流库的出错率显然相对更低

对于FP类语言 这种处理方式基本可以原生支持 但是对于非纯FP语言 随着这个范式的强大逐渐被人看到 有些非FP语言也在刻意的为它提供支持 以提升语言的强大性

这里举一些比较常见的语言的例子

  • Haskell(语言级) 作为纯FP语言 原生支持声明式流范式
  • C#(语言级) 著名的LINQ实现了类似的范式 并和语言强烈集成 具有十分独特的语法
  • C++(半语言级) C++20在标准库上支持该范式 并且C++自由的编程风格和特性 如模板元 运算符重载 使得C++20内的Range流式操作拥有类似Unix管道操作的语法
  • Rust(半语言级) Rust的迭代器特性原生支持流与流的组合 且Rust作为FP与OOP复合语言 这种FP范式也作为它的编程哲学 不过无特殊语法
  • Python(半语言级) Python允许通过特别的列表操作语法 创造支持惰性求值的迭代器
  • Java(标准库) JDK8的内置流库Stream提供了类似的范式 但是无特殊语法

以下将着重从两个比较典型的语言的实现来叙述 C++20引入的Range 以及JDK8的Stream

可以把C++20的Range在概念上等同于流 虽然它们有些许不一样

从命令式到声明式

命令式描述了一种编程范式 即我们应该明确告诉语言我们期望做的事情的每一个步骤

在这个范式下 对应代码的主要逻辑应当由我们掌握 且完全包含于那段代码

声明式则相反 它提倡我们应当只告诉语言做什么 而实现的过程由实现方来具体实现 实现方只是暴露对应接口 我们只是使用实现方的较为概念明确的接口

显然 如果声明式的实现方 库或语言较为完善的情况下 声明式具有相当多的好处 毕竟我们并不需要处理那么多细节的事情 将这些事情转交给别人 毕竟大家都不喜欢自己干事

  • 这使得程序更加简洁
  • 这在一定程度上降低了程序出错的概率

但是 在一般场合 我们基本很难分出绝对的命令式和声明式 我们或多或少在某个小粒度下(对变量加一) 使用的是声明式(不管底层内存操作 只是告诉编程语言) 又或多或少在某个大粒度下(调用函数) 使用命令式描述行为(按照什么顺序调用函数)

比如我们需要对容器内的每一个元素进行+1操作 可能需要这样书写
因此 我们对命令式和声明式的讨论应当都是相对的

比如对容器内的每个元素+1 偏向命令式的写法大抵如下

C++:

cpp 复制代码
for (auto& x : vec)
{
    x += 1;
}

Java:

java 复制代码
for (int i = 0; i < vec.size(); i++) 
{
    vec.set(i, vec.get(i) + 1);
}

Java理论上不支持C++的引用auto&传递 所以我们不能像C++那样使用基于容器的for循环 这样相当于我们逐个按值复制引用(类似指针) 然后修改这个引用引用的值 引用新的x+1的值 但是这无法影响原来的容器

如果你想这么做的话 可能会想到使用对应对象的成员函数 很遗憾 Java的基础类型的包装类都是不可变类

而比上述写法更加声明式的写法是

C++ (使用 std::for_each):

cpp 复制代码
std::for_each(vec.begin(), vec.end(), [](auto& x){ 
    x += 1; 
});

Java (使用 replaceAll):

java 复制代码
vec.replaceAll(x -> x + 1);

它们达到了相同的用途 但是代码简短了很多 而且我们没有直接操作底层的索引/迭代器 我们只是告诉计算机要对容器内的所有元素进行这个操作

显然 上面的操作都不是流式操作 也不说惰性求值的 像这种 提前对数据源做整体的操作 称为贪婪Eager求值 或者提前Early求值

从迭代算法到流式操作

从上述的对声明式的叙述上 我们可能能注意到 在容器/数据操作除了流式操作 还有一个早期的方式 即内置的迭代算法

迭代算法相比流式算法 支持某些额外的操作 比如我们马上就希望通过数据源整体或者是一部分导出一些东西 或者是需要支持随机读取特性的获取一些值 代价是它基本不支持流的一些优良的特性 以及由于它们没有统一的概念 比如流式 它们的使用较为杂乱无序

  • 对于惰性求值和贪婪求值 有些场合强制要求贪婪求值 但是在大部分场合 惰性求值都是可以完成要求的 而此时 惰性求值都性质优良很多
  • 内存访问效率 由于迭代算法没有统一的理念限制 这常常取决于实现 而流式算法一般内存效率都是摊销的 只存储单个元素
  • 可组合性 由于迭代算法没有统一的理念 包括他们返回的东西 所以迭代算法之间很难组合 而流式算法基本都返回流(除了结束/物化阶段) 因此仍然可以继续组合其他的流式算法
  • 无穷 迭代算法天然不支持无穷 这是因为迭代算法是贪婪求值的 而无穷是永远无法求完的

组成与实例

常见的声明式数据流风格的实现通常由三个部分组成

  • 数据源作为流构建起来的基础
  • 流转换器 它接受一个流 在其内包含了特定的算法 它流式处理原来的流内的元素 返回具有新的性质的流
  • 约简/物化 它接受一个流 运用特定的算法 返回非流的结果

流的构建

想要使用流 我们得先得到流

从容器内

作为最常见的场合 流可以从容器内创建 而容器可以分为三种

数组

数组是语言中最基础的连续内存容器 虽然它们本身不是复杂的对象 但现代C++和Java都提供了直接从数组创建流的便捷方式

  • C++20 Ranges C++中的原生数组可以直接被视为一个范围 无缝接入范围for循环或管道操作 对于需要更明确范围对象的场景 可以使用std::views::all或更通用的std::span
cpp 复制代码
#include <iostream>
#include <vector>
#include <ranges>
#include <span>

int main()
{
    int arr[] = {1, 2, 3, 4, 5};

    // 直接使用,数组本身就是一个范围
    for (int i : arr | std::views::reverse)
    {
        std::cout << i << " "; // 输出: 5 4 3 2 1
    }
    std::cout << std::endl;

    // 使用 std::span 创建一个非拥有的视图 可以取子范围 需要保证原数组的生命周期一致
    std::span<int> arr_span(arr);
    for (int i : arr_span.subspan(1, 3)) // 取子范围 {2, 3, 4}
    {
         std::cout << i << " ";
    }
    std::cout << std::endl;
}
  • Java Stream API Java 提供了 Arrays.stream()Stream.of()两个静态方法来从数组创建流
java 复制代码
import java.util.Arrays;
import java.util.stream.Stream;

class Main
{
    public static void main(String... args)
    {
        String[] arr = {"C++", "Java", "Python"};

        // 使用 Arrays.stream() 可以指定范围
        Arrays.stream(arr, 2, 3)
              .filter(s -> s.contains("a"))
              .forEach(s -> System.out.println("Found: " + s)); // Found: Java

        // 使用 Stream.of(),更通用
        Stream.of(arr)                          .map(String::toUpperCase)
        .forEach(System.out::println);
        // C++, JAVA, PYTHON
    }
}
集合

集合是语言集合框架的一部分 如向量 列表 集合等 它们是创建流最自然的数据源

集合(Set)作为经典的关联容器 一般不需要考虑 也不支持范围

  • C++20 Ranges 在C++20中 所有标准库容器(如std::vector std::list std::set)都满足"范围"的概念 因此可以直接与范围适配器通过管道符号 | 连接 无需任何显式的"创建"步骤 这是Ranges头文件由运算符重载特性实现的
cpp 复制代码
#include <vector>
#include <set>
#include <ranges>
#include <iostream>

int main()
{
    std::vector<int> vec = {1, 2, 3};
    auto vec_view = vec | std::views::transform([](int n){ return n * 2; });

    std::set<std::string> s = {"a", "b", "c"};
    auto set_view = s | std::views::reverse
}

如果希望指定一个范围 可以使用两个迭代器

cpp 复制代码
auto it_begin = vec.begin() + 2;
auto it_end = vec.begin() + 5;

auto sub = std::ranges::subrange(it_begin, it_end);

这种无缝集成是C++ Ranges设计的核心优势之一 它没有引入一个新的"流"类型 而是让现有的容器直接成为"范围"

  • Java Stream API 所有实现了java.util.Collection接口的类(如List Set)都有一个.stream()方法 用于从此集合创建一个流
java 复制代码
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

class Main
{
   public static void main(String... args)
   {
       List<Integer> list = List.of(1, 2, 3);
       List<Integer> doubled = list.stream()
           .map(n -> n * 2)
           .collect(Collectors.toList());

       Set<String> set = Set.of("a", "b", "c");
       set.stream()
          .forEach(System.out::println);
   }
}

如果希望指定一个范围 可以获取subList之后由subList()创建流

java 复制代码
List<Integer> sub_list = list.subList(2, 5);
var stream = sublistView.stream()
映射

映射(map)存储键值对 处理方式和集合略有不同 因为你需要决定是处理键 值 还是整个条目

映射作为经典的关联容器 一般不需要考虑 也不支持范围

  • C++20 Ranges std::map本身是一个由std::pair组成的范围 Ranges库提供了views::keysviews::values来方便地提取键或值
cpp 复制代码
#include <map>
#include <string>
#include <ranges>
#include <iostream>

int main()
{
    std::map<std::string, int> ages = {{"Alice", 30}, {"Bob", 25}};

    // 遍历键
    for (const auto& name : std::views::keys(ages)) {
        std::cout << name << std::endl;
    }

    // 遍历值
    for (int age : std::views::values(ages)) {
        std::cout << age << std::endl;
    }
}
  • Java Stream API Map 接口没有直接的.stream()方法 你需要先获取它的键集 (.keySet()) 值集合 (.values()) 或条目集 (.entrySet()),然后对这些集合调用.stream()
java 复制代码
import java.util.Map;

class Main
{
    public static void main(String[] args)
    {
        Map<String, Integer> ages = Map.of("Alice", 30, "Bob", 25);

        // 从键集创建流
        ages.keySet().stream().forEach(System.out::println);

        // 从值集合创建流
        ages.values().stream().forEach(System.out::println);

        // 从条目集创建流 (最常用)
        ages.entrySet().stream()
           .filter(entry -> entry.getValue() > 28)
           .forEach(entry -> System.out.println(entry.getKey()));
    }
}
字符串流

Java和C++都允许将分隔符 作用于字符串导出一个子列流 但是C++的分隔符不支持RE 这确实是遗憾

  • C++ std::views::split 可以根据分隔符将字符串分割成一个范围
  • Java Pattern.compile(regex).splitAsStream(string) 提供了类似的功能

从IO流(字节流)中派生

在C++ 我们一般使用std::ostreamstd::istream两个基类来描述面相标准库的全部包装了常见IO操作的IO流

而在Java内 我们一般使用ScannerReader来描述包装了常见IO操作的IO流 它们的构造需要传入一个流

一般标准库都提供了从这两种流中派生出我们可以进行流式操作的流来扩展声明式编程的易用性

  • 在C++20中 这一功能的核心是 std::ranges::istream_view 它是一个视图(View),可以将任何标准的输入流 (std::istream) 适配成一个单遍 (single-pass) 的输入范围 (input_range)

它的工作原理是 每次你尝试从这个视图中迭代获取一个元素时 它就会在内部使用输入流的运算符重载>>(分隔符取决于传入的流的行为) 来读取一个类型为T(取决于模板参数)的值

至于为什么是单遍的 因为实现istream基类并不要求流可回溯

如果手上的流不是istream对象呢 可以考虑做一个适配器类 它包装现有的其他类型的流对象 继承自istream对象 然后转发实现其内的函数

和Java的流和流处理分离不同 C++的流与流处理是统一的 所以 如果你希望替换分隔策略 可能也需要做适配器类 不过这个时候可以直接继承自原来的istream的派生类 然后覆写>>重载方法

在Java内 有两种方式可以做现有流的包装 一种是Scanner 它代表着主流的 强大的 支持正则表达式作为分隔符的token式处理 还有一种是Reader 它只能按行(相当于Scanner以换行分隔)处理元素

  • 对于Reader 可以使用成员方法.lines()创建关于行的流 这个方法更通用 可以适配任何Reader对象 包括标准输入或字符串
  • 对于Scanner 可以使用成员方法.tokens()创建以正则表达式规定分隔符下的每一个元素的流 也可以使用成员函数.findAll()创建所有关于分隔符的匹配性组成的流

Java的IO体系相对没有C++那么混乱 相对比较有体系 所以这里没什么注意事项

从生成规则创建

有时,我们需要处理的不是一个已有的数据集,而是一个符合某种规则的序列,甚至是无限序列。

广义函数生成

广义函数是接受一个或多个自变量的函数 流内可以存储并迭代这些自变量 从而构造出函数值的流 这是大多数无限流的构造方法

  • C++20 Ranges:使用std::views::generate包装一个有状态的lambda来创建更复杂的序列(如斐波那契数列)

可能会对C++的Lambda表达式的Capture列表(就是那个中括号)感到疑惑 实际上 从Lambda代表的可调用对象(重载了()运算符)的角度 Capture其实确实代表了类字段 即状态 如果标识引用& 则这个字段是关于上下文变量的引用变量 如果不标识 则是按值传递的

可以在Capture列表内初始化变量 这是按值传递的 相当于在对应的对象字段内声明变量 同时作用域不外溢

cpp 复制代码
#include <iostream>
#include <ranges>

int main()
{
    // generate: 生成斐波那契数列
    auto fib = std::views::generate([a=0, b=1]() mutable
    {
        int old_a = a;
        a = b;
        b += old_a;
        return old_a;
    });

    for (int i : fib | std::views::take(8))
    {
        std::cout << i << " "; // 0 1 1 2 3 5 8 13
    }
    std::cout << "\n";
}
  • Java Stream API: 使用Stream.iterate来生成基于前一个元素的新序列 同时拥有Stream.generator的无参数版本
java 复制代码
import java.util.stream.IntStream;
import java.util.stream.Stream;

class Main
{
    public static void main(String... args)
    {
        // iterate: 生成斐波那契数列
        Stream.iterate(new int[]{0, 1}, t -> new int[]{t[1], t[0] + t[1]})
              .limit(8)
              .map(t -> t[0])
              .forEach(fib -> System.out.print(fib + " "));
        System.out.println();
    }
}
简单流生成

有些时候 我们可能需要一些性质极其简单的流 虽然它们完全可以由广义函数生成 但是使用内置方法会更简单 比如

  • 纯粹数值迭代 C++使用std::views::iota生成连续序列 它是惰性求值的 可以指定范围 也可以产生无限流
cpp 复制代码
// iota: 生成 0 到 4
for (int i : std::views::iota(0, 5))
{
    std::cout << i << " ";
}
std::cout << "\n";
  • 纯粹数值迭代 Java使用IntStream.rangeLongStream.range生成数值范围 它们都是惰性求值的 可以指定范围 也可以生成无限流
java 复制代码
// range: 生成 0 到 4
IntStream.range(0, 5).forEach(i -> System.out.print(i + " "));

System.out.println();
  • 空流 C++内使用std::views::empty<T>()创建一个指定类型T的空流

  • 空流 Java内使用Streams.<T>empty() 创建指定类型T的空流

  • 重复流 C++内使用std::views::repeat(value, count) (C++23) 创建对应元素重复count次的流 如果不提供count 则这是个无限流

  • 空或非空流 Java内 ofNullable(T t)函数可以在t为空的时候返回空流 非空时返回t本身 这个函数时常配合迭代和展平使用 用于在含空流内将所有空元素剔除 防止之后的流操作遇到null而产生异常(但是filter也是完全可行的)

随机生成

生成一个由随机数构成的流

  • C++20 Ranges:标准库没有直接的随机数视图 但可以优雅地通过std::views::generate结合<random> 库中的工具来实现
cpp 复制代码
#include <iostream>
#include <ranges>
#include <random>

int main()
{
    std::mt19937 gen(std::random_device{}());
    std::uniform_int_distribution<> distrib(1, 100);

    auto random_numbers = std::views::generate([&] { return distrib(gen); });

    for (int r : random_numbers | std::views::take(5)) {
        std::cout << r << " ";
    }
    std::cout << "\n";
}
  • Java Stream API:java.util.Random类直接提供了生成随机数流的便捷方法
java 复制代码
import java.util.Random

class Main
{
    public static void main(String... args)
    {
        // 生成5个范围在[1, 101)内的随机整数
        new Random().ints(5, 1, 101)
                  .forEach(r -> System.out.print(r + " "));
        System.out.println();
    }
}

流的转化

流操作的比较核心的部分便是流的转化 可以说 这种范式内 流的转化干了绝大部分事情

流的转化和之前提到的管道的拼接的概念是类似的 运用了流概念天然的良组合性 以及流概念的概念约束性

流的转化是通过流转化器/适配器实现的 它们必须接受一个流 输出另一个流 也必须是惰性求值的

这区分了流的转化和流的准物化(非惰性求值 但是输出是一个流) 以及流的物化(非惰性求值 输出不是一个流)

下面介绍一些常见的流转化器

Filter

Filter 即流的过滤操作 代表接受一个流 返回一个只包含满足特定元素的 顺序和原来流一致的流 它一般是惰性求值的 即我们从这个流获取元素时 Filter过滤器才开始遍历流 直到找到第一个符合条件的元素之后停止
Filter一般接受一个谓词 即接受一个元素类型的形参 返回一个布尔值的映射

  • C++20 Range: 通常使用std::views::filter()来实现 它接受一个谓词(满足特定条件的C++内的泛函数)
cpp 复制代码
auto new_stream = vec | std::views::filter([](const int& x) -> bool
{
	return x > 0;
});
  • Java Stream: 通常使用Stream类型的成员函数.filter() 它接受一个谓词(Java内的函数式接口) 这个函数返回一个流
java 复制代码
var new_stream = vec.stream().filter((x) -> 
{
	return x > 0;
});

Transform/Map

虽然在C++和Java内 两个操作的名称不同 C++内使用Transform 即转化 而Java使用Map 即映射

但是它们都在做相同的操作 对流式输入的元素进行处理 输出处理后的类型 也就是说 它返回的是会对元素进行处理的流 这个过程是惰性的

就像是加了一层滤纸 输出过滤后的流
使用非流式非惰性求值的说法就是 对每个元素做一个操作 返回这个操作的返回值构成的容器
需要注意的是 和传统逐元素操作不同 Transform/Map操作允许我们传入的操作的返回值和源流不同类型 此时输出的流类型和返回值一致
这正是Transform/Map操作的强大之处

  • C++20 Range: 通常使用std::views::transform()来实现 它接受一个映射函数(接受元素 返回处理后的值) 返回映射函数返回值的类型的流
cpp 复制代码
auto new_stream = vec | std::views::filter([](const int& x) -> int
{
	return x + 1;
});

// 或者不同类型
auto new_stream = vec | std::views::filter([](const int& x) -> std::string
{
	return std::to_string(x + 1);
});
  • Java Stream: 通常使用Stream类型的成员函数.map() 它接受一个映射函数(接受元素 返回处理后的值) 返回映射函数返回值的类型的流
java 复制代码
var new_stream = vec.stream().map((x) -> 
{
	return x + 1;
});

// 或者不同类型
var new_stream = vec.stream().map((x) -> 
{
	return Double.valueOf(x + 1).toString();
});

需要注意的是 由于Java的基础类型无法被泛型化 所以Java为基础类型单独创建了流类型 如IntStream DoubleStream

我们用包装类显然也可以描述它们 如Stream<Integer> Stream<Double>
基础类型的流的行为稍微有些不一样 对于基础类型流 它们的map方法只允许返回相同的基础类型 不可以改变类型

如果希望改变类型返回 可以使用它们的内置成员方法.mapToObj() 这个方法不允许返回基本类型 且会返回泛型化的标准流 比如希望把codePoints()返回的IntStream转换为面相字符的UTF-8字符串流

java 复制代码
var code_point_str = str.codePoints().mapToObj(em -> new String(new int[] {em}, 0, 1));

会返回Stream<String>的新流

Slicing(Take/Drop/Limit)

可以对一个流进行切片 具体体现在跳过某些元素 遇到某些元素 或者说遍历多少个元素之后结束 总之代表限制流的范围

切片都是惰性求值的 只有在获取值时 才开始略过(Drop)以及结束条件判断(Take)

  • C++20 Range: std::views::take(count)用于接受一个数值 创建只包含前count个元素的流(如果数据源更小 则范围取最小)
  • C++20 Range: std::views::take_while(Predicate)用于让流在第一次让谓词为假的时候截止 即逐个遍历元素 直到谓词为假(如果谓词永真 可能会产生无限流 或是无效)
  • C++20 Range: std::views::drop(count)用于跳过输入range的前count个元素 包含之后的所有元素 即略过流前count个元素(如果数据源更小 则产生空流)
  • C++20 Range: std::views::drop_while(predicate)用于让流略过前面的使得谓词为真的元素 直到谓词为假为止 即略过第一个让谓词为假的元素的前面的所有元素(如果谓词永真 可能会产生死循环或者空流)

Java内的切片操作几乎一致 只是对于接受数字的切片操作 命名有些许不一样

  • Java Stream: 流成员函数.limit(count) 用于接受一个数值 创建只包含前count个元素的流(如果数据源更小 则范围取最小)
  • Java Stream: 流成员函数.takeWhile(Predicate)用于让流在第一次让谓词为假的时候截止 即逐个遍历元素 直到谓词为假(如果谓词永真 可能会产生无限流 或是无效)
  • Java Stream: 流成员函数.skip(count)用于跳过输入流的前count个元素 包含之后的所有元素 即略过流前count个元素(如果数据源更小 则产生空流)
  • Java Stream: 流成员函数.dropWhile(predicate)用于让流略过前面的使得谓词为真的元素 直到谓词为假为止 即略过第一个让谓词为假的元素的前面的所有元素(如果谓词永真 可能会产生死循环或者空流)

可以发现 对无限流作用drop_while有点危险 可能会产生死循环 而且不是fail-fast的 所以可以考虑使用take先限制一下最大元素个数(流大小)

Concat

Concat描述了两个流的拼接过程 一般而言 是从前往后直接简单对接

即第一个形参的流的末尾和第二个形参的开头拼接 产生一个新流

组合在一起的流 只有在第一个形参的流消耗完之后 才会开始消耗第二个形参的流

如果第一个形参的流永远无法消耗完 是个无限流的话 那么拼接操作毫无意义

  • C++20 Range: std::views::concat()接受两个流 然后返回拼接后的流 需要注意的是 std::views::concat支持拼接不同类型的流 只要它们具有共同引用类型 此时返回的流会是最大共同引用类型

比如拼接intdouble 返回值会是最大共同引用common_typedouble 这对继承链也亦然

java 复制代码
std::vector<int> ints = {1, 2, 3};
std::vector<double> doubles = {4.5, 5.5, 6.5};

// Concat int and double
auto concatenated_view = std::views::concat(ints, doubles);

// 迭代器的 value_type 会是 int 和 double 的共同类型 (common_type),即 double
  • Java Stream: Stream.concat()静态方法接受两个流 返回拼接后的流 这要求两个流类型严格一致(与C++不同)

Join/Flat

有些时候 我们可能得到的是一个关于流的流(可能由数据源生成或者由Transform/Map操作生成) 希望脱去一个层次 比如Stream<Stream<Integer>>希望变成Stream<Integer> 或是Stream<Stream<Stream<Integer>>>变成Stream<Stream<Integer>>

此时可以使用Join/Flat操作 它自然是惰性求值的 相当于上文的Concat操作运用于多个元素一样 本质上是先遍历第一个流 之后处理第二个流

  • C++20 Range: std::views::join()用于接受一个流 然后脱去第一层 即展平一次

  • Java Stream: 流成员函数.flatMap()作为map操作和join操作的复合 用于将每个元素传递入.flatMap()的映射函数内 展平映射函数返回的嵌套流 也就是返回比映射函数返回值层次低一级的流

显然 如果希望在Java内纯粹展平元素 而不希望进行map 可以使用flatMap()然后原样返回

java 复制代码
vec.stream().flatMap(em -> em);

Splitting

Splitting 意为分隔 是讲流分隔为多个子流 输出流的流的形式

可以认为它和Join/Flat是逆过程
Java Stream几乎没有Splitting操作 不过你可以考虑自己实现 因为大抵都不算太难

  • C++20 Range: std::views::split(delimiter) 根据分隔符(查询完全相同的元素 通过完全等于的运算符重载判断)将一个range分割成多个子range(这个操作是惰性的 对子range的确认发生在下一次调用)
  • C++20 Range: std::views::lazy_split(delimiter) 根据分隔符(查询完全相同的元素 通过完全等于的运算符重载判断)将一个range分割成多个子range(这个操作是绝对惰性的 下一次获取range只是先确认下一个范围的开头 在遍历获取到的range时再确认结尾)

对于上面的两种split 第一种split由于其预查找相对高效 但是要求范围是多遍 的(forword_iterator) 而第二种lazy_split相对低效 但是支持面较广 支持input_iterator 这几乎是最基本输入流了

  • C++20 Range: std::views::chunk(n) 将输入 range 分割成大小为n的不重叠的块(n个n个地分隔流 如果最后不足n个则为对应剩余个数 返回流的流)

  • C++20 Range: std::views::chunk_by(predicate) 根据二元谓词对相邻元素进行分组 其运行过程为两个两个输入流 如果它们有类似的性质 则归为一组 由于在匹配过程中 前面被归类的元素具有和已经和两个元素中的前一个元素类似的性质 所以本质上 这个操作可以按性质和连续性分组(连续的具有某个性质的元素为一组)

  • C++20 Range: std::views::slide(n) 创建一个滑动窗口(首先从原始范围的第0个元素开始 取n个元素构成第一个窗口 然后 窗口向右滑动一个位置 从第1个元素开始 再取n个元素构成第二个窗口 这个过程持续进行 直到窗口的末尾到达原始范围的末尾 )

  • C++20 Range: std::views::adjacent<N>slide类似 创建一个基于元组tuple的滑动窗口 不过输出是关于元组的流 而不是嵌套流

Unique

std::views::unique(仅C++ Range) 会产生移除掉相邻的重复元素 的流 如果流是有序的则可以完全去重

这个实现显然基于有限状态机

流的准物化(介于物化和转化之间)

流操作的核心魅力在于其惰性 但并非所有返回流的操作都能保持完全的惰性 有些操作 为了完成其自身的逻辑 必须在内部查看甚至处理完所有上游元素 然后才能产生第一个输出元素 这种非惰性求值 但输出仍是一个流 的操作 我们可以称之为流的准物化

就像是一个中间站 必须把所有货物卸下 重新排序或登记后 才能将它们装上下一班列车 虽然最终货物还是在轨道上(流中) 但中途已经发生了一次完整的 非惰性的处理

任何需要了解全局信息 (如排序)或需要记住历史信息 (如全局去重)才能处理当前元素的中间操作 都可以被看作是准物化操作 它们通常是有状态的 (stateful) 且需要缓冲 (buffer) 数据
准物化操作和转化操作的区别是 在准物化操作 通常和物化一样 会产生时间或空间成本 成本并非完全均摊 惰性的

在准物化操作的处理上 C++与Java语言的理念不同

  • C++20 Range: C++ 的ranges库对此有非常明确的区分 视图 (views) 都是惰性的 而需要全局信息的操作(如排序)是一个算法 (algorithm) 它是一个即时 (eager) 操作 如果你想从一个无序的流得到一个有序的流 你必须显式地承担其成本 这体现了C++的透明性和效率优先
  • Java Stream: Java 将这类操作封装成了有状态的中间操作 (stateful intermediate operation)体现了Java的易用性与人性化

也就是说 C++没有关于准物化操作的包装 只有Java有

Sort

C++ Range由于其理念 并没有准物化操作的包装 比如sort 所以我们必须使用更透明的方式

  1. 将原始流物化到一个新的容器中
  2. 对新容器调用std::ranges::sort()
  3. 从这个排好序的容器创建一个新的流(视图)

这种方式非常明确地暴露了排序的成本 它需要一次完整的数据拷贝和一次完整的排序操作 这个过程是非惰性 的 C++ 没有用一个看似方便的views::sort来隐藏这个成本

比如

cpp 复制代码
auto materialized_vec = odd_numbers_view | std::ranges::to<std::vector>(); 

std::ranges::sort(materialized_vec);

auto materialized_set = materialized_vec | std::views::unique()

这里对std::ranges::sort的算法的调用有两个要求

  • 数据源必须是支持随机访问的(我们上面将流元素收集到了std::vector内 显然支持随机访问)
  • 元素必须具有可比性 且具有全序性(重载排序运算符)

对于Java 有内置的准物化的实现

  • Java Stream: 流成员函数sorted() 它内部缓冲所有上游元素到可随机访问集合(和std::ranges::to<std::vector>()第一步类似) 在内部完成排序后 才能将排好序的元素逐个传递给下游

显然 它也要求元素必须具有可比性 且具有全序性(实现Comparable接口 或提供Comparator

java 复制代码
var semi_materialized_stream = List.of(3, 1, 4, 1, 5, 9, 2, 6).stream()
.filter(x -> x > 2)
.sorted();

Distinct

Java支持一个准物化的全局去重操作

  • distinct(): 全局去重操作 它必须在内部维护一个集合(如 HashSet)来记住所有已经见过的元素 以便判断当前元素是否需要被丢弃

显然 这个实现相比C++unique 其去重不局限于相邻元素 而是全局的 且相比先排序再unique去重 这个实现可以保留原有顺序

那么C++是不是完全没有办法了 也并非 可以自己实现一个 也可以下沉到非流式实现 直接对容器去重再转换为流

流的物化

如果说流的转化和准物化本质上还是搭建流的过程 那么流的物化 就是流彻底转化为非流概念的最后一步

物化是一个终端操作 (Terminal Operation) 它会启动整个惰性求值的链条 真正地消耗 掉流 并产生一个最终的结果 这个结果不再是一个流 而可能是一个集合 一个单一值 或者干脆没有返回值(例如只是打印元素)

一旦流被物化(或称被消耗),它就不能再被使用了。

Collecting

Collecting概念描述了将流中的所有元素收集到一个新的集合里 这是最常见的物化方式

  • C++20 Range: 使用 C++23 引入的 std::ranges::to<Container>() 是最直接的方式(显然也可以使用循环手动收集)
cpp 复制代码
auto even_numbers_view = numbers | std::views::filter(is_even);
// 物化到一个 vector
std::vector<int> result_vec = even_numbers_view | std::ranges::to<std::vector>();
// 物化到一个 set
std::set<int> result_set = even_numbers_view | std::ranges::to<std::set>();
  • Java Stream: 使用 .collect() 方法,配合 java.util.stream.Collectors 工具类。
java 复制代码
var stream = numbers.stream().filter(is_even);
// 物化到一个 List
List<Integer> resultList = stream.collect(Collectors.toList());
// 注意:流已被消耗,不能复用。需要重新创建流来物化到 Set
Set<Integer> resultSet = numbers.stream().filter(is_even).collect(Collectors.toSet());

Reducing

将流中的所有元素依据某种性质聚合成一个值 例如求和 求积 找最大/最小值

有趣的事 这个过程类似于空间压缩 会丢失关于元素的信息

  • C++20 Range: 由于范围本身是可迭代容器 所以C++通常使用标准库中的算法,如 std::ranges::accumulate
cpp 复制代码
// 假设 numbers_view 是一个整数视图
int sum = std::ranges::accumulate(numbers_view, 0);
  • Java Stream: 提供了丰富的终端操作,如 .count() .sum() .max() .min()以及通用的 .reduce()
java 复制代码
long count = numbers.stream().count();
int sum = numbers.stream().mapToInt(Integer::intValue).sum();
Optional<Integer> max = numbers.stream().max(Integer::compareTo);

Matching & Finding

检查流中的元素是否满足某些条件 或找出第一个满足条件的元素 这些通常是短路操作 (short-circuiting) 一旦找到结果就会立即停止

  • C++20 Range: 仍然使用标准库算法 如std::ranges::any_of all_of, none_of以及find_if 等算法
cpp 复制代码
bool has_even = std::ranges::any_of(numbers, is_even);
auto first_even_iter = std::ranges::find_if(numbers, is_even);
  • Java Stream: 流提供了对应的方法 .anyMatch(), .allMatch(), .noneMatch(), .findFirst()
java 复制代码
boolean hasEven = numbers.stream().anyMatch(is_even);

Optional<Integer> firstEven = numbers.stream().filter(is_even).findFirst();

Side-effects

对流中的每个元素执行一个操作 通常不关心返回值 比如打印到控制台 这是为了与外部世界交互 而不是为了生成一个结果

  • C++20 Range: 使用标准库算法std::ranges::for_each()
cpp 复制代码
std::ranges::for_each(numbers_view, [](int x){ std::cout << x << " "; });
  • Java Stream: 使用流成员函数 .forEach()
java 复制代码
numbers.stream().forEach(x -> System.out.print(x + " "));
相关推荐
莲动渔舟2 天前
第4.3节:awk正则表达式详解-特殊字符
正则表达式·编程语言·awk
你的人类朋友3 天前
【Node&Vue】JS是编译型语言还是解释型语言?
javascript·node.js·编程语言
Mirageef4 天前
aardio简单爬取网站图片链接和名称
编程语言
Moonbit4 天前
MoonBit Perals Vol.06: MoonBit 与 LLVM 共舞 (上):编译前端实现
后端·算法·编程语言
Moonbit4 天前
MoonBit Pearls Vol.05: 函数式里的依赖注入:Reader Monad
后端·rust·编程语言
ansurfen4 天前
Hulo 编程语言开发 —— 解释器
开源·编程语言
楽码6 天前
自动修复GoVet:语言实现对比
后端·算法·编程语言
楽码7 天前
理解自动修复:编程语言的底层逻辑
后端·算法·编程语言
Moonbit10 天前
MoonBit Perals Vol.04: 用MoonBit 探索协同式编程
后端·程序员·编程语言