エンタープライズギークス (Enterprise Geeks)

企業システムの企画・開発に携わる技術者集団のブログです。開発言語やフレームワークなどアプリケーション開発に関する各種情報を発信しています。ウルシステムズのエンジニア有志が運営しています。

Java8の基本 - 遅延処理

本稿では、Java8における遅延評価のサポートについて紹介する。
遅延評価とは、ある値に対する計算処理を、実際にその値が必要になった時点で行うことであり、関数型言語が一般的にサポートする仕組みである。

Java8では、Stream APIにおいて遅延評価を局所的にサポートしている。このため、本記事では以降、Java8の仕組みを遅延評価ではなく、遅延処理と呼ぶことにする。

Stream API の遅延処理

Stream API ではStreamの要素に対して設定した中間操作は、終端操作を実行するまで遅延される。 この特徴を生かすことで、従来の方法では複雑になってしまう処理を簡潔に記述し、かつ高速に実行できる可能性がある。
こうした遅延処理が有効に働く場合について、IO処理を例に見てみよう。

従来のIO処理のコード

テキストファイルの中から、特定の文字を含む行だけを抽出したり、先頭の100行のみを取得したりするなど、一部だけを取得する処理を考えてみる。

このような抽出処理におけるIO処理は煩雑になりがちである。このため、ファイルを指定してListを返すような共通処理を定義し、そのListに対して抽出処理を書くことが多い。 以下に示すのは、Java 7 から導入されたjava.nio.fileを使ってファイルから全行をリストとして取得するコード例である。

FileSystem fs = FileSystems.getDefault();
List<String> contents = Files.readAllLines(fs.getPath("some_file"));

この方法には以下のような問題がある。

  • いったんListにテキストファイルの全てを展開するため、大きなファイルを読み込むとヒープ領域を圧迫する。
  • 読み込んだ部分の一部しか使わない場合でも、全件読み込みとListからの絞込み処理が必要となる。
  • 全件読み込みを回避するためには、ループ内で抽出処理を行うような、煩雑なコードを記述する必要がある。

遅延処理によるIO処理のコード

以降では、Stream APIを利用した遅延処理のコードを紹介する。

最初に紹介するのは、ファイルの部分読み込みの例である。
まずファイルからStream<String>を返すメソッドを用意する。

// リソースの解放を呼び出し側で行うため、Readerを引数とした。
public static Stream<String> toStream(Reader r) {
    return new BufferedReader(r).lines();
}

このtoStreamメソッドではBufferedReader#linesを使ってStreamを生成している。 このコードだけを見ると、linesメソッドを呼び出した時点で全件読み込みが行われるように見えるかもしれないが、この時点ではファイルは読み込まれない。実際に読み込みを開始するのはStreamの終端操作を実行した時点である。

このtoStreamを呼び出した後で、中間操作のskiplimitを指定して、ファイルの一部のみを取得するコードは次のようになる。

// 101行目から200行目までを取得する。
try (Reader r = new FileReader("somefile")) {
    List<String> contents = toStream(r)
        .skip(100).limit(100).collect(Collectors.toList());
}

このコードでは、最初の100行をスキップしたあと、200行目まで読み込みを行った時点でファイル読み込みが終了する。
従来のAPIでもループを使用して部分的な読み込み処理を行う事は可能であるが、ループの中に分岐,continue,breakが混じったコードとなってしまうだろう。
Stream APIによるコードでは、Streamに件数制限を中間操作で設定することで容易に実現できる。

次に、ファイルの中で"test"という文字列が含まれている行を取得するコード例を紹介する。

try (Reader r = new FileReader("somefile")) {
    List<String> contents = toStream(r)
        .filter(l -> l.contains("test")).collect(Collectors.toList());
}

ここでは、全件読み込みにはなるものの、"test"という文字列を含む行のみが変数contentsに格納されるため、メモリ消費は抑えることができる。

このようにStream APIの遅延処理を利用することで、リソースから部分的な取得を行う場合に実行効率の良いアプリケーションを簡潔に記述できる。

生成元の変更

