2020/04/07

Java の final の仕様(1) - final 変数

Java の final の仕様について検索している方が多いように見受けられます。final キーワードは、変数、クラス、フィールド、メソッドの宣言に付加できますが、どういう意味になるかは簡単なようで意外にややこしいものです。Java 言語仕様に基づいてきっちり理解しておきましょう。
まず、final 変数について見ていきたいと思います。final クラス/メソッドについては次の記事をご覧ください。

final 変数とは?

final を付加して宣言された変数は、1回だけ代入ができる変数となります。final 変数への代入操作は、それまで確実に代入されていないのでない限り、コンパイルエラーとなります。逆に言うと、宣言時に初期化している方が多いかと思いますが、必ずしもそうする必要はありません。例えば以下のように、条件分岐して異なる値を代入することは可能です。

final String x;
if (...) {
  x = "Yes";
} else {
  x = "No";
}

final 宣言はプログラムを読む人に、この変数は変更されないよ、ということを伝えることになりますので、可読性が向上します。プログラムを書く側にとっても、うっかり意図せず値を上書きしてしまうことを防げるため、やはり有用なものです。積極的に使いましょう。

コンパイル時の代入済み判定

前述のように、final 変数への代入操作は、1回だけかつアクセス前に確実に行われていることがコンパイル時に検査されます。「確実に」と言うは易しですが、正確なところは少し複雑です。

例えば、次のコードを見てください。

public static void printYesNo(final boolean c) {
  final String x;
  if (c) {
    x = "Yes";
  }
  if (!c) {
    x = "No";
  }
  System.out.println(x);
}

最初の例と似ていますが、メソッドの引数 c の値に応じて、final 変数 x の値を "Yes" または "No" に初期化することを意図したコードです。else 節を使わず、別の if ステートメントを書いて、条件式を not で真偽反転しています。c の値はメソッドの途中で変化することはないので、4行目と7行目はどちらか一方しか実行されないことが我々人間には容易に分かります。
しかし、コンパイラはこれを識別しません。コンパイラはステートメントと式の構造からのみ判断します。4行目は if 節の中にあるので、実行される場合とされない場合がある、と判断されます。7行目の同様です。条件句が排他的であることは識別されません。c の値や条件式の値まではコンパイラは評価できないからです。ここでは c の取りうる値は2値しかないので、総当たりが簡単にできますが、一般の変数の値をコンパイル時に総当たりすることは現実的ではないため、コンパイラはそういう評価はしません。
結果として、コンパイラは2つのコンパイルエラーを指摘します。

  1. 7行目は、すでに代入済みの可能性のある final 変数 x に代入しようとしているのでエラー
  2. 9行目は、ローカル変数 x に一度も代入されていない可能性があるのでエラー

初期化していない final インスタンス/クラス変数への代入タイミング

final インスタンス/クラス変数は、代入が済んでいないといけないタイミングが決まっています。
初期化していない final クラス変数は、宣言しているクラスの static 初期化子で代入する必要があります。
初期化していない final インスタンス変数は、コンストラクタで代入する必要があります。全てのコンストラクタで必ず代入しなければならないことに注意してください。
これらのタイミングで代入が完了していない場合、コンパイルエラーとなります。

final 変数の値は変化しないが、中のオブジェクトの状態は変化し得る

当たり前ですが、final 変数は一度代入されたら、ずっと同じ値が入ったままになっています。オブジェクトへの参照を持つ final 変数なら、ずっと同じオブジェクトを参照し続けます。しかし、そのオブジェクトに対する操作によって、オブジェクトの状態は変化する可能性があります。配列もオブジェクトですから、final 付きの配列変数であっても、配列の中身は変化する可能性があります。担保されているのは、ずっと同じ配列オブジェクトが割り当てられたままであるということだけです。
ちょっと奇妙な例ですが、次のコードを見てください。

class AlphabetUtil {
 static final char[] ALPHABET = {'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J'};

 public static char getAlphabet(final int i) {
  return ALPHABET[i];
 }
}

