首页 > 解决方案 > 非直观类型推断的 Kotlin 案例

问题描述

我发现了一些类型推断的非直觉行为。因此,语义等效代码的工作方式不同,具体取决于编译器推断出的有关函数返回类型的信息。当您在最小单元测试中重现此案例时,或多或少清楚发生了什么。但我担心在编写框架代码时,这种行为可能会很危险。

下面的代码说明了这个问题,我的问题是:

  1. 为什么无条件的puzzler1调用notok1会抛出 NPE?据我从字节码了解,调用ACONST_NULL ATHROW后立即抛出 NPE puzzler1,忽略返回值。

  2. <T : TestData>编译器推断类型时忽略上限()是否正常?

  3. suspend如果您在函数中添加修饰符,NPE 变成 ClassCastException 是否是一个错误?当然,我知道runBlocking+suspendcall 给了我们不同的字节码,但是“coroutineized”代码不应该尽可能地等同于传统代码吗?

  4. 有没有办法以puzzler1某种方式重写代码,消除不清楚的地方?

@Suppress("UnnecessaryVariable", "MemberVisibilityCanBePrivate", "UNCHECKED_CAST", "RedundantSuspendModifier")
class PuzzlerTest {

    open class TestData(val value: String)

    lateinit var whiteboxResult: TestData

    fun <T : TestData> puzzler1(
        resultWrapper: (String) -> T
    ): T {
        val result = try {
            resultWrapper("hello")
        } catch (t: Throwable) {
            TestData(t.message!!) as T
        }
        whiteboxResult = result
        return result // will always return TestData type
    }

    // When the type of `puzzler1` is inferred to TestData, the code works as expected:
    @Test
    fun ok() {
        val a = puzzler1 { TestData("$it world") }
        // the same result inside `puzzler1` and outside of it:
        assertEquals("hello world", whiteboxResult.value)
        assertEquals("hello world", a.value)
    }

    // But when the type of `puzzler1` is not inferred to TestData, the result is rather unexpected.
    // And compiler ignores the upper bound <T : TestData>:
    @Test
    fun notok1() {
        val a = try {
            puzzler1 { throw RuntimeException("goodbye") }
        } catch (t: Throwable) {
            t
        }
        assertEquals("goodbye", whiteboxResult.value)
        assertTrue(a is NullPointerException) // this is strange
    }

    // The same code as above, but with enough information for the compiler to infer the type:
    @Test
    fun notok2() {
        val a = puzzler1 {
            @Suppress("ConstantConditionIf")
            if (true)
                throw RuntimeException("goodbye")
            else {
                // the type is inferred from here
                TestData("unreachable")

                // The same result if we write:
                // puzzler1<TestData> { throw RuntimeException("goodbye") }
            }
        }

        assertEquals("goodbye", whiteboxResult.value)
        assertEquals("goodbye", (a as? TestData)?.value) // this is stranger
    }

    // Now create the `puzzler2` which only difference from `puzzler1` is `suspend` modifier:

    suspend fun <T : TestData> puzzler2(
        resultWrapper: (String) -> T
    ): T {
        val result = try {
            resultWrapper("hello")
        } catch (t: Throwable) {
            TestData(t.message!!) as T
        }
        whiteboxResult = result
        return result
    }

    // Do exactly the same test as `notok1` and NullPointerException magically becomes ClassCastException:
    @Test
    fun notok3() = runBlocking {
        val a = try {
            puzzler2 { throw RuntimeException("goodbye") }
        } catch (t: Throwable) {
            t
        }
        assertEquals("goodbye", whiteboxResult.value)
        assertTrue(a is ClassCastException) // change to coroutines and NullPointerException becomes ClassCastException
    }

    // The "fix" is the same as `notok2` by providing the compiler with info to infer `puzzler2` return type:
    @Test
    fun notok4() = runBlocking {
        val a = try {
            puzzler2<TestData> { throw RuntimeException("goodbye") }

            // The same result if we write:
            // puzzler2 {
            //     @Suppress("ConstantConditionIf")
            //     if (true)
            //         throw RuntimeException("goodbye")
            //     else
            //         TestData("unreachable")
            // }
        } catch (t: Throwable) {
            t
        }
        assertEquals("goodbye", whiteboxResult.value)
        assertEquals("goodbye", (a as? TestData)?.value)
    }
}

标签: kotlinkotlin-coroutines

解决方案


是什么类型的throw RuntimeException("goodbye")?好吧,因为它从不返回值,所以你可以在任何你喜欢的地方使用它,不管期望什么类型的对象,它总是会进行类型检查。我们说它有 type Nothing。这种类型没有值,它是每种类型的子类型。因此,在 中notok1,您可以调用puzzler1<Nothing>。从构造TestDataT = Nothing内部的转换puzzler1<Nothing>是不健全但未经检查的,并且puzzler1当它的类型签名表明它不应该能够时最终返回。notok1注意到puzzler1当它说它不能返回时已经返回,并立即抛出异常。它的描述性不是很好,但我相信它抛出 NPE 的原因是因为如果一个无法返回的函数返回了,就会出现“严重错误”,所以语言决定程序应该尽快死掉。

对于notok2,您实际上确实得到T = TestData了: return 的一个分支,另一个,以及其中的ifLUB是(因为是 的子类型)。没有理由相信不能回来,所以它不设陷阱一回来就死。NothingTestDataTestDataNothingTestDatanotok2puzzler1<TestData>puzzler1

notok3与 有本质上相同的问题notok1。返回类型 ,Nothing意味着唯一要做的puzzler2<Nothing>就是抛出一个异常。因此,协程处理代码notok3期望协程保存 aThrowable并包含重新抛出它的代码,但不包含处理实际返回值的代码。当puzzler2实际返回时,notok3尝试将其TestData转换为 aThrowable并失败。notok4出于同样的原因notok2

解决这个烂摊子的办法就是不使用不健全的演员表。有时 puzzler1<T>/puzzler2<T>将能够返回 a T,如果传递的函数实际上返回 a T。但是,如果该函数抛出,它们只能返回 a TestData,并且 aTestData不是a (a是 a ,而不是相反)。(和类似的)的正确签名是TTTestDatapuzzler1puzzler2

fun <T : TestData> puzzler1(resultWrapper: (String) -> T): TestData

由于函数在返回类型中是协变的,因此您可以摆脱类型参数

fun puzzler1(resultWrapper: (String) -> TestData): TestData

推荐阅读