15.1.4 可组合函数和对象

15.1.4 可组合函数和对象

创建可组合库的第二个选项,是构建一些函数或对象,表示声明性的规范,并可以执行。这限制了用组合对象所能做的操作,因为,操作是每个基元的一个固有部分。事实上,许多时候,只需要执行单个操作,比如,绘制动画帧,或计算金融合同的交易。我们可以轻松地增加新的基元,为可组合的基元实现新的方法。在函数式编程语言中,以这种方式写出的库,被称为连接符库(combinator libraries)。

在 F# 中使用连接符库

当开发连接符库时,需要创建几个基元,把它们实现为函数或简单的对象值。为了构建更复杂的规范,需要添加连接符:用于组合基元值的函数或运算符。这种做法很灵活,因为,可以通过组合由库所提供的核心基元,创建新的基元。还可以创建更复杂的函数,用几个连接符,把基元组合到一起。

我们将使用这种编程风格来创建 F# 版本的动画库。有必要看一下这个示例的最终结果,这样,可以知道我们的目标是什么。下面的代码断显示了一个动画,有一个太阳和两个旋转的行星:

let planets =
sun -- (rotate 150.0f 1.0f earth)
-- (rotate 200.0f 0.7f mars)

动画由三个基元,用一个连接符 -- 组成。我们将使用三个太阳系对象,这些并不是动画库的核心部分,它们是用其他可用的基元来定义的。示例还使用了 rotate 基元,指定对象如何旋转。这看起来像基本的基元,但是同样,它也是从描述对象运动的一个基元派生而来。图 15.1 显示了这个动画运行的结果。


图 15.1 模拟火星和地球围绕太阳旋转

解析器连接符(Parser combinators)

连接符库在函数式编程社区很流行,已用于许多任务。最著名的例子可能是 Parsec 库,用于创建解析器 [[Leijen, Meijer, 2001]。在这种情况下,我们会建构建一些函数,取字符列表作为参数值,返回解析过的值,例如,可以是以树状层次结构加载的 XML 文档。我们开始的基元非常简单,比如,解析器,如果匹配由用户指定的断言(predicate),就返回一个字符(例如,当字符是 + 时,返回 true 的函数),所有其他情况就失败。连接符可以并行地运行两个解析器,返回由一个解析器返回的成功值,或者依次运行。

下面的示例显示了如何写一个简单的解析器,可以解析简单的数值表达式,比如,数字和变量的加法 (5+10 和 x-12)。我们将使用基元 parse,从一个断言创建,另一个基元 repeated,依次应用这个解析器一次或多次。接下来,我们使用两个组合了解析器的自定义运算符。<|> 运算符构建一个解析器,当两个解析器的任何一个成功,它就成功,而 <+> 运算符,依次组合解析器:

let argument = parse Char.IsLetter <|> repeated (parse Char.IsDigit)
let operator = parse ((=) '+') <|> parse ((=) '-')
let simpleExpression = argument <+> operator <+> argument

我们首先定义一个解析器 argument,当输入包含一个字母时,表示变量名,它就成功,或者,当包含一个或多个数字,表示数值(它可以解析字符串,如"x"或"123",但不能是"1x3")。下一步,我们构建名为 operator 解析器,当输入包含加号或减号时,就成功。注意,我们将使用偏函数应用,来构建断言,当输入是指定字符时,返回 true。最后,我们使用 <+> 运算符,依次组合两个解析器,构建一个解析器,来解析整个表达式。

我们也可以在 C# 中实现类似连接符库的东西,不使用函数调用和自定义运算符,我们将使用面向对象的语言,比如 C#,所具有的最基本表达式:方法调用(method call)。

在 C# 中使用方法链(METHOD CHAINS)

在本书的很多地方,我们都讨论过不可变对象,因此,你应该了解其基本的公理:不可变对象的所有操作,返回的是这个对象的一个新实例,有修改过的值。这更改了我们处理类型的方式,也改变了可以使用的语法。当每个方法调用返回一个新对象时,我们可以通过创建方法链,顺序操作。我们将 LINQ 查询作为示例来演示。正如你已经知道的,所有的 LINQ 运算符返回一个新的序列,原来的保持不变,所以,我们能够优雅地把操作链起来,像这样:

var q = data.Where(c => c.Country == "London")
.OrderBy(c => c.Name)
.Select(c => c.Name);

使用可组合的函数式库写的代码,通常写为单个表达式。在 F# 中,表达式可以分解成使用 let 绑定的片段,但这只是一种使表达式更具可读性的方法。在面向对象语言中,使用方法链写的代码并无不同。我们将写一个表达式,看上去像想要得到结果的声明性规范。

现在,让我们回到动画示例中。我们可以很容易想象,表示太阳系对象(太阳、地球和火星)的值是一些不可变的对象。那么,我们就可以写太阳系的相同的声明性规范,像这样:

var planets =
sun.Compose(earth.Rotate(150.0f, 1.0f))
.Compose(mars.Rotate(200.0f, 0.7f));

所有的太阳系对象都可以被称为 AnimShape,的相同类型的值。(这并不是我们在这一章的后面所实现的表示,但是,可以用它来描述代码段。)Rotate 方法可以在任何 AnimShape 值上调用。它取用于旋转的参数作为参数值,并创建一个新的动画形状,有额外的旋转。

这段代码是非常的具有可组合性,因为 AnimShape 的所有操作都返回新的 AnimShape 值,作为结果。第一个对 Compose 方法的调用,创建了一个新的形状,包括太阳和旋转的地球。第二个调用在组合的形状一,加上旋转的火星,所以,我们会得到一个 AnimShape 的值,表示三个太阳系对象,每一个有不同的运动。这些运行也是可组合的。如果我们把 MoveFromTo 调用(表示两个指定点之间移动),链到 Rotate 方法的调用上,该对象将是旋转的,并且旋转的中心将在指定的点之间移动。

方法链的好处是,我们能够以声明的方式写代码。在函数式编程中,这不需要额外的努力,因为,我们能方便地得到语法,是由于不可变性。类似的构造也可以用于可变对象。

可变对象的方法链

在命令式编程语言中,提供相同的语法有点困难。如果我们写代码来构建一个太阳系的动画,使用可变对象时,配置对象的方法会返回 void,我们不能使用方法链。创建和配置对象的典型的命令式代码,如下所示:

var planets = new AnimShape();
earth.SetRotation(150.0f, 1.0f);
mars.SetRotation(200.0f, 0.7f);
planets.Children.AddRange(new AnimShape[] { sun, earth, mars });

一旦你习惯了使用声明性的可组合方式,就会发现,这段代码比前面的代码段的可读性要低。可以使用各种技术来获取相同的语法,即使是处理可变对象。在面向对象的环境中,编程风格也被称为流畅的接口(fluent interface),它有许多支持者,因为增强的可读性。

当使用这方式,创建包装类型,配置在后台创建的可变对象。Martin Fowler 称这种构造为表达式生成器(Expression Builder),描述它如何做 [[Fowler, 2008]。这是在 C# 中使用函数式编程风格的一个好处:最后,通常会创建一个可读的流利接口,虽然你不是故意做的。使用像这样的 API 时要小心。使用流利接口的类可能看起来像是不可变的,虽然它不是!

刚才我们看到的示例展示了,可组合性是一个强大的原理,因此,你可能会急于知道,,我们如何可以实现这样的库。在接下来的几节中,我们会实现 F# 和 C# 的库,来描述动画,我们还将看到,连接符和方法链是如何工作的。