Java中的堆栈安全递归

stack_safe_recursion_in_Java_ebook 在本文中,摘自《 Java中的函数式编程 》一书,我解释了如何使用递归,同时避免了StackOverflow异常的风险。

Corecursion正在使用第一步的输出作为下一步的输入来构成计算步骤。 递归是相同的操作,但是从最后一步开始。 在这种情况下,我们必须延迟评估,直到遇到基本条件(与corecursion的第一步相对应)为止。

假设我们的编程语言中只有两条指令:递增(向值加1)和递减(从值中减去1)。 让我们通过编写这些指令来实现加法。

Corecursive和递归加法示例

为了将两个数字x和y相加,我们可以执行以下操作:

  • 如果y == 0 ,则返回x
  • 否则,递增x ,递减y ,然后重新开始。

这可以用Java编写为:

static int add(int x, int y) {
  while(y > 0) {
    x = ++x;
    y = --y;
  }
  return x;
}

或更简单:

static int add(int x, int y) {
  while(y-- > 0) {
    x = ++x;
  }
  return x;
}

注意,直接使用参数xy没问题,因为在Java中,所有参数都是按值传递的。 另请注意,我们已使用后减量来简化编码。 但是,我们可以通过稍微改变条件来使用预减量,从而将形式从y迭代为1到将y‑1迭代为0

static int add(int x, int y) {
  while(--y >= 0) {
    x = ++x;
  }
  return x;
}

递归版本比较棘手,但仍然非常简单:

static int addRec(int x, int y) {
  return y == 0
      ? x
      : addRec(++x, --y);
}

两种方法似乎都可行,但是如果我们尝试使用大量的递归版本,可能会感到惊讶。 虽然

addRec(10000, 3);

切换参数,产生预期结果10003,如下所示:

addRec(3, 10000);

产生一个StackOverflowException

如何用Java实现递归?

要了解正在发生的事情,我们必须查看Java如何处理方法调用。 调用方法时,Java会挂起当前正在执行的操作,并将环境压入堆栈以为调用的方法执行留出空间。 当此方法返回时,Java弹出堆栈以恢复环境并恢复程序执行。 如果我们依次调用一个方法,则堆栈将始终保存这些方法调用环境中的至少一个。

但是方法不仅是通过一个接一个地调用它们而构成的。 方法调用方法。 如果method1作为其实现的一部分调用method2 ,则Java会再次挂起method1执行,将当前环境压入stack ,然后开始执行method2 。 当method2返回时,Java从堆栈中弹出最后推送的环境并恢复执行(在本例中为method1 )。 当method1完毕后,Java从栈中弹出一次,并恢复它在调用此方法之前做的事情。

当然,方法调用可能嵌套得很深。 方法嵌套深度是否有限制? 是。 限制是堆栈的大小。 在当前情况下,该限制约为几千个级别,尽管可以通过配置堆栈大小来增加此限制。 但是,所有线程都使用相同的堆栈大小,因此增加单个计算的堆栈大小通常会浪费空间。 默认堆栈大小在320k和1024k之间变化,具体取决于Java版本和所使用的系统。 对于具有最小堆栈使用率的64位Java 8程序,嵌套方法调用的最大数量约为7000。通常,除了非常特殊的情况外,我们不需要更多的嵌套方法调用。 一种这样的情况是递归方法调用。

消除尾调用(TCE)似乎有必要将环境推送到堆栈上,以便允许在被调用方法返回后恢复计算。 但不总是。 如果对方法的调用是调用方法中的最后一件事,则返回时没有任何恢复操作,因此可以直接与当前方法的调用者而不是当前方法本身一起恢复。 在最后一个位置发生的方法调用(即返回之前的最后一件事)称为tail call 。 避免在尾部调用之后将环境压入堆栈以恢复方法处理是一种称为尾部消除(TCE)的优化技术。 不幸的是,Java没有实现TCE。

