該当する結果がありません

一致する検索結果がありませんでした。

お探しのものを見つけるために、以下の項目を試してみてください。

  • キーワード検索のスペルを確認してください。
  • 入力したキーワードの同義語を使用してください。たとえば、「ソフトウェア」の代わりに「アプリケーション」を試してみてください。
  • 下記に示すよく使用される検索語句のいずれかを試してみてください。
  • 新しい検索を開始してください。
急上昇中の質問
Java Magazineロゴ

Java Magazine2014年5月号からの転載。講読申込み受付中。

Java SE 8のストリームを使用したデータ処理、パート1

Raoul-Gabriel Urma著

ストリームの操作によって、データ処理における洗練された問合せを表現する

コレクションがなければ何もできません。ほぼすべてのJavaアプリケーションで、コレクションの作成処理が行われます。コレクションは、多くのプログラミング・タスクにとって基本的な機能であり、コレクションによってデータのグループ化と処理が可能になります。たとえば、顧客の取引明細書を表すための銀行取引コレクションを作成できます。その後、そのコレクション全体を処理して、顧客の支出額を計算できます。このように重要度が高いにもかかわらず、Javaにおけるコレクション処理は、完全とは言えません。

まず、コレクションに対する典型的な処理パターンは、SQLの操作に類似しています。「検索」(例:最大値を含む取引の検索)や「グループ化」(例:食料品の購入に関連するすべての取引のグループ化)がそのような操作の例として挙げられます。ほとんどのデータベースでは、これらの操作を宣言的に記述できます。たとえば、以下のSQL問合せにより、最大値を含む取引のIDを検索できます。"SELECT id, MAX(value) from transactions".

このSQLから分かるとおり、どのように最大値を計算するかを開発者が実装する必要はありません(ループや、最大値を記録する変数などは使用していません)。SQLでは単に、何をしてほしいのかを表現します。このような基本的な考え方があるため、開発者が明示的な実装方法に悩む必要はほとんどありません。自分の代わりに実装してもらえるのです。なぜ、コレクションではSQLと同じようにできないのでしょうか。ループを使用して上記の操作を何度も実装していることに、ふと気付くことも多いはずです。

次に、膨大なコレクションを効率的に処理するためにどのような方法をとれるでしょうか。理想的には、処理を高速化するためにマルチコア・アーキテクチャを利用したいのですが、並列処理のコーディングは困難でミスが起こりやすい作業です。 

Java SE 8が救世主となります。Java SE 8のAPI設計者たちは、ストリームという新しい抽象概念の導入に向けて、APIの更新を進めています。ストリームを使用すれば、データを宣言的に処理できるようになります。さらに、マルチスレッドのコードを1行も記述せずに、マルチコア・アーキテクチャを利用できるようになります。よさそうだと思いませんか。本記事から始まるシリーズでは、このストリームについて掘り下げます。

驚くような概念が登場:この2つの操作で要素を「永久に」作成できます。

ストリームで何ができるかについて詳細を説明する前に、まずは例を見て、Java SE 8のストリームがもたらす新しいプログラミング・スタイルの感覚をつかみましょう。ここでは、食料品(grocery)型のすべての取引(Transaction)を検索して、取引額の降順でソートして取引IDのリストを返す必要があると仮定します。Java SE 7ではリスト1のように実装し、Java SE 8ではリスト2のように実装することになります。

List groceryTransactions = new Arraylist<>();
for(Transaction t: transactions){
  if(t.getType() == Transaction.GROCERY){
    groceryTransactions.add(t);
  }
}
Collections.sort(groceryTransactions, new Comparator(){
  public int compare(Transaction t1, Transaction t2){
    return t2.getValue().compareTo(t1.getValue());
  }
});
List transactionIds = new ArrayList<>();
for(Transaction t: groceryTransactions){
  transactionsIds.add(t.getId());
} 

リスト1

List transactionsIds = 
    transactions.stream()
                .filter(t -> t.getType() == Transaction.GROCERY)
                .sorted(comparing(Transaction::getValue).reversed())
                .map(Transaction::getId)
                .collect(toList()); 

