Chamada de método recursivo causa StackOverFlowError no kotlin, mas não no java

14

Eu tenho dois códigos quase idênticos em java e 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)
}

O código java passa no teste com uma entrada enorme, mas o código kotlin causa uma a StackOverFlowErrormenos que eu tenha adicionado uma tailrecpalavra-chave antes da helperfunção no kotlin.

Eu quero saber por que essa função funciona em java e também no kolin com tailrecmas sem no kotlin sem tailrec?

PS: eu sei o que tailrecfazer

hamid_c
fonte
11
Quando os testei, descobri que a versão Java funcionaria para tamanhos de matriz de até 29500, mas a versão Kotlin para por volta de 18500. Essa é uma diferença significativa, mas não muito grande. Se você precisar que isso funcione para grandes matrizes, a única boa solução é usar tailrecou evitar a recursão; o tamanho da pilha disponível varia entre execuções, entre JVMs e configurações e dependendo do método e de seus parâmetros. Mas se você está perguntando por pura curiosidade (uma razão perfeitamente boa!), Não tenho certeza. Você provavelmente precisaria olhar o código de bytes.
gidds

Respostas:

7

Eu quero saber por que essa função funciona em java e também no kotlin com tailrecmas não no kotlin sem tailrec?

A resposta curta é porque seu método Kotlin é "mais pesado" que o método JAVA . A cada chamada, chama outro método que "provoca" StackOverflowError. Então, veja uma explicação mais detalhada abaixo.

Equivalentes de bytecode Java para reverseString()

Eu verifiquei o código de bytes para seus métodos no Kotlin e JAVA correspondentemente:

Método de Kotlin bytecode em 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);
    }
}
...

Bytecode do método JAVA em 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);
    }
}
...

Então, existem 2 diferenças principais:

  1. Intrinsics.checkParameterIsNotNull(s, "s")é chamado para cada um helper()na versão Kotlin .
  2. Os índices esquerdo e direito no método JAVA são incrementados, enquanto no Kotlin novos índices são criados para cada chamada recursiva.

Então, vamos testar como Intrinsics.checkParameterIsNotNull(s, "s")sozinho afeta o comportamento.

Teste as duas implementações

Eu criei um teste simples para os dois casos:

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

E

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

Para JAVA, o teste foi bem-sucedido sem problemas, enquanto para Kotlin falhou miseravelmente devido a um StackOverflowError. No entanto, depois que adicionei Intrinsics.checkParameterIsNotNull(s, "s")ao método JAVA , ele também falhou:

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

Conclusão

Seu método Kotlin possui uma profundidade de recursão menor, pois invoca Intrinsics.checkParameterIsNotNull(s, "s")a cada passo e, portanto, é mais pesado que o equivalente em JAVA . Se você não quiser esse método gerado automaticamente, poderá desativar as verificações nulas durante a compilação, conforme respondidas aqui

No entanto, como você entende o que o benefício tailrectraz (converte sua chamada recursiva em iterativa), você deve usá-la.

Anatolii
fonte
@ user207421 toda chamada de método possui seu próprio quadro de pilha, inclusive Intrinsics.checkParameterIsNotNull(...). Obviamente, cada um desses quadros de pilha exige uma certa quantidade de memória (para a LocalVariableTablepilha e operando e assim por diante) ..
Anatolii
0

O Kotlin tem um pouco mais de fome de pilha (objetos de parâmetro int io parâmetros de int). Além da solução tailrec que se encaixa aqui, você pode eliminar a variável local tempxor-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)
}

Não tenho certeza se isso funciona para remover uma variável local.

A eliminação de j também pode fazer:

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
fonte