kagamihogeの日記

kagamihogeの日記です。

Optionals: Patterns and Good Practicesをテキトーに訳した

https://community.oracle.com/docs/DOC-991686 をテキトーに訳した。

Optionals: Patterns and Good Practices

著:Jose Paumard

データ処理のパイプラインにおけるコーナーケースの扱い方に関する新しくエレガントなパターンについて

Java SE 8で導入された多くの機能がアプリケーションとAPIの記述方法に重要な変化をもたらしました。その一例は、勿論ラムダ式でStreamとCollectors APIも同様です。ラムダ式Javaのインタフェースの役割にも変更を波及し、デフォルトおよびstaticメソッドが導入されました。これら新規の機能により、Collections Frameworkは関連インタフェースに大幅な変更が加えられました。

その他に導入されたものとしてfinalクラスのOptionalがあります。このクラスはstream上に構築されるデータ処理パイプラインの記述方法を改善します。本文書の目的は"optional"の概念に関する詳細を解説し、optionalのエレガントなパターンを紹介することです。Optionalsは以下に見るようにstreamsとの組み合わせで効果を発揮します。

What Is an Optional?

optionalの考え方そのものは新しいものではなくJava以外のいくつかの言語で既に実装されています。この考え方が有用なのは、あるメソッド呼び出しが不明な値(unknown value)もしくは存在しない値を返しうる、ことをモデリングする場合です。

そうしたケースの場合、大抵はデフォルト値を返します。しばしばそれが設計上の選択となります*1。たとえば、map.get(key)はmapにキーが存在しない場合はnullを返します。これはCollections Frameworkのmapの動作ですが、この振る舞いは不明瞭です。このメソッドからは指定のキーがmapに存在するかどうかは不明です。すなわち、もしキーがnull値に関連付けられていた場合(普通はやるべきでないですが)、map.get(key)はnullを返します。

mapにキーが存在するかどうかを確認する正しい方法は以下のコードとなります。

map.containsKey(key);

map.get()Optionalを返すようにも出来ます, even if it might have been cumbersome to constantly check the returned object for its content at each call. *2 それはそれとして、コーナーケースの処理だけがOptionalの役割であると考えるのは間違いです。実際のところ、Javaは20年間optional無しでコーナーケースを処理してきています。

新規導入されたOptioanlで非常にエレガントな書き方が可能となり、Stream APIと共に使うことでデータ処理パイプラインのコーナーケース処理で新しいやり方が可能となります。

How Can We Build Optionals?

純技術的な観点から見ると、Optionalはprivateなコンストラクタを持つfinalクラスです。optinalを作成するには、二つのファクトリメソッドのいずれか一つを使用します。

一つ目はOptional.of()です。引数には非nullのオブジェクトを指定します。nullオブジェクトを渡す場合はNullPointerExceptionになります。このメソッドはoptionalで非nullオブジェクトをラップするのに使用可能です。

二つ目はOptional.ofNullable()です。このメソッドも引数にオブジェクトを取りますが、違いはnullが可能な点です。ただし、optionalでラップしたnullを取得出来ると期待してはいけません。このメソッドにnullを渡す場合、空のoptionalが返されます。

よって、nullオブジェクトをラップするoptionalを作成する方法はありません。optionalは非nullオブジェクトもしくは空を保持するラッパーです。

Why Do We Need Optionals?

存在しない結果("a result that does not exist")の考え方は色々な場面で使われます。Stream APIを基にした簡単な例を考えて見ます。streamで最大値を計算してみます。

IntStream stream = Stream.range(‐100, 100);
OptionalInt opt = stream.max(Comparator.naturalOrder());

max()OptionalIntでラップした結果を返します。もしintを返していたとしたら何が問題となるかを考えて見ます。

maxが空となるような計算をするstreamの場合に問題が出てきます。実際のstreamは複雑な計算結果、マッピング・フィルタリング・セレクションなどを含むもの、になりえます。パラレル計算のために複数CPUコアへメインストリームをサブストリームに分割して計算結果を出すことも可能です。この場合、空のstreamでは問題が発生する可能性があります。

分割ケースを実行するコードを書いてみます。ここではmax()がoptionalではなくintを返すと仮定します。

// This part of the code is run on CPU‐1 
IntStream stream1 = Stream.range(‐100, 0);
int max1 = stream1.max(Comparator.naturalOrder()); // max1 = ‐1

// And this part on CPU‐2
IntStream stream2 = Stream.range(0, 100);
int max2 = stream2.max(Comparator.naturalOrder()); // max2 = 99 
int max = Integer.max(max1, max2);

このコードはstreamが空ではないので問題ありません。

それでは問題のケースを見て見ます。以下ではstreamの一つが空になっています。

IntStream stream1 = Stream.range(‐100, 0);
int max1 = stream1.max(Comparator.naturalOrder()); // max1 = ‐1

