kotlin tips: 自己を参照される可能性のあるコールバックとか単一処理を複数のコールバックで扱うことについて

※とりあえず五月雨式にメモしておく。結論はまだない。

戻り値方式とクロージャ方式の例

下記の returnValue1.kt は、Model の関数が結果を戻り値で返しています。

//
// returnValue1.kt
//
package returnValue1

import returnValue1.AppendResult.Correct
import returnValue1.AppendResult.Wrong

interface Model {
    val isCompleted: Boolean
    fun append(c: Char): AppendResult
}

sealed class AppendResult {
    class Correct(val isComplete: Boolean) : AppendResult()
    class Wrong : AppendResult()
}

class ModelImpl : Model {
    override var isCompleted = false
        private set
    private val inputLetters = StringBuilder()
    
    override fun append(c: Char): AppendResult {
        return when (c) {
            in '0'..'9' -> {
                inputLetters.append(c)
                Correct((inputLetters.length == 2).apply { isCompleted = this })
            }
            else        -> {
                Wrong()
            }
        }
    }
}

object view {
    fun onCorrectLetter(model: ModelImpl) {
        println("『ピッ!』")
    }
    
    fun onWrongLetter(model: ModelImpl) {
        println("『ブブーッ!』")
    }
    
    fun onComplete(model: ModelImpl) {
        println("『ピロリロリン!』[isCompleted = ${model.isCompleted}]")
    }
}

// controller
fun main(args: Array<String>) {
    
    val model = ModelImpl()
    
    fun append(c: Char) {
        with(model.append(c)) {
            when (this) {
                is Correct -> {
                    view.onCorrectLetter(model)
                    if (isComplete) view.onComplete(model)
                }
                is Wrong   -> view.onWrongLetter(model)
            }
        }
    }
    
    append('1') // 数字なので正常受付。『ピッ!』と鳴る。
    append('a') // 数字じゃないのでエラーを表示。『ブブーッ!』っと鳴る。
    append('2') // 数字なので正常受付。『ピッ!』と鳴る。入力完了音が『ピロリロリン!』と鳴る。
}

実行結果

『ピッ!』
『ブブーッ!』
『ピッ!』
『ピロリロリン!』[isCompleted = true]

下記の closure1.kt は、Model 関数にクロージャが渡され、結果をそのクロージャ経由で通知しています。

結果毎の処理を Model 側でキックしてくれるので、controller 側に条件分岐が必要無くなっています。

//
// closure1.kt
//
package closure1

interface Model {
    val isCompleted: Boolean
    fun append(c: Char, onCorrectLetter: (model: ModelImpl) -> Unit, onWrongLetter: (model: ModelImpl) -> Unit, onComplete: (model: ModelImpl) -> Unit)
}

class ModelImpl : Model {
    override var isCompleted = false
        private set
    private val inputLetters = StringBuilder()
    
    override fun append(c: Char, onCorrectLetter: (model: ModelImpl) -> Unit, onWrongLetter: (model: ModelImpl) -> Unit, onComplete: (model: ModelImpl) -> Unit) {
        when (c) {
            in '0'..'9' -> {
                inputLetters.append(c)
                onCorrectLetter(this)
                if (inputLetters.length == 2) {
                    isCompleted = true
                    onComplete(this)
                }
            }
            else        -> {
                onWrongLetter(this)
            }
        }
    }
}

object view {
    fun onCorrectLetter(model: ModelImpl) {
        println("『ピッ!』")
    }
    
    fun onWrongLetter(model: ModelImpl) {
        println("『ブブーッ!』")
    }
    
    fun onComplete(model: ModelImpl) {
        println("『ピロリロリン!』[isCompleted = ${model.isCompleted}]")
    }
}

// controller
fun main(args: Array<String>) {
    
    val model = ModelImpl()
    
    model.append('1', view::onCorrectLetter, view::onWrongLetter, view::onComplete) // 数字なので正常受付。『ピッ!』と鳴る。
    model.append('a', view::onCorrectLetter, view::onWrongLetter, view::onComplete) // 数字じゃないのでエラーを表示。『ブブーッ!』っと鳴る。
    model.append('2', view::onCorrectLetter, view::onWrongLetter, view::onComplete) // 数字なので正常受付。『ピッ!』と鳴る。入力完了音が『ピロリロリン!』と鳴る。
}

