Local Variable Type Inference Style Guidelines

原文はこちら。
The original article was written by Stuart W. Marks.
https://openjdk.org/projects/amber/guides/lvti-style-guide

Introduction

Java SE 10 では、ローカル変数に型推論が導入されました。

JEP 286: Local-Variable Type Inference
https://openjdk.org/jeps/286

以前は、すべてのローカル変数の宣言は、左辺に明示的な(manifest)型を必要としました。型推論では、初期化子を持つローカル変数の宣言では、明示的な型は予約型名varで置換できます。変数の型は、初期化子の型から推論されます。

この機能については、ある種の論争があります。この機能によって簡潔になることを歓迎する人もいれば、重要な型情報が奪われ、読みやすさが損なわれることを懸念する人もいます。そして、どちらの意見も正しいのです。冗長な情報を排除することでコードの可読性を高めることもできるし、有用な情報を排除することでコードの可読性を低下させる可能性もあります。また、この型推論が過度に使用され、その結果、より多くの悪いJavaコードが書かれることを懸念する人たちもいます。これも事実ですが、より多くの良い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のコード解析機能に大きく依存したくなります。型宣言は、いつでも変数を指して型を決定できるのだから、どこでもvarを使えばよさそうに思えますが、そうでない理由はなぜでしょうか。

理由は2つあります。1つ目は、コードはIDEの外で読まれることが多いということです。コードは、ドキュメント内のスニペット、インターネット上のリポジトリ、パッチファイルなど、IDEが利用できない多くの場所に現れます。コードが何をやっているかを理解するために、わざわざIDEにコードを取り込まなければならないのは逆効果です。

2 つ目の理由は、IDE 内でコードを読んでいるときでも、変数に関する詳細情報を IDE に問い合わせるために明示的な操作が必要になることが多いからです。例えば、varで宣言された変数の型を問い合わせるには、変数にポインタを合わせてポップアップを待たなければならないかもしれません。これは一瞬のことかもしれませんが、読書の流れを乱すことになります。

コードは自明であるべきです。ツールによる支援を必要とせず、そのまま理解できるものであるべきです。

P4. Explicit types are a tradeoff.

Javaではこれまで、ローカル変数の宣言には明示的に型を含める必要がありました。明示的な型は非常に便利ですが、あまり重要でない場合もあり、邪魔になるだけの場合もあります。明示的な型を要求することで、有用な情報を消してしまうような乱雑さが加わります。

明示的な型を省略することで、混乱を減らすことができますが、それはその省略が理解しやすさを損なわない場合に限ります。読み手に情報を伝える方法は、型だけではありません。他の手段としては、変数名やイニシャライザ式などがあります。これらのチャンネルの一つをミュートしてよいかどうかを判断する際には、利用可能なすべてのチャンネルを考慮する必要があります。

Guidelines

G1. Choose variable names that provide useful information.

これは一般的に良い習慣ですが、varを利用する場合にはより重要です。var宣言では、変数の意味と用途に関する情報は変数名を使って伝達できます。明示的な型をvarに置き換えることは、しばしば変数名の改良を伴うはずです。例えば以下の例を見てみましょう。

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

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

この例では、無意味な変数名は変数の型を連想させる名前に置き換えられ、var宣言で暗黙的に定義されるようになりました。

変数の型を名前に含めると、論理的な結論としてHungarian Notation(ハンガリアン記法)になります。

Hungarian notation
https://en.wikipedia.org/wiki/Hungarian_notation

明示的な型と同じように、これは役に立つこともあれば、混乱を招くだけのこともあります。この例では、custListという名前は、リストが返されることを意味します。しかし、これはあまり意味のない場合もあります。正確な型の代わりに、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.

ローカル変数のスコープを制限することは、一般的に良い習慣です。この習慣は Effective Java (3rd Edition), Item 57 で説明されています。varが使用されている場合は、特に強力に適用されます。

Effective Java (3rd Edition)
https://www.pearson.com/en-us/subject-catalog/p/Bloch-Effective-Java-3rd-Edition/P200000000138/9780134685991

