新しい種類のストリーム: ジェネレーター機能を Java に追加する

著者:文磊(イーライ)

序文

この記事は、ツールを推奨するものでも、アプリケーション ケースを共有するものでもありません。主なアイデアは、新しいデザイン パターンを導入することです。抽象的な数学的美しさを備えているだけでなく、膨大な機能セットを推測し、単純なインターフェースから多くの新しい概念を導入することができます。同時に、確かなエンジニアリングの実用的価値もあり、それによって実現されるツールのパフォーマンスは、同様のトップ オープン ソース製品のパフォーマンスを大幅に上回る可能性があります。

この設計パターンは Java ではなく、非常に単純なスクリプト言語で生まれました。その言語機能に対する要件は非常に低いため、その価値は多くの最新のプログラミング言語に共通しています。

ストリームについて

まず、Java での従来のストリーミング API を簡単に確認します。Java 8 でラムダ式と Stream が導入されて以来、Java 開発の利便性は質的に飛躍しました. Stream は、複雑なビジネス ロジックの処理効率を 2 倍にすることができ、すべての Java 開発者が習得すべき基本的なスキルです. しかし、parallelStream、つまり並行ストリームを除けば、実際には良い設計ではありません。

まず、パッケージが重すぎて、実装が複雑すぎて、ソースコードが非常に読みにくいです。これが並行ストリームとの互換性のための妥協点かもしれないことは理解できますが、結局のところ、カップリングが深すぎて、難しくてわかりにくいようです。すべての初心者がソース コードに怯えた後、ストリームは非常に高度で複雑な機能であるという印象を持つに違いありません。これは実際には当てはまりません。ストリームは実際には非常に単純な方法で構築できます

次に、API が冗長すぎます。冗長性は、stream.collect 部分に反映されます。対照的に、Kotlin が提供する toList/toSet/associate(toMap) などの豊富な操作は、ストリームに直接適用できます。Java は Stream が 16 まで直接呼び出すことができる toList を追加せず、toSet/toMap を一緒に追加することさえ拒否しました。

第三に、API 関数は単純です。チェーン操作については、元の Java8 では map/filter/skip/limit/peek/distinct/sorted の 7 つだけでしたが、Java9 では takeWhile/dropWhile が追加されました。ただし、Kotlin には、これらのいくつか以外にも多くの追加のユーティリティ関数があります。

例えば:

mapIndexed、mapNotNull、filterIndexed、filterNotNull、onEachIndexed、distinctBy、sortedBy、sortedWith、zip、zipWithNext など、2 倍以上。これらの実装は複雑ではなく、単なる利便性の問題ですが、ユーザーにとっては、持っている場合と持っていない場合の経験の違いは非常に大きいと言えます。

この記事では、ストリームを構築するための新しいメカニズムを提案します。この仕組みは非常にシンプルで、ラムダ式 (クロージャ) を理解できる学生なら誰でも自分で実装でき、クロージャをサポートするプログラミング言語であれば、このメカニズムを使用して独自のフローを実装できますこの仕組みがシンプルだからこそ、開発者は比較的低コストで実用的な API を多数作成でき、ユーザー エクスペリエンスは Stream から 2 つ離れていますが、問題はありません。

発電機について

ジェネレーター (ジェネレーター) [1] は、多くの最新のプログラミング言語で好評を博している重要な機能であり、Python/Kotlin/C#/Javascript およびその他の言語で直接サポートされています。そのコア API は、yield キーワード (またはメソッド) です。

ジェネレーターを使用すると、反復可能/反復子または厄介なクロージャーのいずれであっても、ストリームに直接マップできます。たとえば、アンダースコア文字列をキャメル ケースに変換するメソッドを実装したいとします。Python では、ジェネレーターを使用して次のように再生できます。

def underscore_to_camelcase(s):
    def camelcase():
        yield str.lower
        while True:
            yield str.capitalize

    return ''.join(f(sub) for sub, f in zip(s.split('_'), camelcase()))

これらの数行のコードは、あらゆる場所で Python ジェネレーターの創意工夫を反映していると言えます。まず、キャメルケースメソッドにyieldキーワードが現れると、インタプリタはそれをジェネレータとみなし、このジェネレータはまず下位関数を提供し、次に無数のcapitalize関数を提供します。ジェネレーターの実行は常に遅延するため、while true メソッドを使用して、パフォーマンスやメモリを浪費することなく無限ストリームを生成することは非常に一般的です。次に、Python のストリームはリストと一緒に圧縮できます. 制限されたリストと無限のストリームは一緒に圧縮されます. リストが終了すると、ストリームは自然に終了します.

このコードでは、最後の join() 行の括弧内は Python では Generator Comprehension [2] と呼ばれますが、これは依然として本質的にストリームであり、zip ストリームがマップされた後の文字列ストリームであり、最後に集約されます。 join メソッドで文字列に変換します。

上記のコードの操作は、ジェネレーターをサポートするどの言語でも簡単に実行できますが、Java では、あえて考えることさえできないかもしれません。Java の歴史において、不朽の Java8 であろうと、Project Loom[3] を導入した最新の OpenJDK19 であろうと、コルーチンも利用できますが、ジェネレーターの直接的なサポートはまだありません。

本質的に, ジェネレータの実装は継続の一時停止と回復に依存します[4]. いわゆる継続は, プログラムが指定された位置まで実行された後のブレークポイントとして直感的に理解できます. コルーチンはその後のジャンプを指します.この関数のブレークポイントは中断されます。スレッドをブロックすることなく、別の関数のブレークポイントで実行が続行され、ジェネレーターも同じことを行います。

Python はスタック フレームの保存と復元によって関数の再入可能性とジェネレーターを実装し [5]、Kotlin は CPS (Continuation Passing Style) [6] テクノロジを使用してコンパイル フェーズでバイトコードを変換し、それによって JVM でコルーチンをシミュレートします [7]。他の言語は、大まかにこれを行うか、より直接的なサポートを提供します。

したがって、ストリームを動的かつ高性能に作成するために、コルーチンなしで Java で yield キーワードを実装または少なくともシミュレートする方法はありますか。答えはイエスです。

文章

Java でのストリームは Stream と呼ばれ、Kotlin でのストリームは Sequence と呼ばれます。これ以上の名前は思いつきません. Flow と呼びたかったのですが、再び使用されました. 簡単にするために Seq と呼びましょう.

コンセプト定義

最初に Seq のインターフェース定義を与える

public interface Seq<T> {
    void consume(Consumer<T> consumer);
}

本質的に消費者の消費者であり、その真の意味は後で説明します。このインターフェイスは抽象的に見えるかもしれませんが、実際には非常に一般的です. java.lang.Iterable には、おなじみの forEach であるこのインターフェイスが自然に付属しています。メソッドの派生を使用して、Seq の最初のインスタンスを記述できます。

List<Integer> list = Arrays.asList(1, 2, 3);
Seq<Integer> seq = list::forEach;

この例では、 consume と forEach が完全に同等であることがわかります. 実際、私は最初にこのインターフェイスを forEach と名付け、数回の繰り返しの後に、より正確な意味で消費するように変更しました.

Java では、単一メソッド インターフェイスが FunctionalInteraface として自動的に認識されるという優れた機能を使用して、単純なラムダ式を使用して、要素が 1 つだけのストリームなどのストリームを構築することもできます。

static <T> Seq<T> unit(T t) {
    return c -> c.accept(t);
}

このメソッドは数学において非常に重要であり (実際にはあまり実用的ではありません)、ジェネリック型 Seq の単位操作、つまり T -> Seq のマッピングを定義します。

マップと flatMap

地図

