功能风格–第6部分

高阶函数II:咖喱。

我们之前在第5部分中已经看到了如何使用函数组合将代码排列为代表主要“快乐”流程的步骤序列,而其他“不快乐”执行路径则封装在称为monad的可重用结构中。 组成函数需要perforce,这些函数只接受一个参数。 但是您可能有一个带有多个参数的函数,那么您该怎么办? 你不走运吗?

答案是,您确定需要更改函数的哪个参数,然后创建一个新函数,该函数调用原始函数,同时将固定值传递给所有剩余参数。 它与微积分中的部分微分技术非常相似。 这样,您最终获得了可以与之组合的单参数函数。 这在逻辑学家Haskell Curry之后被称为“ 部分应用程序” ,也称为“ currying”,在此之后,Haskell编程语言也被命名。

现在,如果我们要静态定义一个新函数来调用另一个函数,并为其某些参数使用静态值,那么它的实用性将非常有限。 为了使之成为一种有效的编程技术,我们需要一种在运行时动态地部分应用函数的方法,而这正是我们所做的。 如果听起来很复杂,请不要担心,就像我们已经看到的大多数事情一样,它确实非常简单。 高阶函数使其非常容易实现。 让我们通过定义一个将两个数字相加的函数来进行说明:

(defn add [a b] (+ a b))

我们可以通过固定以下参数之一来创建一个添加静态数量的新函数:

(def add-one (partial add 1))

我们看到它产生了正确的结果:

user=> (add-one 2)
3

但这不是深奥的Clojure魔术。 要了解它的琐碎性,我们也可以在Javascript中进行。 这是一个add函数:

function add(a, b) {
    return a + b
}

这是修正了其中一个参数的版本:

function addOne(a) {
    return add(a, 1)
}

好的,这与我们在Clojure中所做的不完全相同 。 我们有一个称为partial的高阶函数,它可以接受任何函数并修复其某些参数。 这是一个更接近Clojure版本的Javascript示例。 我们必须创建我们自己的partial函数,但这很简单:

function partial(f, fixedValue) {
    return function(a) {
        return f(fixedValue, a)
    }
}

然后,我们可以使用它返回add函数的当前版本:

var addTwo = partial(add, 2)
undefined
addTwo(3)
5

用咖喱清除地雷。

到目前为止,毫无用处。 正如我所说,只有在运行时动态使用它来按需创建部分应用的函数时,这才成为真正有效的编程技术。 因此,让我们尝试一个使用更有趣的技术的示例。 您可能还记得曾经随Windows一起免费提供的Minesweeper游戏(您仍然可以在Windows 10上免费下载)。

高阶函数

它为玩家提供了一个方格网,每个方格上可能有也可能没有地雷。 想法是,您将在一个正方形上单击鼠标左键,如果包含地雷,则结束游戏。 如果它不包含地雷,那么正方形将被发现,您将继续游戏。 如果该正方形不与其中有地雷的正方形相邻,那么它将为空白,并且游戏会自动发现其中也没有地雷的任何相邻正方形。 但是,如果未覆盖的正方形与一个或多个地雷相邻,则会显示一个数字,指示其相邻的正方形中有多少个包含地雷。 通过这种方式,您可以推断出包含地雷的正方形,并通过右键单击它们进行标记。 游戏的目的是发现没有方块的所有方块。

无论如何,这使编码工作变得非常不错:编写一个程序,给定一个代表扫雷器板的字符串,输出一个标识尺寸的板,其中包括指示每个单元相邻的地雷数量的数字。 例如,给定此输入:

"      *   \n"
"     *   *\n"
"**        \n"
"  *     * \n"
"  ***   * \n"
"     * *  \n"
"      *   \n"
"          \n"
"  *       \n"
"        * \n"

它应该产生以下输出:

"    12*111\n"
"221 1*211*\n"
"**21111122\n"
"24*421 2*2\n"
" 2***223*2\n"
" 1233*3*21\n"
"    12*21 \n"
" 111 111  \n"
" 1*1   111\n"
" 111   1*1\n"

我们将在Clojure中解决此问题,它将使用curring的多种用法,有望显示出此技术为何有用的原因。

认识邻居。