リスト2

図1に、Java SE 8のコードを図解します。まず、取引のリスト(データ)からストリームを取得します。そのために、Listで利用可能なstream()メソッドを使用します。次に、いくつかの操作(filter、sorted、map、collect)を連結してパイプラインを形成します。この作業は、データに対する問合せを形成しているものと見なすことができます。

streams-f1

図1 コードのパラレル化も、Java SE 8では簡単です。上記のstream()をリスト3のようにparallelStream()に置き換えれば、Streams APIの内部的な処理によって問合せが分割され、コンピュータの複数のコアが利用されるようになります。

List transactionsIds = 
    transactions.parallelStream()
                .filter(t -> t.getType() == Transaction.GROCERY)
                .sorted(comparing(Transaction::getValue).reversed())
                .map(Transaction::getId)
                .collect(toList()); 

リスト3

このコードがやや難しく感じるとしても、心配は不要です。コードの仕組みについては以降の項で詳しく見ていきます。ただし、ラムダ式(例:t-> t.getCategory() == Transaction.GROCERY)とメソッド参照(例:Transaction::getId)が使用されている点には注意してください。これらの機能についてはすでになじみがあるでしょう(ラムダ式について復習する場合は、本記事の最後に挙げる、過去のJava Magazineの記事やその他のリソースを参照してください)。

現時点では、ストリームは、「データのコレクションに対する、SQLに似た効率的な操作」を表す抽象概念であると考えてください。さらに、これらの操作はラムダ式を使用して簡潔にパラメータ化できます。

Java SE 8のストリームに関する本シリーズ記事をすべて読めば、Streams APIを利用してリスト3のようなコードを記述し、強力な問合せを表現できるようになります。

ストリームを使用した開発の開始

まずは理論について少し学習しましょう。ストリームはどのように定義されているでしょうか。短い定義としては、「ソースから取得した一連の要素であり、集計操作をサポートするもの」です。この定義を分解します。 

  • 一連の要素:ストリームは、特定の要素の型を持つ値の順序付きセットに対するインタフェースを提供する。ただし、実際に要素が格納されるわけではなく、オンデマンドで処理される。
  • ソース:ストリームは、コレクション、配列、I/Oリソースなどのデータ提供元からのデータを利用する。
  • 集計操作:ストリームは、SQLに似た操作や関数型プログラミング言語の一般的な操作(filter、map、reduce、find、match、sortedなど)をサポートする。 

さらに、ストリームの操作には、コレクションの操作とは大きく異なる以下の2つの基本的な特徴があります。

  • パイプライン:ストリームの操作の多くはストリーム自体を返す。そのため、操作を連結して大きなパイプラインを形成できる。その結果、遅延省略などの最適化が実現される(後述)。
  • 内部イテレーション:明示的なイテレーション(外部イテレーション)が行われるコレクションとは対照的に、ストリーム操作のイテレーションは自動的に行われる。 

すでに紹介したコード例を再度見ながら、これらの考え方について説明します。図2は、リスト2をより詳細に図解したものです。

streams-f2

図2 まず、stream()メソッドを呼び出して、取引のリストからストリームを取得します。データソースはこの取引のリストであり、ストリームに対して一連の要素を提供します。次に、ストリームに対して以下に示す一連の集計操作を適用します。filter(フィルタ条件に基づいて要素をフィルタする)、sorted(コンパレータに基づいて要素をソートする)、map(情報を抽出する)の各操作です。collectを除くこれらの操作はStreamを返すため、連結してパイプラインを形成できます。このパイプラインは、ソースに対する問合せと見なすことができます。

実際の処理は、collectが呼び出されるまで実行されません。collect操作によりパイプラインの処理が開始され、結果が返されます(返される結果はStreamではなく、この例ではListです)。collectの詳細は今後の記事で取り上げるため、今のところは気にしないでください。現時点では、collectは、ストリームの要素を蓄積して集計結果を生成するための各種レシピを引数として受け取る操作であると理解しておいてください。この例の場合、toList()が、StreamをListに変換することを示すレシピを表します。

