Clojure 学习笔记 :11 函数组合

Clojure 零基础 学习笔记 偏函数 串行宏 高阶函数 闭包


函数组合 --- 简单而又有力的武器

在函数式编程中,我们偏爱使用不可变值声明式的处理,以及函数组合来解决问题。我们已经在之前的章节里简要介绍了不可变值以及声明式遍历,而函数组合,其实早在一开始学会如何使用函数的时候,就已经开始运用这种技巧了。

提起可组合性,你可能会想到面向对象编程中把一个对象包裹在另一个对象中的行为,也许脑海中还会蹦出几个设计模式的名字,或者你对其一无所知脑中一片空白。不过这都不重要,暂时忘记之前你所了解到的繁琐的面向对象编程中的组合,这次我们所介绍的函数组合简单易用又强大。

何为函数组合?顾名思义,就是把多个函数拧巴在一起形成一个新函数。
Clojure 提供了许多工具来帮助你进行函数组合。


comp

使用 comp 函数(也就是英文 composition 的前四个字母)可以把几个函数组合成一个函数。
把大象装进冰箱需要三步:

  1. 打开冰箱
  2. 把大象塞进去
  3. 关上冰箱
(def refrigerator {:open? false, :content ["milk", "apple"]}) ;; 冰箱

(defn open-it
    [container]
    (if (:open? container)
        container
        (assoc container :open? true)))
                
(defn close-it
    [container]
    (if (:open? container)
        (assoc container :open? false)
        container))
        
(defn put-in
    [container something]
    (let [{:keys [open? content]} container]
        (if open?
            (assoc container :content (conj content something))
            container)))

(defn put-elephant-in
    [container]
    (put-in container "elephant"))
    
((comp close-it put-elephant-in open-it) refrigerator)
;; 上述表达式的值为
;= {:open? false, :content [milk apple elephant]}

如果前一个执行的函数的返回值并不能作为后一个函数的参数,那么在执行的时候就会出现问题。
注意,comp 组合的函数执行顺序是从右往左的。
如果不使用 comp,那么也可以有下面这样等价的调用方式:

(close-it (put-elephant-in (open-it refrigerator)))

这也是为啥 comp 要以看起来很奇怪的从右往左的顺序执行的原因。

通常情况下,简单起见,类似 put-elephant-in 这种用于某一特定情形下只使用一次的函数,可以使用“匿名函数的字面量”来简化它。(函数字面量在第 8 节 Clojure 学习笔记 :8 遍历元素 中有所介绍。)
也就是不需要单独定义它,而是直接在需要的位置填写函数字面量:

