言語設計者のノート: 何よりもまず害をなさないこと

新しい言語機能で実現される不適切なコードは適切なコードよりも影響が大きい場合があります

提案されている言語機能のなかには、問題を探すためのソリューションでしかないものもありますが、ほとんどの言語機能は、既存の機能ではプログラマーは望んでいるほどの容易さ、明確さ、簡潔さ、そして安全性を持って表現することができないという現実の状況に端を発して提案されています。「この機能のおかげで、作成できるようにしたいと望んでいたコードを作成できるようになる」という状況を念頭に置くことは素晴らしいことですが、言語設計者はその言語機能によって可能になる不適切なコードについても考慮した上で、その言語機能を評価する必要があります。

Brian Goetz, Java Language Architect, Oracle

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



2011年 8月 26日

この連載について

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

ゼロから言語を設計する場合には、言語の機能を 1 つのグループとして評価する機会があり、それらの言語機能が互いに相乗的な作用をするように、あるいは互いに効果を打ち消すようなことがないように、調整が行われます。また、言語機能の選択を通じて、どのプログラミング・イディオムやメンタル・モデルを推奨するかを選択する機会もあります。しかし既存の言語に対して新しい言語機能を考える場合には、ゼロから設計する場合に比べ、選択肢は多くありません。多くの場合、新しい機能に対応して他の機能を調整することは (少なくとも容易には) できず、またその言語の体系の中には既に特定のプログラミング・イディオムが含まれています。こうした場合、せいぜい可能なことは、そうした制約の中で設計することです。

提案されている機能のなかには、漠然とした発想から生まれるものもありますが、ほとんどの機能は具体的な状況に端を発して提案されます。それらの機能は多くの場合、特定のイディオムを表現するためのコードとして言語に現在含まれているコードがスマートさに欠けていたり、冗長であったり、脆弱であったりすることへの不満から生まれており、「・・・のように書けさえすれば便利なのに」といった思いが背景にあります。しかし、「この洗練されたコードを実現する」ことと、「優れた機能」との間には、大きな隔たりがあります。ある言語機能が望ましいものであるためには、その言語機能のおかげで、以前は不可能だった何らかの「適切な」新しいプログラム表現が可能になるようでなければならないことは明らかです。しかし新しい言語機能により、何らかの新しい「不適切な」プログラム表現もできるようになる可能性があります。また、たとえ新しい機能によって新しい「不適切な」プログラム表現が可能になることはないとしても、その新機能により、既存の言語の不変要素、ユーザーによる想定、パフォーマンス・モデルの特性などに悪影響を与えるかもしれません。既存の言語を進化させるためには、表現力が向上するという利点と、安全性や機能同士の相互作用が低下する、あるいはユーザーが混乱するといった悪影響とのトレードオフが必要になります。

単純な例: オブジェクトを判断基準に switch 文の分岐を行う

Java SE 7 で導入された言語機能の 1 つは、switch 文の判断基準の対象としてプリミティブ型や列挙型と同様に文字列型の変数を使えるようになったことです。switch 文の適用対象を文字列型のみならず、他の型にまで拡張することは、長年にわたり機能強化要求のテーマとして繰り返し取り上げられてきました (例えば RFE 5012262 では、equals() メソッドを使って比較を行うことで、文字列だけでなく任意のオブジェクトを switch 文の判断基準の対象とするように要求しています (「参考文献」を参照))。同じく頻繁に要求される内容として、定数ではない式も switch 文の case ラベルに使えるようにしたい、という要求もあります。

一見、switch 文はネストされた if ... else 文に対する構文糖にすぎないように思えます。実際、開発者は、switch 文とネストされた if ... else 文のうち、どちらがコード内での見栄えが良いかを大方の基準として、いずれかを選ぶことが頻繁にあります。しかし、switch 文とネストされた if ... else 文は同じではありません。switch 文にはパフォーマンスと安全性の両方の理由から、switch 文に特有の制約があります。その制約とは、case ラベルは定数値でなければならず、switch のオペランドは定数のように振る舞う型に制限される、というものです。ラベルを定数に制限することで、分岐計算は O(n) ではなく O(1) の演算になります。ネストされた if ... else 文で else ブロックに到達するためには、すべての比較を実行する必要があります。というのも、if ... else 文の動作は順次実行することを要求するからです。case ラベルを定数に似た値 (プリミティブ、列挙、ストリング) に制限することで、比較演算で副次作用を伴うことは決してありません (そのため、case ラベルを制限しなかった場合には不可能な、ある種の最適化を行うことができます)。一方、任意のオブジェクトを case ラベルに使用できるようにすると、equals() メソッドを呼び出すことによって予想外の副次作用を伴う可能性があります。

