kagamihogeの日記

kagamihogeの日記です。

Local Variable Type Inference: Style Guidelinesを読んだ

javavarをどう使うかについて何か良い資料無いかな、とぐぐってたら本家にあったので読んで訳した。とはいえ、ごく当たり前のことで、まとめれば、極めて狭いスコープかつ変数名は可読性を損なわないように、プラスでジェネリクスとかダイヤモンド演算子とかリテラルとかの注意事項が少々、といったところか。

特記事項としてはIDE以外でも読めるように、のくだり。これは現在でも起こる問題で、たとえば、ぐぐって出てきたjavaのサンプルコードでクラス名が欠落しているが、正にそのクラス名が知りたい、といったケース。varはそれに拍車をかける可能性が高いので、その場合は注意しないといけない。


https://openjdk.java.net/projects/amber/LVTIstyle.html

Local Variable Type Inference: Style Guidelines

Stuart W. Marks 2018-03-22

Introduction

Java SE 10でローカル変数型推論(type inference for local variables)を導入しました。すべてのローカル変数宣言は明示的な(マニフェスト)型が左辺に必要でした。型推論により、初期化子を持つローカル変数宣言で明示的な型を予約された型名varで置き換えられます。変数の型は初期化子の型から推論します。

本機能については様々な議論が存在するのは確かです。簡潔さを歓迎する人もいれば、重要な型情報が無くなり可読性を損なう事を懸念する人もいます。両者の主張は共に正しいです。冗長さを削除して可読性を上げられるし、有益な情報を消して可読性を下げることも出来ます。また、使い過ぎの懸念もあり、その場合bad Javaになります。それも真実ですが、good Javaとなる可能性もあります。どんな機能にも言えますが、何らかの判断基準が必要です。使うべきかそうでないか、の絶対的なルールはありません。

ローカル型宣言はそれ単独では存在しえず、前後のコードがvarに影響を与えます。本ドキュメントの目的はそうした前後のコードがvarに与える影響とトレードオフについて解説し、varの効果的な使用に関するガイドラインを示します。

Principles

P1. Reading code is more important than writing code.(書くよりも読めるコードが重要)

コードは書くよりも読む方が非常に多いです。また、書く時には頭の中にコンテキスト全体があるのが通常ですが、その後、読む段になると、頭の中でコンテキスト切り替えが必要になります、場合によってはとても急いで。ある言語機能を使うかどうか、どのように使うかどうかは、プログラムを将来的に読む人に対する影響で決めるべきです。書く人ではありません。短いプログラムは長いものよりも好ましいですが、過度に短くするとプログラムを読むのに必要な情報も無くしてしまいます。ここの本質的な問題は、理解しやすさを最大化するプログラムの最適サイズは何処にあるか、という点です。

なお、あるプログラムを書くのに必要なタイピング量についてはここでは扱いません。簡潔さは書き手には利点かもしませんが、それは本来の目的ではありません。本来の目的とは、作成されたプログラムの理解しやすさを上げることです。

P2. Code should be clear from local reasoning.(コードがローカルで明確)

読むときにvarを見たら、その変数の使われ方も見て、何をしているかがすぐに理解出来るのが良いです。理想的には、ある一部のコードのコンテキストのみの知識で理解出来ることが望ましいです。もし、varを理解するのに読み手にコードの複数個所を探させるよう強いるのであれば、varは不適切です。これは、プログラム自体に問題がある可能性が高いです。

P3. Code readability shouldn't depend on IDEs.(IDEに依存しない可読性)

コードの読み書きは基本的にはIDEで行うため、IDEのコード分析機能に依存したくなります。型宣言においては、変数の型を見るのは簡単なのに、varの使用箇所を限定する理由とは何でしょうか?

2つ理由があります。1つ目はIDEで無い場所で読むケースです。コードはIDEの機能が使えない色々な場所、ドキュメント・webブラウザ等でリポジトリを参照・パッチファイル、に現れます。そのコードを理解したいためだけにDEへインポートするのは非効率です。