ストリームで利用できる各メソッドについて確認する前に、一息ついて、ストリームとコレクションの概念的な違いを考察しましょう。

ストリームとコレクションの比較

Javaの考え方として、既存のコレクションと新しいストリームはともに、一連の要素へのインタフェースを提供します。では、これらの違いは何でしょうか。一言で言えば、コレクションはデータに関する概念であり、ストリームは計算に関する概念です。

DVDに収録された映画を例に挙げると、全体的なデータ構造が格納されているという点で、この映画はコレクションです(構成要素がバイト・データなのかフレームなのかはここでは不問です)。一方、同じ動画をインターネット経由のストリーミングにより視聴する場合には、この映画は(バイトまたはフレームの)ストリームとなります。このストリーミング・ビデオ・プレイヤーは、ユーザーが視聴する時点より前の数フレームのみをダウンロードしておけばよいのです。そうすれば、ストリーム内の大半の値を計算し終える前でも、ストリームの開始時点から値を表示し始めることができます(サッカーの試合のライブ・ストリーミングを思い浮かべてみてください)。

大ざっぱに言えば、コレクションとストリームの違いは、計算を実行するタイミングにあります。コレクションはメモリ内のデータ構造です。データ構造の現在の値がすべて、コレクション内に保持されます。つまり、コレクション内のすべての要素は、計算してからコレクションに追加する必要があります。これに対して、ストリームは、概念的には固定されたデータ構造ですが、そのデータ構造に格納される要素はオンデマンドで計算されます。

Collectionインタフェースを使用するためには、ユーザーがイテレーションを記述する必要があります。そのためには、たとえば拡張形式のforループ(foreachと呼ばれる)を使用します。このようなイテレーションは、外部イテレーションと呼ばれます。

これに対して、Streamsライブラリでは内部イテレーションが使用されます。イテレーションや、出力されるストリームの値の保管が自動的に実行されます。開発者の作業は、何を実行するかを示す関数を指定することだけです。リスト4(コレクションを使用した外部イテレーション)およびリスト5(ストリームを使用した内部イテレーション)のコードにより、この違いについて説明します。

List transactionIds = new ArrayList<>(); 
for(Transaction t: transactions){
    transactionIds.add(t.getId()); 
}  

リスト4

List transactionIds = 
    transactions.stream()
                .map(Transaction::getId)
                .collect(toList());   

リスト5

リスト4では、取引のリストを明示的にイテレーションして、取引IDを順に抽出し、アキュムレータに追加しています。これに対して、ストリームを使用する場合は明示的なイテレーションを記述しません。リスト5のコードでは問合せを組み立てています。パラメータ化されたmap操作により取引IDを抽出し、collect操作により、出力されたStreamをListに変換しています。

以上で、ストリームの定義と、ストリームを使用して実行できることについて理解できました。次に、独自のデータ処理の問合せを表現できるようになるために、ストリームでサポートされる各種操作について見ていきます。

ストリームの操作:データ処理のためのストリームの有効活用

java.util.stream.Stream内のStreamインタフェースには多くの操作が定義されていますが、これらの操作は2つのカテゴリに分類できます。図1に図解した例には以下の操作があります。 

  • filter、sorted、map:連結してパイプラインを形成可能
  • collect:パイプラインを閉じて結果を返却 

連結可能なストリームの操作は、中間操作と呼ばれます。中間操作を連結できるのは、各操作の戻り型がStreamであるためです。ストリーム・パイプラインを閉じる操作は終端操作と呼ばれます。終端操作は、パイプラインの結果を生成します。結果の型は、List、Integerはもちろん、void(非Streamの任意の型)にすることもできます。

「なぜこの違いが重要なのだろう」と思った方もいるでしょう。ストリーム・パイプラインの終端操作が呼び出されるまで、中間操作は何の処理も実行しません。つまり中間操作は「遅延型」です。通常、中間操作は「マージ」可能であり、終端操作によって1つのパスにまとめることができるためです。

List numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8);
List twoEvenSquares = 
    numbers.stream()
           .filter(n -> {
                    System.out.println("filtering " + n); 
                    return n % 2 == 0;
                  })
           .map(n -> {
                    System.out.println("mapping " + n);
                    return n * n;
                  })
           .limit(2)
           .collect(toList());
  

リスト6

たとえば、リスト6のコードでは、指定した数値のリストから、偶数の2乗を2つ計算します。驚くことに、この出力は以下のようになります。

filtering 1
filtering 2
mapping 2
filtering 3
filtering 4
mapping 4 

この理由は、limit(2)で省略が使用されることにあります。結果を返すために、ストリーム全体ではなく一部のみを処理すればよいのです。and演算子で連結された長いBoolean式を評価する場合と同様です。式の1つがfalseを返したら、式のすべてを評価しなくても、その時点で式全体がfalseであると推論できます。この例では、limit操作はサイズ2のストリームを返します。 

さらに、filter操作とmap操作は同じパスにマージされています。

これまでに学習したことをまとめると、一般的にストリームの操作には以下の3点が関係します。 

Streams APIの内部的な処理によって問合せが分割され、コンピュータの複数のコアが利用されるようになります。

  • 問合せの対象となるデータソース(コレクションなど)
  • 一連の中間操作(ストリーム・パイプラインを形成)
  • 1つの終端操作(ストリーム・パイプラインを実行して結果を生成) 

以降では、ストリームで利用できる操作の一部を確認していきます。操作の一覧については、java.util.stream.Streamインタフェースの説明を参照してください。また、そのほかの例については、本記事の最後に挙げるリソースを参考にしてください。

フィルタ:ストリームから取得した要素をフィルタするための操作は以下のとおりです。 

  • filter(Predicate):条件(java.util.function.Predicate)を引数として受け取り、指定された条件を満たすすべての要素を含むストリームを返す
  • distinct:一意の要素を含むストリームを返す(一意性はストリーム要素のequals実装に基づく)
  • limit(n):指定したサイズ(n)以下のストリームを返す
  • skip(n):最初のn個の要素を破棄したストリームを返す 

検索と一致:よくあるデータ処理パターンは、指定したプロパティと一致する要素があるかを判断することです。この目的で、anyMatch、allMatch、noneMatchの各操作を使用できます。これらのメソッドはすべて、条件を引数として受け取り、booleanを結果として返します(つまり終端操作です)。たとえば、allMatchを使用して、取引のストリーム内にあるすべての要素に100よりも大きい値が含まれているかを確認できます(リスト7)。

boolean expensive =
    transactions.stream()
                .allMatch(t -> t.getValue() > 100);  

リスト7

また、Streamインタフェースには、指定した条件を満たす要素を取得するためのfindFirst、findAnyという操作もあります。これらのメソッドはOptionalオブジェクトを返します(リスト8)。

Optional = 
    transactions.stream()
                .findAny(t -> t.getType() == Transaction.GROCERY); 

リスト8

Optional<T>クラス(java.util.Optional)は、値の有無を表すコンテナ・クラスです。リスト8のfindAnyでは、grocery型の取引が見つからない可能性があります。このOptionalクラスには、要素の存在を確認するための複数のメソッドがあります。たとえば、取引が存在する場合は、リスト9に示すようにifPresentメソッドを使用して、オプション・オブジェクトに対する操作を適用できます(リスト9では単純に取引を出力しています)。

 transactions.stream()
              .findAny(t -> t.getType() == Transaction.GROCERY)
              .ifPresent(System.out::println); 

リスト9

マップ:ストリームはmapメソッドをサポートします。このメソッドは、関数(java.util.function.Function)を引数として受け取り、ストリームの要素を別の形式に転換します。この関数は各要素に適用され、各要素を新しい要素に「マップ」します。

