BT

最新技術を追い求めるデベロッパのための情報コミュニティ

寄稿

Topics

地域を選ぶ

InfoQ ホームページ アーティクル Javaにおけるラムダ:詳細な分析

Javaにおけるラムダ:詳細な分析

原文(投稿日:2010/06/03)へのリンク

Sun Microsystemsを獲得したことで、Oracleは多くの人々に活気を失ったと思われている言語を再活性化させるという真剣なビジネスに向き合うことができるようになりました。多くの人々の要求の内で最たるものは、クラスから独立した関数を受け渡し、その関数が他の関数やメソッドの引数として使えるようにして欲しいというものでした。HaskellF#のような関数型言語は純粋にこのパラダイムに基づいていますし、関数型プログラミングの歴史は80年前のラムダ計算にまでさかのぼります。ここからラムダという単語が多くの言語において匿名関数を示すのに使われるようになったのです。

PythonRubyのような言語は長いことラムダを使ってきました。最近のJVMベース言語の台頭(特にGroovyとScala)により、Javaにおいてもラムダを求める声が再燃したのです。だから、これが公表されOpenJDKプロジェクトとして提案された時には、多くの人が興奮したのでした。ラムダとクロージャが混同されていたとしてもです。この議論についてもっと知りたいと興味を持つ人のために、InfoQのニュースもあります(ここにはJavaにおけるラムダのシンタックスに関する議論も含まれています)。

ラムダとクロージャ

匿名関数に対する正しい名前は常にラムダか、もしくはギリシア文字のλです。これは、関数とは純粋に引数の結果であるという考え方です。ラムダ計算において、インクリメントを行う関数は次のように記述されます。λx.x+1これはPythonの関数で言うと、def inc(x): return x+1と似ていますが、こちらは'inc'という名称に結びつけられています。(Pythonで純粋なラムダをこう書くこともできますlambda x:x+1。そして結果を変数に格納し、別のデータ構造に保存することもできます。)

すべてが引数で決まるのではなく、外部の状態に依存するような不完全な関数を書くこともできます。例えば、λx. x+yは、開いた関数で、yが定まるまで評価できません。ローカルな(語彙的な)状態を捉えるふるまい、例えば、λy. λx. x+yは関数に対してクロージャをもたらします。すべての変数はここに捕捉され、したがって「閉じた」用語で参照されるのです。クロージャという名称は、開いた用語を閉じるというこのふるまいを差しています。)すべての匿名関数はラムダですが、それが常にクロージャであるわけではありません。

実際、Javaには1.1の時代から(インナークラスという形で)クロージャがあります。次のコードを見て下さい。

public interface IFilter {
  public boolean filter(int x);
}
public class FilterFactory {
  public static IFilter greaterThan(final int i) {
    return new IFilter() {
      public boolean filter(int x) {
        // iは語彙的スコープの外部から与えられる
        return x > i;
      }
    };
  }
}

上記のコードサンプルでは、FilterFactorygreaterThanというファクトリメソッドがあり、これは呼び出しに際して引数に関するクロージャを返します。同じコードを異なる引数で呼び出すと、異なるクロージャが作られます。それぞれが独自の値を捕捉するのです。次のラムダ式においても、同じことが言えます。λi. λx. x > iを閉じられたラムダ(iを引数にとる)として見ると、これは関数を返し、評価された場合には引数をクロージャ時のiの値と比較します。3と4を与えて2度呼び出した場合、別々のクロージャが2つできることになります。すなわちλx. x > 3λx. x > 4です。匿名関数をクロージャと呼ぶのは意味的に正しくありません。すべてのラムダがクロージャであるわけではなく、すべてのクロージャがラムダであるわけでもありません。しかし、あらゆる匿名関数はラムダです。

ラムダ試案

最初の提案は12月に投稿されました(Devoxxに向かう飛行機の中で考えられたものがもとになっています)。より正式な0.1版が1月に提出され、続いて2月には0.1.5版が提出されました。それ以来、メーリングリストは静かでしたが、最近5月に行われた熱い議論から別の議論が派生し、新しい提案翻訳文書が示されました。作業は水面下で進められているようです。メーリングリストはほぼ外部にいて興味を持っている人々で成り立っていますが、すべての作業は閉ざされた扉の奥で、Oracleによって進められているのです。言語の変更はJCPを通過しますが、JCPそれ自体でどんな変更が進められているのか(そもそも変更があるのか)についてのニュースはありません。さらに、Harmony問題もまだ解決されていません。

