言語設計者のノート: 新機能と、それに付随する新機能

言語に新機能を追加することによって、別の新機能も追加することになる場合

ある重要な新機能が言語に追加される場合、それに付随して (その良し悪しによらず) 他の新機能の追加が必要になることや推奨されることが非常によくあります。今回の「言語設計者のノート」では、言語に機能を追加するのに伴い、他の機能も必要になる状況について、著者の Brian Goetz が説明します。

Brian Goetz, Java Language Architect, Oracle

Brian Goetz photoBrian Goetz は Oracle の Java 言語アーキテクトであり、developerWorks へのベテラン寄稿者です。彼が執筆した記事や著書には、2002年から 2008年まで developerWorks に公開された「Java の理論と実践」シリーズ、そして Java の並行処理に関する決定版、『Java並行処理プログラミング ―その「基盤」と「最新API」を究める―』(2006年ソフトバンククリエイティブ刊) などがあります。



2011年 11月 25日

この連載について

おそらくすべての Java 開発者が、Java 言語を改良するとしたらどのように改良できるか、いくつかアイデアを持っているはずです。この連載では、Java 言語アーキテクトである Brian Goetz が、Java SE 7、Java SE 8 およびそれ以降へと Java 言語が進化する中で課題として提示された言語設計上の問題について探ります。

言語の機能の中には、例えば Java SE 7 の「2 進数リテラル」のように、単独でも十分存在できるものもあります。しかし言語の主要機能の多くは、その機能が有効に機能するために、または既存の機能との兼ね合いのために、追加の機能が必要になりがちです。これは問題になる可能性があります。大規模な機能を言語に追加すること自体がすでに危険であり、その機能に付随してさらに機能が追加されると危険性が高まるだけだからです。

コンビニエンス機能に対して高まる圧力

言語に 1 つの機能を追加することに付随して別の機能も追加することになる要因の 1 つは、新機能の追加によってそれとは無関係の「コンビニエンス機能」を追加すべきであるという圧力が高まることにあります。Java SE 5 で追加された autoboxing を考えてみてください。Java には当初から基本型 (int など) と、これらの型をボックス化するラッパー・クラス (Integer など)、そしてこれらの間の変換を行うためのメソッドがありました。autoboxing 機能 (基本型からそれに対応するラッパー・クラスへの変換を暗黙的に行う機能) は、その気になれば最初からいつでも追加できた機能であり、Java SE 5 よりも前に autoboxing を求める声はありました。しかしこのコンビニエンス機能を最終的に追加するだけの十分な圧力が高まったのは、Generics が追加され、それに伴ってコレクションが広く使用されるようになったためでした。それ以前にも、基本型とそのラッパーとの間で変換が必要な状況はありましたが、ジェネリック・コレクションによってそうした状況がはるかに一般的になりました。なぜなら、ボックス化された基本型のキーや値を持つコレクションを作成しておくと重宝するようになったからです。これにより、以前はほんの少し不便であったものが大きな不便となり、autoboxing を追加すべきだという圧力が高まりました。

似たような例として、同じく Java SE 5 で追加された enum と静的インポートの関係があります。静的インポートも登場が待たれていたコンビニエンス機能でした。単純に PI と記述するのではなく Math.PI と記述しなければならないのは面倒だと昔から思われていました。しかし Java SE 5 で enum 機能が追加されたことで、静的インポートを使えるようにすべきだという圧力が高まりました。enum により、名前付きの構造化定数 (Color.REDColor.BLUE など) を容易に作成できるようになったからです。そして何かが容易になると、それによってさらに多くのものが得られるようになるものです。以前は、静的定数として使用できるのはシステムによって定義される 2 ~ 3 個だけでしたが、enum が追加されたことでユーザーが静的定数を作成できるようになりました。このため、使うたびに (単純に REDBLUE と記述するのではなく) 名前を修飾しなければならない面倒が従来以上に大きくなりました。その結果、enum が追加される相当前から単独の機能として静的インポートを追加することもできたものの、enum が追加されたことによって静的インポートの追加を正当化する圧力が十分に高まったのです。

