功能风格–第7部分

懒惰的评估。

看到一粒野花在沙粒中的世界和天堂
一小时内将无限握在手中,永恒

–威廉·布莱克

几年前,我参加了有关C#的培训课程。 我记得特别在理解两件事时遇到困难。 其中之一就是LINQ,部分原因是我不太了解语法。 我已经沉迷于SQL多年了,这种语言虽然相似但又不太一样,这让我感到困惑。 另外,我还没有学习编程的功能风格。 现在我拥有了,这对我来说更有意义。

另一件事是yield关键字。 但是,再次了解功能样式会更有意义,而且实际上非常简单。 .NET文档提供了此示例用法:

public class PowersOf2
{
    static void Main()
    {
        foreach (var i in Power(2, 8))
            Console.Write("{0} ", i);
    }

    public static IEnumerable<int> Power(int number, int exponent)
    {
        var result = 1;
        for (var i = 0; i < exponent; i++)
        {
            result = result * number;
            yield return result;
        }
    }
}

运行时将打印出: 2 4 8 16 32 64 128 256

发生的是, Power方法返回一个IEnumerable实例,并且foreach循环在其上重复调用MoveNext。 但是,Power方法不会显式创建IEnumerable的实例。 通过使用yield return语句,该方法从字面上变为一个迭代器,该迭代器根据需要计算其迭代值。 此处返回的值是第一个迭代的元素。 此时,控制返回到foreach循环,然后其主体执行一次。 然后,它在迭代器上再次调用MoveNext,这使控件在yield return语句之后立即返回Power方法。 Power方法的内部状态从以前保留,因此其for循环再次迭代,并产生下一个迭代的元素。 因此,控制反复在foreach循环和yield return语句之间跳转,直到最后for循环终止并且Power退出,而无需再次调用yield return。

正如文档所说明的那样, yield return的主要用例是在方法中为自定义集合类型实现迭代器,而无需创建新的IEnumerable或IEnumerator实现,从而避免了定义新类的麻烦。 它给出了另一个例子来说明这一点。

但是,如果迭代器方法永不退出怎么办?

假设我们删除了终止条件,那么包含yield return语句的循环将永远继续:

public static IEnumerable<int> Numbers()
{
    var number = 1;
    for (;;)
        yield return number++;
}

如果我们这样称呼,我们将打印出1 2 3 4 5 6 7 8 9 10

static void Main()
{
    foreach (var i in Numbers())
    {
        Console.Write("{0} ", i);
        if (i == 10)
            break;
    }
}

现在,我们不得不跳出foreach循环,因为显然Numbers方法永远不会正常退出。 那么,这有什么意义呢? 实际上,这是一个深刻而深刻的观点。 我们现在拥有的是一个IEnumerable ,它声称包含所有自然数,这是一个无限集。

当然这是不可能的!

的确如此,但是事实证明,诺言比不能兑现的诺言更为重要。

如果您在第4部分中回想过,我们有这段代码会生成一个质数筛子,并返回一个谓词来测试数字是否为质数:

private Predicate<Integer> notInNonPrimesUpTo(int limit) {
    Set<Integer> sieve = IntStream.range(2, (limit / 2))
            .boxed()
            .flatMap(n -> Stream.iterate(n * 2, nonPrime -> nonPrime += n)
                    .takeWhile(nonPrime -> nonPrime <= limit))
            .collect(Collectors.toSet());
    return candidate -> !sieve.contains(candidate);
}

我说Stream.iterate很有趣,我稍后会Stream.iterate 。 时机已到。 这是迭代器的另一个示例,该迭代器旨在迭代无限列表:

Stream.iterate(n * 2, nonPrime -> nonPrime += n)

它生成从(n * 2)并每次增加n的整数流。 请注意,没有上限。 终止条件(当超过limit值时)在此处:

.takeWhile(nonPrime -> nonPrime <= limit)

另外,如果您还记得上一篇文章,我们在Clojure中看到了range函数的几种用法。 在每种情况下,我都以这种方式使用它:

(range 1 10)

它的结果是一个从1到10的数字列表。但是我们也可以不带任何参数调用range

user=> (take 5 (range))
(0 1 2 3 4)