より大きな関心はスケジュールによせられています。4月にNeil Gafter氏は、ラムダプロジェクトの遅延は(当時の)JDKのスケジュールと合わなくなるだろうとコメントしていました。(JDK7は常に遅延すると見ていた人々もいました。)現在はっきりしているのは、ラムダが今でも作業中であり、JDK7もまだリリースが可能な状態になっていないということです。両者が相互に影響を与え続けるのかは分かりません。

最近になって、最初の実装がOpenJDKで入手できるようになりました。これは提示されたものの一部を実装したものです。なお、この記事におけるシンタックスと記述は、現在の試案に基づくものであり、最終的な仕様が公開された場合には、この記事はそれを反映するように更新される予定です。

Javaにおけるラムダ

ラムダは、JSR292における新しい機能と関係しています。これはメソッドハンドルと呼ばれるもので、シンタックス上メソッドへの直接参照ができるようにするというものです(この場合、リフレクションによる階層を経由することはありません)。何よりも、メソッドハンドルというVMレベルの概念により、メソッドのインライン化に際してさらなる最適化ができるようになります。これはJITが示す唯一最大の功績です。

Javaにおいてラムダを表現するにあたって示されたシンタックスは、#の後に引数が来て、さらに式かブロックが続くというものでした。これは最初の提案から変わっていないため、最後までこのままであることが予想されますが、もちろん変わるかもしれません。(これは既に論じられている通り、Project Coinにおいてなされた、外来メソッド("exotic method")名を意味するために#を使うという決定に従ったものです)

inc = #(int x) (x+1); // single expression
inc2 = #(int x) { return x+1; }; // block

同様に、ラムダによって自分を取り巻くローカルなスコープにある変数を捕捉することができるようになり、したがって以下のように加算に対応するものを書くことができるようになるのです。

int y = ...;
inc = #(int x) (x+y);

これは、関数ファクトリを生成するために、関数から変数を取得するという風に一般化することができます。

public #int(int) incrementFactory(int y) {
  return #(int x) (x+y);
}

#int(int)型はラムダ自体の型であり、この場合には「intを引数として受け取り、戻り値としてintを返す」ということを意味します。

ラムダを呼び出すにあたっては、以下のように書けることが理想です。

inc(3);

しかし、これではうまくいきません。問題はJavaがフィールドとメソッドについて異なる名前空間を持っているという点にあります(この点は名前空間を共有するC++とは異なります)。したがって、C++においてはfooというフィールドとfoo()というメソッドを持つことができませんが、Javaではまったく問題ありません。

public class FooMeister {
  private int foo;
  public int foo() {
    return foo;
  }
}

ラムダにおいてはどうなるでしょうか。問題が起こるのは、ラムダをクラスのスタティックフィールドに代入した時です。

public class Incrementer {
  private static #int(int) inc = #(int x) (x+1);
  private static int inc(int x) {
    return x+2;
  }
}

Incrementer.inc(3)を呼び出した場合、どんな値が返されるのでしょうか。ラムダにバインドされた場合には4になりますが、メソッドにバインドされた場合には5になります。このことから、ラムダはメソッド呼び出しと同じシンタックスを用いることができません。場合によってはあいまいになってしまうからです。

現在はラムダを呼び出すのに、ピリオドとカッコを使うことが提案されています。つまり、上記のラムダを呼び出す場合、Incrementer.inc.(3)となります。ピリオドが1つ多いことに注意して下さい。これによりラムダの呼び出しが通常のメソッド呼び出しと区別されます。実際には、ラムダ式を変数に代入する必要もありません。必要ならば、次のように書くこともできます。#(int x) (x+1).(3)

ラムダと配列

ラムダの基礎について語ったところで、意識しておくべきポイントがあります。次のコードサンプルについて考えてみて下さい。

#String(String)[] arrayOfFunction = new #String(String)[10];
Object[] array = arrayOfFunction;
array[0] = #void() {throw new Exception()};

ここでの問題は、もし最初の代入が可能で、同じく2番目の代入も可能であれば(あらゆる参照型はObjectにキャストできるので)、異なるシグニチャの関数を配列に代入できることになります。その結果、arrayOfFunction[0].("Foo")を呼び出すようにコンパイルされたコードは実行に失敗します。配列の先頭にあるラムダは引数としてStringを取ることができないからです。

