こんにちは。コミュニケーションアプリ「LINE」のモバイルクライアントを開発している石川です。
この記事は、毎週木曜の定期連載 "Weekly Report" 共有の第 62 回です。 LINEヤフー社内には、高い開発生産性を維持するための Review Committee という活動があります。ここで集まった知見を、Weekly Report と称して毎週社内に共有しており、その一部を本ブログ上でも公開しています。(Weekly Report の詳細については、過去の記事一覧を参照してください)
二匹目の close
以下のコードは、あるリソースを管理するためのクラスです。リソース の使用後は close
を呼ぶことでリソースの開放をすることを期待しています。close
を呼び出したときにリソースがすでに解放済みだった場合は、error
によってランタイムエラーが投げられます。
class FooResource {
var isClosed = false
private set()
...
fun close() {
if (isClosed) {
error(...)
}
... // Release resources
isClosed = true
}
}
このコードに何か改善点はありますか?
同じことを聞く
上記のコードで close
を安全に呼ぶためには、isClosed
の確認が必要になります。
if (!fooResource.isClosed) {
fooResource.close()
}
この close
を呼ぶ箇所が増えてくると、isClosed
のチェックを漏らしてしまい、意図せずランタイムエラーを発生させてしまう可能性があります。
FooResource.close
に関しては、Java や Kotlin の AutoCloseable
と同様に 冪等 (idempotent) にすると良いでしょう。冪等とは、1 回呼び出した結果と、2 回以上呼び出した結果が同じになるという性質です。以下の実装では、close
を 1 回呼び出した場合と、close
を 2 回以上呼び出した場合で、結果は等しく「リソースをリリースし、isClosed を true にする」というものになります。
class FooResource {
var isClosed = false
private set()
...
fun close() {
if (isClosed) {
return
}
... // Release resources
isClosed = true
}
}
関数が冪等性 (idempotency) を満たすことで、 以下のような利点を享受できる可能性があります。
- 関数を呼び出す前に状態チェックを行う必要がなくなる
- 関数呼び出し後の状態が予測しやすくなる
- 「初期状態」が存在することを隠蔽できる
この内、「関数を呼び出す前に状態チェックを行う必要がなくなる」と「関数呼び出し後の状態が予測しやすくなる」という利点は、冪等でない関数にも応用できます。エラーとなる呼び出しで、「例外を使う代わりに、何もしない」という動作にするだけで良いです。
戻る場合でも
状態遷移が巡回する場合でも、「状態チェックを行う必要がなくなる」と「関数呼び出し後の状態が予測しやすくなる」の 2 つの利点を活かすことができます。以下の Animation
クラスには "running" と"not-running" の 2 つの状態があり、それぞれ setRunning(true)
と setRunning(false)
を呼ぶことで相互に遷移可能です。このとき、すでに "running"/"not-running" であるときに setRunning(true)
/setRunning(false)
を呼び出した場合の動作は、「何もしない」になります。
class Animation {
private var isRunning = false
fun setRunning(isRunning: Boolean) {
if (this.isRunning == isRunning) {
return
}
this.isRunning = isRunning
...
}
}
状態遷移は次のようになります。
この状態遷移では、setRunning(true)
と setRunning(false)
をそれぞれ別の関数とみなせば、冪等な関数と言うことができます。
このように、すでに遷移済みの場合は何もしないという挙動にすることで、複数のイベントハンドラが setRunning
を呼び出す場合に、コードを単純にしやすくなります。現在の状態にかかわらず、setRunning
の引数によって最新の状態が決定されるということが保証されているためです。
3つの状態でも
この考え方は、2 つの関数をもつ 3 状態の場合にも適用できます。代表的な例として、次の 2 つを紹介します。
- 特定の関数が他の関数よりも優先される場合
- 最初に呼び出された関数が他の関数よりも優先される場合
特定の関数が他の関数よりも優先される場合
以下の Task
には start
と finish
という関数があり、それぞれ "RUNNING" と "FINISHED" に遷移します。ただし、start
と finish
が両方とも呼ばれたならば、順序にかかわらず finish
が優先されるという仕様です。
class Task(...) {
private var state: State = State.INITIAL
fun start() {
if (state != State.INITIAL) {
return
}
state = State.RUNNING
...
}
fun finish() {
if (state == FINISHED) {
return
}
state = State.FINISHED
...
}
enum class State { INITIAL, RUNNING, FINISHED }
}
状態遷移は次の ようになります。
これは、見方を変えると "INITIAL" と "RUNNING" が大きな 1 つの状態を構成し、finish
を呼ぶことでそれらから "FINISHED" に移行するとみなせます。このような解釈のもとでは、finish
は冪等な関数と言えるでしょう。
逆に、"RUNNING" と "FINISHED" を 1 つの状態にまとめて見ることで、start
も冪等な関数とみなすことができます。
どちらかの状態遷移が優先し、かつ、それ以外の呼び出しは現状を維持することで、リソースやタスクのライフサイクル管理を容易にする事ができます。
最初に呼び出された関数が他の関数よりも優先される場合
以下の Task
では、finish
で正常終了する可能性と cancel
でキャンセルされる可能性があります。先程の例とは異なり、finish
と cancel
はどちらが優先されるかはあらかじめ決まっておらず、先に呼び出されたほうが優先されるというものになります。
class Task(...) {
private var state: State = State.RUNNING
fun finish() {
if (state != State.RUNNING) {
return
}
state = State.FINISHED
...
}
fun cancel() {
if (state != State.RUNNING) {
return
}
state = State.CANCELLED
...
}
enum class State { RUNNING, FINISHED, CANCELLED }
}
そして、状態遷移は次のようになります。
ここで、"FINISHED" と "CANCELLED" 1 つの大きな状態とし、finish
と cancel
の関数も 1 つの関数とみなしてしまえば、そのまとめられた関数は冪等であると言えます。
このような遷移設計は、特に異なる呼び出し元が非同期に finish
や cancel
を呼び出す状況で有効です。たとえば、クエリの実行が完了されたときに finish
が呼ばれるが、ユーザによっていつでも非同期に cancel
される可能性がある場合、finish
や cancel
を呼び出した時点でもう一方がすでに呼ばれている可能性があります。そのような状況でも、現在の状態を確認せずとも関数を安全に呼べるようにすることで、呼び出し元のコードを単純にできます。
一言まとめ
遷移済みのときに例外を投げる代わりに、何もしないという動作にすることで、コードを頑健にできる可能性がある。
キーワード: idempotency
, state transition
, error handling