多くの場合、こうしたことは問題になりませんが、これらのコンビニエンス機能が問題をはらんでいる場合もあります。例えば、autoboxing と三項条件演算子とをうまく組み合わせるのは難しく、また autoboxing を使用すると、まったくオブジェクト参照と無関係のように見えるコードから NullPointerExceptions がスローされる場合があります。


LINQ: 1 つの機能の追加が 6 つの機能をもたらす

言語に1 つの機能を追加したことに付随して大量の機能が追加された好例の代表は、.NET 3.0 で中心的な改善点として追加された LINQ (Language-Integrated Query: 統合言語クエリー) 機能でしょう。LINQ により、オブジェクトの値を持つクエリーをコードに直接埋め込むことができます。これらのクエリーはデータベースに対して実行できるだけではなく、XML 文書やメモリー内のコレクションなど、他のデータ・プロバイダーに対しても実行することができます。問い合わせ言語を汎用のプログラミング言語に組み込むという発想は簡単に実現できるように思えますが、掘り下げて検討を始めると、その機能を実現するためには他の機能が大量に必要になることがわかります。

以下の C# コードはコレクションに対して LINQ クエリーを実行する場合の典型的な例を示しています。このコードは Person オブジェクトのコレクションを引数に取り、18 才未満の人の苗字 (p.LastName) と名前 (p.FirstName) を選択して出力します。

var results = 
    from p in people
    where p.Age < 18
    select new {p.FirstName, p.LastName};   

foreach (var r in results) {           
    Console.WriteLine(r.FirstName + " " + r.LastName); 
}

このコードを機能させるには、他にも多数の機能が必要になります。このクエリーによって返される型は何でしょう。このクエリーは FirstName プロパティーと LastName プロパティーを問い合わせているだけなので、返される型は Person オブジェクトのコレクションではなく、FirstName プロパティーと LastName プロパティーのみを持つクラスのコレクションです。コンパイラーはクエリーの中の選択されたフィールドを基に、このクラスを生成します。そのために、.NET は匿名クラスを導入しました。匿名クラスが導入されなかったとしたら、ほとんどすべてのクエリーの結果に対し、新たに名前を付けたクラス型を作成しなければなりません。

また LINQ ではラムダ式 (クロージャー) もサポートする必要がありますが、これは上記のクエリーの例では明確にはわかりません。LINQ を実装するためには、プロバイダーの API を呼び出すためのクエリーを作成し直す必要があります (このプロバイダー API により、まったく異なるデータ・ソースに対してクエリーを実行することができます)。コンパイラーは上記のクエリーを以下のように作成し直します。

var results = 
    people.Where(p => p.Age < 18)
          .Select(p => new {p.FirstName, p.LastName});

Where() メソッドは、指定された要素を選択する必要があるかどうかを決定する述部を引数に取り、そのフィルターを通った要素からなるストリームを生成します。次に、FirstName プロパティーと LastName プロパティーのみを含む匿名クラスの新しいインスタンスに対し、Select() メソッドは選択された各要素をマッピングします。

しかしそれで終わりではありません。データ・プロバイダーが SQL データベースの場合には WHERE 節を各レコードに適用する必要があります。そのための 1 つの方法は、データベースからすべてのレコードを取得してアプリケーションに入れ、各レコードの Age プロパティーをテストする方法です。しかしこの方法はおそらく極めて非効率であり、できればもっとデータに近いところで WHERE 節を評価したいものです。賢明な方法としては、データベースに WHERE 節を送信することです。しかしそれはつまり、述部 p.Age < 18 を SQL に変換し、その SQL をデータベースに送信する、ということです。

この問題に対して LINQ に導入されたソリューションが式ツリーです。式ツリーはリフレクションに似たメカニズムであり、クラスのメンバーのみならずメソッドのコードに対しても使用することができます。そのため、SQL LINQ プロバイダーは Where() メソッドに渡されたクロージャーを分析して SQL に変換することができます。