是的,没有参数的range是另一个据称是无限的整数列表。 但是,如果您仅在REPL中执行(range) ,则仅阻塞即可。 它正在等待采取某些措施。 就像Java Stream.iterate和C# yield循环一样,仅在请求值时才生成值。 这就是为什么评估被认为是懒惰的。 一个“热切”的评估序列在创建时立即生成其所有值,就像(range 1 10)那样。 “延迟”评估的序列仅按需生成其值。 正是这种延缓的执行使伪造无限序列成为可能。 只要眨眨眼和一个会意的点头,假装就可以维持,前提是该程序从不真正要求兑现诺言。

循环索引。

正如您现在可能已经知道的那样,我和下一个人一样喜欢一个聪明的把戏,但是如果可以将其用于实际使用以使我的代码变得更好,我将最喜欢它。 懒惰的评估也是如此。

在本系列的前面,我断言采用函数式风格意味着您几乎不必再次编写循环。 映射和归约确实确实涵盖了很多用例,但是尽管如此,您有时仍需要使用某种计数器来跟踪迭代。 现在,如果我使用C#或Java这样的语言进行编程,而这两种语言在本质上都是必不可少的,那么我就不会费心去以函数式风格进行操作。 我会改用传统的for循环:

public void IterateWithIndex()
{
    var numbers = new[] {"one", "two", "three", "four", "five"};
    for (var i = 0; i < numbers.Length; i++)
        Console.WriteLine("{0}: {1}", i, numbers[i]);
}

希望不必要地改变状态,但对我来说,简单仍然是一种较高的美德,在某些语言中,这仍然是解决问题的最简单方法。 在所有其他条件都相同的情况下,我将选择三行简单的代码来对超过六行执行相同工作的难以理解的功能代码进行状态变异。

某些语言为您提供了一种开箱即用的简便方法,可以以功能样式迭代序列,还为您提供了迭代计数器。 Groovy提供了eachWithIndex方法,该方法无需管理即可完成:

[
        [symbol: 'I', value: 1],
        [symbol: 'V', value: 5],
        [symbol: 'X', value: 10],
        [symbol: 'L', value: 50],
        [symbol: 'C', value: 100],
        [symbol: 'D', value: 500],
        [symbol: 'M', value: 1000],
].eachWithIndex { numeral, i -> 
    println("${i}: ${numeral.symbol} ${numeral.value}")
}

在这种情况下,这是一个不错的选择。 但是让主题回到懒惰的评估,如果您正在使用Clojure,可以使用(range)以一种有趣的方式解决问题:

(map (fn [i number] (format "%d: %s" i number))
     (range)
     '("one" "two" "three" "four" "five"))

回想一下,在多个序列上的map一直持续到最短的序列用完为止。 显然,懒惰求值的序列不会首先用完,因此它将继续递增计数,直到其他列表用尽。 因此,结果是:

("0: one" "1: two" "2: three" "3: four" "4: five")

也就是说,Clojure还提供了map-indexed功能,该功能与Groovy中的eachWithIndex相似:

(map-indexed (fn [i number] (format "%d: %s" i number))
             '("one" "two" "three" "four" "five"))

给我看一个例子,其中懒惰的评估确实有帮助。

我最近在现实世界中遇到的需要循环索引的情况是,我想编写参数化的日志消息。 为此,我选择了一种类似于C#中使用的模板格式,即:

The {0} has a problem. Cause of failure: {1}. Recommended solution: {2}.

那不是实际的日志消息之一,但是您知道了。 该程序使用Java,解决方案非常简单:

private String replaceParameters(String template, String... parameters) {
    var out = template;
    for (var i = 0; i < parameters.length; i++)
        out = out.replace(String.format("{%d}", i), parameters[i]);
    return out;
}

它是绝对必要的,它可以无耻地改变其内部状态。 如果我们尝试将其重写为更具功能性的样式,则可能看起来像这样:

private String replaceParameters(String template, String... parameters) {
    var i = new AtomicInteger(0);
    return Arrays.stream(parameters)
            .reduce(template, (string, parameter) ->
                    string.replace(
                            String.format("{%d}", i.getAndIncrement()),
                            parameter));
}

我不认为这是实用的。 相反,我认为这是出于自己的目的而使用功能样式,而不是因为它更好。 回想一下我所说的函数式编程的最佳位置吗? 不是吗 一方面,牺牲了原始文件的清晰度:为了帮助提高可读性,我不得不将其分成更多行。 而且,它实际上并没有任何功能-无论如何,它仍然会改变状态,因为AtomicInteger正在递增。

因此,在Java中,我认为传统循环是最好的解决方法,但是如果使用功能适当的语言却无法为我们提供选择,那又如何呢? 在Clojure中, map-indexed功能在这里无济于事。 相反,这是reduce的工作,并且没有reduce-indexed功能。 我们拥有的是zipmap ,在其他语言中有时也称为“ zip”。 之所以这样命名是因为它的行为让人联想到拉链的作用:

user=> (zipmap [1 2 3] ["one" "two" "three"])
{1 "one", 2 "two", 3 "three"}

我们可以使用它使用reduce编写参数替换函数,如下所示:

(defn- replace-parameter [string [i parameter]]
  (clojure.string/replace string (format "{%d}" i) parameter))

(defn replace-parameters [template parameters]
  (reduce replace-parameter template (zipmap (range) parameters)))

而且有效!

user=> (replace-parameters
  #_=>   "The {0} has a problem. Cause of failure: {1}. Recommended solution: {2}."
  #_=>   ["time machine" "out of plutonium" "find lightning bolt"])
"The time machine has a problem. Cause of failure: out of plutonium. Recommended solution: find lightning bolt."

懒惰自己。

我们开箱即用地玩了一些懒惰的序列,所以让我们做一些涉及构建自己的序列的练习。 您可能听说过Pascal的Triangle。 用数学术语来说,帕斯卡的三角形是二项式系数的三角形阵列,但是您真的不需要知道这意味着什么,因为它的构造非常简单。 首先在顶点处放置1,然后在下面放置数字行,以偏移上面的数字的左右,从而建立一个三角形,以便每行比上面的数字多一个。 每个数字等于其上方两个数字的和。 对于三角形边缘上的数字,不存在的数字视为零。 该图应该清楚:

1
    1 1
   1 2 1
  1 3 3 1
 1 4 6 4 1

编写一个程序来产生这个问题是一个有趣的问题。 如果观察到可以通过复制前一行,将其中一个向左或向右移动,然后将偏移位数字相加来生成下一行,则变得更加简单。

1  4  6  4  1  0
+   0  1  4  6  4  1
=   1  5 10 10  5  1

用Clojure术语可以这样表示:

(defn next-row [previous]
  (apply vector (map + (conj previous 0)
                       (cons 0 previous))))

快速测试表明它有效:

#'user/next-row
user=> (next-row [1])
[1 1]
user=> (next-row [1 1])
[1 2 1]
user=> (next-row [1 2 1])
[1 3 3 1]

然后我们可以像下面这样使用iterateiterate地建立一个整个三角形:

(def triangle (iterate next-row [1]))

Clojure中, iterate通过重复应用所述先前迭代的结果到建立一个序列懒惰地next-row功能,从所述种子值[1]这是一个包含数字1的向量。

然后,您可以根据需要从triangle获取任意多行:

user=> (take 7 triangle)
([1] [1 1] [1 2 1] [1 3 3 1] [1 4 6 4 1] [1 5 10 10 5 1] [1 6 15 20 15 6 1])

甚至请求任意行:

user=> (nth triangle 10)
[1 10 45 120 210 252 210 120 45 10 1]

显然,尽管triangle似乎是一个序列,但内存中不存在任何实际序列,除非您碰巧从结果中构建自己。 如果从头开始阅读C#中的yield return示例,您将在此处看到相同的行为。 似乎很矛盾,无限的收集需要更少的内存,但这是一个重要的性能提示。

重复一遍。

为了完成对惰性评估的探索,让我们玩得开心。 正如有人曾经干observed地观察到的那样,FizzBu​​zz的kata很受欢迎,因为它避免了在房间里没人能记住如何对数组进行二进制搜索的尴尬。 如果您是世界上少数不熟悉该游戏的人之一,那就太简单了:您可以对数字进行计数,同时用“ Fizz”替换所有三倍的整数,用“ Buzz”替换所有五倍的整数,以及所有都是“ FizzBu​​zz”的倍数。

注意:通常说它起源于喝酒游戏,尽管沃里克规则略有不同,但实际上我确实在大学玩过这样的喝酒游戏。 在播放时,嗡嗡声是4而不是5,还有一个附加规则:当十进制数字包含数字3时,您还必须说“嘶嘶声”,而当包含4时,您必须说“嗡嗡声”。 因此,12代表“嘶嘶声”,13代表“嘶嘶声”,14代表“嗡嗡声”,15代表“嘶嘶声”。 这使其变得更加复杂,因此我们变得更加醉酒。

我们曾经在一个乐队的社交活动中尝试过使用二进制数字进行演奏,但是男中音萨克斯演奏者以他是法律系学生而不是科学家为由反对。 因此,我们同意改为使用罗马数字,这样一来就杀死了两片kata。

如果存在规范的实现,则在Java中可能看起来像这样:

public class FizzBuzz {

    public static void main(String[] args) {
        for (var i = 0; i < 100; i++)
            System.out.println(fizzBuzz(i));
    }

    private static String fizzBuzz(int i) {
        if (i % 3 == 0 && i % 5 == 0)
            return "FizzBuzz";
        else if (i % 3 == 0)
            return "Fizz";
        else if (i % 5 == 0)
            return "Buzz";
        else
            return String.valueOf(i);
    }
}

(但绝对不是这样 )。

因此,该解决方案将其视为算术问题,但也许并非必须如此。 如果我们对其进行分析,则整个周期将连续重复十五个周期,如下所示:

数量,数量,嘶嘶声,数量,嗡嗡声,嘶嘶声,数量,数量,嘶嘶声,嗡嗡声,数量,嘶嘶声,数量,数量,FizzBu​​zz

也许我们可以在程序中以这种方式对待它。 Clojure具有称为cycle的功能,该功能会无限期地重复提供的模式:

user=> (take 10 (cycle [nil nil "Fizz"]))
(nil nil "Fizz" nil nil "Fizz" nil nil "Fizz" nil)

如果我们有一个映射到这三个惰性序列上的函数,那么我们可以从它们中生成一个惰性评估的FizzBu​​zz实现:

(def numbers (map inc (range)))               ; goes 1 2 3 4 5 6 7 8 9 10 etc.
(def fizzes (cycle [nil nil "Fizz"]))         ; goes nil nil fizz nil nil fizz etc.
(def buzzes (cycle [nil nil nil nil "Buzz"])) ; goes nil nil nil nil buzz nil nil nil nil buzz etc.

map inc是必需的,因为range从0开始并且我们需要从1开始,所以我们将序列中的每个值都增加1。现在让我们在编写函数时遇到一点麻烦:

(defn fizzbuzz [n fizz buzz]
  (if (or fizz buzz)
      (str fizz buzz)
      (str n)))

那很简单; 如果fizz buzzbuzz是正确的(即不是nil),则将它们串联在一起-在串联字符串时,将nil视为空字符串-否则将n返回为字符串。 当我们在所有三个惰性序列上映射它时,我们得到FizzBu​​zz!

user=> (take 30 (map fizzbuzz numbers fizzes buzzes))
("1" "2" "Fizz" "4" "Buzz" "Fizz" "7" "8" "Fizz" "Buzz" "11" "Fizz" "13" "14" "FizzBuzz" "16" "17" "Fizz" "19" "Buzz" "Fizz" "22" "23" "Fizz" "Buzz" "26" "Fizz" "28" "29" "FizzBuzz")

我们还可以使用nth从序列中获取任何值(考虑到nth从零开始计数,而不是从1开始计数):

user=> (nth (map fizzbuzz numbers fizzes buzzes) 1004)
"FizzBuzz"

那不是很酷吗?

下次。

在下一篇文章中,我将结束对函数式编程实践方面的考察。 我将研究持久性数据结构,这是功能语言如何实现不可变数据结构,从而给人以可变的印象,同时通过避免不必要的数据重复来最有效地利用内存。

整个系列:

  1. 介绍
  2. 第一步
  3. 一流的函数I:Lambda函数和映射
  4. 一流的功能II:过滤,缩小和更多
  5. 高阶函数I:函数组成和Monad
  6. 高阶函数II:咖喱
  7. 懒惰评估

翻译自: https://www.javacodegeeks.com/2018/11/functional-style-part-7.html

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值