※本記事は、Simon Roberts、Mikalai Zaikinによる”Quiz Yourself: Functional Interfaces (Advanced)“を翻訳したものです。
ストリームにおけるボクシングおよびアンボクシングの難解さ
著者:Simon Roberts、Mikalai Zaikin
2019年10月4日
その他の設問はこちらから
過去にこのクイズの設問に挑戦したことがある方なら、どれ1つとして簡単な問題はないことをご存じでしょう。クイズの設問は、認定試験の中でも難しいものに合わせています。「中級者向け」「上級者向け」というレベルは、設問の難易度ではなく、対応する試験による分類です。しかし、ほとんどすべての場合において、「上級者向け」の方が難しくなります。設問は認定試験対策として作成しており、認定試験と同じルールの適用を意図しています。文章は文字どおりに解釈してください。回答者を引っかけようとする設問ではなく、率直に言語の詳細な知識を試すものだと考えてください。
この設問では、プリミティブ版の関数型インタフェースを使うコードを作成したいと思います。 次のコードについて:
DoubleStream ds = DoubleStream.of(1.0, 2.0, 3.0);
… // line n1
System.out.print(ds.map(fun.apply(1.0)).sum());
line n1に追加することで、ストリームがもっとも効率よく処理され、コンソールに 9.0 が出力されるコードはどれですか。1つ選んでください。
Function<Double, DoubleUnaryOperator> fun = a -> d -> d + a;DoubleFunction<DoubleUnaryOperator> fun = a -> d -> d + a;DoubleFunction<DoubleFunction<Double>> fun = a -> d -> d + a;Function<Double, DoubleFunction<Double>> fun = a -> d -> d + a;
解答:この設問では、複数のトピックを扱っています。1つはプリミティブ・データタイプとJavaのジェネリクス・メカニズムとの相互作用、もう1つは複雑な関数型どうしのマッチングです。
まずは、最初のトピックの概要を見ていくところから始めます。Javaのジェネリクス・メカニズムは、オブジェクト型にのみ対応しています。すなわち、任意のSomethingについてList<Something>を定義できるのは、Somethingが何らかのObject型である場合に限られます。List<int>や、他のプリミティブタイプのListを定義することはできません。
この不便さを緩和するために、Javaのラッパー型を使うことができます。そのため、たとえばList<Integer>というような宣言を行うことができます。さらに、このようにジェネリックを扱う場合のほとんどで(ただし、常にというわけではありません)コンパイラのオートボクシングとアンボクシングの機能が働くため、オブジェクトとプリミティブとの相互変換を行うコードを明示的に書かずにプリミティブタイプを処理することができます。したがって、次のコードは問題なく動作します。
List<Integer> li = new ArrayList<>();
li.add(99);
int ninetyNine = li.get(0);
しかし、実際にコンパイラが生成するのは、次の形式と同等のコードです。
List<Integer> li = new ArrayList<>();
li.add(Integer.valueOf(99));
int ninetyNine = ((Integer)li.get(0)).intValue();
この2番目の形式には、見えないCPU負荷がかなりかかっている点に注意してください。 ここでは、Integerオブジェクトの生成と初期化(または、これまでに生成されたオブジェクトのプールから既存のIntegerを探す作業)が行われています。さらに、キャストも行われています。このキャストは、実際にはget(0)の戻り値に対して行われ、intValueメソッドを呼び出してプリミティブの結果を抽出しています。
通常、このCPUオーバーヘッド全体は、ソースコードの可読性(およびその結果としての保守性)改善との引き換えとなる妥当なトレードオフです。ただし、このようなトレードオフが発生すると知っておくことは重要です。パフォーマンスの低下が度重なり、このトレードオフを許容できないこともあるからです。いずれにせよ、パフォーマンスの低下が許容範囲である場合でも、この処理は常に、設問の言葉を使うなら、「あまり効率的でない」と言えます。
次に、Listなどのデータ構造を定義する場合、このボクシングおよびアンボクシングの処理を避けるのは結構難しく、通常はトレードオフが生じるだけの価値はあります。ただし、大量の計算を行っている場合、ジェネリクスではプリミティブを扱えないという制限に直面する可能性もあります。1つの入力を受け取り、1つの結果を生成する処理を定義したい場合を考えてみます。これは一般的なjava.util.function.Function<E,F>であり、操作の入力がE型、結果がF型です。ここで、この操作を使ってdouble値に対する計算を行いたいものとします。次のようなコードを試されるかもしれません。
Function<Double, Double> fdd = in -> in + 2.0;
残念ながら、これで作成されたのはDoubleオブジェクトを受け取る関数です。この関数からコンパイラは、2.0を加算する前にラッパー・オブジェクトからdoubleプリミティブを抽出するコードを生成し、その結果に対してDoubleオブジェクト・ラッパーを適用するコードを生成します。このような変換によるオーバーヘッドは、単純にdoubleプリミティブに2.0を加算する処理に比べれば膨大です。この変換機能が繰り返し使われた場合、トレードオフを許容できなくなる可能性ははるかに高くなります。
以上の制限を踏まえ、こういったボクシングおよびアンボクシングを一切行わないことで効率向上を実現するという明確な目標のもと、関数型インタフェースとStream API(およびJava 8における関数型機能のその他の要素)では、直接プリミティブを扱う特別なバージョンを提供しています。 先ほどの例に関連する機能を以下に示します。
DoubleStream:doubleプリミティブのデータを扱うことを目的としたストリーム概念java.util.function.DoubleFunction<E>:1つのdoubleプリミティブ引数を受け取り、E型のオブジェクトを返すメソッドを定義する関数型インタフェースjava.util.function.DoubleUnaryOperator:1つのdoubleプリミティブ引数を受け取り、doubleプリミティブの結果を返すメソッドを定義する関数型インタフェース
これに類する機能は他にも多く提供されていますが、この設問に直接関係するのは、上に挙げた3つです。
設問の最初の重要な要素を満たすためには、ボクシングとアンボクシングを回避するか、または少なくとも最低限にとどめる答えを見つける必要があります。ボクシングとアンボクシングの処理が行われることで、設問で要求されている、効率が低下するからです。与えられたコードでは、DoubleStreamを直接作成しています。DoubleStreamはプリミティブ版のストリームであり、double値を直接扱うことから、効率に関する条件は満たされています。したがって、以降の処理で、値がプリミティブのまま扱われることを確認しなければなりません。そのためには、プリミティブを扱う関数型インタフェースが使われていることを確認する必要があります。
その他の背景についても考えてみます。すべての選択肢で、変数funを定義しようとしていることがわかります。このfunは、ds.map(fun.apply(1.0)).sum()という式のストリーム処理で使われています。
map操作では、上流ストリームの型の各項目を受け取り、指定された処理操作(これは1つの結果を生成する必要があります)を実行して、その処理操作が返す型のストリームを生成します。ここから、操作に必要ないくつかの要件を特定できます。さらにそこから、funの型と動作に必要な要件を特定できます。
funは、マップに適用される操作ではない点に注意してください。そうではなく、式fun.apply(1.0)を評価すると、map操作によって実行される操作が作成されます。言い換えるなら、fun自体は操作ではなく、動作のファクトリのようなものです。
ここではわかりやすさを優先し、mapが使うこの動作を単に「操作」と呼ぶことにします。この操作では、入力としてdoubleプリミティブを受け取り、結果としてdoubleプリミティブを生成しなければなりません。どうすればこれを実現できるでしょうか。純粋に論理的に推論すれば、map操作の入力と、上流ストリームの型との間に代入互換性がなければならないことがわかります。今回の場合、上流ストリームの型はdoubleです。そのため、doubleは動作し、Doubleもおそらく動作しますが、Doubleにはオートボクシングが必要となり非効率であるため、使うべきではありません。
それでは、下流ストリームの型はどうでしょうか。map操作では常に新しいストリームを生成します。また、sum()操作はプリミティブ・ストリームのみに存在します。いずれにせよ、Automobileオブジェクトのリストを足し合わせて1つのAutomobileを表す結果を生成することはできません。したがって、結果の値はプリミティブタイプでなければならず、doubleが自明の選択であることがわかります。実際には、APIを使ってdoubleプリミティブのストリームをintプリミティブやlongプリミティブのストリームにマップすることはできますが、この変更を行うためには、mapToIntメソッドやmapToLongメソッドが必要です。そのため、明らかにこの型であるという十分な確信を持てず、「設問に他の選択肢がないことからdoubleである」という推測に満足しない場合でも、前述の事実から確実にわかります。
つまり、操作の型は、1つのdouble引数を受け取り、doubleの結果を返すものでなければなりません。これは、DoubleUnaryOperatorインタフェースで定義されている動作です。
ここで、APIを詳細まで理解している方なら、DoubleStreamに対するmapの引数の型はDoubleUnaryOperator以外にないことはご存じでしょう(この設問では、このことを知っていなくても、論理的に考えればわかります)。しかし、重要なことは、これがオートボクシングが対処しない状況の1つであることです。Function<Double, Double>、DoubleFunction<Double>、ToDoubleFunction<Double>はいずれもDoubleUnaryOperatorとの互換性があるはずだと思うかもしれませんが、そうではありません。ここでは、DoubleUnaryOperatorを指定しなければなりません。
それでは、funの型はどうでしょうか。その型が何であれ、double引数(またはオートボクシングが適用されるDoubleも考えられますが、このバージョンは可能であれば避けたいということはわかっています)を受け取り、map操作で使われるDoubleUnaryOperatorを返す動作が実現されていなければなりません。
入力としてdoubleプリミティブを受け取り、オブジェクト型を返す関数はDoubleFunction<E>と呼ばれています。Eは戻りタイプを表します(逆に、引数としてEを受け取り、doubleプリミティブを返す関数はToDoubleFunction<E>です)。これを考えれば、funを実現する効率的な型はDoubleFunction<DoubleUnaryOperator>であることがわかります。この型は、選択肢Bで宣言されているものと一致しています。そこで、この選択肢の動作と、適切な出力が行われるかどうかを考えてみます。
選択肢Bにあるfunの定義のもとでfun.apply(1.0)を呼び出すと、double引数を受け取ってその引数に1を加算したものを返す関数が作成されます。このコードを実行すると、map操作の結果はストリーム・データ2.0、3.0、4.0(対応する入力のそれぞれに1を加算した数字)になり、その合計が要件どおりの9.0になることがわかります。この宣言が最大限に効率化されていることはわかっているため、選択肢Bが正解だと判断して差し支えありません。しかし念のため、他の選択肢はうまく動作しないか、または効率が低いことを確認してみます。
選択肢Aのfunでは、同じ論理計算が宣言されていますが、最初の入力にdoubleプリミティブではなくDoubleオブジェクトを受け取ると宣言されています。結果の関数が、引数に1.0を加算するDoubleUnaryOperatorである点は変わりません。そのため、コードはコンパイルでき、結果も9.0になります。ただし、ボクシング操作によって選択肢Bよりも効率が低いことは明らかであるため、選択肢Aは誤りです。
選択肢Cと選択肢Dではいずれも、DoubleFunction<Double>に関して定義されている操作のファクトリを定義しています。DoubleFunction<Double>は、引数としてdoubleプリミティブを受け取り、結果としてDoubleオブジェクトを返す関数を宣言します。そのため、これはコンパイルできると考える方もいらっしゃるかもしれません。しかし、たとえコンパイルできたとしても、選択肢Bよりも効率が低いことは明らかです。そのため、この段階でいずれの選択肢も誤りであることがわかります。ただし前述のように、オートボクシングのメカニズムでは、doubleとDoubleを相互に変換することはできますが、引数としてDoubleを受け取る関数を、引数としてdoubleを受け取る関数に変換することはできません。これは、単なるボクシングおよびアンボクシングの操作ではなく、関数型の変換です。その結果、選択肢CおよびDはコンパイルできないため、いずれも誤りです。
正解は選択肢Bです。
Java Magazine 日本版Vol.47の他の記事
Java 13のswitch式と再実装されたSocket APIの内側
Javaにテキスト・ブロックが登場
言語の内側:シールド型
TeaVMを使ってブラウザでJavaを動かす
ツールをよく知る
クイズに挑戦:1次元配列(中級者向け)
クイズに挑戦:カスタム例外(上級者向け)
クイズに挑戦:ロケールの読取りと設定(上級者向け)
![]() |
Simon RobertsSimon Roberts:Sun Microsystemsがイギリスで初めてJavaの研修を行う少し前にSunに入社し、Sun認定Javaプログラマー試験とSun認定Java開発者試験の作成に携わる。複数のJava認定ガイドを執筆し、現在はフリーランスでPearson InformITにおいて録画やライブによるビデオ・トレーニングを行っている(直接またはO’Reilly Safari Books Onlineサービス経由で視聴可能)。OracleのJava認定プロジェクトにも継続的に関わっている。 |
![]() |
Mikalai ZaikinMikalai Zaikin:ベラルーシのミンスクを拠点とするIBA IT ParkのリードJava開発者。OracleによるJava認定試験の作成に携わるとともに、複数のJava認定教科書のテクニカル・レビューを行っている。Kathy Sierra氏とBert Bates氏による有名な学習ガイド『Sun Certified Programmer for Java』では、3版にわたってテクニカル・レビューを務めた。 |