((comp close-it #(put-in % "elephant") open-it) refrigerator)
;= {:open? false, :content [milk apple elephant]}

为了使之应用于更广泛的行为 --- 把任意东西放进冰箱,可以再次将其改写为一个高阶函数:

(defn put-some
  [something]
  (comp close-it #(put-in % something) open-it)) ;;返回值是一个函数!

然后就可以这么来使用了:

((put-some "elephant") refrigerator)
;= {:open? false, :content [milk apple elephant]}

在第五集中,我们已经简单接触了高阶函数。所谓高阶函数,就是说这个函数可以接受函数作为参数,或,返回值是函数。按照这个说法,高阶函数其实随处可见。比如上面介绍的 comp 自然就属于高阶函数。
除了使用 Clojure 预先提供的高阶函数来进行函数组合,我们还可以自己来编写高阶函数,比如 put-some 函数。


串行宏

->->> 称为串行宏,它的功能与 comp 基本一致,如果你不想用 comp 那么可以试试这两款。

(-> (open-it refrigerator)
    (put-elephant-in)
    (close-it))
;= {:open? false, :content [milk apple elephant]}

;; 当然也可以使用函数字面量
(-> (open-it refrigerator)
    #(put-in % "elephant")
    (close-it))
;= {:open? false, :content [milk apple elephant]}

-> (一个减号,一个大于号),它接受一系列表达式,并把第一个表达式的值作为第二个表达式的第一个参数,然后求出第二个表达式的值,然后再将这个值作为第三个表达式的第一个参数……
这样说可能并不是很清晰。我们用更形象的方式来描述一下。

(-> (open-it refrigerator) ---↴       ;; 移动下来
    (put-elephant-in   ______________ ) 
    (close-it))
;; 等效于
(-> (put-elephant-in (open-it refrigerator)) 
    (close-it))
;; 再次移动
(-> (put-elephant-in (open-it refrigerator))  ┄┄┄┐       ;; 移动下来
               ┌┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┘
               ↓    
    (close-it ___ ))
;; 等效于
(-> (close-it (put-elephant-in (open-it refrigerator))))
;; 无法再继续移动,移动完毕,最终结果
;; (close-it (put-elephant-in (open-it refrigerator)))
;= {:open? false, :content [milk apple elephant]}

->> (一个减号,两个大于号),它的效果与 -> 显著的区别就是:
-> 把上一表达式的值作为下一表达式的第一个参数
->> 是把上一表达式的值作为下一表达式的最后一个参数
你可以这么来记忆,“长箭头会把内容向右推的更远,而短箭头力气比较小,所以只能推到第一个参数的位置”。

另外,如果后续表达式所需的参数只有一个(如本例),也就是无需区分第一个参数与最后一个参数,那么使用两种串行宏的效果都是一样的。
而且,后续表达式无需使用括号括起来,也一样可以执行:

(-> (open-it refrigerator)
    put-elephant-in
    close-it)
;= {:open? false, :content [milk apple elephant]}

小贴士:
这里所说的 宏 (macro) 并不是函数。与函数不同,宏会在代码“运行”之前对代码做一些“调整”,它是 Lisp 的终极武器。


偏函数

在进行函数组合的时候,你可能需要固定某个函数的前几个参数值,比如设置一些默认值,简化使用。在 Clojure 中可以使用 partial 来实现这种功能,称为偏函数。
举个栗子,比如假设有一个函数用来访问服务器:

(connect-server "8.8.8.8" "53" "data.........")

每次都要输入服务器 IP 和 端口是不是太麻烦了?这时候 partial 就派上用场了。

(def connect-googledns (partial connect-server "8.8.8.8" "53"))
(connect-googledns "data1.........")
(connect-googledns "data2.........")

partial 是一个高阶函数,它效果是构造出一个新函数并返回这个新函数,新函数其实是预先指定了老函数开头的几个参数。
你可能会发现其实函数字面量或者自己手写高阶函数也可以实现类似的功能:

(def connect-googledns #(connect-server "8.8.8.8" "53" %))
;; 或者
(defn connect-googledns
  [data]
  (connect-server "8.8.8.8" "53" data))

而且它们还不限制指定参数的顺序,partial 却必须以顺序指定参数。的确是这样。
但是 partial 的优点是不需要了解函数有多少个参数,只指定第一个参数一样可以工作:

(partial connect-server "8.8.8.8")

字面量则需要手动填上每个参数的位置:

#(connect-server "8.8.8.8" %1 %2)

所以,如果函数的参数个数可变或者个数比较多,你又想固定开头的某些参数,那么你可以考虑 partial


闭包

这个概念看起来很神秘。其实在上面的例子中,我们已经使用了闭包。
闭包的表象是:一个高阶函数 A,它返回一个函数,而且返回的函数的某些参数由 A 来提供。
或者说:某个函数的参数由外部作用域提供,而不是自身作用域提供。
也就是说,闭包的不严谨定义就是:某一局部绑定的值在其生存期外依然可以被访问,因为这个局部绑定被某种东西“包裹”了起来(在 Clojure 中也就是作为函数参数,被函数包裹起来),然后被作为返回值返回了。这个返回值被其它位置引用,所以依然不会被回收。

好吧你可能晕了。我们来看一下 put-some 函数:

(defn put-some
  [something]
  (comp close-it #(put-in % something) open-it)) ;;返回值是一个函数!

这就是一个典型的闭包。为什么呢?
你看,(comp close-it #(put-in 参数1 参数2) open-it) ,本来是需要两个参数的,然而在 put-some 中,也就是在 (comp close-it #(put-in 参数1 参数2) open-it) 的外层,对其 参数2 进行了赋值,然后将这个函数作为返回值返回。
于是参数 something 的值,就被包裹在 (comp ......something...) 中返回了出去。(有种偏函数的感觉)

其实 #(put-in % "elephant") 也可以看成是一个闭包 --- "elephant" 是由外界提供。

顺带一提,Clojure 这个单词就来自于闭包的英文 closure 配上 Java 的首字母 J 。


作者的絮叨:
终于找到工作了,成功的成为上班族。
所以受限于本人的 Clojure 水平以及时间,这个系列可能会慢下来了(喂,本来就更新的很慢好么)。
不过我会尽量继续更新,继续分享我的想法。
同样继续欢迎各位批评指正,毕竟我也是初学者。只希望为 Clojure / Lisp 的普及做一点微小的贡献(推眼镜)。

下次见。


你可能感兴趣的:(Clojure 学习笔记 :11 函数组合)