タケハタのブログ

プログラマの生き方、働き方、技術について雑多に書いていくブログです。

【Kotlin初心者向け】KotlinでvarとMutableListを使わなくする方法

f:id:take7010:20211203090539p:plain Swift/Kotlin愛好会 Advent Calendar 2021 3日目の記事です。

最近Kotlinのコードレビューをしていて、色々と「Kotlinならこういう書き方ができるよ」という内容を見つけることが多くありました。
その中でも特に多かったのが、「必要のないvarやMutableList」についてのものでした。

そこで今回はKotlinらしいコードの書き方の一環として、varやMutableList(一部MutableMapも)を使ってしまいがちなケースと、それをなくしてvalやList(Immutable)で実装する方法を紹介します。

※注:
本記事は書き方や機能の活用方法にフォーカスしているため、コレクションライブラリなどKotlinの機能の詳細については書いておりません。
そちらの情報が必要な場合は別途調べていただければと思います。

なぜvarやMutbaleListを使わない方がいいのか?

前提としてなぜvarやMutableListを使わない方がいいのかも説明しておきます。

変数はロジック上特に書き換える必要のないものであれば、変更不可にしてある方が望ましいです。
変更可能となっていると、

  • 後からコードが修正された際などに意図せず書き換えられて、予期せぬバグを生む
  • 同じ変数でもコード内の呼び出している箇所によって値が変わるため、それぞれの箇所での状態も見ないといけないので可読性のコストも増える
    • (そしてこれもバグにつながる)

といったことが起こりやすくなるので、意図的に変更したいものでなければ基本的に変更不可で定義します。
そのためKotlinでは機能として提供されているvalでの変数定義や、ImmutableなコレクションであるList、Mapなどを使います。

ちなみに私はだいたい変数を定義する時まずvalで定義し、実装上どうしても困ったらvarに変えるという考えでやっています。
(varに変えることはほぼないですが)

varを使わずにvalに

まずは変数でvarを使わずにvalで定義できるようにするパターンを紹介します。

if、whenを式として使う

まずは以下のようなパターンです。

fun getMessage(name: String): String {
    var message = ""
    
    if (name == "") {
        message = "No name"
    } else {
        message = "Hello $name"
    }
    
    return message
}

messageという変数をvarで定義し、引数であるnameの値に応じてif-elseブロックの中で書き換えています。
Kotlinではif-elseを式として扱い結果を受け取ることができるので、以下のように書けます。

fun getMessage(name: String): String {
    val message = if (name == "") {
        "No name"
    } else {
        "Hello $name"
    }
    
    return message
}

if-elseが返した値を変数に入れるだけなので、messageはvalで定義できるようになります。
さらに言うとこの処理では値を書き換えた後そのままreturnしてるだけなので、以下のようにif-elseをそのままreturnしてしまうことでよりスッキリします。

fun getMessage(name: String): String {
    return if (name == "") {
        "No name"
    } else {
        "Hello $name"
    }
}

whenも同様で、次のようなコードは

fun getGreet(time: String): String {
    var message = ""
    when (time) {
        "morning" -> {
            message = "Good Morning"
        }
        "noon" -> {
            message = "Hello"
        }
        "night" -> {
            message = "Good Night"
        }
        else -> {
            throw IllegalArgumentException()
        }
    }
    return message
}

以下のように結果を変数に入れたり、そのままreturnすることができます。

fun getGreet(time: String): String {
    val message = when (time) {
        "morning" -> {
            "Good Morning"
        }
        "noon" -> {
            "Hello"
        }
        "night" -> {
            "Good Night"
        }
        else -> {
            throw IllegalArgumentException()
        }
    }
    return message
}
fun getGreet(time: String): String {
    return when (time) {
        "morning" -> {
            "Good Morning"
        }
        "noon" -> {
            "Hello"
        }
        "night" -> {
            "Good Night"
        }
        else -> {
            throw IllegalArgumentException()
        }
    }
}

try-catchも式として扱える

if-elseと似た形で、try-catchでも以下のようなパターンがあります。

fun execute(numStr: String) {
    var num = 0
    try {
        num = numStr.toInt()
    } catch (e: NumberFormatException){
        e.printStackTrace()
        throw e
    }
    
    // numを使った処理
    // ・・・
}