たとえば、ストリームの各要素から情報を抽出するために使用できます。リスト10に示す例では、あるリストの各単語の長さから成るリストを返しています。リデュース:これまで見てきた終端操作の戻り型は、boolean(allMatchなど)、void(forEach)、またはOptionalオブジェクト(findAnyなど)でした。collectを使用してStream内のすべての要素をListにまとめる方法も確認しました。

List words = Arrays.asList("Oracle", "Java", "Magazine");
 List wordLengths = 
    words.stream()
         .map(String::length)
         .collect(toList()); 

リスト10

しかし、ストリーム内のすべての要素をまとめて、より複雑な処理の問合せ(「IDが最大である取引を取得する」、「すべての取引の値を合計する」など)を組み立てることもできます。このような複雑な処理を実現するのが、ストリームのreduce操作です。この操作は、結果が生成されるまで、各要素に対して特定の操作(例:2つの数値の加算)を繰り返し適用します。関数型プログラミングでreduce操作がしばしば折りたたみ操作と呼ばれるのは、この操作が、1枚の大きな紙(ストリーム)を繰り返し「折って」(fold)、1つの小さな四角形(折りたたみ操作の結果)を作ることにたとえられるからです。

まずはforループを使用した場合にリストの合計をどのように計算できるかを考えてみましょう。

int sum = 0;
for (int x : numbers) {
    sum += x; 
} 

この例では、加算演算子を使用して数値リストの各要素を繰り返し連結し、結果を生成しています。本質的には、複数の数値から成るリストを1つの数値に「減らして」(リデュースして)いると言えます。このコードには2つのパラメータがあります。1つはsum変数の初期値(この場合は0)、もう1つはリストの全要素をまとめるための操作(この場合は+)です。

リスト11のようにストリームのreduceメソッドを使用すれば、ストリームの全要素を合計できます。このreduceメソッドは、以下の2つの引数を受け取ります。


int sum = numbers.stream().reduce(0, (a, b) -> a + b);  

リスト11 

  • 初期値(この場合は0)
  • BinaryOperator<T>:2つの要素をまとめて新しい値を生成 

reduceメソッドは本質的に、繰り返される適用処理のパターンを抽象化したものです。「積を計算する」、「最大値を計算する」などの問合せ(リスト12)は、reduceメソッドの特殊なユースケースになります。

int product = numbers.stream().reduce(1, (a, b) -> a * b);
int product = numbers.stream().reduce(1, Integer::max);  

リスト12

数値ストリーム

前項までに、reduceメソッドを使用して、ストリームを構成する整数の合計を計算できることを確認しました。しかし、この計算にはコストが伴います。Integerオブジェクトを繰り返し加算するために多数のボクシング操作が実行されるからです。リスト13のように、sumメソッドを呼び出すことで、コードの意図について明確化できた方がよいと思いませんか。

int statement = 
    transactions.stream()
                .map(Transaction::getValue)
                .sum(); // error since Stream has no sum method   

リスト13

Java SE 8では、この問題を解消するために、IntStream、DoubleStream、LongStreamという3つのプリミティブな特化型ストリーム・インタフェースが導入されます。それぞれ、ストリームの要素がint、double、longに特化されます。

ストリームを特化型のバージョンに変換するためによく使用されるメソッドが、mapToInt、mapToDouble、mapToLongです。これらのメソッドの動作は前述のmapメソッドとまったく同様ですが、Stream<T>の代わりに特化型のストリームが返されます。たとえば、リスト13のコードはリスト14のように修正できます。また、boxed操作を使用して、プリミティブなストリームをオブジェクトのストリームに変換することもできます。

int statementSum = 
    transactions.stream()
                .mapToInt(Transaction::getValue)
                .sum(); // works!

リスト14

最後に、数値ストリームの便利な形式として、数値の範囲があります。たとえば、1から100までの全数値を生成したい場合があります。Java SE 8では、IntStream、DoubleStream、LongStreamに、そのような範囲を生成するための2つの静的メソッドである、rangeとrangeClosedが導入されます。