2つ目は、IDEでコードを読む場合、変数の詳細情報を調べるのにIDEに対して明示的な操作が必要となるためです。たとえば、varの変数宣言の型を調べるため、変数にポインターを合わせてポップアップが出るのをしばらく待つ、などです。確かにこれは一瞬ですが、読む流れを断ち切りがちです。

コードは自己明示的(self-revealing)であるべきです。ツールのアシスト無く、そのコードだけで理解可能であるべきです。

P4. Explicit types are a tradeoff.(明示的な型のトレードオフ)

Javaは歴史的に明示的な型を持つローカル変数宣言を必須としてきました。明示的な型は極めて有用ですが、場合によっては大して重要では無く、邪魔な場合すらあります。必須の明示的な型が有用な情報をノイズの中に埋もれさせます。

明示的な型の除去によりノイズを減らせますが、その除去が理解しやすさを損なわない場合のみ許容できます。読み手が情報を得る方法は型以外にもあります。変数名と初期化式など。それらの方法の一つを無くしても良いかどうかは、すべての方法を考慮して決定します。

Guidelines

G1. Choose variable names that provide useful information.(意味を伝える変数名にする)

これは基本的なgood practiceですが、varにおいては更に重要です。var宣言では、その意味と変数の使用法に関する情報は変数名で伝えます。varでの明示的な型の省略には変数名の改善も必要です。

// ORIGINAL
List<Customer> x = dbconn.executeQuery(query);

// GOOD
var custList = dbconn.executeQuery(query);

上記の例では、無意味な変数名がその型を強く想起させる名前に変更され、この名前がvar宣言を暗黙的に示しています。

名前に変数の型を埋め込むことは、論理的な帰結として、ハンガリアン記法になります。明示的な型同様、これは場合によっては有用ですが、ノイズにもなります。上記例ではcustListListを暗黙的に意味していますが、これは重要な情報ではありません。実際の型ではなく、役割や特徴を表現する変数名にするのがベターです。この例の場合はcustomersなど。

// ORIGINAL
try (Stream<Customer> result = dbconn.executeQuery(query)) {
    return result.map(...)
                 .filter(...)
                 .findAny();
}

// GOOD
try (var customers = dbconn.executeQuery(query)) {
    return customers.map(...)
                    .filter(...)
                    .findAny();
}

G2. Minimize the scope of local variables.(ローカル変数のスコープはなるべく小さく)

基本的にローカル変数のスコープを制限するのはgood practiceです。このプラクティスはEffective Java (3rd edition), item 57にあります。varを使う場合は更に制限を加えます。

以下のコード例の場合、addメソッドで最後の要素として追加するのは明らかです。なのでその要素が最後に処理されるだろう、と読めます。

var items = new ArrayList<Item>(...);
items.add(MUST_BE_PROCESSED_LAST);
for (var item : items) ...

いま、重複要素を削除するため、このコードをArrayListからHashSetに修正した、とします。

var items = new HashSet<Item>(...);
items.add(MUST_BE_PROCESSED_LAST);
for (var item : items) ...

setは定義済み順序を持たないので、このコードにはバグが出来ました。とはいえ、itemsの宣言と使用箇所とが近くなので、バグは即座に治せると思われます。

いま、上記コード部分が長いメソッドの一部にあり、同様にitemsのスコープも長い、と想定します。

var items = new HashSet<Item>(...);

// ... 100行くらい ...

items.add(MUST_BE_PROCESSED_LAST);
for (var item : items) ...

ArrayListからHashSetへの変更の影響は、itemsの宣言と使用箇所とが遠く離れたため、すぐには分からなくなりました。このバグが長期間潜伏し続ける可能性があります。

itemsを明示的にList<String>と宣言する場合、初期化子の変更で型もSet<String>に変更します。プログラマに対してこうした変更が他のコードに与える影響を調べるよう知らせます(調べないかもしれないが)。varにはそれが無いので、こうしたバグが埋め込まれるリスクがあります。

