初投稿です。
「JavaのStream使いながらでも、まぁコードは書けるよ、そりゃググりながらだけど」くらいの読者想定で。
#Tweet
標題の通り、JavaのStreamに関して常々妙な動きだなぁと感じることが2つほど有ったので、実際どれくらい直感に反するのかをtwitter上の4択にて質問してみることに。
で、回答していただいた手前ちゃんと説明も付そうと思ったんですけど、英語だと書くの怠い上に誰も読まねえんだよなぁ。じゃあ他所に書きますか、という流れです。
(Stream practice quiz)
— ななかけるゼロ/虹目うつろ (@Iridescent_Null) October 2, 2019
What's the result in this simple method (Java 10)? pic.twitter.com/ax8EFCBxXN
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;
public class Streamer {
public static void main(String... args) {
List<Integer> list = new ArrayList<>();
list.add(1);
list.add(null);
list.add(null);
list.add(null);
list.add(null);
long number = Stream.of(list)
.peek(i->{if(i!=null)System.out.print("Peek! ");})
.count();
System.out.print(number);
}
}
選択肢
- Peek! 5
- Peek! 1
- 5
- 1
回答結果が以下のようになりまして(御回答下さった皆様、有り難うございます)、
Choice:
— ななかけるゼロ/虹目うつろ (@Iridescent_Null) October 2, 2019
んで、実行結果がこちら
1
総数が9なのでまぁ大きなことは言えませんが、やっぱり分かりづらいよなぁ、と。
#説明
上記のStreamer.main()には、2つのpitfall(しかも文法的には全く正しいので、コンパイルエラーも警告もでないという素晴らしい感じのやつ)が存在しております。
- Stream.of(T t)
- count()と中間操作の関係
ああ、いや、ワンライナーのifも個人的にクソだと思いますけど、そこの話じゃないです。
###Stream.of()
古代兵器である配列君を近代兵器にアップグレードする感が有って、個人的に好きなstaticメソッド第一位のStream.of()ですが、オーバーロードはこんな感じにされています。
method | 説明 |
---|---|
static <T> Stream<T> of(T t) | Returns a sequential Stream containing a single element. |
static <T> Stream<T> of(T... values) | Returns a sequential ordered stream whose elements are the specified values. |
###なんのこっちゃというと、
Integerを一個入れれば、(T t)の方が呼ばれ、その一個のIntegerだけが入ったStream<Integer>が返ってきます。200個Stringを入れれば、(T... t)の方が呼ばれ、要素数200のStream<String>が返ってきます。
「T...」で表されている可変長引数は配列も受け取ることが出来るので、長さ3億のBooelan[]を放りこめば要素数3億のStream<Boolean>が返ってきます。やったね。
###で、何が問題か
外から見たStream.of()の挙動は以上です。実にシンプル。
で、こんなにシンプルなのが問題でして、つまりStream.of()君は、Listは愚かCollectionだのIteratableと言ったインターフェイスは特に認識しません。彼が中身をかっさばいて展開する気になるのは混じりっけ無しの配列だけで、他のオブジェクトは、いくら人間には中身が詰まっているように見えようがただの単一のインスタンスとして処理されます。
という訳で、
List<Integer>
を 1つ 与えると、Stream.of()君は、複数のInteger ではなく、1つのlistが流れるパイプラインを生成してくれるという寸法です。故にcount()の返り値は1。だってlistが1個だもん。なんじゃそりゃ。
実際、以下のようにflatMapでかっ捌けばちゃんとIntegerが5個流れるようになってcount()で5が返ってきます。
long number = Stream.of(list)
.flatMap(List::stream)
.peek(i->{if(i!=null)System.out.print("Peek! ");})
.count();
まぁ、Stream.of()の、というよりは可変長引数の罠って感じですが、List.stream()が有る為にうっかり勘違いされることが多いように感じているんですよね。
というわけで、冒頭のコードをぱっと見で期待されるように動かすには、
Stream.of(list).peek(this::hogehoge).count();
ではなく、
list.stream().peek(this::fugafuga).count();
が手っ取り早いでしょう。一応Stream.of(list)からのflatMap(List::stream)でも動きますが、ほら可読性とかさぁ。
#count()と中間操作
冒頭のコードなんですけど、実はJava 8環境で動かすと「Peek! 1」とちゃんとpeek()が鳴いてくれます。しかし実行例では明らかにpeek()がよばれておらず、何じゃこりゃって話。
まず大雑把な話をすると、count()は見るからに数を数える為に存在している訳ですが、peek()はデバッグの為に有るのだとJavadocに記述されています。
peek()
API Note:
This method exists mainly to support debugging, where you want to see the elements as they flow past a certain point in a pipeline:
という訳で、なのかどうかはともかく、結果としてはJava 9からは「中間操作がpeek()を初めとする副作用のみを示すものしかなく、つまりcount()で得られる値が明確である場合、 全ての中間操作がぶっ飛ばされる」 ことになってました。なんてこった。
count()
API Note:
(中略)
List<String> l = Arrays.asList("A", "B", "C", "D");
long count = l.stream().peek(System.out::println).count();
The number of elements covered by the stream source, a List, is known and the intermediate operation, peek, does not inject into or remove elements from the stream (as may be the case for flatMap or filter operations). Thus the count is the size of the List and there is no need to execute the pipeline and, as a side-effect, print out the list elements.
恥ずかしながら、「デバック用とはいえ使えるものは使っていいでしょー」とpeek()で必要な処理を日々書いてしまっていたのでぶったまげた次第で、想定されていない様にAPIを使うのはリスキーだなぁと再認識した次第です。(それにしてもすげえ話とは思いますけど)
ちなみに、コンパイル視点で要素数が確定でなければ良いので、しょうもないfilter()を噛ませればちゃんと全ての中間操作が走ります。多い日もこれで安心。
Stream.of(list).filter(i->true).peek(this::hogehoge).count();
ただまぁ、ご覧の通りみょうちくりんな方法で回避しているだけなので、少なくともこれから書くコードでは中間操作でまともな処理をするもんじゃないんだなぁという感じですね。 forEach()は許して。
###余談1
ちなみに、count()の説明文引用で略した部分には、
Returns the count of elements in this stream. This is a special case of a reduction and is equivalent to:
return mapToLong(e -> 1L).sum();
と書いて有るので、ふーん、と
long number = Stream.of(list)
.peek(i->{if(i!=null)System.out.print("Peek! ");})
.mapToLong(i->1L)
.sum();
を試しに走らせると、今度は peek()が呼ばれます。 何でやねん要素数明らかやろ!
追記: 余談2のような場合と違い、Streamの要素自体にはpeek()などの中間操作で干渉出来る(=mutableな要素を流している場合普通に中身を書き換えられるし、そもそもmap()とかあるし)ので、確かにsum()とかの場合は中間操作も流さないといけないっすね。
まぁ、「言ってる割にequivalentじゃねえじゃねえか!」ってオチは変わんないですけど
###余談2
「peek()でも要素数変えられるやろ!」、と
long number = list.stream()
.filter(i->true) //peekが無視されない為に必要
.peek(list::add) //💀💣💀
.count();
等と書くとコンパイルは通りますが、実行時エラーで目茶苦茶怒られます。やめましょう。
#まとめ
というわけで、冒頭のコードは
long number = list.stream()
.filter(i->true) // 正気か
.peek(i->{if(i!=null)System.out.print("Peek! ");})
.count();
とすれば、見た目通りに「Peek! 5」が出力されます。やったぜ。