雰囲気で書かないKotlin ~getter編~
概要
メガベンチャーの21新卒でAndroidエンジニアをしているほりすです。
Android/Kotlinを書き始めて2年ほど経つのですが、恥ずかしながらgetterの挙動でハマってしまったため、bytecodeをJavaにDecompileし、何が起きているかを調べてみました。
また、以前からLiveDataやStateFlow等で外部に公開する変数をgetterつきで書いている人もいれば、書かない人もいて疑問に思っていました。
具体的には以下のようなコードの違いです。
private val _liveData = MutableLiveData("initial value")
val liveData: LiveData<String> = _liveData
のようにgetterを書かない場合と
private val _liveData = MutableLiveData("initial value")
val liveData: LiveData<String> get() = _liveData
のように書く場合です。
実際にdecompileしてみて、どんな挙動になっているか確かめてみましょう。
結論
- 初期値としての代入は(当たり前だが)init時に計算され、アクセスされる度に同じインスタンスを返す
- getterを書くと、アクセスされる度に計算された値が返る
検証
getterを明示的に書かない場合
以下のコードはIntelliJ IDEAで書いています。
class GetterSample {
private var count = 0
private val result = "count: $count"
fun increment() {
count++
}
fun print() {
println(result)
}
}
単純に、incrementメソッドでメンバ変数 count
の数を増加でき、その結果を別の変数 result
にて整形して出力できるprintメソッドがあるだけです。
fun main() {
val sample = GetterSample()
sample.print()
sample.increment()
sample.print()
}
これを何もせず出力させたのと、incrementした後に出力した時の結果をみます。
called 0
called 0
こちらのコードでは当然ですが値は初期値のまま変わりません。
このGetterSample内はどのように展開されているか、Decompileされたコードをみてみましょう
public final class GetterSample {
private int count;
private final String result;
public final void increment() {
int var10001 = this.count++;
}
public final void print() {
String var1 = this.result;
boolean var2 = false;
System.out.println(var1);
}
public GetterSample() {
this.result = "count: " + this.count;
}
}
ここで大事なのは、コンストラクタでresultの値が計算されているということです。
初期値として値が計算され、それ以降変わることがない(val宣言のため)のです
getterを明示的に書く場合
class GetterSample {
private var count = 0
private val result
get() = "count: $count"
/** 略 */
}
main関数や、その他メソッドは同じなので省きます。
この場合の出力は、
count: 0
count: 1
となります。
ここで雰囲気で分かった気にならず、実際にDecompileされたコードを見てどういう挙動になっているかを確認してみましょう。
public final class GetterSample {
private int count;
private final String getResult() {
return "count: " + this.count;
}
/** 略 */
}
resultがフィールドではなく、getResultというメソッドに変わりました。
つまり、 アクセスされる度にgetResult内の処理を実行し、その結果を返す 挙動になります。
getterとsetter内でバッキングフィールドを使用する(おまけ)
特に変わった挙動はないですが、念のため
class GetterSample {
private var count = 0
private var result = "0"
get() = "counted: $field"
set(value) {
field += ", $value"
}
fun increment() {
count++
result = count.toString()
}
/** 略 */
}
変更点としては、getterとsetterを書き、バッキングフィールドを使用してみました。
Kotlinの場合は、フィールド(メモリに値を一時的に保存するためのもの)を持つことができないため、初期値を与えることでバッキングフィールドを内部的に自動で作成されます。
getterやsetter内では、無限参照でのStackOverFlowを避けるためにバッキングフィールドを使用します。
出力はこちらです。
counted: 0
counted: 0, 1
Decompile後のコードはこちら
public final class GetterSample {
private int count;
private String result = "0";
private final String getResult() {
return "counted: " + this.result;
}
private final void setResult(String value) {
String var10001 = this.result;
this.result = var10001 + ", " + value;
}
/** 略 */
}
フィールドが生成され、getterやsetterで書いた処理はそのフィールドにアクセスする形で実装されています。
なるほど。
結論
再掲です。
- 初期値としての代入は(当たり前だが)init時に計算され、アクセスされる度に同じインスタンスを返す
- getterを書くと、アクセスされる度に計算された値が返る
ということがわかりました。
ちなみに冒頭で言及したLiveDataの件は、 どちらも同じ です。
valで宣言された _liveData
は初期値としてMutableLiveDataを代入しており、
liveData
ではそれを返しているだけなので、getterを書こうとそのインスタンスは変わりません。
よくよく考えれば当たり前のことですが、bytecodeをJava言語にDecompileして調べてみることで、より深く知ることができました。
Javaからやっているのであればgetterやsetterは書く文化があるので容易に想像ができそうですが、自分のようにKotlin世代だとあまり意識しない点ではあったので、同じような境遇の方は参考になるかも知れません。
Discussion
細かいことですが、bytecodeじゃなくて、bytecodeがJavaへdecompileされたコードですね🙏
もゆるさん、コメントありがとうございます(先輩だ... 🙏)
少し勘違いしていたので、修正させていただきました!
こうしたい目的は、publicでは変更をされたくないからLiveDataのインターフェイスで公開したいだけだと思います。
つまり、、実際にliveDataをobserveするのではなく、中のvalueをobserveするので、同じインスタンスで問題ないと思いますが、どうでしょうか?
まさにその通りだと思います!補足ありがとうございます 🙏