先ほども説明したように、Stream APIでは、生成操作や中間操作を呼び出しただけでは処理は何も行われず、実際に処理が行われるのは終端操作を呼び出したタイミングになる。
このため、一般的には、終端操作を開始するまでであれば、ストリーム生成元のオブジェクトに対して変更が可能であり、変更した内容は終端操作にも反映される。

これは、以下のコードで確認できる。

List<String> list = new ArrayList<>();
list.add("1");
list.add("2");
Stream<String> stream = list.stream();
// ストリーム作成後に変更
list.add("3");
list.add("4");
stream
  .map(Integer::parseInt)
  .filter(n -> n % 2 == 0)
  .forEach(System.out::println); //2 4

この性質を敢えて利用する場面はあまり考えられないが、覚えておくと良いだろう。

ラムダ式による遅延処理

ここまでStream APIの遅延処理を説明してきた。Java8では、Stream API以外に、ラムダ式を使用しても遅延処理を実現できる。

シンプルな例

例で説明しよう。

methodA(methodB())

のようなコードを書いた場合、最初にmethodBを実行し、その実行結果がmethodAに渡される。

このとき、methodAの処理の中で引数として渡された値を必要としない場合があるとしよう(methodAで何らかのチェックを行って、チェックが通るときだけ引数の値を使うなど)。
さらに、methodBの処理が重いため、methodAのチェックが通った場合にのみmethodBを実行させたいものとする。

このようなニーズは、methodAの引数をラムダ式Supplier)に変更することで実現できる。具体的なシグニチャは次の通り。

methodA(() -> methodB())

このようにすることで、methodAの中でmethodBの実行結果を取得するタイミングを制御できる(具体的な制御方法は後述する)。

ログ出力メソッド

上記と同様の内容が、java.util.logging.Loggerのログ出力メソッドに追加されている。

従来、ログ出力を行うときは本来の処理効率を落とさないよう、ログレベルを判定するメソッドを使う必要があった。

Logger logger = Logger.getLogger("test");
    
if (logger.isLoggable(Level.INFO)) {
    logger.info("heavy process log.");
}

これは本来の処理に必要のない分岐があるため、可読性を下げてしまっている。

java8ではLoggerの各種ログ出力メソッドに、Supplier<String>を引数に取るメソッドが追加されている。 infoログのメソッドシグネチャは以下の通りだ。

void info(Supplier<String> msgSupplier)

このメソッドは、ログレベルが満たす場合のみラムダ式を実行してログ出力を行う。 よって、上記と同等のコードが以下のように記述できる。

Logger logger = Logger.getLogger("test");
        
logger.info(() -> "heavy process log.");

分岐がない分、コードが見やすくなっている。

遅延処理のタイミングを制御するコード例

以下に、任意のラムダ式を受け取り、処理時間を計測するコード例を使って、遅延処理のタイミングを制御する例を示す。

以下に示すコードは、1つ前の節で説明した

medhodA(() -> methodB())

という呼び出しを

time(() -> process())

に置き換えたものに相当する。

/** 処理時間計測 */
public static <V> V time(Supplier<V> f) {
    long start = System.currentTimeMillis(); //開始時刻を記録
    V v = f.get(); // 任意の処理の実行。
    System.out.println("end " + (System.currentTimeMillis() - start) + "ms."); //所要時間を出力。
    return v;
}
/** 計測対象メソッド */
public static int process() {
    try {
        Thread.sleep(1000L);
    } catch (InterruptedException ex) {
    }
    return 1;
}

public static void main(String[] args) throws IOException {
    int res = time(() -> process());
    System.out.println(res); // 1
}

ラムダ式で渡したprocessメソッドが実際に実行されるのは、 timeメソッドの中のf.get()の時点である。

まとめ

本稿では、遅延処理の仕組みを使うことで、計算結果が必要なタイミングで初めて計算が行われること、および、ラムダ式を使うことで少ないコード量で遅延処理を導入できることを解説した。

余談であるが、 Haskellは上記のような遅延処理や、変数の初期化などあらゆる処理がデフォルトで遅延される。Scalaでは、名前渡しと呼ばれる文法を使用することで上記遅延処理をより少ないコード量で実現でき、またlazyキーワードで変数初期化の遅延が可能である。

[前多 賢太郎]