Scala和Clojure中的惰性序列

惰性序列 (也称为stream )是一种有趣的功能数据结构,您可能从未听说过。 基本上,惰性序列是一个列表,只有在您实际使用它之前,它才是完全未知/计算的。 想象一下,创建一个列表非常昂贵,并且您不想计算太多,但是仍然允许客户端根据需要或需要消耗大量资源。 与迭代器类似,但是迭代器具有破坏性-一旦您阅读它们,它们就会消失。 另一方面,惰性序列会记住已经计算出的元素。

注意,这种抽象甚至允许我们构造和使用无限的流! 完全有可能创建一个质数或斐波那契数列的惰性序列。 由客户决定他们要消耗多少元素,仅此而已
许多将要产生。 将该表与渴望的列表进行比较,该列表必须在首次使用和迭代之前进行预先计算,而忘记了已经计算出的值。

但是请记住,总是从头开始遍历惰性序列,因此为了找到第N个元素,惰性序列将必须计算前面的N-1个元素。

我尝试避免纯粹的学术示例,因此不会有斐波那契数列示例。 您可以在有关该主题的每篇文章中找到它。 相反,我们将实现一些有用的功能-Cron表达式测试实用程序,返回下一个触发时间序列。 我们之前已经使用递归和迭代器实现了对Cron表达式的测试 。 为了快速回顾一下,我们想确保我们的Cron表达式正确并在我们真正期望的时候触发。 Quartz Scheduler提供了方便的CronExpression.getNextValidTimeAfter(Date)方法,该方法返回给定日期之后的下一个触发时间。 如果要计算例如接下来的十次触发时间,则需要调用此方法十次,但是! 第一次调用的结果应作为第二次调用的参数传递–毕竟,一旦我们知道作业将在何时首次触发,我们就想知道下一次调用(在第一次调用之后)的触发时间是多少。 接下来,为了找到第三次调用时间,我们必须将第二次调用时间作为参数。 此描述使我们想到了简单的递归算法:

def findTriggerTimesRecursive(expr: CronExpression, after: Date): List[Date] =
    expr getNextValidTimeAfter after match {
        case null => Nil
        case next => next :: findTriggerTimesRecursive(expr, next)
    }

getNextValidTimeAfter()可能返回null以表示Cron表达式将永远不会再次触发(例如,它仅在2013年运行,并且我们已经到年底)。 但是,此解决方案有多个问题:

  • 我们真的不知道客户端需要多少个未来日期,因此我们很可能会生成过多的不必要的CPU周期1
  • 更糟糕的是,某些Cron表达式永无休止。 "0 0 17 * * ? *"将每年每天下午5点运行到无穷大。 我们肯定没有那么多时间和记忆
  • 我们的实现不是尾递归的。 容易修复

如果我们拥有一个“列表式”数据结构,可以像其他序列一样传递并使用它,却又不急于对其进行评估,该怎么办? 这是Scala的Stream[Date]中的实现,仅在需要时才计算下一次触发时间:

def findTriggerTimes(expr: CronExpression, after: Date): Stream[Date] =
    expr getNextValidTimeAfter after match {
        case null => Stream.Empty
        case next => next #:: findTriggerTimes(expr, next)
    }

仔细看一下,因为几乎一样! 我们将List[Date]替换为Stream[Date] (均实现LinearSeq ),将Nil替换为Stream.Empty ,将::替换为#:: 。 最后的改变至关重要。 #::方法(是的,这是一个方法…)接受tl: => Stream[A]按名称 。 这意味着findTriggerTimes(expr, next)在这里并不是真正的调用! 实际上,这是我们传递给#::高阶函数的闭包。 仅在需要时评估此关闭。 让我们玩一下这段代码:

val triggerTimesStream = findTriggerTimes("0 0 17 L-3W 6-9 ? *")
 
println(triggerTimesStream)
//Stream(Thu Jun 27 17:00:00 CEST 2013, ?)
 
val firstThree = triggerTimesStream take 3
println(firstThree.toList)
//List(Thu Jun 27 17:00:00 CEST 2013, Mon Jul 29 17:00:00 CEST 2013, Wed Aug 28 17:00:00 CEST 2013)
 
println(triggerTimesStream)
//Stream(Thu Jun 27 17:00:00 CEST 2013, Mon Jul 29 17:00:00 CEST 2013, Wed Aug 28 17:00:00 CEST 2013, ?)

仔细地看。 最初打印流几乎不显示第一个元素。 Stream.toString中的Stream.toString表示流中未知的剩余部分。 然后我们考虑前三个要素。 有趣的是,我们必须将结果转换为List 。 仅调用take(3)几乎不会返回另一个流,因此会尽可能长地进一步推迟评估。 但是再次打印原始流也显示了所有三个元素,但是第四个元素还未知。

让我们做一些更高级的事情。 假设我们想知道Cron表达式何时会第100次触发? 从今天起,一年内它将触发多少次?

val hundredth = triggerTimesStream.drop(99).head
 
val calendar = new GregorianCalendar()
calendar.add(Calendar.YEAR, 1)
val yearFromNow = calendar.getTime
 
val countWithinYear = triggerTimesStream.takeWhile(_ before yearFromNow).size

计算第100次点火时间非常简单–只需丢弃前99个日期,然后取剩下的第一个。 但是, discard这个词有点不幸-这些项目是在triggerTimesStream中计算并缓存的,因此下次我们尝试访问前100个元素中的任何一个时,它们将立即可用。 有趣的事实:Scala中的Stream[T]是不可变的并且是线程安全的,但是当您对其进行迭代时,它会在内部不断变化。 但这是一个实现细节。

