LoginSignup
9
2

[Kotlin] なぜMutableListとListが分かれているのか

Last updated at Posted at 2023-12-05

はじめに

Kotlinではaddremoveなどの変更ができるMutableListと、変更ができないListが分かれています。なぜこのように分ける必要があるのでしょうか?

このようなタイトルですが、本記事はMutableListListの話に限定されません。
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になっており、操作メソッドがある)MutableCounter
内部の状態が全て変更できない(valになっており、操作メソッドがない)ImmutableCounterを作ります。

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" コンパイルエラー
}

これによって、ミュータブル・イミュータブルクラスの変換よりも単純に変更を制限することができます。

ミュータブルクラスと比べたデメリットとして

  • インスタンス全体を再生成するのでコストが高い
  • 再生成、再代入の処理を書くのがめんどくさい

というのがあります。
そのため、複雑な構造を持ちコストが高いListSetStateFlowなどのデータホルダーではミュータブルクラスが選択されやすく、単なる一つのデータである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のLazyColumnMutableState<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にアクセスしてみてください。

9
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
2