La llamada al método recursivo causa StackOverFlowError en kotlin pero no en java

14

Tengo dos códigos casi idénticos en java y 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);
}

Kotlin:

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

El código de Java pasa la prueba con una gran entrada, pero el código de Kotlin causa una, a StackOverFlowErrormenos que agregue una tailrecpalabra clave antes de la helperfunción en Kotlin.

Quiero saber por qué esta función funciona en java y también en kolin con tailrecpero sin kotlin sin tailrec?

PD: sé qué tailrechacer

hamid_c
fuente
1
Cuando probé estos, descubrí que la versión de Java funcionaría para tamaños de matriz de hasta alrededor de 29500, pero la versión de Kotlin se detendría alrededor de 18500. Esa es una diferencia significativa, pero no muy grande. Si necesita que esto funcione para grandes matrices, la única buena solución es usar tailreco evitar la recurrencia ; el tamaño de la pila disponible varía entre ejecuciones, entre JVM y configuraciones, y dependiendo del método y sus parámetros. Pero si lo preguntas por pura curiosidad (¡una razón perfectamente buena!), Entonces no estoy seguro. Probablemente necesite mirar el código de bytes.
gidds

Respuestas:

7

Quiero saber por qué esta función funciona en Java y también en Kotlin con tailrecpero no en Kotlin sin tailrec.

La respuesta corta es porque su método Kotlin es "más pesado" que el de JAVA . En cada llamada se llama a otro método que "provoca" StackOverflowError. Entonces, vea una explicación más detallada a continuación.

Java bytecode equivalentes para reverseString()

Verifiqué el código de byte para sus métodos en Kotlin y JAVA correspondientemente:

Código de bytes del método Kotlin en 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);
    }
}
...

Código de bytes del método JAVA en 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);
    }
}
...

Entonces, hay 2 diferencias principales:

  1. Intrinsics.checkParameterIsNotNull(s, "s")se invoca para cada uno helper()en la versión de Kotlin .
  2. Los índices izquierdo y derecho en el método JAVA se incrementan, mientras que en Kotlin se crean nuevos índices para cada llamada recursiva.

Entonces, probemos cómo Intrinsics.checkParameterIsNotNull(s, "s")solo afecta el comportamiento.

Probar ambas implementaciones

He creado una prueba simple para ambos casos:

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

Y

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

Para JAVA, la prueba tuvo éxito sin problemas, mientras que para Kotlin falló miserablemente debido a a StackOverflowError. Sin embargo, después de agregar Intrinsics.checkParameterIsNotNull(s, "s")al método JAVA , también falló:

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

Conclusión

Su método Kotlin tiene una profundidad de recursión menor, ya que invoca Intrinsics.checkParameterIsNotNull(s, "s")en cada paso y, por lo tanto, es más pesado que su contraparte JAVA . Si no desea este método generado automáticamente, puede deshabilitar las comprobaciones nulas durante la compilación como se responde aquí

Sin embargo, dado que comprende qué beneficio tailrectrae (convierte su llamada recursiva en una iterativa), debe usar esa.

Anatolii
fuente
@ user207421 cada invocación de método tiene su propio marco de pila incluido Intrinsics.checkParameterIsNotNull(...). Obviamente, cada uno de esos marcos de pila requiere una cierta cantidad de memoria (para la LocalVariableTablepila de operandos, etc.)
Anatolii
0

Kotlin es solo un poquito más hambriento (Int object params io int params). Además de la solución tailrec que se ajusta aquí, puede eliminar la variable local temphaciendo 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)
}

No estoy completamente seguro de si esto funciona para eliminar una variable local.

También eliminar j podría hacer:

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)
}
Joop Eggen
fuente