消除尾声有时被称为尾声优化(TCO)。 TCE通常是一种优化,我们可能会没有它。 但是,当涉及到递归函数调用时,TCE不再是一种优化。 这是一项强制性功能。 这就是为什么在处理递归时,TCE比TCO更好的术语。

尾递归方法和功能

大多数功能语言都实现了TCE。 但是,TCE不足以使每个递归调用成为可能。 要成为TCE的候选人,递归调用必须是方法必须要做的最后一件事。 考虑以下计算列表元素总和的方法:

static Integer sum(List<Integer> list) {
    return list.isEmpty()
        ? 0
        : head(list) + sum(tail(list));
  }

此方法使用head()tail()方法。 请注意,递归调用sum方法并不是该方法要做的最后一件事。 该方法的最后四件事是:

  • 调用head方法
  • 调用tail方法
  • 调用sum方法
  • head的结果和sum的结果sum

即使我们拥有TCE,我们也无法在10,000个元素的列表中使用此方法,因为递归被调用方不在尾部位置。 但是,可以重写此方法以便将求和的调用放在尾部位置:

static Integer sum_(List<Integer> list) {
  return sumTail(list, 0);
}
 
static Integer sumTail(List<Integer> list, int acc) {
  return list.isEmpty()
      ? acc
      : sumTail(tail(list), acc + head(list));
}

现在, sumTail方法是尾递归的,可以通过TCE进行优化。

抽象递归

到目前为止,一切都很好,但是由于Java不实现TCE,为什么还要烦恼所有这些呢? 好吧,Java没有实现它,但是我们可以不用它。 我们需要做的是:

  • 表示未评估的方法调用
  • 将它们存储在类似堆栈的结构中,直到遇到终端条件
  • 以LIFO顺序评估呼叫

递归方法的大多数示例都使用阶乘函数作为示例。 其他使用斐波那契数列示例。 要开始研究,我们将使用更简单的递归加法。