forEach の直感的な観点から、map[8] を簡単に記述して、T 型のストリームを E 型のストリームに変換できます。つまり、関数 T -> E に従って Seq -> Seq のマッピングを取得できます。 .

default <E> Seq<E> map(Function<T, E> function) {
  return c -> consume(t -> c.accept(function.apply(t)));
}

フラットマップ

同様に、引き続き flatMap を記述できます。つまり、各要素をストリームに展開してからマージできます。

default <E> Seq<E> flatMap(Function<T, Seq<E>> function) {
    return c -> consume(t -> function.apply(t).consume(c));
}

これら 2 つのメソッドは、IDEA で自分で記述でき、スマート プロンプトと組み合わせて、実際に非常に便利に記述できます。直感的に理解しにくいと思われる場合は、Seq を List と考えて、forEach として消費してください。

フィルタリングしてテイク/ドロップ

map と flatMap は、ストリームをマップして結合する機能を提供します。また、ストリームには、要素のフィルタリングと割り込み制御というコア機能もいくつかあります。

フィルター

要素のフィルタリングも非常に簡単に実装できます

default Seq<T> filter(Predicate<T> predicate) {
    return c -> consume(t -> {
        if (predicate.test(t)) {
            c.accept(t);
        }
    });
}

取った

ストリームの中断制御には多くのシナリオがあり、take は最も一般的なシナリオの 1 つです。つまり、最初の n 個の要素を取得し、その後に続かないシナリオ (Stream.limit と同等) です。

Seq は iterator に依存しないため、例外によって中断される必要があります。そのためには、グローバル シングルトンの専用の例外を構築すると同時に、コール スタックでこの例外のキャプチャをキャンセルして、パフォーマンスのオーバーヘッドを削減する必要があります (グローバル シングルトンであるため、はキャンセルされません)

public final class StopException extends RuntimeException {
    public static final StopException INSTANCE = new StopException();

    @Override
    public synchronized Throwable fillInStackTrace() {
        return this;
    }
}

および対応するメソッド

static <T> T stop() {
    throw StopException.INSTANCE;
}

default void consumeTillStop(C consumer) {
    try {
        consume(consumer);
    } catch (StopException ignore) {}
}

次に、テイクを実装できます。

default Seq<T> take(int n) {
    return c -> {
        int[] i = {n};
        consumeTillStop(t -> {
            if (i[0]-- > 0) {
                c.accept(t);
            } else {
                stop();
            }
        });
    };
}

落とす

drop は take に対応する概念で、最初の n 個の要素を破棄します — Stream.skip に相当します。ストリームの割り込み制御は含まれませんが、フィルターの変形、つまり状態を持つフィルターに似ています。Observe it and the implementation details of take above. 内部的には、ストリームが繰り返されると、常に状態を更新するカウンターがありますが、このカウンターは外部からは認識できません。実際、フローのクリーンな特性はすでにここに反映されている可能性があり、状態を保持していてもまったく露出しません。

default Seq<T> drop(int n) {
    return c -> {
        int[] a = {n - 1};
        consume(t -> {
            if (a[0] < 0) {
                c.accept(t);
            } else {
                a[0]--;
            }
        });
    };
}

その他の API

それぞれの

操作コンシューマーをストリームの要素に追加しますが、ストリームを実行しません - Stream.peek に対応します。

default Seq<T> onEach(Consumer<T> consumer) {
    return c -> consume(consumer.andThen(c));
}

ジップ

ストリームは反復可能な要素とペアで集約され、新しいストリームに変換されます。ストリームには対応するものはありませんが、Python には同じ名前の実装があります。

default <E, R> Seq<R> zip(Iterable<E> iterable, BiFunction<T, E, R> function) {
    return c -> {
        Iterator<E> iterator = iterable.iterator();
        consumeTillStop(t -> {
            if (iterator.hasNext()) {
                c.accept(function.apply(t, iterator.next()));
            } else {
                stop();
            }
        });
    };
}

端末操作

上記で実装されたメソッドは、あるストリームを別のストリームにマップするストリーム チェーン API ですが、ストリーム自体はまだ遅延しているか、実際にはまだ実行されていません。このフローを実際に実行するには、いわゆる端末操作を使用してフローを消費または集約する必要があります。Stream では、消費は forEach であり、集計は Collector です。コレクターの場合、実際にはもっと優れたデザインが存在する可能性があるため、ここでは詳しく説明しません。ただし、例として、結合を簡単かつ迅速に実装できます。

default String join(String sep) {
    StringJoiner joiner = new StringJoiner(sep);
    consume(t -> joiner.add(t.toString()));
    return joiner.toString();
}

そしてtoList。

default List<T> toList() {
    List<T> list = new ArrayList<>();
    consume(list::add);
    return list;
}

ここまでで、わずか数十行のコードで完全なストリーミング API を実装しました。ほとんどの場合、これらの API はすでに使用シナリオの 80 ~ 90% をカバーできます。同じ例に倣って、Go などの他のプログラミング言語でもプレイできます (笑)。

ジェネレーターの派生

この記事ではタイトルからジェネレーターについて語りますが、ジェネレーターがコア機能であると言っても過言ではありませんが、いくつかのコア ストリーミング API を作成した後でも、ジェネレーターとは何かについての説明はまだありません — ——実際にはそうではありません。私はトリッキーにしようとしています。注意深く観察する必要があります。ジェネレーターは、最初に iterable が Seq として誕生したときからすでに登場しています。

List<Integer> list = Arrays.asList(1, 2, 3);
Seq<Integer> seq = list::forEach;

見ませんでしたか?次に、このメソッドの派生を通常のラムダ関数として書き直します。

Seq<Integer> seq = c -> list.forEach(c);

さらに一歩進んで、この forEach をより伝統的な for ループに置き換えると、

Seq<Integer> seq = c -> {
    for (Integer i : list) {
        c.accept(i);
    }
};

このリストは [1, 2, 3] であることがわかっているため、上記のコードはさらに等価的に次のように記述できます。

Seq<Integer> seq = c -> {
    c.accept(1);
    c.accept(2);
    c.accept(3);
};

おなじみですか?同様のものが Python でどのように見えるか見てみましょう:

def seq():
    yield 1
    yield 2
    yield 3

両者を比較すると、フォームはほとんど同じです.これは実際にはジェネレータです.このコードのacceptはyieldの役割を果たします.consumerインターフェースがこの名前を取る理由は、それがconsumer Operationであることを意味します.すべての端末操作はこの消費操作に基づいて実装されます。機能的には、Iterable の forEach と完全に同等です. forEach が直接呼び出されない理由は、その要素が組み込みではなく、クロージャー内のコード ブロックによって一時的に生成されるためです

この種のジェネレータは、従来の意味でのサスペンドに継続を使用するジェネレータではなく、クロージャを使用して一時的に生成された要素をコード ブロックにキャプチャするものであり、サスペンドしなくても、従来のジェネレータの使用法と特性を高度にシミュレートできます。実際、上記の連鎖 API 実装はすべて、生成された要素が元のストリームから取得されることを除いて、基本的にジェネレーターです。

ジェネレーターでは、上記の下線をハンプに変換する操作を Java で記述できます。

static String underscoreToCamel(String str) {
    // Java没有首字母大写方法,随便现写一个
    UnaryOperator<String> capitalize = s -> s.substring(0, 1).toUpperCase() + s.substring(1).toLowerCase();
     // 利用生成器构造一个方法的流
    Seq<UnaryOperator<String>> seq = c -> {
        // yield第一个小写函数
        c.accept(String::toLowerCase);
        // 这里IDEA会告警,提示死循环风险,无视即可
        while (true) {
            // 按需yield首字母大写函数
            c.accept(capitalize);
        }
    };
    List<String> split = Arrays.asList(str.split("_"));
    // 这里的zip和join都在上文给出了实现
    return seq.zip(split, (f, sub) -> f.apply(sub)).join("");
}