IntStream stream2 = Stream.empty();
int max2 = stream2.max(Comparator.naturalOrder()); // Suppose max2 = 0
 
int max = Integer.max(max1, max2); // result is 0

max()の戻り値型をintにすると空のstreamでは誤った結果が導かれることが見れたかと思います。その理由は、空のstreamで実行されたオペレーションの戻り値はそのオペレーションの単位元(identity element)であるべきだからです。この場合のmax()オペレーションは単位元が無いので、戻り値は存在しません。0を返すことは誤った結果を導きます。

Integer.MIN_VALUEを返すのはどうでしょうか? やれないことは無いですが、intlongに変換されないと絶対に確信出来る場合に限ります。このトリックはJDKIntSummaryStatisticsクラスで使われていますが、汎用クラスでは使用出来ません。

Optional to the Rescue

空集合のmaxは未定義であり、適当なデフォルト値を決めることは危険です。データ処理パイプラインの結果が誤ったものとなる可能性があります。maxだけが単位元を持たないオペレーションというわけではありません。minやavgもその一例です。

optional型はそういったケースを適切に処理するために導入されました。optional型は存在しない値(a value might not exist)をモデル化したものです。存在しない値とは、null・0・その他のデフォルト値とは、異なる値です。

Optionals: First Patterns

Optionalクラスが公開するメソッドには二種類のパターンがあります。一つ目はInteger, Longのようにラッパーオブジェクトとしてoptionalオブジェクトを扱います。それらの違いはラッパー内に存在しない値を持てるかどうかです。

ラッパーとしてoptionalを処理するメソッドとしてはisPresent()get()があります。

Optional<Person> opt = ...;
if (opt.isPresent()) {
   int value = opt.get(); // there is a value
} else {
   // decide what to do
}

一つ目のパターンは単純で理解も容易です。ラッパーとしてのoptional内に値が無い場合、何かしらのデフォルト値を定めるか、もう一つのパターンを使います。値が無い状態でoptionalのget()を呼ぶとNoSuchElementExceptionがスローされます。

このパターンには幾つかバリエーションがあります。デフォルト値がある場合はorElse()を使います。

Optional<Person> opt = ...;
Person result = opt.orElse(Person.DEFAULT_PERSON);

上記の場合、optionalが空の場合はデフォルトのpersonが返されます。

上記のパターンはoptional使用時にPerson.DEFAULT_PERSONが既に存在するか生成コストが安い場合には有用です。もし使用時にデフォルト値が存在しないか生成にパフォーマンス上の問題がある場合、恐らくこのパターンを使いたいとは思わないでしょう。

別のバリエーションとして以下のようなやり方があります。

Optional<Person> opt = ...;
Person result = opt.orElseGet(() -> Person.DEFAULT_PERSON);

上記の場合、生成済みのオブジェクトを渡すのではなく、オブジェクト要求がきた場合に呼ばれるサプライヤを渡しています。サプライヤはいわゆる関数型インタフェースで引数を取らず何らかの値を返します。

例外をスローしたい場合は以下のようにします。

Optional<Person> opt = ...;
Person result = opt.orElseThrow(() -> new MyCustomException());

上記の場合、必要に応じて例外がorElseThrow()からスローされます。

このパターンの一連の使い方は、optionalに値があるかどうかをチェックするという点で、いささか古典的なやり方です。optionalが空の場合にデフォルト値を返すか例外をスローするかを決定します。おまけとして、ラムダ式のおかげでサプライヤ形式でデフォルト値や例外を生成することも出来ます。

Optionals: Second Patterns

Optionalクラスが公開するメソッドを見てみると、Streamインタフェースにあるmap(), filter(), flatMap(), ifPresent()と同じ一連のメソッドがあるのが分かります。なお、optionalには一要素以上を持てないので、forEach()を呼び出せる意味はありません。

これらのメソッドの使用方法を見ていきます。解説用に、Cay Horstmann氏の著作 (Addison-Wesley, 2014)Java SE 8 for the Really Impatientの例を使います。

0の逆数や負数の平方根は計算出来ません。そうした未定義の数式を処理するためにNaN(not a number)という特殊な数値が導入されています。

NaNの代わりにoptionalを使うにはどうすればよいでしょうか? オペレーションの戻り値にoptinalを返すOptionalMathクラスを考えてみます。オペレーションが渡される引数で計算可能な場合にはoptionalにその値を入れて返します。もし計算不能な場合、空のoptionalが返されます。OptionalMathクラスは以下のようになります。

public class OptionalMath {

   public static Optional<Double> sqrt(Double d) { 
   return d >= 0 ? Optional.of(Math.sqrt(d)):
                      Optional.empty();
   }

   public static Optional<Double> inv(Double d) { 
   return d != 0 ? Optional.of(1/d):
                      Optional.empty();
   }
}

この例は極めて単純なものです。常に同じオブジェクト型を返し、例外がスローされることはありません。

次にdoubleを処理するstreamを考えます。

