🐤

型だけでバグを減らそう!Kotlinの型パワーを使った実践タイプセーフエンジニアリング -Kotlin Fest 2022-

2022/12/11に公開

はじめに

本記事はKotlin Fest 2022で発表された「型だけでバグを減らそう!Kotlinの型パワーを使った実践タイプセーフエンジニアリング」の記事版となります。

https://www.kotlinfest.dev/

当日のスライドはこちら

かつ、株式会社ログラス Productチーム Advent Calendar 2022の12/11(日)の記事でもあります。
https://qiita.com/advent-calendar/2022/loglass

本記事ではKotlin開発において実装ミスを検出するためになぜ型を使いこなすべきなのか?ということと、実際に実装ミス・バグを減らせる実装パターンを紹介していきます。

目次

  • なぜ型を使いこなすことで実装ミスが減るのか?
  • 型によって実装ミスが減らせるパターン
    1. 標準の型をラップする
    2. 認可処理などの特定の処理をパスしたことを型で示す
    3. 型でデータの不整合をなくす
  • まとめ
  • 最後に: タイプセーフなポストモーテム

なぜ型を使いこなすことで実装ミスが減るのか?

はじめになぜ型を使いこなすことで実装ミスが減るのか?を解説していきます。
もし読者のあなたがKotlinを既に使っているのなら型の機能によって実装ミスが減るということを日毎から無意識に実施していると思います。
その一つがKotlinのNullable typesという機能です。

https://kotlinlang.org/docs/null-safety.html

以下の例ではミドルネームというプロパティがnullで実行されることによってNullPointerExceptionが起きてしまう例です。

class User(
    firstName: String,
    middleName: String,
    lastName: String,
) {
    fun fullName(): String {
        return "$firstName $middleName $lastName"
    }
}

fun main() {
    val user = User("Yuito", null, "Sato")
    println(user.fullName())
    // => NullPointerException
}

しかし実際にはKotlinではこのコードはコンパイル時でエラーを検出します。

=> Null can not be a value of a non-null type String

KotlinではNullable Typesという機能により、nullを許容していないプロパティにnullを代入することはできません。
nullを代入できないことを知ったユーザーはmiddleNameをnullを許容する型に変換し、nullを正しくハンドリングするコードを追加することでしょう。

class User(
    firstName: String,
    middleName: String?,
    lastName: String,
) {
    fun fullName(): String {
        return if (middleName == null) {
            "$firstName $lastName"
        } else {
            "$firstName $middleName $lastName"
        }
    }
}

fun main() {
    val user = User("Yuito", null, "Sato")
    println(user.fullName())
    // => "Yuito Sato"
}

これはシンプルな例ですが、Nullable TypesというKotlinの型機能により実行時に検出されたNullPointerExceptionがコンパイル時に検出されるようになりました。

実際のコードでは条件分岐が多く、その分岐を全て実行するような自動テスト、手動テストをすることは現実的ではありません。
一方でコンパイルは全てのコードについて行われ、Kotlinでは型の検査も全てのコードについてコンパイル時に行われます。

このように実行時のエラーはテストが不十分なことにより見過ごされる場合があります。
しかし、コンパイル時に検出されるエラーは見過ごしようがありません。
なぜならKotlinのコンパイルエラーを無視しながらKotlinのアプリケーションをリリースすることは不可能だからです。

このようにKotlinでは型の機能がとても多くうまく使いこなせば実装ミスを減らせるポイントが多くあります。
本記事では実装に起こりがちなミスを例題に、そのミスを解決するようなKotlinの型機能と実装パターンを紹介していきます。

型によって実装ミスが減らせるパターン

1. 標準の型をラップする

よくあるミス: 引数のIDを取り間違えてしまった

よくあるミスとして、引数のIDを取り間違えてしまうことがバグにつながるケースがあります。
単純なタスク管理アプリのタスクIDを元にタスクの情報を取得したいケースを考えます。
以下の例ではタスクIDでタスクを取得するべきなのに ログインユーザーID(loginUserId)でアクセスをしてしまっています。