ゼロから言語を設計しているとしたら、プログラマーにとっての利便性の方が予測可能性よりも重要かどうかを決定し、またそれに従って switch 文の動作 (および制約事項) を定義する上で、より自由度があります。しかし Java 言語の場合には、既に言語の設計は終わっています。定数に似た値以外も使用できるように switch 文を拡張すると、Java 開発者達が長年築き挙げてきたパフォーマンス・モデルが崩れてしまいます。そのため、任意のオブジェクトを switch 文の判断基準の対象に使用できるように表現力を強化しても、それを上回るほどのメリットはありません。String クラスは不変であり、明確に規定されて制御されているため、switch 文に含めることは現実的でしたが、そこでとどまることが賢明だったのです。


より議論を呼びそうな例

Java SE 8 の新しい言語機能として最も重要なものはラムダ式、つまりクロージャーです。クロージャーは関数リテラルです。つまり値として扱うこと、そして後から呼び出すことが可能な遅延計算を実現する式です。そして、クロージャーではレキシカル・スコープを利用しているため、クロージャー内でのシンボルの意味は、クロージャー外でのそのシンボルの意味と同じになるはずです (ただし、クロージャー内で宣言されたローカル変数はクロージャー外のレキシカル・スコープからは見えません)。Java SE 1.1 以来、Java 言語にはクロージャーの真似ごとができる内部クラスがありましたが、内部クラスが持つ制約や厄介な構文のために、コードをデータとして扱えるメカニズムによって実現される抽象化機能を本格的に活用した API は開発されませんでした。

言語にクロージャーが用意されていれば、計算のごく一部をクライアントが提供できるようにすることで、より協調的であるがゆえにより複雑な計算を API で表現できるようになります。コレクション API は、限られた形でこうした振る舞いをサポートしています (例えば Collections.sort()Comparator を渡す、など)。ただしソートのような比較的重い処理しかサポートしていません。もっと単純な処理、例えば「サイズが 10 よりも大きな要素のリストを作成する」といった処理の場合には、この処理を手動で展開するようにクライアントに強制しています。以下はその一例です。

Collection<Element> bigOnes = new ArrayList<Element>();
for (Element e : collection)
    if (e.size() > 10)
        bigOnes.add(e);

このコードはかなり簡潔で読みやすくなっていますが、コレクション API があまり役に立たたず、基本的に連続的に実行する羽目になっています (これは、コレクションの要素を繰り返し処理する唯一の手段である for ループが連続的であるためです)。この、コレクションの中から必要な要素のサブセットを抽出するという処理は、一般的な処理です。すべての (連続的または並列的な) 制御ロジックをライブラリー・ルーチンの中に入れることができ、対象とする要素の述部のみでパラメーター化できれば便利です。そうすればコードは以下のようになります。