これはvarに対する議論に見えますが、実際には違います。最初の例は確かにそうです。問題は変数のスコープが長過ぎるときにだけ発生します。こうしたケースではvarを回避するのではなく、まずローカル変数のスコープを減らすようコードを変更し、それからvarにします。

G3. Consider var when the initializer provides sufficient information to the reader.(初期化子から意味を読み取れるのならvar)

ローカル変数は基本的にはコンストラクタで初期化します。コンストラクタのクラス名と左辺の明示的な型とで繰り返しになります。型の名前が長い場合、varは情報を損なうことなく簡潔に書けます。

// ORIGINAL
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();

// GOOD
var outputStream = new ByteArrayOutputStream();

初期化子が、staticなファクトリメソッドなど、コンストラクタではなくメソッド呼び出しの場合もvarは妥当です。なおそのメソッド名が十分な型情報を持つ場合に限ります。

// ORIGINAL
BufferedReader reader = Files.newBufferedReader(...);
List<String> stringList = List.of("a", "b", "c");

// GOOD
var reader = Files.newBufferedReader(...);
var stringList = List.of("a", "b", "c");

上記の場合、メソッド名が暗に特定の型を返すことを十分示しており、実際に変数の型推論でその型が使われます。

G4. Use var to break up chained or nested expressions with local variables.(メソッドチェーンやネスト式をvarの中間変数で分割)

いま、文字列のコレクションがあり一番多く出現する文字列を探す、というコードを考えます。これは以下のようになります。

return strings.stream()
              .collect(groupingBy(s -> s, counting()))
              .entrySet()
              .stream()
              .max(Map.Entry.comparingByValue())
              .map(Map.Entry::getKey);

このコードは正しいですが、単一のstreamにまとめているので、ちょっと分かりにくいです。1つ目のstreamの結果に対して2つ目のstreamがあり、2つ目のstreamの結果をOptionalにします。こうしたコードを読みやすくする方法は2,3のステートメントにすることです。最初にグループをmapし、これをreduceし、結果(がもし有れば)のキーを取得します。

Map<String, Long> freqMap = strings.stream()
                                   .collect(groupingBy(s -> s, counting()));
Optional<Map.Entry<String, Long>> maxEntryOpt = freqMap.entrySet()
                                                       .stream()
                                                       .max(Map.Entry.comparingByValue());
return maxEntryOpt.map(Map.Entry::getKey);

しかし、中間変数の型を書くのは面倒なのでこれを避けたがるプログラマは多いです。varにより、中間変数の型を明示的に宣言するコストを負うことなく自然なコード表現が出来ます。

var freqMap = strings.stream()
                     .collect(groupingBy(s -> s, counting()));
var maxEntryOpt = freqMap.entrySet()
                         .stream()
                         .max(Map.Entry.comparingByValue());
return maxEntryOpt.map(Map.Entry::getKey);

最初のコード例である単一メソッドチェーンの方を好む人もいるでしょう。しかし、あるケースでは長いメソッドチェーンは分割するのがベターです。こうしたケースでのvarは1つの選択肢ですが、2つ目のコード例のように中間変数の完全な型を書くのは好ましくないです。他のシチュエーションでも同じですが、varの正しい使用法とは、明示的な型を無くし、ベターな変数名・ベターなコード構造に組みなおすこと、となります*1

G5. Don't worry too much about "programming to the interface" with local variables.(インタフェースに対するプログラミングはローカル変数では重要ではない)

Javaプログラミングのイディオムに、実装型のインスタンスを生成するがインタフェース型の変数に代入する、があります。実装型ではなくその抽象型にコードをバインドすることで、コードの将来的なメンテナンス時の柔軟性を確保します。

// ORIGINAL
List<String> list = new ArrayList<>();

しかしvarの場合、インタフェースではなく実装型が推論されます。