クエリーを API 呼び出しに変換するためには拡張メソッドも必要でした。Where() メソッドはコレクション・オブジェクトで呼び出されていますが、Where() メソッドはコレクション・フレームワークのメンバーではありません。Where() メソッドは LINQ サブシステムで定義される静的メソッドであり、IEnumerable (.NET で Java の Iterable に対応するもの) に注入されます。こうしない限り、コレクションに対する LINQ クエリーを容易に表現することはできないはずです。

最後に、変数の型を明示的に宣言せずに変数にクエリーを割り当てられるように、暗黙的型付けの変数が必要です。このクエリーの結果は何らかの匿名型の IEnumerable であるため、コンパイラーにはこの結果の型がわかりますが、C# で記述することはできません (あるいは、一部のクエリーではクエリーの結果の型を記述することはできますが、記述すると非常に冗長になります)。そのため、C# では var で変数を宣言できるようになっています。var により、コンパイラーは型を判断することができるため、ユーザーが型を記述する必要はありません (記述する必要がないのはプログラマーが怠惰になることを推奨しているわけではなく、型を記述できない場合があるからです)。

ここまでは長い道のりでした。単純に思える目標 (汎用言語にクエリーを組み込む) として出発したものが、結局のところ、匿名クラス、暗黙的型付けの変数、クロージャー、拡張メソッド、式に対するリフレクション・メカニズムが必要になってしまいました。これらの機能はどれも、LINQ の重要な目標のいずれかに不可欠なものでした。

ユーザーとして、これは素晴らしいと結論付ける人がいるかもしれません。1 つの機能を期待したら実は 6 つの機能を得られたのです。しかし既存の言語に新しい言語機能を追加すると、必ず代償が伴います。言語機能 A をサポートするために言語機能 B を追加する場合、「A と組み合わせる場合のみ B を使用すること」という要求をすることはできません。B は単独ではあまり望ましくない機能かもしれず、他の言語機能との相性も良くないかもしれません。確かに、言語の安全性や表現力を高めるなど、A を追加する目的は明確でした。しかし A の追加が成功であったかどうかを評価するためには、A 単体に対して評価を行うのではなく、A に付随して導入されるすべての機能を含め、結果として生まれる新しい言語に対して評価を行う必要があります。最終的な言語が望ましいものではない場合には、元々追加しようとした機能について再検討する必要があります。


Java のラムダ式

Java 言語に対して Java SE 8 で加えられる改善の中心はラムダ式、つまりクロージャーです。しかしラムダ式の場合も .NET の LINQ の場合とまったく同じように、ラムダ式のメリットをフルに生かすためには、SAM 変換、機能強化された型推論、メソッド参照、拡張メソッドなど、いくつかの機能を同時に追加する必要があります。

ラムダ式 (関数を表現する式) は Java では新しい種類の値であるため、ラムダ式の型を記述する方法が必要です。Java にラムダ式を追加することを要求する初期の提案では、型体系に関数型 (「int から int に変換する関数」など) を追加することを要求していました。確かに関数型はラムダ式の型を表現するための自然な方法ですが、残念なことに、既存の言語「機能」であるイレイジャー (erasure) との相性が良くありません。バイトコードで関数型を表現するための自然な方法は Generics を使用する方法なので、関数のシグニチャーの中にある基本型はボックス化されることになり、関数型を引数に取る複数のメソッドをオーバーロードすることはできません (それらのメソッドの引数がまったく異なる場合であってもできません)。ラムダの型を表現する上で、関数型を使用するのは自然な方法かもしれませんが、消去された関数型を使用するのは自然な方法ではありません。

そのため、Java SE 8 のラムダ式では、関数型の代わりに別の新機能、SAM 変換が導入されます。SAM (Single Abstract Method) 型は、これまで Java 言語で関数を表現するためにずっと使用されてきた手段、つまり 1 つのメソッドを持つインターフェース (RunnableComparatorActionListener など) です。SAM 型で API を作成すると (そうした API は既にさまざまなライブラリーの中に数多く存在しています)、コンパイラーはラムダ式 (関数リテラルと似ています) と SAM 型との間の変換を行うことができます (SAM 型の引数型、戻り型、例外型はラムダ式の場合と同じです)。例えば、以下のコードは長さでストリングを比較する Comparator<String> を宣言し、ラムダ式を使って Comparator を定義しています。