Calculating the Inverse of a Square Root, First Version

一つ目のパターン(アンマリ良くない)に従うと以下のようなコードになります。

doubles[] doubles = ...; // the doubles we need to process
List<Double> result = new ArraysList<>(); // the result of our processing

Arrays.stream(doubles).boxed()
   .forEach(
      d -> OptionalMath.sqrt(d)
                           .flatMap(OptionalMath::inv)
                           .ifPresent(result::add)
);

まず、Optional.flatMap()があるおかげで、逆数と平方根のオペレーション呼び出しをメソッドチェーンに出来ます。

次に、計算結果を結果リストに追加するのにifPresent()を使うことで、結果が存在するかどうかに関わらず、メソッドチェーンで繋げられます。このやり方は明快で洗練されており、if‐then‐elseで煩わしくなることもなければ例外処理もありません。処理不可能な値はただ単に自然にstreamから削除されます。

ここでの唯一の問題は、ラムダ式が外部のリストresultを変更している点です。これは完全に悪いというわけでもなく、期待通りに動作します。とはいえ、パフォーマンス上の問題になりえます。ラムダ式からエンクロージングコンテキストにアクセスすることは避けるべきです。

隠れたパフォーマンス問題がもう一つ存在します。外部のArrayListを変更しているため、この計算処理はパラレル化出来ません。最適化の機会を捨てている、と言えます。

Calculating the Inverse of a Square Root, Second Version

外部リスト変更は避けるべきですが、とはいえOptionalMathクラスを使用してstream処理のコードを書くことは可能でしょうか? 答えはyesではありますがデータ処理の方法を再考する必要があります。

ごく自然な形でstream処理のコードを書けば以下のようになります。

Stream<Optional<Double>> intermediateResult = 
Arrays.stream(doubles).boxed()
   .map(d-> OptionalMath.inv(d).flatMap(OptionalMath::sqrt);

optionalのリストはあまり良いやり方ではありません。結果リストには結果だけ格納されているのが望ましいです。よって、optionalのstreamから値のstreamへとmapする方法があれば良いことになります。stream内のoptionalが空の場合、値のstreamには無視して挿入しません。

よって、doubleの引数を取りoptionalのstreamを返す関数が必要となります。このstreamはoptinalが空の場合には空となり、値が存在する場合にはoptionalがラップしている値を一つ持つstreamになります。

上記を行う関数は以下のようになります。

Function<Double, Stream<Double>> invSqrt =
   d -> OptionalMath.inv(d).flatMap(OptionalMath::sqrt)
                       .map(result -> Stream.of(result))
                       .orElseGet(() -> Stream.empty());

メソッド参照を使うことで簡略化出来ます。

Function<Double, Stream<Double>> invSqrt =
   d -> OptionalMath.inv(d).flatMap(OptionalMath::sqrt)
                      .map(Stream::of)
                      .orElseGet(Stream::empty);

上記の関数はどような動作をするのでしょうか? まず、必要な計算処理である逆数と平方根を出してoptionalでラップします。

次に、Optinalmapメソッドにより、結果があればstreamに結果をmapします。ここでのmap()Optional<Stream<Double>>を返します。もしこの前の段階の計算処理が空のoptionalを返していた場合、mapは何もせずに空のoptionalを返します。

最後の段階でoptionalを展開します。もし値が存在するとしたらstreamにラップされているので、orElse()はただ単に結果の値をラップしたstreamを返します。値が無い場合は空のstreamを返します。よって、この段階で空のoptionalは空のstreamに変換されることになります。

doubleの引数を取りその値の逆数と平方根を計算し、結果をstreamでラップして返します。doubleが負数やnullの場合には計算出来ないため、その場合は空のstreamを返します。

というわけで、真に"streamish"な方法でデータ処理パイプラインを書き直してみます。

doubles[] doubles = ...; // the doubles we need to process

List<Double> result = Arrays.stream(doubles).boxed()
                  .flatMap(invSqrt)
                  .collect(Collectors.toList());

外部リストの変更は無くなり、上記のstreamはパフォーマンス向上しうるパラレル計算が可能となります。

Conclusion

上記のうち二番目のoptionalを使うやり方は一番目のものより興味深いものがあります。optionalをstreamのように扱っており、その状態は空かシングルトンになります。これによりパラレル計算へと自然に繋げられます。計算出来ない値はstreamから除去され、その時にif‐then‐elseをする必要は無く、例外処理もなく、NaNも出てきません。なお、この書き方は、optionalからstreamへ変換するという、冗長で特殊な関数に依存しています。Java SE 9ではその変換を直接行うOptional.stream()が使えるようになります。よってJava SE9ではinvSqrtは以下のようになります。

Function<Double, Stream<Double>> invSqrt =
   d -> OptionalMath.inv(d).flatMap(OptionalMath::sqrt).stream();

See Also

About the Author

(省略)

*1:This is the design choice made most oftenが原文。

*2:上手く訳せず