// listはArrayList<String>に型推論される。
var list = new ArrayList<String>();

ここで再度繰り返し述べますがvarはローカル変数でだけ使用出来ます。フィールドの型・メソッド引数の型・戻り値型の推論はできません。インタフェースに対するプログラミング("programming to the interface")の原則はそうしたコンテキストでは今もなお重要です。

変数を伴うコードは実装に対する依存関係を形成する点が真の問題です。変数の初期化子が将来変更されたとすると、推論型は変更され、エラーやその変数を使用する後続コードでバグが起きる可能性があります。

もし、ガイドラインG2で推奨するように、ローカル変数のスコープが狭ければ、後続コードに影響を及ぼし得る実装のリスクは限定的です。ある変数が数行程度のコードでだけ使うものであれば、こうした問題の回避やリスク軽減は容易です。

ある特定ケース、ArrayListListに無いメソッド、ensureCapacitytrimToSizeを持ちます。これらメソッドはリストの中身には影響を与えないので、こうしたメソッド呼び出しはプログラムの正しさには影響を与えません。これにより、推論型がインタフェースではなく実装型である影響を更に軽減します。

G6. Take care when using var with diamond or generic methods.(ダイヤモンドやジェネリックメソッドとのvarは要注意)

varとダイヤモンド("diamond")は共に明示的な型がどこかで利用可能であれば削除可能というものです。では同時にこれらは使用出来るでしょうか。

次を考えます。

PriorityQueue<Item> itemQueue = new PriorityQueue<Item>();

型情報を失うことなく、ダイヤモンドかvarのどちらかで書き直せます。

// OK: どちらもPriorityQueue<Item>の型宣言
PriorityQueue<Item> itemQueue = new PriorityQueue<>();
var itemQueue = new PriorityQueue<Item>();

varとダイヤモンドを両方使えますが、推論型が変わります。

// DANGEROUS: PriorityQueue<Object>に推論される
var itemQueue = new PriorityQueue<>();

この推論においては、ダイヤモンドはターゲット型(基本的には宣言の左辺)かコンストラクタ引数の型のどちらかを使います。どちらも無い場合、フォールバックして広範に適用な型である、Objectになります。これは通常は意図した動作ではありません。

ジェネリックメソッドは型推論を使用するためプログラマは明示的な型引数の指定はほぼしません。有効な型情報を持つ実メソッド引数が無い場合、ジェネリックメソッドの推論はターゲット型に依存します。var宣言では、ターゲット型が無いため、ダイヤモンドでは同様な問題が発生します。

// DANGEROUS: List<Object>と推論
var list = List.of();

ダイヤモンドとジェネリックメソッドでは、推論させたい型を、コンストラクタやメソッドへの実引数で型情報を指定できます。

// OK: PriorityQueue<String>に推論
Comparator<String> comp = ... ;
var itemQueue = new PriorityQueue<>(comp);

// OK: List<BigInteger>に推論
var list = List.of(BigInteger.ZERO);

ダイヤモンドやジェネリックメソッドでvarを使いたい場合、意図通りの型が推論されるように、メソッドやコンストラクタ引数で必要な型情報を渡してください。もしくは、同一の宣言でダイヤモンドやジェネリックメソッドとvarを使うのは避けて下さい。

G7. Take care when using var with literals.(リテラルとのvarに要注意)

var宣言の初期化子にプリミティブリテラルを使用可能です。こうしたケースでのvarは、型名が短くなる程度で、そこまでの利点はありません。ただ、変数名の位置を揃えるなど、varは場合によって有用です。

boolean, character, long, stringリテラルには特に問題はありません。リテラルからの型推論は正確でありその意味も明快です。

// ORIGINAL
boolean ready = true;
char ch = '\ufffd';
long sum = 0L;
String label = "wombat";

// GOOD
var ready = true;
var ch    = '\ufffd';
var sum   = 0L;
var label = "wombat";