これではどのようなタスクIDを渡してもタスクを取得することができません。

class TaskRepository {
    fun findTaskById(taskId: String): Task
}

class FindTaskUseCase(
    private val taskRepository: TaskRepository
) {
    fun findTaskById(taskId: String, loginUserId: String): Task {
      val task = taskRepository.findTaskById(loginUserId)
        ?: throw NotFoundError("タスクが見つかりません")
      return task
    }
}

標準の型をラップする

このミスの対策としては標準の型、ここではStringを別々の型でラップすることが挙げられます。

data class TaskId(
    val value: String
)

data class UserId(
    val value: String
)

このようにすることで、ユーザーのIDでタスクを取得しようとした時にコンパイルエラーで落ちるようになり、ミスをコンパイル時に検出することができます。

class TaskRepository {
    fun findTaskById(taskId: TaskId): Task
}

class FindTaskUseCase(
    private val taskRepository: TaskRepository
) {
    fun findTaskById(taskId: TaskId, loginUserId: UserId): Task {
        val task = taskRepository.findTaskById(loginUserId)
            // => コンパイルエラー
            ?: throw NotFoundError("タスクが見つかりません")
        return task
    }
}

ラップすることによるオーバーヘッド

しかしプリミティブな型(StringやInt)をクラスでラップすると追加のヒープ割り当てにより、実行時のオーバヘッドが大きく、パフォーマンスが低下します。

Sometimes it is necessary for business logic to create a wrapper around some type. However, it introduces runtime overhead due to additional heap allocations. Moreover, if the wrapped type is primitive, the performance hit is terrible, because primitive types are usually heavily optimized by the runtime, while their wrappers don't get any special treatment.

https://kotlinlang.org/docs/inline-classes.html

このようなオーバーヘッドを回避する方法としては、Kotlinでは value classという機能を提供しています。

value class TaskId(
    val value: String
)

このように書くことによりコンパイル時に最適化処理が走り、String型で扱われるのと同等のパフォーマンスを出すことができます。
※JVMバックエンドとして使う場合は@JvmInlineというアノテーションが必要だったりします。

genericsを使ってさらに汎用的に定義する

読者の皆さんはここまで読んでもしかしたらこう思ったかもしれません。
「え、全てのIDについて型をつけるの?それはめんどくさくない?」
たしかにすべての標準型をラップするのは一定のコストがかかります。

しかし型の取り間違いを防ぐことだけをゴールとするならばgenericsをつかってより汎用的なID型を定義することができます。
以下の例ではgenericsを使い、任意のT型をとれるようにすることでタスクIDのId<Task>型とユーザーId<User>を区別して実装できるようにした例です。

value class TaskId(
    val value: String
)

value class Id<T>(
    val value: String
)

