zoukankan      html  css  js  c++  java
  • 《Practical Clojure》学习笔记[3] Clojure中程序流程控制

    http://www.cnblogs.com/debugcool/archive/2012/02/07/Controlling_Program_Flow.html

     1. 函数

      作为一种函数式编程,Clojure程序都是以函数开始,也是以函数结尾的。Clojure程序的结构和树很相似,每个节点都是函数,它的子节点就是对其他函数的调用。要理解Clojure程序就要理解它的函数以及它是以何种模式被调用的。对函数的使用要小心谨慎,要不然你的程序会编程令人费解的“意大利面条”。对函数的使用要深思熟虑,这样你的程序才会简单优雅,无论是写起来还是读起来都是一种享受。

      第一类函数

      在Clojure中所有的函数都是第一类对象,这意味着:

    • 它们可以在整个程序流程中任意点动态的创建。
    • 它们本质上都没有固定名字,却可以被绑定到一个或者多个符号上。
    • 它们的值可以存储在任何数据结构中。
    • 它们可以作为参数传递给任何函数,同样也可以被任何函数作为返回值返回。

      这于大多数静态语言却相反,比如Java和C,在这些语言当中,函数必须在编译之前命名和定义。能够动态的定义一个新函数而且可以将它们存储在任意的数据结构中,这对Clojure和其他函数式编程语言来说是一个巨大的优势。

      用fn定义函数

      一个定义函数最基本的方法就是利用fn这个特殊形式,它在被解析时返回一个新的第一类函数。fn最简单的形式带有两个参数,一个vector(一对方括号)中代表所定义的函数的参数符号,另外还有一个表达式(即当这个函数被调用时所执行的形式)。


    注:vector是由左右方括号括起来的一个集合,第4章会有对它的详细介绍,这里你可以把它想像成一个list,只不过list使用圆括号,而且list会解析为函数的调用,而vector不会,所以在程序中我们经常使用vector来方便地存储多个常量数据结构。


      例如,在REPL中我们定义一个简单的函数用来求两个数的乘积。

    (fn [x y] (* x y))

      这个形式看上去复杂,但其实很简单,它是由其他三个形式组成的:fn,[x y]以及(* x y),fn是要调用的函数,剩下两个是参数,[x y]表示定义的新函数有两个参数x和y,而(* x y)就是函数体,其中x和y都会在函数调用时绑定到各自的值上。可以发现,在这里没有必要用任何的return语法,新定义的函数总是返回我们提供的表达式的值。

      然而,这样还不够,它只是会返回一个新的函数,在REPL中我么可以看到是一串字符串(如下所示),这就是函数的值,但这样的形式并不方便我们使用新函数。

    #<user$eval__43$fn__45 user$eval__43$fn__45@ac06d4>

      而且,糟糕的是你现在还不能使用它,因为它还没有被绑定到任何的符号或者是存入任何数据结构中。这样的话,JVM的垃圾回收机制会回收它,因为它再也没有用了。所以,一种更好的形式是我们将它绑定到一个符号上。

    (def my-mult (fn [x y] (* x y)))

      这样你就可以在程序任何能够访问到它的地方来调用它。

    user=> (my-mult 3 4)
    12

      结果正如我们想的那样。当绑定到符号my-mult时,表达式(fn [x y] (* x y))会被解析为第一类函数。当你调用my-mult时,这个list会被解析为函数调用,将my-mult作为函数,3 和4作为其参数。注意,将函数绑定到一个符号并不是调用函数的唯一方式,其实只要list的第一个形式指向一个函数,那么它都会被调用,不管它是不是一个符号。例如,我们完全可以在定义函数式同时调用它。如下面的代码。

    user=> ((fn [x y] (* x y)) 3 4)
    12

      在这个形式中,整个函数定义形式(fn [x y] (* x y))作为list的第一项了,当解析执行是,它会指向一个函数而3和4就是传递给它的参数,这和把它绑定到一个符号解析执行的方式是一样的。但是,必须注意的是函数和绑定到它的符号是不一样的。在之前的例子中,my-mult并不是函数,它仅仅是绑定到函数的一个符号。当它被调用时,并不是调用的my-mult,而是通过解析它得到一个函数然后在执行这个函数。

      用defn定义函数

      尽管函数和绑定到它的符号是有区别的,但是将以个新定义的函数绑定到一个特殊符号以便在后面调用它这种方式却是很普遍的用法。基于这,Clojure提供了defn这个形式作为定义一个函数并且将它绑定到一个符号的简写。defn其实就相当于是把def和fn组合起来用,但是它用起来更加简单方便。用defn还可以给函数定义一个文档来解释如何使用这个函数。如下图所示。

      在上如中,我们定义了一个函数sq用来求一个数的平方。而且我们还给这个函数定义了一个文档,可以用Clojure内建的doc函数来得到一个函数的文档信息。


    注:doc这个函数在该开始编程的时候非常有用。Clojure所有的内建函数(包括几乎所有的库)都提供了很好的文档,在REPL中可以利用doc很方便的查看某个函数的文档。在编程过程中,我们应该形成一个良好的习惯就是给你的函数都加上文档,即使没有人会阅读你的代码你也应该这样做。你会发现过了一段时间之后,有可能你自己都不知道这个函数具体是怎么用的了,这时候函数的文档就非常有用了,常言说,好记心不如烂笔头。


      多元函数

      元是指函数的参数个数。在Clojure中,可以根据元的不同来预定义多个实现方法(类似于其他语言中,同一个函数名,但是参数个数、类型不同可以有不同的实现)。这类函数的定义同样用的是之前讨论过的fn和defn,稍微有些不同的是,之前定义函数时传的都是一个vector(代表参数符号)以及一个函数实现的表达式,而现在要传的是分别用圆括号括起来的vector/表达式对。如下图所示。

      在上图中,我们定义了一个函数for-test,它根据传给它的参数不同而进行不同的表达式求值。第一种调用方法定义是([] 0),这表示函数如果不带参数调用,返回0;第二种调用方法定义是([x] (* x x)),表明如果只带一个参数调用,则返回这个数的平方;第三种调用方法定义是([x y] (* x y)),是指如果带两个参数调用,则返回这两个数的乘积。这三种调用方式的结果在上图中都可以看到。

      可变参函数

      我们总是遇到这样的问题,一个函数可以接受任意个参数,也就是说函数的元是可变的,而且范围不固定。Clojure中使用了一个特殊符号&,在函数定义的变量符号的vector中用&符号,可以定义可变参函数。使用方法就是在你函数定义的参数符号定义的vector中任何一个你正常定义的参数符号后面加上&以及一个符号名称。当函数被调用时,在你正常定义的参数之后(即&之后)的参数会被添加到一个序列(类似一个list)中,然后将这个序列绑定到你&后面所提供的符号上。如下图所示。

      注意,上图中的(reduce + more)是内建函数,表示对序列more中的元素进行累加。

      正如上面的图中那样,我们定义了一个函数multiply-sum,它可以接受任意个参数,其定义是把第一个参数与后面所有参数的和相乘然后返回结果。可以看到当调用(multiply-sum 5)时,只有一个参数,除开第一个参数之外的参数之和为0,所以结果也即为0了。后面的几个调用的例子就不一一解释了。

      函数快速申明

      虽然fn用来定义函数已经很简洁了,但是在有些情况下完整的写出一个函数的定义还是比较繁琐。典型了,比如我们仅仅在内部定义、使用一个函数,而不需要将其绑定到一个全局的符号上。Clojure提供了一种快捷的函数申明形式,在这个形式中用到了阅读器宏。具体就是在一个#号后面跟上表达式即可。这个表达式就是函数体,而在表达式中的所有%都会被解释为函数的参数。


    注:阅读器宏比较特别,由于它所用到的形式都是除开成对的括号之外的唯一形式,所以它总是能够被解析。在它们被实际编译之前,Clojure会将简洁形式替换为长的、完全的形式。例如,简洁函数形式#(* %1 %2)实际上和完整形式(fn [x y] (* x y))是一样的。阅读器宏仅仅提供了几个非常常见的任务,并且不能被用户自己定义。这是由于,如果在程序中大量的使用阅读器宏,会使得代码阅读性较差,除非你非常了解阅读器宏。不让用户创建自定义的阅读器宏有利于降低代码共享和保持Clojure一致性的难度。但,在一些极其特定的情形下它们又非常有用,所以默认就提供了一个可用的较小的集合。


      例如,求一个数的平方的函数可以像下面这样快捷的申明和调用。

    user=> (def sq #(* % %))
    #'user/sq
    user=> (sq 5)
    25

      函数体中的百分号表示该函数只有一个参数,而且百分号会被解释为函数的参数。要申明一个多变量的函数可以用%后面再跟上一个1~20的数字。例如:

    user=> (def multiply #(* %1 %2))
    '#user/multiply
    user=> (multiply 5 3)
    15

      %1和%都会被解析为函数的第一个参数,%2为第二个参数,依次这样来表示函数的参数。用这种快捷申明的形式会使得代码非常紧凑,特别是在内部申明并调用。例如。

    user=> (#(* % %) 5)
    25

      快捷函数申明唯一的缺点就是可读性差,所以使用的时候要小心谨慎,在它非常短的时候来使用它。另外,注意跨接函数申明是不能嵌套的。

      2. 条件表达式

      程序最本质的特征就是能够根据不同的条件采取不同的处理逻辑。Clojure也不例外,它提供了许多简单的条件形式。

      if是最基本的条件表达式,它的第一个参数是一个测试表达式,如果这个表达式的结果为true,它就返回第二个参数的结果。如果这个表达式结果为false(包括nil),那么它会返回第三个参数(如果存在)的结果或者是nil(没有提供第三个参数)。例如下面的代码。

    user=> (if (= 1 1) "Math still works.")
    "Math still works."
    user=> (if (= 1 2) "Math is broken!" "Math still works.")
    "Math still works."

      Clojure同样也提供了if-not的形式,这中形式和if的使用方法一样,不同的是在相同条件下它们采取的处理逻辑却相反。它返回第二个参数的结果的前提是它的条件表达式结果为false,返回第三个参数结果的前提是其条件表达式的值为true。例如下面的代码。

    user=> (if-not (= 1 1) "Math is broken!" "Math still works.")
    "Math still works."

      有时候,上面的这两种形式仅仅只在true和false中选择的情形下非常有用,但是在有多个条件下做选择的情形就不行了,虽然你可以用嵌套的if或者if-not来实现这个功能,但是更简洁的形式是用cond。cond可以带任意个test/expression(条件/表达式)对,它会首先检测第一个条件的值,如果为true,就返回第一个表达式的结果,否则接着检测下一个条件,这样一直进行下去。如果你提供的条件中所有的都为false,它会返回nil,除非你在最后一个表达式中用:else关键词来提供一个默认值,它永远都为true。例如下面的代码。

    (defn weather-judge
    "Given a temperature in degrees centigrade, comments on the weather."
    [temp]
    (cond
    (< temp 20) "It's cold"
    (> temp 25) "It's hot"
    :else "It's comfortable"))
    user=> (weather-judge 15)
    "It's cold"
    user=> (weather-judge 22)
    "It's comfortable"
    user=> (weather-judge 30)
    "It's hot"

    注:cond非常有用,但是必须小心使用,庞大的条件表达式很难控制,特别是当你程序中可选择的处理逻辑急剧增多的情况下。这时候就要用到Clojure的多态指派multimethods。这将会在第9章详细讲解,multimethods也允许条件逻辑,这和cond相似,但是它更具扩展性。


      3. 局部绑定

      在函数式编程中,所有的值都是通过函数的嵌套调用这样的组成,但是对计算结果有个命名还是有必要的,这可以是代码更加清晰,而且当我们需要多次用到同一个值的时候效率也高一些。

      在Clojure中用let这个形式来实现局部绑定。let允许你依次绑定多个符号以及在一个表达式中来使用这些符号。但是这些符号的作用于仅限于let主体里面。而且它们是不可变的,一旦被绑定,在整个let主体里面都是相同的值。

      let有两个参数,一个是存着绑定信息的vector,另一个是一个表达式。vector中存这很多name/value(名称/值)对。例如下面的代码,就是用let形式将a绑定到2,b绑定到3,然后求它们的和。

    user=> (let [a 2 b 3] (+ a b))
    5

      这是使用let最简单的例子,但是这样的使用方法使得直接使用值代码更加琐碎。还有另外一个引人注目的对let的使用方法,例如下面的代码。

    (defn seconds-to-weeks
    "Converts seconds to weeks"
    [seconds]
    (/ (/ (/ (/ seconds 60) 60) 24) 7))

      函数的意图是将秒转化为周,这样定义同样能用,但是却不是很清晰。嵌套的对除法调用使得大多数人都比较疑惑,而且大多数人还会觉得它貌似把这个简单的功能复杂化实现了,大家可能都有更加简单的方式来实现同样的功能。如此简单的一个函数,像这样写,反倒不好理解了。

      我们可以用let来使这个函数变得清晰,如下面代码所示。

    (defn seconds-to-weeks
    "Converts seconds to weeks"
    [seconds]
    (let [minutes (/ seconds 60)
    hours (/ minutes 60)
    days (/ hours 24)
    weeks (/ days 7)]
    weeks))

      哈哈,这样函数变得更长了,但是实际上我们清除了函数每一步在做什么。利用中间符号绑定minutes, hours, days, and weeks,最后再返回weeks而不是一次性做完所有的操作。这个例子演示了一些风格的选用,它使得代码清晰了但也变长了。什么时候用、怎么用那是你要考虑的,但是最基本的思想就是用let使你的代码更清晰、存储你的中间计算结果,这样你就不需要在多个用的地方重复计算。

      4. 循环和递归

      Clojure中没有提供直接的实现循环的语法,这可能会使得熟悉指令式编程的人来说多少有些惊讶。和大多数函数式编程语言一样,取而代之的是利用递归调用来实现多次执行相同的代码。Clojure鼓励大家使用不可变数据结构,而递归调用相比于典型的指令式迭代更加切合这个思想。

      递归的思考问题是从指令式编程到函数式编程转变的最大挑战,但它的功能很强大,代码也很优雅,更重要的是如何简单的用递归来实现重复的计算学起来很容易。

      大多数程序员都知道递归的最简单的形式——一个函数自己调用自己。这很准确,但是没有理解递归的有用之处以及无何有效的使用它,也没有理解在不同的情形下它到底是怎么工作的。

      在Clojure中如何有效的利用递归(在其它函数式编程中也一样),你只需要记住以下几条规则即可:

    • 利用递归函数的参数来存储和改变一个计算的处理过程。在指令式编程语言中,通常都是通过改变一个变量来控制循环的重复。而在Clojure中,没有变量可以修改,所以需要我们充分利用函数的参数。不要把递归看作是重复地修改一些东西,而是一个函数调用链。每一次调用需要包含计算继续所需要的所有信息。在递归函数计算过程中所修改的任何值或者是任何计算结果都需要以参数的形式传给下一次调用,以便能够继续对它们进行操作。
    • 确保递归有一个边界情况或边界条件。在每个递归函数中都需要测试某个目标条件是否达到,如果达到了就要结束递归并且返回一个值。这有点类似于在指令式编程中要避免无限循环一样。如果代码中没有明确的要停止递归,那么它肯定不会停止。很明显,这会带来无限递归的问题。
    • 每一次迭代我们都必须确保向目标条件靠近一步,否则,我们就不能保证它什么时候能够结束。通常情况下,我们会让某个数字变大或者变小,然后以测试这个值是否到达某个特定的区间作为边界条件。

      例如,在下面的Clojure代码中,我们利用牛顿算法来递归的计算一个数的平方根。它有一个主函数和一些帮助函数来演示了递归的种种特性。下面是sqrt.clj的代码。

    ; sqrt.clj
    (defn abs
    "Calculates the absolute value of a number"
    [n]
    (if (< n 0)
    (* -1 n)
    n))

    (defn avg
    "returns the average of two arguments"
    [a b]
    (/ (+ a b) 2))

    (defn good-enough?
    "Tests if a guess is close enough to the real square root"
    [number guess]
    (let [diff (- (* guess guess) number)]
    (if (< (abs diff) 0.001)
    true
    false)))

    (defn sqrt
    "returns the square root of the supplied number"
    ([number] (sqrt number 1.0))
    ([number guess]
    (if (good-enough? number guess)
    guess
    (sqrt number (avg guess (/ number guess))))))

      然后,我们可以在REPL中加载这个文件,并调用sqrt函数,如下图所示。

      在上图中,如所期望的一样,函数返回结果于准确的平方根在误差在0.001以内。

      在sqrt.clj中,定义的前三个函数abs, avg, and good-enough?,它们都是辅助函数,在这里不用深究它们,当然,如果你愿意也可以。所有的算法和逻辑其实都是在sqrt函数中定义的。

      最明显的一点,sqrt函数根据参数不同有两种实现。第一种实现方法可以看作是一个共用接口,调用起来很简单,只有一个参数:你需要求哪个数字的平方根。第二种是递归的实现,带有两个参数,需要求平方根的数和以猜测的平方根。第一种实现方式仅仅简单的调用第二种,初始猜测的平方根是1.0。

      递归本身的实现很简单。首先是测试边界条件是否满足,即调用函数goodenough?,它返回你的猜测值与准确的平方根之间误差是否足够小。如果这个条件满足,那么函数将不会在继续递归调用了,而是把当前的猜测值作为答案直接返回。如果边界条件不满足,那么它会调用自己进行递归计算,它把需要求平方根的数和猜测的数作为参数继续进一步计算,这满足了前面提到的三个规则的第一个规则。

      需要注意的是,传递给下一次迭代计算的猜测值是(avg guess (/ number guess)),表示当前的猜测值和需要求平方根的数除以当前猜测值的平均值。由于平方根的数学性质保证了新的猜测值会比前一个猜测值更靠近真实的平方根。这满足了前面所提到的三条规则的最后一条,即每次迭代都必须保证处理过程更加接近结果。sqrt函数的每一次运行,都保证了guess(猜测值)更加接近真实的平方根。最终,它会接近到使得good-enough?返回true从而结束运算。

      另外一个例子是利用递归来计算指数。代码如下所示。

    (defn power
    "Calculates a number to the power of a provided exponent."
    [number exponent]
    (if (zero? exponent)
    1
    (* number (power number (- exponent 1)))))

      可以像下面这样调用函数。

    user=> (pow 5 3)
    125

      这个函数也用到了递归,但是和之前的sqrt函数有些不同。pow函数利用了xn = x * xn - 1这个等式,这在函数的递归调用中可以看到,返回结果是number乘以指数减1的调用结果。边界条件是看指数是否为0,如果是则返回1,因为x0始终是1。由于每一次迭代,指数都会减去1,所以这个边界条件肯定会达到的(只要你传的指数是个非负数)。


    注:当然,还有比上面更简单的方式来实现sqrt和pow函数。在Java标准的数学库中这两个函数也是存在的,通过Clojure来调用也是极其简单的。上面只是用来演示Clojure递归逻辑的例子。后面章节也会讲到在Clojure中如何调用Java库函数。


      尾递归

      递归有一个很实际的问题,那就是由于武力计算机的硬件限制,对函数的嵌套层数(也即系统栈的大小)有限制。在JVM中,这个限制是可以变化的,而且可以很大。在我的机器上,这个限制大概是5000。无论这个限制(栈)有多大,都有一个问题,那就是函数的递归调用深度有着严格的限制。对小程序而言,这影响很小。但是如果递归是通用的用来完全替代循环,这就会带来问题。在很多情形下,我们需要无限制的进行迭代或递归。

      以前的函数式编程通过尾部调用优化来解决这个问题。尾部调用优化是指如果某个特定的条件达到,编译器可以优化递归调用使其不会消耗栈空间。实际在系统中,通过编译后的机器码迭代来实现。

      在大多数函数式编程语言中,需要递归调用优化的仅当函数调用出现在尾部的时候。而对尾部位置的定义有很多种,最容易记住也是最重要的是一个函数返回值之前最后做的事情。如果外部函数的返回值完全委派给内部函数,那么这个函数调用就在尾部位置。如果外部函数除了返回值以外做了所有的对这个值的计算逻辑,那么这个内部函数不是尾递归,也就不能被优化。如果理解了调用栈的本质,理解这个就很容易了。如果一个调用发生在尾部位置,程序可以有效的完全忘记它是递归调用的,而把整个程序流程委托给内部函数的结果。但是如果还有另外的流程需要处理,那么编译器就不能完全抛开外部函数,它需要保留下来以便于完成它的结果的计算。

      例如,在前面pow函数的例子中,pow函数的递归调用就不是在尾部位置,因为外部函数并不只是简单的返回递归调用函数的结果,在得到递归函数调用结果后还做了另外一次乘法运算然后才返回的结果。所以它不能被优化。另一方面,sqrt函数的递归调用是在尾部位置,因为整个函数就是用这个递归调用来返回结果的,没有其它的处理逻辑了。

      Clojure中的recur形式

      在一些函数式编程语言中,例如Scheme,只要一个递归调用出现在尾部位置,那么尾部调用优化会自动触发。Clojure不会这样,要使用尾递归,必须用recur这个形式来明确的指出。

      要使用recur,你需要在你递归调用的地方用recur来替换你的函数名称。它会自动的调用外部函数并触发尾部调用优化。

      例如,我们定义一个从1+2+3……+n(n为函数参数)的函数。代码如下所示。

    (defn add-up
    "adds all the numbers below a given limit"
    ([limit] (add-up limit 0 0 ))
    ([limit current sum]
    (if (< limit current)
    sum
    (add-up limit (+ 1 current) (+ current sum)))))

      根据递归的规则,这个函数能够正常工作也是合法的。它以current(当前加到哪个数了)、sum(到目前为止的和)以及limit(上限)为参数,边界条件就是current是否大于limit,如果满足就证明我们加完了。每一次迭代将current加1,这就会离边界条件更近一步。我们可以像下面这样调用这个函数。

    user=> (add-up 3)
    6
    user=> (add-up 500)
    125250
    user=> (add-up 5000)
    java.lang.StackOverflowError

      可以看到,在较小的参数下(3、500),它能够返回正常的结果,但是当传一个大参数(5000)时,会得到java.lang.StackOverflowError异常,这是就需要尾部调用优化了。像下面这样将尾部的递归调用adds-up替换为recur,重新定义函数。

    (defn add-up
    "adds all the numbers up to a limit"
    ([limit] (add-up limit 0 0 ))
    ([limit current sum]
    (if (< limit current)
    sum
    (recur limit (+ 1 current) (+ current sum)))))

      现在你可以传递大参数试一试。

    user=> (add-up 5000)
    12502500

      如上面结果所示,没有问题了。利用recur,对递归调用层次的唯一限制就是你愿意等待程序处理多久。


    注:如果不用recur,在可以使用尾部调用优化的地方Clojure也默认不触发,这使得Clojure遭到了很多抨击。由于JVM的栈限制促使Clojure有了recur,这也使得要想自动触发尾部调用优化很难,但是,许多Clojure社区的成员发现,显式的使用尾递归比隐式的清晰、方便。在Clojure中你可以很容易的区别一个函数调用是否是尾递归,这也避免了错误。如果你使用了recur,可以保证不会因为递归而导致栈空间爆掉。如果你在不合适的地方使用了recur,编译器会报错误信息,这样你就不用去考虑什么地方改用什么地方不该用。


      循环

      loop这种特殊的形式,如果结合recur使用,可以在定义一个递归函数的同时调用这个函数,这使得尾递归更加简单。逻辑上,loop和定义一个函数然后立即递归的调用这个函数没什么区别。但是,它使得代码的逻辑流程更加容易阅读,也就更加容易理解迭代是怎么循环的,尾递归的本质和这是一样的。

      用loop这种形式来定义一个循环结构。它带有两个参数,第一个是一个初始参数绑定(名称/值对的形式)的vector,第二个参数是一个表达式。在循环的主体中无论什么时候recur被调用,它都会再一次递归的调用这个循环,并且将参数绑定到循环体中定义的相同的参数名称上。

      例如,下面这段代码的作用就是将i初始化为0然后递归的将其增加到10,然后返回值。

    (loop [i 0]
    (if (= i 10)
    i
    (recur (+ i 1))))

      注意,和任何递归函数一样,这个循环的主体也包含了边界条件,而且在每一次迭代过程中都向着这个边界条件靠近。不同的是,不用定义函数。loop构建你的函数、初始化一些值,然后用recur来递归调用循环体。你可以把它等同于函数的递归调用,也可以看作是迭代循环,而且在每一次迭代中改变一些值。

      这非常有用,所以在几乎所有需要用到recur的地方都会和loop组合起来使用。一种极其常见的情况就是用其它函数式编程语言来实现一个递归函数,一般是写两个函数,一个递归的,一个是非递归的,在非递归函数中,初始化一些值,然后调用递归函数。这是良好递归风格自然而然的结果——递归函数可能有很多个参数来保持跟踪它的计算状态,但是这些参数并不总是需要函数最终调用者接触到。loop的功能可以使这变得更加简洁。例如下面这个例子,下面的代码是前面提到的sqrt函数,将其中的直接递归调用编程了recur。 

    (defn sqrt
    "returns the square root of the supplied number"
    ([number] (sqrt number 1.0))
    ([number guess]
    (if (good-enough? number guess)
    guess
    (recur number (avg guess (/ number guess))))))

      注意这个函数的两个实现,第一个初始化参数guess的值然后触发递归调用。我们可以利用loop重构这个函数,在同一步中完成这两件事。

    (defn loop-sqrt
    "returns the square root of the supplied number"
    [number]
    (loop [guess 1.0]
    (if (good-enough? number guess)
    guess
    (recur (avg guess (/ number guess))))))

      这个版本只有一个函数实现。loop初始化参数guess然后立即执行它的循环体。当recur被调用时,循环体又被调用,而不是调用整个函数。传递给recur的参数都会在loop里面匹配,所以每一次迭代guess都会绑定到新的值。重复执行的代码仅仅是loop与recur之间的那些。

      5. 慎重处理副作用

      在第二章我们讨论过,为了纯函数风格,Clojure尽可能的避免副作用。但是,有一些任务,例如IO、明确的状态管理以及和Java的相互作用,它们的本性就带着副作用。这些不能被合并到完全函数式程序中,所以Clojure提供了一些结构来显示的运行副作用。

      用do

      最重要也是最基本的运行副作用的方式就是用do这个特殊的形式。do很简单,它带多个表达式作为参数,解析执行它们并返回最后一个表达式的值。这意味着从函数式的角度来看,除了最后一个表达式,其它的都被忽略了。这也仅作为执行副作用的一种方法。

      例如,要执行println函数,println本身就是副作用,因为它就是输出。它返回nil,这在函数式程序(要返回一个有意义的值)中并不合适。下面的代码利用do执行多个println作为副作用,然后返回一个不同的值。

    user=> (do
    (println "hello")
    (println "from")
    (println "side effects")
    (+ 5 5))

      在REPL中可以看到下面的输出。

    hello
    from
    side effects
    10

      前面三行是对println的调用结果的输出,而最后一个数10是do这个形式的返回值并输出到REPL上的,并不是副作用。只要do形式被调用就会有副作用,这于在不在REPL中并没有关系。

      函数定义中的副作用

      如果你有函数也需要执行副作用,Clojure提供了在用fn或者defn定义的函数中,或者是在loop的循环体中直接执行副作用的方法,而不需要显示的调用do这个形式。很简单,你可以直接在函数体或者loop的循环体中使用多个表达式而不是一个。同样最后一个表达式的值会被作为函数的返回值,而其它的都会仅被当做副作用。

      例如下面定义了一个求一个数平方的函数,从函数的角度来看,它和本章之前的那个函数没什么区别,但是除了返回值之外,它还产生了副作用(调用println)。

    (defn square
    "Squares a number, with side effects."
    [x]
    (println "Squaring" x)
    (println "The return value will be" (* x x))
    (* x x))

      和do一样,只有函数定义的最后一行的值会被作为返回值,在REPL中调用这个函数,你可以看到下面这样的输出。

    user=> (square 5)
    Squaring 5
    The return value will be 25
    25

      这样的方法同样适合于fn定义的函数:仅仅需要在返回值的表达式之前添加额外的表达式即可。这种方式非常有用,例如利用记录日志来跟踪一个函数的调用。

      6. 函数式编程技术

      如前面所描述的那样,我们已经知道了在Clojure程序中如何申明函数、定义控制流程的基本方法。这些都是构成Clojure程序的基础的、最根本的组建。Clojure大多数标准库也是用这些基础的结构来表达的(除了一些基于宏的形式,这会在12章讲到)。

      然而,要写出一个漂亮的Clojure程序,你不仅需要知道这些基本的形式,还要掌握一些高效的使用它们的技术以及知道、理解Clojure允许你做的所有事情。这些技术仅对Clojure来说没有什么意义,它们大都是所有函数式编程语言的共性。

      第一类函数

      函数本身也是值,也可以传递给其它函数或有其它函数作为值来返回。在函数式编程中这是一个很重要的特性。这不只是可以用来在代码中做一些聪明的trick,而是用来组织程序的一个很关键的方式。这样能够写一些非常通用的代码并且几乎能够消除代码重复。

      对第一类函数的运用主要有两个方面:把它们作为参数、调用它们;创建、返回它们。前一种用法很常见,概念上也很简单,后面的用法体现出来的功能性很强大。

      消耗第一类函数(将函数作为其它函数的参数)

      将一个函数作为另一个函数的参数,这很常见。这也就是所谓的高阶函数。在Clojure中许多序列操作库都是基于这种技术来实现的。

      允许将一个函数做为另一个函数的参数的主要目的就是为了使得函数更加通用。通过参数函数来指定操作逻辑,那么外部函数就会更加通用,在更多的场景下适用。

      例如,下面这个例子中是计算一个带有两个参数的函数的值以及将两个参数交换为止后的结果。关键是注意它对任意两个参数的函数都适用,可能你在设计的时候只想着一个函数,但是它对其它的函数同样适用。

    (defn arg-switch
    "Applies the supplied function to the arguments in both possible orders. "
    [fun arg1 arg2]
    (list (fun arg1 arg2) (fun arg2 arg1)))

      这个函数返回包括两项的一个list结构,第一项是用两个参数原顺序调用函数,第二项是将两个参数调换顺序再调用函数。可以在REPL中测试一下。

    user=> (arg-switch / 2 3)
    (2/3 3/2)

      上面,传给arg-switch三个参数,函数/(除)以及两个数字2和3。它返回一个list结构,第一项是2/3,第二项是3/2,都是以分数的形式表示的,这是因为分数是Clojure表示有理数的默认方式。

      同样,当你给arg-switch传递其它函数时同样能够适用。

    user=> (arg-switch > 2 3)
    (false true)

      当传递比较函数>给arg-switch时,它返回(> 2 3) 和 (> 3 2)的结果,即(false true)。arg-switch对于非数字参数也是适用的,你可以像下面这样实现字符串连接功能。

    user=> (arg-switch str "Hello" "World")
    ("HelloWorld" "WorldHello")

      你还可以传递一个自定义函数给它。

    user=> (arg-switch (fn [a b] (/ a (* b b))) 2 3)
    (2/9 3/4)

      如你所见,通过允许将函数作为函数的参数,你可以很方便的实现一个通用的、灵活的、在各个场景都适用的函数。这相比于对于不同的操作写不同的代码逻辑来实现方便多了。当程序变得复杂后,这个优势也就更加明显了。函数可以完全关注自己的核心逻辑而将其它的指派到别的操作中去。

      产生第一类函数(将函数作为函数的返回值)

      函数不仅能够作为其它函数的参数,也能够作为其它函数的返回值。但是在这过程中如果不保持代码的整洁和清晰的话,可读性就差了,尽管这样,这仍然是一个非常强大的功能点。这也是历史上Lisp语言和人工只能联系在一起很重要的原因,函数能够在其它函数中被创建这一点允许机器能够推演并且定义自己需要的操作。尽管能够自我调整的程序从来没有辜负人们的期望,但是这种能够动态的定义函数的功能还是非常强大的,而且在日常一些编程任务中也经常用到。

      例如,下面这个函数只是简单的创建一个函数来检测某个数字是否在一个给定的范围内。

    (defn rangechecker
    "Returns a function that determines if a number is in a provided range."
    [min max]
    (fn [num]
    (and (<= num max)
    (<= min num))))

      可以在REPL中调用它,并保存它的结果。

    user=> (def myrange (rangechecker 5 10))
    #’user/myrange

      现在,你相当于创建了一个新的函数myrange,可以试一试。

    user=> (myrange 7)
    true
    user=> (myrange 11)
    false

      如果你仅仅只有一个范围需要检测,直接写肯定更加简单一些。但是在程序中可能会动态的产生范围或者范围有成千上万哥不同的,这时候创建一个“函数工厂”函数rangechecker就非常有用了。对于一些相比于检查范围更加复杂的函数,这个优势就更加明显了,因为动态创建的函数不需要我们手动的写许多复杂的逻辑。

      闭包

      Clojure的名字可能也于闭包(closures)有关,闭包是Clojure的核心特征,那么闭包到底是什么呢?为什么影响这么大呢?

      简单的说,闭包就是值和代码一样的第一类函数。这些值的作用域是在函数申明范围内,并且随着函数一起保存。无论函数在什么时候定义,它里面绑定到符号的值都是随着它一起存储的。它们闭包在函数中并随着函数一起被操控。这意味着在函数的整个生命周期它们都是有效的,同样的,函数也可以是闭包的。

      例如,前面定义的rangechecker函数就是一个闭包。内部函数定义引用了min和max这两个符号。如果这些值不封闭,不是作为内层定义函数的一部分,那么当内部定义的函数被调用的时候,这些值的作用返回就在函数之外了。所以,这些生成函数会一直保持这些符号,使得它们被调用时这些符号是合法可用的。

      一旦函数被创建了,这些闭包的值也就不可变了,事实上,它们已经成为这些函数内部的常量了。

      闭包另外一个有趣的性质是它们的双重性:既是行为也是数据。这个特性在面向对象中可以扮演一些对象的角色。就像Java中只有一个方法的匿名内部类用来模拟第一类函数一样,闭包也可以看作是只有一个方法的对象。如果你把这个方法实现为通用的到闭包的消息分发器,这就是一个面向对象系统的开始(尽管这对大多数程序来说是矫枉过正)。在行为和数据同等重要的情况下创建闭包是很常见的。

      局部套用和组合函数

      局部套用——由Moses Schonfinkel首次发明,但是由Haskell Curry命名,是指通过将参数封装到闭包中来把一个函数的参数变少的过程。这样操控函数比较方便,因为它允许我们创建新的、自定义的函数而不需要显示的写出定义。

      用partial实现局部套用

      在Clojure中,任何函数通过partial都可以被局部调用,partial以函数作为第一个参数,后面还可以跟任意个额外的参数。它的功能和第一个参数函数的功能类似,但是却只需要更少的参数。它将partial的额外参数也作为自己的参数。

      例如,*这个函数通常都带有两个参数,但是如果你需要只要一个参数的本版,你可以适用partial局部调用它,并将一个特定的值作为它的额外参数。例如下面的代码。

    user=> (def times-pi (partial * 3.14159))
    #’user/times-pi

      现在你可以只用一个参数来调用times-pi函数了,如下所示。

    user=> (times-pi 2)
    6.28318

      注意(times-pi 2)和(* 3.14159 2)是完全相等的。上面所做的就是调用*函数,而且还指定了其中一个参数。你也可以手动的像下面这样定义。

    (defn times-pi
    “Multiplies a number by PI”
    [n]
    (* 3.14159 n))

      像上面那样,通过指定一个值然后对*函数进行封装,这样尽管比较繁琐,它消除了这种明确写出简单封装函数的需要,这使得我们眼前一亮。前面用partial定义的函数和手动定义的time-pi功能完全一样,但是用particular你可以很清楚的明白times-pi这个函数只是利用乘法函数和一个特殊的值的封装。这使得代码很容易跟踪,更加准确的反应了代码的抽象逻辑。

      用comp实现组合函数

      局部套用和组合函数结合起来功能更加强大。从某种意义上来说,每一个函数都是组合函数,因为在函数定义当中都会用到其它的函数。然而,用comp这个函数我们能够只依靠已经存在的函数来组合来简洁的创建新的函数而不需要指定实际的函数体。

      comp函数可以有任意个参数:每一个参数都是函数,它返回一个从右至左调用其参数函数的函数。从最右边开始,调用函数并将结果传递给下一个函数,依次这样。因此,comp返回的函数的元数(参数个数)和它最右边的参数函数的一样。最后返回的值是其最左边参数函数的结果。

      为了更好的理解comp,我们可以在REPL中示例一下。

    user=> (def my-fn (comp - *))
    #'user/my-fn

      上面定义的my-fn函数可以带任意个参数,将它们相乘,把得到的积求反就是最后的结果。如下面的调用示例。

    user=> (my-fn 5 3)
    -15

      可以看到,和期望的一样,结果就是-(* 5 3)即-15。首先,最右边的参数函数被调用,在这个例子中是“*”函数,即*(5 3),结果为15,然后这个结果会传递给下一个参数函数,在本例中是“-”函数,它也是最左边的函数,那么整个结果即为-15。这时你就可以适用comp,因为整个计算逻辑可以被展开为求积、求反。当然,我们也可以像下面这样手写一个功能完全相同的函数。

    (defn my-fn
    “Returns –(x * y)”
    [x y]
    (- (* x y)))

      然而,由于这里仅仅这是简单的乘法和求反的组合,所以看上去它和用comp来表述一样简单。

      要注意到,comp的参数函数传递结果给后续的函数时都只是一个参数,这种再用上partial来实现局部套用就相当完美了。例如,你需要一个和上面定义的功能差不多的函数,但要增加额外的一步,要将最后的结果乘以10。简单算术的表达就是说你要实现10 * -(x * y)这个算式的功能。

      正常来说,仅仅适用comp是不能完成这个功能的,除了最右边的参数函数,comp其它的参数函数都只能带一个参数,但是最后一步乘以10需要两个参数。这时候利用partial的局部套用就可以很好的解决这个问题了,可以将partial的结果作为comp的一个参数。例如下面的代码。

    user=> (def my-fn (comp (partial * 10) - *))
    #'user/my-fn
    user=> (my-fn 5 3)
    -150

      如我们预期的一样,当“*”函数和“-”函数执行完了之后得到结果-15,这时候将其传给partial所创建的函数(将参数乘以10),这就得到了最终结果-150。

      上面这个例子只是简单的演示了如何运用comp和partial来通过已经存在的函数组合创建更复杂的函数。适用局部套用和组合函数也可以使得你的代码清晰简洁。通常情况,我们都可以利用单行简单函数的组合来替代庞大复杂的多行函数。

      7. 总结

      本章涵盖了Clojure程序中许多基础结构:函数、递归以及条件逻辑。要想高效的运用Clojure,和这些基础的结构打好交道是很重要的一点。

      然而,不像其它语言那样,Clojure并不止步于这些基础的结构而是要基于这些基础结构。很显然,我们可以仅用这些基础的结构就能够构建一个庞大的复杂的程序。条件、循环以及函数调用就能够实现很多东西了,毕竟这在某些语言中只有这些可用的工具。但是,这样程序只是水平增长,只是越来越多的条件、越来越多的循环、越来越多的函数以及越来越复杂的循环和递归的堆积而已。然而,修改维护这样的程序的代价也是线性的,小的改动或许只需要较小的工作量,如果是大改动,就够你受了。

      Clojure鼓励我们在已有的基础结构上面来构建自己的控制结构(而不是直接使用它们)从而使程序垂直增长。第一类函数和闭包都非常适合做这些。通过识别你自己程序、问题领域的特定模式,然后用基础的结构来构建自己的控制结构,这比那些基础结构功能强大的多,是那些基础的结构所不能及的。同时,你的程序可以很容易的以次线性的代价来进行扩展和修改,小改动大改动都不难,因为现在语言本身就已经定制化到你特定的问题领域了。

      例如,我们完全可以通过手工递归来处理集合,而在Clojure中提供了高阶操作函数如map,reduce,filter等使得这只是一个普通的任务。在第5章会详细讲到,对于集合的操作仅仅需要一行简单的形式而不是针对于每一个场景完全新建一个递归函数。同样可以把这种理念应用到任何其它问题领域中,Clojure提供集合操作是应为集合几乎在所有的问题领域都有用到。同样你也可以定制特定的数据结构来解决你特定的问题。不要仅仅是为了完成某个功能来解决某个问题,而是要利用高阶函数(或后面要讲到的宏)来构建可以辅助解决那种类型问题的上层工具。

      如果设计的好,当Clojure程序的达到一定的复杂程度后,你会发现它更像是一种高度自定义的领域定制语言(Domain Specific Language,DSL)。这不需要什么额外的工作,自然而然的就这样了,而且相比于重复使用基础结构的程序来说它的程序更小、更加轻量级一些。loop、recur以及cond这些形式是很有用,但是它们只应该是程序的构建块而不是程序本身。当一个项目这样思考的时候,你会惊讶的发现它所需要的结构那么少。


    以上。

    译自《Practical Clojure》CHAPTER 3, Controlling Program Flow

  • 相关阅读:
    ASP.NET伪静态配置
    拖动条控件 (UISlider)
    进度环控件 (UIActivityIndicatorView)
    进度条控件 (UIProgressView)
    图像控件 (UIImageView)
    分段控件 (UISegmentedControl)
    CocoaPods安装和使用教程
    iOS 在UILabel显示不同的字体和颜色
    iOS开源项目(新)
    iOS开源项目(旧)
  • 原文地址:https://www.cnblogs.com/chulia20002001/p/2341520.html
Copyright © 2011-2022 走看看