8.5 以测试驱动开发的方式探索monad
在函数式编程中,monad用于创建简单的组件——以安全的方式串接一系列操作。每个组件都封装了一个值,并确保接下来将调用的组件能够将其输出作为输入。例如,如果组件A的输出为nil(null),而链条中的下一个组件不能将nil作为输入,整个链条的计算将自动停止。
在纯粹的函数式编程语言Haskell中,大量地使用了monad,在其他函数式编程语言中,monad也有用武之地。这里将简要地介绍monad,但不涉及复杂的理论和背景知识。
我们将创建一个简单的monad,它返回一条格式良好的消息:在传入的字符串前后添加大量的星号。请打开文件src/exploring-monads/core.clj,将其中的代码替换为下面的代码(我们将对这些代码进行单元测试)。函数pretty-msg的初始实现很简单,这让我们能够在提交这个API前检查它是否正确:
(ns exploring-monads.core)(use 'clojure.algo.monads)(defn pretty-msg [msg asterisk-amount](str ""))
将这个文件存盘。下面来定义用于存储单元测试的源代码文件。为此,请打开文件test/exploring-monads/core_test.clj,并用下面的代码替换其既有内容:
(ns exploring-monads.core-test(:require [clojure.test :refer :all][exploring-monads.core :refer :all]))(deftest test-sane-parameters(testing "pretty-msg with with sane parameters"(is (= (pretty-msg "test" 3) "***test***"))))
在这里,我们定义了用于包含测试用例的命名空间exploring-monads.core-test。关键字:require keyword添加了到名称空间clojure.test和exploring-monads.core的引用。最后,我们定义了第一个测试用例,运行测试后将更详细地介绍它。
要将文件的内容发送给正在运行的Clojure REPL实例,可使用组合键Ctrl + Alt + S(Windows/Linux)或cmd + alt + s(macOS)。
为运行修改后的代码,请执行如下操作。
(1) 打开文件src/exploring-monads/core.clj并按Ctrl + Alt + S(在macOS中为cmd + alt + S)。
(2) 打开文件test/exploring-monads/core_test.clj并再次按Ctrl + Alt + S(在macOS中为cmd + alt + S)。
(3) 按Ctrl + Alt + N(在macOS中为cmd + alt + n)切换REPL选项卡的活动命名空间。
至此,运行的Clojure实例便有了编译后的代码。大多数IDE都支持单元测试,但Counterclockwise不支持。为了运行单元测试,一种办法是在REPL中手动执行函数run-tests,为此请在Clojure REPL选项卡中输入如下代码并按回车:
(run-tests)
为方便起见,可在脚本中调用
run-tests,这样当REPL执行脚本时将自动运行这个函数。但不推荐这样做,因为当你使用Leiningen内置的测试命令时,这会导致冲突。
这将打印如下输出:
Testing exploring-monads.core-testFAIL in (test-sane-parameters) (core_test.clj:7)pretty-msg with with sane parametersexpected: (= (pretty-msg "test" 3) "***test***")actual: (not (= "" "***test***"))Ran 1 tests containing 1 assertions.1 failures, 0 errors.{:test 1,:pass 0,:fail 1,:error 0,:type :summary}
输出行actual: (not(= "" "***test***"))指出了问题所在:函数pretty-msg返回的是一个空字符串(""),而不是期望的字符串**test***。但我们对这个API(函数pretty-msg)很满意,因此可着手实现它,让这个测试通过。为此我们将使用一个monad。请打开文件src/monad_test/core.clj,并将其中的函数pretty-msg替换为如下代码:
(defn pretty-msg [msg asterisk-amount](domonad identity-m[a asterisk-amountb (clojure.string/join (repeat a "*"))c (str b msg)](str c b)))
根据定义,monad包含一个bind函数。这个bind函数确保一个组件的输出可用作下一个组件的输入,还可用来做决策(稍后你将看到这一点)。在这个示例中,我们只使用clojure.algo.monads库提供的预制monad类型,它们有内置的bind函数。在上述代码中,我们使用的monad类型identity-m确实有bind函数,但它没有使用bind函数来处理值或根据值做决策。后面我们将使用另一种利用了其bind函数的monad类型,并借此机会详细地阐述bind函数的原理。
将这个文件存盘,并按Ctrl + F11运行它。现在该来再次运行测试了。为此,请打开文件test/monad_test/core_test.clj,按Ctrl + Alt + S并在REPL中再次运行函数(run-tests)。情况应该比前一次测试更好:
Testing exploring-monads.core-testRan 1 tests containing 1 assertions.0 failures, 0 errors.{:test 1, :pass 1, :fail 0, :error 0, :type :summary}
确定这个monad像预期那样工作后,下面来详细研究文件src/monad_test/core.clj中的代码。
(1) 首先,我们导入了monads库。
(2) 我们创建了一个函数,它接受两个参数:msg和asterisk-amount,其中前者包含消息,而后者指定要在消息前后分别添加多少个星号。
(3) 在函数体内,调用了domonad宏,并指定了monad类型identity-m——这种类型将在后面更详细地介绍。
(4) 我们定义了一个向量,用于包含monad的组件。
(5) 第一个组件被绑定到局部名称(local)a,并与一个整数相关联,而这个整数表示将在消息前后分别添加多少个星号。
(6) 第二个组件被绑定到局部名称b,并与接下来的表达式相关联。例如,如果a为3,b将为字符串***。
(7) 接下来指定了第三个组件,其值将被绑定到c。这个组件将b的结果与包含消息的参数msg合并。这是最后一个组件,因此向量到此结束。如果msg包含test,而a被绑定到3,那么c将包含***test。
(8) 最后,将c和b合并,得到整个monad的结果。在这个示例中,c包含***test,而b包含***。
因此,在单元测试中,pretty-msg返回的字符串为***test***。
根据上述代码,可得出如下几个结论。
- 在monad中,每个组件都可使用前一个组件的值。
domonad宏的第一个参数为monad类型,这里为内置类型identity-m。稍后将介绍其他的monad类型。- 第二个参数是一个包含组件的向量,其中每个组件的结果都被绑定到一个局部名称。
- 第三个参数是一个表达式。如果成功地执行了整个组件链,这个表达式的结果就是monad的返回值。
下面来做个试验:如果将要添加的星号数指定为nil,结果将如何呢?我们希望返回的结果为nil。为找出这个问题的答案,在测试脚本test/monad_test/core_test.clj中再添加一个测试用例:
(deftest test-nil-amount(testing "pretty-msg with with amount=nil"(is (= (pretty-msg "JVM" nil) nil))))
再次运行这个测试脚本:按Ctrl + Alt + S(在macOS中为cmd + alt + S,并在REPL中运行(run-tests)。你将看到有一个测试导致了错误(为简洁起见做了删节):
ERROR in (test-nil-amount) (RT.java:1241)pretty-msg with with amount=nilexpected: (= (pretty-msg "JVM" nil) "***JVM***")actual: java.lang.NullPointerException: null
之所以会出现这样的错误,是因为monad的第二个组件调用的函数repeat不能接受nil(在Java中为null),因此引发NullPointerException异常。当前使用的monad类型identity-m接受所有的值,并假定下一个组件能够使用前一个组件的值。有一种内置的monad类型是maybe-m,它在一个组件的结果为nil时停止执行组件链。
先来介绍一些背景知识。前面说过,所有monad都有一个bind函数,这个bind函数可用来对前一个组件的输出数据进行转换,让下一个组件能够使用它。然而,它还可以用来根据返回值做出特定的选择。bind函数会被库自动调用。虽然前面使用的monad类型identity-m有bind函数,但它没有以任何方式对数据进行处理,也没有根据值来做出决策。另一方面,monad类型maybe-m有一个定义如下的bind函数(你无需输入这个定义,它是由我们使用的clojure.algo.monads库提供的):
...fn m-bind-maybe [mv f] (when-not (nil? mv) (f mv))...
上述代码嵌套在一个这里没有显示的列表中。请不要过度关注定义函数的fn宏。组件执行完毕后,clojure.algo.monads会调用函数m-bind-maybe。这个函数被调用时,第一个参数(mv)将包含前一个组件的输出值,而第二个参数(f)是一个函数,包含将调用的下一个组件的实现。从上述代码可知,仅当mv不是nil时,才会调用(函数f表示的)下一个组件。因此,如果一个组件返回nil,将停止执行组件链。虽然monad的bind函数通常用于转换数据,但在monad类型maybe-m中,这个函数用于根据前一个组件的输出值判断是否可调用下一个组件。这也是bind函数的合法用途。
在文件src/monad_test/core.clj中,将monad类型从identity-m改为maybe-m。修改后,函数pretty-msg应类似于下面这样:
(defn pretty-msg [msg asterisk-amount](domonad maybe-m[a asterisk-amountb (clojure.string/join (repeat a "*"))c (str b msg)](str c b)))
将这个文件存盘,并按Ctrl + F11再次运行主程序。由于我们没有修改测试代码,因此无需将单元测试代码发送给REPL,而只需在REPL中执行表达式(run-tests)即可。这次没有引发异常,而monad返回的结果为nil,因此测试通过了。只要有组件的输出为nil,monad类型maybe-m就会退出组件链。在这种情况下,返回的结果为nil。因此,现在clojure-test的结果如下:
Ran 2 tests containing 2 assertions.0 failures, 0 errors.
可在monad中添加显式的条件,为此可在组件向量中添加关键字:when。例如,要在传入的消息不是至少包含一个字符的字符串时停止执行,可在组件向量末尾添加如下条件:
(defn pretty-msg [msg asterisk-amount](domonad maybe-m[a asterisk-amountb (clojure.string/join (repeat a "*"))c (str b msg):when (> (count msg) 0)](str c b)))
如果这个条件为true,将正常执行;否则monad将停止执行并返回nil。这种条件可添加到所有类型的monad中,它将在计算结果表达式前被评估。
结束对monad的讨论前,必须指出的是,根据定义,monad还有一个unit函数(通常被称为return或result函数)。这个函数相当于类中接受输入参数的构造函数。unit函数初始化monad,并确保传入的数据可供第一个组件使用(这通常是通过对数据进行转换实现的)。与bind函数一样,unit函数也是由clojure.algo.monads库提供的,但在monad类型identity-m和maybe-m中,unit函数都没有以任何方式对传入的输入值进行处理。
通过创建自定义monad,可巧妙地利用unit和bind函数以及monad结束时返回的表达式,从而创建出能够轻松地与其他monad组合(串接)的monad类型。
要将文件的内容发送给正在运行的Clojure REPL实例,可使用组合键Ctrl + Alt + S(Windows/Linux)或cmd + alt + s(macOS)。