実行結果

『ピッ!』
『ブブーッ!』
『ピッ!』
『ピロリロリン!』[isCompleted = true]

下記の closure2.kt は、Model にあらかじめクロージャが渡され、関数が結果をそのクロージャ経由で通知しています。

関数呼び出し毎に異なるクロージャを渡す必要がない場合はこちらのほうがさらにシンプルです。

//
// closure2.kt
//
package closure2

interface Model {
    val isCompleted: Boolean
    fun append(c: Char)
}

class ModelImpl(val onCorrectLetter: (model: ModelImpl) -> Unit, val onWrongLetter: (model: ModelImpl) -> Unit, val onComplete: (model: ModelImpl) -> Unit) : Model {
    override var isCompleted = false
        private set
    private val inputLetters = StringBuilder()
    
    override fun append(c: Char) {
        when (c) {
            in '0'..'9' -> {
                inputLetters.append(c)
                onCorrectLetter(this)
                if (inputLetters.length == 2) {
                    onComplete(this)
                    isCompleted = true
                }
            }
            else        -> {
                onWrongLetter(this)
            }
        }
    }
}

object view {
    fun onCorrectLetter(model: ModelImpl) {
        println("『ピッ!』")
    }
    
    fun onWrongLetter(model: ModelImpl) {
        println("『ブブーッ!』")
    }
    
    fun onComplete(model: ModelImpl) {
        println("『ピロリロリン!』[isCompleted = ${model.isCompleted}]")
    }
}

// controller
fun main(args: Array<String>) {
    
    val model = ModelImpl(view::onCorrectLetter, view::onWrongLetter, view::onComplete)
    
    model.append('1') // 数字なので正常受付。『ピッ!』と鳴る。
    model.append('a') // 数字じゃないのでエラーを表示。『ブブーッ!』っと鳴る。
    model.append('2') // 数字なので正常受付。『ピッ!』と鳴る。入力完了音が『ピロリロリン!』と鳴る。
}

クロージャ方式にてバグを埋め込む可能性のある例

closure1.kt と closure2.kt については、Model が受け取るコールバック内部から自身が参照される場合、バグを埋め込む可能性があります。
具体的には、以下のようにコードを変更すると結果表示が変わってしまいます。

// 正しい順序
inputLetters.append(c)
onCorrectLetter(this)
if (inputLetters.length == 2) {
    isCompleted = true           // isCompleted を true に変更してから onComplete(this) を呼び出している
    onComplete(this)
}

// 順序を変更してバグを埋め込む例
inputLetters.append(c)
onCorrectLetter(this)
if (inputLetters.length == 2) {
    onComplete(this)             // この時点では isCompleted を true に変更する前に onComplete(this) を呼び出している
    isCompleted = true
}

実行結果

『ピッ!』
『ブブーッ!』
『ピッ!』
『ピロリロリン!』[isCompleted = false]


returnValue1.kt のように戻り値を返す方式の場合、間違えようとしても間違えようがないので、コールバック方式よりも戻り値方式のほうが保守性が高そうです。

要件変更への耐性

余談になりますが、要件変更への耐性の例として、以下の要件変更を考えてみる。

  • 完了時は『ピッ!』という音を出さずに『ピロリロリン!』だけを鳴らす。

戻り値方式の場合の変更点

1行削除でOK。

when (this) {
    is Correct -> {
        view.onCorrectLetter(model)             // ← この1行を削除すればOK
        if (isComplete) view.onComplete(model)
    }
    is Wrong   -> view.onWrongLetter(model)
}

コールバック方式の場合

controller と model の両方に変更が発生する例。

//
// closure3.kt
//
package closure3

interface Model {
    val isCompleted: Boolean
    fun append(c: Char)
}

// 変更前: class ModelImpl(val onCorrectLetter: (model: ModelImpl) -> Unit, val onWrongLetter: (model: ModelImpl) -> Unit, val onComplete: (model: ModelImpl) -> Unit) : Model {
class ModelImpl(val onCorrectLetter: (isCompleted: Boolean, model: ModelImpl) -> Unit, val onWrongLetter: (model: ModelImpl) -> Unit) : Model {
    override var isCompleted = false
        private set
    private val inputLetters = StringBuilder()
    