次の例では、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 lines of code ...

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

ArrayListからHashSetに変更した場合の影響は、itemsがその宣言からかなり離れたところで使われるため、もはやすぐにわかりません。このバグは長い間生き残る可能性があります。

もしitemsList<String>として明示的に宣言されていた場合、イニシャライザを変更すると、Set<String>に型を変更する必要があります。このため、プログラマはこのような変更によって影響を受けるコードがないか、メソッドの残りの部分を検査するよう促されるかもしれません(そうでない場合もあります)。varを使用すると、このようなプロンプトがなくなり、このようなコードでバグが発生するリスクが高まる可能性があります。

これは、varを使用することに反対する議論のように思えるかもしれませんが、実際にはそうではありません。varを使用した最初の例は全く問題ありません。問題は、変数のスコープが大きい場合にのみ発生します。このような場合には、単にvarを避けるのではなく、ローカル変数のスコープを小さくするようにコードを変更し、その上で初めてvarで宣言するようにすれば良いのです。

G3. Consider var when the initializer provides sufficient information to the reader.

ローカル変数はコンストラクタで初期化されることが多く、コンストラクトされるクラスの名前は、左辺の明示的な型として繰り返されることがよくあります。型名が長い場合は、varを使用すると情報を失うことなく簡潔になります。

// ORIGINAL
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();

// GOOD
var outputStream = new ByteArrayOutputStream();

また、イニシャライザがコンストラクタではなく静的ファクトリーメソッドなどのメソッド呼び出しであり、その名前に十分な型情報が含まれている場合には、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.

文字列のコレクションを受け取り、最も頻繁に出現する文字列を見つけるコードを考えてみましょう。以下のような感じでしょうか。

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

このコードは正しいのですが、単一のストリームパイプラインのように見えるので、混乱する可能性があります。実際には、短いストリームと、最初のストリームの結果に対する2番目のストリームと、2番目のストリームのOptionalな結果のマッピングが続いています。以下のように、まずエントリーをmapにグループ化し、そのmap上でリダクションを行い、その結果から(もし存在すれば)キーを抽出するという、2個か3個の文にすれば、このコードは最も読みやすくなっていたでしょう。

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);

最初のスニペットのように、長いメソッド呼び出しのチェーンが1つだけというスタイルをお好みの方がいらっしゃると思います。しかし、場合によっては、長いメソッドチェーンを分割した方が良いこともあります。このような場合にvarを使用するのは有効な方法ですが、2番目のスニペットでは中間変数を完全に宣言しているため、味気ない代案になっています。他の多くの状況と同様に、varの正しい使い方には、何かを取り除き(明示的な型)、何かを戻す(より良い変数名、より良いコードの構造化)ことの両方が必要です。

G5. Don’t worry too much about “programming to the interface” with local variables.

Javaプログラミングでは、具象型のインスタンスを作成し、それをインターフェイス型の変数に代入するというイディオムが一般的です。これは、コードを実装ではなく、抽象化されたものに束縛することで、将来のコードのメンテナンスの際に柔軟性を保つためです。例えば以下のような例です。

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

しかし、var使用時は、インターフェースではなく具象型が推論されます。

// Inferred type of list is 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とダイアモンドはどちらも、すでに存在する情報から型情報を導き出せる場合に、明示的な型情報を省略することができる機能です。同じ宣言の中で両方を使うことはできるのでしょうか?

以下の例を考えてみましょう。

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

この場合、ダイアモンドとvarのどちらを使っても、型情報を失わずに書き換え可能です。

// OK: both declare variables of type PriorityQueue<Item>
PriorityQueue<Item> itemQueue = new PriorityQueue<>();
var itemQueue = new PriorityQueue<Item>();

varとダイアモンドの両方を利用すること自体に問題はありませんが、推論される型が変わります。

// DANGEROUS: infers as PriorityQueue<Object>
var itemQueue = new PriorityQueue<>();