特定のラムダ実装においてこれは問題になりません。残念ながら、これはJavaのタイプシステムにおいて、すべてがObject(もしくは、0bjectの配列)にキャストできることに由来する弱点です。総称型も実行時のClassCastExceptionを引き起こすとはいえ、こちらは配列の型として表現されたものと合致しないシグニチャの関数を許してしまうことになります。

ここから、ラムダの配列はJavaにおいて使えません。ただし、総称型のListならば使うことができます。

共有された可変ステートと実質的final("Effectively final")

インナークラスに関するJavaのコンベンションでは、インナークラスで用いられるすべての変数はfinalキーワードを用いて宣言しなければなりません。これはインナークラスによって取得された後で変数の値が変わってしまうことを防ぐためです(こうして、インナークラスの実装を実質的にシンプルにしています)。これにより開発者は、インナークラスとしてラッピングする場合には数文字余計にコードに加える必要があります。もしくはIDEによって自動的に行うことも考えられます。

ラムダが求められるユースケースをシンプルにするために、実質的finalという概念が生み出されました。これにより、finalではない値がメソッドの本体によって変更されていないことをコンパイラが検証できる限り、ラムダによって捕捉できるようになりました。実際に、これは、IDEがコンパイラフェーズにおいて実施できることを推進させるものです。

しかし、捕捉された変数が変更可能であるということは、ラムダを捕捉する際に無視できないオーバヘッドが潜在的に導入されるということを意味します。したがって、ラムダによって捕捉される変数が同じくラムダによって変更される(あるいはラムダが捕捉したスコープの後で変更される)可能性があるならば、sharedキーワードによって明示的に示される必要があります。実質的に、このルールはラムダによって捕捉されるローカル変数のデフォルトをfinalにするものですが、可変性を示すためにさらなるキーワードが必要になるのです。

public int total(List list) {
  shared int total = 0;
  list.forEach(#void(Integer i) {
    total += i;
  });
  return total;
}

可変ステートに関する問題は、純粋に関数的なアプローチに対して異なる実装を要求します。例えば、ラムダが異なるスレッドやメソッドの外部で実行されるような場合には、その結びつきを表現するのに、別のヒープが割り当てられたオブジェクトが必要になるでしょう。例えば、以下のコードはインナークラスと同じ結果をもたらします。

public int total(List list) {
  final int total = new int[1];
  list.forEach(#void(Integer i) {
    total[0] += i;
  });
  return total;
}

ここで特筆すべきは次のことです。すなわち、前者の方が意図は明確ですが、どちらもメソッド呼び出しが完了しても生き続けるヒープオブジェクトを構築することになります。(forEachに渡されるラムダが、例えば1つの要素からなる整数の配列のような内部の変数に保持される保証はありません。)

このことから共有された可変ステートがJavaのラムダではアンチパターンであることが分かるでしょう。特にこの種の合算プロセスはある種の畳み込みと還元を行う方が、命令的な合算を行うよりも優れていることが多いのです。

特に、共有可変ステートを用いるラムダがマルチスレッド環境でうまく動作するには、加算器に対するアクセスが同期されたアクセサによってガードされる必要があります。

Collectionsクラスの拡張

Java言語においてラムダが使用できるかどうかということは、Collectionsクラスに採用されるかどうかとは関係ありませんが、Collectionsクラスで関数の操作を実行できた方が好ましいことは明らかです。(上記の例はまだ存在しないforEachメソッドがList上に作られることを想定しています。)

しかし、多くのクライアントが既にListインタフェースを実装してしまっているため、インタフェースに新しいメソッドを追加すると、前の世代との後方互換を破壊することになります。この問題を解決するため、defender methodの提案が別途提示されました。これはインタフェースにメソッドが存在しない場合の「デフォルト」メソッドを定義するというもので、ヘルパクラス上のスタティックメソッドにバインドされます。例えば、以下のように記述できます。

