Optionalは引数に使うべきでない、という幻想について
継続渡しすると戻り値は引数になるから「Optional
は戻り値にのみ使うべき」というルールは無意味だよ、という話。
あ、そういう話ね、と分かった方はこれ以上読む必要はありません。
Mono
が Async
+ Optional
+ 例外という欲張りパック状態なのも問題ですが、それについてはまた今度(Mono<Optional<T>>
使わずに Mono<T>
を使え、という指摘があり得る。ただ、そっちもそっちで言いたいことはある、という程度)。
今回は、 Mono
は Async
くらいの意図として使っています*1。
まず、こんなメソッドがあったとします。
Mono<Optional<String>> f();
これ自体は戻り値に Optional
を使っているだけなので、「Optional
は戻り値にのみ使うべき」は守っています。
しかし、これを使う側はそうはいきません。 例えば、値が取ってこれなかった場合はログして404を返し、取れてきた場合はログして200を返すような処理を考えます。
Mono<ServerResponse> g(ServerRequest req) { return f().map(strOpt -> { if (strOpt.isEmpty()) { logger.info("str is not found."); return ServerResponse.notFound(); } var str = strOpt.get(); logger.info("str is {}.", str); return ServerResponse.ok(); }); }
ここで、map
に渡すラムダ式の引数は Optional<String>
になります。
おおっと、引数に Optional
が出てきてしまいました。
このように、Mono
など「後続処理をラムダ式として受け取る」ようなスタイルの設計においては、戻り値を引数として受け取ることになります。
これを「ラムダ式の引数は例外とする」というルールを加えてしまうと、メソッド参照によるラムダ式の置き換えが出来なくなってしまいます。
Mono<ServerResponse> g(ServerRequest req) { return f().map(this::h); } ServerResponse h(Optional<String> strOpt) { if (strOpt.isEmpty()) { logger.info("str is not found."); return ServerResponse.notFound(); } var str = strOpt.get(); logger.info("str is {}.", str); return ServerResponse.ok(); }
これは先ほどのラムダ式をメソッドとして抽出しただけで、やっていることは同じです。
ラムダ式の中身が複雑になる場合は自然にこういう書き換えはやりたくなるとおもいますが、ラムダ式を例外とするだけでは足りないことが分かります。
まぁ、 public
以外にはルールは適用しない、みたいな例外を追加するようなことは考えられますが、いっそあきらめて Optional
を引数に使わない、なんてルールやめてしまった方がいいと思います。
いやいや、複雑になったとしても取得処理を呼び出し側でやって、メソッドの引数には Optional
を許さない!という態度もなくはないです。
Mono<ServerResponse> g(ServerRequest req) { return f().map(strOpt -> { if (strOpt.isEmpty()) { logger.info("str is not found."); return ServerResponse.notFound(); } var str = strOpt.get(); logger.info("str is {}.", str); return h(str); }); } ServerResponse h(String str) { // strを使った複雑な処理 return ServerResponse.ok(); }
こんな感じですね。
値の有り無しの振り分けをラムダ式側、処理自体をメソッド側と役割を分けられるため、なくはないです。
ただ、今回の例は Optional
が1つでしたが、これが2つ、3つと増えてくると、ラムダ式内が複雑になっていくため、そこにテストを書くためにメソッド化したくなります。
その際は、やはり Optional
を引数に持つメソッドが欲しくなります。
このように、 「Optional
を引数に使わない」というルールは実践的には無意味です。捨てましょう。
「フィールドに Optional
を使わない」というルールの方はもうちょっとだけ分からなくもない理由付けがあります*2が、
こちらもすべてのクラスがシリアル化対象なわけでもないので、個人的には無意味だと思っています。
re:僕にとってMaybe / Nullable / Optional が、どうしてもしっくりこないわけ。
元ネタ: 僕にとってMaybe / Nullable / Optional が、どうしてもしっくりこないわけ。 - 亀岡的プログラマ日記
OOPの文脈で見ると、元の記事が言っていることもわからなくはないのですが、対象が広すぎていろいろと不正確になってしまっているので、ちょっとまとめてみます。
元の記事が対象にしているのは、Maybe / Optional / Nullableの3つです。 対応する型を持つ言語を見てみると、下記のようになります。
これらは、「値がないこと」を表すもの、という見方では同じですが、それぞれ異なる価値観の元に作られています。
Maybe/OptionalとNullable
これらはすべて型パラメータを取ります*1。
しかし、この中でNullable
だけは型パラメータに値型のみという制約が付きます。
これは、C#のNullable
は他のものとかなり性質が違うことを意味しています。
元の記事では並べて書かれていましたが、そもそもNullable
に関してはここに並べるべきものではありません。
C#のNullable
C#のNullable
は、値型にもnull
が使いたいという動機で導入された機能です。
そのため、Nullable
はMaybe
やOptional
と違って、むしろnull
を積極的に使えるようにするための機能と言えるでしょう。
これによってC#の上ではNullable
を使うことでコード上の見た目だけに関しては、値型でもnull
が使えるように見せています。
見た目だけの話であれば、SwiftのOptional
と同じように型名の後ろに?
が付くため、同じような言語機能に見えますが、実際には全くの別物と考えたほうがいいでしょう。
JavaのOptional
JavaのOptional
は、メソッドの戻り値として使うことが想定されているらしく、引数やフィールドに使うことは想定されていないそうです。
JavaのOptional
は参照型なので、Optional
自体がnull
になりうるという点を考えると、引数に使うべきではないというのもある程度納得できます*2。
Javaでは現状、型パラメータに指定できるのは参照型だけであり、null
になりうるのも参照型だけです。
そのため、JavaのOptional
はC#のNullable
と違って、(戻り値としての)null
を排除しよう(少なくとも、減らそう)という動機で導入されたとみていいでしょう。
SwiftのOptional
SwiftのOptional
は、Javaと名前は同じですが、より言語の仕組みに根差しています。
Javaでは、Optional<T>
型の変数にT
型の値を代入できません。
Integer i = 42; Optional<Integer> x = i; // コンパイルエラー
それに対して、Swiftではそれが可能です。
var i: Int = 42 var x: Optional<Int> = i // OK // Optional<Int>はInt?とも書ける
SwiftではOptional
ではない型にnull
*3は代入できません。
ですが、Optional
という仕組みによって今までの知識からあまり外れない書き方でnull
を扱えるようになっています。
HaskellのMaybe
HaskellのMaybe
は実装としてはSwiftのOptional
に一番近くなっています。
ただ、他の言語と違ってHaskellではそもそも他の言語のようなnull
はなく、Swiftのように既存言語ユーザーを取り込むために「今までの知識からあまり外れない書き方」というのも求めていません。
結果だけ見ると、JavaのOptional
と同じように、Maybe t
型の変数にt
型の値は代入できません。
Nullのダメな理由
元記事では、「Nullは何がダメなんだっけ?」について、
- Nullは型ではない
- あるメソッドがNullになるかどうかの判断ができない
が挙げられています。 細かい点ですが、ここにも誤解があります。
(少なくともJavaでは)Nullは型を持つ
まず、Javaの話であればnull
は型を持っています*4。
そのため、「ClassHoge
のnull
」という表現は、少なくともJavaにおいては正確ではなく、正確には「ClassHoge
型に暗黙変換されたnull
」とでもなるでしょうか。
詳しくは言語仕様を参照してください。
ここで言いたかったのはおそらく、「null
はどんな型にも変換できてしまう」ということでしょう。
あるメソッドの結果(もしくは変数)がNullかどうかの判断が型だけからできない
これも細かいですが、あるメソッド(の結果)がNullになるかどうかの判断ができないというのは、正確には「型を見ただけでは」できないのであり、元記事中にあるように、if
文によって実行時にはチェックできます。
本題
さて、ここからが本題です。
元記事では、Optional
が解決するのは2だけ、としていますが・・・
Javaの場合
JavaのOptional
では、Optional<T>
型とT
型は全くの別物であり、相互に暗黙変換できません。
そのため、
- (
null
に相当する)Optional.empty()
はOptional
型の変数にしか入れることはできない*5 - あるメソッドの結果が
Optional.empty()
になりうるかどうか型だけで判断できる
となります。
Swiftの場合
SwiftのOptional
は、Optional<T>
型の変数にT
型の値を代入できますが、その逆はできません。
そのため、
- (
null
に相当する)nil
はOptional
型の変数にしか入れることはできない - あるメソッドの結果が
nil
になりうるかどうか型だけで判断できる
となります。
Haskellの場合
HaskellのMaybe
は、Maybe t
型とt
型は全くの別物であり、相互に暗黙変換できません。
そのため、
- (
null
に相当する)Nothing
はMaybe
型の変数にしか入れることはできない - ある関数の結果が
Nothins
になりうるかどうか型だけで判断できる
となります。
元記事が言いたかったこと
このように、Optional
もしくはMaybe
は、1も2も解決できています。
これは十分なメリットであり、このメリットだけでもOptional/Maybeは有用です。
ただ、元記事を見ると、
本当に解決したいのは「Nullに型独自の処理をさせたい」なので、残ったままです
とあります。 つまり、本当に問題にしていたのは、「型Aの値がない場合と型Bの値がない場合で別の処理をさせたい」となります。
if文相当の処理を書く必要性
Optional/Maybeを使ったコードで頻繁にif
相当の処理を書いてしまっている場合、それはそもそもOptional/Maybeの使い方を間違っている可能性が大きいです。
リストを操作する関数にリストを返す関数が多く含まれるのと同じように、あるいはTask
を返す関数を内部で呼び出している関数がTask
を返す関数になるのと同じように、Optional
/Maybe
を返す関数を内部で呼び出している関数は、その関数の戻り値もOptional
になることがよくあります。
この場合、if
相当の処理を書くのは間違っています。
// Javaだと思う(Swiftは書いたことないので) Optional<User> user = findUser(pred); if (user.isPresent()) { // 何かする return Optional.of(何か処理の結果); } else { return Optional.empty(); }
例えば、「何かする」の部分がuser
の中身を使った関数を呼び出すような処理の場合、
// 値がなかった場合のことは気にしない return findUser(pred).map(user -> 何かする関数(user)); // ラムダ式を使わずに直接関数を渡す、でも可
のようになるでしょう。
map
の他にも、様々なメソッドが用意されているため、if
による分岐に頼る場面は少なくなります。
Haskellの場合、do
構文によってさらに複雑な例でもすっきり書けますが、それはまた別の話。
手続き的には見えない
さて、上のコードが手続き的に見えるでしょうか?
呼び出し側はOptional
のチェックを行っておらず、コードも短く簡潔です。
ではオブジェクト指向プログラミングではどのようになるでしょうか?
User
に対してNullObject
を用意して、このようになるでしょう。
public interface User { 戻り値の型 何かする処理(); } public class UserImpl implements User { @Override public 戻り値の型 何かする処理() { // 何かする return 何かした結果; } // ... } public class EmptyUser implements User { @Override public 戻り値の型 何かする処理() { return 戻り値の型のNullObject; } // ... }
// 呼び出し側は意識しない(意識できない) return findUser(pred).何かする処理();
呼び出し側だけ見ると、Optional
のときとそんなに変わってませんよね*6。
つまり、元のコードも手続き的には見えないと言っていいでしょう。
NullObjectをちゃんと使いたい、というよりは、Optionalをどう使うか考えたい
OOPの文脈で、どこまでOptional
を使えばいいのかというのはよくわかりません。
Optionalを使うと、どうしてもコードはOOPから離れて行ってしまうという感覚は確かにあります。
どのあたりでバランスを取るのがいいのかはケースバイケースでしょうし、一般化できるものではないと思っています。
コメント欄では id:kyon_mm が(やはりOOPの文脈で)
Optionを返していいのはむしろprivateやprotected的なものだけだと思っていて、それ以外はオブジェクトだったり、バリアント(判別共用体)的な感じで返すのが「オブジェクトとやり取りをしていて、それぞれに責務が割あたっている」といえるのではないだろうか。と思っています。
と言っています。
これはこれで一つの態度ではありますが、基本的なライブラリでは Optional
を返す関数*7というのはもっと積極的に作られることになると思います。
FPに軸足を置くか、OOPに軸足を置くかは、対象の性質やメンバーのスキル等によって決めていくしかないでしょう。 ただし、間違ったOptionalの使い方をもって「しっくりこない」というのであれば、まずはOptionalの正しい使い方を学び、実践すべきです。 そのための言語としては、F#なんていかがでしょうか。
2つのOptionalで分岐する
僕のOptional<>の使い方がカッコワルイ。 - 谷本 心 in せろ部屋にある最後の例ですが、
2つのOptional
の組み合わせによって処理を分岐させるコードが幾つか載っています。
if
で書いた例を見てみましょう。
public String hello(Optional<String> name1, Optional<String> name2) { if (name1.isPresent()) { if (name2.isPresent()) { return call(name1.get(), name2.get()); } else { return call1(name1.get()); } } else { if (name2.isPresent()) { return call2(name2.get()); } else { return call(); } } }
まとめると、こんな感じでしょうか。
name1 | name2 | 呼び出すメソッド |
---|---|---|
値有り | 値有り | 2引数call |
値有り | Empty | call1 |
Empty | 値有り | call2 |
Empty | Empty | 0引数call |
これ、F#だったらmatch
式を使えば分かりやすく書けます。
let hello name1 name2 = match name1, name2 with | Some name1, Some name2 -> call(name1, name2) | Some name1, None -> call1(name1) | None, Some name2 -> call2(name2) | None, None -> call()
JavaのOptional
にもしmatch
メソッドが実装されていれば、こんな感じに書けるんですけどね。
public String hello(Optional<String> name1, Optional<String> name2) { return name1.match( n1 -> name2.match(n2 -> call(n1, n2), () -> call1(n1)), () -> name2.match(n2 -> call2(n2), () -> call()) ); }
Optional
のような型を導入するのであれば、やはりmatch
式のようなもの*1が欲しくなりますね、という話でした。
なごやかJavaで発表してきた
なごやかJava第一回で、「.NET系開発者から見たJava」というタイトルで発表してきました。 Javaのこの機能って.NET開発者から見てどうなの?というような内容です。
大阪から参加してくれた方の感想を載せておきます。
おかしい、終わった後の感想が「F# すごい!」だ…… #ngojava
— irof@Javarista (@irof) 2014, 11月 30
JavaでTupleってみる
可変長な型変数の表現より、タプルの話です。
.NETのタプル
.NETでは、型引数の数が違うクラスを定義できるので、Tuple
という名前で7要素まで対応しています。
public class Tuple<T1> { ... } public class Tuple<T1, T2> { ... } public class Tuple<T1, T2, T3> { ... } public class Tuple<T1, T2, T3, T4> { ... } public class Tuple<T1, T2, T3, T4, T5> { ... } public class Tuple<T1, T2, T3, T4, T5, T6> { ... } public class Tuple<T1, T2, T3, T4, T5, T6, T7> { ... }
そして、各要素にアクセスするために、Item1
, Item2
のようなプロパティを使います。
さて、では.NETのタプルは8要素以上は対応していないかと言うと、そうではありません。
タプルのインスタンスを作るためには、Tuple.Create
というメソッドを使って作るのが楽です。
そしてこのTuple.Create
は、なんと8引数版まで用意されているのです。
var t = Tuple.Create(true, 2, 3.0, "4", new[] { 5 }, 6M, 7L, 8);
このt
の型は、こうなります。
Tuple<bool, int, double, string, int[], decimal, long, Tuple<int>>
実は、Tuple
は8番目の型引数として、「残りの要素を保持するタプルの型」が受け取れるようになっています。
ちなみにTuple<T1>
という謎のクラスはこの時のみ使われるクラスです。
public class Tuple<T1, T2, T3, T4, T5, T6, T7, TRest> { ... }
「残りの要素を保持するタプル」にアクセスするためには、Item8
ではなく、Rest
という名前のプロパティを使います。
そのため、8要素タプルの8番目の要素にアクセスしたい場合は、
var value = t.Rest.Item1;
となります。 さてさて、ここからが.NETの残念なところなのですが、実は9要素以上のタプルを作る簡単な方法は用意されていません*1。 ので、コンストラクタを使うことになります。
var t = new Tuple<int, int, int, int, int, int, int, Tuple<int, int>>( 1, 2, 3, 4, 5, 6, 7, Tuple.Create(8, 9));
コンストラクタの型パラメータは省略できないので、悲惨なことになっていますね・・・
これは、Tuple.Create
の8要素版が8要素タプルを作るためではなく、ネストしたタプルを作るためのヘルパーになっていればもっと楽が出来たのです。
var t = Tuple.Create(1, 2, 3, 4, 5, 6, 7, Tuple.Create(8, 9)); // こうはなっていない
残念すぎる・・・
Restという考え方
さて、.NETのタプルは残念ですが、「足りない部分はRestで」というのはどこかで聞いたことのあるような話です。 そう、コンスセルですね!
ということで、コンスセルの考え方を使ってタプルのないJavaにタプルを作ってみましょう。 まずは、コンスセルの終端を表すためのクラスを導入します。 以降では、特にパッケージとか書きませんけど、全部同一パッケージに入っていると思ってください。
public final class TupleNil { static final TupleNil nil = new TupleNil(); private TupleNil() {} }
簡単ですね。
こいつは状態を持っておらず、インスタンスも外部からは生成できず、
さらには唯一の静的フィールドであるnil
もパッケージプライベートなので、
パッケージ外からはこのフィールドにすらアクセスできません。
これだけでは本当に全く何の役にも立たないクラスです。
次に、2つの型パラメータを取って実際に要素を保持するクラスを作ります。
public final class TupleCons<T, Rest> { public final T value; public final Rest rest; TupleCons(T value, Rest rest) { this.value = value; this.rest = rest; } }
これも簡単ですね。
単に、値を2つ持っているだけのクラスです。
このクラスのvalue
に値が、rest
に残りの要素を表すものが格納されます。
コンストラクタがパッケージプライベートなので、パッケージ外からこのクラスのインスタンスを生成することはできません。
最後にタプルを作るためのメソッドを持ったクラスです。
public final class Tuple { private Tuple() {} public static <T> TupleCons<T, TupleNil> singleton(T value) { // こことか return new TupleCons<T, TupleNil>(value, TupleNil.nil); } public static <T1, T2, Rest> TupleCons<T1, TupleCons<T2, Rest>> cons(T1 value, TupleCons<T2, TupleNil> rest) { // ここってダイアモンド演算子使えるんですか?使えそうだけどJavaとかわかりません>< return new TupleCons<T1, TupleCons<T2, Rest>>(value, rest); } }
あとは、これを使ったコードです。
TupleCons<Integer, TupleCons<String, TupleNil>> t2 = Tuple.cons(42, Tuple.singleton("hoge")); System.out.println(t2.value); // 42 System.out.println(t2.rest.value); // hoge
やったー!(何が
まとめ(ない)
- .NETのタプルは割と現実見て、7要素までは自然に扱える
- 大量要素のタプル使うな
- でももし扱う場合があるといけないから、一応対応しとくぜ!
- .NETの
Tuple.Create
はクソ - タプルってコンパイル時のリスト(コンスセル)だよね!
*1:まぁ、そんなタプル作るなと言われればその通り、としか言えないですが
C# 使いから見てうらやましい Java8 の default 実装の使い方
Java8 から追加されるインターフェイスの default 実装ですが、C# の拡張メソッドに似てますよね。 実際、このどちらも「シンインターフェイス」を定義するだけで「リッチインターフェイス」が手に入ります。
しかし、C# の拡張メソッドと Java のインターフェイスの default 実装には、それぞれの利点と欠点があります。
拡張メソッドの利点
拡張メソッドの利点は、インターフェイスの実装者だけでなく、 インターフェイスの使用者に対してもインターフェイスの拡張が開かれている点です。 既存の型ですら、後付けでメソッドを追加することができるということです。
using System; public static class StringExtension { // インターフェイスでなくても、どんな型に対しても拡張可能 public static int ToInt(this string self) { return int.Parse(self); } }
var num = "100".ToInt();
それに対して Java8 のインターフェイスの default 実装は、 あくまでインターフェイスの提供者しか提供できません。
これは、Java8 の default 実装に対する、拡張メソッドの大きなメリットです。
default 実装の利点
では default 実装には利点がないか、というと、そうではありません。 default 実装は、リッチインターフェイスをシンインターフェイスに変換してしまうこともできるのです。
import java.util.*; // Appendableは3つのメソッドを提供するインターフェイスだが、 // SamAppendable(SamはSingle Abstract Method)はそのうち2つをdefault実装として提供することで、 // 1つの抽象メソッドを実装すればいいようにしている interface SamAppendable extends Appendable { Appendable append(CharSequence csq, int start, int end); default Appendable append(CharSequence csq) { this.append(csq, 0, csq == null ? 0 : csq.length()); return this; } default Appendable append(char c) { this.append("" + c, 0, 1); return this; } } public class Main { static void appendHoge(SamAppendable a) { a.append("hoge"); } public static void main(String[] args) { // (実はあまりいい例ではないのだけれど) // SAMインターフェイスにしたおかげで、ラムダ式が使える! appendHoge((csq, start, end) -> { System.out.printf("csq: %s, start: %d, end: %d%n", csq, start, end); return null; }); } }
それに対して、拡張メソッドではこのようなことは出来ません。
ジェネリックな IEnumerable は非ジェネリックな IEnumerable を継承しているため、 GetEnumerator を 2 種類実装する必要があって面倒なんですが、 「あれ、Java 8 の default 実装だとこの問題解決できるんじゃ?」 ・・・と思ったのがこのエントリを書くことになったきっかけです。 なるほど、拡張メソッドにしなかったのには理由がありそうですね。
そんなことより Scala ですよ
こんな感じで、どちらも一長一短であって優劣つけがたい感じなのですが・・・ なんと、Scala の trait はこのどちらにも対応しています。
既存の型へのメンバの追加
object Extensions { implicit class StringExtension(val value: String) { def toInteger: Int = value.toInt } }
2.10 からかなり簡単に書けるようになりました。
リッチインターフェイスのシンインターフェイス化
trait SamAppendable extends Appendable { def append(csq: CharSequence, start: Int, end: Int): Appendable def append(csq: CharSequence): Appendable = { this.append(csq, 0, if (csq == null) 0 else csq.length) this } def append(c: Char): Appendable = { this.append("" + c, 0, 1) this } }
Scala では Appendable を SAM インターフェイスにする意味はないですが、 リッチでファットなインターフェイスをシンインターフェイスにできるのは、 既存の Java コードとのやり取りを考えるとありがたいかもしれません。
Java8
Java8 に期待するより、今使える Scala 使おう! でも、default 実装が拡張メソッドの劣化コピーとか言っちゃダメ!
遅延評価いうなキャンペーンとかどうか
遅延評価については以前も書いてるんですが、そのときは結論なしでした。
が、ちょっと考えるところがあって、言語を Java に絞って自分の考えを明確にしておきます。
結論から書きましょう。
「Java(とC#) で遅延評価って書いてあるものは遅延評価ではない」です。
Java における「評価」とは
まず一番最初に、Java で「評価」って言うと、どういうことを指すのかを確認しておきます。
言語仕様の該当部分を要約すると、こんな感じでしょうか。
プログラム中の式を評価すると、結果は
- 変数
- 値
- 無し
のうちのどれかとなる。
評価した結果が値になる、というのはいいでしょう。それ以外の 2 つを軽く説明します。
評価の結果が「変数」とは?
コメント欄で指摘が入っています。
代入の結果は変数ではありません(15.26)。
結果が変数となるのは、ローカル変数、現在のオブジェクトやクラスの変数、フィールドアクセス、配列アクセスを評価した場合です。
調べ直したところ、確かに「代入式の結果自体は変数ではない」とありました。以下の記述は誤りですので、無視してください。
評価の結果が変数になる場合というのは、例えば次のような場合です。
String line; while ((line = in.readLine()) != null) { ... }
このコードにおいて、(line = in.readLine()) は評価されると、line という変数となります。そして、 line != null として評価され、真偽値となります。
評価の結果が「無し」とは?
評価の結果が無しになる場合というのは、void を戻り値とするメソッドを呼び出した場合に限られます*1。
例えば、
PrintStream ps = System.out;
ps.println("hello");
というコードにおいて、ps.println("hello") は評価されても結果としては「無し」となります。
式を評価しようとしたら例外が発生した、ということではないので注意してください。
まぁ、このエントリを読む分にはどうでもいいことですが・・・
完全に蛇足ですが、評価の完了は「通常の完了」と「急な完了」に分けられており、例外が発生した場合は「急な完了」となります(言語仕様の 15.6 より)。
となると、Java での評価は、
- 通常の完了
- 変数
- 値
- 無し
- 急な完了
のように分けられる、ということですかね。
「(遅延)評価」の誤用の例
このエントリを書くきっかけとなったものですが、
Java Advent Calendar 1 日目 - Project Lambda の遅延評価
などです。
例えば、
というのも、Stream インタフェースで提供されているメソッドは遅延評価されるからなのです
Java in the Box Annex: Java Advent Calendar 1 日目 - Project Lambda の遅延評価
という記述は「Stream インタフェースで提供されているメソッド」が式ではないため、(Java 8 で evaluation の意味が変わらない限り) 評価という言葉の誤用です。
「メソッド(起動式)は遅延評価される」と補えば、誤用ではないということもできるでしょうが、この文が言いたいのはそこではないのでやはり駄目でしょう*2。
遅延評価という言葉
この言葉が lazy evaluation を指すのか delayed evaluation を指すのかというのはひとまず置いておきましょう*3。
ここで問題にしたいのは、「遅延評価」という言葉を、「遅延リスト」のことと思って使っている人が多いのではないか、という点です。
これ、Java プログラマに限らず結構な人がそう思ってるような気がします。
少なくとも Java の言語仕様では式を値に変換することを評価と呼んでいますので、Java で評価ですらないものを遅延評価と呼んでしまう(しかも Java 界でもかなりすごい人が)のはよくないのでは、と思うわけです。
ということで、「遅延評価いうなキャンペーン」どうですかね。
や、とくに何をやるわけでもないですが。
遅延リスト
では、遅延リスト*4は何を遅延しているのでしょうか。
それは、「要素の計算」です*5。
上記ブログエントリから、例を拝借してみてみましょう(コメントは消してあります)。
List<Integer> nums = Arrays.asList(0, 1, 2, 3, 4, 5); Stream<Integer> stream = nums.stream(); Stream<Integer> stream2 = stream.filter(s -> s%2 == 0); stream2.forEach(s -> System.out.println(s));Java in the Box Annex: Java Advent Calendar 1 日目 - Project Lambda の遅延評価
この例で、3 番目の文に注目します。
// sという名前はあまりよろしくないので、nにした Stream<Integer> stream2 = stream.filter(n -> n%2 == 0);
この文を実行(評価ではないですよ)すると、stream2 には stream から偶数のみを取り出す Stream が格納されます。
偶数のみを取り出「した」Stream ではなく、偶数のみを取り出「す」Stream と表現したのがミソです。
そう、stream2 はこの時点ではまだ、stream をフィルタしておらず、「フィルタすることを予約した」とでも言うべき状態なのです。
この stream2 から実際に要素を取り出そうとするまで、フィルタという計算が遅延されるわけですね。
ここで注意してほしいのですが、この文に含まれる式で「評価されるべき式は評価されきっている」のです。
つまり、どの式も遅延評価されません。
それでは、文をばらして式にし、評価の結果どんな値になっていくのかを見てみましょう。
ローカル変数宣言文
上記の文は、全体としてローカル変数宣言文という種類の文に分類されます。
ローカル変数宣言文は、乱暴に言うと次の形の構文のことです(厳密には違うので、詳しく知りたければ 14.4 Local Variable Declaration Statements をどうぞ)。
型 変数名 = 式;
イコールの右側に式が出てきました。
上の文を当てはめると、型は Stream
メソッド起動式
stream.filter(n -> n%2 == 0) はメソッド起動式です。
メソッド起動式は
一次式.メソッド名(引数リスト)
の形をしており、stream が一次式、filter がメソッド名、n -> n%2 == 0 が引数リストです(厳密には 15.12 をどうぞ)。
引数リストは、
式 引数リスト, 式
のどちらかであり、今回は最初の方ですね。
つまり、n -> n%2 == 0 も式です。
そして、メソッド起動式を評価するためには一次式の評価と、引数リスト中の式の評価が必要となります(メソッド名は式ではないので、評価の対象ではありません)。
一次式は評価すると普通に値になるので、問題ないでしょう。
ここでは、stream を評価すると、stream という変数に格納されている Stream オブジェクトという値になります。
ラムダ式
さて、n -> n%2 == 0 を評価する必要がありますが、ラムダ式はまだ言語仕様として公式にリリースされていません。
そこで、意味的に同じものに置き換えて考えましょう。
public interface Filter<T> { boolean apply(T t); }
こんなインターフェイスがあったとして、filter が
public Stream<T> filter(Filter<T> filter)
のようなシグネチャを持っていたとします*6。
すると、次の 2 つの式は同じことを意味します。
/* Stream<Integer>なstreamがあったとして */ stream.filter( // 1. ラムダ式 n -> n%2 == 0 ) stream.filter( // 2. クラスインスタンス生成式 new Filter<Integer>() { public boolean apply(Integer n) { return n%2 == 0; } } )
つまり、ラムダ式は、クラスインスタンス生成式(15.9)とみなすことができます。
クラスインスタンス生成式は、
new 型名(引数リスト) クラス本体
となっており、型名は Filter
何をしたかったかというと、メソッド起動式を評価するために、メソッドの引数リストであるラムダ式を評価して値にすることでした。
ここで、ラムダ式を値にすると何になるかはもう明白ですね。Filter オブジェクトです。
これで一次式も引数リストも評価が終わりましたので、メソッド起動式の評価に移れます。
メソッド起動式を評価すると、起動対象となるメソッドの本体を実行し、その結果がメソッド起動式の値となります。
字面に出てくる評価すべき式をすべて評価しきりましたが、ここまでに遅延された評価(値が必要になるまで評価が保留された式)は一つもありませんでした。
Java 8 で導入予定の Stream は、遅延リストであっても遅延評価ではないということです。
n%2 == 0 が残っているじゃないか?
ラムダ式の本体の式の評価が行われていないのは、それが「まだ評価すべきではない式」だからです。
これを遅延評価と呼ぶのであれば、全てのラムダ式を遅延評価と呼ぶことになります。
例えば、
stream.forEach(n -> System.out.println(n));
とか、
Integer found = stream.findFirst(n -> n < 5);
とかも遅延評価だ、ということになりますが、これはさすがに変じゃないでしょうか。
forEach メソッドを実行すると、stream の要素全てを計算してしまいますし、findFirst も見つかるまでの要素は計算してしまいます。
少なくとも、引き合いに出したエントリではこれらのメソッドは「遅延評価されずに即時評価される」とありましたので、今回はこれを遅延評価とするという意見は取り上げません。
遅延リストの実装パターン
ということで、先のブログのエントリは遅延評価実現のための手法の解説ではなく、遅延リストを実装するための手法の解説ということになります。
これは Iterator を使った遅延リストの実装パターンとでも呼べるでしょう。特別に名前を付けたければ、「遅延評価」などと既存の用語の使いまわしをせず、遅延計算パターンとでも言えばいいんじゃないでしょうか。
用語の使いまわしは、変数名の使いまわし同様、可能な限り避けるべきです。
皆さんも、安易に「遅延評価」言わないようにしましょう。
じゃぁ遅延評価って?
ここに書いてあることは自信ないので参考程度にしてください。
メソッド起動式の評価に、レシーバとなる一次式の評価と、引数リスト中の式の評価が必要になる、と書きました。
起動するメソッドの中でその引数を使っている、使っていないにかかわらず引数リスト中の式は評価する必要があります。
このような評価戦略を、正格な評価戦略と呼びます。
それに対して、メソッドの中で実際に引数を評価する必要が出てきてから引数の評価を行うような評価戦略を、非正格な評価戦略と呼びます。
さらにその中で、一度評価したものを再び評価しないようなものを遅延評価(lazy evaluation)と呼び、毎回評価するものを遅延評価(delayed evaluation)と呼びます。
どちらも遅延評価となっていて紛らわしいので、lazy の方を怠惰評価と訳する場合もあります。が、この呼び方はあまり広まっていないようです。この 2 つを使い分ける必要がある場合に怠惰評価という言葉を使えばいいでしょう。
正格評価と非正格評価で結果が異なる例を挙げておきます。
int infLoop() { while (true) { } return 0; } void f(int i) { }
このようなプログラムがあった際、f(infLoop()) という関数呼び出しを行った場合、
- 正格評価では f の呼び出し前に infLoop の呼び出しが完了している必要があるため、infLoop を呼び出してしまって無限ループとなる
- 非正格評価では f の定義中で i の評価が必要ない(i の値を使っていない)ため、infLoop を呼び出さずに済み、無限ループにならない
のように、非正格評価の場合、なんと無限ループになりません。
おまけ
今回は Java に限定しましたけど、C# も遅延リストを遅延評価と表現する人多い感じがします(LINQの仕組み&遅延評価の正しい基礎知識とか)。
C# の言語仕様では「評価」そのものに対する節はないのでもにょるんですが、評価という言葉が使われている部分から、評価の対象はやはり式に限定しています。
また、「7.1.1 式の値」を見ると、「最終的に式が"値"を表すことが求められます」とある*7ことと、評価という言葉の使い方から、Java とほぼ同様の意味で「評価」という言葉を使っているようです。
ということで、C# も遅延評価って言うのは避けたいです。
あと Ruby 方面にも言いたい(enumerable_lzによる遅延評価のススメとか)んですが、Ruby は言語仕様がアレでソレなので・・・ねぇ?持ってないんですよね・・・
Ruby で「遅延評価」って表現する場合は、何を評価と言っているかを明らかにしてほしいところです。
遅延評価とは関係ないですが、三項演算子とか参照渡しとかについても、こう・・・まぁいいや。
*1:と、実は続きを読むとそう書いてあります
*2:それに、後で述べますが、メソッド起動式は遅延評価されません
*3:この 2 つを区別するような人はそもそも誤用しないでしょう
*4:Java 8 では Stream と呼ぶらしい。InputStream とかあるのに Stream なんて名前付けちゃうのもいらぬ混乱の元となりそうなんでどうかと思うんですが・・・
*5:Java 言語仕様では calcurate や calcuration という単語が含まれる節はないので、「計算」という言葉に身構えなくて大丈夫です。たぶん。
*6:例を簡単にするために単純化してありますが、実際には Predicate というインターフェイスだし、シグネチャも微妙に違います
*7:この直前に「式が関係する構造のほとんどでは、」のほとんどが何を言っているのかは気になるが・・・