public class Alphabet {
 static {
  AlphabetUtil.ALPHABET[0] = 'Z';
  AlphabetUtil.ALPHABET[1] = 'Z';
  AlphabetUtil.ALPHABET[2] = 'Z';
  AlphabetUtil.ALPHABET[3] = 'Z';
  AlphabetUtil.ALPHABET[4] = 'Z';
  AlphabetUtil.ALPHABET[5] = 'Z';
  AlphabetUtil.ALPHABET[6] = 'Z';
  AlphabetUtil.ALPHABET[7] = 'Z';
  AlphabetUtil.ALPHABET[8] = 'Z';
  AlphabetUtil.ALPHABET[9] = 'Z';
 };

 public static void main(final String[] args) {
  System.out.println(AlphabetUtil.getAlphabet(9));
  
 }
}

AlphabetUtil というクラスがあって、getAlphabet() をいうメソッドを持っているとします。0〜9 に対して 'A'〜'J' を返すという意図を持ったメソッドです。
AlphabetUtil は内部で ALPHABET という final 宣言された char の配列変数を持っていて、'A'〜'J' が入った長さ10の配列オブジェクトで初期化されています。final 変数なので、この配列オブジェクトがずっと保持されたままであることは担保されています。
getAlphabet() は、引数 i に指定された位置の char をこの ALPHABET から取得して返します。
この AlphabetUtil を利用するクラスとして Alphabet を作ったとします。main() メソッドで getAlphabet(9) を実行して、'J' を取得しようとしています。しかし、Alphabet の static 初期化ブロックが定義されていて、Alphabet クラスがロードされたときに、AlphabetUtil.ALPHABET の中身を全部 'Z' に書き換えています。
そのため、getAlphabet(9) を実行しても、結果は 'Z' となっています。
このように、変数を final にしても、中の配列/オブジェクトの状態の変更は妨げられません。

定数変数

プリミティブ型および String 型の final 変数が定数式で初期化されている場合、特に定数変数と呼びます。Java には定数式特有の仕様がいくつかあり、定数変数は定数式の構成要素になることができ、リテラルと同様に扱われます。逆に言うと、定数変数ではない変数が含まれる式は、定数式ではなくなるため、定数式しか記述できない箇所に記述できなくなります。
定数式は以下のような性質を持ちます。

  • switch ステートメント/switch 式 の case ラベルに使用できる
  • 数値型の変数を初期化する場合の狭い型へのキャストを省略できる
  • static フィールドの初期化に使用できる
  • 制御構文の条件式に使用された場合、コンパイル時に到達可能性検査が行われる
  • 数値型の三項演算子式の型の決定に影響する
String の定数変数の値は intern 化されます。

暗黙の final 変数

final と宣言しなくても、暗黙的に final 変数となる場合が3つあります。

  1. インターフェースのフィールド
  2. try-with-resources ステートメントのリソースとして宣言されたローカル変数
  3. multi-catch 節の例外パラメータ

uni-catch の場合は勝手に final になることはありません。ただし後述しますが、実質的 final 変数となることが見込まれます。

実質的 final 変数

ラムダ式や無名クラス内のメソッド定義では、外側のローカル変数にアクセスできます。そのローカル変数は final 変数でなければならないのですが、final 宣言されていない変数でも、実質的に final 変数とみなされ、使用できる場合があります。以下の3パターンがあります。

  1. 初期化されているローカル変数で、final 宣言されていない場合
    • 代入式の左辺に現れない
    • 前置/後置のインクリメント/デクリメント演算子のオペランドとして現れない
  2. 初期化されていないローカル変数で、final 宣言されていない場合
    • 代入式の左辺に現れるときは常に、確実に代入されていない状態
    • 前置/後置のインクリメント/デクリメント演算子のオペランドとして現れない
  3. メソッド、コンストラクタ、ラムダ式、catch のパラメータについても、初期化されているローカル変数に準じて扱われる (つまり1番の条件を満たせば実質的 final 変数となる)

実質的 final 変数が final 変数と同様に扱われるのは、ラムダ式/無名クラスにおける参照の場合だけで、定数変数の構成要素にはなりません。