public interface List {
    ...
  extension public void forEach(#void(Object))
    default Collections.forEach;
}
public class Collections {
  public void forEach(Collection c, #void(object) f) {
    for(i:c)
      f.(i);
  }
}

この結果、List.forEach(#)の呼び出し元はCollections.forEachの実装を呼び出す可能性があり、また、これは本来の対象に向けるために追加で引数をとることになるでしょう。しかし、各実装では、性能上、より最適化された形でこのデフォルトを実装することができます。

public class ArrayList implements List {
  private Object elements[];
  public void forEach(#void(Object o) f) {
    for(int i=0;i<elements.length;i++)
      f.(elements[i]);
  }
}

しかし、懸念もあります。たとえば、Collectionsクラスのretro-fittingは、ラムダとスケジュールを合わせるために急いで終わらされた感があります。Java言語をラムダをサポートするように拡張するに留めて、java.utilのCollectionsクラスにおけるラムダのサポートは行わず、それは後のJDKの一部にまわすということもあり得るでしょう。

「サム、もう一度聴かせてよ」

現在の関数型Java実装における一般的なパターンとして、シングルアクセスメソッド(SAM:single access method)を拡張的に用いるというものがあります。多くのフィルタ操作がPredicateFunctionといった操作を備えており、これらは現在所定のインタフェースを実装したサブクラスとして表現されます。たとえば、Integerのリストをフィルタリングするには、以下のように書くことができます。

Iterables.filter(list,new Predicate() {
  public boolean apply(Integer i) {
    return(i > 5 );
  }
});

しかし、複数のインタフェースにおけるSAMメソッドが可能になれば、以下の記述が可能になります。

Iterables.filter(list,#(Integer i) (i > 5));

つけ加えると、こちらの方が性能も優れています。コードが評価される度にPredicateインスタンスを作る必要がないためです。また、もしJSR292の改善によってJavaのダイナミックメソッドが拡張されてasSam()呼び出しがサポートされるようになれば、メソッド-インスタンス-インタフェースの紐づけはすべての関連する呼び出しを最適化した上で除去するという目的で、JVMによって理解されるでしょう。しかし、これにはラムダグループのスコープ外でもサポートする必要が生じてしまうため、現在のスケジュールでは不可能でしょう。

thisの意味と非ローカルの戻り値

ラムダにおけるthisの扱いに関するオープンクエッションがもう1つあります。thisが参照するのは、ラムダ自体でしょうか、それともエンクローズしているオブジェクトインスタンスでしょうか?ラムダは参照型ですが、その参照を内側からつかむことができるということは、再帰的な関数の実装を許すことにつながります。たとえば次のようなコードが考えられます。

fib = #int(int i) { 
  if(i == 0 || i == 1 ) 
    return 1 
  else
    return this.(i-1) + this.(i-1);
}

thisがなければ、ラムダが代入される変数への参照を引き出さなければなりません(つまり、右辺が代入の左辺に依存することになりるのです)。逆に言えば、エンクローズしているオブジェクトへの参照がより難しくなるということです。つまり、クラス名をインナークラスの時のようにクラス名を先頭につけなければなりません。

public class EnclosingScope {
  #Object() { return EnclosingScope.this; } 
}

最初のバージョンでは、ラムダの内部でthisが許されないということはあり得ますが、エンクローズされたスコープによってthisを修飾するということはいずれにせよあり得ます。

他に考えるべきこととしては、returnbreakといったフロー制御処理が持つ意味があります。言語によっては、Selfのようにローカルではないreturn句がブロック内に書けることもあります。この場合、ブロック自体がエンクローズしているメソッドのreturn句をキックすることができます。同じように、フロー制御(breadcontinueを用いたループなど)は、処理中のラムダの外側でイベントをキックします。

この場合、return句のセマンティクスは、外部の構造よりもエンクローズするラムダのreturnと密接に結びつけられます。しかし、この場合でも、例外は期待通りに動作します。ラムダの内部からスローされた例外はエンクローズするメソッドにまで伝播し、そこからメソッド呼び出しが連鎖します。これは例外がBGGAの提案において制御される仕方と似ています。

結論

ラムダのドラフトは進んではいますが、JDKのリリース計画において当初提案されたスケジュールとは異なっていますが、最近になってdefender methodが追加されたことからも分かる通り、完全な実装がこの夏に出るとは思えません。JDK7がラムダを外して(あるいはretro-fittingに対応したCollections APIを外して)リリースされるのか、ラムダ拡張が達成されるまでJDK7のリリースが延期されるのかはまだ分かりません。

この記事に星をつける

おすすめ度
スタイル

BT