kagamihogeの日記

kagamihogeの日記です。

JEP 286: Local-Variable Type Inferenceをテキトーに訳した

http://openjdk.java.net/jeps/286 をテキトーに訳した。英語とJavaの勉強には丁度良かろうと読んでみたけど、型推論の知識あんま無いんでかなり苦戦した。なので、誤訳してる可能性がかなりあると思います。

JEP 286: Local-Variable Type Inference

Author   Brian Goetz
Owner   Dan Smith
Created 2016/03/08 15:37
Updated 2016/03/09 20:18
Type    Feature
Status  Candidate
Component   specification?/?language
Scope   SE
Discussion  platform dash jep dash discuss at openjdk dot java dot net
Effort  M
Duration    S
Priority    3
Reviewed by Alex Buckley, Mark Reinhold
Endorsed by Mark Reinhold
Issue   8151454

Summary

ローカル変数宣言初期化子の型推論(type inference)を拡張するためにJava言語を改良します。

Goals

我々はJavaコード記述時に関する儀式的な箇所を削減することで開発者向けの使い勝手向上を図っています。静的な型安全性というJavaの約束事は維持しつつ、多くの場合に不要なローカル変数型の明示的な宣言(manifest declaration of local variable types)を削除することで実現します。この機能は例えば以下のような宣言になります。

var list = new ArrayList<String>();  // ArrayList<String>と推論
var stream = list.stream();          // Stream<String>と推論

この方法は、ローカル変数の初期化子・拡張forループのインデックス・従来型forループのローカル宣言のみに制限されます。なお、メソッド仮引数・コンストラクタ仮引数・メソッド戻り値型・フィールド・catch仮引数・その他の変数宣言では利用出来ません。

Success Criteria

適切な型を推論する本機能により、実際のコードベースのローカル変数宣言の相当量が変換可能なことを定量的に示したいと考えています。

定性的には、我々はローカル変数型には制限を設けたいと考えており、これらの制限を設ける動機は、一般的なユーザに利用しやすくするためです。(これは当然ながら一般的に達成困難です。すべてのローカル変数に妥当な型を推論することは出来ないというだけでなく、ある種のユーザは、制約解決(constraint solving)のアルゴリズムというよりは型推論を読心術(mind reading)の一種だと夢想しています。その場合には何の説明もしないほうが実用的かもしれません。)我々はある方法で線引きを設けようとしています。この方法では、ある特定の構成要素(particular construct)が境界線を越えるかどうかを明確に出来ます。また、この方法では、言語の任意の制限というよりは、コンパイラ診断によりユーザのコードの複雑さと効果的に結び付けることが可能です。

Motivation

開発者はおおむねJavaが要求するボイラープレートの度合いに不満を持っています。ローカルでの明示的な型宣言はおおむね不要と解釈可能であるか、何の役割なのか明確で適切な変数名によっても同様に不要と解釈可能です。

また、すべての変数に明示的な型を付ける必要性は、開発者に無意識のうちに過度な複雑な式をかかせる方向へと向かわせます。儀式的な宣言シンタックスが少ない状態では、複雑なチェーンあるいはネストした式をシンプルな式へと分解するための障害は少なくなります。

おおむねすべてのその他の一般的な静的型付け"中括弧"("curly-brace")言語では、JVMベースのものもそうでないものも、既にローカル変数型推論をサポートしています。C++(auto), C#(var), Scala(var/val), Go(declaration with :=). Javaはおそらくローカル変数型推論を採用していない唯一のメジャーな静的型付け言語であり、この点については、議論の余地はありません。

型推論の範囲はJava SE 8で広範囲に広がりました。これには、ラムダ式の推論と、ネストおよびチェーンされたジェネリックメソッド呼び出しのために拡張された推論を含みます。Java SE 8の変更によりメソッドチェーンを行うAPIの設計が容易となり、そうしたAPI(Streamsなど)はごく一般的なものとなり、開発者は中間型の推論については既に慣れ親しんだものとなっています。例えば以下のチェーンなどです。

int maxWeight = blocks.stream()
                      .filter(b -> b.getColor() == BLUE)
                      .mapToInt(Block::getWeight)
                      .max();