第一步是找到任何给定小区的邻居。 为了使它更容易,我们将通过假设两件事来扩展域:

  • 董事会是无限的。
  • 负坐标也可以。

正如我们将看到的那样,这些假设都不会影响最终解决方案。 要计算一个单元的邻居,我们可以这样做:

(def neighbours
  [[-1,  1] [0,  1] [1,  1]
   [-1,  0]         [1,  0]
   [-1, -1] [0, -1] [1, -1]])

(defn neighbours-of [x y]
  (set (map (fn [[x-offs y-offs]] [(+ x-offs x) (+ y-offs y)]) neighbours)))

我们已将neighbours定义为坐标偏移对的向量,该向量对应于与任何板位置相邻的八个像元。 我对其进行了格式化,以使其更加清晰:中间的Kong表示板的位置。 neighbours-of函数将lambda映射到neighbours ,以便将每个邻居的x和y偏移量添加到提供的单元格中。 因此,它将构建一个包含该单元格的所有八个邻居的集合,如下所示:

user=> (neighbours-of 2 2)
#{[2 3] [3 3] [1 1] [1 3] [3 1] [2 1] [1 2] [3 2]}

确实是单元格(2,2)的所有邻居的坐标。 有了这个,我们可以构建一个仅包含一个矿井的包含邻居的委员会。 这个想法是,我们将逐个单元检查输入板,为输入中的每个单元生成一个中间板,然后将它们全部组合成最终结果。 如果所考虑的单元中有一个地雷,则生成的木板在每个相邻单元中将包含1; 所有其他单元格将包含零。 最后,通过将每个中间板中的相应单元格相加,将所有生成的中间板减少到结果板中。

搭建董事会。

为了构造一个板,我们需要构造一条线,并且为了构造一条线,我们需要构造一个单元,所以让我们从这里开始:

(defn generate-cell [neighbours y x]
  (if (contains? neighbours [x y]) 1 0))

neighbours参数是由neighbours-of生成的与矿山相邻的一组单元位置。 如果所考虑的单元格包含地雷,则该单元格将包含其邻居的位置,否则将为空集。 y和x参数是要生成的单元格的坐标。 由于某种特定原因,它们按此顺序排列,我们很快就会看到。 除此之外,函数非常简单:如果要生成的像元恰好与矿井相邻,则返回1,否则返回0:

user=> (generate-cell (neighbours-of 2 2) 2 3)
1
user=> (generate-cell (neighbours-of 2 2) 4 5)
0

因此,我们可以使用generate-cell通过在x位置序列上映射它来生成一行单元格:

(defn generate-line [neighbours width y]
  (map (partial generate-cell neighbours y)
       (range 0 width)))

现在我们看到一些烦人的事情。 我们将前两个参数部分地应用于了generate-cell ,以便动态创建一个仅接受单个参数(即单元格的x位置)的新函数。 这就是为什么x和y参数相反的原因。 然后,我们将此新函数映射到由(range 0 width)生成的序列上,该序列形成一行中每个单元格的x坐标:

user=> (range 0 10)
(0 1 2 3 4 5 6 7 8 9)

现在我们有了一个生成线的函数,通过为y坐标范围调用此函数,我们可以看到木板开始成形。 为了完全理解此示例,假设我们正在为(2,2)的单元生成一个5×5的板,恰好包含一个地雷:

user=> (generate-line (neighbours-of 2 2) 5 0)
(0 0 0 0 0)
user=> (generate-line (neighbours-of 2 2) 5 1)
(0 1 1 1 0)
user=> (generate-line (neighbours-of 2 2) 5 2)
(0 1 0 1 0)
user=> (generate-line (neighbours-of 2 2) 5 3)
(0 1 1 1 0)
user=> (generate-line (neighbours-of 2 2) 5 4)
(0 0 0 0 0)

然后,很容易看到如何编写函数以生成整个电路板:

(defn generate-board [dimensions neighbours]
  (mapcat (partial generate-line neighbours (dimensions :w))
          (range 0 (dimensions :h))))

