Рекурсивний виклик методу викликає StackOverFlowError в kotlin, але не в Java


14

У мене є два майже однакових коду в java та kotlin

Java:

public void reverseString(char[] s) {
    helper(s, 0, s.length - 1);
}

public void helper(char[] s, int left, int right) {
    if (left >= right) return;
    char tmp = s[left];
    s[left++] = s[right];
    s[right--] = tmp;
    helper(s, left, right);
}

Котлін:

fun reverseString(s: CharArray): Unit {
    helper(0, s.lastIndex, s)
}

fun helper(i: Int, j: Int, s: CharArray) {
    if (i >= j) {
        return
    }
    val t = s[j]
    s[j] = s[i]
    s[i] = t
    helper(i + 1, j - 1, s)
}

Код Java проходить тест з величезним введенням, але код kotlin викликає, StackOverFlowErrorякщо я не додав tailrecключове слово перед helperфункцією в kotlin.

Я хочу знати, чому ця функція працює в java, а також в kolin з, tailrecале не в kotlin без tailrec?

PS: Я знаю, що tailrecробити


1
Коли я тестував їх, я виявив, що версія Java працює для розмірів масивів приблизно до 29500, але версія Котліна зупинятиметься близько 18500. Це суттєва різниця, але не дуже велика. Якщо вам потрібно це для великих масивів, єдиним хорошим рішенням є використання tailrecабо уникнення рекурсії; доступний розмір стека залежить від прогонів, між JVM і налаштуваннями, і залежно від методу та його параметрів. Але якщо ви просите з чистої цікавості (цілком поважна причина!), То я не впевнений. Вам, мабуть, доведеться подивитися на байт-код.
gidds

Відповіді:


7

Я хочу знати, чому ця функція працює в java, а також в kotlin з, tailrecале не в kotlin без tailrec?

Коротка відповідь полягає в тому, що ваш метод Котліна "важчий", ніж метод JAVA . На кожен дзвінок він називає інший метод, який "провокує" StackOverflowError. Отже, докладніше пояснення див. Нижче.

Еквіваленти байт-коду Java для reverseString()

Я перевірив байт-код на ваші методи у Котліні та JAVA відповідно:

Байт-код методу Котліна в JAVA

...
public final void reverseString(@NotNull char[] s) {
    Intrinsics.checkParameterIsNotNull(s, "s");
    this.helper(0, ArraysKt.getLastIndex(s), s);
}

public final void helper(int i, int j, @NotNull char[] s) {
    Intrinsics.checkParameterIsNotNull(s, "s");
    if (i < j) {
        char t = s[j];
        s[j] = s[i];
        s[i] = t;
        this.helper(i + 1, j - 1, s);
    }
}
...

Байт-код методу JAVA в JAVA

...
public void reverseString(char[] s) {
    this.helper(s, 0, s.length - 1);
}

public void helper(char[] s, int left, int right) {
    if (left < right) {
        char temp = s[left];
        s[left++] = s[right];
        s[right--] = temp;
        this.helper(left, right, s);
    }
}
...

Отже, є 2 основні відмінності:

  1. Intrinsics.checkParameterIsNotNull(s, "s")викликається для кожного helper()у версії Котліна .
  2. Ліві та праві індекси методу JAVA збільшуються, тоді як у Котліні створюються нові індекси для кожного рекурсивного виклику.

Отже, давайте перевіримо, як Intrinsics.checkParameterIsNotNull(s, "s")поодинці впливає на поведінку.

Перевірте обидві реалізації

Я створив простий тест для обох випадків:

@Test
public void testJavaImplementation() {
    char[] chars = new char[20000];
    new Example().reverseString(chars);
}

І

@Test
fun testKotlinImplementation() {
    val chars = CharArray(20000)
    Example().reverseString(chars)
}

Для JAVA тест вдався без проблем, тоді як для Котліна він зазнав невдач через a StackOverflowError. Однак після того, як я додав Intrinsics.checkParameterIsNotNull(s, "s")до методу JAVA, він також не вдався:

public void helper(char[] s, int left, int right) {
    Intrinsics.checkParameterIsNotNull(s, "s"); // add the same call here

    if (left >= right) return;
    char tmp = s[left];
    s[left] = s[right];
    s[right] = tmp;
    helper(s, left + 1, right - 1);
}

Висновок

Ваш метод Котліна має меншу глибину рекурсії, оскільки він викликає Intrinsics.checkParameterIsNotNull(s, "s")на кожному кроці і, таким чином, важчий, ніж його аналог JAVA . Якщо ви не хочете цього автоматично створеного методу, ви можете відключити нульові перевірки під час компіляції, як тут відповіли

Однак, оскільки ви розумієте, що tailrecприносить користь (перетворює ваш рекурсивний дзвінок в ітеративний), вам слід скористатися цією.


@ user207421 Для кожного виклику методу є власний фрейм стека, включаючи Intrinsics.checkParameterIsNotNull(...). Очевидно, що кожен такий кадр стека потребує певної кількості пам’яті (для LocalVariableTableстека та операнду тощо)
Анатолій

0

Котлін - це просто крихітний голод, що голодує (Int object params io int params). Окрім рішення, що підходить тут, ви можете усунути локальну змінну tempза допомогою xor-ing:

fun helper(i: Int, j: Int, s: CharArray) {
    if (i >= j) {
        return
    }               // i: a          j: b
    s[j] ^= s[i]    //               j: a^b
    s[i] ^= s[j]    // i: a^a^b == b
    s[j] ^= s[i]    //               j: a^b^b == a
    helper(i + 1, j - 1, s)
}

Не зовсім впевнений, чи працює це для видалення локальної змінної.

Також усунення j може зробити:

fun reverseString(s: CharArray): Unit {
    helper(0, s)
}

fun helper(i: Int, s: CharArray) {
    if (i >= s.lastIndex - i) {
        return
    }
    val t = s[s.lastIndex - i]
    s[s.lastIndex - i] = s[i]
    s[i] = t
    helper(i + 1, s)
}
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.