これらのメソッドは、範囲の開始値を第1パラメータとして、範囲の終了値を第2パラメータとして受け取ります。ただし、rangeの場合はその開始値と終了値が範囲に含まれず、rangeClosedの場合はそれらの値が範囲に含まれます。リスト15に、rangeClosedを使用して10から30までのすべての奇数値から成るストリームを返す例を示します。

IntStream oddNumbers = 
    IntStream.rangeClosed(10, 30)
             .filter(n -> n % 2 == 1); 

リスト15

ストリームの組み立て

ストリームを組み立てる方法はいくつかあります。コレクションからストリームを取得する方法についてはすでに確認しました。また、数値のストリームについても色々試しました。さらに、値、配列、ファイルからストリームを作成することもできます。そして、関数からストリームを生成して無限のストリームを生成することまでも可能です。 

値や配列からのストリームの作成は簡単です。単純に、値の場合はStream.of、配列の場合はArrays.streamという静的メソッドを使用します(リスト16)。

明示的なイテレーション(外部イテレーション)が行われるコレクションとは対照的に、ストリーム操作のイテレーションは自動的に行われます。

Stream numbersFromValues = Stream.of(1, 2, 3, 4);
int[] numbers = {1, 2, 3, 4};
IntStream numbersFromArray = Arrays.stream(numbers);
  

リスト16

また、Files.lines静的メソッドを使用して、ファイルをデータ行のストリームに変換することもできます。たとえば、リスト17では、ファイル内の行数をカウントします。

long numberOfLines = 
    Files.lines(Paths.get(“yourFile.txt”), Charset.defaultCharset())
         .count();
  

リスト17

無限ストリーム:ストリームに関する第1回の記事をまとめる前に、衝撃的な考え方を紹介します。前項までに、ストリームの要素はオンデマンドで生成されることを理解できました。また、関数からストリームを作成するための、Stream.iterate、Stream.generateという2つの静的メソッドがあります。しかし、要素がオンデマンドで計算されることから、これら2つの操作により要素を「永久に」生成することが可能です。いわゆる無限ストリームであり、サイズが固定されないストリームです(一方、サイズが固定されたコレクションから作成したストリームの場合は、固定サイズになります)。

リスト18に、iterateを使用して、10の倍数であるすべての数値から成るストリームを作成する例を示します。iterateメソッドは初期値(例では0)とラムダ式(UnaryOperator<T>型)を引数として受け取ります。このラムダ式は、生成された新しい値のそれぞれに対して、次々に適用されます。


Stream<Integer> numbers = Stream.iterate(0, n -> n + 10);  

リスト18

無限ストリームは、limit操作を使用して固定サイズのストリームに変換できます。たとえば、例に示したストリームのサイズを5に制限できます(リスト19)。


  numbers.limit(5).forEach(System.out::println); // 0, 10, 20, 30, 40  

リスト19

まとめ

Java SE 8で導入されるStreams APIを使用すれば、データ処理において、洗練された問合せを表現できます。本記事では、ストリームでサポートされる数々の操作(filter、map、reduce、iterateなど)を組み合わせることで、データ処理において、簡潔で表現豊かな問合せを記述できることを確認しました。このような新しいコーディング方法は、Java SE 8より前のリリースにおけるコレクションの処理方法とはかなり異なります。しかし、以下をはじめとする多くの利点があります。まず、Streams APIでは、遅延や省略といったさまざまな技術によって、開発者が記述したデータ処理の問合せが最適化されます。次に、ストリームは、マルチコア・アーキテクチャを利用するように自動的にパラレル化されます。本シリーズの次回記事では、flatMapやcollectなどの高度な操作について詳しく見ていきます。ご期待ください。

OTN Japan Newsletter ご購読のご案内

raoul-headshot

Raoul-Gabriel Urma:ケンブリッジ大学において計算機科学の博士号を取得見込。大学ではプログラミング言語の研究に従事。『Java 8 in Action: Lambdas, Streams and Functional-style Programming』(Manning、2014年)の著者。