此函数期望dimensions为包含板的宽度和高度的地图,例如对于10×10的板: {:h 10 :w 10} 。 再次,它部分地将前两个参数(邻居和宽度)应用于生成线,然后将部分应用的函数映射到由(range 0 (dimensions :h))生成的序列上,该序列给出所有线的y坐标。

什么是地图猫?

您可能想知道mapcat会做什么。 好吧,这是如果我们使用map而不是mapcat的generate-board函数的作用:

user=> (generate-board {:h 5 :w 5} (neighbours-of 2 2))
((0 0 0 0 0) (0 1 1 1 0) (0 1 0 1 0) (0 1 1 1 0) (0 0 0 0 0))

这就是mapcat

user=> (generate-board {:h 5 :w 5} (neighbours-of 2 2))
(0 0 0 0 0 0 1 1 1 0 0 1 0 1 0 0 1 1 1 0 0 0 0 0 0)

如您所见,它已将列表列表扁平化为一个列表。 换句话说,它的作用与Java中Stream.flatMap作用相同。 这将使我们的生活更轻松,因为正如我之前所说,我们将为输入中的每个单元生成这些板之一。 如果单元格包含一个地雷,则生成的木板将在每个邻居所在的位置包含1(尽管它不包含地雷本身:这将作为最后一步覆盖在最终结果上)。 然后,通过对每个位置的像元值求和,将这些生成的板减少为一个板。

生成中间板。

这是从输入中获取单个单元格并生成其输出板的代码:

(defn mine? [cell]
  (= \* cell))

(defn board-for-cell [dimensions y x cell]
  (generate-board dimensions (if (mine? cell) (neighbours-of x y))))

这里的if形式缺少else子句。 当条件产生假值时,它将返回nil ,在这种情况下,它适合我们。 像往常一样,最好的感觉是在REPL中执行它:

user=> (board-for-cell {:w 5 :h 5} 1 1 \space)
(0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0)
user=> (board-for-cell {:w 5 :h 5} 1 1 \*)
(1 1 1 0 0 1 0 1 0 0 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0)

现在,我们可以为给定的输入线生成一组板:

(defn boards-for-line [dimensions line y]
  (map (partial board-for-cell dimensions y)
       (range 0 (dimensions :w))
       line))

在REPL中使用各种输入执行时,会执行此操作。 输出看起来实际上不是这样,因为为清楚起见我已经对其进行了格式化,但是数据的结构是准确的:

user=> (boards-for-line {:w 3 :h 3} "*  " 0)
((0 1 0 
  1 1 0 
  0 0 0) 
 (0 0 0 
  0 0 0 
  0 0 0) 
 (0 0 0 
  0 0 0 
  0 0 0))
user=> (boards-for-line {:w 3 :h 3} " * " 1)
((0 0 0 
  0 0 0 
  0 0 0) 
 (1 1 1 
  1 0 1 
  1 1 1) 
 (0 0 0 
  0 0 0 
  0 0 0))
user=> (boards-for-line {:w 3 :h 3} "  *" 2)
((0 0 0 
  0 0 0 
  0 0 0) 
 (0 0 0 
  0 0 0 
  0 0 0) 
 (0 0 0 
  0 1 1 
  0 1 0))

我通过将内部列表分成3×3数组来格式化输出,以帮助您了解发生了什么。 希望这是有道理的。

现在,我们拥有组装解决方案核心所需的一切:

(let [input-board "*  \n * \n  *",
      lines (clojure.string/split-lines input-board),
      dimensions {:h (count lines), :w (count (first lines))}]
  (mapcat (partial boards-for-line dimensions)
          lines
          (range 0 (dimensions :h))))

这将产生以下输出(进行一些格式化以帮助理解):

((0 1 0 1 1 0 0 0 0)
 (0 0 0 0 0 0 0 0 0)
 (0 0 0 0 0 0 0 0 0)
 (0 0 0 0 0 0 0 0 0)
 (1 1 1 1 0 1 1 1 1)
 (0 0 0 0 0 0 0 0 0)
 (0 0 0 0 0 0 0 0 0)
 (0 0 0 0 0 0 0 0 0)
 (0 0 0 0 1 1 0 1 0))

汇总中间板。