これらのコードをコピーして実行すると、目的の機能が実際に達成されるかどうかを確認できます。

発電機の性質

ジェネレーターは推測できたものの、途中で何が起こったのか、無限ループがどのように飛び出したのか、要素を生成する方法については、まだ少し混乱しているようです。さらに詳しく説明するために、もう 1 つのおなじみの例を示します。

生産者と消費者のパターン

プロデューサーとコンシューマーの間の関係は、マルチスレッドまたはコルーチンのコンテキストに現れるだけでなく、シングル スレッドにもいくつかの古典的なシナリオがあります。たとえば、A と B の 2 人の学生がプロジェクトで共同作業を行い、それぞれ 2 つのモジュールを開発するとします。A はデータの作成を担当し、B はデータの使用を担当します。A は、B がデータをどのように処理するかを気にしません。最初に一部のデータをフィルター処理し、集計後に計算を実行するか、ローカルまたはリモート ストレージに書き込む必要がある場合があります。B は、A のデータがどのように取得されたかは気にしません。ここでの唯一の問題は、データ項目の数が多すぎて、一度にメモリが収まらないことです。この場合、従来のアプローチでは、A がコールバック関数のコンシューマーとのインターフェイスを提供し、B が A を呼び出すときに特定のコンシューマーを渡します。

public void produce(Consumer<String> callback) {
    // do something that produce strings
    // then use the callback consumer to eat them
}

このコールバック関数に基づく対話方法はあまりにも古典的で、言うことはありません。しかし、すでにジェネレーターを作成した後は、勇気を出して少し変更を加えたほうがよいかもしれません。上記の Produce インターフェイスを注意深く観察してください。これはコンシューマーを入力し、void を返します。つまり、実際には Seq です。

Seq<String> producer = this::produce;

次に、コードをわずかに調整して、コールバック関数に基づいてこのインターフェイスをアップグレードし、ジェネレーターに変換する必要があります。

public Seq<String> produce() {
    return c -> {
        // still do something that produce strings
        // then use the callback consumer to eat them
    };
}

この抽象化層に基づいて、生産者としての A と消費者としての B は、真に完全かつ完全に分離されます。A は、データ生成プロセスをジェネレーターのクロージャーに配置するだけでよく、IO 操作など、プロセスに関連するすべての副作用は、このクロージャーによって完全に分離されます。B はきれいなフローを直接取得します. 彼はフローの内部の詳細を気にする必要はありません, そしてもちろん、彼がしたい場合は気にすることはできません. 彼は自分がやりたいことだけに集中する必要があります.

さらに重要なことは、A と B は操作ロジックに関しては完全に分離されており、互いに見えませんが、CPU スケジューリング時間に関しては相互にインターリーブされてお​​り、B は A の生産プロセスを直接ブロックして中断することさえできます。コルーチンよりも優れたコルーチンはありません。

これまでのところ、ジェネレーターとしての Seq の真の本質 、つまりコールバックのコンシューマーを発見することに成功しました明らかにコールバック関数の消費者なのに、いきなり生産者になるのはちょっと変です。しかし、よくよく考えてみると、消費者の要求 (コールバック) を満たすことができるのは、その要求がどんなに奇妙なものであっても、プロデューサーではないでしょうか。

コールバック メカニズムに基づくジェネレーターの呼び出しオーバーヘッドは、ジェネレーター クロージャー内のコード ブロックの山の実行オーバーヘッドに、わずかなクロージャー作成オーバーヘッドを加えたものに過ぎないことは簡単にわかります。ストリーミング コンピューティングと制御を含む多くのビジネス シナリオでは、これによりメモリとパフォーマンスが大幅に向上します。後で、そのパフォーマンス上の利点を示す特定のシナリオ例を示します。

さらに、この変更されたコードを観察すると、produce の出力はまったく関数のままであり、実際に実行および生成されるデータがないことがわかります。これは、匿名インターフェースとしてのジェネレータ固有の利点です:遅延計算- 消費者はストリーム全体を取得したように見えますが、実際には単なるラブ ナンバー プレートであり、走り書きまたは破棄できますが、本物のコールバックが必要です。 . 償還の瞬間に、実際の流れが実行されます。

発電機の本質は人間の本性とは正反対です: ハトバスター - 誰もハトできません

IO 分離とストリーム出力

Haskell は、純粋な関数の世界から IO 操作を分離するために、いわゆる IO モナド [9] を発明しました。Java は Stream を使用して、同様のカプセル化効果をほとんど実現していません。java.io.BufferedReader を例に取ると、ローカル ファイルをストリームとして読み取るには、次のように記述できます。

Stream<String> lines = new BufferedReader(new InputStreamReader(new FileInputStream("file"))).lines();

lines メソッドの実装を詳しく見てみると、大きなコード ブロックを使用して反復子を作成し、それをストリームに変換していることがわかります。実装がいかに面倒かはさておき、ここで最初に注意すべきことは、BufferedReader が Closeable であることです. 安全な方法は、使用後に閉じるか、try-with-resources 構文を使用してレイヤーをラップし、自動クローズを実現することです. しかし、BufferedReader.lines はソースを閉じません。これは安全性の低いインターフェイスです。というか、その分離は不完全です。Java もこれにパッチを適用し、java.nio.file.Files.lines を使用して、onClose コールバック ハンドラを追加し、ストリームが使い果たされた後にクローズ操作が確実に実行されるようにします。

では、より一般的なアプローチはありますか? 結局のところ、BufferedReader.lines と Files.lines にこのようなセキュリティ上の違いがあることを誰もが知っているわけではなく、すべての Closeables が同様の安全に閉じられたストリーミング インターフェイスを提供できるわけではない、またはさらには、ある可能性が高いです。ストリーミング インターフェイスはまったくありません。

幸いなことに、今では Seq があり、その閉鎖機能には、副作用を分離するという固有の利点があります。たまたま、大量のデータ IO を伴うシナリオでは、コールバック インタラクションを使用するのが非常に古典的な設計方法です。

ジェネレーターを使用して IO 分離を実現するのは非常に簡単です.try-with-resources コード全体をラップするだけでよく、IO のライフサイクル全体もラップします.

Seq<String> seq = c -> {
    try (BufferedReader reader = Files.newBufferedReader(Paths.get("file"))) {
        String s;
        while ((s = reader.readLine()) != null) {
            c.accept(s);
        }
    } catch (Exception e) {
        throw new RuntimeException(e);
    }
};

コア コードは実際には 3 行だけで、データ ソースを構築し、データを 1 つずつ読み取り、yield (つまり、accept) します。ストリームに対する後続の操作は、ストリームが作成された後に発生するように見えますが、実際の実行は IO ライフサイクルにラップされ、1 つを読み取って 1 つを消費し、交互に実行され、必要に応じて実行されます。

つまり、ジェネレーターのコールバック メカニズムにより、Seq を変数として渡すことができる場合でも、関連する副作用操作はすべて同じコード ブロックにラップされ、遅延実行されることが保証されます。Monad のようにする必要はなく、IOMonad、StateMonad などのさまざまな Monad を定義する必要があります。

同様に、Tunnel を使用しておなじみの ODPS テーブル データをストリームとしてダウンロードする Ali ミドルウェアの別の例を次に示します。

