递归方法在Kotlin中会导致StackOverFlowError,但在Java中不会。

14
我有两个几乎相同的Java和Kotlin代码。
Java:
```java // 这里是 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)
}

这段文字的意思是:Java 代码在输入大量数据时通过了测试,但 Kotlin 代码会导致 StackOverFlowError,除非我在 Kotlin 的 helper 函数前加上 tailrec 关键字。我想知道为什么这个函数在 Java 和带有 tailrec 的 Kotlin 中都可以工作,但在不带 tailrec 的 Kotlin 中却不行?补充说明:我知道 tailrec 的作用。

1
当我测试这些时,我发现Java版本适用于大约29500个数组大小,但Kotlin版本会在18500左右停止。 这是一个显着的差异,但不是非常大的差异。 如果您需要使其适用于大型数组,则唯一的好方法是使用tailrec或避免递归; 可用堆栈大小在运行之间,JVM和设置之间以及取决于方法及其参数之间有所不同。 但是,如果您只是出于纯好奇心而问(完全可以理解!),那么我不确定。 您可能需要查看字节码。 - gidds
2个回答

7
我想知道为什么这个函数在Java中和带有tailrecKotlin中可以工作,但在没有tailrecKotlin中不能工作?
简短的答案是因为你的Kotlin方法比Java方法更“重”。 每次调用它都会调用另一个方法,从而引起StackOverflowError。 因此,请参见下面的详细说明。 reverseString()的Java字节码等效项 我分别检查了你的KotlinJava方法的字节码: Kotlin方法字节码(在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);
    }
}
...

所以,有两个主要区别:

  1. 在Kotlin版本中,对于每个helper(),都会调用Intrinsics.checkParameterIsNotNull(s, "s")
  2. 在JAVA方法中,左右索引会被递增,而在Kotlin中,为每个递归调用创建新的索引。

因此,让我们测试一下仅使用Intrinsics.checkParameterIsNotNull(s, "s")如何影响行为。

测试这两种实现

我为两种情况创建了一个简单的测试:

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

And

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

对于JAVA,测试没有问题,但是对于Kotlin,由于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);
}

结论

你的Kotlin方法的递归深度更小,因为它在每一步都调用Intrinsics.checkParameterIsNotNull(s, "s"),因此比其JAVA对应方法更重。如果你不想要这个自动生成的方法,可以在编译期间禁用空检查,如此 这里 中所述。

然而,既然你了解tailrec带来的好处(将递归调用转换为迭代调用),你应该使用它。


@user207421,每个方法调用都有自己的堆栈帧,包括 Intrinsics.checkParameterIsNotNull(...)。显然,每个这样的堆栈帧都需要一定量的内存(用于 LocalVariableTable 和操作数栈等)。 - Anatolii

1

Kotlin的堆栈需求略微更高(Int对象参数而不是int参数)。除了适合这里的tailrec解决方案外,您还可以通过异或来消除本地变量temp

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

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接