kotlin小ネタ:initializer block や property initializer は可能な限り避ける
※あまり深く考えてないのでまだアイデアレベルのメモ。ちゃんと考えないと一般化はできないと思われる。
initializer block や property initializer を利用する状況というのは、インスタンスが生成された状態、つまり this が参照できる状態で val/var の初期化完了が保証されていない状況と言える。そのため、『val で宣言と同時に初期化してるから確実に値が入ってる』と思ったが実際は null でしたなんてことが起こり得る。
以下のコードを考えてみる。
※fullNameを取得する処理が非常に重く、毎回 firstName と lastName から作り出すのは要件的に許容できないと仮定する。
class Person(first: String, last: String) { val firstName: String = first // "John" val lastName: String = last // "Smith" val fullName: String = "$lastName, $firstName" // "Smith, John" } fun main(args: Array<String>) { with(Person("John", "Smith")) { println("firstName = [$firstName], lastName = [$lastName], fullName = [$fullName]") } } // 実行結果: firstName = [John], lastName = [Smith], fullName = [Smith, John]
このコードに対して、メンバをアルファベット順にするリファクタリングをしようとするとコンパイルエラーになる。
// メンバをアルファベット順にするリファクタリングをしようとしてコンパイルエラーになる例 class Person(first: String, last: String) { val firstName: String = first val fullName: String = "$lastName, $firstName" // <- Variable 'lastName' must be initialized val lastName: String = last }
これを見る限り val を用いれば非 null が保証されるように思える。
しかし、関数を経由するとそうはならず、バグを埋め込むことになる。
// メンバをアルファベット順にするリファクタリングをしたつもりがバグを埋め込んでしまった例 class Person(first: String, last: String) { val firstName: String = first // "John" val fullName: String = toFullName() // "null, John" ← null が入っている val lastName: String = last // "Smith" private fun toFullName() = "$lastName, $firstName" } fun main(args: Array<String>) { with(Person("John", "Smith")) { println("firstName = [$firstName], lastName = [$lastName], fullName = [$fullName]") } } // 実行結果: firstName = [John], lastName = [Smith], fullName = [null, John]
非 null の val を参照して null が返るのは、property initializer *1 のコードが走る時点でインスタンスの生成が完了しているため、つまり this が参照可能な状態のためと考えられる。
ということは、this を参照する関数(fun/get()/set())を呼び出すコードを書く場合は、関数が直接的及び間接的に参照するプロパティをすべて洗い出し、それらが初期化されていることを確認する必要があるということになる。それは、プロパティの初期化に関するコードを変更する際にも、そのプロパティを参照する全関数のコードを調査する必要があるということになる。
それならば、this が作られる前に処理を行ってしまえばいいのでは?
// 引数をすべて primary constructor で扱うことにより this を参照することが無い保証をした場合 class Person private constructor(val firstName: String, val lastName: String, val fullName: String) { constructor(firstName: String, lastName: String) : this( firstName, // "John" lastName, // "Smith" toFullName(firstName, lastName) // "Smith, John" ) companion object { private fun toFullName(firstName: String, lastName: String) = "$lastName, $firstName" } } fun main(args: Array<String>) { with(Person("John", "Smith")) { println("firstName = [$firstName], lastName = [$lastName], fullName = [$fullName]") } } // 実行結果: firstName = [John], lastName = [Smith], fullName = [Smith, John]
private primary constructor で対応することができた。
上記でバグ埋め込みの原因となった宣言順序変更と同様のリファクタリングを行ってみるとどうなるか。
// メンバの宣言順序をアルファベット順にリファクタリングする例 class Person private constructor(val firstName: String, val fullName: String, val lastName: String) { constructor(firstName: String, lastName: String) : this( firstName, // "John" toFullName(firstName, lastName),// "Smith, John" lastName // "Smith" ) companion object { private fun toFullName(firstName: String, lastName: String) = "$lastName, $firstName" } } fun main(args: Array<String>) { with(Person("John", "Smith")) { println("firstName = [$firstName], lastName = [$lastName], fullName = [$fullName]") } }
this を参照しない、つまり、受け取った値のみを利用しているため、初期化順序に影響されずに対応ができた。
実験のための実験として、primary constructor arguments の順序変更によりバグを埋め込む例が作れるか試してみる。
// 実験のための実験1 class Person private constructor(val firstName: StringBuilder, val lastName: StringBuilder, val fullName: StringBuilder) { constructor(firstName: StringBuilder, lastName: StringBuilder) : this( firstName, // "John" lastName.apply { setLength(0); append("William") }, // "William" toFullName(firstName, lastName) // "William, John" ) companion object { private fun toFullName(firstName: StringBuilder, lastName: StringBuilder) = StringBuilder("$lastName, $firstName") } } fun main(args: Array<String>) { with(Person(StringBuilder("John"), StringBuilder("Smith"))) { println("firstName = [$firstName], lastName = [$lastName], fullName = [$fullName]") } } // 実行結果: firstName = [John], lastName = [William], fullName = [William, John]
上記の例では、secondary constructor の lastName の評価の際に引数に副作用を加えている。その結果、"Smith" が "William" に書き換えられている。
primary constructor の引数の順序変更をIDEのリファクタリング機能で行ってみる。
// 実験のための実験2 class Person private constructor(val firstName: StringBuilder, val fullName: StringBuilder, val lastName: StringBuilder) { constructor(firstName: StringBuilder, lastName: StringBuilder) : this( firstName, // "John" toFullName(firstName, lastName), // "Smith, John" lastName.apply { setLength(0); append("William") } // "William" ) companion object { private fun toFullName(firstName: StringBuilder, lastName: StringBuilder) = StringBuilder("$lastName, $firstName") } } fun main(args: Array<String>) { with(Person(StringBuilder("John"), StringBuilder("Smith"))) { println("firstName = [$firstName], lastName = [$lastName], fullName = [$fullName]") } } // 実行結果: firstName = [John], lastName = [William], fullName = [Smith, John]
IDEのリファクタリング機能を使っただけなのに結果が変わってしまった。(fullName が "William John" ではなく "Smith John" になっている)
そうなるように書いたコードなので当然ですがw
ここで重要なポイントは、以下のようなものになると思う。
- どんなコードでも実験のための実験2で書かれたような無茶なことはできる。
- しかしそのようなコードが製品のコードとして実際に書かれる可能性は著しく低い。 *2
primary constructor 方式は this を参照するコードを書こうとしてもコンパイルエラーになるという点がステキかも。
// 保守時に『無用なバケツリレーをメンバ参照に改善しよう』と改善するつもりでコンパイルエラーを起こす例 class Person(val firstName: String, val fullName: String, val lastName: String) { constructor(firstName: String, lastName: String) : this( firstName, // Cannot access 'toFullName' before superclass constructor has been called と怒られる toFullName(), lastName ) private fun toFullName() = "$lastName, $firstName" }
では、primary constructor 引数だけで対応できるかというと、当然 this を必要とするケースでは対応できないし、get()/set() を伴う場合など言語仕様的に対応できないケースも多々ある。
以下、転落人生の例:
class Person(val firstName: String, val fullName: String, val lastName: String, address: String) { constructor(firstName: String, lastName: String, address: String) : this( firstName, toFullName(firstName, lastName), lastName, address ) var address: String by Delegates.observable(address) { _, old, new -> println("address: [$old] -> [$new]") } companion object { private fun toFullName(firstName: String, lastName: String) = "$lastName, $firstName" } // 実行結果 // firstName = Brandon, lastName = Walsh, fullName = Walsh, Brandon, address = Beverly Hills, 90210 // address: [Beverly Hills, 90210] -> [No fixed abode] // firstName = Brandon, lastName = Walsh, fullName = Walsh, Brandon, address = No fixed abode } fun main(args: Array<String>) { with(Person("Brandon", "Walsh", "Beverly Hills, 90210")) { println("firstName = $firstName, lastName = $lastName, fullName = $fullName, address = $address") address = "No fixed abode" println("firstName = $firstName, lastName = $lastName, fullName = $fullName, address = $address") } }
primary constructor の引数の評価が始まる前(や処理中)に処理を入れ要とする場合、強引に入れ込むなら以下のような方法がある。
class Something private constructor(val firstName: String, val lastName: String, val fullName: String) { constructor(firstName: String, lastName: String):this(doSomething().run { firstName }, lastName, "$lastName, $firstName") companion object { fun doSomething() = println("doSomething()") } // 実行結果: doSomething() } fun main(args: Array<String>) { Something("John", "Smith") }
でも保守性悪そう(´・ω・`)
primary constructor と secondary constructor の signature が同じ場合は使えないし、、、。
そういう場合は設計から考え直すが吉ですかね。
次は、primary constructor arguments を作成するための一時変数が欲しい場合を考えてみる。
なんかの計算をしてその値を格納するクラス。処理が重いのでキャッシュが必須とする。
class Something(val a: Int, val b: Int, val c: Int) { // 計算処理がめちゃ重いのでキャッシュせざるを得ない val d = a + b // 計算処理がめちゃ重いのでキャッシュせざるを得ない val e = d + c } fun main(args: Array<String>) { Something(3, 5, 7).apply { println("a = $a, b = $b, c = $c, d = $d, e = $e") } } // 実行結果: a = 3, b = 5, c = 7, d = 8, e = 15
全て primary constructor arguments として入れ込もうとすると以下のようになる。
class Something private constructor(val a: Int, val b: Int, val c: Int, val d: Int, val e: Int) { // a + b の計算を 2 回やっているのがもったいない constructor(a: Int, b: Int, c: Int) : this(a, b, c, a + b, a + b + c) }
上記の場合、a + b の計算を 2 回やっているのがもったいない
計算を重複して行わないよう、ThreadLocal 上に値を入れ込んでみる。
// ThreadLocal を利用して a + b の計算結果を格納する例 class Something private constructor(val a: Int, val b: Int, val c: Int, val d: Int, val e: Int) { constructor(a: Int, b: Int, c: Int) : this(a, b, c, calcD(a, b), calcE(c)) companion object { private val tmp = ThreadLocal<Int>() private fun calcD(a: Int, b: Int) = (a + b).apply { tmp.set(this) } private fun calcE(c: Int) = tmp.get() + c } }
上記の例は正しく動作するが、順序変更のリファクタリングをすることによりバグを埋め込むリスクが生まれる。
// change signature のリファクタリングをしてバグを入れ込む例 class Something private constructor(val a: Int, val b: Int, val c: Int, val e: Int, val d: Int) { constructor(a: Int, b: Int, c: Int) : this(a, b, c, calcE(c), calcD(a, b)) companion object { private val tmp = ThreadLocal<Int>() private fun calcD(a: Int, b: Int) = (a + b).apply { tmp.set(this) } private fun calcE(c: Int) = tmp.get() + c // <- ここでヌルポが出る } }
順序を変更しても問題ないバージョン。
class Something private constructor(val a: Int, val b: Int, val c: Int, val d: Int, val e: Int) { constructor(a: Int, b: Int, c: Int) : this(a, b, c, d(a, b, c), e(a, b, c)) object args { private var d: ThreadLocal<Int>? = null private var e: ThreadLocal<Int>? = null private fun init(a: Int, b: Int, c: Int) { d = ThreadLocal<Int>().apply { set(a + b) } e = ThreadLocal<Int>().apply { set(d!!.get() + c) } } fun d(a: Int, b: Int, c: Int): Int { if (d == null) init(a, b, c) return d!!.get() } fun e(a: Int, b: Int, c: Int): Int { if (e == null) init(a, b, c) return e!!.get() } } }
引数を毎回全部渡さなきゃならんのと、コード量が多いのが面倒ですね。
実際に有効なケースがどの程度あるのかは未知数ですね。