try-catchの後に処理を続けたい場合、変数をtryブロックの中で定義してしまうと参照できなくなるため、numという変数を先にvarで定義しています。
しかしKotlinではtry-catchも式として扱い結果を返すことができるため、以下のように書けます。

fun execute(numStr: String) {
    val num = try {
        numStr.toInt()
    } catch (e: NumberFormatException){
        e.printStackTrace()
        throw e
    }
    
    // numを使った処理
    // ・・・
}

catchブロックに入った場合は例外処理を実行して終了し、toIntの処理が正常に通った場合は結果がnumに入ります。

Nullだった場合にNullを設定したい場合は安全呼び出しで

if-elseで書いている例と似ていますが、今度は引数がnullだった場合は変数にもnullを設定したい場合のパターンです。

fun execute(param: String?) {
    var length: Int? = null
    if (param != null) {
        length = param.length
    }
    
    // lengthを使った処理
    // ・・・
}

こういう場合は、?を付けて安全呼び出しを使えば変数をvalにした上で1行で書けます。

fun execute(param: String?) {
    val length = param?.length
    
    // lengthを使った処理
    // ・・・
}

これで変数paramがnullだった場合はlengthにnullが入り、nullでなかった場合はparam.lengthの結果が入ります。

Nullだった場合の処理はエルビス演算子で

今度は逆にnullだった場合のみ変数に値を設定するパターンです。

fun execute(param: String?) {
    var length = param?.length
    if (length == null) {
         length = 0
    }
    
    // lengthを使った処理
    // ・・・
}

この場合は、エルビス演算子を使うことで以下のようにvalにした上で1行で書けます。

fun execute(param: String?) {
    val length = param?.length ?: 0
    
    // lengthを使った処理
    // ・・・
}

param?.lengthだけだと前述の内容の通り「paramがnullだった場合はlengthにnullが入る」ことになりますが、エルビス演算子を使うことでnullだった場合は0を入れるようにしています。

Listから特定の値の要素を取得するのはfindで

次はListを使ったパターンです。
まずは以下のようにListをforで回して、探している値(ここではhogeから始まる文字列)が見つかったら変数に代入してしてbreakする、Linear Searchの処理。

fun execute(params: List<String>) {
    var target: String? = null
    for (p in params) {
        if (p.startsWith("hoge")) {
            target = p
            break
        }
    }

    // targetを使った処理
    // ・・・
}

これはコレクションライブラリであるfindを使用することでシンプルに書けます。

fun execute(params: List<String>) {
    val target = params.find {
        it.startsWith("hoge")
    }

    // targetを使った処理
    // ・・・
}

これでhogeで始まる値が存在した場合はその値を返し、存在しなければnullを返して変数targetに代入します。

Listに特定の値が存在するか確認するのはanyで

findと似たようなパターンですが、以下のように存在するかしないかをBoolean型の変数に代入したい場合です。

fun execute(params: List<String>) {
    var isExist = false
    for (p in params) {
        if (p.startsWith("hoge")) {
            isExist = true
            break
        }
    }

    // isExistを使った処理
    // ・・・
}

こちらもコレクションライブラリである、anyを使うことでシンプルに書けます。

fun execute(params: List<String>) {
    val isExist = params.any {
        it.startsWith("hoge")
    }

    // isExistを使った処理
    // ・・・
}

ラムダ式の書き方はfindと全く同じです。
違うのはfindは存在した場合はその値を返すのに対し、anyはBoolean型のtrueを返します。
また、存在しなかった場合はnullではなくfalseを返します。

MutableListを使わずにListに

ここからはMutableListを使わず、List(Immutable)を使うようにするパターンを紹介していきます。
(一部MutableMapの話も含まれています)

Listから違う型のListを作る(map)

以下のように、あるListから別の型のListを作るパターンです。

fun convertList(params: List<String>): List<Int> {
    val lengthList = mutableListOf<Int>()
    for (p in params) {
        lengthList.add(p.length)
    }
    return lengthList
}

