为何给Kotlin中错误的corecursion函数添加tailrec修饰后能正确运行?运行与调试结果不一致的原因是什么?
这问题挺有意思的,我来帮你捋清楚背后的核心逻辑——答案其实藏在整数溢出里,调试和运行的差异也和它直接相关!
先还原你遇到的场景:你在《Kotlin之乐》第四章看到的尾递归加法实现是这样的:
tailrec fun add(a: Int, b: Int): Int = if (b == 0) a else add(inc(a), dec(b)) fun inc(n: Int) = n + 1 fun dec(n: Int) = n - 1
你发现add(3, -3)能正确返回0,但去掉tailrec修饰符就会栈溢出;而且按逻辑推导,当b为负数时,每次dec(b)会让b越来越小,根本不可能到0,应该无限循环才对,但实际运行却得到了正确结果,调试时又真的陷入了“无限循环”,这到底是咋回事?
1. 原代码的“正确结果”是依赖Int溢出的巧合
Kotlin的Int是32位有符号整数,范围是-2147483648到2147483647。当传入b=-3时,代码会不断执行dec(b)(也就是b-1):
- 从
-3开始,一步步变成-4、-5……直到降到-2147483648(Int的最小值) - 这时候再执行
dec(-2147483648),就会触发整数溢出,数值直接跳转到2147483647(Int的最大值) - 接下来
b就从最大值开始一步步dec到0,这个过程中a也一直在inc,同样会触发溢出,最终刚好抵消得到正确的0
运行时能快速得到结果,是因为这个溢出循环的总次数是固定的(2^32次),现代CPU全速执行的话,眨眼就能跑完这个循环。
2. 调试和运行的差异原因
调试时你是一步步断点执行,每次只走一次循环,要走完2^32次循环几乎不可能,所以你会觉得是无限循环;但实际运行时是全速执行,很快就完成了整个溢出循环流程,自然能得到结果。
3. 为啥去掉tailrec会栈溢出?
没有tailrec修饰时,Kotlin不会把递归转换成循环,每次递归调用都会在JVM栈上压入一个新的栈帧,2^32次调用早就超出了JVM的栈容量,直接触发栈溢出错误。而添加tailrec后,编译器会把递归逻辑转成你反编译看到的while循环,循环不会占用额外栈空间,所以能顺利执行完所有溢出循环。
4. 你写的实现才是逻辑上的正确版本
你自己给出的分正负处理的代码:
tailrec fun add(a: Int, b: Int): Int = if (b == 0) a else if (b > 0) add(inc(a), dec(b)) else add(dec(a), inc(b))
这才是真正合理的实现——它不依赖整数溢出,而是根据b的正负来决定对a进行inc还是dec,从根本上避免了溢出循环的问题。你用Long类型验证的代码也能证明这一点:Long的范围大得多,溢出需要的次数极多,运行时会真的陷入“无限”循环(实际是要跑很久),这就说明原代码的“正确结果”完全是依赖Int溢出的巧合。
备注:内容来源于stack exchange,提问作者sharubhat