public static Seq<Record> downloadRecords(TableTunnel.DownloadSession session) {
    return c -> {
        long count = session.getRecordCount();
        try (TunnelRecordReader reader = session.openRecordReader(0, count)) {
            for (long i = 0; i < count; i++) {
                c.accept(reader.read());
            }
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    };
}

レコード ストリームでは、map 関数を実装できれば、レコード ストリームをビジネス セマンティクスを持つ DTO ストリームに簡単にマップできます。これは、実際には ODPS リーダーに相当します。

非同期フロー

コールバック メカニズムに基づくジェネレーターは、IO フィールドでその筋肉を柔軟に使用できることに加えて、当然のことながら非同期操作とも互換性があります。やはり、コールバック関数という言葉を聞くと、反射的に非同期性やフューチャーを思い浮かべる人が多いのではないでしょうか。コールバック関数であり、その運命は、それがどこに配置され、どのように使用されるかを気にしないことを決定します。たとえば、いくつかの暴力的な非同期ロジックでスローされます。

public static Seq<Integer> asyncSeq() {
    return c -> {
        CompletableFuture.runAsync(() -> c.accept(1));
        CompletableFuture.runAsync(() -> c.accept(2));
    };
}

これは、単純で粗雑な非同期ストリーム ジェネレーターです。外部ユーザーの場合、非同期ストリームは、要素の順序が保証されないことを除いて、同期ストリームと同じです. 本質的には、実行中にデータを生成する実行可能なコードの一部です. 誰が使用するかしないかのコールバック関数。

同時ストリーム

誰のためではないので、ForkJoinPool はどうでしょうか。——Java の有名な parallelStream は、ForkJoinPool に基づいて実装されています。これを使用して、独自の同時ストリームを作成することもできます。特定のメソッドは非常に単純です。上記の非同期ストリームの例の CompletableFuture.runAsync を ForkJoinPool.submit に置き換えるだけです。もう 1 つ注意してください。単純ではありません ForkJoinPool にタスクを送信しますが、その後で結合を行います。

この点に関しては、最も暴力的で単純なアイデアを採用し、ForkJoinTask のリストを作成し、要素を forkJoinPool に順番に送信し、タスクを生成してこのリストに追加し、すべての要素が送信されるのを待つこともできます。 、次にこのリストのすべてのタスクを実行します 統合結合.

default Seq<T> parallel() {
    ForkJoinPool pool = ForkJoinPool.commonPool();
    return c -> map(t -> pool.submit(() -> c.accept(t))).cache().consume(ForkJoinTask::join);
}

これはジェネレーターベースの並行ストリームであり、その実装には 2 行のコードしか必要ありません。この記事の冒頭で述べたように、ストリームは非常に単純な方法で構築できますせっかくストリームが苦労した並行ストリームでも、別の言い方をすればとんでもないほど簡単に実現できる。

このメカニズムは Java に限定されるものではなく、クロージャーをサポートする任意のプログラミング言語で再生できることを再度強調する価値があります。実際、このストリーミング メカニズムの最も初期の検証と実装は、AutoHotKey_v2[10] ソフトウェアに付属する単純なスクリプト言語で行われました。

生産者と消費者のモデルについてもう一度話します

ジェネレーターのコールバックの性質を説明するために、シングル スレッドでのプロデューサー/コンシューマー モードが導入されました。非同期フローを実装した後、物事はより興味深いものになります。

中間データ構造としての Seq は、プロデューサーとコンシューマーを完全に切り離すことができることを思い出してください.一方の当事者は、生産データをそれに引き渡すだけでよく、もう一方の当事者は、そこからデータを消費するだけで済みます. この構造はおなじみですか?はい、Java 開発者に共通のブロッキング キューであり、Go や Kotlin などのコルーチンをサポートする言語のチャネル (Channel) です

チャネルは、ある意味ではブロッキング キューでもあります. 従来のブロッキング キューとの主な違いは、チャネル内のデータが制限を超えるか空の場合、対応するプロデューサー/コンシューマーがブロッキングではなくハングすることです.生産/消費は中断されますが、コルーチンが中断された後に CPU を解放することができ、他のコルーチンで引き続き動作することができます。

Seq は Channel よりもどのような利点がありますか? 利点が多すぎます。まず、ジェネレータ クロージャのコールバック コード ブロックにより、生成と消費が交互に実行される必要があることが厳密に保証されます。 、分離する必要はありません キューを維持するためにヒープ メモリを開きます キューがなければ、ロックは発生しません ロックがなければ、ブロックや中断は発生しません 第二に, Seq は本質的に生産を聞く消費です. 生産がなければ, 消費はありません. 過剰な生産がある場合, ああ, 生産は決して過剰にはなりません. Seq は不活性であるためです.

これは、ジェネレーター、キューフリー、ロックフリー、ノンブロッキング チャネルを理解する別の方法です。Go 言語チャネルでしばしば批判されるデッドロックとメモリ リークの問題は Seq には存在せず、Kotlin によって開発された非同期フローと同期フロー Sequence の 2 つの API セットは、Seq に置き換えることができます。

セキュリティ上の問題がまったくないため、Seq ほど安全なチャネルの実装はないと言えます生産されて消費されない?Seq は本質的に不活性であり、消費がなければ何も生成されません。消費後にチャンネルを閉じましたか?そもそも Seq を閉じる必要はありません。ラムダを閉じる必要はありません。

より直感的に理解できるように、簡単なチャネルの例を次に示します。最初に、ForkJoinPool に基づく非同期消費インターフェイスを実装します。これにより、ユーザーは消費後に参加するかどうかを自由に選択できます。

default void asyncConsume(Consumer<T> consumer) {
    ForkJoinPool pool = ForkJoinPool.commonPool();
    map(t -> pool.submit(() -> consumer.accept(t))).cache().consume(ForkJoinTask::join);
}

非同期消費インターフェイスを使用すると、Seq のチャネル機能をすぐに発揮できます。

@Test
public void testChan() {
    // 生产无限的自然数,放入通道seq,这里流本身就是通道,同步流还是异步流都无所谓
    Seq<Long> seq = c -> {
        long i = 0;
        while (true) {
            c.accept(i++);
        }
    };
    long start = System.currentTimeMillis();
    // 通道seq交给消费者,消费者表示只要偶数,只要5个
    seq.filter(i -> (i & 1) == 0).take(5).asyncConsume(i -> {
        try {
            Thread.sleep(1000);
            System.out.printf("produce %d and consume\n", i);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    });
    System.out.printf("elapsed time: %dms\n", System.currentTimeMillis() - start);
}

運用実績

produce 0 and consume
produce 8 and consume
produce 6 and consume
produce 4 and consume
produce 2 and consume
elapsed time: 1032ms

消費は並行して実行されるため、各要素の消費に 1 秒かかっても、最終的な全体の消費時間は 1 秒強になることがわかります。もちろん、これは従来のチャネル モードと同じではありません。たとえば、実際の作業スレッドは大きく異なります。より包括的な設計は、ストリームに基づいてロックフリーで非ブロッキングのキューを追加して、深刻なチャネルを実装することです.これにより、Goチャネルの多くの問題を解決し、同時にパフォーマンスを向上させることができます.それについては後で別の記事を書きます. .

発電機の適用シナリオ

ジェネレータの本質的な特徴は上で紹介されました. コールバックの消費者であり, IO 操作をクロージャの形で完全にカプセル化できます. 非同期フローと並行フローにシームレスに切り替えることができ, 非同期相互作用でロックフリーの役割を果たします. . チャネルの役割。これらのコア機能によってもたらされる利点に加えて、多くの興味深い価値のあるアプリケーション シナリオもあります。

ツリートラバーサル

コールバック関数。その運命は、それがどこに配置され、どのように使用されるかを気にしないことを決定します。たとえば、再帰に配置します。再帰の典型的なシナリオは、ツリー トラバーサルです。比較として、yield を使用して Python でバイナリ ツリーをトラバースする方法を見てみましょう。

def scan_tree(node):
    yield node.value
    if node.left:
        yield from scan_tree(node.left)
    if node.right:
        yield from scan_tree(node.right)

Seq については、Java では関数内に関数をネストすることができないため、もう少し記述が必要です。核となる原則は実際には非常に単純です。コールバック関数を再帰関数にスローし、再帰するたびにそれをピギーバックすることを忘れないでください。

//static <T> Seq<T> of(T... ts) {
//    return Arrays.asList(ts)::forEach;
//}

// 递归函数
public static <N> void scanTree(Consumer<N> c, N node, Function<N, Seq<N>> sub) {
    c.accept(node);
    sub.apply(node).consume(n -> {
        if (n != null) {
            scanTree(c, n, sub);
        }
    });
}

// 通用方法,可以遍历任何树
public static <N> Seq<N> ofTree(N node, Function<N, Seq<N>> sub) {
    return c -> scanTree(c, node, sub);
}

// 遍历一个二叉树
public static Seq<Node> scanTree(Node node) {
    return ofTree(node, n -> Seq.of(n.left, n.right));
}

ここでの ofTree は、非常に強力なツリー トラバーサル メソッドです。ツリー自体を走査すること自体は珍しいことではありませんが、走査プロセスをストリームとして出力することには、想像の余地がたくさんあります。ツリーの構築は、プログラミング言語の世界のどこにでもあると言えます。たとえば、JSONObject をトラバースするストリームを非常に簡単に構築できます。

static Seq<Object> ofJson(Object node) {
    return Seq.ofTree(node, n -> c -> {
        if (n instanceof Iterable) {
            ((Iterable<?>)n).forEach(c);
        } else if (n instanceof Map) {
            ((Map<?, ?>)n).values().forEach(c);
        }
    });
}

そうすれば、JSON を分析するのに非常に便利です.たとえば、特定の JSON に整数フィールドがあるかどうかを確認したい場合、フィールドがどのレイヤーにあるかに関係なく. ストリームの any/anyMatch などのメソッドを使用すると、1 行のコードで実行できます。

boolean hasInteger = ofJson(node).any(t -> t instanceof Integer);

この方法の威力は、単純であるだけでなく、短絡操作であることです。通常のコードを使用して、深さ優先の再帰関数で短絡を実行するか、例外をスローするか、追加のコンテキスト パラメーターを追加して再帰に参加します (ルート ノードに戻った後にのみ停止できます)。実装が非常に面倒。しかし、Seq では、any/all/none のみが必要です。

別の例として、JSON フィールドに不正な文字列 "114514" があるかどうかを確認する場合も、次のコード行になります。

boolean isIllegal = ofJson(node).any(n -> (n instanceof String) && ((String)n).contains("114514"));

ちなみに、JSON の前身である XML もツリー構造を持っているため、多くの成熟した XML パーサーと組み合わせることで、同様のストリーミング スキャン ツールを実装することもできます。たとえば、より高速な Excel パーサーですか?

デカルト積の改善

デカルト積は、ほとんどの開発ではあまり役に立たないかもしれませんが、関数型言語では重要な構成要素であり、最適化モデルを構築する際のオペレーションズ リサーチの分野では非常に一般的です。以前は、Stream を使用して Java で複数のデカルト積を構築する場合、flatMap ネストの複数レイヤーが必要でした。

public static Stream<Integer> cartesian(List<Integer> list1, List<Integer> list2, List<Integer> list3) {
    return list1.stream().flatMap(i1 ->
        list2.stream().flatMap(i2 ->
            list3.stream().map(i3 -> 
                i1 + i2 + i3)));
}

そのようなシナリオのために、Scala は、ユーザーが for ループ + yield の形式でデカルト積を結合できるようにする構文糖衣を提供します [11]。ただし、Scala の yield は純粋なシンタックス シュガーであり、ジェネレーターとの直接的な関係はなく、コンパイル フェーズでコードを上記の flatMap 形式に変換します。このシュガーは、形式的には Haskell の do アノテーションと同等です [12]。

幸いなことに、ジェネレーターができたので、より良い選択ができました.ネストされた for ループを直接記述し、構文を追加したり、キーワードを導入したり、コンパイラーを煩わせたりすることなく、ストリームとして出力することができます。また、フォームはより自由になり、for ループの任意のレベルでコード ロジックを追加できます。

public static Seq<Integer> cartesian(List<Integer> list1, List<Integer> list2, List<Integer> list3) {
    return c -> {
        for (Integer i1 : list1) {
            for (Integer i2 : list2) {
                for (Integer i3 : list3) {
                    c.accept(i1 + i2 + i3);
                }
            }
        }
    };
}

言い換えれば、Java はそのような砂糖を必要としません。Scalaも同様に免れたかもしれません。

Java でおそらく最速の CSV/Excel パーサー

以前の記事で繰り返し強調したように、ジェネレーターがパフォーマンスに大きな利点をもたらすことは、理論的なサポートに加えて、このビューには明確なエンジニアリング プラクティス データ、つまり、私が CSV ファミリ用に開発した統一アーキテクチャのパーサーもあります。いわゆる CSV ファミリーには、CSV の他に Excel や Alibaba Cloud の ODPS も含まれますが、実際には、フォームが統一されたパラダイムに準拠している限り、すべてこのファミリーに入ることができます。

しかし、CSV ファミリーの処理は、常に Java 言語の問題点でした。ODPS については、そのようなことはまったくないかのように話しません。CSVライブラリはたくさんありますが、どれもAPIが面倒か、パフォーマンスが低いかのどちらかで、PythonのPandasとは比べものになりません。その中で、比較的よく知られているのは、OpenCSV[13]、Jackson の jackson-dataformat-csv[14]、およびいわゆる最速の一義性パーサー[15] です。

Excel は違う. グループのオープン ソース ソフトウェア EasyExcel[16] があり, これは最初の宝石である. 私はそれよりも高速であることを確認することしかできません.

CsvReaderの実装に関しては、似たような製品がたくさん出回っているのでいちいち比較する気力がありません.最速—私は約 1 年前にそれを実装しました.私のオフィスのコンピューターでの CsvReader の速度は、せいぜい一義的なパーサーの 80% ~ 90% にしか達せず、いくら最適化しても引き上げることはできません。その後、私はジェネレーターのメカニズムを発見してリファクタリングし、速度は前者を 30% から 50% 直接上回り、私が知っている同様のオープン ソース製品の最速の実装になりました。

Excel の場合、特定のデータ セットで、実装した ExcelReader はEasyExcel よりも50% ~ 55% 高速であり、POI と比較するのが面倒です。テストの詳細については、上記のリンクを参照してください。

注: 最近、Fastjson の作成者である Gaotie と多くのやり取りがありました.まだ正式にリリースされていないFastjson2 の 2.0.28-SNAPSHOT バージョンでは、 CSV 実装のパフォーマンスは基本的に私の実装と同等でした。複数の JDK バージョン。厳密に言えば、私の実装は、この投稿が公開される前に知られている中でおそらく最速だったとしか言えません (笑)。

ストリームを直接出力できるように EasyExcel を変換します。

前述の EasyExcel はアリの有名なオープンソース製品であり、機能が豊富で品質に優れ、広く評価されています。たまたま、それ自体が IO インタラクションにコールバック関数を使用するもう 1 つの古典的なケースですが、例として使用するのにも非常に適しています。公式ウェブサイトの例によると、コールバック関数に基づいて最も単純な Excel 読み取りメソッドを構築できます。

public static <T> void readEasyExcel(String file, Class<T> cls, Consumer<T> consumer) {
    EasyExcel.read(file, cls, new PageReadListener<T>(list -> {
        for (T person : list) {
            consumer.accept(person);
        }
    })).sheet().doRead();
}

EasyExcel を使用すると、コールバック リスナーを介してデータがキャプチャされます。たとえば、ここの PageReadListener には、内部にリスト キャッシュがあります。キャッシュがいっぱいになったら、それをコールバック関数にフィードしてから、キャッシュの更新を続けます。コールバック関数に基づくこのアプローチは確かに非常に古典的ですが、必然的にいくつかの不都合があります:

  1. コンシューマはプロデューサの内部キャッシュに注意する必要があります。たとえば、ここのキャッシュはリストです。

  2. コンシューマーがすべてのデータを取得したい場合は、リストを入れて 1 つずつ追加するか、毎回 addAll する必要があります。この操作は怠惰ではありません。

  3. 読み取りプロセスをストリームに変換することは困難です. ストリーミング操作はすべてリストに格納し、処理する前にストリームに変換する必要があります. 柔軟性が非常に悪い。

  4. コールバックリスナーを実装するときにこのロジックをオーバーライドしない限り、特定の条件 (数値など) に達した後に直接中断するなど、コンシューマーがデータ生成プロセスに介入することは不便です [17]。

ジェネレーターを使用すると、上記の例で Excel を読み取るプロセスを完全に閉じることができます。コンシューマーはコールバック関数を渡す必要はなく、内部の詳細を気にする必要もありません。ストリームを直接取得するだけです。変換も非常に単純で、主なロジックはそのまま残り、コールバック関数をコンシューマーでラップするだけです。

public static <T> Seq<T> readExcel(String pathName, Class<T> head) {
    return c -> {
        ReadListener<T> listener = new ReadListener<T>() {
            @Override
            public void invoke(T data, AnalysisContext context) {
                c.accept(data);
            }

            @Override
            public void doAfterAllAnalysed(AnalysisContext context) {}
        };
        EasyExcel.read(pathName, head, listener).sheet().doRead();
    };
}

この変換はEasyExcelにPR[18]を提出したのですが、Seqを出力するのではなく、ジェネレータ原理に基づいて構築されたStreamです.構築方法については後で詳しく説明します.

さらに、Excel の解析プロセスをジェネレーター メソッドに変換し、1 回限りのコールバック呼び出しを使用して多数の内部状態の保存と変更を回避することは完全に可能です。これにより、パフォーマンスが大幅に向上します。この作業は上記の CsvReader の一連の API に依存するため、当面 EasyExcel に投稿することはできません。

ジェネレーターでストリームを構築する

まったく新しいデザイン パターンとして、ジェネレーターは確かにより強力なストリーミング API 機能を提供できますが、結局のところ、誰もが最もよく知っている Stream とは異なり、適応コストまたは移行コストが常に発生します。既存の成熟したライブラリの場合、Stream の使用は依然としてユーザーにとって最も責任のある選択です。幸いなことに、仕組みはまったく異なりますが、Stream と Seq は依然として互換性が高いです。

まず第一に、Iterable と同様に、Stream は自然に Seq であることは明らかです。

Stream<Integer> stream = Stream.of(1, 2, 3);
Seq<Integer> seq = stream::forEach;

Seq を Stream に変換できますか? Java Stream によって提供される公式の実装には、ユーザーが反復子をストリームに変換するのに役立つ StreamSupport.stream 構築ツールがあります。このエントリでは、実際にジェネレータを使用して非標準イテレータを構築できます。 hastNext と next を実装する代わりに、 forEachRemaining メソッドを個別にオーバーロードし、それによって Stream の基礎となるロジックにハッキングします — 迷路のようなソース コードで、は非常に隠れたコーナーで、AbstractPipeline.copyInto と呼ばれるメソッドは、ストリームが実際に実行されるときに要素をトラバースするために Spliterator の forEachRemaining メソッドを呼び出します。本物の王子様のためのニセジャコウネコ。

public static <T> Stream<T> stream(Seq<T> seq) {
    Iterator<T> iterator = new Iterator<T>() {
        @Override
        public boolean hasNext() {
            throw new NoSuchElementException();
        }

        @Override
        public T next() {
            throw new NoSuchElementException();
        }

        @Override
        public void forEachRemaining(Consumer<? super T> action) {
            seq.consume(action::accept);
        }
    };
    return StreamSupport.stream(
        Spliterators.spliteratorUnknownSize(iterator, Spliterator.ORDERED),
        false);
}

つまり、ジェネレーターを使用してストリームを構築することもできます! 例えば:

public static void main(String[] args) {
    Stream<Integer> stream = stream(c -> {
        c.accept(0);
        for (int i = 1; i < 5; i++) {
            c.accept(i);
        }
    });
    System.out.println(stream.collect(Collectors.toList()));
}

Turing さん、Stream の作者に感謝します。

もちろん、ここの Iterator の性質が変わったので、この操作にもいくつかの制限があります. parallel メソッドを使用して並行ストリームに変換することはできなくなりました. limit メソッドを使用して数を制限することもできません. . それ以外は、map、filter、flatMap、forEach、collect などのメソッドは、ストリームの中断を伴わない限り、通常どおり使用できます。

無限再帰級数

実用的なアプリケーション シナリオは多くありません。Stream の iterate メソッドは、再帰的な単一シードの無限シーケンスをサポートできますが、プログラマーに最も人気のあるフィボナッチ数列のように、2 つ以上のシードの再帰的再帰は役に立ちません。

public static Seq<Integer> fibonaaci() {
    return c -> {
        int i = 1, j = 2;
        c.accept(i);
        c.accept(j);
        while (true) {
            c.accept(j = i + (i = j));
        }
    };
}

また、別の興味深い応用として、ファレ木の特性を利用してディオファントス近似を行う [22] . つまり、有理数を使って実数を近似するというものです。これは、デモに非常に適した例であり、十分に興味深いものです. スペースの制限があるため、これを拡張することはしません. それについて説明する別の記事を書く機会があります.

ストリームのその他の機能

ストリームの集約

ストリーミング アグリゲーション インターフェイスの設計方法は非常に複雑なトピックです. 真剣に議論したい場合は、数千語を書くことができます. スペースが限られているため、ここにいくつかの文章を示します. 私の意見では、優れたストリーミング API は、最初に Collector を使用して Collector を構築し、次に Stream を使用して Stream のように Collect を呼び出すのではなく、ストリーム自体が集約関数を直接呼び出せるようにする必要があります。次の 2 つの方法を比較すると、どちらが優れているか一目でわかります。

Set<Integer> set1 = stream.collect(Collectors.toSet());
String string1 = stream.map(Integer::toString).collect(Collectors.joinning(","));

Set<Integer> set2 = seq.toSet();
String string2 = seq.join(",", Integer::toString);

この点で、Kotlin は Java よりもはるかに優れています。しかし, 多くの場合, 長所と短所があります. ユーザーの使用よりも機能インターフェイスの観点から, Collector の設計は実際にはより完全です. stream と groupBy については同形です: コレクターの対流で直接実行できるすべてのことを使用できます. groupBy の後、同じコレクターで実行できます。groupBy 自体がコレクターであっても同様です。

したがって、関数の完全性と同型性を維持するだけでなく、ストリームから直接呼び出すためのショートカットを提供することも、より優れた設計です。説明のために、Java と Kotlin が実装されていないが、需要は非常に一般的であり、加重平均が計算される例を次に示します。

public static void main(String[] args) {
    Seq<Integer> seq = Seq.of(1, 2, 3, 4, 5, 6, 7, 8, 9);

    double avg1 = seq.average(i -> i, i -> i); // = 6.3333
    double avg2 = seq.reduce(Reducer.average(i -> i, i -> i)); // = 6.3333
    Map<Integer, Double> avgMap = seq.groupBy(i -> i % 2, Reducer.average(i -> i, i -> i)); // = {0=6.0, 1=6.6}
    Map<Integer, Double> avgMap2 = seq.reduce(Reducer.groupBy(i -> i % 2, Reducer.average(i -> i, i -> i)));
}

上記のコードの Average, Reducer.average と groupBy で使用される average は完全に同形です. つまり、同じ Reducer をストリームで直接使用することも、ストリームをグループ化した後に各サブストリームで使用することもできます. . ストリーム オン. これは Collector に似た一連の API で、Collector のいくつかの問題を解決するだけでなく、より豊富な機能も提供します。要点は、これはオープンであり、メカニズムは誰でも書けるほどシンプルだということです。

ストリームの断片化

セグメンテーション処理は、実はさまざまなストリーミング API の盲点であり、map であれ forEach であれ、前半と後半で異なる処理ロジックを採用することを望む場合もあれば、より直接的には、最初の要素を特別に処理することを望む場合もあります。 . . この点に関して、要素置換置換、セグメント マップ、およびセグメント消費消費の 3 つの API を提供します。

上記の下線からキャメルケースへのシナリオを典型的な例として取り上げます。下線文字列を分割した後、最初の要素に小文字を使用し、残りの要素に大文字を使用します。分割マップ機能を使用すると、この機能をより迅速に実装できます。

static String underscoreToCamel(String str, UnaryOperator<String> capitalize) {
    // split=>分段map=>join
    return Seq.of(str.split("_")).map(capitalize, 1, String::toLowerCase).join("");
}

別の例として、CSV ファイルを解析するときに、ヘッダーがあれば、解析中に個別に処理する必要があります。ヘッダー情報を使用してフィールドを並べ替え、残りのコンテンツを行ごとに DTO に変換します。適切なセグメンテーション ロジックを使用すると、この一見面倒な操作を 1 つのフローで一度に実行できます。

ディスポーザブルフローかリユースフローか?

Stream に精通している学生は、Stream が 1 回限りのストリームであることを知っておく必要があります。これは、そのデータが反復子から取得され、使用済みの Stream を 2 回呼び出すと例外がスローされるためです。Kotlin の Sequence は異なる設計コンセプトを採用しており、そのストリームは、ほとんどの場合再利用可能な Iterable から来ています。しかし、Kotlin がファイル ストリームを読み取るときも、Stream と同じ考え方を使用し、BufferedReader を Iterator としてカプセル化するため、これも 1 回限りです。

上記の 2 つとは異なり、ジェネレーターのアプローチは明らかにより柔軟です。ストリームが再利用可能かどうかは、ジェネレーターによってラップされたデータ ソースが再利用可能かどうかに完全に依存します。例えば、上記のコードでローカルファイルであろうとODPSテーブルであろうと、ジェネレーターでデータソースの構築が完了していれば、当然再利用可能です。通常の List と同じように、同じストリームを複数回使用できます。この観点から、ジェネレーター自体は不変であり、その要素の生成はコード ブロックから直接行われ、動作環境に依存せず、メモリ状態データに依存しません。どのコンシューマーに対しても、同じプロデューサーが一貫したストリームを提供することが期待できます。

発電機の本質は人間と同じ、みんなリピーター

もちろん、リピーターによる繰り返しもコストに依存します. IO などの高コストのストリームを再利用する必要があるシナリオでは、同じ IO 操作を繰り返し実行することは絶対に無理です. ストリームのキャッシュ方法を設計することもできます.キャッシング。

キャッシュする最も一般的な方法は、データを ArrayList に読み込むことです。ArrayList 自体は Seq インターフェースを実装していないため、ArrayList と Seq の両方である ArraySeq を作成することもできます。前に何度も述べたように、List は当然 Seq です。

public class ArraySeq<T> extends ArrayList<T> implements Seq<T> {
    @Override
    public void consume(Consumer<T> consumer) {
        forEach(consumer);
    }
}

ArraySeq を使用すると、ストリーム キャッシングをすぐに実装できます

default Seq<T> cache() {
    ArraySeq<T> arraySeq = new ArraySeq<>();
    consume(t -> arraySeq.add(t));
    return arraySeq;
}

注意深い友人は、並行ストリームを構築するときに、このキャッシュ メソッドを既に使用していることに気付くかもしれません。さらに、ArraySeq の助けを借りて、ストリームの並べ替えを簡単に実現でき、興味のある友人は自分で試すことができます。

バイナリ フロー

コールバックのコンシューマはフローを構築するメカニズムとして使用できるため、興味深い問題が発生します.コールバックがコンシューマではなくバイコンシューマである場合はどうなるでしょうか? ——答えはバイナリーフロー!

public interface BiSeq<K, V> {
    void consume(BiConsumer<K, V> consumer);
}

Binary stream is a brand-new concept . 以前は、Java Stream、Kotlin Sequence、Python ジェネレーターなどのイテレータベースのストリームは、バイナリ ストリームで再生できませんでした。結局のところ、ここにいる全員の next メソッドはオブジェクト インスタンスを吐き出す必要があります。つまり、同時に 2 つの要素を持つストリームを構築したい場合でも、次のような構造にラップする必要があります。ペアとして - その本質はまだ単項フローです。ストリームに多数の要素が含まれる場合、メモリ オーバーヘッドが大きくなる可能性があります。

バイナリ ストリームのように見える Python の zip でさえ:

for i, j in zip([1, 2, 3], [4, 5, 6]):
    pass

ここの i と j は、実際にはタプルをアンパックした結果のままです。

しかし、コールバック メカニズムに基づくバイナリ ストリームはそれらとはまったく異なり、単項ストリームと同じくらい軽量ですこれは、メモリを節約すると同時に高速であることを意味します。たとえば、CsvReader を実装する場合、出力がストリームになるように String.split メソッドを書き換えます.このストリームと DTO フィールド zip はバイナリ ストリームであり、値とフィールドの間で 1 対 1 の一致を実現できます。添字に頼る必要はなく、一時的な配列やリストを作成して保存する必要もありません。分割された各部分文字列は、ライフ サイクル全体で 1 回限りのものであり、使用されるたびに破棄できます。

ここで言及する価値があるのは、Iterable と同様に、Map in Java は本質的にバイナリ ストリームです。

Map<Integer, String> map = new HashMap<>();
BiSeq<Integer, String> biSeq = map::forEach;

BiConsumer ベースのバイナリ ストリームには、TriConsumer ベースの 3 進ストリーム、4 進ストリーム、および IntConsumer や DoubleConsumer などのネイティブ型に基づくストリームも存在する可能性があります。これは非常に大きなストリーム ファミリであり、単項ストリームとは異なる多くの特別な操作さえあるため、ここではあまり拡張しませんが、1 つだけ言及します。

バイナリ ストリーム、ターナリ ストリーム、さらには複数のストリームでさえ、Java で実際の遅延タプルを構築できます関数が複数の戻り値を返す必要がある場合、手動で Pair/Triple を記述することに加えて、BiSeq/TriSeq をジェネレーターの形式で直接返すというより良い選択ができるようになりました。 tuple. レイジー コンピューティングの追加の利点は、本当に必要なときにコールバック関数で消費できます。null ポインターをチェックする必要さえありません。

結論

まず最初に、ここまで読んでくれてありがとう. 伝えたい話は基本的に終わった. 議論されていない興味深い詳細がまだたくさんありますが, 話の完全性には影響しません. もう一度強調したいのは、私が実装した CsvReader シリーズを含め、コード、機能、ケースのいずれであっても、上記のすべてのコンテンツは、すべてのソースであるこの単純なインターフェイスから派生したものであるということです。記事の最後にもう一度書く価値があります。

public interface Seq<T> {
    void consume(Consumer<T> consumer);
}

この魔法のインターフェイスでは、次のように呼びます。

Daoshengyi——最初に Seq 定義

One life two - ストリームとジェネレータの両方である Seq の特性をエクスポートする

2 つは 3 を生む — ジェネレーターは豊富なストリーミング API を実装し、安全で分離された IO ストリームをエクスポートし、最後に非同期ストリーム、同時ストリーム、およびチャネル特性を導き出します。

Sanshengwuwuの部分については、フォローアップ記事があり、できるだけ早く外部に公開することを楽しみにしています.

付録

付録の元のコンテンツには、API ドキュメント、参照アドレス、およびパフォーマンス ベンチマークが含まれます。まだオープンソースではないので、ここではモナドのみ紹介します。

モナド

モナド[24]は圏論からの概念であり、関数型プログラミング言語の代表であるHaskellにおいても非常に重要な設計パターンです。ただし、ストリームやジェネレーターには必要ないため、付録に記載しています。

Monad に言及したい理由は、Seq が unit と flatMap を実装した後、それは自然に一種の Monad になるからです。関連する理論に注意を払う学生にとっては、言及さえしないと少し不快になるかもしれません。残念ながら、Seq は正式にはモナドですが、哲学的にはいくつかの矛盾があります。たとえば、Monad の重要な flatMap はコア定義の 1 つであるだけでなく、結合とアンパックという 2 つの重要な機能も担います。Monad には map さえ必要ありません。flatMap と unit から派生できますが (派生プロセスについては以下を参照)、その逆はできません。ただし、ストリーミング API の場合、map は最も重要で頻度の高い操作ですが、flatMap はそれほど重要ではなく、一般的にはまったく使用されません。

Monad の設計パターンは、いくつかの重要な機能、遅延評価、連鎖呼び出し、および副作用の分離 (純粋な関数の世界では、後者は生命を脅かすイベントと呼ばれることさえあります) を備えているため、高く評価されています。しかし、Java を含むほとんどの通常の言語では、遅延評価を実装するより直接的な方法は、オブジェクト指向 (インスタンス) プログラミングではなく、インターフェイス指向です. インターフェイスは、メンバー変数を持たないため、本質的に遅延です. チェーン操作はストリームの自然な機能であるため、詳細に入る必要はありません。副作用の分離に関しては、これもモナドに限ったことではありません。ジェネレーターは、前回の記事で紹介したクロージャー+コールバックのやり方でもできます。

マップの実装を推測する

まず、マップは unit と flatMap を直接結合することで取得できます。ここではこれを map2 と呼びます。

default <E> Seq<E> map2(Function<T, E> function) {
    return flatMap(t -> unit(function.apply(t)));
}

つまり、T 型の要素は E 型の Seq に変換され、flatMap とマージされます。これは最も直感的で、アプリオリなフローの概念を必要とせず、Monad 固有のプロパティです。もちろん、効率は非常に悪いはずですが、単純化できます。

unit と flatMap の既知の実装

static <T> Seq<T> unit(T t) {
    return c -> c.accept(t);
}

default <E> Seq<E> flatMap(Function<T, Seq<E>> function) {
    return c -> supply(t -> function.apply(t).supply(c));
}

最初にユニットを展開し、上記の map2 の実装に置き換えます。

default <E> Seq<E> map3(Function<T, E> function) {
    return flatMap(t -> c -> c.accept(function.apply(t)));
}

この flatMap の関数を flatFunction に提案し、flatMap を展開すると、

default <E> Seq<E> map4(Function<T, E> function) {
    Function<T, Seq<E>> flatFunction = t -> c -> c.accept(function.apply(t));
    return consumer -> supply(t -> flatFunction.apply(t).supply(consumer));
}

ここで flatFunction には 2 つの連続した矢印があることに気付くのは簡単です。これは、実際には 2 つのパラメーター (t, c) 関数のカリー化と完全に同等です。逆カリー化操作を実行して、この 2 つのパラメーター関数を逆にします。

Function<T, Seq<E>> flatFunction = t -> c -> c.accept(function.apply(t));
// 等价于
BiConsumer<T, Consumer<E>> biConsumer = (t, c) -> c.accept(function.apply(t));

この同等の 2 パラメーター関数は実際には BiConsumer であり、それを map4 に代入すると、次のようになることがわかります。

default <E> Seq<E> map5(Function<T, E> function) {
    BiConsumer<T, Consumer<E>> biConsumer = (t, c) -> c.accept(function.apply(t));
    return c -> supply(t -> biConsumer.accept(t, c));
}

ここでbiConsumerの実パラメータと仮パラメータは完全に一致しているため、そのメソッド本体はすぐ下に置き換えることができることに注意してください。

default <E> Seq<E> map6(Function<T, E> function) {
    return c -> supply(t -> c.accept(function.apply(t)));
}

この時点で、この map6 は、上記のストリーミングの概念から直接記述されたマップとまったく同じです。証拠!

参考リンク:

[1]https://en.wikipedia.org/wiki/Generator_(computer_programming)

[2]https://www.pythonlikeyoumeanit.com/Module2_EssentialsOfPython/Generators_and_Comprehensions.html

[3]https://openjdk.org/projects/loom/

[4]https://en.wikipedia.org/wiki/続き

[5]https://hackernoon.com/the-magic-behind-python-generator-functions-bc8eeea54220

[6]https://en.wikipedia.org/wiki/Continuation-passing_style

[7]https://kotlinlang.org/spec/asynchronous-programming-with-coroutines.html

[8]https://en.wikipedia.org/wiki/Map_(%E9%AB%98%E9%98%B6%E5%87%BD%E6%95%B0)

[9]https://crypto.stanford.edu/~blynn/haskell/io.html

[10]https://www.autohotkey.com/docs/v2/

[11]https://stackoverflow.com/questions/1052476/what-is-scalas-yield

[12]https://stackoverflow.com/questions/10441559/scala-equivalent-of-haskells-do-notation-yet-again

[13]https://opencsv.sourceforge.net/

[14]https://github.com/FasterXML/jackson-dataformats-text/tree/master/csv

[15]https://github.com/uniVocity/univocity-parsers

[16]https://github.com/alibaba/easyexcel

[17]https://github.com/alibaba/easyexcel/issues/1566

[18]https://github.com/alibaba/easyexcel/pull/3052

[20]https://github.com/alibaba/easyexcel/pull/3052

[21]https://github.com/alibaba/fastjson2/blob/f30c9e995423603d5b80f3efeeea229b76dc3bb8/extension/src/main/java/com/alibaba/fastjson2/support/csv/CSVParser.java#L197

[22]https://www.bilibili.com/video/BV1ha41137oW/?is_story_h5=false&p=1&share_from=ugc&share_medium=android&share_plat=android&share_session_id=96a03926-820b-4c9f-a2fd-162944103bed&share_source=COPY&share_tag=s_i×t amp=1663058544&unique_k=p94n8tD

[24] https://en.wikipedia.org/wiki/Monad_(functional_programming)

その他のコンテンツについては、ここをクリックしてクラウド ネイティブ テクノロジ コミュニティに参加し、表示してください。

おすすめ

転載: blog.csdn.net/alisystemsoftware/article/details/130355414