ここではString型のListから、それぞれの要素のlengthの値をInt型のListに入れ替えています。
こういった各要素の値を使って別のListに置き換えるような処理は、mapを使って以下のように書けます。

fun convertList(params: List<String>): List<Int> {
    return params.map {
        it.length
    }
}

ラムダ式で各要素から作りたい値を記述することで、その値のListに変換してくれます。
ここではInt型であるlengthの値を結果として返しているため、mapが返すListの型もList<Int>になります。

Listから特定条件に該当する要素を抽出する(filter)

以下のように、あるListから特定の条件に該当する値のみを抽出して別のListに入れていくパターンです。

fun getOddList(params: List<Int>): List<Int> {
    val oddList = mutableListOf<Int>()
    for (p in params) {
        if (p % 2 == 1) {
            oddList.add(p)
        }
    }
    return oddList
}

ここではInt型のListから、奇数になる値だけをoddListに追加していっています。
これはfilterを使うことで以下のように書けます。

fun getOddList(params: List<Int>): List<Int> {
    return params.filter {
        it % 2 == 1
    }
} 

ラムダ式には抽出したい条件の式を記述し、各要素に対して実行しtrueになった値のみの入ったListがが、返却されます。

Listの要素を元にMapを作る(associate、associateBy、associateWith)

次はListの要素を使って、Mapを作る処理です。
ここはMutableListではなく、MutableMapを使わなくする実装の紹介をします。

いくつかのパターンがあるので、順に説明していきます。

KeyとValueを両方指定する(associate)

以下のようにListをforで回し、各要素の値からMapのKeyとValueを両方指定しているパターンです。

data class User(val id: Int, val name: String)

fun convertToMap(params: List<User>): Map<Int, String> {
    val map = mutableMapOf<Int, String>()
    for (p in params) {
        map[p.id] = p.name
    }
    return map
}

ここではデータクラスであるUserのListから、それぞれの要素のidをKey、nameをValueとしてMapに入れています。
これは以下のように、associateを使うことでシンプルに記述できます。

fun convertToMap(params: List<User>): Map<Int, String> {
    return params.associate {
        it.id to it.name
    }
}

Listの各要素に対して、KeyとValueとして使いたい値を、ラムダ式でKey to Valueの形で返してPair型の値として返しています。 Pairの1つ目の要素がKey、2つ目の要素をValueとしたMapが生成されます。

要素をValueとしてKeyだけ指定する(associateBy)

associateはKeyとValueを両方指定していましたが、今度はKeyだけを指定したい(ValueはListの要素そのまま)の場合のパターンです。

fun convertToMap(params: List<User>): Map<Int, User> {
    val map = mutableMapOf<Int, User>()
    for (p in params) {
        map[p.id] = p
    }
    return map
}

※Userクラスはassociateで書いたものと同様です

ここではUserのListをforで回し、MapにはidをKey、UserのオブジェクトそのものをVlaueとしてMapに入れています。
このようにValueは要素そのもので、Keyだけ式を使って作りたい場合は、以下のようにassociateByを使うことでシンプルに書けます。

fun convertToMap(params: List<User>): Map<Int, User> {
    return params.associateBy {
        it.id
    }
}

ラムダ式の中には、Keyとして指定したい値を作る処理を記述します。
ここでは各要素のidを返すようにしているので、idをKey、各要素の値そのもの(ここではitの値)をValueとしたMapを返します。

要素をKeyとしてValueだけ指定する(associateWith)

associateByとは逆に、Valueだけを指定したい(KeyはListの要素そのまま)の場合のパターンです。

fun convertToMap(params: List<String>): Map<String, Int> {
    val map = mutableMapOf<String, Int>()
    for (p in params) {
        map[p] = p.length
    }
    return map
}

※Userクラスはassociateで書いたものと同様です

こちらはString型のListをforで回し、要素の値をKey、要素に対してのlengthの結果をValueとしてMapに入れています。
これは以下のようにassociateWithを使うことでシンプルに書けます。

fun convertToMap(params: List<String>): Map<String, Int> {
    return params.associateWith {
        it.length
    }
}

associateByとほぼ同じで、こちらはラムダ式の中にValueとして指定したい値を作る処理を記述します。
これで要素の値(ここではitの値)をKey、lengthの値をValueとしたMapを返します。

