OSS: kotlin-resultを読んで、contract、高階関数の使いどころを理解する
kotlin-resultとは?
この記事は株式会社ログラスDevアドベントカレンダー2022の12/3(土)の記事です!
手頃にKotlinで書かれているOSSのコードを読みたい、でもExposedとかKtorとか大きすぎてどこから読めばいいかわからないよ、、、という皆さん!
読んで勉強になるシンプルでコンパクトな素晴らしいOSSを紹介します!
それが、kotlin-resultというOSSです。
kotlin-resultは Rust、Elm、HaskellのResult型にインスパイアされたようなインターフェースを持つライブラリです。
成功したときの値か、失敗したときの値かのどちらかを持つResult型を提供しています。
例外とは違って返り値にエラー情報を明示することで可読性を上げたり、使用者にエラーハンドリングを強制させることができます。
日本語でよくまとまっている記事はこちら(なんと本家のReadmeからも参照されています!スゴイ!)
軽く使い方を話すと、このような形でOkかErrかをResult型としてリターンできたり、
fun checkPrivileges(user: User, command: Command): Result<Command, CommandError> {
return if (user.rank >= command.mininimumRank) {
Ok(command)
} else {
Err(CommandError.InsufficientRank(command.name))
}
}
処理の中で例外が起きたら型としてThrowableを詰めるなどもできます。
val result: Result<Customer, Throwable> = runCatching {
customerDb.findById(id = 50) // could throw SQLException or similar
}
内部のコードを見ると、 contract機能や高階関数などの機能をキレイに扱っており、コードとしてとても勉強になるので今回紹介することになりました。
目次
- Result型のコードリーディング
- seald class
- ジェネリクスと共変
- componentN
- runCatchingのコードリーディング
- 高階関数
- callsInPlace contract
- レシーバを受け取る高階関数 A.(B) -> C
- infix
- getのコードリーディング
- implies contract
- implies contractについてわからなかったこと
Result型のコードリーディング
ではまずはResult.ktを読んでいきます。
ポイントをかいつまんで解説していきます。
sealed class
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>()
sealed classを用いた代数的データ型でResult型を定義しています。
Result型がOk型の場合は成功値の value: V
を Err型の場合はエラー値の error: E
を取るようになっています。
ジェネリクスと共変
public sealed class Result<out V, out E>
val result: Result<Any, Throwable> = Ok(1)
val result: Result<Any, Throwable> = Err(Exception("error"))
ジェネリクスのVとEにはout(共変)がついていますね。このおかげでVのサブクラスとEのサブクラスも型として受け入れることができます。
componentN
public sealed class Result<out V, out E> {
public abstract operator fun component1(): V?
public abstract operator fun component2(): E?
}
componentN は Destructuring Declarations
と呼ばれるもので、 val (a, b)
のように分解できるものです。
Pairなどの構文がイメージしやすいと思います。
val (first, second) = Pair(1, "A")
ちなみにdata classでは自動で宣言されています
data class Person(val name: String, val age: Int)
val (name, age) = person
componentNが定義されていることで Result型を V? と E? に分割することができます。
val result: Result<Customer, Throwable> = runCatching {
customerDb.findById(id = 50) // could throw SQLException or similar
}
val (customer, error) = result
型を明示的に書くとこうなります
val (customer: Customer, error: Throwable) = result
ところで、これらはabstract関数なためサブクラスのOk型とErr型には実装が必要です。
Ok型はどのようになっているのでしょうか?
public class Ok<out V>(public val value: V) : Result<V, Nothing>() {
override fun component1(): V = value
override fun component2(): Nothing? = null
}
component1 と component2 の返す型が変わっていることに注意です。
V? とE? だったものが V と Nothing? になっていますね。
あれ、関数の返す型で実装時に変更できたっけ?→サブクラスならできます。
まずVはV?のサブクラスです。
val i1: Int = 1
val i2: Int? = i1 // 代入可能
また、 Nothing は すべてのクラスのサブクラスなため、 Nothing? は E?のサブクラスだと言えます。
val nothing: Nothing? = null
val i: Int? = nothing // 代入可能
このように定義することでOk型の場合は型をいきなり確定させることできます。
val (value: Int, error: Nothing?) = Ok(1)
val (value: Int) = Ok(1)
val (value) = Ok(1)
一方でErr型は以下のようになります。
public class Err<out E>(public val error: E) : Result<Nothing, E>() {
override fun component1(): Nothing? = null
override fun component2(): E = error
}
先ほどと逆ですね。
そのためこのように使うことができます。
val (value: Nothing?, error: Exception) = Err(Exception("error"))
val (_, error: Exception) = Err(Exception("error"))
val (_, error) = Err(Exception("error"))
runCatchingのコードリーディング
次は runCatchingのコードを見ていきます。
使い方は下記のようなイメージです。
val result: Result<Customer, Throwable> = runCatching {
customerDb.findById(id = 50) // could throw SQLException or similar
}
実装はトップレベル関数として定義されています。
public inline fun <V> runCatching(block: () -> V): Result<V, Throwable> {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return try {
Ok(block())
} catch (e: Throwable) {
Err(e)
}
}
高階関数
runCatching は 関数を引数として受け取る関数(高階関数)となっていますね。
public inline fun <V> runCatching(block: () -> V): Result<V, Throwable>
引数として受け取った block関数はそのまま内部のTry節のなかで評価されています。
block関数は最後にV型を返すので最終的には Result<V, Throwable> を返すようになります。
public inline fun <V> runCatching(block: () -> V): Result<V, Throwable> {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return try {
Ok(block())
} catch (e: Throwable) {
Err(e)
}
}
callsInPlace contract
runCatghingではcontractも使われていました。
contractはkotlinコンパイラに想定しうる実行時エラーは起きえないことを教えることで、無駄なエラーハンドリングをなくす機能です。
以下の記事がうまくまとまっています。
ではrunCatchingではどのようにcontractが使われているのでしょうか?
public inline fun <V> runCatching(block: () -> V): Result<V, Throwable> {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
// 中略
}
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
はblockという関数は必ず一度評価されるという意味です。
これがどう嬉しいの?という話を説明していきます。
たとえば var を使った場合、その変数を使う場合は初期化を済ます必要があります。
var i: Int
println(i)
// => Variable 'i' must be initialized
関数というのは場合によっては内部で処理が分岐する場合もあるので var を使った場合必ず初期化できるとは限りません。
fun runSomething(block: () -> Unit) {
block()
}
var i: Int
runSomething { i = 1 }
println(i)
// => Variable 'i' must be initialized
しかし callsInPlace(block, InvocationKind.EXACTLY_ONCE)
を使うと必ず一度実行するとコンパイルに伝えることができます。
fun runSomething(block: () -> Unit) {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
block()
}
var i: Int
runSomething { i = 1 }
println(i)
// => 1
これがcallsInPlaceのよいところです。そのためrunCatchingも同様で以下のようなことができます。
var i: Int
runCatching { i = 1 }
println(i)
// => 1
レシーバーを受け取る高階関数 A.(B) -> C
kotlin-resultには2つのrunCatchingが存在します。以下の2つです。
内部の実装は同じです。インターフェースだけ違います。
ここまでは前者の方を説明していました。
public inline fun <V> runCatching(block: () -> V): Result<V, Throwable>
public inline infix fun <T, V> T.runCatching(block: T.() -> V): Result<V, Throwable>
後者の方は何が違うのでしょうか?
まずは拡張関数を使っていますね。
任意の型Tに対してrunCatchingメソッドを生やしています。そのため以下のようにIntのインスタンスに対してメソッドを呼ぶことができます。
1.runCatching { doSomething() }
引数のblockも違いますね。 block: T.() -> V
のような型となっています。このT.()はレシーバーを受け取れるという関数の定義です。
そのため以下のようなことができます。
class Person(
val name: String
) {
fun hello() {
return "Hello, I am ${name}"
}
}
val person = Person("taro")
val result: Result<String, Throwable> = person.runCatching { this.hello() }
thisを使ってpersonを受け取っていますね。
もちろんこのthisは省略可能です。
val result: Result<String, Throwable> = person.runCatching { hello() }
似たような関数としてはletがありますが、こちらはレシーバーではなく引数として受け取る形になっています。
public inline fun <T, R> T.let(block: (T) -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return block(this)
}
val person = Person("taro")
person.let { p -> p.hello() }
runCatchingでは block: T.() -> R
だったものが、 letでは block: (T) -> R
になっていることを注意してください(※比較のためジェネリクス名を揃えています)。
差分としては微々たるものですが、レシーバーを受け取るタイプの場合block: T.() -> R
は以下のように関数を丸ごと渡せることが便利かもしれないです。
class Person(
val name: String
) {
fun hello() {
return "Hello, I am ${name}"
}
}
val person = Person("taro")
// Person::hello をそのまま渡す。
val result: Result<String, Throwable> = person.runCatching(Person::hello)
そう考えると Person::hello という関数の型は Person.() -> String
と表すことが可能ですね。
// 代入可能
val personHello: Person.() -> String = Person::hello
余談ですが、この新しく定義した personHello は拡張関数のように振る舞うことが可能です。
val personHello: Person.() -> String = Person::hello
val person = Person("taro")
person.personHello()
めちゃくちゃマニアックな話ではありますが、内部構造を理解する上では面白いですね。
infix
とてもマイナー記法かと思いますが、infixはドットを省略してメソッド呼び出しできる記法です。
ちょっとだけオシャレに書けます。
public inline infix fun <T, V> T.runCatching(block: T.() -> V): Result<V, Throwable>
// .ドットを省略
1 runCatching { doSomething }
getのコードリーディング
mapやflatMapなどいろいろ面白いのはあるのですが、最後はgetを読んでいきます。
getはOk型なら成功値のvalueをNullableで返します。
val result: Result<String, Throwable> = runCatching { "a" }
val value: String? = result.get()
getはResult型の拡張関数として定義されています。
public fun <V, E> Result<V, E>.get(): V? {
contract {
returnsNotNull() implies (this@get is Ok<V>)
returns(null) implies (this@get is Err<E>)
}
return when (this) {
is Ok -> value
is Err -> null
}
}
型判定でOk型ならvalueを返していますね。そしてErrならnullを返しています。
implies contract
またでました。contractです。
public fun <V, E> Result<V, E>.get(): V? {
contract {
returnsNotNull() implies (this@get is Ok<V>)
returns(null) implies (this@get is Err<E>)
}
// 中略
}
一つ目から見ていきます。
returnsNotNull() implies (this@get is Ok<V>)
こちらは「getがnullを返さない場合、getのレシーバーは Ok<V>
型である」という意味です。
Ok型にSmart Castができるということですね。
this@getもあまり聞きなれない記法ですね。公式ドキュメントを貼っておきます。
Ok型にSmart Castができるということで以下のようなことができます。
class Person(
val name
)
val result = runCatching { Person("taro") }
if (result.get() != null) {
println(result.value.name) // Smart Castで型が確定している
}
if節のなかでresultを明示的にキャストせず、 Ok<Person>
型として扱うことができます。
しかし現状のKotlinでは以下のように一度変数に受けるとSmart Castがうまく作動しないようです。
この辺も後々対応されていくような感じはします(感じがするだけ)。
val result = runCatching { Person("taro") }
val person: Person? = result.get()
if (person != null) {
println(result.value.name)
// コンパイルエラー
}
次はこちらのcontractですね。
returns(null) implies (this@get is Err<E>)
こちらは「getがnullを返す場合、getのレシーバーは Err<E>
型である」という意味です。
先ほどと逆ですね。
そのためこのようなことができます。
val result = runCatching { Person("taro") }
if (result.get() == null) {
println(result.error) // Smart Castで型が確定している
}
implies contractについて分からなかったこと
上記のようなSmart Castは同じモジュール内ならできたのですが、なぜか別モジュールとしてインポートするとコンパイラがうまく動作しませんでした。
kotlin-resultをcloneして同モジュール内でgetを使うとSmart Castが作動するが、外部ライブラリとしてインポートすると作動しないということですね。
import com.github.michaelbull.result.runCatching
val result = runCatching { Person("taro") }
if (result.get() != null) {
println(result.value.name)
// なぜかコンパイルエラーになる
}
impliesについてはExperimentalContractsだからか?と思ったのですが、公式ドキュメントにはそのようなことは記載されていませんでした。
Any usage of a declaration annotated with @ExperimentalContracts must be accepted either by annotating that usage with the OptIn annotation, e.g. @OptIn(ExperimentalContracts::class), or by using the compiler argument -opt-in=kotlin.contracts.ExperimentalContracts.
と書いているので、OptInを記述してコンパイラを走らせたりしたのですがうまくいかず。。Stableになったらまた試してみます。
ちなみに上述の callsInPlace contract については問題なく動作しました。
どなたか詳しい方いれば是非お教えください、、、!
おわり
今回のOSS解説はこのへんで終わりにします。
他にも面白い関数があるのでぜひみてみてください。
kotlin-resultについては今回解説したところを抑えておけば基本読み進められると思います。
これにて 株式会社ログラスDevアドベントカレンダー2022の12/3(土) の記事は終了です。
明日はログラス開発組織のフルスタック大エースエンジニアの @urmot の記事です!お楽しみに!
Discussion