とりわけ注意が必要なのは初期化子が数値、特にintegerリテラル、の場合です。左辺に明示的な型があると、数値は暗黙的にint以外の広いまたは狭い型になります。varでは、数値はintに推論され、これは場合によっては意図通りではありません。

// ORIGINAL
byte flags = 0;
short mask = 0x7fff;
long base = 17;

// DANGEROUS: すべてintに推論
var flags = 0;
var mask = 0x7fff;
var base = 17;

浮動小数リテラルは明確です。

// ORIGINAL
float f = 1.0f;
double d = 2.0;

// GOOD
var f = 1.0f;
var d = 2.0;

floatリテラルは暗黙的にdoubleに出来ます。 3.0fなどと明示的にfloatリテラルdoubleを初期化するのはちょっと不格好(somewhat obtuse)ですが、floatフィールドでdoubleを初期化する場合にこれが起こります。

// ORIGINAL
static final float INITIAL = 3.0f;
...
double temp = INITIAL;

// DANGEROUS: floatに推論
var temp = INITIAL;

(この例はガイドラインG3に違反しています。読み手が推論型を確認できるよう、初期化子に十分な情報がありません)

Examples

このセクションではvarを有効に使えるサンプルをいくつか示します。

以下のコードはMapから最大maxマッチするエントリを削除します。メソッドに柔軟性を持たせるためにワイルドカード型を用いていますが、結果としてかなり冗長な記述になっています。よろしくないことに、iteratorの型はネストしたワイルドカードで、その宣言はかなり冗長です。この宣言はfor-loopのヘッダーとしては長過ぎて単一行には収めにくく、したがって読みにくいです。

// ORIGINAL
void removeMatches(Map<? extends String, ? extends Number> map, int max) {
    for (Iterator<? extends Map.Entry<? extends String, ? extends Number>> iterator =
             map.entrySet().iterator(); iterator.hasNext();) {
        Map.Entry<? extends String, ? extends Number> entry = iterator.next();
        if (max > 0 && matches(entry)) {
            iterator.remove();
            max--;
        }
    }
}

ここでvarによりローカル変数でノイズとなっている型宣言を減らせます。Iteratorとループ内のMap.Entryの明示的な型は不要になります。これによりfor-loopの制御を単一行に収められ、可読性を上げられます。

// GOOD
void removeMatches(Map<? extends String, ? extends Number> map, int max) {
    for (var iterator = map.entrySet().iterator(); iterator.hasNext();) {
        var entry = iterator.next();
        if (max > 0 && matches(entry)) {
            iterator.remove();
            max--;
        }
    }
}

次にtry-with-resourcesでソケットからテキスト行を読み込む例を考えます。ネットワーキングI/O APIはオブジェクトラッピングのイディオムを使います。中間オブジェクトをリソース変数として宣言するので、後続ラッパーのオープン中にエラーが発生したとしても、適切にクローズします。このための従来コードは変数宣言の左・右辺でクラス名を繰り返す必要があり、やや煩雑です。

// ORIGINAL
try (InputStream is = socket.getInputStream();
     InputStreamReader isr = new InputStreamReader(is, charsetName);
     BufferedReader buf = new BufferedReader(isr)) {
    return buf.readLine();
}

varによりかなりのノイズを減らせます。

// GOOD
try (var inputStream = socket.getInputStream();
     var reader = new InputStreamReader(inputStream, charsetName);
     var bufReader = new BufferedReader(reader)) {
    return bufReader.readLine();
}

Conclusion

var宣言でノイズを減らしてコードを改善し、最も重要な情報を際立たせられます。しかし、varの乱用はかえって逆効果です。適切に使用することで、varはgood codeとなり、理解しやすさを損なうことなく短く簡潔なコードとなります。

References

*1:the correct use of var might involve both taking something out (explicit types) and adding something back (better variable names, better structuring of code.)が原文。something out/backがちゃんと訳せてるか自信無い