Comparator<String> c 
    = (String a, String b) -> a.length() — b.length();

ラムダ式は適切な引数と戻り型を持っているので、コンパイラーはそのラムダ式を Comparator<String> に変換できることを確認し、変換のための適切なコードを生成します。これが SAM 変換と呼ばれるものです。

ラムダ式を要求する主な理由は、コードをデータとして表現したいからです。そうすればコード・リテラルをライブラリーに渡し、都合の良いときにコード・リテラルを呼び出させることができます。ラムダ式を要求するもう 1 つの理由は、内部クラスの冗長性を減らしたいためです。これは現在、ラムダ式による効果を最も直接的に得られる部分です。構文上の冗長な構成体を削除し始めると、際限なく削除したくなりがちです。その結果、ラムダ式の導入により、別の機能、つまりターゲットによる型付けによって拡張された型推論が導入されます。先ほどのラムダ式は Comparator<String> に割り当てられているため、abSring 型と宣言するのは冗長であり、通常はコンパイラーがそれを判断してくれます。割り当てコンテキストの型を使用して ab の型を推論することにより、上記の例を以下のように簡潔にすることができます。

Comparator<String> c 
    = (a, b) -> a.length() — b.length();

Person オブジェクトのコレクションがあったとし、このリストを Person の苗字 (LastName) でソートしたい場合、それを現状の方法で作成すると以下のようになります。

