はじめに
Kotlinではadd
やremove
などの変更ができるMutableList
と、変更ができないList
が分かれています。なぜこのように分ける必要があるのでしょうか?
このようなタイトルですが、本記事はMutableList
とList
の話に限定されません。
Kotlin(などの参照型中心の言語)にはMutableXXX
: ミュータブルクラスとXXX
: イミュータブルクラスの二つのクラス(インターフェース)に分かれて設計されているケースが多くあります。なぜこれらを分ける必要があるのでしょうか?
valとvar
「var
は変数だから変更ができて、val
は定数だから変更できない」のように簡潔に説明されることが多いかなと思います。
var a = 0
a += 1
val b = 0
// b += 1 コンパイラエラー
この話には続きがあります。
valは「参照」を定数にするだけ
valが定数(変更不可)にするのは
クラスの「参照」だけであって、「実態」=クラスの中身は変更できます。
class Counter {
private var count = 0
fun getCount() = count
fun increment() {
count += 1
}
}
fun main() {
val counter = Counter()
println(counter.getCount()) // 0
counter.increment()
println(counter.getCount()) // 1
}
できないのは「参照」への再代入であって、「実態」の変更ではありません。
fun main() {
val counter = Counter()
// counter = Counter() これができないだけ
}
なぜなら、class
が参照型だからです。
val counter = Counter()
と書いた時、コンピュータ上のメモリでは以下のようになっています。
counter変数に0x1234...
という「実態」の場所を示す「参照≒メモリアドレス」が保持されており、アドレス0x1234...
にCounter
クラスの実態が存在します。
(単なる変数ならIntはJavaのプリミティブ型(値型)のint
になるので、0x1234...
に直接counter: 0
の値が存在する)
この時、val
によって定数になるのはcounter
が持つ0x1234...
の値それ自体だけなので、アドレス0x1234...
に存在するcount
は変更できるわけです。
一方で、counter
が持つアドレス0x1234...
を別のアドレスに変えることはできない(再代入はできない)というわけです。
実態も定数にしたい
ここで、「実態も変更不可にしたい」という需要が生まれます。
例えば、他の関数やクラスに読み取り専用で渡したり公開する場合です。
変更可能なインスタンスの参照を闇雲に公開すると、意図せず変更が伝播してバグの温床になるからです。
fun main() {
val counter = Counter()
counter.increment()
show(counter)
}
fun show(counter: Counter) {
// ここでは読み取るだけにしたい
println(counter.getCount())
counter.increment() // これを禁止したい
}
class HogeViewModel {
val counter = Counter()
}
class HogeView {
val viewModel = ViewModel()
fun doSomething() {
// ここでは読み取るだけにしたい
println(viewModel.counter.getCount())
viewModel.counter.increment() // これを禁止したい
}
}
その解決策の一つが、イミュータブルクラス です。
内部の状態が変更できる(varになっており、操作メソッドがある)Mutable
版Counter
と
内部の状態が全て変更できない(valになっており、操作メソッドがない)Immutable
版Counter
を作ります。
class MutableCounter {
private var count: Int = 0
fun getCount() = count
fun increment() {
count += 1
}
fun asImmutable() = ImmutableCounter(count)
}
class ImmutableCounter(val count: Int) {}
これを用いて先ほどの処理を書き直します。
fun main() {
val counter = MutableCounter()
counter.increment()
show(counter.asImmutable())
}
fun show(counter: ImmutableCounter) {
// counter.increment() 禁止できた
}
class HogeViewModel {
private val _counter: MutableCounter = MutableCounter()
val counter: ImmutableCounter get() = _counter.asImmutable()
}
class HogeView {
val viewModel = ViewModel()
fun doSomething() {
// viewModel.counter.increment() 禁止できた
}
}
このように、変更したい場所ではMutableCounter
を利用し、それをImmutableCounter
に変換して読み取り専用として公開することができます。
つまり
なぜMutableListとListが分かれているのか
の理由(の一つ)は
valやvarでは制限しきれない実態の変更を制限し、意図しない値の変更の伝搬を防ぐため
になります
基本的なKotlin/Androidのクラスには多くのミュータブル・イミュータブルクラスのペアが存在します。
-
MutableList
,List
-
MutableSet
,Set
-
MutableLiveData
,LiveData
-
MutableStateFlow
,StateFlow
お気づきかもですが、基本的にImmutable
は省略します。
なぜなら、Kotlin/Androidで提供されているクラスは基本的にImmutableで実装されていることが多いからです。(基本がImmutable
で、例外的にMutable
が存在するというイメージ)
実際にはインターフェースを用いて設計される
実際のMutableList
, List
などは、interface
を用いて設計されていることが多く
MutableCounter: ImmutableCounter
という子関係になっていることが多いです。
// インターフェース
interface ImmutableCounter {
fun getCount(): Int
}
interface MutableCounter: ImmutableCounter {
fun increment()
fun asImmutable(): ImmutableCounter
}
// 実装
class ImmutableCounterImpl(val count: Int): ImmutableCounter {
override fun getCount() = count
}
class MutableCounterImpl: MutableCounter {
private var count: Int = 0
override fun getCount() = count
override fun increment() {
count += 1
}
override fun asImmutable() = ImmutableCounterImpl(count)
}
実はasImmutable()
を呼んでクラスを変換しなくとも、そのままMutableCounter
の変数をImmutableCounter
として受け取って利用できます。
fun main() {
val counter = MutableCounterImpl()
counter.increment()
show(counter)
}
fun show(counter: ImmutableCounter) {
println(counter.getCount())
// counter.increment() 禁止できた
}
実際にMutableList
で試した例
fun main() {
val mutable = mutableListOf(1)
sub(mutable)
}
fun sub(list: List<Int>) {
println(list.last()!!)
}
補足
しかし、クラスを変換しないと
キャストによってMutableList
に戻すことができてしまうため注意が必要でしょう。
fun main() {
val mutable = mutableListOf(1)
sub(mutable)
}
fun sub(list: List<Int>) {
println(list.last()!!)
val mutable = list as MutableList<Int>
mutable.add(2)
println(list.last()!!)
}
イミュータブルクラスの他の視点
イミュータブルクラスには他の視点があります。簡単に紹介します。
イミュータブルクラスのみにし、再生成によって値を変更する
値を変更したい需要がある場合、イミュータブルクラスのみで済ませ、インスタンス自体を再生成することによって値の変更を強制する設計があります。例えばString
です。
Stringには「自分自身を変更するメソッド (破壊的メソッド) 」は一つも実装されていません。
その代わりに「自分自身に変更を加えた新しいStringインスタンスを返すメソッド (非破壊的メソッド) 」は実装されているので、常に新しいStringインスタンスを再生成することで変更を行います。
fun main() {
var hello = "Hello"
// "Hello World"のStringを作成し、helloに再代入している
hello += " World"
val hello2 = "Hello"
// hello2 += " World" コンパイルエラー
}
これによって、ミュータブル・イミュータブルクラスの変換よりも単純に変更を制限することができます。
ミュータブルクラスと比べたデメリットとして
- インスタンス全体を再生成するのでコストが高い
- 再生成、再代入の処理を書くのがめんどくさい
というのがあります。
そのため、複雑な構造を持ちコストが高いList
やSet
やStateFlow
などのデータホルダーではミュータブルクラスが選択されやすく、単なる一つのデータであるString
などではミュータブルクラスのみが選択されやすいと考えられます。
data class
実はdata class
はこの考え方に基づいた型です。
(data class
のプロパティはvar
で宣言できるのでミュータブルにも一応できますが)
型をdata class
で定義するとcopy
メソッドが自動的に作成されます。
このcopy
メソッドを用いることで、非破壊的に値を更新できます。
data class Message(val id: Int, val content: String)
fun main() {
val hello = Message(1, "Hello")
// hello.content += " World" コンパイルエラー
val helloWorld = hello.copy(content = hello.content + " World")
}
ただ、copy
メソッドがあってもやはりめんどくさい場合があります。
それはネストされたデータ構造を変更する時です。
data class Lecture(val id: Int, val name: String, val subject: Subject)
data class Subject(val id: Int, val name: String, val color: String)
fun main() {
var lecture = ...
lecture = lecture.copy(subject = message.subject.copy(color = "#F6F6F6"))
}
TypeScriptのスプレッド構文
TypeScriptを書いたことのある方は、data class
の説明でピンと来た方もいらっしゃると思います。
そうです。TypeScriptのスプレット構文はdata class
のcopyと同じ考え方です。
let lecture = ...
lecture = {
...lecture,
subject: {
...lecture.subject,
color: "#F6F6F6"
}
}
実態の変更を検知しにくい
ミュータブルクラスには他の問題もあります。
クラスの実態が変更されても、実態が変更されたことが参照側から検知しにくい点です。
例えばMutableList
に要素が追加されても、mutableList
変数が指すアドレス(参照)には変化がないので、値の変更が起きたことがわかりません。
val mutableList = mutableListOf(1)
mutableList.add(2)
これがどういう問題になるのかというと、JetPackComposeで問題になっています。
ToDoリストを作成するColabにてこんな記述があります。
次に、リストからタスクを削除する動作を追加するには、まずリストを可変リストにします。
このために可変オブジェクト(ArrayList や mutableListOf, など)を使用しても機能しません。これらの型は、リスト内のアイテムが変更されたことを Compose に通知せず、UI の再コンポーズをスケジュール設定しません。別の API が必要です。
Compose で監視できる MutableList のインスタンスを作成する必要があります。この構造により、Compose は変更を追跡して、リストでアイテムを追加または削除したときに UI を再コンポーズできます。
JetPackComposeのLazyColumn
でMutableState<MutableList<T>>
を用いた場合、MutableList
から要素を削除・追加しても、変更を検知できず、再コンポーズできません(画面に変更が反映されません)。
なぜなら、MutableState
は参照が変化しないと変更を検知しないからです。
そのため、要素の削除・追加を検知できるように実装した専用のListであるSnapshotStateList
を用いる必要があります。(実際にはtoMutableStateList
等を使います)
さらに
これだけでは要素の追加・削除は検知できても、要素の値の変更までは検知できないため、値の変更をする場合は
- 値の変更をしたいプロパティを
MutableState
型にする - Listからの削除し、値を変更した要素を追加する(実質的には再代入)
のどちらかをしなければいけないと記述があります。
Compose が MutableList について追跡しているのは、要素の追加と削除に関連する変更であるためです。これが削除の仕組みです。ただし、行アイテムの値(この場合は checkedState)の変更は、それについても追跡するよう指示しない限り、認識されません。
この問題は、次の 2 つの方法によって解決できます。
・checkedState が Boolean ではなく MutableState になるように、データクラス WellnessTask を変更します。これにより、Compose はアイテムの変更を追跡できます。
・変更しようとしているアイテムをコピーし、リストからそのアイテムを削除して、変更後のアイテムを再度リストに追加します。これにより、Compose はそのリストの変更を追跡します。
これらの煩雑さは、参照型の根本的な仕組みが原因で起きている問題です。
最後に
本記事はHeart of Swift: Value Semantics を持たない型の問題と対処法 を大幅に参考にしています。このサイトでは、本記事で触れた内容をSwiftがどうやって解決しているのかを解説しています。
Swiftは値型中心の言語です。Swiftはミュータブルクラス・イミュータブルクラスを用いず、値型を用いることで今回の問題をシンプルに解決しています。また、値型を中心に設計されたSwiftUIは、State<Array<T>>
だけで配列の要素の削除・追加・変更まで監視可能になっています。
興味のある方は、ぜひHeart of Swiftにアクセスしてみてください。