Kotlinの.isInitialized呼び出しになぜCallableReferenceの指定が必要なのか


Misocaの thara です。
この記事は Misoca+弥生+ALTOA Advent Calendar 2018 - Qiita 7日目の記事です。

最近は主にモバイルアプリ(Android版iOS版)のバックエンドを担当してますが、Java歴が長いのとSwift関連の本を書いたこともあってクライアントサイドのコードもちょこちょこ書いてます。

今回はKotlinで書かれたソースコードをレビューしていて疑問に思ったことを掘り下げて調べてみたので、そのことについて書きます。
(本文は丁寧語を考えるのが面倒なので、文語体風で書きます)


.isInitializedについての疑問

Kotlinにはlateinit という修飾子がある。 この修飾子は、非nullのプロパティや変数に対してコンストラクタ外で初期化することを許可する。 初期化する前にそのプロパティや変数にアクセスすると、UninitializedPropertyAccessException という例外がスローされる。 よって、lateinit がつけられたプロパティや変数に安全にアクセスするためには、.isInitialized が真であることを確認してからアクセスしなければならない。

初期化しない場合

これは以下のような例外が発生する。

Exception in thread "main" kotlin.UninitializedPropertyAccessException: lateinit property s has not been initialized
    at A.print(Simplest version.kt:8)

.isInitialized で初期化済みであることを確認

さて、上記のコードでは .isInitialized を呼び出す際に::というprefixをつけている。これは、Callable Referenceを省略したもので、たとえば lateinit がクラスのインスタンスフィールドにつけられていた場合にはthis::という記述になる。
これを s.isInitialized に変えるとコンパイルが通らない。String には isInitializedというプロパティがないからだ。

また、lateinit という修飾子がつけていなくても、当然のようにコンパイルが通らない。

では、この.isInitializedはどこからやってくるのだろうか?
なぜ::this::といった、Callable Referenceの指定が必要なのだろうか?

Kotlinの内部を追う

Android Studio上で .isInitialized の定義元に飛ぶと、kotlin/Lateinit.kt であった。

/**
 * Returns `true` if this lateinit property has been assigned a value, and `false` otherwise.
 *
 * Cannot be used in an inline function, to avoid binary compatibility issues.
 */
@SinceKotlin("1.2")
@InlineOnly
inline val @receiver:AccessibleLateinitPropertyLiteral KProperty0<*>.isInitialized: Boolean

どうやら通常のプロパティアクセスとは異なり、コンパイラからは特別なプロパティリテラルとして認識されるらしい。

lateinitが導入されたコミットログを参考にコードを追うと .isInitialized の実態は kotlin/LateinitIntrinsics.kt で自動生成されたもののようだ。
また、kotlin/LateinitIntrinsicApplicabilityChecker.kt では、コンパイル時の構文チェックを行っている。そこで以下のような記述を見つけた。

if (!referencedProperty.isLateInit) {
    context.trace.report(LATEINIT_INTRINSIC_CALL_ON_NON_LATEINIT.on(reportOn))
}

context.trace.report(LATEINIT_INTRINSIC_CALL_ON_NON_LATEINIT.on(reportOn)) は、おそらくコンパイルエラーのメッセージを指定しているのだろう。kotlin/DefaultErrorMessages.javaには、
この LATEINIT_INTRINSIC_CALL_ON_NON_LATEINIT という名前で、さきほどの lateinit がつけられていない変数にアクセスした際に表示されたエラーメッセージが指定されていた。