Collections.sort(people, new Comparator<Person>() { 
    Public int compare(Person a, Person b) { 
        return a.getLastName().compareTo(b.getLastName());
    }
}

ラムダ式を使用すると、上記の式を以下のように簡潔にすることができます。

Collections.sort(people, (a, b) -> 
    a.getLastName().compareTo(b.getLastName());

これは冗長性を減らす上で大きな前進ですが、それでもあまり抽象化されてはおらず、ユーザーは相変わらず命令型で比較関数を計算しなければなりません。ライブラリーを少し変更すると、もっとうまくラムダ式を使用し、ソート動作の中心部分、つまりソート・キーを選択する部分を分離することができます。StringComparable であるため、ソート・メソッドはソート・キーが抽出された後で比較を行う方法を既に認識しているはずです。

Collections.sortBy(people, p -> p.getLastName());

間違いなく、良くなってきました。コードから「人を苗字でソートする」という問題の記述を読み取れるようになっています。しかしボイラープレートを除くと、ソート・キー (ここでは苗字 (LastName)) の抽出に使用されているイディオム自体が少し込み入っています。上記のラムダ式は、引数を取得して (この場合は引数がありません) 既存のメソッド getLastName() に渡し、メソッドを呼び出すオブジェクトとして最初の引数を扱っているにすぎません。この例の場合には、名前の指定が必要な余分な引数がないため (指定が必要な場合は名前を 2 度繰り返す必要があります)、それほど悪いものとは思えませんが、単純にメソッドの名前を直接指定した方がはるかに適切に思えます。関連した機能であるメソッド参照を使用すると、それができるようになります。つまり名前でメソッドを参照し、そのメソッドを (ラムダ式とまったく同じように) 関数の値を持つデータとして扱うことができます。

Collections.sortBy(people, Person::getLastName);

最後に、ボイラープレートはなくなっているので、sortBy() メソッドは実際にはどこかのユーティリティー・クラスの静的メソッドである必要はなく、コレクションのインスタンス・メソッドであるべきだということが、以前よりも一層明白になっています。しかし、インターフェースの不幸な性質の 1 つとして、いったんインターフェースを規定すると、既存の実装を変更せずに新しいメソッドを追加することはできません。ラムダ式に付随して導入された最後の機能が仮想拡張メソッドです。仮想拡張メソッドを使用すると、(オーバーライド可能な) デフォルトの実装をメソッドの宣言と共に提供することにより、互換性のある方法で新しいメソッドをインターフェースに追加することができます。その結果、ラムダ式を使いやすい (そして並列処理を行いやすい可能性のある) forEach() などのメソッドを List に追加できるようになります。sortBy() に対して拡張メソッドを List に追加すると、上記の例が今度は以下のようになります。

people.sortBy(Person::getLastName);

不思議なことに、このコードはラムダ式をまったく使用していません。しかしこのコードは、言語にラムダ式を追加するという中心となる目的、つまり計算の部分をデータとして渡せるように取り込み、ソートなどのライブラリー機能をより表現力豊かにパラメーター化できるようにする、という機能を実現しています。この特定の例では、メソッド参照の方がラムダ式よりも意図を明確に表現することができますが、考え方は同じです。

SAM 変換、型推論、メソッド参照、拡張メソッドを追加せずに Java にラムダ式を追加することもできたかもしれません。しかしこれらの機能がないと、結局は苦労する羽目になる可能性が高く、そうした苦労の基になる正確な原因を理解することすらできないかもしれません。


場合によると、逆の方向もあり得ます

Java に関数型を追加しないことに対する理由として、関数型に付随して必然的に追加される言語機能を望まない、またはそれらの追加機能に対応できないかもしれない、といった理由を挙げることもできます。関数型はラムダ式の型を表現するための自然な方法となるはずであり、関数型を追加すれば PredicateMapper などの nominal な型はあまり必要なくなるかもしれませんが、イレイジャー (erasure) との関係があまりにも面倒です。当然の反応として、そうした面倒な機能をなくすために、別の機能として具象化 (reification) を併せて導入する方法が考えられます。具象化された Generics を Java 言語に追加することに関しては賛否両論がありますが、実際には、具象化のように大規模で影響の大きな (言語、コンパイラー、ライブラリーに影響します) 機能をラムダ式の追加と同時に追加するのは非現実的でした。そのため、私達は関数型に付随して追加される機能に対応する余裕がないことを考慮し、(少なくとも差し当たっては) 関数型も断念せざるを得ませんでした。


まとめ

言語の主要機能の大部分は完全に単独で存在することはできません。それらの機能はほとんど必ず、その機能が本来実現しようとしているメリットを完全に得るには別の機能も必要とします。付随して他の機能も必要となる機能の追加を検討する際には、それらの付随機能を含めて新機能を追加することが本当に必要なのかどうか、注意深く検討する必要があります。そうしないと付随機能によって身動きがとれなくなってしまうかもしれません。新機能に付随した新機能に対応することができない場合には、元々の新機能自体も不要であると結論付けざるを得ないでしょう。

参考文献

学ぶために

製品や技術を入手するために

議論するために

コメント

developerWorks: サイン・イン

必須フィールドは(*)で示されます。


IBM ID が必要ですか?
IBM IDをお忘れですか?


パスワードをお忘れですか?
パスワードの変更

「送信する」をクリックすることにより、お客様は developerWorks のご使用条件に同意したことになります。 ご使用条件を読む

 


お客様が developerWorks に初めてサインインすると、お客様のプロフィールが作成されます。会社名を非表示とする選択を行わない限り、プロフィール内の情報(名前、国/地域や会社名)は公開され、投稿するコンテンツと一緒に表示されますが、いつでもこれらの情報を更新できます。

送信されたすべての情報は安全です。

ディスプレイ・ネームを選択してください



developerWorks に初めてサインインするとプロフィールが作成されますので、その際にディスプレイ・ネームを選択する必要があります。ディスプレイ・ネームは、お客様が developerWorks に投稿するコンテンツと一緒に表示されます。

ディスプレイ・ネームは、3文字から31文字の範囲で指定し、かつ developerWorks コミュニティーでユニークである必要があります。また、プライバシー上の理由でお客様の電子メール・アドレスは使用しないでください。

必須フィールドは(*)で示されます。

3文字から31文字の範囲で指定し

「送信する」をクリックすることにより、お客様は developerWorks のご使用条件に同意したことになります。 ご使用条件を読む

 


送信されたすべての情報は安全です。


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=60
Zone=Java technology
ArticleID=775779
ArticleTitle=言語設計者のノート: 新機能と、それに付随する新機能
publish-date=11252011