Kotlin случай неинтуитивного вывода типа - PullRequest
3 голосов
/ 19 марта 2020

Я обнаружил неинтуитивное поведение вывода типа. В результате семантически эквивалентный код работает по-разному, в зависимости от того, какую информацию компилятор получает о типе возвращаемого значения функции. Более или менее понятно, что происходит, когда вы воспроизводите этот случай в минимальном модульном тесте. Но я боюсь, что при написании кода платформы такое поведение может быть опасным.

Приведенный ниже код иллюстрирует проблему, и у меня возникают следующие вопросы:

  1. Почему puzzler1 колл от notok1 безоговорочно выбрасывает NPE? Насколько я понимаю из байт-кода, ACONST_NULL ATHROW выбрасывает NPE сразу после вызова puzzler1, игнорируя возвращаемое значение.

  2. Это нормально, что верхняя граница (<T : TestData>) игнорируется, когда компилятор выводит тип?

  3. Это ошибка, что NPE становится ClassCastException, если вы добавляете модификатор suspend к функции? Конечно, я понимаю, что runBlocking+suspend вызов дает нам другой байт-код, но не должен ли "сопрограммированный" код быть как можно более эквивалентным обычному коду?

  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)
    }
}

1 Ответ

4 голосов
/ 19 марта 2020

Какой тип throw RuntimeException("goodbye")? Ну, поскольку он никогда не возвращает значение, вы можете использовать его где угодно, независимо от того, какой тип объекта ожидается, и он всегда будет проверять тип. Мы говорим, что он имеет тип Nothing. Этот тип не имеет значений и является подтипом каждого типа. Следовательно, в notok1 у вас есть звонок на puzzler1<Nothing>. Приведение из сконструированного TestData в T = Nothing внутри puzzler1<Nothing> нецелесообразно, но не проверяется, и puzzler1 заканчивается возвратом, когда его сигнатура типа говорит, что не должна этого делать. notok1 замечает, что puzzler1 возвратился, когда сказал, что не сможет, и немедленно генерирует исключение. Это не очень наглядно, но я считаю, что причина, по которой он генерирует NPE, заключается в том, что что-то пошло «ужасно неправильно», если функция, которая не может вернуть, вернулась, поэтому язык решает, что программа должна выполнить d ie как можно быстрее.

Для notok2 вы действительно получаете T = TestData: одна ветвь if возвращает Nothing, другая TestData и LUB из них TestData (так как Nothing является подтипом TestData). notok2 не имеет оснований полагать, что puzzler1<TestData> не может вернуться, поэтому он не устанавливает ловушку на d ie, как только возвращается puzzler1.

notok3 имеет практически то же самое проблема как notok1. Тип возврата, Nothing, подразумевает, что единственное, что puzzler2<Nothing> сделает, это выдаст исключение. Таким образом, код обработки сопрограммы в notok3 ожидает, что сопрограмма будет содержать Throwable и содержит код для его повторного выброса, но не содержит кода для обработки фактического возвращаемого значения. Когда puzzler2 действительно возвращается, notok3 пытается преобразовать это TestData в Throwable и терпит неудачу. notok4 работает по той же причине, что и notok2.

Решение этого беспорядка - просто не использовать неправильное приведение. Иногда puzzler1<T> / puzzler2<T> сможет вернуть T, если переданная функция фактически возвращает T. Но, если эта функция выдает, они могут только вернуть TestData, а TestData - это , а не a T (T - это TestData, а не наоборот) , Правильная подпись для puzzler1 (и аналогично для puzzler2):

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

Поскольку функции являются ковариантными в типе возвращаемого значения, вы можете просто избавиться от параметра типа

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