kotlin/LateinitIntrinsicApplicabilityChecker.kt をさらに読み進めると、以下の判定式を見つけられた。

} else if (!isBackingFieldAccessible(referencedProperty, context)) {
    context.trace.report(LATEINIT_INTRINSIC_CALL_ON_NON_ACCESSIBLE_PROPERTY.on(reportOn, referencedProperty))

isBackingFieldAccessible という名前から、backing fieldにアクセスできない場合は .isInitialized を使うことができないらしい。

Kotlinの通常のプロパティアクセスの構文ではgetterやsetterが呼び出されるが、特定のコンテキストではgetterやsetterを介さず直接フィールドにアクセスできる。
そのとき、そのフィールドをbacking fieldと呼ぶ。

backing fieldにアクセスできるのは、そのプロパティが宣言されたソースファイル内に限られる。 これは、そのソースファイル以外のクラスのlateinit が修飾されたプロパティや変数に対して、.isInitializedを呼ぶことができないことでもある。

これで、先程挙げた2つの疑問が解消した。

疑問の答え

まず、「.isInitializedはどこからやってくるのだろうか?」という疑問。

これは、lateinitを修飾したプロパティや変数は.isInitialized という特殊なプロパティリテラルを使えるようにコンパイラが特別扱いしていたのだった。具体的な実装はインライン化されており自動生成される。1

そして、次の「なぜ::this::といった、Callable Referenceの指定が必要なのだろうか?」という疑問。

これは.isInitializedを使用可能なのがbacking fieldに限られるからだ。lateinitをつけたプロパティのオブジェクトに対する通常のプロパティアクセス(getter/setterの呼び出し)と区別するためにCallable Referenceが構文上必要だった。

さて、ここで新たな疑問が起こる。
Kotlinでは、当然のようにCallable Referenceを指定して通常のプロパティアクセスも可能だ。fooというプロパティが存在すれば、this::foo と呼び出せる。
では、元々 lateinitをつけたプロパティの型に、.isInitializedというメソッドが定義されていた場合はどうなるのだろうか?

.isInitializedメソッドが存在するときの挙動

挙動を確認するために以下のコードを書いた。

これを実行すると、 Initialized が表示される。つまり、kotlin/Lateinit.kt で定義されている .isInitializedが実行される。

次は、以下のような変更を加えてみる。Callable Referenceの指定を取ったものだ。

これを実行すると、今度は Not initialized が表示される。これは、XクラスのisInitialized プロパティが呼び出されたことを表している。

これらのことから、 Callable Referenceを指定してisInitializedプロパティを使うとkotlin/Lateinit.ktのisInitializedが優先される ことがわかった。
これは2つ目の「なぜ::this::といった、Callable Referenceの指定が必要なのだろうか?」という疑問の答えを補完しているようにも思える。
Callable Referenceを指定するとisInitializedの解決にkotlin/Lateinit.kt.isInitializedが優先的に扱われるため、コンパイラから見て曖昧さが無くなるのではないだろうか。

まとめ

  • lateinitを修飾したプロパティや変数が使用できる.isInitialized はコンパイラから特別扱いされた特別なプロパティリテラルである
  • .isInitializedはbacking fieldに対してのみ使用可能であるため、Callable Referenceを用いる必要がある。
  • Callable Referenceの使用によりkotlin/Lateinit.kt.isInitializedを使用することをコンパイラに伝える。

感想

isInitialized を最初レビューで見かけたときには、奇妙な構文だと思った。
通常のプロパティアクセスのような構文を部分的に特殊扱いしている点が洗練されていない印象を持ったが、既存の構文への影響を考えると、実用的なKotlinらしい判断でもあると言えそうだ。

ちなみに、今回の記事を書くにあたってKotlinのGitHubリポジトリのコードを読んでいたのだが、途中でKotlin/KEEP: Kotlin Evolution and Enhancement Processなるリポジトリを見つけた。
このリポジトリはKotlinの言語仕様に対するproposalを管理しているのだが、今回のisInitializedの件もちゃんとproposalが出ていた。

KEEP/lateinit-property-isinitialized-intrinsic.md at master · Kotlin/KEEP · GitHub

これを最初に読んでおけば、Kotlin本体を見なくても疑問解決したなぁ・・・

自分が「洗練されていない印象を持った」という感想を持ったと先に書いたが、 現に

The solution is admittedly very ad-hoc.

と言及されていた。ですよねー。

まぁ、Kotlin本体のソースコードリーディング楽しかったし、なんかコントリビュートしたい気持ちになったので、良しとしよう。

Swiftもそうだが、最近の言語はKEEPのような言語仕様のproposalもGitHub上でオープンに議論される風潮にあり、良い傾向だと思う。


明日は めろたんさん が「なんかがんばります」だそうです。
これは期待できますよ・・・!


  1. MisocaでPRレビューしたときには、見当違いの指摘をしていた気がする。ごめんなさい…