    override fun append(c: Char) {
        when (c) {
            in '0'..'9' -> {
                inputLetters.append(c)
    
                // 変更前
                // onCorrectLetter(this)
                // if (inputLetters.length == 2) {
                //     onComplete(this)
                //     isCompleted = true
                // }
                
                // 変更後
                onCorrectLetter((inputLetters.length == 2).apply { isCompleted = this }, this)
            }
            else        -> {
                onWrongLetter(this)
            }
        }
    }
}

object view {
    fun onCorrectLetter(model: ModelImpl) {
        println("『ピッ!』")
    }
    
    fun onWrongLetter(model: ModelImpl) {
  k      println("『ブブーッ!』")
    }
    
    fun onComplete(model: ModelImpl) {
        println("『ピロリロリン!』[isCompleted = ${model.isCompleted}]")
    }
}

// controller
fun main(args: Array<String>) {
    
    // 変更前
    // val model = ModelImpl(view::onCorrectLetter, view::onWrongLetter, view::onComplete)
    
    // 変更後
    val model = ModelImpl(
            { isCompleted, model ->
                when (isCompleted) {
                    true  -> view.onComplete(model)
                    false -> view.onCorrectLetter(model)
                }
            },
            view::onWrongLetter
    )
    
    model.append('1') // 数字なので正常受付。『ピッ!』と鳴る。
    model.append('a') // 数字じゃないのでエラーを表示。『ブブーッ!』っと鳴る。
    model.append('2') // 数字なので正常受付。入力完了音が『ピロリロリン!』と鳴る。
}


controller 側のみに変更を局所化することも一応可能ではあるという例。
フラグ立てまくりでカオスwww

//
// closure4.kt
//
package closure4

interface Model {
    val isCompleted: Boolean
    fun append(c: Char)
}

class ModelImpl(val onCorrectLetter: (model: ModelImpl) -> Unit, val onWrongLetter: (model: ModelImpl) -> Unit, val onComplete: (model: ModelImpl) -> Unit) : Model {
    override var isCompleted = false
        private set
    private val inputLetters = StringBuilder()
    
    override fun append(c: Char) {
        when (c) {
            in '0'..'9' -> {
                inputLetters.append(c)
                onCorrectLetter(this)
                if (inputLetters.length == 2) {
                    onComplete(this)
                    isCompleted = true
                }
            }
            else        -> {
                onWrongLetter(this)
            }
        }
    }
}

object view {
    fun onCorrectLetter(model: Model) {
        println("『ピッ!』")
    }
    
    fun onWrongLetter(model: Model) {
        println("『ブブーッ!』")
    }
    
    fun onComplete(model: Model) {
        println("『ピロリロリン!』[isCompleted = ${model.isCompleted}]")
    }
}

// controller
fun main(args: Array<String>) {
    
    var isCorrectLetter = false
    fun onCorrectLetter(model: Model) {
        isCorrectLetter = true
    }
    
    var isComplete = false
    fun onComplete(model: Model) {
        isComplete = true
    }
    
    val model = ModelImpl(::onCorrectLetter, view::onWrongLetter, ::onComplete)
    
    fun append(c: Char) {
        model.append(c)
        if (isCorrectLetter) {
            if (isComplete) view.onComplete(model) else view.onCorrectLetter(model)
        }
        isCorrectLetter = false
        isComplete = false
    }
    
    append('1') // 数字なので正常受付。『ピッ!』と鳴る。
    append('a') // 数字じゃないのでエラーを表示。『ブブーッ!』っと鳴る。
    append('2') // 数字なので正常受付。入力完了音が『ピロリロリン!』と鳴る。
}

考察

なんか歯切れのよい結論が出せないのだけれども、戻り値方式でもクロージャ方式でも、同じ情報を扱えるので数学的には等価だと思われ。しかし、戻り値で対応できるケースをクロージャ渡しで代用するのは結構 error prone かつ仕様変更に弱い気がする。呼び出しが無限ループするバグを埋め込んだりも結構アリがちだし、、、w

機械的な判断基準が欲しいですね。そうすればビルド時に自動で検出できるし、、、。

ここら辺を論じている書籍や論文など知ってる人よろしくです m(_ _)m