誰も中間型Stream<Block>IntStreamについて思い悩まない(し気付かない)し、ラムダ式bの型も同様で、これらの型はソースコード上には明示的に現れません。

ローカル変数型推論は柔軟な構造を持つAPIと同様の効果をもたらします*1。ローカル変数の用途の大半は本質的にチェーンであり、推論によるメリットを多くのケースで受けられます。

var path = Path.of(fileName);
var fileStream = new FileInputStream(path);
var bytes = Files.readAllBytes(fileStream);

Description

初期化子有りのローカル変数宣言・拡張forループのインデックス・従来型forループのインデックス変数宣言では、予約型名varが明示的な型の代わりに指定可能となります。

var list = new ArrayList<String>(); // ArrayList<String> と推論
var stream = list.stream();         // Stream<String> と推論

推論される型は初期化子の型に基づきます。初期化子が無い場合、初期化子はnullリテラルとなり、初期化子の型が適切なdenotable type(intersection typeとcapture typesを含む)に正規化可能なものが無いか、初期化子がターゲット型を要求するpoly expressionの場合(ラムダ式・メソッド参照(method ref)・暗黙的な配列初期化子(implicit array initializer))、その変数宣言はエラーになります。

final varのシノニムとしてvalないしletの追加も考えています。(大抵の場合、varで宣言するローカル変数は実質的にfinalです。)

var識別子はキーワードに入れるのではなく、予約型名(reserved type name)となります。つまり、変数やパッケージ名としてvarを使うコードは影響を受けません。クラスやインタフェース名としてvarを使うコードは影響を受けます(とはいえそうした名前は命名規則に違反していますが)。

初期化子の無いローカル変数の除外により"action at a distance"*2推論エラーを排除します。これにより一般的なプログラムのごく一部は型推論が出来ないことになります。

非denotableな型を持つ右辺式を除外することで機能はシンプルになりリスク軽減になります。しかし、すべての非denotable型を除外することは厳格すぎると思われます。実際のコードベースを分析すると、capture types(とそれより低い頻度で無名クラス型)がそれなりの頻度で現れます。無名クラス型は容易にdenotable型に正規化されます。例えば。

var runnable = new Runnable() { ... }

たとえ推論がより具体的な型(sharper type)であるFoo$23を生成したとしても、runnableの型はRunnableに正規化します。

同様に、capture types Foo<CAP>では、ワイルドカードFoo<?>に正規化が可能です。これらのテクニックにより推論が失敗するケースを劇的に減らせます。

JDKソースコードでプロトタイプを実行したところ、

  • 83.5% ソースコード内に存在する実型に推論された。
  • 4% 別のdenotable typeに推論された。(大抵はより具体的な型(sharper type))
  • 0% 推論した型が非denotableなために失敗した。
  • 8.5% 初期化子が無いために失敗した。
  • 3% 初期化子がnullなために失敗した。
  • .5% ターゲット型が要求されるために失敗した。

初期化子が無いものやnullで失敗したケースを除くと、ローカル変数の99%が推論可能であり、95%がソースコード内に存在する実型に推論されました。

(全ローカル変数の77%の)実質的にはfinalなローカル変数では、

  • 86% ソースコード内に存在する実型に推論された。
  • 4.5% 別のdenotable typeに推論された。(大抵はより具体的な型(sharper type))
  • 0% 推論した型が非denotableなために失敗した。
  • 8% 初期化子が無いために失敗した。
  • .5% 初期化子がnullなために失敗した。
  • .5% ターゲット型が要求されるために失敗した。

Alternatives

ローカル変数型に明示的な宣言が必要なままにすることも可能です。

代入の左辺においてダイアモンドをサポートすることも可能です。これはvarが扱うケースのサブセットとなります。

これまでに述べた設計ではスコープ・シンタックス・非denotable typeについての決定事項を含んでいます。挙げられた別案についてもこのドキュメント内で述べます。

Scope Choices

