JDK 16 : stream.toList() に見るAPI設計の難しさ


はじめに

JDK 16 で追加された stream.toList()

タイプ量が減るのは良いのですが、API 設計から見た場合、多少の気持ち悪さが残ります。

そして、.collect(Collectors.toList()) から .toList() へは、単純に置き換えることができないよ という話です。


JDK 16 で導入された stream.toList()

JDK 16 で Stream に stream.toList() が追加されました。 今までは Collectors を使う必要があり、以下のように書いてきました。

List<String> names = people.stream()
    .map(Person::getName)
    .collect(Collectors.toList());


Java 16 からは以下のように書くことができます。

List<String> names = people.stream()
    .map(Person::getName)
    .toList();

すっきりしましたね。

しかし stream.collect(Collectors.toList())stream.toList()実装上の違いがあるため、安易に喜んでばかりはいられません。


Collectors.toList() の API と実装

旧来からの Collectors.toList() ですが、API 仕様は以下のようになっています。

入力要素を新しいListに蓄積するCollectorを返します。 返されるListの型、可変性、シリアライズ性、またはスレッド安全性は一切保証されません。返されるListをより細かく制御する必要がある場合は、toCollection(Supplier)を使用してください。

ここで注目したいのが、「返されるListの型、可変性、直列化可能性、またはスレッド安全性は一切保証されません」という点です。 API 仕様としては、生成された List について多くを期待してはいけないことが語られています。


ところが、Collectors.toList() の実装は以下のようになっており、java.util.ArrayList のインスタンスが返却されるのが現実です。

public static <T> Collector<T, ?, List<T>> toList() {
    return new CollectorImpl<>((Supplier<List<T>>) ArrayList::new, List::add,
        (left, right) -> { left.addAll(right); return left; },
        CH_ID);
}

java.util.ArrayList のインスタンスであるため、可変性、シリアライズ性は確保されます。 つまり、API仕様以上の仕様が、実装上は満たされているということになります。

で、以下のように生成したリストに対して、後から要素を追加したり、シリアライザを介した処理をしたりといったことを「一切行っていない」と言い切れる人はどれだけいるでしょうか?

List<String> names = people.stream()
    .map(Person::getName)
    .collect(Collectors.toList());

API 仕様に即して、より狭義の実装に切り替えることは、仕様上は問題ないかもしれませんが、厳密にそのことを意識して使われていない状況が多く、なかなか大鉈を振るうということはできないものです。 影響範囲の大きさから、Collectors.toList() は、今後も java.util.ArrayList のインスタンスを恒久的に返さざる負えないというのが現実的な状況だと思います。

ちなみに Collectors.toSet() は以下のように HashSet のインスタンスを生成します。

public static <T> Collector<T, ?, Set<T>> toSet() {
    return new CollectorImpl<>((Supplier<Set<T>>) HashSet::new, Set::add,
        (left, right) -> { left.addAll(right); return left; },
        CH_UNORDERED_ID);
}


stream.toList() の API と実装

一方、stream.toList() のAPI仕様は以下のようになっています。

このストリームの要素をリストに蓄積します。リスト内の要素は、このストリームの遭遇順序が存在する場合、それに従います。返されたリストは変更できません。ミューテーター・メソッドを呼び出すと、常にUnsuptorpedOperationExceptionがスローされます。返されるListの型やシリアライズ性については保証されません。 返されるインスタンスは値ベースのものかもしれません。呼び出し側は、返されたインスタンスのアイデンティティについて仮定してはいけません。これらのインスタンスに対する同一性を重視した操作は、信頼性に欠けるため避けるべきです。

こちらのAPI仕様は「返されるListの型、シリアライズ性」については保証しないのは Collectors.toList() と同一ですが、可変性については「リストは変更できません」と、必ずイミュータブルになるという定義となっています。


API仕様通りに、実装は以下のようになっています(Stream の実装側でオーバーライドされる可能性がある点は注意ください)。

default List<T> toList() {
    return (List<T>) Collections.unmodifiableList(
        new ArrayList<>(Arrays.asList(this.toArray())));
}

「ミューテーター・メソッドを呼び出すと、常にUnsuptorpedOperationException がスローされます」を満たすよう、Collections.unmodifiableList が使われていますね。

ちなみに Collections.unmodifiableList() の実装は以下の通りです。

public static <T> List<T> unmodifiableList(List<? extends T> list) {
        return (list instanceof RandomAccess ?
                new UnmodifiableRandomAccessList<>(list) :
                new UnmodifiableList<>(list));
}


stream.toList() には安易に切り替えられない

stream.toList() はコード上の見通しも良くなるため、気軽に collect(Collectors.toList()) から変更したくなりますが、今まで動いていたものが動かなくなる可能性があります。

API仕様上は互換に見えますが、実装の違いによりUnsuptorpedOperationException に遭遇するかもしれません。Arrays.asList() に要素追加しようとして「おっと・・」となるのと同じですね。


気持ち悪いのは、Collectors には、Collectors.toList()Collectors.toUnmodifiableList()(こちらはJDK10で追加) があり、あたかも Collectors.toList() は(API仕様上保証されていないが)ミュータブルなリストを返却するように見える点です。そして現在の実装ではそうなっています。

一方、同じシグネチャの stream.toList() は、UnmodifiableList を返すため、同じ java.util.stream パッケージにあるにも関わらず、API としての統一感がありません。 かといって、stream.toList() ではなく、stream.toUnmodifiableList() としたのでは、そもそも(他の言語のように)簡単に書きたいという筋からはずれてしまいます。

それぞれについて使い手側に知識を強制させるものになってしまいました。

Collectors.toList() が実装としてもイミュータブルなリストを返しておけば、Collectors.toUnmodifiableList() なども出てこず、stream.toList() との統一感も出たでしょう。 ただ、後方互換性を重視する Java では、旧来より List といえばミュータブルという世界だったので、なんとも難しいところですね。