Stream: ループを使わない
背景
不吉な匂い(Code Smell)という,バグではないものの,バグの温床となり得る場所を指す言葉があります.
リファクタリング 第二版 という本で,不吉な匂いにループが新たに追加されています.
forやwhileなどのループはバグの温床になり得るので避けるべきと言われているのです.
なぜなら,1行に書くべきことは1つと言われながらも,for文は1行に初期化式,継続条件,反復式の3つの式が存在しています.
また,配列などの長さを超えてのアクセスは,インデックスで要素にアクセスしているために起こります.
Java 8 から Stream API と呼ばれるデータ処理を行うためのAPIが導入されており,これにより for などのループを置き換えることが可能になります. この Stream API とメソッド定義を簡略化して書けるラムダ式を用いることにより,より簡潔に分かりやすく書けるようになります. そして,この Stream API は Java だけでなく,昨今のプログラム言語で数多くサポートされています.
Stream API
第0講 基礎文法 例題で示した 1以上100未満の奇数一覧を出力するプログラムを例に挙げます.
元のプログラムのは次の通りです.
for(Integer i = 1; i < 100; i++){
// iを割った余りが1であれば,奇数.
if(i % 2 == 1){ // i % 2 != 0 の条件でも可.
System.out.print(i);
System.out.print(" ");
}
}
上のプログラムを Stream API で書き直したものが下のものです.
メソッドの返り値を変数に代入せず,そのままメソッド呼び出しを続けている点に注意して読んでください(メソッドチェイニング (Method chaining)) と呼びます).
Terminal で jshell を実行し,以下のプログラムをコピー&ペーストして実行してみましょう.
System.out.println( // 得られた文字列を出力する.
IntStream.range(1, 100) // 1以上,100未満の各値に対して,続く処理を適用する.
.filter(i -> i % 2 == 1) // iを2で割った余りが1のもの(奇数)のみ残す.
.mapToObj(i -> Integer.toString(i)) // Integer 型を String 型に変換する.
.collect(Collectors.joining(" "))); // 全ての文字列を空白区切りで連結させる.
Stream APIは上記のように,繰り返しの中での様々な処理を関数を渡すことにより,副作用の少ないプログラムを書くことを目指します. 元のプログラムに比べての下のプログラムのメリットは次の通りです.
- プログラムの副作用が少ない.
- 副作用とは元のプログラムで
iに何度も値が再代入されている点です.- 再代入は,現在の
iの値が何であるかを把握するために労力を要するので,ない方が望ましいわけです.
- 再代入は,現在の
- 副作用とは元のプログラムで
forの継続条件であるi < 100の<が<=であるべきか,iの初期値が1で良いのかと悩む必要はありません.- 問題文に記されている通り,以上,未満を表すのが
IntStream.rangeメソッドです.- 以上,以下であれば,
IntStream.rangeClosedを使いましょう.
- 以上,以下であれば,
- 問題文に記されている通り,以上,未満を表すのが
System.out.printは最後に一度だけ実行される(文字列の出力は一般的にオーバーヘッドが大きい).- 各行で何を行っているのかはメソッドを見ると大まかにわかります.
filterは不要な値を削除する,map(mapToObj)は値を変換している,collectは集めている,とおおまかな処理内容が推測できます.- 元のプログラムは
ifの条件で何が起こるかを把握しなければプログラムの内容を読み解けません.
Streamの利用方法
Stream の生成法
ArrayList<K>からStream<K>を入手する.list.stream()
- 配列(
array)からStreamを入手する.Arrays.stream(array)
- その他
Stream.iterate(seed, prev -> prev + step)- 上のように,一番最初の値(
seed)とseedからprev + stepを順に適用した数値を返します.
Stream でできること
filter
Stream の要素を削除します.
引数(Predicate)は要素を受け取り Boolean を返します.
返り値がfalseのものはこのStreamから削除されます(元のListや配列から削除されるわけではありません).
map
Streamの各要素に対して,値の変換を適用します.
引数(Function)は,値を受け取り,変換後の値を返します(元のListや配列の要素が変換されるわけではありません).
forEach
Streamの各要素に対して,処理を行います.
引数(Consumer)は,値を受け取り,何も返しません.
collect
Map 型の Stream
Map はキーと対応するバリューのペアを格納します.
そのため,そのまま Stream の実体を取得できません.
次のように,3つの方法で取得します(Map<K, V>型の変数mapから取得します.K,VはString,Integerなどと読み替えてください)
map.keySet().stream()Stream<K>が返されます.
map.values().stream()Stream<V>が返されます.
map.entrySet().stream()Stream<Map.Entry<K, V>>が返されます.
forEach のみが必要であれば,mapに対して直接 forEach を呼び出すことも可能です.
この場合,map.forEach((k, v) -> ...) のように,キーとバリューの両方を受け取ります.
Streamの適用例
奇数の一覧の別解
第0講 基礎文法 例題で示した 1以上100未満の 奇数一覧の別解.
System.out.println(
IntStream.iterate(1, prev -> prev + 2) // 無限 stream を取得する.
.takeWhile(value -> value <= 100) // takeWhile は Java 11 から利用できる.
// .limit(50) // 最初から50個の要素のみ取得する.Java 8だと takeWhile が使えないため.
.mapToObj(value -> Integer.toString(value))
.collect(Collectors.joining(" ")));
総和を求める.
第0講 練習問題3を Stream で解くと次のようなプログラムになります.
Integer sum(Integer from, Integer max) {
return IntStream.rangeClosed(from, max) // from以上,max以下の範囲の各値に対して
.sum(); // 全てを合計する.
}
ArgsPrinter
第1講 例題 ArgsPrinter を Stream で解くと次のようなプログラムになります.
argsのインデックス番号とその値を出力したいが,Arrays.stream を用いるとインデックス番号が取得できない.
そのため,IntStream.range を用いる.
IntStream.rangeClosed(0, args.length)
.forEach(i -> System.out.printf("%d: %s%n", i, args[i]));
モンテカルロ法による$\pi$の計算
double pi(int loopCount) {
return 4d * IntStream.range(0, loopCount) // 0以上,loopCount以下繰り返す.
.map(i -> calculateDistance()) // 乱数で得た座標と原点の距離を計算する.
.filter(length -> length < 1d) // 得られた距離が 1 より小さいもののみにする.
.count(); // 数を数える.
}
double calculateDistance() {
double x = Math.random();
double y = Math.random();
return Math.sqrt(x * x + y * y);
}
参考資料
- 不吉な匂い
- リファクタリング 第二版(原著)の紹介
- リファクタリング第二版で追加された「不吉な臭い」 (devtab.jp)
- コードの不吉な臭い・バージョン2 (ryo511.info)
- All Loops Are a Code Smell (medium.com)
- リファクタリング 第二版(原著)の紹介
- Stream APIとラムダ式
- Java8 ラムダとStreamAPIを理解する (qiita.com)