递归和核心递归函数都是函数,其中f(n)f(n‑1)f(n‑2)f(n‑3) ,依此类推,直到遇到终止条件(通常为f(0)f(1) 请记住,在传统编程中,编写通常意味着编写评估结果。 这意味着组成函数f(a)g(a)包括对g(a)求值,然后将结果用作f的输入。 不必那样做。 您可以开发一个compose方法来编写函数,并开发一个higherCompose函数来完成相同的事情。 此方法或此函数均不会评估组成的函数。 它们只会产生另一个功能,以后可以应用。

递归和核心递归相似,但有所不同。 我们创建函数调用列表,而不是函数列表。 使用corecursion,每个步骤都是最终步骤,因此可以对其进行评估以便获得结果并将其用作下一步的输入。 通过递归,我们从另一端开始。 因此,我们必须将未评估的调用放入列表中,直到找到终止条件为止,我们可以根据该条件以相反的顺序处理列表。 换句话说,我们堆叠步骤(不评估它们)直到找到最后一个步骤,然后我们以相反的顺序处理堆叠(后进先出),评估每个步骤并将结果用作下一个输入(实际上是前一个)。

我们遇到的问题是Java为此使用了线程堆栈,并且其容量非常有限。 通常,堆栈将在6,000到7,000个步骤之间溢出。

我们要做的是创建一个返回未评估步骤的函数或方法。 为了表示计算中的步骤,我们将使用一个名为TailCall的抽象类(因为我们希望表示对出现在尾部位置的方法的调用)。

这个TailCall抽象类将有两个子类:一个代表中间调用,当一个步骤的处理被暂停以调用用于评估下一步骤的新方法时。 这将由名为Suspend的类表示。 将使用Supplier<TailCall>>实例化它,它表示下一个递归调用。 这样,我们将把每个尾部调用与下一个尾部链接起来,而不是将所有的TailCalls放入列表中。 这种方法的好处是,这样的链表实际上是一个堆栈,可提供恒定的时间插入以及对最后插入的元素的恒定时间访问,这对于LIFO结构是最佳的。

第二个实现将代表最后一个调用,该调用应返回结果。 因此,我们将其称为Return 。 它不会保存到下一个TailCall的链接,因为接下来没有任何内容,但是它将保存结果。 这是我们得到的:

import java.util.function.Supplier;

public abstract class TailCall<T> {

  public static class Return<T> extends TailCall<T> {

    private final T t;

    public Return(T t) {
      this.t = t;
    }
  }

  public static class Suspend<T> extends TailCall<T> {

    private final Supplier<TailCall<T>> resume;

    private Suspend(Supplier<TailCall<T>> resume) {
      this.resume = resume;
    }
  }
}

要处理这些类,我们将需要一些方法:一个返回结果,一个返回下一个调用,以及一个帮助程序方法,确定TailCallSuspend还是Return 。 我们可以避免使用最后一种方法,但是我们必须使用instanceof来完成这项工作,这很丑陋。 这三种方法将是:

public abstract TailCall<T> resume();

public abstract T eval();

public abstract boolean isSuspend();

resume方法在Return中将没有实现,只会抛出运行时异常。 我们API的用户不应处于调用此方法的情况,因此,如果最终调用该方法,则将是一个错误,并且我们将停止该应用程序。 在Suspend类中,它将返回下一个TailCall

eval方法将返回存储在Return类中的结果。 在我们的第一个版本中,如果在Suspend类上调用它将抛出运行时异常。

isSuspend方法将在Suspend返回true ,在ReturnReturn false 。 清单1显示了第一个版本。

清单1: TailCall抽象类及其两个子类
import java.util.function.Supplier;

public abstract class TailCall<T> {

  public abstract TailCall<T> resume();

  public abstract T eval();

  public abstract boolean isSuspend();

  public static class Return<T> extends TailCall<T> {

    private final T t;

    public Return(T t) {
      this.t = t;
    }

    @Override
    public T eval() {
      return t;
    }

    @Override
    public boolean isSuspend() {
      return false;
    }

    @Override
    public TailCall<T> resume() {
      throw new IllegalStateException("Return has no resume");
    }
  }

  public static class Suspend<T> extends TailCall<T> {

    private final Supplier<TailCall<T>> resume;

    public Suspend(Supplier<TailCall<T>> resume) {
      this.resume = resume;
    }

    @Override
    public T eval() {
      throw new IllegalStateException("Suspend has no value");
    }

    @Override
    public boolean isSuspend() {
      return true;
    }

    @Override
    public TailCall<T> resume() {
      return resume.get();
    }
  }
}

现在,要使我们的递归方法可以在任意数量的步骤中工作(在可用内存大小的限制之内!),我们几乎不需要做任何更改。 从我们的原始方法开始:

static int add(int x, int y) {
  return y == 0
      ? x
      : add(++x, --y)  ;
}

我们只需要进行清单2中所示的修改即可。

清单2:修改后的递归方法
static TailCall<Integer> add(int x, int y) {  // #1
  return y == 0
      ? new TailCall.Return<>(x)   // #2
      : new TailCall.Suspend<>(() -> add(x + 1, y – 1));  // #3
}
  • #1方法现在返回一个TailCall
  • #2在终端条件下,返回Return
  • #3在非终止条件下,返回挂起

现在,我们的方法返回TailCall<Integer>而不是int(#1)。 如果已经达到终止条件,则此返回值可以是Return<Integer> (#2),如果尚未达到,则可以是Suspend<Integer> (#3)。 Return用计算结果实例化(由于y为0,所以x是x),而Suspend用Supplier<TailCall<Integer>>实例化,后者是按照执行顺序进行下一步计算,或者就调用顺序而言,前一个。 重要的是要了解,Return对应于方法调用的最后一步,但对应于评估的第一步。 另请注意,我们对评估进行了少许更改,用x + 1y – 1替换了++x--y 。 这是必要的,因为我们使用的是闭包,仅当对变量的闭包实际上是最终的时才起作用。 这是骗人的,但没有那么多。 我们可以使用原始运算符创建并调用dec和inc这两个方法。

此方法返回的是一连串的TailCall实例,所有实例都是Suspend实例,除了最后一个实例(即Return)。

到目前为止,还不错,但是显然,这种方法并不能代替原始方法。 没有大碍! 原始方法用于:

System.out.println(add(x, y))

我们可以这样使用新方法:

TailCall<Integer> tailCall = add(3, 100000000);

while(tailCall .isSuspend()) {

tailCall = tailCall.resume();

}

System.out.println(tailCall.eval());

看起来不是很好吗? 好吧,如果您感到有些沮丧,我可以理解。 您认为我们将以透明的方式使用新方法代替旧方法。 我们似乎离这很远。 但是,我们可以不费吹灰之力就能使事情变得更好。

直接替换堆栈基础递归方法

在上一节的开头,我们说过,递归API的用户将没有机会通过在Return上调用resume或在Suspend上调用eval来弄乱TailCall实例。 通过将评估代码放在Suspend类的eval方法中,可以轻松实现:

public static class Suspend<T> extends TailCall<T> {

  ...

  @Override
  public T eval() {
    TailCall<T> tailRec = this;
    while(tailRec.isSuspend()) {
      tailRec = tailRec.resume();
    }
    return tailRec.eval();
  }

现在,我们可以以更简单,更安全的方式获得递归调用的结果:

add(3, 100000000).eval()

但这还不是我们想要的。 我们想要摆脱对eval方法的调用。 这可以通过一个辅助方法来完成:

import static com.fpinjava.excerpt.TailCall.ret;
import static com.fpinjava.excerpt.TailCall.sus;

. . .

public static int add(int x, int y) {
  return addRec(x, y).eval();
}

private static TailCall<Integer> addRec(int x, int y) {
  return y == 0
      ? ret(x)
      : sus(() -> addRec(x + 1, y - 1));
}

现在,我们可以完全像原始方法一样调用add方法。 请注意,通过提供静态工厂方法来实例化Return和Suspend,我们使递归API更易于使用:

public static <T> Return<T> ret(T t) {
  return new Return<>(t);
}

public static <T> Suspend<T> sus(Supplier<TailCall<T>> s) {
  return new Suspend<>(s);
}

清单3显示了完整的TailCall类。 我们添加了一个私有的no arg构造函数,以防止被其他类扩展。

清单3:完整的TailCall
package com.fpinjava.excerpt;

import java.util.function.Supplier;

public abstract class TailCall<T> {

  public abstract TailCall<T> resume();

  public abstract T eval();

  public abstract boolean isSuspend();

  private TailCall() {}

  public static class Return<T> extends TailCall<T> {

    private final T t;

    private Return(T t) {
      this.t = t;
    }

    @Override
    public T eval() {
      return t;
    }

    @Override
    public boolean isSuspend() {
      return false;
    }

    @Override
    public TailCall<T> resume() {
      throw new IllegalStateException("Return has no resume");
    }
  }

public static class Suspend<T> extends TailCall<T> {

  private final Supplier<TailCall<T>> resume;

  private Suspend(Supplier<TailCall<T>> resume) {
    this.resume = resume;
  }

  @Override
  public T eval() {
    TailCall<T> tailRec = this;
    while(tailRec.isSuspend()) {
      tailRec = tailRec.resume();
    }
    return tailRec.eval();
  }

    @Override
    public boolean isSuspend() {
      return true;
    }

    @Override
    public TailCall<T> resume() {
      return resume.get();
    }
  }

  public static <T> Return<T> ret(T t) {
    return new Return<>(t);
  }

  public static <T> Suspend<T> sus(Supplier<TailCall<T>> s) {
    return new Suspend<>(s);
  }
}

既然有了堆栈安全的尾部递归方法,就可以对函数执行相同的操作吗? 在我的《 Java中的函数式编程》一书中,我谈到了如何做到这一点。

翻译自: https://www.javacodegeeks.com/2015/10/stack-safe-recursion-in-java.html

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值