Listの結合は+演算子で

複数のListを結合して、新たな別のListを作るパターンです。

fun joinTarget(params1: List<String>, params2: List<String>): List<String> {
    val list = mutableListOf<String>()
    list.addAll(params1.filter { it.startsWith("A") })
    list.addAll(params2.filter { it.length > 5 })
    return list
}

上記の例では、引数の2つのListからそれぞれfilterで抽出した別のListを生成し、MutableListにaddAllで追加しています。
この書き方でも問題なさそうに見えますが、以下のようにそれぞれをImmutableなListとして生成し、+演算子を使うことで結合したListを返すこともできます。

fun joinTarget(params1: List<String>, params2: List<String>): List<String> {
    val list1 = params1.filter { it.startsWith("A") }
    val list2 = params2.filter { it.length > 5 }
    return list1 + list2
}

このサンプルだと短いのであまり感じませんが、「MutableListを作ってそれに追加していく」というような処理は、コードの各所でそのMutableListの状態が変わるため可読性は良くないと思っています。
また、後から追加された実装でMutableListに予期せぬ値を入れられてしまう可能性もありえます。

そのため記述量はそんなに変わりませんが、MutableListは使わないこの書き方の方が望ましいと考えています。
(ここではlist1、list2という適当な変数名を付けていますが、意味のある名前を付ければもっと読みやすくなります)

複雑なものでなく短い処理の場合は、もちろん以下のようにそれぞれの結果を直接結合して返すということもできます。

fun joinTarget(params1: List<String>, params2: List<String>): List<String> {
    return params1.filter { it.startsWith("A") } + params2.filter { it.length > 5 }
}

条件に応じてListに追加する要素を変更する(listOfNotNull)

以下のように、様々なif文などの条件に応じてListに値を追加していくパターンです。

fun getStatusList(user: User): List<String> {
    val statusList = mutableListOf<String>()
    if (user.name.length > 5) {
        statusList.add("name length greater than 5")
    }
    if (user.name.startsWith("A")) {
        statusList.add("name initials is A")
    }
    if (user.age > 30) {
        statusList.add("age greater than 30")
    }
    return statusList
}

よくエラーコードのListを生成する処理などで見かけます。
これも一見良さそうに見えますが、以下のようにlistOfNotNullを使ってMutableListを排除することができます。

fun getStatusList(user: User): List<String> {
    return listOfNotNull(
        if (user.name.length > 5) { "name length greater than 5" } else null,
        if (user.name.startsWith("A")) { "name initials is A" } else null,
        if (user.age > 30) { "age greater than 30" } else null,
    )
}

listOfNotNullは引数の中でnullでないものだけを持ったListをを生成します。
このように各要素にif-elseの結果を指定しておくことで、trueだった場合はその結果を追加、falseだった場合は追加しない(nullなのでlistOfNotNullで排除される)という形になります。

関数の引数に分岐をひたすら並べることになるので、あまりに処理が長くなってきた場合はif-elseの処理を関数化などしてリファクタリングをしないと読みづらくなる可能性はあります。
が、前述の通りMutableListの状態を把握するということ自体がコストではあるので、できるだけこういった書き方に倒した方が安全だと思います。

まとめ

ということで、色々とvarやMutableList、MutableMapを使わない方法を紹介してきました。
読んでいてなんとなく気づく部分もあるかと思いますが、コレクションライブラリを使いこなすと色々シンプルにできます

Null Safetyと合わせ、変数もImmutableにすることでKotlinの特性である安全な言語仕様をより活かしていけます。
今回紹介したような内容を使いこなしていくと、「Kotlinらしい書き方」みたいなところに一歩近づいてくると考えています。

記述をシンプルにするというだけでなく、コードの堅牢性を高める意味でも、意識して使っていってもらえればと思います。

【宣伝】サーバーサイドKotlinの書籍を発売しています!

私の執筆した書籍「Kotlin サーバーサイドプログラミング実践開発」が発売中です!

gihyo.jp

タイトル通り実践での開発にも持っていける内容になっているので、サーバーサイドKotlinを始めてみたい方はぜひお手にとっていただければと思います。