您可能想知道为什么我使用takeWhile(...).size而不是简单的filter(...).size甚至count(...) ? 好吧,由于定义流中的触发时间正在增长,因此,如果我们只想计算一年内的日期,那么当我们找到第一个不匹配的日期时就可以停止。 但这不仅是微观优化。 还记得流可以是无限的吗? 想一想。 同时,我们会将小型实用程序移植到Clojure。

Clojure

Clojure中的流( lazy-seq ):

(defn find-trigger-times [expr after]
    (let [next (. expr getNextValidTimeAfter after)]
        (case next
            nil []
            (cons next (lazy-seq (find-trigger-times expr next))))))

这几乎是Scala代码的精确翻译,除了它使用一个let绑定来捕获getNextValidTimeAfter()结果。 可以使用if-let形式编写较少的文化,但翻译更紧凑:

(defn find-trigger-times [expr after]
    (if-let [next (. expr getNextValidTimeAfter after)]
        (cons next (lazy-seq (find-trigger-times expr next)))
        []))

if-let结合了条件和绑定。 如果与next绑定的表达式为false(在本例中为nil ),则根本不评估第三行。 而是返回第四行的结果(空序列)。 这两个实现是等效的。 为了完整起见,让我们看看如何获​​取第100个元素并计算一年内与Cron表达式匹配的日期数:

(def expr (new CronExpression "0 0 17 L-3W 6-9 ? *"))
(def trigger-times (find-trigger-times expr (new Date)))
 
(def hundredth (first (drop 99 trigger-times)))
 
(def year-from-now (let [calendar (new GregorianCalendar)]
    (. calendar add Calendar/YEAR 1)
    (. calendar getTime)))
 
(take-while #(.before % year-from-now) trigger-times)

再次注意,我们使用了take-while不是简单的filter

时空复杂度

想象一下使用filter()而不是takeWhile()计算明年Cron触发器将触发多少次。 请记住,一般而言,流(尤其是我们的Cron流)可以是无限的。 Stream上的简单filter()将一直运行到结束为止-无限流可能永远不会发生。 即使是size类的简单方法也是如此, Stream会不断评估,直到达到终点为止。 但是,您的程序会尽快填满整个堆空间。 为什么? 因为一旦评估了元素, Stream[T]将对其进行缓存以备后用。 意外抓住大Stream头是另一个危险:

val largeStream: Stream[Int] = //,..
//...
val smallerStream = largeStream drop 1000000

smallerStream是对没有前一百万个元素的流的引用。 但是这些元素仍保留在原始largeStream 。 只要您保留对它的引用,它们就会保留在内存中。 当largeStream引用超出范围时,前一百万个元素有资格进行垃圾回收,而流的其余部分仍被引用。

上面的讨论同样适用于Scala和Clojure。 如您所见,使用惰性序列时必须非常小心。 它们在功能语言中非常强大且无处不在-但是
能力强大,责任重大 。 开始使用可能无限的实体的那一刻,您必须小心。

重复

如果您对Clojure或Scala更有经验,您可能想知道为什么我没有使用(iterate fx)Stream.iterate() 。 当您有无限的流并且每个元素都可以作为前一个元素的函数进行计算时,这些辅助方法非常有用。 显然,Cron stream无法利用此便捷工具,因为它如前所述可能是有限的。 但是为了完整起见,这是一个简短得多使用iterate 错误实现:

def infiniteFindTriggerTimes(expr: CronExpression, after: Date) =
    Stream.iterate(expr getNextValidTimeAfter after){last =>
        expr getNextValidTimeAfter last
    }

…和Clojure:

(defn find-trigger-times [expr after]
    (iterate
        #(. expr getNextValidTimeAfter %)
        (. expr getNextValidTimeAfter after)))

两种情况下的想法都很简单:我们提供初始元素x (Scala中的第一个参数,Clojure中的第二个参数)和将前一个元素转换为当前元素的函数f 。 换句话说,我们产生以下流: [x, f(x), f(f(x)), f(f(f(x))), ...]

以上实现会一直工作到流结束(如果有)。 因此,以肯定的事物结束时,我们将使用天真的素数来使用iterate来产生无限量的素数流(对此理论问题表示歉意) prime? 谓语:

(defn- divisors [x]
    (filter #(zero? (rem x %))
        (range 2 (inc (Math/sqrt x)))))
(defn- prime? [x] (empty? (divisors x)))
(defn- next-prime [after]
    (loop [x (inc after)]
        (if (prime? x)
            x
            (recur (inc x)))))
(def primes (iterate next-prime 2))

我希望这个想法和实现都清楚。 如果数字没有除数,则将其视为质数。 next-prime返回大于给定值的后续质数。 因此(next-prime 2)产生3(next-prime 3)产生5 ,依此类推。 使用此函数,我们可以通过简单地提供第一个素数和next-prime一个素数函数来构建primes惰性序列。

结论

惰性序列(或流)是很好的抽象,用命令式语言表示不可能或很乏味。 它们看起来像普通列表,但仅在需要时才进行评估。 Scala和Clojure都对它们有很大的支持,它们的行为类似。 您可以在流上映射,过滤,剪切等,只要它们不是真正需要的,它们就不会真正计算其元素。 而且,它们缓存已经计算出的值,同时仍然是线程安全的。 但是,在处理无穷大时,必须小心。 如果您试图无辜地计算无限流的元素或找到不存在的项(例如primes.find(_ == 10) ),那么没有人会救您。

1
getNextValidTimeAfter()完整实现长度为400行

参考:来自Java和社区博客的JCG合作伙伴 Tomasz Nurkiewicz提供的Scala和Clojure中的惰性序列

翻译自: https://www.javacodegeeks.com/2013/05/lazy-sequences-in-scala-and-clojure.html

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值