class Task(val id: Id<Task>, // 中略)
class User(val id: Id<User>, // 中略)

fun main() {
    val taskId: Id<Task> = Id("1")
    val userId: Id<User> = taskId
    // コンパイルエラー
}

class TaskRepository {
    fun findTaskById(taskId: Id<Task>): Task
}

class FindTaskUseCase(
    private val taskRepository: TaskRepository
) {
    fun findTaskById(taskId: Id<Task>, loginUserId: Id<User>): Task {
        val task = taskRepository.findTaskById(loginUserId)
	    // => コンパイルエラー
	    ?: throw NotFoundError("タスクが見つかりません")
	return task
    }
}

このようにKotlinのgenericsをうまく使うことで型をむやみに定義するコストを下げることができます。

2. 認可処理などの特定の処理をパスしたことを型で示す

よくあるミス: 権限のないIDで参照をしてしまった

よくあるミス(しかし相当やばいミス)として認可処理(権限チェック)を通っていないIDを使ってDBへの問い合わせをしてしまうことがあります。
以下の例では、リクエストから取得したタスクIDを認可処理をせずにそのままDBの問い合わせをおこなってしまい、たとえば他人のタスクなどを見ることができてしまうようなコードです。

class TaskRepository {
    fun findTaskById(taskId: TaskId): Task?
}

class TaskAuthChecker {
    fun authorizeTaskId(taskId: TaskId): TaskId
}

class FindTaskUseCase(
    private val taskRepository: TaskRepository,
    private val taskAuthChecker: TaskAuthChecker
) {
    fun findTaskById(taskId: TaskId): Task? {
        // 認可処理を書くのを忘れてしまった
	// val authorizedTaskId = taskAuthChecker.authorizeTaskId(taskId)
	// 認可処理をしていないIDを渡してしまう
        val task = taskRepository.findTaskById(taskId)
        return task
    }
}

ではこのようなミスはどのように防げるのでしょうか?

「認可された」ID型を導入する

取りえる選択肢としてID型とは別に認可されたIDという別のクラスを用意します。

class AuthorizedTaskId public constructor(
    val value: String
)

また、この型は認可処理をする特別なクラス TaskAuthChecker が返す特別な型とします。

class TaskAuthChecker {
    fun authorizeTaskId(taskId: TaskId): AuthorizedTaskId {
        if (checkTaskId(taskId)) {
	    return AuthorizedTaskId(taskId.value)
	}
	throw Exception("このタスクへの権限がありません")	
    }
}

そして、TaskRepository.findTaskById は AuthorizedTaskId のみ引数として取れるようにします

class TaskRepository {
    fun findTaskById(taskId: AuthorizedTaskId): Task?
}

こうすることで 認可処理が通っていないただのタスクIDはDBへの問い合わせ処理に使うことができず、コンパイルエラーで落ちるようになります。

class FindTaskUseCase(
    private val taskRepository: TaskRepository,
    private val taskAuthChecker: TaskAuthChecker
) {
    fun findTaskById(taskId: TaskId): Task? {
        val task = taskRepository.findTaskById(taskId)
        // コンパイルエラー
        return task
    }
}
class FindTaskUseCase(
    private val taskRepository: TaskRepository,
    private val taskAuthChecker: TaskAuthChecker
) {
    fun findTaskById(taskId: TaskId, loginUserId: UserId): Task? {
        val authorizedTaskId = taskAuthChecker.authorizeTaskId(taskId)
        // 認可処理が通ったことを型として確約できる
        val task = taskRepository.findTaskById(authorizedTaskId)
        return task
    }
}

このように特定の処理が通ったことをパスしたことを型で表すことで、処理の通し忘れなどの実装ミスをコンパイルフェーズで検出することができるようになります。

しかし、ここで安心してはいけません。AuthorizedTaskIdは data classで実装されています。そのためコンストラクタは公開されています。
そのため、極端な例ですが TaskAuthChecker を経由せずに AuthorizedTaskIdインスタンスを生成することも可能です。

class FindTaskUseCase(
    private val taskRepository: TaskRepository,
    private val taskAuthChecker: TaskAuthChecker
) {
    fun findTaskById(taskId: TaskId): Task? {
        val authorizedTaskId = AuthorizedTaskId(taskId.value)
        val task = taskRepository.findTaskById(authorizedTaskId)
        // コンパイルエラーは起きない。
        return task
    }
}

これは非常に極端であり、こんなことをする人はいないと皆さん思うかもしれません。
しかし皆さんが扱う実際のコードは上記の例と比べるまでもなく複雑で、大なり小なり意図した設計とは違う「ハック」をされることが往々にしてあります。
ここではそんな「ハック」をされないために使えるKotlinのテクニックを紹介します。

コンストラクタの可視性を制御する

先ほどの問題はAuthorizedTaskIdのコンストラクタが TaskAuthCheckerから呼べるということでした。

そこでKotlinでの特定のクラスからしか呼べないようなテクニックを紹介します。

それはメインの実装をinterfaceで行い、それとは別にコンストラクタ用のdata classを用意することです。

そのコンストラクタ用のdata classは可視性修飾子(private, internal等)で制御します。

sealed interface AuthorizedTaskId {
    val value: String
}

private data class AuthorizedTaskIdImpl(
    override val value: String
) : AuthorizedTaskId

class TaskAuthChecker {
    fun authorizeTaskId(taskId: TaskId): AuthorizedTaskId {
        if (checkTaskId(taskId)) {
            // 同ファイルのためprivateなAuthorizedTaskIdImplが呼べる
            return AuthorizedTaskIdImpl(taskId.value)
        }
        throw Exception("このタスクへの権限がありません")
    }
}

こうすることで別ファイルの FindTaskUseCase ではAuthorizedTaskIdのインスタンスを直接生成できなくなります。

class FindTaskUseCase(
    private val taskRepository: TaskRepository,
    private val taskAuthChecker: TaskAuthChecker
) {
    fun findTaskById(taskId: TaskId): Task? {
        val authorizedTaskId = AuthorizedTaskId(taskId.value)
        // => interfaceはインスタンス化できないためコンパイルエラー
        val authorizedTaskId = AuthorizedTaskIdImpl(taskId.value)
        // => AuthorizedTaskIdImplクラスはprivateなためアクセスできずコンパイルエラー
        val task = taskRepository.findTaskById(authorizedTaskId)
        return task
    }
}

似たような実装としては通常のクラスのコンストラクタにprivateを付与する方法がありますが、こちらはそのクラス内からしかアクセスできなくなるため今回のケースには合いません。

class AuthorizedTaskId private constructor(
    val value: String
)

class TaskAuthChecker {
    fun authorizeTaskId(taskId: TaskId): AuthorizedTaskId {
        if (checkTaskId(taskId)) {
            // 同ファイルではもAuthorizedTaskIdのクラス内ではないのでアクセス不可
            return AuthorizedTaskId(taskId.value)
        }
        throw Exception("このタスクへの権限がありません")
    }
}

また AuthorizedTaskId と TaskAuthCheckerの実装が同じファイルや同じモジュールに存在しない場合でも data classの実装を TaskAuthCheckerの具象クラスの同ファイルにおけば問題なく実装できます。

この場合はAuthorizedTaskIdの sealed 修飾子は外します。

interface AuthorizedTaskId {
    val value: String
}

// 認可処理はDBと繋ぐ必要があり、インターフェースと実装を分ける
interface TaskAuthChecker {
    fun authorizeTaskId(taskId: TaskId): AuthorizedTaskId
}
private data class AuthorizedTaskIdImpl(
    override val value: String
) : AuthorizedTaskId

class TaskAuthCheckerImpl : TaskAuthChecker {
    override fun authorizeTaskId(taskId: TaskId): AuthorizedTaskId {
        if (checkTaskId(taskId)) {
            // 同ファイルのためprivateなAuthorizedTaskIdImplが呼べる
            return AuthorizedTaskIdImpl(taskId.value)
        }
        throw Exception("このタスクへの権限がありません")
    }
}

このように、コンストラクタの可視性を interface と data class で制御することで意図しないコーディングである「ハック」を防止することができるようになりました。

3. 型でデータの不整合をなくす

よくあるミス: ありえないデータを作成してしまった

タスクには未着手と着手中と完了というステータスがあるとします。
そしてこの内、完了のときのみ完了時刻を持つものとします。
完了以外のステータスはもちろん完了時刻を持ちません。

data class Task(
    val taskId: TaskId,
    val content: String,
    val status: Status,
    val completedAt: LocalDateTime?,
)

enum class Status {
    WAITING, // 未着手
    WORKING, // 着手中
    COMPLETED, // 完了
}

しかしなんと、開発者は間違えて着手した時に現在時刻を入れるように実装してしまいました。

data class Task(
    val taskId: TaskId,
    val content: String,
    val status: Status,
    val completedAt: LocalDateTime?,
) {
    fun startWorking(): Task {
	return this.copy(
	    status = Status.WORKING,
	    completedAt = LocalDateTime.now()
	)
    }
}

enum class Status {
    WAITING, // 未着手
    WORKING, // 着手中
    COMPLETED, // 完了
}

これにより完了時刻を持っているのにまだ着手中という不整合なデータが生まれてしまいました。
ではこれはどのように実装すればコンパイル時にミスを防げるでしょうか?

sealed classを導入する

完了のときだけ完了時刻を持つというデータ構造はどのように表すのか。
Kotlinではsealed class (interfaceでもOK) を使って表現することができます。

sealed class Status {
    object Waiting : Status()

    object Working : Status()

    data class Completed(
        val completedAt: LocalDateTime
    ): Status()
}

sealed class + object, data classのパターンはEnumのように扱うことが可能です。

また、「sealed」をつけることで他のファイルで継承されることを防げます。

fun main() {
    val status = Status.Working

    when (status) {
	Status.Waiting -> println("未着手")
	Status.Working -> println("着手中")
	// data classのときのみ型判定を使う
	is Status.Completed -> println("完了")
    }
}

Enumだとこういうパターンマッチになるのでほぼ同じように扱えます。

enum class Status {
    WAITING,
    WORKING,
    COMPLETED
}

fun main() {
    val status = Status.Working

    when (status) {
        Status.WAITING -> println("未着手")
        Status.WORKING -> println("着手中")
        Status.COMPLETED -> println("完了")
    }
}

こうすることで完了のステータスのときだけ完了時刻を持つことができます。

このStatusクラスを持ったTaskクラスは以下のように実装されます。

data class Task(
    val taskId: TaskId,
    val content: String,
    val status: Status,
)

TaskクラスからnullableなcompletedAtプロパティが消えました。
こうすることで着手中のステータスに変更する際に完了時刻を入力することが型として不可能になります。
また、完了ステータスにする際に必ず完了時刻を入力させることを型として強制させることも可能です。

data class Task(
    val taskId: TaskId,
    val content: String,
    val status: Status,
) {

    fun complete(): Task {
        return this.copy(
	    status = Status.Completed(
                // completedAtは必須プロパティなので完了時刻を必ず引数に渡さないといけない
                completedAt = LocalDateTime.now()
            )
	)
    }
}

kotlin-resultの事例

このテクニックの事例としてわかりやすいのがKotlinでResult型を提供するkotlin-resultというOSSです。

https://github.com/michaelbull/kotlin-result

Result型とは処理の返り値としてエラーか任意の成功値を返す型で、ScalaやRust, Haskellでは標準の型として似たような型が提供されています。

fun checkPrivileges(
    user: User, command: Command
): Result<Command, CommandError> {
    return if (user.rank >= command.mininimumRank) {
        Ok(command)
    } else {
        Err(CommandError.InsufficientRank(command.name))
    }
}

https://github.com/michaelbull/kotlin-result/blob/master/kotlin-result/src/commonMain/kotlin/com/github/michaelbull/result/Result.kt

public sealed class Result<out V, out E> { // 中略 }

public class Ok<out V>(public val value: V) : Result<V, Nothing>() { // 中略 }

public class Err<out E>(public val error: E) : Result<Nothing, E>() { // 中略 }

Result型には成功時のOK型と失敗時のErr型があります。

seald classを使うことで成功時にnot nullな値を持ち、失敗時にnot nullなエラー値を持つことができます。

以下のように成功値とエラー値をnullableで持つような型とは違うことがポイントです。

// NG
public class Result<out V, out E>(
    public val value: V?,
    public val error: E?,
)

このような定義をしてしまうと成功値もエラー値もどちらもnullではない(もしくはどちらもnull)というデータ構造を許容してしまい、データの不整合を起こす可能性があります。

kotlin-resultについてはsealed classの他にもいろいろ参考になるコードが多いです。
詳しくは以下の記事にまとめましたので、興味ある方はどうぞ!

https://zenn.dev/loglass/articles/05fc3cb3c2ed4e

Exposedの事例

より複雑な例としては、Kotlin製ORマッパーで有名なExposedでも使用されています。

https://github.com/JetBrains/Exposed

Exposedではfor update句の各RDBMSの種類ごとの実装をsealed classを使って実装しています。

すべてのRDBMSの種類の for update句のオプションを nullableなプロパティで管理していないことがわかります。

https://github.com/JetBrains/Exposed/blob/c6cf3792997b28a2c6617c17adc5460ed77a1da7/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/Default.kt#L673

sealed class ForUpdateOption(open val querySuffix: String) {

    internal object NoForUpdateOption : ForUpdateOption("") {
        override val querySuffix: String get() = error("querySuffix should not be called for NoForUpdateOption object")
    }

    object ForUpdate : ForUpdateOption("FOR UPDATE")

    // https://dev.mysql.com/doc/refman/8.0/en/innodb-locking-reads.html for clarification
    object MySQL {
        object ForShare : ForUpdateOption("FOR SHARE")

        object LockInShareMode : ForUpdateOption("LOCK IN SHARE MODE")
    }

    // https://mariadb.com/kb/en/select/#lock-in-share-modefor-update
    object MariaDB {
        object LockInShareMode : ForUpdateOption("LOCK IN SHARE MODE")
    }

    // https://www.postgresql.org/docs/current/sql-select.html
    // https://www.postgresql.org/docs/12/explicit-locking.html#LOCKING-ROWS for clarification
    object PostgreSQL {
        enum class MODE(val statement: String) {
            NO_WAIT("NOWAIT"), SKIP_LOCKED("SKIP LOCKED")
        }

        abstract class ForUpdateBase(querySuffix: String, private val mode: MODE? = null, private vararg val ofTables: Table) : ForUpdateOption("") {
            // 中略
        }

	// 中略
    }

    // https://docs.oracle.com/cd/B19306_01/server.102/b14200/statements_10002.htm#i2066346
    object Oracle {
        object ForUpdateNoWait : ForUpdateOption("FOR UPDATE NOWAIT")

        class ForUpdateWait(timeout: Int) : ForUpdateOption("FOR UPDATE WAIT $timeout")
    }
}

まとめ

コンパイル時に実装ミスが検出できるようにするために、ここまで3つの実装パターンを紹介してきました。

  1. 標準の型をラップする
    1. → JvmInlineやgenericsを使ってオーバーヘッドを減らしつつ汎用的に型を定義する
  2. 認可処理などの特定の処理をパスしたことを型で示す
    1. → ハックをさせないよう、data classをコンストラクトして扱い、その可視性をコントロールする
  3. 型でデータの不整合をなくす
    1. → sealed classを使い、データの不整合が起こりえないクラスを定義する

Kotlinには今回紹介しきれなかったような機能がたくさんあり、まだまだ研究の最中です。

最後に: タイプセーフなポストモーテム

読者の皆さんには今回紹介した枝葉のテクニックだけではなく、より抽象的に型を使ってミスやバグ、障害をなくすという考えがあることを伝えられたらいいなと思っています。

今回のような型を使って実装ミスをなくすという考え方はポストモーテム(障害分析)の再発防止策を考える際にも有用な考え方です。

例えば非常にインパクトのある障害として情報漏洩があります。
情報漏洩した際の再発防止策はどのようなものが考えられますでしょうか?

  • 情報漏洩しないようにレビュワーを一人から二人に増やす
  • なんか偉い人に全てのリリース物をチェックしてもらう
  • GitHubのPRテンプレートに「情報漏洩がないかチェックをおこなった」というチェック項目をつける
  • テスト工数を2倍にする

このような再発防止策を考えてはいないでしょうか?(もちろん一定大事ではある)
しかしテストやレビューの工数には限界があります。

障害のたびに上記のようにテスト、レビュー工数を増やしたり、ある意味で不毛な「儀式」を増やしていては、良いものをスピーディにリリースすることはできません。

最優先で考えるべき再発防止策はコンパイル時に障害のタネを気付けるようにすることです。
喜ばしいことにそのために必要な機能をKotlinは豊富に取り揃えています。
今回紹介したテクニックや、考え方が皆様の開発の役に立てることを祈っています。

明日 12/12(月) のアドベントカレンダーは担当は @zaki___yama さんです!
https://twitter.com/zaki___yama

フロントエンドのビジュアルリグレッションテストの記事を出すらしいのでぜひ読んでみてくださいー!!

株式会社ログラス テックブログ

Discussion