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]));
モンテカルロ法によるπの計算
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)