ダイアモンドを使う推論の場合、対象となる型(通常は宣言の左辺)またはコンストラクタの引数の型が使用されます。どちらも存在しない場合、最も広い範囲で適用できる型(Objectであることが多い)にフォールバックします。これは通常、意図されたものではありません。

ジェネリックメソッドは型推論をうまく使っているので、プログラマが明示的な型引数を与えることはほとんどありません。ジェネリックメソッドの推論は、十分な型情報を提供する実際のメソッド引数がない場合、ターゲットの型に依存します。var宣言では、対象の型がないので、ダイアモンドの場合と同様の問題が発生する可能性があります。以下の例を考えてみましょう。

// DANGEROUS: infers as List<Object>
var list = List.of();

ダイアモンドとジェネリックメソッドでは、コンストラクタやメソッドへの実際の引数によって追加の型情報を提供することができるため、以下のように意図した型を推論できます。

// OK: itemQueue infers as PriorityQueue<String>
Comparator<String> comp = ... ;
var itemQueue = new PriorityQueue<>(comp);

// OK: infers as List<BigInteger>
var list = List.of(BigInteger.ZERO);

ダイアモンドやジェネリックメソッドでvarを使用する場合は、メソッドやコンストラクタの引数に十分な型情報を与えて、推論される型が意図したものと一致するようにする必要があります。そうでない場合は、同じ宣言でvarをダイアモンドやジェネリックメソッドとの併用は避けてください。

G7. Take care when using var with literals.

プリミティブリテラルは、var宣言のイニシャライザとして使用できます。一般に型名が短いので、このような場合にvarを使用してもあまり利点はないでしょう。しかし、変数名を揃えるためなどに、varが有効な場合もあります。

booleancharacterlongstringの各リテラルについては、問題はありません。これらのリテラルから推測される型は正確であるため、varの意味は曖昧ではありません。

// 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";

イニシャライザが数値、特に整数リテラルである場合は特に注意が必要です。左辺に明示的な型があると、数値は暗黙のうちにint以外の型に広げられたり狭められたりすることがあります。varを使っている場合、値はintと推論され、意図しない場合があります。

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

// DANGEROUS: all infer as 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に暗黙のうちに拡張されることがあります。double変数を3.0fのような明示的なfloatリテラルで初期化するのはやや難解ですが、double変数をfloatフィールドから初期化するようなケースもありえます。このような場合、varの扱いに注意が必要です。

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

// DANGEROUS: now infers as float
var temp = INITIAL;

この例は、ガイドラインG3に違反します。なぜなら、読み手が推論された型を確認するのに十分な情報がイニシャライザにないためです。

Examples

この節では、varを最大限に利用できる例をいくつか紹介します。

次のコードは、Mapから多くてもmax件の一致するエントリを削除します。メソッドの柔軟性を高めるため、ワイルドカードを使用した型境界が使用されており、結果としてかなり冗長になっています。残念ながら、これにはIteratorの型をネストしたワイルドカードにする必要があり、その宣言はより冗長になっています。この宣言が長すぎて、もはやforループのヘッダーが1行に収まらず、コードがさらに読みづらくなっています。

// 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を使い、ローカル変数に対する煩わしい型宣言を排除しています。このようなループでIteratorMap.Entryのローカル変数に明示的な型を持たせる必要性はほぼありません。varを使うことで、forループの制御も1行に収まるようになり、可読性がさらに向上しています。

// 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文を使って、ソケットから1行のテキストを読み取るコードを考えてみましょう。ネットワークおよび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は優れたコードを改善し、理解しやすさを損なうことなく、より短く、より明確なコードにできます。

コメントを残す

以下に詳細を記入するか、アイコンをクリックしてログインしてください。

WordPress.com ロゴ

WordPress.com アカウントを使ってコメントしています。 ログアウト /  変更 )

Twitter 画像

Twitter アカウントを使ってコメントしています。 ログアウト /  変更 )

Facebook の写真

Facebook アカウントを使ってコメントしています。 ログアウト /  変更 )

%s と連携中