那就是我们需要通过添加相应的像元值来减少为单个板的一组板,这是我们下一步要做的。 但是,首先,老鹰眼可能已经注意到,咖喱函数(partial boards-for-line dimensions)被映射在两个序列上: lines(range 0 (dimensions :h)) ,它是从00的整数序列(number of lines - 1) 。 这在Clojure中是一件很整洁的事情:您可以一次在多个序列上映射一个函数。 被映射的函数需要接受与序列一样多的参数,并且映射将继续直到最短的序列用完。 例如:

(map #(str %1 "-" %2)
     (list 1 2 3 4)
     (list "one" "two" "three"))

产生:

("1-one" "2-two" "3-three")

请注意,即使第一个列表包含四个元素,映射也会在第三个元素之后停止。 我们将利用此功能对单元求和以将板减少为一个,但首先我们需要一个函数,该函数接受任意数量的参数并将其求和:

(defn sum-up [& vals]
  (reduce + vals))

快速测试证明它有效:

user=> (sum-up 0 1 1 0 0 1 0 1 1 1 0 0 1 1 1 0 1)
10

现在,我们可以使用总结将板缩减为单个板:

(defn draw [input-board]
  (let [lines (str/split-lines input-board),
        dimensions {:h (count lines), :w (count (first lines))}]
    (->> (mapcat (partial boards-for-line dimensions)
                 lines
                 (range 0 (dimensions :h)))
         (apply map sum-up))))

通过快速测试,再次格式化以提高理解能力:

user=> (minesweeper/draw "*   \n *  \n  * \n   *")
(1 2 1 0 
 2 2 2 1 
 1 2 2 2 
 0 1 2 1)

这里有两件事我们之前从未见过。 其中一个是apply :这样做是将提供的函数应用于序列,就好像该序列中的元素是函数的参数一样。 例如:

(apply str (list "one" "," "two" "," "three"))

与以下功能相同:

(str "one" "," "two" "," "three")

换句话说,它使您可以将任何序列用作可变参数函数的参数列表,这确实非常有用。 我们正在使用它,以便将列表列表视为要映射的单独参数,以便获得与我们相同的结果:

(map sum-up
     '(0 1 0 1 1 0 0 0 0)
     '(0 0 0 0 0 0 0 0 0)
     '(0 0 0 0 0 0 0 0 0)
     '(0 0 0 0 0 0 0 0 0)
     '(1 1 1 1 0 1 1 1 1)
     '(0 0 0 0 0 0 0 0 0)
     '(0 0 0 0 0 0 0 0 0)
     '(0 0 0 0 0 0 0 0 0)
     '(0 0 0 0 1 1 0 1 0))

得出结果(1 2 1 2 2 2 1 2 1) 。 (注:带引号的Clojure会按字面意义对待列表,而不是将其视为函数调用)。

之前未看到的另一件事是线程最后宏->> 。 这与我们在第4部分中看到的线程优先宏相对应。它是Clojure中的语法糖,用于使组合函数更具可读性。 它允许您重写:

(function-3 (function-2 (function-1 value)))

像这样:

(->> value function-1 function-2 function-3)

画龙点睛。

现在,我们正在接近所需的解决方案。 我们需要将每个单元格从整数转换为文本:

(->> (mapcat (partial boards-for-line dimensions)
             lines
             (range 0 (dimensions :h)))
     (apply map sum-up)
     (map cell-as-text))

其中, cell-as-text用空格替换零,并将其他值转换为字符串:

(defn cell-as-text [cell-value]
  (if (zero? cell-value) \space (str cell-value)))

现在的行为是:

user=> (draw "*   \n *  \n  * \n   *")
("1" "2" "1" \space "2" "2" "2" "1" "1" "2" "2" "2" \space "1" "2" "1")

接下来,我们需要将输出分成几行,我们使用partition

(->> (mapcat (partial boards-for-line dimensions)
             lines
             (range 0 (dimensions :h)))
     (apply map sum-up)
     (map cell-as-text)
     (partition (dimensions :w)))

这给了我们:

user=> (draw "*   \n *  \n  * \n   *")
(("1" "2" "1" \space) ("2" "2" "2" "1") ("1" "2" "2" "2") (\space "1" "2" "1"))

现在,我们需要将内部列表串在一起,这需要花费更多时间:

(->> (mapcat (partial boards-for-line dimensions)
             lines
             (range 0 (dimensions :h)))
     (apply map sum-up)
     (map cell-as-text)
     (partition (dimensions :w))
     (map (partial reduce str)))

str是一个函数,但是由于函数是一等公民,因此您可以将它们作为其他函数的参数传递,这意味着没有理由不能将一个函数部分地应用到第二个函数并创建第三个函数: (partial reduce str) 。 这具有将(reduce str)应用于每个内部列表的效果,这使我们得到以下结果:

user=> (draw "*   \n *  \n  * \n   *")
("121 " "2221" "1222" " 121")

我们在两行之间插入换行符:

(->> (mapcat (partial boards-for-line dimensions)
             lines
             (range 0 (dimensions :h)))
     (apply map sum-up)
     (map cell-as-text)
     (partition (dimensions :w))
     (map (partial reduce str))
     (interpose \newline))
user=> (draw "*   \n *  \n  * \n   *")
("121 " \newline "2221" \newline "1222" \newline " 121")

然后将字符串连接在一起:

(->> (mapcat (partial boards-for-line dimensions)
             lines
             (range 0 (dimensions :h)))
     (apply map sum-up)
     (map cell-as-text)
     (partition (dimensions :w))
     (map (partial reduce str))
     (interpose \newline)
     (reduce str))
user=> (draw "*   \n *  \n  * \n   *")
"121 \n2221\n1222\n 121"

覆盖地雷。

现在只有一件事要做:我们需要将原始输入字符串中的地雷覆盖在结果顶部。 这很简单:

(defn overlay-cell [top bottom]
  (if (mine? top) top bottom))

如果我们在REPL中尝试过,则会得到以下信息:

user=> (map minesweeper/overlay-cell 
            "*   \n *  \n  * \n   *" 
            "121 \n2221\n1222\n 121")
(\* \2 \1 \space \newline \2 \* \2 \1 \newline \1 \2 \* \2 \newline \space \1 \2 \*)

我们的输出以列表形式出现,因为那是map始终执行的操作,因此我们需要再次将其重新串起来:

(defn overlay-boards [top bottom]
  (reduce str (map overlay-cell top bottom)))

因此,我们的最终解决方案是这样的:

(defn draw [input-board]
  (let [lines (str/split-lines input-board),
        dimensions {:h (count lines), :w (count (first lines))}]
    (->> (mapcat (partial boards-for-line dimensions)
                 lines
                 (range 0 (dimensions :h)))
         (apply map sum-up)
         (map cell-as-text)
         (partition (dimensions :w))
         (map (partial reduce str))
         (interpose \newline)
         (reduce str)
         (overlay-boards input-board))))

产生这个结果,为了方便阅读,我再次对其进行了格式化:

user=> (draw "*    \n *   \n  *  \n   * \n    *")
"*21  \n
 2*21 \n
 12*21\n
  12*2\n
   12*"

如果您要查看解决方案完整版本,可以在我的Github页面上找到该解决方案完整版本,而不是在此处全部复制。

REPL驱动的开发。

这是一个比该系列中任何其他例子都复杂得多的示例,但是我觉得值得展示如何将函数式编程技术应用于比罗马数字或筛选质数更复杂的问题。 特别是,我希望它已表明currying不仅仅是出于智力上的好奇心,而是组装功能程序的基本工具,以及用于将代码结构化为数据转换管道的功能组合的实用程序。

我希望传达的另一件事是REPL驱动的开发的力量。 我当然不主张用它来代替测试驱动的开发:TDD的主要优点是它拥有了全面的机器可执行测试套件所留下的遗产,而REPL中的测试却无法为您提供。 相反,我认为它们是互补的。 尽管TDD周期的TDD周期约为一分钟左右,但REPL中的反馈环路实际上需要几秒钟。 作为一种使调试时间最短的构造程序的技术,我所知道的是无与伦比的。

下次。

就像我的同事豪尔赫(Jorge)喜欢说的那样,我们将走向无限……甚至更多! 在下一篇文章中,我们将研究懒惰的评估,以及它如何为您提供一切—只要您实际上不要求它。

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

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值