Collection<Element> bigOnes
    = collection.filter(#{ Element e -> e.size() > 10 });

これを内部クラスによって行うこともできますが、内部クラスは使いにくく、内部クラスを使わない方がよいと思えることもありました。コレクション・フレームワークが開発された当時、内部クラスはありましたが、内部クラスの構文にはオーバーヘッドがあるため、内部クラスを積極的に使用するコレクション API を作成することは、あまり望ましくありませんでした。(ここに示したラムダ式の構文とコレクション API の改善は暫定的なものであり、Java SE 8 によってどのようなコードを作成できるかを示すためのものにすぎません。)

上記の例のラムダ式は特に適切な振る舞いをするラムダ式であり、そのレキシカル・スコープから値を取り込みません。しかし、既にスコープ内にある他の値との相対的な関係を使って計算を表現できると有益なことはよくあります。例えば以下のメソッドでローカル変数 n を取り込むような場合です。

public static<T> Collection<Element> biggerThan(Collection<Element> coll, int n) {
    return coll.filter(#{ Element e -> e.size() > n });
}

内部クラスとラムダ式には、それらのレキシカル・スコープからは final と宣言されたローカル変数しか参照することができないという制約があります。Java SE 8 のラムダ式では、実質的な final 変数 (final と宣言されていないものの、最初に割り当てられてから変更されていない変数) も取り込めるようにすることで、この制約が少しだけ緩くなっています。(あるインスタンスのコンテキストで内部クラスの式が使われる場合、内部クラスは可変のインスタンス・フィールドを参照することができますが、それと同じではありません。適切な考え方としては、内部クラスを包含するクラスのフィールド x を内部クラスの中で参照する場合、その参照は実際には Outer.this.x の短縮形 (Outer.this は暗黙的に final と宣言されたローカル変数) であるということです。) この制約を設けたのにはいくつかの理由がありますが、その主な理由は、取り込むローカル変数を final と宣言されたフィールドのみに制限することで、その参照をクロージャーがコピーできるようにするためでした。それによって、ローカル変数の存続期間はそのローカル変数を宣言したブロックの存続期間と同じであるという動作が維持されるようになります。

レキシカル・コンテキストの不変状態を取り込むことができないという制約がプログラマーにとって非常に苛立たしいことは間違いありません。ようやく Java 言語にクロージャーが導入されたにもかかわらず、クロージャーのこうした側面は焦点がずれているように思え、おそらく苛立たしさは倍加されるはずです。

可変のローカル変数を取り込みたい場合の標準的なコード・サンプルは、リスト 1 のようになるはずです。

リスト 1. 可変のローカル変数をクロージャーによって取り込む (この方法は Java SE 8 では認められません)
int sum = 0;
collection.forEach(#{ Element e -> sum += e.size() });
System.out.printf("The sum is %d%n", sum);

確かにこのコードは、可変のローカル変数を取り込みたい場合には、妥当なコードのように思え、さらには明白なコードであるようにさえ思えます。またクロージャーをサポートする他の一部の言語では、このコードは確かに一般的なイディオムです。ではなぜ、Java ではこのコードが認められないのでしょう。

第 1 に、最初はそう見えませんが、このコードによってローカル変数の動作が大きく変わります。ローカル変数は、そのローカル変数が宣言されているブロックの存続期間を超えて存続することはありません。しかしラムダ式は値として扱われるため、変数の中に保存することができ、取り込まれた変数を宣言したブロックがスコープ外になった後も、そのラムダ式を実行することができます。もし可変ローカル変数を取り込むことができるならば、そのプラットフォームはローカル変数の存続期間を延長し、そのローカル変数を取り込む任意のラムダ式の動的な存続期間とローカル変数の存続期間とを同じにする必要があります。それはプログラマーがローカル変数に関して想定している動作と大きく異なることになり、「この変数は、存続期間が長くて奇妙な新しいローカル変数の 1 つである」という特別な宣言がない場合には、なおさら意外な動作になってしまいます。

この問題は、関数をコレクションのさまざまな要素に並行して適用できるようにするために、他のスレッドから forEach() メソッドがラムダ式を呼び出そうとする可能性があることを考えると、さらに悪化します。その場合には、複数のスレッドが同時にローカル変数 sum を更新しようとする可能性があるため、sum にデータ競合を生じさせることになってしまうことでしょう。「ローカル変数のデータ競合状態」は新たな種類の危険です。現在、ローカル変数にアクセスする場合には、データ競合状態は決して発生しないと想定されているからです。リスト 1 のコードをスレッドセーフにするための単純な方法はないため、このイディオムは問題の発生を待っているようなものです。

賢明なのは、この時点でローカル変数をラムダ式に取り込むのをあきらめることです。並行 Java プログラムでデータの競合を避けようとすること自体、既に私達の想定をはるかに超えるほど困難です。ローカル変数はデータの競合と無縁であるという事実は、その危険を回避するための 1 つの安心材料でした。ローカル変数にアクセスできるのは 1 つのスレッドのみだからです。しかし可変のローカル変数をラムダ式が取り込めるようにすると、ローカル変数は (そう見えませんが) ローカルではなくフィールドのように振る舞うことになり、データの競合という危険にさらされることになります。2011年に言語を進化させた結果、並行処理や並列処理が従来以上に危険なものになる、というのは馬鹿げています。

このイディオムを救うために、例えば、取り込むことが可能な可変ローカル変数に対する修飾子を定義する (そして通常のローカル変数と明確に区別する) 方法が考えられます。その修飾子により、それらの変数を取り込むラムダ式の動作を制限し、その変数が定義されるスレッドとレキシカル・スコープのみでラムダ式が有効になるようにする方法です。そうした機能には長所と短所があります。つまり言語は複雑になりますが、特定のプログラミング・イディオム (そして本質的に逐次処理という、時代遅れの手段) を有効なまま維持することができます。

より適切なソリューション

現在の段階では、このイディオムをサポートするために言語を複雑にするという手段は推奨できません。なぜなら、もっと適切な方法で同じ結果が得られるからです。このイディオムは、Map 処理と Reduce 処理または Fold 処理を組み合わせ、一連の値に集計演算子 (summax) をペアで適用する方法の一例です。こうした Reduce 処理は、集計演算子のおかげで、容易に並列化することができます。以下のように、コレクションに対して mapReduce() メソッドを直接適用することができます。

int sum = collection.mapReduce(0, #{ Element e -> e.size() },
                               #{ int left, int right -> left + right });

この場合、最初のラムダ式はマッパーです (各要素をその要素のサイズにマッピングします)。2 番目のラムダ式はリデューサーであり、2 つのサイズを引数にとり、その 2 つを加算します。このコードで計算される結果はリスト 1 の例と同じですが、このコードの方が並列化しやすい点が異なります。(ただし、何もせずに並列化されるわけではなく、ライブラリーによって並列化の処理を行う必要があります。しかしこのイディオムをこのように表現することで、少なくともライブラリーで並列に処理を実行することができます。) map 処理と reduce 処理は並列化しやすいだけではなく、map 処理と reduce 処理を組み合わせ、より効率的な 1 つの並列パスにすることができます。(しかもこれらをすべて、クライアント・コードに可変状態をまったく発生させずに行うことができます。)

実際には、マッパーの size() メソッドに対するメソッド参照と、整数の和を計算するための定義済みのリデューサーを使用することで、おそらくこれをもっと簡潔に表現できるはずです。以下はその一例です。

int sum = collection.mapReduce(0, #Element.size, Reducers.INT_SUM);

このようにして計算を規定する考え方に慣れると、このコードを問題文のように読めるようになります。つまりこのコードでは、コレクションの各要素に size() メソッドを適用した結果に対し、整数の和の計算を適用します。

争ってはなりません

ほとんどの開発者は、可変のローカル変数を取り込む上での制約に対する「回避策」があることに、すぐに気付くはずです。つまり要素が 1 つの配列への final 参照でローカル変数を置き換えればよいのです (リスト 2)。

リスト 2. 要素が 1 つの配列への final 参照によってコンパイラーをだます方法 (こんなことをしてはなりません)
int[] sumH = new int[1];
collection.forEach(#{ Element e -> sumH[0] += e.size() });
System.out.printf("The sum is %d%n", sumH[0]);

このコードはコンパイラーをとおります。そのため、「システムをだました」という、ちょっとした満足が得られるかもしれませんが、このコードにより、データの競合状態が発生する可能性が再び生じます。このコードは良いアイデアではないため、このコードの誘惑に耐える必要があります。テーブル・ソーのブレード・ガードを外した場合と同様、事故のリスクが高まります。しかしテーブル・ソーの場合とは異なり、切断される指はおそらく皆さん自身の指ではなく、誰か他の人の指です。このコードよりも安全な (そしておそらく高速な) map-reduce というイディオムがあることを考えると、たとえ「この場合は」安全と思える場合であれ、こうした危険なコードを作成して言い訳することは許されません。


まとめ

新しい言語機能を見る場合、その機能によって実現される適切なコードのみを見てしまいがちです。何かを行う上では、常により適切な方法を注意して探す必要がありますが、新しい言語機能により、非常に不適切なことも発生してしまう可能性があります。不適切な言語機能を導入してしまうリスクは非常に高いため、言語設計者は良い効果が悪い影響を上回るかどうかの費用対効果を分析する際、保守的でなければなりません。明確にわからない場合には、Primum non nocere (何よりもまず害をなさないこと) という格言に従う必要があります。

参考文献

学ぶために

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

  • Open beta program for IBM SDK for Java 7 をご利用ください。このプログラムにより、最新のベータ版 IBM SDK for Java 7 に対し、ライセンス許可を受けてアクセスすることができます。

議論するために

コメント

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=753221
ArticleTitle=言語設計者のノート: 何よりもまず害をなさないこと
publish-date=08262011