本機能について詳細な調査をしていたかもしれない別案がいくつか存在します。その一つとして、実質的にはfinalなローカル変数(val only)に機能を制限する、というものです。しかし、我々はこの案は採用しませんでした。理由としては、

  • 初期化子を持つローカル変数の大半(JDKおよび広範な資料の75%以上)はすでに実質的にはイミュータブルで、本機能が提供するミュータブルを避ける微調整("nudge")を制限することになります。
  • ラムダ・内部クラスによるCapturabilityは実質的にはfinalなローカル変数を既に提供しています。
  • In a code block with (say) 7 effectively final locals and 2 mutable ones, the types required for the mutable ones would be visually jarring, undermining much of the benefit of the feature.

我々は空のfinal("blank" finals)(つまり初期化子を必要とせず、代わりにdefinite assignment analysisに依存)のローカル変数を含めるように本機能を拡張することも検討しました。我々は"初期化子有りの変数"という制限に決定しました。その理由は、機能の簡潔さを維持しつつ候補となる変数の大半をカバーし、また、"action at a distance"エラーを減らすためです。

また、型推論時に初期化子だけでなくすべての代入式を考慮することも検討しました。本機能が利用可能なローカル変数の割合を引き上げられる一方で、"action at a distance"エラーのリスクも向上します。

Syntax Choices

