原文:
zh.annas-archive.org/md5/542d15e7552f9c0cf0925a989aaf5fc0
译者:飞龙
第七章:函数式技术和主题
我们已经涵盖了与函数式编程相关的基础技术。但是,你可能可以想象,还有很多其他主题需要涵盖。在本章中,你将学习一些这些模式和想法。
一些主题将被深入讨论,其他主题将被提及,并指向外部资源,如果你想了解更多。由于这是一本关于 PHP 函数式编程的入门书,最先进的想法超出了范围。然而,如果你在某个地方遇到了关于这样一个主题的文章,你应该有足够的理解力,至少能够理解其要点。
本章的各节并不一定彼此关联。有些内容可能对你来说是新的,有些则与之前呈现的摘录相关。
在本章中,我们将涵盖以下主题:
-
类型系统、类型签名及其用途
-
无点风格
-
使用
const
关键字来方便匿名函数的使用 -
递归、堆栈溢出和跳板
-
模式匹配
-
类型类
-
代数结构和范畴论
-
单子变换器
-
镜头
类型系统
免责声明:我并不打算在静态和动态类型的爱好者之间挑起争端。讨论哪种更好以及为什么并不是本书的目标,我会让你们每个人自己决定喜欢什么。如果你对这个主题感兴趣,我可以推荐阅读pchiusano.github.io/2016-09-15/static-vs-dynamic.html
,这是一个很好的总结,尽管有点偏向静态类型。
话虽如此,类型和类型系统是函数式编程中的重要主题,即使这些类型并不是由语言强制执行的。函数签名的类型是一种元语言,可以简洁有效地传达有关函数目的的信息。
正如我们将看到的那样,清楚地声明函数的输入和输出的预期类型是其文档的重要部分。它不仅通过允许你跳过阅读函数代码来减轻认知负担,还允许你推断出关于正在发生的重要事实并推导出“自由定理”。
Hindley-Milner 类型系统
Hindley-Milner,也称为 Damas-Milner 或 Damas-Hindley-Milner,以最早理论化它的人的名字命名,是一种类型系统。类型系统是一组规则,定义变量或参数可以具有的类型以及不同类型如何相互作用。
Hindley-Milner 的主要特点之一是它允许类型推断。这意味着你通常不需要明确地定义类型;它可以从其他来源推断出,比如上下文或周围元素的类型。确切地说,类型推断是由一种叫做算法 W的算法完成的,它与 Hindley-Milner 类型系统有关,但并不完全相同。
它还允许多态性;这意味着如果你有一个返回列表长度的函数,列表元素的类型不需要被知道,因为它对计算没有影响。这类似于你可以在 C++或 Java 中找到的泛型,但并不完全相同,因为它更加强大。
大多数静态类型的函数式语言,如 Haskell、OCaml 和 F#,使用 Hindley-Milner 作为它们的类型系统,通常还会使用扩展来处理一些边缘情况。Scala 以其自己的类型系统而著称。
除了关于类型系统的理论,还有一种通常被接受的方法来描述函数的输入和输出参数,这正是我们感兴趣的。这与你可以在 PHP 中使用的类型提示非常不同,当语言不使用这种特定语法时,它通常被放在函数顶部的注释中。从现在开始,我们将把这样的类型注释称为函数的“类型签名”。
类型签名
作为第一个简单的例子,我们将从strtoupper
和strlen
PHP 函数的类型签名开始:
// strtoupper :: string -> string
// strlen :: string -> int
这很简单理解:我们从函数名开始,后面是参数的类型,一个箭头,和返回值的类型。
那么有多个参数的函数呢?考虑以下情况:
// implode :: string -> [string] -> string
为什么有多个箭头?如果您考虑到柯里化,这可能会帮助您得到答案。如果我们使用括号编写相同的函数签名,这可能会进一步帮助您:
// implode :: string -> ([string] -> string)
基本上,我们有一个接受string
类型并返回一个接受字符串数组并返回字符串的新函数。最右边的类型始终是返回值的类型。所有其他类型都是按顺序的各种参数。括号用于表示函数。让我们看看它在有更多参数的情况下是什么样子的:
// number_format :: float -> (int -> (string -> (string -> string)))
或者不使用括号:
// number_format :: float -> int -> string -> string -> string
我不知道您的意见是什么,但我个人更喜欢后者,因为它的噪音较小,一旦您习惯了它,括号就不会带来太多信息。
如果您熟悉number_format
函数,您可能已经注意到我提出的类型签名包含所有参数,甚至是可选参数。这是因为没有一种传达这些信息的标准方法,因为函数式语言通常不允许这样的参数。然而,Haskell 有一个Optional
数据类型,用于模拟这一点。有了这些信息,具有默认值的参数有时会显示如下:
// number_format :: float -> Optional int -> Optional string -> Optional string -> string
这很有效,并且很容易理解,直到你有一个名为Optional
的数据类型。也没有一种常见的方法来表达默认值是什么。
据我所知,没有办法传达函数接受可变数量参数的信息。由于 PHP 7.0 引入了一个新的语法,我建议我们在本书的其余部分使用它:
// printf :: string -> ...string -> int
我们之前看到括号用于表示函数的概念,但通常出于可读性的原因通常不使用。然而,当使用函数作为参数时,情况并非如此。在这种情况下,我们需要保留函数期望或返回另一个函数的信息:
// array_reduce :: [a] -> (b -> a -> b) -> Optional a -> b
// array_map :: (a -> b) -> ...[a] -> [b]
您可能会问自己,那些a
和b
变量是什么?这是我们之前谈到的多态特性。array_reduce
和array_map
函数不关心数组中包含的元素的真实类型是什么,它们不需要知道这些信息来执行它们的工作。我们可以像我们之前使用printf
方法那样使用mixed
函数,但那样我们将丢失一些有用的数据。
a
变量是某种类型,b
变量是另一种类型,或者也可以是相同的。像a
和b
这样的变量有时被称为类型变量。这些类型签名所说的是,我们有一个具有某种类型(类型a
)的值数组,一个接受这样一个值并返回另一个类型(类型b
)的函数;显然,最终值与回调的值相同。
名称a
和b
是一种约定,但您可以自由使用任何您想要的东西。在某些情况下,使用更长的名称或某些特定字母可能有助于传达更多信息。
注意
如果您对array_reduce
函数的签名有困难,这是完全正常的。您还不熟悉语法。让我们尝试逐个接受参数:
-
包含
a
类型元素的数组 -
一个函数,接受类型
b
(累加器),类型a
(当前值),并返回类型b
(新的累加器内容) -
一个与数组元素相同类型的可选初始值
-
返回值是
b
类型,与累加器相同类型
这个签名没有告诉我们a
和b
的确切类型。就我们所知,b
本身可能是一个数组,一个类,一个布尔值,真的可以是任何东西。类型a
和b
也可以是相同的类型。
您还可以有一个唯一的类型变量,就像array_filter
函数一样:
// array_filter :: (a -> bool) -> [a] -> [a]
由于类型签名只使用了a
类型,这意味着输入数组中的元素和返回的数组将具有完全相同的类型。由于a
类型不是特定类型,这个类型签名也告诉我们array_filter
函数适用于所有类型,这意味着它不能转换值。列表中的元素只能被重新排列或过滤。
类型签名的最后一个特点是,您可以缩小给定类型变量的可能类型。例如,您可以指定某个类型m
应该是给定类的子类:
// filterM :: Monad m => (a -> m Bool) -> [a] -> m [a]
我们刚刚引入了一个新的双箭头符号。您将始终在类型签名的开头找到它,而不会在中间找到。这意味着前面的内容定义了某种特定性。
在我们的例子中,我们将m
类型变量约束为Monad
类的后代。这使我们能够声明filterM
方法首先接受一个返回布尔值的函数封装在一个 monad 中作为第一个参数,并且它的返回值将被封装在相同的 monad 中。
如果您愿意,可以指定多个约束。如果我们想象有两种类型,TypeA
和TypeB
类型,我们可以有以下类型签名:
// some_function :: TypeA a TypeB b => a -> b -> string
仅仅通过查看类型签名就无法清楚地知道这个函数的作用,但我们知道它期望TypeA
类型的一个实例和TypeB
类型的一个实例。返回值将是一个字符串,显然是基于参数计算的结果。
在这种情况下,我们不能像array_filter
方法那样做出相同的假设,即不会发生任何转换,因为我们对类型变量有约束。我们的函数很可能知道如何操作我们的数据,因为它们是某种类型或其子类的实例。
我知道有很多东西要理解,但正如前面的array_reduce
函数示例所证明的那样,类型签名允许我们以简洁的方式编码大量信息。它们也比 PHP 类型提示更精确,因为它们允许我们说array_map
方法可以从一种类型转换为另一种类型,而array_filter
方法将保持数组中的类型。
如果您浏览过php-functional
库的代码,您可能已经注意到作者在大多数函数的文档块中使用了这样的类型签名。您还会发现一些其他函数式库也在做同样的事情,这种习惯在 JavaScript 世界也在传播。
自由定理
类型签名不仅让我们了解函数的作用,还允许我们根据类型信息推导出有用的定理和规则。这些被称为自由定理,因为它们随类型签名免费提供。这个想法是由 Philip Walder 在 1989 年发表的论文*Theorems for free!*中发展起来的。
通过使用一个自由定理,我们可以肯定以下事实:
// head :: [a] -> a
// map :: (a -> b) -> [a] -> [b]
head(map(f, $x)) == f(head($x)
这对您来说可能显而易见,因为您有一些常识并知道函数的作用,但计算机缺乏常识。因此,为了将等式的左边优化到右边,我们的编译器或解释器必须依赖自由定理来实现。
类型签名如何证明这个定理?还记得我们说过a
类型是我们的函数一无所知的通用类型吗?推论是它们不能修改数组中的值,因为这样的通用函数不存在。唯一知道如何转换的函数是f
,因为它需要符合 map 强制的(a -> b)
类型签名。
由于head
和map
函数都不修改元素,我们可以推断先应用函数然后取第一个元素与先取第一个元素再应用函数是完全相同的。只是第二种方式更快:
// filter :: (a -> Bool) -> [a] -> [a]
map(f, filter(compose(p, f), $x)) === filter(p, map(f, $x))
稍微复杂一些,这个自由定理说,如果你的谓词需要使用函数f
转换一个值,然后你在结果上应用f
函数,这与首先对所有元素应用f
然后进行过滤完全相同。再次的想法是通过只应用一次f
来优化性能,而不是两次。
当将函数组合在一起或在彼此之后调用它们时,尝试查看类型签名,看看是否可以通过推导一些自由定理来改进你的代码。
Haskell 用户甚至可以在www-ps.iai.uni-bonn.de/cgi-bin/free-theorems-webui.cgi
上使用自由定理生成器。
结束语
类型签名为我们带来了很多东西,你可以找到基于它们的函数搜索引擎。像www.haskell.org/hoogle/
和scala-search.org/
这样的网站允许你仅基于它们的类型签名搜索函数。
当使用函数式技术时,经常会出现这样的情况,你的数据有一定的结构,你需要将其转换为其他形式。由于大多数函数都是完全通用的,很难找到正确的关键字来搜索你要找的东西。这也是类型签名和Hoogle这样的搜索引擎派上用场的地方。只需输入你的输入的类型结构,想要的输出类型,然后浏览搜索引擎返回的函数列表。
PHP 是一种动态类型的语言,而且最近才引入了标量类型,显然还没有围绕类型签名的有用工具。但也许只是时间的问题,人们会想出一些东西。
无点风格
无点风格,也称为暗示编程,是一种编写函数的方式,其中你不明确定义参数或点,因此得名。根据语言的不同,可以在不同的层次上应用这种特定的风格。你如何有没有确定参数的函数?通过使用函数组合或柯里化。
事实上,在本书中我们之前已经进行了一些无点风格的编程。让我们使用第四章中的一个例子,组合函数,来说明它是关于什么的:
<?php
// traditional
function safe_title(string $s)
{
return strtoupper(htmlspecialchars($s));
}
// point-free
$safe_title = compose('htmlspecialchars', 'strtoupper');
第一部分是传统函数,其中你声明一个输入参数。然而,在第二种情况下,你看不到明确的参数声明;你依赖于组合函数的定义。第二个函数被称为无点风格。
PHP 语法要求我们将组合或柯里化的函数分配给一个变量,但在一些其他语言中并没有这样清晰的分离。以下是 Haskell 中的三个例子:
-- traditional
sum (x:xs) = x + sum xs
sum [] = 0
-- using foldr
sum xs = foldr (+) 0 xs
-- point-free
sum = foldr (+) 0
正如我们所看到的,这三种情况下函数定义的结构是相同的。第一个是在不使用折叠的情况下定义sum
方法的方式。第二个例子承认我们可以简单地对数据进行折叠,但仍然明确声明了参数。最后一个例子是无点风格的,因为没有任何参数的痕迹。
另一种语言中,函数和变量之间的区别比在 PHP 中更微妙的是 JavaScript。事实上,所有函数都是变量,由于没有变量的特殊语法,传统函数和分配给变量的匿名函数之间没有区别:
// traditional
function snakeCase(word) {
return word.toLowerCase().replace(/\s+/ig, '_');
};
// point-free
var snakeCase = compose(replace(/\s+/ig, '_'), toLowerCase);
显然,这不是有效的 JavaScript,因为没有原生的compose
函数,而且两个作用于字符串的函数不能这样简单地调用。然而,有多个库可以让你轻松编写这样的代码,比如Ramda,我强烈推荐。这个例子的重点只是为了证明你无法区分传统函数和 JavaScript 中的匿名函数,就像在 PHP 中一样。
使用这种风格有一些好处:
-
通常你会有更简洁的代码,有些人认为这样更清晰,更容易阅读。
-
它有助于以抽象的方式思考。JavaScript 示例中的参数名
word
暗示该函数只对单词起作用,而它实际上可以对任何字符串起作用。这对于更通用的函数尤其如此,比如那些在列表上工作的函数。 -
它有助于开发人员以函数组合而不是数据结构的方式思考,这通常会导致更好的代码。
然而,也有一些可能的缺点:
-
从定义中去掉显式参数可能会使事情更难理解;没有参数名,例如,有时会去掉有用的信息。
-
长链的组合函数可能导致失去对数据结构和类型的视野。
-
代码可能更难维护。当你有一个明确的函数时,你可以很容易地添加新的行,进行调试等。但是当函数组合在一起时,这几乎是不可能的。
有些反对者有时会使用术语无意义的风格来描述这种技术的结果。
阅读和使用无点代码肯定需要一些时间来适应。我个人对此没有强烈的意见。我建议你使用最适合你的风格,而且即使你更喜欢其中一种,也有一些情况下另一种可能更好,所以不要犹豫混合使用两种风格。
最后,我想提醒你,“参数顺序很重要!”就像我们在第四章中讨论的那样,组合函数。如果你想使用无点风格,这一点尤其重要。如果你需要处理的数据不是最后一个参数,你将无法使用。
使用 const 关键字来定义函数
这种技术与函数式编程无关,而是 PHP 本身的一个巧妙技巧。然而,它可能会帮助你很多,所以我们来试试。
如果你看过functional-php
库的代码,你可能已经注意到几乎所有函数顶部都有常量的定义。这里有一个小例子:
<?php
const push = 'Widmogrod\Functional\push';
function push(array $array, array $values)
{
// [...]
}
这背后的想法是允许更简单地使用函数作为参数。我们之前看到,你传递函数或方法作为参数的方式是使用一个叫做callable
的东西,通常是一个字符串或一个由对象实例和要调用的方法的字符串组成的数组。
使用const
关键字使我们更接近于在函数与变量不是分开构造的语言中找到的东西:
<?php const increment = 'increment';
function increment(int $i) { return $i + 1; }
// using a 'callable'
array_map('increment' [1, 2, 3, 4]);
// using our const
array_map(increment, [1, 2, 3, 4]);
去掉我们函数名周围的尴尬引号。这看起来真的像你在传递函数本身,就像其他语言如 Python 或 JavaScript 中的情况一样。
如果你使用的是 IDE,情况会更好。你可以使用转到声明或等效的功能,定义const
的文件将在定义它的行上打开。如果你将它声明在真正函数的顶部或底部,你将快速访问到它的定义。
一些我不知道的 IDE 可能会为callable
提供相同的功能,但至少对我使用的这个来说并不是这样。然而,如果我在第二个例子中按下Ctrl +点击increment
函数,它会聚焦在const
声明上,这真的节省了很多时间。
当像这样声明常量时,你不仅限于影子函数;它也适用于静态对象方法。你还可以使用DocBlock注释来声明你的常量代表一个callable
类型:
<?php
class A {
public static function static_test() {}
public function test() {}
}
/** @var callable */
const A_static = ['A', 'static_test'];
遗憾的是,这个技巧对存储在变量中的匿名函数或在对象实例上调用方法不起作用。如果你尝试这样做,PHP 会用一个响亮的Warning: Constants may only evaluate to scalar values or arrays
警告来回报你。
尽管不是万能的,而且伴随着一些限制,这个小技巧将帮助你编写更清晰、更容易在 IDE 中导航的代码。
递归、堆栈溢出和跳板
我们首先在第三章中将递归作为解决编程问题的可能解决方案进行了介绍,PHP 中的函数基础。一些内存问题已经被暗示出来;现在是时候进一步调查了。
尾调用
在返回值之前执行的最后一个语句是称为尾调用的函数调用。让我们看一些例子来理解它的含义:
<?php
function simple() {
return strtoupper('Hello!');
}
毫无疑问,这是一个尾调用。函数的最后一个语句返回strtoupper
函数的结果:
<?php
function multiple_branches($name) {
if($name == 'Gilles') {
return strtoupper('Hi friend!');
}
return strtoupper('Greetings');
}
在这里,对strtoupper
函数的两次调用都是尾调用。函数内的位置并不重要;重要的是在函数调用之后是否进行了任何操作。在我们的例子中,如果参数值是Gilles
,函数将做的最后一件事是调用strtoupper
函数,使其成为尾调用:
<?php
function not_a_tail_call($name) {
return strtoupper('Hello') + ' ' + $name;
}
function also_not_a_tail_call($a) {
return 2 * max($a, 10);
}
这两个函数都没有尾调用。在这两种情况下,调用的返回值被用来计算最终值,然后函数返回。操作的顺序并不重要,解释器需要首先获取strtoupper
和max
函数的值,以计算结果。
正如我们刚才看到的,发现尾调用并不总是容易的。你可以在一个非常长的函数的前几行中有一个尾调用,并且处于最后一行并不是一个充分的标准。
如果尾调用是对函数本身的调用,或者换句话说是递归调用,那么尾递归这个术语经常被使用。
尾调用消除
为什么要费心呢?也许你正在问自己?因为编译器和解析器可以执行一种称为尾调用消除或有时称为尾调用优化(TCO)的东西。
程序不是执行一个新的函数调用并遭受所有相关的开销,而是简单地跳转到下一个函数,而不向堆栈添加更多信息并浪费宝贵的时间传递参数。
这在尾递归的情况下特别重要,因为它允许堆栈保持平坦,不使用比第一个函数调用更多的内存。
听起来很棒,但是像大多数高级编译技术一样,PHP 引擎并没有实现尾调用消除。然而,其他语言做到了:
-
任何符合ECMAScript 6 标准的 JavaScript 引擎
-
安装了 tco 模块后的 Python
-
在 Scala 中,甚至有一个注解(
@tailrec
)可以触发编译器错误,如果你的方法不是尾递归的 -
Elixir
-
Lua
-
Perl
-
Haskell
在Java 虚拟机(JVM)级别也有正在进行的提案和工作来执行尾调用消除,但到目前为止在 Java 8 中还没有具体的实现,因为这不被认为是一个优先特性。
尾递归函数通常更容易处理,特别是在折叠方面;正如我们在本节中看到的,存在一些技术来缓解堆栈增长问题,但代价是一些处理能力。
从递归到尾递归
既然我们对我们正在谈论的内容有了更清晰的理解,让我们学习如何将递归函数转换为尾递归函数,如果还不是的话。
我们拒绝将计算阶乘作为第三章中好的递归示例,PHP 中的函数基础,但是因为它可能是最简单的递归函数,我们将从这个例子开始:
<?php
function fact($n)
{
return $n <= 1 ? 1 : $n * fact($n - 1);
}
这个函数是尾递归的吗?不是,我们将一个值与我们递归调用fact
方法的结果相乘。让我们更详细地看一下各个步骤:
fact(4)
4 * fact(3)
4 * 3 * fact(2)
4 * 3 * 2 * fact(1)
4 * 3 * 2 * 1
-> 24
你有什么想法可以将这个转换为尾递归函数吗?在继续阅读之前,花点时间玩一下一些想法。如果你需要提示,想一想用于折叠的函数是如何操作的。
当涉及到尾递归时,通常的答案是使用累加器:
<?php
function fact2($n)
{
$fact = function($n, $acc) use (&$fact) {
return $n <= 1 ? $acc : $fact($n - 1, $n * $acc);
};
return $fact($n, 1);
}
这里我们使用了一个内部辅助函数来隐藏累加器的实现细节,但我们也可以只使用一个唯一的函数来写它:
<?php
function fact3($n, $acc = 1)
{
return $n <= 1 ? $acc : fact3($n - 1, $n * $acc);
}
让我们再次看一下这些步骤:
fact(4)
fact(3, 4 * 1)
fact(2, 3 * 4)
fact(1, 2 * 12) -> 24
太好了,在每个递归调用之后没有未决操作了;我们真的有一个尾递归函数。我们的fact
函数非常简单。我们之前写的汉诺塔求解器呢?这里有它,这样你就不用再去找了:
<?php
function hanoi(int $disc, string $source, string $destination, string $via)
{
if ($disc === 1) {
echo("Move a disc from the $source rod to the $destination rod\n");
} else {
// step 1 : move all discs but the first to the "via" rod
hanoi($disc - 1, $source, $via, $destination);
// step 2 : move the last disc to the destination
hanoi(1, $source, $destination, $via);
// step 3 : move the discs from the "via" rod to the destination
hanoi($disc - 1, $via, $destination, $source);
}
}
就像我们的阶乘计算一样,花点时间尝试将函数转换为尾递归函数:
<?php
use Functional as f;
class Position
{
public $disc;
public $src;
public $dst;
public $via;
public function __construct($n, $s, $d, $v)
{
$this->disc = $n;
$this->src = $s;
$this->dst = $d;
$this->via = $v;
}
}
function hanoi(Position $pos, array $moves = [])
{
if ($pos->disc === 1) {
echo("Move a disc from the {$pos->src} rod to the {$pos- >dst} rod\n");
if(count($moves) > 0) {
hanoi(f\head($moves), f\tail($moves));
}
} else {
$pos1 = new Position($pos->disc - 1, $pos->src, $pos->via, $pos->dst);
$pos2 = new Position(1, $pos->src, $pos->dst, $pos->via);
$pos3 = new Position($pos->disc - 1, $pos->via, $pos->dst, $pos->src);
hanoi($pos1, array_merge([$pos2, $pos3], $moves));
}
}
hanoi(new Position(3, 'left', 'right', 'middle'));
正如你所看到的,解决方案非常相似,即使在函数内部有多个递归调用时,我们仍然可以使用一个累加器。诀窍是使用一个数组而不是仅存储当前值。在大多数情况下,累加器将是一个堆栈,这意味着你只能在开头添加元素并从开头删除它们。堆栈被称为后进先出(LIFO)结构。
如果你不太明白这个重构是如何工作的,我鼓励你写下两种变体的步骤,就像我们为fact
方法所做的那样,这样你就可以更好地理解涉及的机制。
事实上,花时间写下递归算法的步骤通常是清楚地理解发生了什么以及如何重构为尾递归或修复错误的好方法。
堆栈溢出
我们为尾递归的汉诺塔求解器使用类似堆栈的数据结构并非巧合。当你调用函数时,所有需要的信息也会存储在内存中类似堆栈的结构中。在递归的情况下,它看起来会像这样:
这个堆栈有一个有限的大小。它通过memory_limit
配置选项进行限制,即使你移除了限制,也无法超出系统中可用的内存。此外,诸如Xdebug之类的扩展引入了特定的机制,以避免有太多嵌套的递归调用。例如,你有一个名为xdebug.max_nesting_level
的配置选项,默认值为 256,这意味着如果你递归调用一个函数超过这个值,就会引发错误。
如果 PHP 执行尾调用优化,而不是将函数信息的各个部分堆叠在一起,调用将会替换堆栈中的当前信息。这样做是安全的,因为尾调用的最终结果不依赖于函数局部变量。
由于 PHP 不执行这种优化,我们需要找到另一个解决方案来避免堆栈溢出。如果你遇到这个问题,并且愿意牺牲一些处理能力来限制内存使用,你可以使用trampolines。
trampolines
我们避免堆栈增长的唯一方法是返回一个值而不是调用一个新函数。这个值可以保存执行新函数调用所需的信息,从而继续计算。这也意味着我们需要函数的调用者的一些合作。
这个有用的调用者就是 trampoline,这是它的工作原理:
-
trampoline 调用我们的函数
f
-
f
函数不再进行递归调用,而是返回封装在数据结构中的下一个调用和所有参数 -
trampoline 提取信息并对
f
函数进行新的调用 -
重复最后两个步骤,直到
f
函数返回一个真实值 -
trampoline 接收一个值并返回给真实的调用者
这些步骤也应该解释了这个技术的名称来源,每次函数返回到 trampoline 时,它都会以下一个参数弹回来。
为了执行这些步骤,我们需要一个数据结构,其中包含以callable
形式调用的函数和参数。我们还需要一个辅助函数,它将继续调用数据结构中存储的任何内容,直到获得真实值:
<?php
class Bounce
{
private $f;
private $args;
public function __construct(callable $f, ...$args)
{
$this->f = $f;
$this->args = $args;
}
public function __invoke()
{
return call_user_func_array($this->f, $this->args);
}
}
function trampoline(callable $f, ...$args) {
$return = call_user_func_array($f, $args);
while($return instanceof Bounce) {
$return = $return();
}
return $return;
}
足够简单,让我们试试:
<?php
function fact4($n, $acc = 1)
{
return $n <= 1 ? $acc : new Bounce('fact4', $n - 1, $n * $acc);
}
echo trampoline('fact4', 5)
// 120
效果很好,代码也没有那么难读。然而,在使用蹦床时会有性能损失。在计算阶乘的情况下,蹦床版本在我的笔记本电脑上大约慢了五倍。这是因为解释器需要做的工作比简单调用下一个函数要多得多。
知道这一点,如果你的递归算法有一个有限的深度,并且你确信不会发生堆栈溢出,我建议你只执行传统的递归,而不是使用蹦床。然而,如果有疑问,不要犹豫,因为堆栈溢出错误可能对生产系统造成严重影响。
多步递归
蹦床甚至对执行不完全尾调用消除的语言也有用处。例如,当涉及两个函数时,Scala 无法执行这样的优化。不要试图解释我在说什么,让我们看一些代码:
<?php
function even($n) {
return $n == 0 ? 'yes' : odd($n - 1);
}
function odd($n) {
return $n == 0 ? 'no' : even($n - 1);
}
echo even(10);
// yes
echo odd(10);
// no
这可能不是确定一个数字是奇数还是偶数的最佳最有效的方法,但它有简单说明我在谈论什么的优点。两个函数都在调用自己,直到数字达到 0 为止,这时我们可以决定它是奇数还是偶数。
根据你问的人,这可能是递归,也可能不是。它符合我们在第三章中给出的学术定义,PHP 中的函数基础:
递归是将问题分解为相同问题的较小实例的想法。
然而,该函数并没有调用自身,所以这就是为什么有些人会尝试用其他术语来定义这里发生的事情。最终,我们称之为什么并不重要;在一个大数字上,我们将会遇到堆栈溢出。
正如我所说,Scala 执行不完全的尾调用消除,只有当函数调用自身作为最后一条指令时才会这样做,这会导致堆栈溢出错误,就像 PHP 会做的那样。这就是为什么即使在一些函数式语言中也会使用蹦床来解决堆栈溢出问题。
作为一个真正简单的练习,我邀请你使用蹦床来重写odd
和even
函数。
蹦床库
如果你想在自己的项目中使用蹦床,我邀请你使用composer
命令安装以下库,因为它相对于我们的粗糙实现提供了一些帮助:
**composer require functional-php/trampoline**
数据结构和功能已经合并在同一个名为Trampoline
的类中。助手以函数的形式可用:
-
bounce
助手用于创建一个新的函数包装器。它接受一个callable
和参数。 -
trampoline
助手运行一个可调用对象直到完成。它接受一个callable
和它的参数。该方法还接受Trampoline
类实例作为参数,但在这种情况下,参数将被忽略,因为它们已经包装在实例中。
该类还定义了__callStatic
,这允许我们直接在类上调用全局命名空间中的任何函数。
以下是从文档中摘取的一些示例:
<?php
use FunctionalPHP\Trampoline as t;
use FunctionalPHP\Trampoline\Trampoline;
function factorial($n, $acc = 1) {
return $n <= 1 ? $acc : t\bounce('factorial', $n - 1, $n * $acc);
};
echo t\trampoline('factorial', 5);
// 120
echo Trampoline::factorial(5);
// 120
echo Trampoline::strtoupper('Hello!');
// HELLO!
还有另一个带有所有蹦床功能的可调用返回助手,它被称为trampoline_wrapper
助手:
<?php
$fact = t\trampoline_wrapper('factorial');
echo $fact(5);
// 120
作为练习,你可以尝试将我们的汉诺塔求解器转换为使用trampoline
库,并看看是否得到相同的结果。
替代方法
除了使用蹦床来解决堆栈溢出问题之外,还可以使用队列来存储对我们函数的连续递归调用的所有参数。
原始函数需要包装在一个辅助函数中,该函数将保存队列并以链式调用所有参数调用原始函数。为了使其工作,递归调用需要在包装器而不是原始函数中进行:
-
创建包装器函数
-
使用第一个参数调用包装器的第一次调用
-
当队列中有参数时,包装器进入一个循环,调用原始函数
-
每次对包装器的后续调用都会将参数添加到队列中,而不是调用原始函数
-
循环完成后(即所有递归调用都已完成),包装器返回最终值
为了使其工作,原始函数在进行递归调用时真的很重要。这可以通过在包装函数内部使用的匿名函数或使用Closure
类的bindTo
方法来完成,正如我们在第一章中讨论的那样,“PHP 中的一等公民函数”。
trampoline
库使用后一种技术实现了这种方法。以下是您可以使用它而不是蹦床的方法:
<?php
use FunctionalPHP\Trampoline as t;
$fact = T\pool(function($n, $acc = 1) {
return $n <= 1 ? $acc : $this($n - 1, $n * $acc);
});
echo $fact(5);
// 120
由pool
函数创建的包装器将Pool
类的实例绑定到$this
。该类具有一个__invoke
方法,在我们的原始函数中可调用。这样做将再次调用包装器,但这次它将把参数添加到队列中,而不是调用原始函数。
从性能的角度来看,这种方法和蹦床之间没有区别,两者的性能应该大致相同。然而,使用pool
函数时,不能进行多步递归,因为包装器只知道一个函数。
此外,直到 PHP 7.1 发布,由于一些困难将可调用的字符串形式转换为Closure
类的实例以绑定类,此方法也仅限于匿名函数。PHP 7.1 将在Closure
上引入一个新的fromCallable
方法,允许解除此限制。
结束语
最后要注意的是,我们已经看到的蹦床和队列技术只有在递归函数是尾递归时才能解决堆栈溢出问题。这是一个强制条件,因为函数需要完全返回,以便辅助函数继续计算。
此外,由于蹦床方法的缺点较少,我建议使用它而不是pool
函数实现。
模式匹配
模式匹配是大多数函数式语言中非常强大的功能。它嵌入在语言的各个层面。例如,在 Scala 中,您可以将其用作强化的switch
语句,在 Haskell 中,它是函数定义的一个组成部分。
模式匹配是检查一系列标记与模式是否匹配的过程。它不同于模式识别,因为匹配需要精确。该过程不仅匹配,还分配值,就像 PHP 中的list
构造一样,这个过程称为解构赋值。
不要与正则表达式混淆。正则表达式只能操作字符串的内容,而模式匹配还可以操作数据的结构。例如,您可以匹配数组的元素数量。
让我们在 Haskell 中看一些例子,以便我们对其有所了解。最简单的模式匹配形式是匹配特定值:
fact :: (Integral a) => a -> a
fact 0 = 1
fact n = n * fact (n-1)
这是您可以在 Haskell 中定义fact
函数的方式:
-
第一行是类型签名,应该让您想起本章前面看到的内容;
Integral
是一种比Integer
类型不那么严格的类型,不详细介绍。 -
第二行是如果参数的值为 0 的函数体。
-
最后一行在所有其他情况下执行。该值分配给
n
变量。
如果您对某些值不感兴趣,可以使用_
(下划线)通配符模式来忽略它们。例如,您可以轻松地定义函数来从元组中获取第一个、第二个和第三个值:
first :: (a, b, c) -> a
first (x, _, _) = x
second :: (a, b, c) -> b
second (_, y, _) = y
third :: (a, b, c) -> c
third (_, _, z) = z
注意
元组是一个具有固定数量元素的数据结构,与可以改变大小的数组相对。(1, 2)
和('a', 'b')
元组的大小都是两个。在已知元素数量的情况下使用元组而不是数组的优势在于强制正确的大小和性能。
诸如 Haskell、Scala、Python、C#和 Ruby 之类的语言在其核心或标准库中都有元组类型。
您可以将值解构为不止一个变量。为了理解以下示例,您需要知道“:”(冒号)是将元素前置到列表的操作符。这意味着
1:[2, 3]元组将返回列表
[1, 2, 3]`:
head :: [a] -> a
head [] = error "empty list"
head (x:_) = x
tail :: [a] -> [a]
tail [] = error "empty list"
tail (_:xs) = xs
sum :: (Num a) => [a] -> a
sum [] = 0
sum (x:xs) = x + sum xs
head
和tail
变量具有相同的结构,如果列表为空,它们返回一个错误。否则,返回x
,即列表开头的元素,或者xs
,即列表的其余部分。sum
变量也类似,但它同时使用x
和xs
。顺便说一句,Haskell 将不允许定义这两个函数,因为它们已经存在:
firstThree :: [a] -> (a, a, a)
firstThree (x:y:z:_) = (x, y, z)
firstThree _ = error "need at least 3 elements"
firstThree
变量有点不同。它首先尝试匹配至少三个元素的列表,x
,y
和z
。在这种情况下,_
模式可以是空列表,也可以不是,模式将匹配。如果匹配不成功,我们知道列表少于三个元素,然后显示一个错误。
您还可以将模式匹配用作强化的开关语句。例如,这也是head
的有效实现:
head :: [a] -> a
head xs = case xs of [] -> error "empty lists"
(x:_) -> x
如果您想同时使用解构数据和整个值,可以使用as 模式:
firstLetter :: String -> String
firstLetter "" = error "empty string"
firstLetter all@(x:_) = "The first letter of " ++ all ++ " is " ++ [x]
最后,您还可以在进行模式匹配时使用构造函数。以下是使用Maybe
类型的一个小例子:
increment :: Maybe Int -> Int
increment Nothing = 0
increment (Just x) = x + 1
是的,您可以像这样轻松地获取 Monad 中的值,使用解构。
您可以有重叠的模式;Haskell 将使用第一个匹配的模式。如果它无法找到匹配的模式,它将引发一个错误,显示函数 XXX 中的非穷尽模式
。
我们可以大致展示 Scala、Clojure 或其他函数语言的相同类型的功能,但由于这只是一个了解模式匹配的示例,如果您对此话题感兴趣,我建议您阅读有关该主题的教程。相反,我们将尝试在 PHP 中模拟这一强大功能的一部分。
PHP 中的模式匹配
显然,我们永远无法像在 Haskell 中看到的那样声明函数,因为这需要在语言的核心实现。但是,一个库试图尽可能地模拟模式匹配,以创建一个更强大的开关语句版本,具有自动解构功能。
您可以使用 Composer 中的composer
命令安装该库:
**composer require functional-php/pattern-matching**
为了尽可能地表达 Haskell 中的可用内容,它使用字符串来保存模式。以下是定义各种可能语法的表格:
名称 | 格式 | 示例 |
---|---|---|
常量 | 任何标量值(整数、浮点数、字符串、布尔值) | 1.0 ,42 ,“test” |
变量 | 标识符 | a ,name ,anything |
数组 | [<模式>, …, <模式>] | [] ,[a] ,[a, b, c] |
Cons | (标识符:列表标识符) | (x:xs) ,(x:y:z:xs) |
通配符 | _ | _ |
As | 标识符@(<模式>) | all@(x:xs) |
在撰写本文时,尚不支持在 Monad 或其他类型内部自动解构值,也不支持约束我们匹配的特定项的类型的可能性。但是,关于这两个功能存在已打开的问题github.com/functional-php/pattern-matching/issues
。
由于在 PHP 中无法使用命名参数,参数将按照它们在模式中定义的顺序传递,并且不会根据它们的名称进行匹配。这使得有时使用该库会有点麻烦。
更好的 switch 语句
该库还可以用于执行更高级的switch
语句,还可以使用结构并提取数据,而不仅仅是对值进行等价判断。由于函数是柯里化的,您还可以将它们映射到数组上,与switch
语句相反。
<?php
use FunctionalPHP\PatternMatching as m;
$users = [
[ 'name' => 'Gilles', 'status' => 10 ],
[ 'name' => 'John', 'status' => 5 ],
[ 'name' => 'Ben', 'status' => 0],
[],
'some random string'
];
$statuses = array_map(m\match([
'[_, 10]' => function() { return 'admin'; },
'[_, 5]' => 'moderator',
'[_, _]' => 'normal user',
'_' => 'n/a',
]), $users);
print_r($statuses);
// Array (
// [0] => Gilles - admin
// [1] => John - moderator
// [2] => Ben - normal user
// [3] => incomplete array
// [4] => n/a
// )
列表中匹配的第一个模式将被使用。如您所见,回调可以是一个函数,如第一个模式,也可以是一个将被返回的常量。显然,在这种情况下,它们都可以是常量,但这是为了举例。
传统的switch
语句无法像您所看到的那样灵活,因为您不受数据结构的约束。在我们的例子中,我们为错误的数据创建了一个通用模式。使用switch
语句,您需要过滤数据或执行某种其他数据规范化。
这个例子也可以使用解构来避免具有常量的三个模式(同时,我们还将使用数组中的名称)。
<?php
$group_names = [ 10 => 'admin', 5 => 'moderator' ];
$statuses = array_map(m\match([
'[name, s]' => function($name, $s) use($group_names) {
return $name. ' - '. (isset($group_names[$s]) ? $group_names[$s] : 'normal user');
},
'[]' => 'incomplete array',
'_' => 'n/a',]), $users);
print_r($statuses);
// Array (
// [0] => admin
// [1] => moderator
// [2] => normal user
// [3] => incomplete array
// [4] => n/a
// )
您还可以编写匹配各种不同结构的模式,并根据它确定要执行的操作。它还可以用于在 Web 应用程序内执行某种基本路由。
$url = 'user/10';
function homepage() { return "Hello!"; }
function user($id) { return "user $id"; }
function add_user_to_group($group, $user) { return "done."; }
$result = m\match([
'["user", id]' => 'user',
'["group", group, "add", user]' => 'add_user_to_group',
'_' => 'homepage',
], explode('/', $url));
echo $result;
// user 10
显然,一个更专业的库会更好地执行路由并具有更好的性能,但牢记这种可能性会很方便,并且它展示了模式匹配的多功能性。
其他用途
如果您只对解构数据感兴趣,extract
函数可以满足您的需求。
<?php
$data = [
'Gilles',
['Some street', '12345', 'Some City'],
'xxx xx-xx-xx',
['admin', 'staff'],
['username' => 'gilles', 'password' => '******'],
[12, 34, 53, 65, 78, 95, 102]
];
print_r(m\extract('[name, _, phone, groups, [username, _], posts@(first:_)]', $data));
// Array (
// [name] => Gilles
// [phone] => xxx xx-xx-xx
// [groups] => Array ( [0] => admin [1] => staff )
// [username] => gilles
// [posts] => Array ( ... )
// [first] => 12
//)
提取数据后,您可以使用 PHP 的extract
函数将变量导入当前作用域。
如果您想创建类似我们在 Haskell 示例中看到的函数,可以使用func
辅助方法。显然,语法不太好,但它可能会派上用场。
<?php
$fact = m\func([
'0' => 1,
'n' => function($n) use(&$fact) {
return $n * $fact($n - 1);
}
]);
请注意,函数创建仍处于测试阶段。存在一些问题,因此 API 可能会在将来更改。如果遇到任何问题,请参阅文档。
类型类
在阅读有关函数式编程的论文、帖子或教程时,您经常会遇到的另一个概念是类型类,特别是如果内容涉及 Haskell。
类型类的概念最初是在 Haskell 中引入的,作为一种实现可以轻松地为各种类型进行重载的操作符的方法。从那时起,人们发现了它们的许多其他用途。例如,在 Haskell 中,函子、应用函子和单子都是类型类。
Haskell 主要需要类型类,因为它不是面向对象的语言。例如,操作符的重载在 Scala 中是以不同的方式解决的。您可以在 Scala 中编写类型类的等效物,但这更像是一种模式而不是一种语言特性。在其他语言中,可以使用traits和interfaces来模拟 Haskell 类型类的一些特性。
在 Haskell 中,类型类是一组需要在给定类型上实现的函数。最简单的例子之一是Eq
类型类。
class Eq a where
(==) :: a -> a -> Bool
(/=) :: a -> a -> Bool
任何实现Eq
类的类型,都必须为==
和/=
操作符实现相应的实现,否则将出现编译错误。这与类的接口非常相似,但适用于类型而不是类。这意味着您可以强制创建操作符,就像我们的情况一样,而不仅仅是方法。
您可以相当容易地为您的类型类创建实例;这是Maybe
实例的一个例子。
instance (Eq m) => Eq (Maybe m) where
Just x == Just y = x == y
Nothing == Nothing = True
_ == _ = False
这应该很容易理解。 左侧的两个Just
值在其内部内容相等时是相等的,Nothing
值等于自身,其他任何值都是不同的。 定义这些Eq
实例允许您在 Haskell 代码中的任何地方检查两个单子的相等性。 实例定义本身只是强制要求存储在单子内的类型m
变量也实现了Eq
类型类。
正如您所看到的,类型类的函数并未在类型内部实现。 它是作为一个单独的实例来完成的。 这使您可以在代码的任何地方声明此实例。
甚至可以为任何给定类型拥有相同类型类的多个实例,并导入你需要的实例。 想象一下,例如,为整数有两个单子实例,一个是乘积,另一个是总和。 然而,这是不鼓励的,因为当导入两个实例时会导致冲突:
Prelude> Just 2 == Just 2
True
Prelude> Just 2 == Just 3
False
Prelude> Just 2 == Nothing
False
注意
Prelude>
是 Haskell REPL 中的提示符,您可以在其中简单地运行 Haskell 代码,就像在 CLI 上运行 PHP 时使用-a
参数一样。
我们可能认为类型类只是接口,或者更确切地说是特征,因为它们也可以包含实现。 但是,如果我们仔细看看我们的类型类及其单子实现,至少有三个缺点,任何我们在 PHP 中所做的事情,至少在合理的范围内,都会有。
为了证明这一点,让我们想象我们创建了一个名为Comparable
的 PHP 接口:
<?php
interface Comparable
{
/**
* @param Comparable $a the object to compare with
* @return int
* 0 if both object are equal
* 1 is $a is smaller
* -1 otherwise
*/
public function compare(Comparable $a): int;
}
撇开 PHP 不允许像我们在 Haskell 中用==
符号演示的那样进行运算符重载这一事实,试着想一想 Haskell 类型类允许的三个特性,这在 PHP 中几乎不可能模拟。
其中两个问题与强制正确类型有关。 Haskell 将自动为我们进行检查,但在 PHP 中,我们将不得不编写代码来检查我们的值是否是正确的类型。 第三个问题与可扩展性有关。 例如,考虑在外部库中声明的要比较的类。
第一个问题与compare
函数期望与接口相同类型的值有关,这意味着如果您有两个不相关的类A
和B
,都实现了Comparable
接口,您可以比较类A
的实例与类B
的实例而不会出现 PHP 的任何错误。 显然这是错误的,这迫使您首先检查两个值是否是相同类型,然后再进行比较。
当您有一个类层次结构时,情况变得更加棘手,因为您真的不知道要测试什么类型。 然而,Haskell 将自动获取两个值共享的第一个公共类型,并使用相关的比较器。
第二个问题更加复杂。 如果我们在任何类型的容器上实现Comparable
接口,我们将需要在比较运行时检查包含的值是否也是可比较的。 在 Haskell 中,类型签名(Eq m) => Eq (Maybe m)
已经为我们处理了这个问题,如果您尝试比较包含不可比较值的两个单子,将自动引发错误。
Haskell 类型系统还强制要求单子内的值是相同类型,这与我们之前发现的第一个问题有关。
最后,第三个问题可能是关于在外部库或 PHP 核心的类上实现Comparable
接口的可能性。 由于 Haskell 类型类实例位于类本身之外,您可以随时为任何类型添加一个实例,无论是现有类型类还是您刚刚创建的新类型类。
您可以创建适配器或包装类来包围这些对象,但然后您将不得不执行某种装箱/拆箱操作,以将正确的类型传递给使用对象的各种方法,这远非愉快的体验。
Scala 是一种面向对象的语言,没有对类型类提供核心支持,可扩展性问题通过使用语言特性隐式转换巧妙地解决了。广义上来说,这个想法是你定义了从类型A
到类型B
的转换,当编译器发现一个方法期望一个B
类的实例,但你传递了一个A
类的实例时,它会寻找这个转换并在可用时应用它。
这样,你可以创建我们之前提出的适配器或包装器,但是不需要手动执行转换,Scala 编译器会为你处理,使整个过程完全透明。
最后,由于我们谈到了比较对象和重新定义运算符,目前有两个关于 PHP 的 RFC 提出了关于这两个主题的讨论:
然而,目前还没有关于类型类或隐式类型转换的 RFC,就像我们在 Scala 中看到的那样。
代数结构和范畴论
到目前为止,我们一直避免谈论数学。本节将试图以轻松的方式这样做,因为大多数函数概念都源于数学,我们已经讨论过的许多抽象都是从范畴论领域借鉴的想法。本节的内容可能会帮助你更好地理解本书的内容,但并不需要完全理解它们才能使用函数式编程。
在本节中,遇到数学术语时将对其进行定义。重要的不是你理解所有的细微差别,而是给你一个大致的概念。
函数式编程的根源是λ演算,或λ-演算。它是数理逻辑中的一个图灵完备形式系统,用于表达计算。将 lambda 这个术语用于闭包和匿名函数是来自于这个。你写的任何代码都可以转换为λ演算。
注意
当一个语言或系统可以用来模拟图灵机时,就被称为图灵完备。一个同义词是计算通用。所有主要的编程语言都是图灵完备的,这意味着你可以在 C、Java、PHP、Haskell、JavaScript 等任何一种语言中编写的任何东西也可以在其他任何一种语言中编写。一个形式系统由以下元素组成:
-
一组符号或关键词的有限集
-
定义一个有效语法的语法
-
一组公理,或者基本规则
-
一组推理规则,用于从公理中推导出其他规则
代数结构是在其上定义了一个或多个操作的集合,并且有一系列公理或法则。我们在前几章中学习的所有抽象都是代数结构:幺半群、函子、应用函子和单子。这很重要,因为当一个新问题被证明遵循与现有集合相同的规则时,以前理论化的任何东西都可以被重用。
注意
集合是数学中的一个基本概念。它是一组不同对象的集合。你可以决定将任何东西组合在一起并称之为集合,例如,数字 5、23 和 42 可以形成一个集合{5, 23, 42}。集合可以明确地定义,就像我们刚刚做的那样,也可以使用规则来定义,例如,所有正整数。
范畴论是研究两个或多个数学结构之间关系的领域。它以对象和箭头或态射的集合来形式化它们。态射将一个对象或一个类别转换为另一个。一个范畴具有两个属性:
-
能够组合态射以关联
-
一个从一个对象或类别到自身的恒等态射
集合是满足这两个属性的多个可能类别之一。类别、对象和态射的概念可以非常抽象。例如,你可以将类型、函数、函子或幺半群视为类别。只要这两个属性得到遵守,它们可以是有限的或无限的,并且持有各种对象。
如果你对这个主题感兴趣,并想了解更多数学方面的知识,我可以推荐 Eugenia Cheng 写的书《蛋糕、卡斯塔和范畴论:理解复杂数学的简单配方》。这本书非常易懂,不需要先前的数学知识,而且读起来很有趣。
纯函数和引用透明的整个概念来自于λ演算。类型系统,特别是 Hindley-Milner,深深植根于形式逻辑和范畴论中。态射的概念与函数的概念非常接近,而组合处于范畴论的中心,导致了在过去几年对函数式语言的大多数进展在某种程度上与这个数学领域相关。
从数学到计算机科学
正如我们刚刚看到的,范畴论是函数式世界中的一个重要基石。你不必了解它来编写函数式代码,但它绝对有助于你理解基本概念并推理最抽象的东西。此外,关于函数式编程甚至库的论文中使用的许多术语都直接来自范畴论。因此,如果你对它有良好的直觉,通常会更容易理解你所阅读的内容。
类别实际上是一个非常简单的概念。它只是一些对象和它们之间的箭头。如果你能够使用这些想法以图形方式表示某些东西,那么它很可能是正确的,并且将极大地帮助你理解发生了什么。例如,如果我们决定表示函数组合,我们最终会得到类似这样的东西:
在图中,我们的三个形状是整个类别或给定类别中的对象并不重要。然而,立即清楚的是,如果我们有两个态射f
和g
,如果我们按顺序应用它们或者应用组合版本,结果是相同的。函数可以在相同类型上工作,例如,我们从一个形状转换到另一个形状,或者三角形代表字符串,菱形代表字符,圆圈代表整数。这没有区别。
另一个例子是 applicative 的同态定律;pure(f)->apply($x) == pure(f($x))
。如果我们将pure
函数视为从一个类别到表示 applicative 可能对象的类别的态射,我们可以将这个定律可视化如下:
虚线箭头是pure
函数,用于在 applicative 类别中移动x
,f
和y
。当我们这样看时,这个定律显然是正确的。你觉得呢?顺便说一句,这两个图表都被称为可交换图表。在可交换图表中,每条具有相同起点和终点的路径在箭头之间使用组合时是等价的。
另外,你可以考虑每种类型都是一个类别,而态射是一个函数。你可以想象函数作为从一个类别中的对象到同一个类别或不同类别中的对象的态射。你可以通过更小的类别在更大的类别中表示类型和类别的层次结构。例如,整数和浮点数将是数值类别中的两个类别,并且它们在一些数字上重叠。
这可能不是最学术上正确的描述类型、函数和范畴论的方式,但这是一个容易理解的方式。它使得更容易将抽象概念(如函子或单子)与我们习惯的东西并行概念化。
你可以将更传统的函数可视化为对值本身的操作。例如,strtoupper
函数是从string
类别中的一个对象到同一类别中的另一个对象的态射,而count
方法是从array
类别中的一个对象到integer
类别中的一个对象的态射。因此,这些都是从一个对象到另一个对象的箭头。
如果我们像在第二个图表中那样,从我们的基本类型中退后一步,我们可以想象函数作用于类型本身。例如,单子的pure
函数接受某个类别,无论是类型还是函数,并将其提升到一个新的类别中,在这个类别中,所有对象现在都被包裹在单子的上下文中。
这个想法很有趣,因为你以前的任何箭头也可以被提升,并且将继续在它们的新上下文中产生相同的结果,正如我们刚刚可视化的同态法则所证明的那样。
这意味着,如果你在使用单子或任何抽象概念时遇到困难,只需在纸上使用类别、对象和箭头绘制操作,然后你可以通过删除或添加上下文将一切归结为其本质。
重要的数学术语
在阅读有关函数式编程时,你可能会遇到一些数学术语。它们被使用是因为它们让我们能够快速传达当前描述的结构是关于什么以及可以从中期望什么属性。
我们已经学习了一个这样的术语,那就是幺半群。你可以在数学定义中找到关于它的一个定义:幺半群是一个在关联二进制操作下封闭并具有单位元素的集合。在这一点上,你应该能够理解这个定义;然而,这里是一个关于字符串连接幺半群的快速概述:
-
集合是所有可能的字符串值
-
二进制操作是字符串连接运算符,
.
-
单位元素是空字符串,
''
集合在操作下封闭的概念表明给定操作的结果也是集合的一部分。例如,对两个整数进行加法运算总是会得到一个整数。
以下是你可能在阅读中遇到的各种数学术语的快速词汇表。
-
关联性:如果操作的顺序不重要,则操作是关联的;例如加法和乘法是 a + (b + c) === (a + b) + c。
-
交换性:如果你可以改变操作数的顺序,那么操作是可交换的。大多数关联操作也是可交换的,如 a + b === b + a`。一个不可交换的关联操作的例子是函数组合,如 f (g h) === (f g) h,但 f g != g f。
-
分配性:如果 a * (b + c) == (a * b) + (a * c),则两个操作是分配的。在这种情况下,乘法是“分配于”加法。在这个例子中,**和+*可以被任何二进制操作替换。
-
半群:在一个关联操作下封闭的集合。
-
幺半群:具有单位元素的半群。
-
群:具有逆元素的幺半群。逆元是一个值,你可以将其添加到另一个元素中以获得身份元素。例如,10 + -10 = 0,-10是整数加法群中 10 的逆元。
-
阿贝尔群:操作是可交换的群。
-
环:具有第二个幺半群操作的阿贝尔群,该操作对第一个操作具有分配性。例如,整数是一个环,其中加法是第一个操作,乘法是第二个操作。
-
半环:一个环,其中阿贝尔群被可交换的幺半群(即,逆元素不存在)所取代。
-
余单子、余函子、余 XXX:单子、函子或任何东西的对偶范畴。对偶的另一个词是相反的。如果一个单子是将某物放入上下文的一种方式,那么它的余单子将是从中取出的一种方式。这是一个非常模糊的定义,没有解释用途,这需要一个章节来解释。
幻想乡
既然我们已经讨论了理论,我想向你介绍一个描述常见代数结构接口的 JavaScript 项目,名为Fantasy Land,网址为github.com/fantasyland/fantasy-land
。
它已经被社区广泛采用,每天有越来越多的项目实现了这个提议的接口,以便更好地在这些代数结构的各种实现之间进行互操作。在幻想乡命名空间下,你可以找到我们之前发现的各种单子的实现,以及许多其他更高级的函数构造。值得注意的是,还有Bilby库(bilby.brianmckenna.org/
),它试图尽可能接近 Haskell 的哲学。
为什么我要谈论一个 JavaScript 库呢?因为php-functional
库已经将幻想乡规范移植到了 PHPgithub.com/widmogrod/php-functional/tree/master/src/FantasyLand
。
我最希望其他项目以这些为基础,实现他们自己的函数代码,因为这将通过为开发人员提供更多可能使用的特性集,来增强 PHP 中的函数式编程。
在撰写本文时,有讨论要将幻想乡移植与库的其余部分分开,以便可以在不依赖其他一切的情况下使用。我希望在你阅读本文时,这项工作已经完成,我敦促你使用这套常见接口。
单子变换器
我们看到,如果你单独考虑每一个单子,它们已经是一个非常强大的想法。如果我告诉你,你可以将单子组合在一起,以便同时从它们的多个特性中受益,你会怎么想?例如,一个Maybe
接口和一个Writer
单子,可以告诉你为什么操作没有返回结果。
这正是单子变换器的意义所在。单子变换器在各个方面都类似于它所示范的单子,只是它不是一个独立的实体,而是修改另一个单子的行为。在某种程度上,我们可以想象这是在另一个单子的顶部添加一个新的单子层。当然,你可以将多个层堆叠在一起。
在 Haskell 中,大多数现有的单子都有对应的变换器。通常,它们的名称相同,只是后面加了一个T
:StateT
、ReaderT
、WriterT
。如果你要将一个变换器应用到恒等单子上,结果将具有与等效单子完全相同的特性,因为恒等单子只是一个简单的容器。
为了使其正确工作,State
、Reader
、Writer
和其他 Haskell 单子实际上是具有两个实例的类型类;一个是变换器,另一个是传统单子。
我们将在这里结束我们的探索,因为我所知道的 PHP 中没有这个概念的实现;尝试自己做这件事将是相当困难的,至少需要一个完整的章节。
至少你已经听说过这个想法,谁知道,也许将来会有人创建一个库,为 PHP 添加单子变换器。
镜头
当一切都是不可变的时,修改数据可能会变得非常麻烦,特别是如果你有一些复杂的数据结构。比如,假设你有一个用户列表,每个用户都有与他们关联的帖子列表,你需要在其中一个帖子上做一些改变。由于你不能直接改变任何东西,你需要复制或重新创建一切来修改你的值。
就像我说的,很繁琐。但与大多数事物一样,镜头有一个漂亮干净的解决方案。想象一下您的镜头就像双筒望远镜的一部分;它让您可以轻松地聚焦在数据结构的一部分上。然后,您可以轻松地修改它,并获得一个全新的闪亮数据结构,其中您的数据已更改为您想要的任何内容。
镜头是一种查找函数,它让您可以在数据结构的当前深度获取特定字段。您可以从镜头中读取,它将返回指定的值。您也可以向镜头写入,它将返回您修改后的整个数据结构。
镜头真正伟大的地方在于,由于它们是函数,您可以将它们组合在一起,因此如果您想深入到第一级之下,可以将第二个查找函数与第一个组合。您还可以在其上添加第三、第四等查找。在每一步,您都可以获取或设置值(如果需要)。
由于它们是函数,您可以像使用其他函数一样使用它们,可以映射它们,将它们放入应用函子,将它们绑定到单子。突然之间,一个非常繁琐的操作可以利用您的语言的所有功能。
遗憾的是,由于 PHP 中不常见不可变数据结构,没有人花时间为其编写镜头库。此外,这种可能性的细节有些混乱,需要相当长的时间来解释。这就是为什么我们现在将其留在这里的原因。
如果您感兴趣,Haskell 的lens
库有一个网页,上面有大量信息和很好的介绍,尽管有一个真正具有挑战性的视频,网址是lens.github.io/
。
总结
在本章中,我们涵盖了许多不同的主题。其中一些概念在 PHP 开发中会很有用,比如在递归时避免堆栈溢出、模式匹配、无点风格以及使用const
关键字使您的代码更易于阅读。
其他一些主题纯粹是理论性的,在 PHP 中目前没有使用,这些想法只是为了让您能够更好地理解有关函数式编程的其他写作,比如单子变换器、函数式编程与范畴论之间的联系以及镜头。
最后,有些主题在日常编码中有一定用处,但在 PHP 中实践起来有些困难,因为支持不足。即使不完美,您现在也了解了类型类、类型签名和代数结构。
我相信您并没有因为本章中主题的不断变化而感到太过困扰,并且学到了一些有价值和有趣的东西。我也希望这些内容激发了您对这些主题的进一步学习的兴趣,也许尝试一种函数式语言,看看所有这些暗示的好处是什么。
在下一章中,我们将回到一个更实际的话题,首先讨论测试功能代码,其次学习一种称为基于属性的测试的方法论,这并不严格属于函数式编程,但最初是在 Haskell 中理论化的。
第八章:测试
我们在整本书中已经多次断言纯函数更容易测试;现在是时候证明它了。在本章中,我们将首先介绍有关这个主题的小词汇表,以确保我们使用共同的语言。然后,我们将继续讨论功能性方法如何帮助传统测试。最后,我们将了解一种称为基于属性的测试的代码测试方法。
本章的主题并不严格限于函数式编程;您可以在任何传统代码库中使用任何内容。此外,这不是一本关于测试的书,所以我们不会详细介绍每个细节。还假定您对在 PHP 中测试代码有一些先验知识。
在本章中,我们将涵盖以下主题:
-
小型测试词汇表
-
测试纯函数
-
测试并行化作为一种加速技术
-
基于属性的测试
测试词汇表
我不会声称给你一个完整的所有与测试相关术语的词汇表,也不会解释每个术语的微妙差异和解释。这一部分的目的只是为了奠定一些共同的基础。
词汇表不会按字母顺序排列,而是根据类别分组。此外,绝对不能认为它是一个完整的词汇表。与测试相关的术语和技术远不止这里所呈现的内容,特别是如果包括所有与性能、安全性和可用性相关的测试方法:
-
单元测试:针对每个单独的组件进行的测试。被视为单元的内容各不相同-可以是一个函数/方法、一个整个类、一个整个模块。通常会模拟对其他单元的依赖,以清晰地隔离每个部分。
-
功能测试:以黑盒方式测试软件,以确保其符合规格。通常会模拟外部依赖。
-
集成测试:针对整个应用程序及其依赖项(包括外部依赖项)进行的测试,以确保一切正确集成。
-
验收测试:由最终客户/最终用户根据一组约定的标准进行的测试。
-
回归测试:在进行某些更改后重复测试,以确保没有引入问题。
-
模糊测试/ Fuzzing:通过输入大量(半)随机数据进行的测试,以使其崩溃。这有助于发现编码错误或安全问题。
-
临时测试:在没有正式框架或计划的情况下进行的测试。
-
组件测试:见单元测试。
-
黑盒测试:见功能测试。
-
行为测试:见功能测试。
-
用户验收测试(UAT):见验收测试。
-
Alpha 版本:通常是作为黑盒测试的第一个版本。它可能不稳定并导致数据丢失。
-
Beta 版本:通常是功能完整且足够好以发布给外部人员的第一个版本。它仍然可能存在严重问题,不应在生产环境中使用。
-
发布候选版(RC):被认为足够稳定以发布给公众进行最终测试的版本。通常最后一个 RC 会被“提升”为发布版本。
-
模拟(mock):创建模拟软件或外部服务的组件,以仅测试手头的问题。
-
存根(stub):见模拟。
-
代码覆盖率:测试覆盖的应用程序代码或功能的百分比。可以有不同的粒度:按行、按函数、按组件等。
-
仪器化:向应用程序添加代码以测试和监视行为或覆盖范围的过程。可以手动完成,也可以通过工具在源代码、编译形式或内存中完成。
-
同行评审:一种同事检查所产出工作(如代码、文档或与发布相关的任何内容)的过程。
-
静态分析:分析应用程序而无需运行它,通常由工具完成。它可以提供有关覆盖范围、复杂性、编码风格甚至发现问题的信息。
-
静态测试:在不执行应用程序的情况下进行的所有测试和审查。参见同行审查和静态分析。
-
冒烟测试:对应用程序的主要部分进行表面测试,以确保核心功能正常工作。
-
技术审查:参见同行审查。
-
决策点:代码中的一个语句,控制流可以发生变化,通常是一个
if
条件。 -
路径:从函数开始到结束执行的语句序列。根据其决策点,函数可以有多个路径。
-
圈复杂度:代码复杂性的度量。有各种算法来计算它;其中一个是“决策点的数量+1”。
-
缺陷、失败、问题或错误:在应用程序中未按预期工作的任何内容。
-
假阳性:测试结果被视为缺陷,而实际上一切都正常。
-
假阴性:测试结果被视为成功,而实际上存在缺陷。
-
测试驱动开发(TDD):一种开发方法,您首先编写测试,然后编写最少量的代码使其通过,然后重复该过程。
-
行为驱动开发(BDD):一种基于 TDD 的开发方法,其中您使用特定于领域的语言描述行为,而不是编写传统测试。
-
类型驱动开发:在功能世界中的一个笑话,您可以使用强类型系统替换测试。取决于您问的人,这个想法可能会更或更不严肃。
-
基于 X 的开发:每周都会出现一种新的最佳开发方法;网站
devdriven.by/
试图引用它们。
测试纯函数
正如我们在术语表中看到的那样,有很多潜在的测试应用程序的方法。在本节中,我们将限制自己只进行函数级别的测试;换句话说,我们将进行单元测试。
那么,纯函数为什么要容易得多呢?有多种原因;让我们从列举它们开始,然后我们将通过真实的测试用例来看为什么:
-
模拟变得简单,因为您只需要提供输入参数。无需创建外部状态,也无需存根单例。
-
对于给定的参数列表,重复调用将产生完全相同的结果,无论是白天还是之前运行的测试。无需将应用程序置于特定状态。
-
函数式编程鼓励编写更小的函数,每个函数只做一件事。这通常意味着更容易编写和理解的测试用例。
-
引用透明度通常意味着您需要更少的测试来获得对代码的相同信任水平。
-
无副作用保证了您的测试不会对任何其他后续测试产生影响。这意味着您可以以任何您想要的顺序运行它们,而不必担心在每个测试之间重置状态或者独立运行它们。
这些声明中的一些可能对您来说似乎有点大胆,或者您可能不确定我为什么要这样做。让我们花点时间用例子来验证它们为什么是真的。我们将把我们的例子分成四个不同的部分,以便更容易跟踪。
所有输入都是显式的。
正如我们之前发现的,纯函数需要将其所有输入作为参数。您不能依赖于单例的某些静态方法,生成随机数,或者从外部来源获取任何可能发生变化的数据。
其推论是,您可以在一天中的任何时间,在任何环境中,对于任何给定的参数列表运行测试,输出将保持不变。这个简单的事实使得编写和阅读测试变得更容易。
想象一下,您需要测试以下函数:
<?php
function greet()
{
$hour = (int) date('g');
if ($hour >= 5 && $hour < 12) {
return "Good morning!";
} elseif ($hour < 18) {
return "Good afternoon!";
} elseif ($hour < 22) {
return "Good evening!";
}
return "Good night!";
}
问题在于,当你调用函数时,你需要知道现在是什么时间,这样你才能检查返回值是否正确。这一事实导致了一些问题:
-
基本上,你必须在测试中重新实现函数逻辑,因此可能在测试和函数中都存在相同的错误。
-
在你计算期望值并且函数再次返回结果之间,可能会有一分钟的时间流逝,改变当前的小时,从而改变函数的结果。这种假阳性的情况真的很头疼。
-
在不以某种方式操纵系统时钟的情况下,你无法测试所有可能的输出。
-
当前时间的依赖性被隐藏,阅读测试的人只能推断函数在做什么。
通过简单地将$hour
变量作为参数传递,我们解决了之前提到的所有问题。
此外,如果你使用一个允许你为测试创建数据提供程序的测试运行器,比如PHPUnit或atoum,测试函数就变得非常简单,只需要创建一个提供程序,生成与预期返回相关联的小时列表,然后将时间提供给函数并检查结果。这种测试比之前需要编写的任何其他内容都更简单、更易于理解和扩展。
引用透明性和无副作用
引用透明性确保你可以在代码的任何地方用计算结果替换函数调用(带有特定参数)。这对于测试也是一个有趣的特性,因为这基本上意味着你需要测试的内容更少,就能获得相同的信任。让我解释一下。
通常,在进行单元测试时,你会尽量选择最小的单元,以满足你对代码的信任。通常情况下,你会在模块、类或方法级别进行测试。显然,在进行函数式编程时,你会在函数级别进行测试。
你的函数显然会调用其他函数。在传统的测试设置中,你会尽量模拟尽可能多的函数,以确保你只测试当前单元的功能,而不会受到其他函数可能存在的错误的影响。
虽然在 PHP 中模拟函数并非不可能,但在我们的情况下有些麻烦。特别是对于像$title = compose('strip_tags', 'trim', 'capitalize');
这样的组合函数,由于 PHP 中使用闭包实现组合的方式,这变得有些困难。
那么我们该怎么办呢?基本上什么都不做。单元测试的目标是对代码按预期方式的工作获得信心。在传统的命令式方法中,你会尽量模拟尽可能多的依赖项,原因如下:
-
每个依赖项都可能依赖于你需要提供的某些状态,使你的工作更加困难。更糟糕的是,依赖项可能有自己的依赖项,也需要一些状态,依此类推。
-
命令式代码可能会产生副作用,这可能导致你的函数或某些依赖项出现问题。这意味着,如果没有模拟,你不仅在测试你的函数,还在测试所有其他依赖项和它们之间的交互;换句话说,你在进行集成测试。
-
控制结构引入决策点,这可能使对函数的推理变得复杂;这意味着,如果你将移动部件的数量减少到最低限度,你的函数就更容易测试。模拟其他函数调用可以减少这种复杂性。
在进行函数式编程时,第一个问题是无关紧要的,因为没有全局状态。你的依赖项所需的一切要么已经在被测试函数的参数中,要么将在途中计算。因此,模拟依赖项将使你做更多的工作,而不是更少。
由于我们的函数是纯函数且引用透明的,因此副作用不会对计算结果产生任何影响,这意味着即使我们有依赖关系,我们也不进行集成测试。当然,如果调用的函数中有错误,那么会导致错误,但希望它也会在另一个测试中被捕获,从而清楚地说明发生了什么。
关于复杂性,如果我们回到我们的组合函数,$title = compose('strip_tags', 'trim', 'capitalize');
,我认为任何人都很容易理解发生了什么。如果所有三个函数都已经经过测试,那么即使我们在没有compose
命令的情况下重新编写它,也不会出现太多问题。
<?php
function title(string $string): string
{
$stripped = strip_tags($string);
$trimmed = trim($stripped);
return capitalize($trimmed);
}
这里没有太多需要测试的地方。显然,我们需要编写一些测试来确保我们将正确的临时值传递给每个函数,并且管道的工作符合预期,但是如果我们对所有三个被调用的函数都有信心,那么我们就可以非常有信心地认为这个函数也会工作。
这种推理是可能的,因为我们知道由于引用透明的属性,这三个函数中的任何一个都不会以一些微妙的方式影响其他任何一个,这意味着它们自己的单元测试给了我们足够的信任,即它们不会出错。
所有这些的结果通常是,您会为函数式代码编写更少的测试,因为您会更快地获得信任。但这并不意味着title
函数不需要测试,因为您可能在某个地方犯了一个小错误。每个组件仍然应该被测试,但可能在正确隔离一切方面要小心一些。
显然,我们不是在谈论数据库访问,第三方 API 或服务;出于与任何测试套件相同的原因,这些都应该被模拟。
简化模拟
这可能已经很清楚了,但我真的想强调一点,您需要做的任何模拟都会大大简化。
首先,您只需要创建要测试的函数的输入参数。在某些情况下,这意味着创建一些相当大的数据结构或实例化复杂的类,但至少您不必模拟外部状态或注入到依赖项中的大量服务。
这可能在所有情况下都不是真的,但通常您的函数在较小的规模上运行,因为它们是更大东西的一小部分,这意味着任何一个函数只会接受一些非常精确和简洁的参数。
显然,会有例外,但不是很多,正如我们之前讨论的那样,由于构成整体的所有部分已经被测试。因此,您的信心程度应该比通常情况下更高。
构建模块
函数式编程鼓励创建小的构建模块,这些模块作为更大函数的一部分被重复使用。这些小函数通常只做一件事。这使它们更容易理解,也更容易测试。
函数的决策点越多,测试每个可能的执行路径就越困难。一个小的专门函数通常最多有两个这样的决策点,这使得它相当容易测试。
通常较大的函数不会执行任何控制流,它们只是以直接的方式由我们的较小模块组成。由于这意味着只有一条可能的执行路径,这也意味着它们很容易测试。
结束语
当然,我并不是说您不会遇到一些难以测试的纯函数。通常情况下,您编写测试时会遇到更少的麻烦,并且您也会更快地对代码产生信任。
随着行业越来越接近 TDD 等方法论,这意味着函数式编程确实非常适合现代应用。一旦你意识到,通过只使用函数式编程技术就已经强制执行了大部分关于编写“可测试代码”的建议,这一点尤其正确。
使用并行化加速
如果你曾经寻找加速测试套件的解决方案,很可能会找到关于测试并行化的内容。通常,PHPUnit 的用户会找到ParaTest实用工具,例如。
主要思想是同时运行多个 PHP 进程,以利用计算机的所有处理能力。这种方法主要有两个原因:
-
单次测试运行存在瓶颈,比如源文件解析的磁盘速度或数据库访问。
-
由于 PHP 是单线程的,像几乎所有现在的计算机一样,多核 CPU 在单次测试运行中没有得到充分利用。
通过并行运行多个测试,这两个问题都可以解决。然而,能够这样做的能力受到了一个限制,即每个测试套件都是独立的,这一特性在函数式代码库中已经通过引用透明性得到了强制执行。
这意味着,如果被测试的函数遵循函数式原则,你可以在不做任何调整的情况下并行运行所有的测试。在某些情况下,这可能会将整个测试套件所需的时间缩短十分之一,大大改善了你在开发过程中的反馈循环。
如果你使用 PHPUnit 实用工具,前面提到的 ParaTest 实用工具是最简单的入门方式之一。你可以在 GitHub 上找到它:github.com/brianium/paratest
。我建议你使用**-functional
**命令行参数,这样每个函数都可以同时进行测试,而不仅仅是测试用例。
PHPUnit 用户还有一个全新的实用工具叫做PHPChunkIt。我还没有机会测试它,但我听说它很有意思。你可以在 GitHub 上找到它:github.com/jwage/phpchunkit
。
另一个更灵活的选择是使用 Fastest,可以在github.com/liuggio/fastest
找到。工具文档中显示的示例是针对 PHPUnit 的,但理论上它能够并行运行任何东西。
如果你使用的是 atoum 实用工具,那么默认情况下你的测试已经处于他们所谓的并发模式,这意味着它们是并行运行的。你可以根据执行引擎文档中的注释修改每个测试的行为:atoum-en.rtfd.org/en/latest/engine.html
。
behat框架的用户可以使用Parallel Runner扩展,也可以在 GitHub 上找到:github.com/shvetsgroup/ParallelRunner
。如果你使用CodeCeption框架,要实现并行化可能有点困难;然而,文档(codeception.com/docs/12-ParallelExecution
)中有多种可能的解决方案。
我强烈建议你研究一下并行化你的测试,因为这将是花费时间的好方法。即使你每次运行只能节省几秒钟,这种收益很快就会累积起来。更快的测试意味着你会更频繁地运行它们,这通常是改进代码质量的好方法。
基于属性的测试
约翰·休斯和科恩·克拉森厌倦了费时费力地编写测试用例,他们决定是时候改变一下了。15 年多前,他们写了一篇关于他们称之为QuickCheck的新工具的论文并发表了出来。
主要思想是,不是定义可能的输入值列表,然后断言结果是我们期望的,而是定义表征函数的属性列表。然后工具会自动生成所需的测试用例,并验证属性是否成立。
默认的操作模式是QuickCheck生成随机值并将其提供给您的函数。然后检查结果是否符合属性。如果检测到失败,工具将尝试将输入减少到生成问题的最小输入集。
拥有一个工具可以生成尽可能多的测试值是无价的,可以找到需要花费数小时才能想到的边缘情况。测试用例被减少到最小形式也很容易确定出了什么问题以及如何解决。偶然情况下,随机值并不总是测试某些东西的最佳方式。这就是为什么您还可以提供要使用的生成器。
此外,将测试视为一组需要保持真实的属性是一种更清晰地关注系统应该做什么而不是专注于查找测试值的好方法。这在进行 TDD 时尤其有帮助,因为您的测试将更像是规范。
如果你想了解更多关于这种方法的信息,原始论文可以在www.cs.tufts.edu/~nr/cs257/archive/john-hughes/quick.pdf
上找到。作者在论文中使用 Haskell,但内容相当容易阅读和理解。
属性到底是什么?
属性是您的函数必须遵守的规则,以确定其正确性。它可以是非常简单的东西,比如函数添加两个整数的结果也需要是整数,也可以是更复杂的东西,比如验证单子定律。
通常,您希望创建的属性不是已经由其他属性或语言强制执行的。例如,如果我们使用 PHP 7 引入的标量类型系统,我们之前的整数示例就不需要了。
举个例子,我们将从论文中选取一些内容。假设我们刚刚编写了一个函数,用于反转数组中元素的顺序。作者建议这个函数应该具有以下属性:
-
reverse([x]) == [x]
属性,反转一个只有一个元素的数组应该产生完全相同的数组 -
reverse(reverse(x)) == x
属性,两次反转数组应该产生完全相同的数组 -
reverse(array_merge(x, y)) == array_merge(reverse(y), reverse(x))
属性,反转两个合并的数组应该产生与将第二个数组反转后合并到第一个数组反转的结果相同
前两个属性将确保我们的函数不会干扰值。如果我们只有这两个属性,一个除了返回参数之外什么都不做的函数将会轻松通过测试。这就是第三个属性发挥作用的地方。它的写法确保我们的函数按我们期望的方式工作,因为没有其他方式属性会成立。
有趣的是这些属性在任何时候都不执行任何计算。它们很容易实现和理解,这意味着几乎不可能在其中引入错误。如果您通过某种方式重新实现它们正在进行的计算来测试您的函数,这将有点违背初衷。
尽管非常简单,这个例子完美地展示了找到有价值的属性既有意义又足够简单以确保它们不会有错误并不容易。如果您在找到好的属性方面有困难,我鼓励您以业务逻辑的角度来审视您的函数。不要以输入和输出为出发点,而是尝试看到更广阔的画面。
实现 add 函数
关于为什么基于属性的测试是一种有价值的工具的很好的解释可以在网上的幻灯片中找到www.slideshare.net/ScottWlaschin/an-introduction-to-property-based-testing
。还有一个伴随的博客帖子,提供了更多信息fsharpforfunandprofit.com/posts/property-based-testing-2/
。我将在这里快速总结它们。
要求开发人员编写一个添加两个值的函数,并进行一些测试。他编写了两个预期结果为 4 的测试;一切正常。要求函数的人要求进行更多的测试;它们失败的原因是函数总是返回值 4,而没有做任何有意义的事情。
开发人员重写函数,使测试再次通过,但新一轮的测试继续失败。实际上所做的是将结果合并到原始函数中的新测试中作为特殊情况。开发人员提出的借口是,他们遵循了 TDD 的最佳实践,即需要编写最小的代码来使测试通过。
发生的事情可能对于这样一个简单的功能来说似乎很愚蠢,但是如果你用一些需要实现的复杂业务逻辑来替换它,这样的故事可能比你想象的更常见,也是 TDD 的一个缺点,正如其反对者所说的。如果你严格遵循 TDD,你的代码永远不会比你的测试更好。
幻灯片继续介绍了一些值为随机整数的测试,并通过将结果与x + y
进行比较来测试函数。在这种情况下,开发人员无法使用函数中的特殊情况进行欺骗。然而,显然还有另一个问题,即在测试中重新实现了函数以验证结果。
进入基于属性的测试。首先实现的属性是add(x, y) == add(y, x)
。开发人员将add
属性实现为x * y
,这样就能正确通过测试。
这意味着我们需要第二个属性,例如add(add(x, 1), 1) == add(x, 2)
属性。这也可以通过实现x - y
来实现,但在这种情况下,第一个测试将失败。这就是为什么开发人员的最新实现只是返回0
。
在这一点上,最后一个属性add(x, 0) == x
被添加。开发人员最终被迫为我们的函数编写正确的实现,因为这一次他无法找到欺骗的方法。
如果我们回到我们的最后三个属性,并将它们与我们对数学中加法属性的了解进行比较,我们可以得出以下比较:
-
在
add(x, 0) == x
属性中,0 是加法的单位元 -
在
add(x, y) == add(y, x)
属性中,加法是交换的 -
在
add(add(x, 1), 1) == add(x, 2)
属性中,加法是结合的
这三个属性实际上都是我们试图实现的操作的众所周知的属性。正如我们之前所说的,退一步反思“是什么”而不是“谁”,对于提出属性时是一个很好的帮助。
幻灯片的其余部分是一次很好且有趣的阅读,但是我不想剽窃整个内容,我更愿意鼓励你去网上阅读。我只会从中选取三条建议,因为我觉得它们真的很好,也很容易记住:
-
不同的路径,同一个目的地:想出两种不同的方法来使用被测试的函数得到相同的结果,就像我们为
reverse
的第三个属性所做的那样。 -
来回一趟:如果你的函数有一个反函数,尝试同时应用两者,看看是否能得到初始值,就像我们为
reverse
的第二个属性所做的那样。 -
有些事情永远不会改变:如果您的输入的某些属性不会被函数改变,那么对它们进行测试,例如数组长度或数据类型。
有了这一切,现在您应该对如何为您的函数找到好的属性有了一个很好的想法。这仍然是一项困难的任务,但最终您可能会节省很多时间,因为您不必在找到它们时添加边缘情况。
如果您想要一个真实生活中由于基于属性的测试而被发现的错误的很好例子,约翰·休斯本人在vimeo.com/68383317
上做了一个很棒的演讲,并举了一些很好的例子。
PhpQuickCheck 测试库
在我们已经看到了属性测试的理论方面之后,现在我们可以将注意力转向 PHP 特定的实现-PhpQuickCheck
库。源代码可以在 GitHub 上找到github.com/steos/php-quickcheck
,并且可以使用**composer
**命令进行安装:
**composer require steos/php-quickcheck -stability dev**
您可能需要在您的composer.json
文件中将minimum-stability
设置为dev
,或者根据 GitHub 页面上的说明手动添加依赖项,因为目前该库还没有稳定版本。
该项目始于 2014 年 9 月,大部分开发工作都在同年 11 月之前进行。自那时以来,没有添加太多新功能,主要是改进编码风格和一些小的改进。
虽然我们不能说该项目今天真的还很活跃,但它是 PHP 中第一个严肃尝试拥有QuickCheck
库的项目之一,并且它具有一些功能,这些功能在其主要竞争对手中尚不可用,稍后将进行讨论。
但是,让我们不要急于行事;让我们回到我们的第一个例子,即反转函数。想象一下,我们编写了 PHP 中可用的array_reverse
函数,并且我们需要对其进行测试。使用PhpQuickCheck
库,它将如下所示:
<?php
use QCheck\Generator;
use QCheck\Quick;
$singleElement = Quick::check(1000, Generator::forAll(
[Generator::ints()],
function($i) {
return array_reverse([$i]) == [$i];
}
), ['echo' => true]);
$inverse = Quick::check(1000, Generator::forAll(
[Generator::ints()->intoArrays()],
function($array) {
return array_reverse(array_reverse($array)) == $array;
}
), ['echo' => true]);
$merge = Quick::check(1000, Generator::forAll(
[Generator::ints()->intoArrays(), Generator::ints()- >intoArrays()],
function($x, $y) {
return
array_reverse(array_merge($x, $y)) ==
array_merge(array_reverse($y), array_reverse($x));
}
), ['echo' => true]);
check
静态方法接受需要生成的测试数据量作为第一个参数。第二个参数是Generator
函数的实例;通常,您将使用Generator::forAll
在示例中创建它。最后一部分是您可以传递的选项数组,包括随机生成器seed
变量,生成的数据的max_size
函数(此值的含义取决于所使用的生成器),或者最后的echo
选项,它将显示一个点(.
)表示每个通过的测试。
forAll
实例接受一个表示测试参数和测试本身的数组。在我们的例子中,对于第一个测试,我们生成随机整数,对于另外两个测试,我们生成随机整数数组。测试必须返回一个布尔值:true
表示通过,否则为false
。
如果您运行我们的小例子,它会显示每个生成的随机数据的一个点,因为我们传递了echo
选项。结果变量包含有关测试结果本身的信息。在我们的情况下,如果您显示$merge
,它将显示:
array(3) {
["result"]=> bool(true)
["num_tests"]=> int(1000)
["seed"]=> int(1478161013564)
}
seed
实例在每次运行时都会不同,除非您将其作为参数传递。重用seed
实例允许您创建完全相同的测试数据。这对于检查特定边缘情况是否在被发现后被正确修复非常有用。
一个有趣的功能是根据类型注释自动确定要使用哪个生成器。您可以使用Annotation
类上的方法来实现:
<?php
/**
* @param string $s
* @return bool
*/
function my_function($s) {
return is_string($s);
}
Annotation::check('my_function');
然而,这个功能目前只能与注释一起使用,类型提示将被忽略。
正如您在这些小例子中所看到的,PhpQuickCheck
库在很大程度上依赖于静态函数。代码库本身有时也有点难以理解,而且该库缺乏良好的文档和活跃的社区。
总的来说,我认为我不会推荐使用这个选项,我们将在下面看到的选项可能更好。我只是想向您介绍这个库作为一个可能的替代方案,谁知道,它的状态可能会在未来发生变化。
Eris
Eris的开发始于 2014 年 11 月,大约是PhpQuickCheck
库引入最后一个重大功能的时间。正如我们将看到的,编码风格明显更现代。一切都清晰地组织在命名空间中,辅助函数采用函数的形式而不是静态方法。
像往常一样,您可以使用**composer
**命令获取 Eris:
**composer require giorgiosironi/eris**
文档可在线获取,网址为eris.rtfd.org/
,并且非常完整。我对它唯一的抱怨是,唯一的示例是为使用 PHPUnit 运行其测试套件的人准备的。应该可以使用其他测试运行器,但目前尚未有文档记录。
如果我们想使用 Eris 来测试我们为array_reduce
定义的属性,我们的测试用例将如下所示:
<?php
use Eris\Generator;
class ArrayReverseTest extends \PHPUnit_Framework_TestCase
{
use Eris\TestTrait;
public function testSingleElement()
{
$this->forAll(Generator\vector(1, Generator\nat()))
->then(function ($x) {
$this->assertEquals($x, array_reverse($x));
});
}
public function testInverse()
{
$this->forAll(Generator\seq(Generator\nat()))
->then(function ($x) {
$this->assertEquals($x, array_reverse(array_reverse($x)));
});
}
public function testMerge()
{
$this->forAll(
Generator\seq(Generator\nat()),
Generator\seq(Generator\nat())
)
->then(function ($x, $y) {
$this->assertEquals(
array_reverse(array_merge($x, $y)),
array_merge(array_reverse($y), array_reverse($x))
);
});
}
}
该代码与我们为PhpQuickCheck
库编写的代码有些相似,但利用了由提供的 trait 添加到我们的测试用例和生成器函数中的方法,而不是静态方法。forAll
方法接受表示测试函数参数的生成器列表。随后,您可以使用then
关键字来定义函数。您可以访问 PHPUnit 提供的所有断言。
文档详细解释了您如何配置库的各个方面,例如生成的测试数据量,限制执行时间等。每个生成器也都有详细的说明,包括各种示例和用例。
让我们看看当我们有一个失败的测试用例时会发生什么。想象一下,我们想证明没有字符串也是一个数值;我们可以编写以下测试:
<?php
class StringAreNotNumbersTest extends \PHPUnit_Framework_TestCase
{
use Eris\TestTrait;
public function testStrings()
{
$this->limitTo(1000)
->forAll(Generator\string())
->then(function ($s) {
$this->assertFalse(is_numeric($s),"'$s' is a numeric value.");});
}
}
您可以看到我们使用limitTo
函数将迭代次数从默认的 100 提高到 1,000。这是因为实际上有很多字符串并不是数值,如果不提高迭代次数,我只能得到三次测试中的一次失败。即使有了更高的限制,有时所有的测试数据仍可能通过测试而没有失败。
这是你会得到的输出类型:
PHPUnit 5.6.2 by Sebastian Bergmann and contributors. F 1 / 1 (100%)
Reproduce with:
ERIS_SEED=1478176692904359 vendor/bin/phpunit --filter StringAreNotNumbersTest::testStrings
Time: 42 ms, Memory: 4.00MB
There was 1 failure:
1) StringAreNotNumbersTest::testStrings
'9' is a numeric value. Failed asserting that true is false. ./src/test.php:55
./src/Quantifier/Evaluation.php:51
./src/Quantifier/ForAll.php:154
./src/Quantifier/ForAll.php:180
./src/test.php:57
FAILURES! Tests: 1, Assertions: 160, Failures: 1\.
测试在 160 次迭代后失败,字符串为"9"
。Eris 还会给出命令,如果您想通过手动设置随机生成器来精确重现此失败的测试:
**ERIS_SEED=1478176692904359 vendor/bin/phpunit -filter StringAreNotNumbersTest::testStrings".**
正如您所看到的,当您的测试是为 PHPUnit 编写时,该库非常易于使用。否则,您可能需要做一些调整,但我认为这值得您的时间。
结束语
QuickCheck
库在严格类型的函数式编程语言中更容易使用,因为只需为某些类型声明生成器和一些函数的属性,几乎其他所有事情都可以自动完成。PhpQuickCheck
库试图模拟这种行为,但结果有点麻烦。
然而,这并不意味着您不能有效地在 PHP 中使用基于属性的测试!一旦您创建了生成器,框架将使用它生成尽可能多的测试数据,可能会发现您从未想到的边缘情况。例如,在 PHP 中,DateTime
方法的实现存在一个在闰年时出现的 bug,手动创建测试数据时很容易忽略。有关此问题的更多详细信息,请参阅 Eris 的创建者在www.giorgiosironi.com/2015/06/property-based-testing-primer.html
中的测试语言部分。
编写属性可能具有挑战性,特别是在开始阶段。但往往它有助于您思考您正在实现的功能,并且可能会导致更好的代码,因为您花时间从不同的角度考虑它。
总结
在本章中,我们快速了解了在使用更功能化的编程方法时可以在测试方面做些什么。正如我们所看到的,功能化代码通常更容易测试,因为它强制执行了在进行命令式编码时被认为是最佳实践的测试。
通过没有副作用和明确的依赖关系,您可以避免在编写测试时通常遇到的大部分问题。这将导致测试时间减少,更多时间集中在应用程序上。
我们还发现了基于属性的测试,这是发现与边缘情况相关问题的好方法。它还允许我们退一步,思考您想要强制执行的函数属性,这类似于为它们创建规范。这种方法在进行 TDD 时特别有效,因为它迫使您思考您想要什么,而不是如何做。
现在我们已经讨论了测试以确保我们的函数执行应该做的事情,接下来我们将学习关于代码优化,以便在应用程序性能方面进行。经过充分测试的代码库将帮助您进行必要的重构,以实现更好的速度。
第九章:性能效率
现在我们已经涵盖了与函数式编程相关的各种技术,是时候分析它如何影响像 PHP 这样的语言的性能了,尽管每个版本都引入了越来越多的函数式特性,但 PHP 仍然在其核心是命令式的。
我们还将讨论为什么性能最终并不那么重要,以及我们如何利用记忆化和其他技术来在某些情况下缓解这个问题。
我们还将探讨两种由引用透明性启用的优化技术。第一种是记忆化,这是一种缓存类型。我们还将谈论如何在 PHP 中并行运行长时间的计算,以及您如何利用这一点。
在本章中,我们将涵盖以下主题:
-
函数式编程的性能影响
-
记忆化
-
计算的并行化
性能影响
由于没有核心支持柯里化和函数组合等功能,它们需要使用匿名包装函数来模拟。显然,这会带来性能成本。此外,正如我们在关于尾调用递归的部分已经讨论过的那样,在 第七章 函数式技术和主题 中,使用跳板也更慢。但与更传统的方法相比,你会损失多少执行时间呢?
让我们创建一些函数作为基准,并测试我们可以实现的各种速度。该函数将执行一个非常简单的任务,即将两个数字相加,以确保我们尽可能有效地测量开销:
<?php
use Functional as f;
function add($a, $b)
{
return $a + $b;
}
function manualCurryAdd($a, $b = null) {
$func = function($b) use($a) {
return $a + $b;
};
return func_num_args() > 1 ? $func($b) : $func;
}
$curryiedAdd = f\curry('add');
function add2($b)
{
return $b + 2;
}
function add4($b)
{
return $b + 4;
}
$composedAdd4 = f\compose('add2', 'add2');
$composerCurryedAdd = f\compose($curryiedAdd(2), $curryiedAdd(2));
我们创建了第一个函数 add
并对其进行了柯里化;这将是我们的第一个基准。然后我们将比较一个专门添加 4
到一个值的函数与两种不同的组合。第一个是两个专门函数的组合,第二个是两个 add
函数的柯里化版本的组合。
我们将使用以下代码来对我们的函数进行基准测试。它非常基础,但应该足以展示任何有意义的差异:
<?php
use Oefenweb\Statistics\Statistics;
function benchmark($function, $params, $expected)
{
$iteration = 10;
$computation = 2000000;
$times = array_map(function() use($computation, $function, $params, $expected) {
$start = microtime(true);
array_reduce(range(0, $computation), function($expected) use ($function, $params) {
if(($res = call_user_func_array($function, $params)) !== $expected) {
throw new RuntimeException("Faulty computation");
}
return $expected;
}, $expected);
return microtime(true) - $start;
}, range(0, $iteration));
echo sprintf("mean: %02.3f seconds\n", Statistics::mean($times));
echo sprintf("std: %02.3f seconds\n", Statistics::standardDeviation($times)); }
统计方法来自于可通过 composer 获取的 oefenweb/statistics
包。我们还检查返回的值是否符合预期,作为额外的预防措施。我们将连续运行每个函数 200 万次,每次运行 10 次,并显示 200 万次运行的平均时间。
让我们先运行柯里化的基准测试。显示的结果是针对 PHP 7.0.12。当尝试在 PHP 5.6 中运行时,所有基准测试都会变慢,但它们在各种函数之间表现出相同的差异:
<?php
benchmark('add', [21, 33], 54);
// mean: 0.447 seconds
// std: 0.015 seconds
benchmark('manualCurryAdd', [21, 33], 54);
// mean: 1.210 seconds
// std: 0.016 seconds
benchmark($curryiedAdd, [21, 33], 54);
// mean: 1.476 seconds
// std: 0.007 seconds
显然,结果将根据测试运行的系统而有所不同,但相对差异应该保持大致相同。
首先,如果我们看标准偏差,我们可以看到 10 次运行大多数情况下花费了相同的时间,这表明我们可以相信我们的数字是性能的良好指标。
我们可以看到柯里化版本明显更慢。手动柯里化效率稍微更高,但两个柯里化版本大部分情况下比简单函数版本慢三倍。在得出结论之前,让我们看看组合函数的结果:
<?php
benchmark('add4', [10], 14);
// mean: 0.434 seconds
// std: 0.001 seconds
benchmark($composedAdd4, [10], 14);
// mean: 1.362 seconds
// std: 0.005 seconds
benchmark($composerCurryedAdd, [10], 14);
// mean: 3.555 seconds
// std: 0.018 seconds
标准偏差足够小,以至于我们可以认为这些数字是有效的。
关于值本身,我们可以看到组合也大约慢了三倍,而柯里化函数的组合,毫不奇怪,慢了九倍。
现在,如果我们将最坏情况的 3.55 秒与最佳情况的 0.434 秒进行比较,这意味着在使用组合和柯里化时我们有 3 秒的开销。这重要吗?看起来失去了很多时间吗?让我们试着在一个 web 应用程序的背景下想象这些数字。
开销重要吗?
我们对我们的方法进行了两百万次执行,用了三秒钟。我最近参与的一个项目是一个奢侈品牌的电子商务应用程序,在 26 个国家和 10 多种语言中可用,完全是从零开始编写的,没有使用任何框架,渲染一个页面需要大约 25,000 次函数调用。
即使我们承认所有这些调用都是事先柯里化的组合函数,这意味着在最坏的情况下,开销现在大约为 40 毫秒。该应用程序大约需要 180 毫秒来显示一个页面,因此我们在性能上会有 20-25%的降低。
这仍然很多,但远不及我们之前看到的三倍慢的数字。与每个函数调用相关的函数式技术的开销将随着每个函数调用的增加而线性增长。在基准测试中看起来很好,因为执行的计算是微不足道的。在现实应用中,你会有外部瓶颈,比如数据库、第三方 API 或文件系统。你还有执行复杂计算的函数,需要比简单的加法更多的时间。在这种情况下,引入的开销将成为应用程序总执行时间的一个较小部分。
这也是一个最坏的情况,我们假设一切都是组合和柯里化的。在现实世界的应用程序中,你可能会使用传统的框架,其中包含没有开销的函数和方法。你还可以识别代码中的热点路径,并手动优化它们,使用显式的柯里化和组合而不是辅助函数。也不需要对所有东西都进行柯里化;你将有只有一个参数的函数不需要它,还有一些函数使用柯里化是没有意义的。
这些数字是考虑到缓存不热的应用程序。你已经采取的任何减少页面渲染时间的机制将继续发挥作用。例如,如果你有一个 Varnish 实例在运行,你的页面可能会以相同的速度提供。
不要忘记
我们将一个非常小的函数与组合和柯里化进行了比较。现代 PHP 代码库将使用类来保存业务逻辑和值。让我们使用以下add
函数的实现来模拟这一点:
<?php
class Integer {
private $value;
public function __construct($v) { $this->value = $v; }
public function get() { return $this->value; }
}
class Adder {
public function add(Integer $a, Integer $b) {
return $a->get() + $b->get();
}
}
传统方法所需的时间会增加:
<?php
benchmark([new Adder, 'add'], [new Integer(21), new Integer(33)], 54);
// mean: 0.767 seconds
// std: 0.019 seconds
只需将所有东西封装在一个类中并使用 getter,执行时间几乎翻了一番,这意味着在基准测试中,函数式方法只比传统方法慢 1.5 倍,而我们示例应用程序中的开销现在已经是 10-15%,这已经好多了。
我们能做些什么吗?
遗憾的是,我们实际上没有什么可以做的。我们可以通过更有效地实现curry
和compose
方法来节省一点时间,就像我们使用手动柯里化版本的add
方法一样,但这不会带来太大的影响。
然而,将这两种技术作为 PHP 的核心部分实现,将带来很多好处,可能使它们与传统函数和方法持平,或者非常接近。但据我所知,目前没有计划这样做。
可能还可以创建一个 C 语言扩展程序,以更有效的方式实现这两个函数在 PHP 中的应用。然而,这将是不切实际的,因为大多数 PHP 托管公司不允许用户安装自定义扩展程序。
结束语
正如我们刚才看到的,使用柯里化和函数组合等技术对性能有一定影响,而这种影响很难自行减轻。在我看来,收益大于成本,但重要的是要有意识地转向函数式编程。
现在大多数 Web 应用程序都在 PHP 应用程序前面有某种缓存机制。因此,唯一的成本将是在填充此缓存时。如果你处于这种情况,我认为没有理由避免使用我们学到的技术。
记忆化
记忆化是一种优化技术,它会存储昂贵函数的结果,以便在任何后续具有相同参数的调用中直接返回。这是数据缓存的一个特例。
尽管它可以用于非纯函数,并具有与任何其他缓存机制相同的失效问题,但它主要用于所有函数都是纯函数的函数式语言,因此极大地简化了它的使用。
这个想法是用存储空间来换取计算时间。第一次使用给定的输入调用函数时,结果会被存储,下一次使用相同参数调用相同函数时,已经计算出的结果可以立即返回。在 PHP 中可以很容易地使用static
关键字来实现这一点:
<?php
function long_computation($n)
{
static $cache = [];
$key = md5(serialize($n));
if(! isset($cache[$key])) {
// your computation comes here, the rest is boilerplate
sleep(2);
$cache[$key] = $n;
}
return $cache[$key];
}
显然有很多种不同的方法来做类似的事情,但这种方法足够简单,可以让你了解它是如何工作的。人们也可以想象实现一种过期机制,或者,由于我们使用内存空间而不是计算时间,一种数据结构,在这种数据结构中,值在不使用时被擦除以为新的结果腾出空间。
另一个选择是将信息存储到磁盘上,以便在同一脚本的多次运行之间保留值,例如。PHP 中至少存在一个库(github.com/koktut/php-memoize
)就是这样做的。
然而,该库不能很好地处理递归调用,因为函数本身没有被修改,因此值只会保存第一次调用,而不是递归调用。库自述文件中链接的文章(eddmann.com/posts/implementing-and-using-memoization-in-php/
)更详细地讨论了这个问题并提出了解决方案。
有趣的是Hack有一个属性,可以自动记忆具有特定类型参数的函数的结果(docs.hhvm.com/hack/attributes/special#__memoize
)。如果您正在使用 Hack 并希望使用注释,我建议您首先阅读Gotchas部分,因为它可能并不总是按照您的意愿进行操作。
注意
Hack 是一种在 PHP 基础上添加新功能并在 Facebook 编写的 PHP 虚拟机上运行的语言-HipHop 虚拟机(HHVM)。任何 PHP 代码都与 Hack 兼容,但 Hack 添加了一些新的语法,使代码与原始的 PHP 解释器不兼容。有关更多信息,您可以访问hacklang.org
/。
Haskell、Scala 和记忆化
Haskell 和 Scala 都不会自动执行记忆化。这两者都没有核心功能来执行记忆化,尽管你可以找到多个提供这一功能的库。
有一种误解认为 Haskell 默认情况下会对所有函数进行记忆化,这是因为这种语言是惰性的。实际上,Haskell 试图尽可能延迟函数调用的计算,并且一旦这样做了,它就使用了引用透明属性来用计算出的值替换其他类似的调用。
然而,有多种情况下,这种替换不能自动发生,除了重新计算值之外别无选择。如果您对这个话题感兴趣,这个Stack Overflow问题是一个很好的起点,其中包含所有正确的关键字stackoverflow.com/questions/3951012/when-is-memoization-automatic-in-ghc-haskell
。
我们将在这里结束讨论,因为这本书是关于 PHP 的。
结束语
这只是一个对记忆化的快速介绍,因为这种技术相当简单实现,实际上并没有太多可以说的。我只是想介绍一下,让你了解这个术语。
如果你有一些长时间运行的计算,会多次使用相同的参数,我建议你使用这种技术,因为它可以真正加快速度,并且不需要调用者做任何事情。使用起来非常透明。
但要注意,这并不是万能的。根据返回值的数据结构,它可能会很快地消耗内存。如果遇到这个问题,你可以使用一些机制来清理缓存中的旧值或者少用的值。
计算的并行化
拥有纯函数的另一个好处是,你可以将计算分成多个小部分,分发工作负载,并组装结果。对于任何映射、过滤和折叠操作都可以这样做。我们将看到,用于折叠的函数需要是单子的。用于映射和过滤的函数除了纯度之外没有特定的约束。
映射除了纯函数之外没有特定的约束。假设你有四个核心或计算机,你只需要按照以下步骤进行:
-
将数组分成四部分。
-
将部分任务发送到每个核心进行映射。
-
合并结果。
在这种特殊情况下,它可能比在单个核心上执行要慢,因为合并操作会增加额外的开销。然而,一旦计算时间变长,你就能够利用更多的计算能力,从而节省时间。
过滤操作与映射完全相同,只是你发送的是一个谓词而不是一个函数。
只有当你拥有一个单子操作时,折叠操作才能发生,因为每个拆分都需要从空值开始,否则可能会使结果产生偏差:
-
将数组分成四部分。
-
将部分任务发送到每个核心进行折叠,初始值为空值。
-
将所有结果放入一个新数组中。
-
在新数组上执行相同的折叠操作。
如果你的集合非常大,你可以将最终的折叠再次分成多个部分。
PHP 中的并行任务
PHP 是在计算机只有一个核心时创建的,自那时起,使用它的传统方式是为每个请求提供一个单独的线程。你可以在 web 服务器中声明多个工作进程来处理不同的请求,但一个请求通常只会使用一个线程,因此只会使用一个核心。
尽管 PHP 的线程安全版本存在,但由于前述原因,Linux 发行版通常会提供非线程安全版本。这并不意味着在 PHP 中无法并行执行任务,但这确实会使任务变得更加困难。
pthreads 扩展
PHP 7 发布了一个新版本的pthreads扩展,它允许你使用新设计的面向对象 API 并行运行多个任务。这真的很棒,甚至还有一个polyfill,如果扩展不可用,可以按顺序执行任务。
注意
术语polyfill起源于 JavaScript 开发。它是一小段代码,用于替换用户浏览器中未实现的功能。有时也会使用另一个术语shim。在我们的情况下,pthreads-polyfill提供了一个与扩展 API 在所有点上相似的 API,但是它是按顺序运行任务的。
遗憾的是,使用这个扩展有点挑战。首先,你需要一个线程安全的 PHP 二进制文件,也称为ZTS二进制文件,即Zend Thread-safe。正如我们刚才看到的,发行版通常不提供这个版本。据我所知,目前没有官方 PHP 软件包支持 ZTS。当你尝试为你的 Linux 发行版创建自己的 ZTS 二进制文件时,通常可以在谷歌上找到相关的指导。
Windows 和 Mac OS 用户则更加方便,因为你可以在www.php.net
上下载 ZTS 二进制文件,并且在使用homebrew
软件包管理器安装 PHP 时可以启用该选项。
另一个限制是该扩展将拒绝在 CGI 环境中加载。这意味着您只能在命令行上使用它。如果您对 pthreads 扩展的维护者为什么选择设置这个限制感兴趣,我建议您阅读他写的这篇博客文章,网址为blog.krakjoe.ninja/2015/09/the-worth-of-advice.html
。
现在,如果我们假设您能够拥有 PHP 的 ZTS 版本,并且只编写 CLI 应用程序,让我们看看如何使用pthreads扩展执行并行折叠。该扩展程序托管在 GitHub 上,网址为github.com/krakjoe/pthreads
,安装说明可以在官方 PHP 文档中找到,网址为docs.php.net/manual/en/book.pthreads.php
。
显然,我们可以以多种方式实现使用线程进行折叠。我们将尝试采用一种通用的方法。在某些情况下,更专门的版本可能更快,但这应该已经涵盖了整个一系列用例:
<?php
class Folder extends Thread {
private $collection;
private $callable;
private $initial;
private $results;
private function __construct($callable, $collection, $initial)
{
$this->callable = $callable;
$this->collection = $collection;
$this->initial = $initial;
}
public function run()
{
$this->results = array_reduce($this->collection, $this- >callable, $this->initial);
}
public static function fold($callable, array $collection, $initial, $threads=4)
{
$chunks = array_chunk($collection, ceil(count($collection) / $threads));
$threads = array_map(function($i) use ($chunks, $callable, $initial) {
$t = new static($callable, $chunks[$i], $initial);
$t->start();
return $t;
}, range(0, $threads - 1));
$results = array_map(function(Thread $t) {
$t->join();
return $t->results;
}, $threads);
return array_reduce($results, $callable, $initial);
}
}
实现非常简单;我们有一个简单的Thread
执行每个块的减少,然后使用简单的array_reduce
函数将它们组合在一起。我们本可以选择使用Pool
实例来管理各个线程,但在这种简单情况下,这将使实现变得复杂。
另一种可能性是递归,直到生成的数组包含最多$threads
个元素为止;这样,我们将利用我们手头的全部计算能力直到结束。但同样,这将使实现变得复杂。
您如何使用它?只需调用静态方法:
<?php
$add = function($a, $b) {
return $a + $b;
};
$collection = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
echo Folder::fold($add, $collection, 0);
// 55
如果您想尝试一下这个想法,一个小型库以并行方式实现了所有三个高阶函数(github.com/functional-php/parallel
)。您可以使用 composer 安装它:
**composer require functional-php/parallel**
消息队列
PHP 中另一个并行化任务的选项是使用消息队列。消息队列提供了一种异步通信协议。您将拥有一个服务器,它将保存消息,直到一个或多个客户端检索它们。
我们可以通过让我们的应用程序向服务器发送 X 条消息来实现并行计算,每个分布式任务发送一条消息。然后,一定数量的工作线程将检索消息并执行计算,将结果作为新消息发送回应用程序。
有很多不同的消息队列实现可以使用。通常,队列本身并不是用 PHP 实现的,但它们大多数都有客户端实现可以使用。我们将使用RabbitMQ和php-amqplib客户端。
解释如何安装服务器超出了本书的范围,但您可以在互联网上找到很多教程。我们也不会解释所有关于实现的细节,只解释与我们主题相关的内容。您可以使用 composer 安装 PHP 库:
**composer require php-amqplib/php-amqplib**
我们需要为我们的工作线程和应用程序都实现。让我们首先创建一个包含公共部分的文件,我们将其命名为09-rabbitmq.php
:
<?php
require_once './vendor/autoload.php';
use PhpAmqpLib\Connection\AMQPStreamConnection;
$connection = new AMQPStreamConnection('localhost', 5672, 'guest', 'guest');
$channel = $connection->channel();
list($queue, ,) = $channel->queue_declare($queue_name, false, false, false, false);
$fold_function = function($a, $b) {
return $a + $b;
};
现在我们创建工作线程:
<?php
use PhpAmqpLib\Message\AMQPMessage;
$queue_name = 'fold_queue';
require_once('09-rabbitmq.php');
function callback($r) {
global $fold_function;
$data = unserialize($r->body);
$result = array_reduce($data['collection'], $fold_function, $data['initial']);
$msg = new AMQPMessage(serialize($result));
$r->delivery_info['channel']->basic_publish($msg, '', $r- >get('reply_to'));
$r->delivery_info['channel']->basic_ack($r- >delivery_info['delivery_tag']);
};
$channel->basic_qos(null, 1, null);
$channel->basic_consume('fold_queue', '', false, false, false, false, 'callback');
while(count($channel->callbacks)) {
$channel->wait();
}
$channel->close();
$connection->close();
现在我们创建应用程序本身:
<?php
use PhpAmqpLib\Message\AMQPMessage;
$queue_name = '';
require_once('09-rabbitmq.php');
function send($channel, $queue, $chunk, $initial)
{
$data = [
'collection' => $chunk,
'initial' => $initial
];
$msg = new AMQPMessage(serialize($data), array('reply_to' => $queue));
$channel->basic_publish($msg, '', 'fold_queue');
}
class Results {
private $results = [];
private $channel;
public function register($channel, $queue)
{
$this->channel = $channel;
$channel->basic_consume($queue, '', false, false, false, false, [$this, 'process']);
}
public function process($rep)
{
$this->results[] = unserialize($rep->body);
}
public function get($expected)
{
while(count($this->results) < $expected) {
$this->channel->wait();
}
return $this->results;
}
}
$results = new Results();
$results->register($channel, $queue);
$initial = 0;
send($channel, $queue, [1, 2, 3], 0);
send($channel, $queue, [4, 5, 6], 0);
send($channel, $queue, [7, 8, 9], 0);
send($channel, $queue, [10], 0);
echo array_reduce($results->get(4), $fold_function, $initial);
// 55
显然,这是一个非常天真的实现。自 PHP 5 以来,要求这样的文件是不好的做法,而且代码非常脆弱,但它达到了演示消息队列提供的可能性的目的。
当您启动工作线程时,它会注册自己作为fold_queue
队列的消费者。当接收到消息时,它会使用在公共部分声明的折叠函数对数据进行折叠,并将结果发送回作为回复的队列。循环确保我们等待传入的消息;根据代码,工作线程不应该自行退出。
应用程序有一个send
函数,它在fold_queue
队列上发送消息。Results
类实例注册自己作为默认队列的消费者,以接收每个工作进程的结果。然后发送四条消息,并要求Results
实例等待它们。最后,我们减少接收的数据以获得最终结果。
如果只启动一个工作进程,结果将按顺序发送;但是,如果启动多个工作进程,每个工作进程将从 RabbitMQ 服务器检索消息并处理它,从而实现并行化。
与使用线程相比,消息队列有多个好处:
-
工作进程可以在多台计算机上
-
工作进程可以在任何其他具有所选队列客户端的语言中实现
-
队列服务器提供冗余和故障转移机制
-
队列服务器可以在工作进程之间进行负载平衡
在可用时使用 pthreads 库可能会更容易一些,如果你计划只在唯一计算机的核心之间分配工作负载,但如果你想要更灵活性,消息队列是更好的选择。
其他选择
在 PHP 中启动并行计算的其他方法,但通常会使检索值比我们刚才看到的更加困难。
一种选择是使用curl_multi_exec
函数异步执行多个 HTTP 请求。一般结构类似于我们在消息队列示例中使用的内容。但是,与完整消息系统的全部功能相比,可能也有限制。
你也可以使用多个相关函数之一创建其他 PHP 进程。在这种情况下,难点通常是在不丢失数据的情况下传递和检索数据,因为这样做的方式将取决于与环境相关的许多因素。如果你想这样做,popen
、exec
或passthru
函数可能是你最好的选择。
如果你不想做所有的苦力活,你也可以使用Parallel.php
库,它可以将大部分复杂性抽象化。你可以使用 composer 安装它:
**composer require kzykhys/parallel**
文档可在 GitHub 上找到github.com/kzykhys/Parallel.php
。由于该库使用 Unix 套接字,大部分与数据丢失相关的问题都已经解决。但是你无法在 Windows 上使用它。
结束语
正如我们所看到的,使用多个线程或进程在 PHP 中可能并不是最容易的事情,特别是在网页的上下文中。然而,这是可以实现的,并且可以大大加快长时间计算的速度。
随着 PHP 7 中 pthreads 的重写,我们可以希望更多的 Linux 发行版和托管公司将开始提供 ZTS 版本。
如果是这种情况,并且并行计算开始在 PHP 中变得真实,可能可以进行一些轻量级的大数据处理,而无需求助于其他语言的外部库,比如Hadoop 框架。
我想用几句话来结束关于消息队列的话题。即使你不以功能方式使用它们来处理数据并获取结果,它们也是在网页请求的上下文中执行长时间操作的好方法。例如,如果你让用户上传一堆图片并且你需要处理它们,你可以将操作加入队列并立即返回给用户。排队的消息将在适当的时间被处理,你的用户不必等待。
总结
在本章中,我们发现当进行函数式编程时,很遗憾是需要付出代价的。由于 PHP 没有对柯里化和函数组合等功能的核心支持,因此在使用它们时会有与包装函数相关的开销。在某些情况下,这显然可能是一个问题,但缓存通常可以减轻这种成本。
我们谈到了记忆化,这是一种缓存技术,结合纯函数,可以加速对给定函数的后续调用,而无需使存储的结果失效。
最后,我们讨论了通过利用在集合上执行的任何纯操作可以在多个节点之间分发,而不必担心共享状态来并行化计算在 PHP 中的计算。
下一章将专门针对使用框架的开发人员,我们将发现如何在 PHP 世界中目前最常用的框架的背景下利用我们迄今为止学到的技术。