Kotlin Coroutines Flowのcombine()とResult型の組み合わせ

Kotlin CoroutinesのFlowを使っていて複数のFlowをまとめるcombine 関数と Result型の組み合わせで躓いたメモ。

環境

Kotlin Coroutines: 1.3.7

combine関数について -> Asynchronous Flow - Kotlin Programming Language

Result型について -> Result - Kotlin Programming Language

combineの挙動
    flowOf(1, 2, 3).combine(flowOf("one", "two", "three")) { a, b ->
        "$a : $b"
    }.collect {
        println(it)
    }

上のように複数のFlowを組み合わせた時以下のように一方のFlowがemitする度に値が生成されることになる。

1 : one
2 : one
2 : two
3 : two
3 : three
Result型との組み合わせ

Result型と組み合わせた場合が期待した挙動と違った。

    val flow1 = flow<Result<String>> {
        emit(Result.success("flow1 success1"))
        emit(Result.failure(AssertionError("flow1 error1")))
        emit(Result.success("flow1 success2"))
        emit(Result.failure(AssertionError("flow1 error1")))
    }
    val flow2 = flow<Result<String>> {
        emit(Result.success("flow2 success1"))
        emit(Result.failure(AssertionError("flow2 error1")))
        emit(Result.success("flow2 success2"))
        emit(Result.failure(AssertionError("flow2 error1")))
    }

    flow1.combine(flow2) { result1, result2 ->
        "$result1 $result2"
    }.collect { println(it) }

Outputは以下でFailure型もSuccess型でwrapされているように見える。

Success(Success(flow1 success1)) Success(Success(flow2 success1))
Success(Failure(java.lang.AssertionError: flow1 error1)) Success(Success(flow2 success1))
Success(Failure(java.lang.AssertionError: flow1 error1)) Success(Failure(java.lang.AssertionError: flow2 error1))
Success(Success(flow1 success2)) Success(Failure(java.lang.AssertionError: flow2 error1))
Success(Success(flow1 success2)) Success(Success(flow2 success2))
Success(Failure(java.lang.AssertionError: flow1 error1)) Success(Success(flow2 success2))
Success(Failure(java.lang.AssertionError: flow1 error1)) Success(Failure(java.lang.AssertionError: flow2 error1))

例えば以下のようにどちらのflowもSuccessした時だけなにか処理をしたい場合もすべてSuccess扱いとなってしまう。

   flow1.combine(flow2) { result1, result2 ->
        if (result1.isSuccess && result2.isSuccess) {
            "$result1 + $result2 "
        } else {
            "Unit"
        }
    }.collect { println(it) }

また、実際Resultの中の値を取ろうとすると ClassCastException で落ちてしまう。

   flow1.combine(flow2) { result1, result2 ->
        "${result1.getOrNull()} ${result2.getOrNull()}"
    }.collect { println(it) }
java.lang.ClassCastException: kotlin.Result cannot be cast to java.lang.String
解決策

同じ問題のような現象でIssueもあがっているのでcombine関数のbugらしいもの。 https://youtrack.jetbrains.com/issue/KT-38937

combine関数で2つversionと3つversionを比べてみると3つの方はinlineがついている。

@JvmName("flowCombine")
public fun <T1, T2, R> Flow<T1>.combine(flow: Flow<T2>, transform: suspend (a: T1, b: T2) -> R): Flow<R> = flow {
    combineTransformInternal(this@combine, flow) { a, b ->
        emit(transform(a, b))
    }
}

public inline fun <reified T, R> combine(
    vararg flows: Flow<T>,
    crossinline transform: suspend (Array<T>) -> R
): Flow<R> = flow {
    combineInternal(flows, { arrayOfNulls(flows.size) }, { emit(transform(it)) })
}

これに習うと2つversionにinline化したものを自プロジェクトで拡張関数として宣言してそちらを使うことで一旦解決した。

inline fun <T1, T2, R> Flow<T1>.combineInline(flow2: Flow<T2>, crossinline transform: suspend (T1, T2) -> R): Flow<R> =
    combine(flow2) { t1, t2 -> transform(t1, t2) }

Flowの返り値が inline class によって起きているようなので、独自で作った(inline classでない) Result型もどきを使うでも解決する。

FYI: Result型の使用について

KEEP/result.md at master · Kotlin/KEEP · GitHub

Result型の使用について直接返り値として使えないなどの制限があるように Flow<Result<T>> の形で使うことも推奨されない形なのだろうか…