シンタックスについて多様な意見が出るのはごく自然なことです。その多様さには大きく分けて二種類あり、使用するキーワード(var, autoなど)と、イミュータブルなローカル変数(val, let)形式には新規で別途のものを設けるかどうか、です。我々は以下のシンタックスに関する意見を議論しました。

  • var x = expr only (like C#)
  • var, plus val for immutable locals (like Scala, Kotlin)
  • var, pluslet``` for immutable locals (like Swift)
  • auto x = expr (like C++)
  • const x = expr (already a reserved word)
  • final x = expr (already a reserved word)
  • let x = expr
  • def x = expr (like Groovy)
  • x := expr (like Go)

イミュータブルなローカル変数(val, let)用に別の形式を持つかどうかはトレードオフがあり、設計意図を捕らえるために余計な労力が追加されます*3。我々は既にラムダとinner class captureにおいて実質的にはイミュータブルに関する分析を結果を得ており、ローカル変数の大半は既に実質的にはイミュータブルです。ある種の人々はvarvalの類似性を好み、コードを読む際には相違点は気にならないとする一方、そうでない人は似ているからこそ気が散る、と言います。また、ある種の人々はvarletが全く異なるのを好む一方、そうでない人はその違いに気が散らされる、と言います。(新しい形式をサポートするとしたら、その形式は(valletの双方で)構文上の重みは同等(equal syntactic weight)にすべきでしょう。怠惰であることにより、ユーザがイミュータブル宣言を省略する可能性を減らせます。)

autoは実現可能な選択肢ですが、JavaデベロッパC++よりかはJavascript, C#, Scalaの経験を持っていることが多いため、C++のエミュレートにはあまり価値がありません。

constfinalの使用は、新規キーワードの導入ではないため、当初は魅力的に映りました。しかし、このやり方ではミュータブルなローカル変数での推論は実行出来なくなります。defも同様な欠点があります。

goのシンタックス(代入演算子に別の種類を設ける)はあまりJavaっぽくないです。

Non-Denotable Types

nondenotable types(null型、無名クラス型、capture types, intersection types)に関する処理方法はいくつかの選択肢があります。エラーにする(明示的な型を要求)、推論型として許容する、denotable typesへの"変換"("detune")を試行する、が挙げられます。

エラー派の意見には以下などがあります。

  • リスクの縮小。captureとintersectionsなどのweird typesには仕様とコンパイラの両面で周知の落とし穴が多数あります。変数がこれらの型を持つのを許容すると、広く使用されるようになる可能性があり、そうすると落とし穴が顕在化してユーザを困惑させることになります。(我々はこれらの浄化に取り組んではいますが、この作業は時間が必要です)
  • 発現性温存(Expressibility-preserving). non-denotable typesをエラーにすることで、varを持つプログラムはvar無しのプログラムへと単純に変換することになります。

許容派の意見には以下などがあります。

  • チェーン呼び出しではdenotable typeの推論を既に行っているので、so it is not like our programs are free of these types anyway, or that the compiler need not deal with them.
  • capture type不要と考える場合、以下のケース(var x = m(), m()Foo<?>を返す)でcapture typeが使われるため、これをエラーにするとユーザは不満を抱く可能性がある。 これまでに述べたように我々はエラー派のアプローチを取っています。しかし、capture variablesを含むケースではこの制限にユーザが当惑することも認識しています。例えば、以下の推論です。
var c = Class.forName("com.foo.Bar")

この式の型は"明らかに"Class<?>ですが、推論はcapture type Class<CAP>を生成します。よって、我々はワイルドカードに変換可能なcapture variablesでは"uncapture"を取ることに決めました(this strategy has applications elsewhere as well)。capture typesは結果を汚染しかねない場合が数多くあるため、このテクニックは有効です。

また、我々は無名クラス型をそのスーパータイプに正規化します。intersectionないしunion typesは正規化しません。最後に、我々が推論不可能としている残りは初期化子がnullの場合です。

Risks and Assumptions

リスク:Javaには既に右辺(ラムダ式ジェネリックなメソッド型引数、ダイアモンド)での型推論(significant type inference)*4が存在するため、こうした式の左辺でvar/valを使用すると失敗する可能性があり、エラーメッセージから原因を読み取るのが難しくなる可能性があります。

左辺が推論される場合にはエラーメッセージを単純化することで軽減を図っています。

Main.java:81: error: cannot infer type for local
variable x
        var x;
            ^
  (cannot use 'val' on variable without initializer)

Main.java:82: error: cannot infer type for local
variable f
        var f = () -> { };
            ^
  (lambda expression needs an explicit target-type) 

Main.java:83: error: cannot infer type for local
variable g
        var g = null;
            ^
  (variable initializer is 'null')

Main.java:84: error: cannot infer type for local
variable c
        var c = l();
            ^
  (inferred type is non denotable)

Main.java:195: error: cannot infer type for local variable m
        var m = this::l;
            ^
  (method reference needs an explicit target-type)

Main.java:199: error: cannot infer type for local variable k
        var k = { 1 , 2 };
            ^
  (array initializer needs an explicit target-type)

リスク:non-denotable typesの推論は仕様およびコンパイラで以前から微妙だった箇所を顕在化させる可能性があります*5

軽減策としては一般的なnon-denotableについては正規化し、残りはエラーにします。

リスク:ソースの非互換性(型の名前として"var"を使っているユーザがいるかもしれない)

予約型名により軽減する。"var"と"val"などの名前は型の命名規則には準じていないため、型として使われている可能性は低いです。識別子としては"var"と"val"という名前はありふれています。こちらは特に問題ありません。

リスク:可読性の低下、リファクタリング時の困惑。

他言語の機能で見られるように、ローカル変数型推論を使うことでコードは明瞭にも不明瞭にもなりえます。最終的には、明瞭なコードを書く責任はユーザにあります。

*1:原文は Local variable type inference allows a similar effect in less tightly structured APIs; less tightly structured APIはつまりStreams APIみたいにメソッドチェーンで柔軟な記述が可能な設計のAPIを指してると思われる。直訳すれば『あまり密には構造化されていないAPI』だが、ここでは『柔軟な構造を持つAPI』とちょっと意訳した。

*2:何かを宣言したコードからかなり離れたコードでその宣言が何かをやらかすこと。グローバル変数の悪影響を論じる時とかに使われる単語っぽい。

*3:a tradeoff of additional ceremony for additional capture of design intent.が原文。上手く訳せなかったが、たぶん、新しい構文を増やすほど理解するのが大変になる、って感じだと思う(たぶん)

*4:この場合のsignificantは何と訳すのが良いかニュアンスが分からんので訳文には含めず

*5:原文はmight press on already-fragile pathsで、直訳だと『壊れやすい箇所にプレッシャーをかける』くらいの感じだが、要するに前からヤバい箇所が新しいブツにより浮き彫りになるかも、という意味だと思うんで、こういう訳にした