PHP 函数式编程高级教程(二)
四、高级函数式技术
到目前为止,您已经对函数式编程风格及其带来的优势有了一些了解。你可以从今天开始使用这些技术,而不需要进一步阅读。不过,理想情况下,我已经激起了您的兴趣,让您更进一步,学习更多的函数式技术,以添加到您的程序员工具箱中。
在这一章中,你将会看到函数式编程的一些更高级的方面,这些方面将会让你以更加函数化的方式来构建你的 PHP 代码。这一章是“理论”的最后一节,在这本书的下一部分,你将开始讨论实际的例子。您将从 currying 开始,它将部分函数应用的概念扩展为一种将它们分解成低级版本的自动化方式。接下来,您将看到虚构的单子,它帮助您进行程序流控制,并允许您处理在现实世界中工作时会遇到的讨厌的副作用。在那之后,你会看到蹦床,这是一种控制递归的方法。最后,我将简单介绍一下使用类型声明的严格类型化和动态类型化,虽然严格来说这不是一个函数概念,但在某些方面是有用的(而在其他方面则不是)。
Currying 函数
在前一章中,您看到了部分函数的优点,我提到了一种自动化这种函数分解的方法。分解是通过固定一个或多个参数的值,将多元函数分解成具有较小签名的函数的行为。你将要看到的自动分解方法叫做 currying,它是以 Haskell Curry 的名字命名的,他的名字(字面上)遍布函数式编程!
Currying 确实与部分函数应用密切相关,一个 currying 函数乍一看很像您创建的部分函数生成器。然而,有一些微妙但重要的区别。也就是说,部分功能应用只是一种奉承(或者反过来,取决于你和谁说话),所以两者的好处是相似的。您选择使用哪一种取决于在您的情况下什么对您有效。
在部分函数生成器中,您接受一个函数,加上一个绑定到第一个参数的值,并返回一个签名少了一个参数的函数。在 currying 中,通过获取一个函数和一个或多个参数的列表,并将所有这些参数绑定到返回的新函数,可以使这一点更加灵活。到目前为止,currying 是类似的(如果更一般)。要获得函数的实际结果,您需要到达已经绑定和/或传入所有函数参数的点,然后函数将执行并返回值。
生成器返回的部分函数和 currying 函数(在这两种情况下都是闭包)是两者区别的关键。使用您看到的简单部分生成器,返回的函数是一个具有简化签名的函数(即,调用它所需的参数数量减少)。如果您想进一步减少它以创建另一个部分函数,您可以在返回的闭包上再次调用部分函数生成器。相比之下,curry 例程返回的闭包是一个独立的函数,可以自动进一步 curry。例如,如果您有一个有五个参数的函数,您通过修改两个参数来处理它,您将得到一个接受三个参数的闭包。如果您随后用一个或多个参数调用这个闭包,那么它不会在没有一组完整参数的情况下执行(正如前面显示的部分函数那样),它会自动搜索自己并返回一个接受两个参数的闭包(如果用另一个参数调用,它还能够进一步搜索自己)。将这与您的生成器中的部分函数进行对比;如果您向接受三个参数的部分函数提供一个参数,它将尝试使用减少的参数集执行,这通常会导致错误。
和往常一样,这个例子可能会更清楚。由于编写一个格式正确的 currying 函数并不容易,您将使用 Matteo Giachino 编写的名为php-curry的库来帮助您。这可以在 GitHub 的 https://github.com/matteosister/php-curry 获得,可以通过 Composer 安装,也可以直接通过包含它来安装,如清单 4-1 和清单 4-2 所示。
<?php
include('Curry/Placeholder.php');
include('Curry/functions.php');
use Cypress\Curry as C;
# Let's make a function place an order with our chef
# for some delicious curry (the food, not the function)
$make_a_curry = function($meat, $chili, $amount, $extras, $where) {
return [
"Meat type"=>$meat,
"Chili hotness"=>$chili,
"Quantity to make"=>$amount,
"Extras"=>$extras,
"Eat in or take out"=>$where
];
};
# We think that everyone will want a mild Rogan Josh, so
# let's curry the function with the first two parameters
$rogan_josh = C\curry($make_a_curry, 'Lamb','mild');
# $rogan_josh is now a closure that will continue to
# curry with the arguments we give it
$dishes = $rogan_josh("2 portions");
# likewise $dishes is now a closure that will continue
# to curry
$meal = $dishes('Naan bread');
# and so on for meal. However, we only have 1 parameter
# which we've not used, $where, and so when we add
# that, rather than returning another closure, $meal
# will execute and return the result of $make_a_curry
$order = $meal('Eat in');
print_r( $order );
# To show that our original function remains unmutated, when
# we realize that actually people only want 1 portion of curry
# at a time, with popadoms, and they want to eat it at home, we
# can curry it again. This time, the parameters we want to bind
# are at the end, so we use curry_right.
$meal_type = C\curry_right($make_a_curry, 'Take out', 'Poppadoms', '1 portion');
$madrass = $meal_type('hot', 'Chicken');
print_r( $madrass );
# We could curry the function with all of the parameters
# provided, this creates a parameter-less closure but doesn't
# execute it until we explicitly do so.
$korma = C\curry($make_a_curry,
'Chicken', 'Extra mild', 'Bucket full', 'Diet cola', 'Eat in');
print_r($korma());
Listing 4-1.currying.php
Array
(
[Meat type] => Lamb
[Chili hotness] => mild
[Quantity to make] => 2 portions
[Extras] => Naan bread
[Eat in or take out] => Eat in
)
Array
(
[Meat type] => Chicken
[Chili hotness] => hot
[Quantity to make] => 1 portion
[Extras] => Poppadoms
[Eat in or take out] => Take out
)
Array
(
[Meat type] => Chicken
[Chili hotness] => Extra mild
[Quantity to make] => Bucket full
[Extras] => Diet cola
[Eat in or take out] => Eat in
)
Listing 4-2.currying-output.txt
Currying 提供了一种更简洁的方式来管理部分函数,特别是当您经常需要一个给定函数或一组部分函数的许多不同版本时。由于额外的 currying 代码,这种权衡在闭包中是一个更大的开销,但是这通常是一个无关紧要的考虑。您也失去了允许可选参数的能力(至少对于这里实现的 currying 来说是这样的);在所有参数都被赋值之前,该函数不会执行。
除了曲线拟合,还有相反的过程,通常称为去曲线拟合或去曲线拟合,它采用一组 n 个单参数函数,并将它们组合成一个 n 元函数。这通常应用有限,所以我不会在本书中涉及。
神秘的单子
单子是程序控制的一种通用形式,是现实世界副作用问题的解决方案(比如从文件中读取和打印到屏幕上),也可以说是函数式编程(或一般生活)中最难理解的概念之一。如果你查找关于单子的介绍性文章或视频,他们总是会首先告诉你,专家说你需要理解范畴定理、内函子等数学主题,可能还有其他相关的深奥概念。然后他们会说,实际上,你不需要理解这些,因为他们已经找到了一种简单易懂的方式来解释单子。然后,他们将开始讨论函子(数学函子,不是编程函子)、应用函子、幺半群等等,最终根据这些概念定义幺半群,并在此过程中失去 90%的读者。然后,他们会声称他们给出了一个简短的非技术概述,这里有一个完整的数学解释的链接,以帮助澄清这一切,在这一点上,每个人都放弃了单子,函数式编程,有时甚至是他们迄今为止的整个生活方式。所以我不会那么做。
著名的 JavaScript 教育家道格拉斯·克洛克福特在他的谷歌技术演讲“单子和生殖腺”中有一句名言:
“单子的诅咒是,一旦有人知道单子是什么以及如何使用它们,他们就失去了向其他人解释(它们)的能力。”
一种不那么轻率的说法是,也许你对单子是什么以及单子能做什么的理解通常会随着时间的推移而发展。当你已经完全掌握了它们的力量,并且这个概念已经完全“点击”时,很难记得所有那些导致理解它们如何工作的更小的“发现”时刻。
所以,我从问题的另一端开始。我不会试图从基本原理中推导出单子,我会告诉你单子是什么,你用单子做什么,并给你看一些例子。一旦你习惯了使用它们,你就会有自己的“哦,是的,我现在明白了”的时刻,即使你不明白背后的数学原理。当然,我也会在最后提供与数学相关的解释的必要链接。
什么是单子?
monad 是一种函数,它封装了值,并在 monad 的上下文中将函数应用于这些值。你可以把它们看作是处理状态的容器,不同类型的单子在状态上承担不同种类的工作。你能用单子做什么?
- 控制程序流(作为功能组合的一种形式)
- 封装副作用(把它们从你的纯函数中去掉,使它们仅仅是程序本身的一个效果)
- 降低代码复杂性(好吧,一旦你理解了单子本身)
虽然单子是“唯一的”函数,您可以用 PHP 函数实现它们,但是使用对象创建它们通常更方便,因为这给了您更多的灵活性(并且允许您有时用额外的帮助方法作弊)。这是大多数实现单子的 PHP 库采用的方法。请记住,方法本质上只是在对象的上下文中调用的一个函数(事实上,闭包本质上是一个具有单一方法的对象,该方法对封装在其上下文中的一组数据进行操作)。
那么,野外的单子长什么样?它将有两个关键方法。
- 一个构造函数方法,它接受一个值并创建一个“包装”该值的 monad 对象
- 一种“绑定”方法
- 接受一个函数(或其他可调用函数)作为输入
- 在前面提到的构造函数包装的值上调用它
- 返回通过对被调用函数的结果调用构造函数方法而创建的新 monad
如您所见,bind 方法是这里的关键。它接受一个函数,将其应用于存储在 monad 中的值(即状态),并返回一个新的 monad 对象,其结果(“新”状态)作为该新 monad 的值。它还具有三个关键的数学属性,这使它有别于其他恰好保护一个值并绑定一个函数的结构。稍后您将详细查看这些内容,并了解如何使用它们来测试某个东西实际上是否是单子。
是时候看一个例子了。您不用编写自己的 monad 类,而是使用 Anthony Ferrara 编写的名为 MonadPHP 的库(可在 GitHub 的 https://github.com/ircmaxell/monad-php 获得)。这是单子的一个简单的“玩具”实现,非常适合学习。我强烈建议看一下源代码,因为它写得很好,很容易理解。您可以用 Composer 安装它,或者只需要这些文件,正如我将展示的那样。您将首先查看清单 4-3 中的身份单子(输出如清单 4-4 所示),它唯一的工作是调用包装值上的传递函数。它不是很有用,但是演示了一个基本单子的结构和属性。
<?php
require('MonadPHP/Monad.php');
require('MonadPHP/Identity.php');
# Use the namespace
use MonadPHP\Identity;
# Define a couple of pure functions
$double = function ($n) { return $n*2; };
$add_ten = function ($n) { return $n+10; };
# Create a monad by calling the static unit method,
# with a value (33). The unit method is a constructor
# which checks if what we are passing in is already
# an instance of this monad, or create a new one if
# not by calling the _construct method to bind
# our value (33) and return a monad object
$monad_a = Identity::unit(33);
# Let's check it is an object of class MonadPHP\Identity
# encapsulating the value 33
var_dump( $monad_a );
# Now we bind one of our functions to the monad
$monad_b = $monad_a->bind($double);
# $monad_b should be a new monad object.
# Let's check that it is and that we haven't
# just mutated monad1
var_dump( $monad_a ); # should be the same as above
var_dump( $monad_b ); # should be a new monad encapsulating 66
# This library includes a helper method "extract" to
# get the encapsulated value back out of the monad
var_dump( $monad_b->extract() ); #66
# Let's bind that function again to the new monad...
$monad_c = $monad_b->bind($double);
var_dump( $monad_c->extract() ); #132
# ... and check that monad_b is unchanged
var_dump( $monad_b->extract() ); #66
# finally, bind the function again to monad_b,
# to demonstrate again that its encapsulated value
# isn't mutated.
$monad_d = $monad_b->bind($double);
var_dump( $monad_d->extract() ); #132
# Let's now repeatedly bind methods
# in a chain
$monad_e = $monad_d->bind($double) # *2
->bind($add_ten) # +10
->bind($add_ten); # +10
var_dump( $monad_e->extract() ); # 284
# and take a look at the returned monad_e,
# take note of the object identifier (#7)
var_dump( $monad_e );
Listing 4-3.monad.php
object(MonadPHP\Identity)#3 (1) {
["value":protected]=>
int(33)
}
object(MonadPHP\Identity)#3 (1) {
["value":protected]=>
int(33)
}
object(MonadPHP\Identity)#4 (1) {
["value":protected]=>
int(66)
}
int(66)
int(132)
int(66)
int(132)
int(284)
object(MonadPHP\Identity)#7 (1) {
["value":protected]=>
int(284)
}
Listing 4-4.monad-output.txt
在该示例的最后一部分,您将一组绑定调用链接在一起,这看起来很像您在前一章中看到的那种函数组合。虽然您确实可以以这种方式使用 identity 函数来组合函数,但是 monads 不仅仅是将前一个函数的输出传递给下一个函数的输入。看一下散列值(#3、#4 等。)在清单 4-4 的var_dump输出中。这些是 PHP 在当前上下文中创建的对象的内部标识符(数字形式)。(它从#3 开始,因为#1 和#2 是你的纯函数$double和$add_ten,它们是闭包类型的对象。)具体来说,#3 对应$monad_a,#4 是$monad_b。你不用在它们上面使用var_dump,但是$monad_c会是#5,$monad_d会是#6。#7 是monad_e。为什么这很重要?它演示了每次您在单子上调用bind时,您会完全获得另一个对象。您每次都在围绕您的边界值改变上下文(状态),而不只是像简单的合成那样传递它。这允许你创建一些奇特的单子,做有趣的(和有用的)事情,你马上就会看到。
但是您在$monad_d上创建$monad_e的绑定调用“链”是什么呢?你已经应用了三个函数,但是最后你只得到一个新的单子。事实上,您正在创建三个新的单子,并使用 PHP 的对象解引用将下一个函数绑定到上一个单子。一眨眼的功夫,单子就在“幕后”被创造出来,然后被销毁,这就是为什么你看不到它们的原因。如果你不相信,你可以添加一个不纯的echo('creating')调用到身份构造器方法,你会看到它为每个绑定调用输出creating。
这些是单子的基础。现在让我们做一些有用的事情。虽然您可以发明单子来做您能想到的任何事情,但是您会遇到一些常见的单子来解决常见的函数式编程问题并实现典型的函数式模式。在像 Haskell 这样的纯函数式语言中,单子很受欢迎,因为它们几乎是完成实际工作的唯一方法。你将会看到一些最常见的单子来展示这种结构的可能性和力量。
可能单子
在前面的例子中,Identity monad 刚刚调用了绑定函数,其值封装在 monad 中。Maybe monad 更进一步,在用它调用绑定函数之前添加了一个对值的测试。
“我称之为我的十亿美元错误。”不,那不是我在谈论写这本书。这是查尔斯·安东尼·理查德·霍尔爵士的话,他是参考文献的发明者。Null 虽然是一个很好的传递信息的工具,比如失败或者缺少值,但是它也有自己的问题。函数通常返回 null,表示没有实际值要返回,这通常是因为函数中存在错误或类似错误,或者传递给函数的参数有问题。这使得调用者能够判断是否是这种情况,例如,返回值是否合法地为 0、false、空数组或类似的值,如果 null 不存在,这些值可能会被用作错误代码。然后调用者可以测试是否为空,并适当地处理它。
要了解这为什么会有问题,请考虑您在函数组合示例中看到的函数链。如果其中一个函数返回 null 作为返回值,会发生什么?空值作为输入被送入链中的下一个函数,这意味着现在需要修改所有的函数来测试和处理空值,否则就会出现问题。向前迈进一步,使用 Maybe monad,您可以处理“可能”会失败或“可能”会正确工作的函数,而不必在每个函数中编写额外的代码来检查。Maybe monad 通过在每次调用一个函数后检查是否有 null 返回值来实现这一点,如果发现 null,就不调用下一个函数。让我们看一个例子(参见清单 4-5 和清单 4-6 ),然后我将讨论它是如何工作的以及为什么工作。您将再次使用 MonadPHP 库。
<?php
require('MonadPHP/Monad.php');
require('MonadPHP/Maybe.php');
use MonadPHP\Maybe;
# We'll use the shopping list array from the previous chapter.
# It's a nested array, and not all elements have the
# same level of nesting.
$shopping_list = [
"fruits" => [ "apples" => [ "red" => 3, "green" => 4], "pears" => 4, "bananas" => 6 ],
"bakery" => [ "bread" => 1, "apple pie" => 2],
"meat" => [ "sausages" =>
["pork" => ["chipolata" => 5, "cumberland" => 2], "beef" => 3],
"steaks" => 3, "chorizo" => 1 ]];
# Let's create some functions.
# This function takes a category (e.g. fruits) and returns either
# a) a closure that returns that category from the supplied list
# or
# b) null if the category doesn't exist.
$get_foods = function ($category) {
return function ($list) use ($category) {
echo("get_foods return closure called\n");
return isset($list[$category]) ? $list[$category] : null;
};
};
# This function does the same, except it returns a closure that returns
# the foods (e.g. apples, pears...) from the category (fruit), or null
$get_types = function ($food) {
return function ($category) use ($food) {
echo("get_types return closure called\n");
return isset($category[$food]) ? $category[$food] : null;
};
};
# and lastly another function of the same type to get the types of food
# (e.g. red, green) from the food, or null
$get_count = function ($type) {
return function ($types) use ($type) {
echo("get_count return closure called\n");
return isset($types[$type]) ? $types[$type] : null;
};
};
# Now let's create a Maybe monad, encapsulating our
# shopping list as its value.
$monad = Maybe::unit($shopping_list);
# We'll repeatedly bind our functions against it as
# we did in the previous example.
var_dump( $monad ->bind($get_foods('fruits'))
->bind($get_types('apples'))
->bind($get_count('red'))
->extract() # returns 3
);
# None of our closures test for null parameters, so what
# happens if we try to look for something that doesn't exist?
var_dump( $monad ->bind($get_foods('fruits'))
->bind($get_types('apples'))
->bind($get_count('purple')) # doesn't exist
->extract() # returns null
);
var_dump( $monad ->bind($get_foods('cheeses')) # doesn't exist
->bind($get_types('cheddar')) # doesn't exist
->bind($get_count('mature')) # doesn't exist
->extract() # returns null
);
var_dump( $monad ->bind($get_foods('bakery'))
->bind($get_types('pastries')) # doesn't exist
->bind($get_count('danish')) # doesn't exist
->extract() # returns null
);
Listing 4-5.maybe_monad.php
get_foods return closure called
get_types return closure called
get_count return closure called
int(3)
get_foods return closure called
get_types return closure called
get_count return closure called
NULL
get_foods return closure called
NULL
get_foods return closure called
get_types return closure called
NULL
Listing 4-6.maybe_monad-output.txt
(注意,如果你在 PHP 7 上运行这个,你会得到一个抛出的警告,因为这个库从 PHP 5 开始就没有更新过;您可以更改库中的绑定声明,也可以安全地忽略该警告。)
我在那里加入了一些不纯的echo语句,这样你就可以看到闭包何时被调用。正如您所看到的,当您试图从数组中获取不存在的项时(因此您的闭包返回 null),链中的后续函数调用不会被执行,即使您将它们绑定到 monad。那么,这是如何工作的,为什么呢?在这种情况下,单位单子的功能如下:
- 首先创建一个 monad,将购物清单作为它的值。
- 然后将第一个函数绑定到它,它用购物清单值调用这个函数。
- 这将返回一个值(数组的请求部分或 null),该值将被放入一个新的 monad 对象中并返回。
- 然后像前面一样将下一个函数绑定到新的单子上,以此类推,直到所有的函数都被绑定。
与同一性单子的关键区别在于,在绑定函数的阶段,条件语句检查单子的值,如果是 null,它不调用函数,而是将 null 返回到新单子中。因此,只要链中的一个函数返回空执行,所有后续的函数都会被跳过,因为封装在每个单子中的值都是空的(并且不会改变,因为它是空的,所以没有一个函数可以被调用…!).每次仍然在链中创建单子,但是封装的值(null)只是沿着链传播。
因此,作为程序员,您仍然需要编写一个空检查,但只在链的末端编写一次,而不是针对每个单独的函数。这允许您编写更简单的函数,这些函数可以假设它们将获得有效值(或者至少非空值)作为输入进行操作。如果您的函数对它们的输入值有其他共同的要求(例如,它们总是需要一个整数作为输入),您可以很容易地构造类似的条件单子,以您想要的任何方式检查它们的封装值。
这种在每次函数调用时执行代码的能力是 monads 相对于您在前一章中看到的简单函数组合技术的优势之一。它被比作“可编程的分号”,因为 PHP(像许多编程语言一样)一个接一个地执行语句,语句之间用分号隔开。想象一下,如果你能让分号做点什么;这应该给你一个单子在这种情况下的力量的概念。
单子公理
正如我在单子介绍中提到的,单子必须遵守三个公理(或定律)才能归类为单子。理解这些对判断你使用的东西是否真的是单子是有用的。明确地说,一些类似单子的结构只有一两个数学属性,仍然非常有用,但是您需要格外小心,以确保它们以您想要的函数方式运行,以确保您的代码具有您期望的全功能代码的属性。
同样,我不会试图推导或解释这些公理的方法和原因,但我会以一种有用的方式呈现什么。我将用 PHP 将它们表示为伪代码,而不是用它们的数学符号。
单子公理 1
bind( unit($i), $func ) == $func( $i )
这意味着如果你将一个函数$func绑定到一个用值$i创建的单子上,这相当于直接在$i上调用$func。unit是单子构造函数的常用名称。
单子公理 2
bind($monad, unit) == $monad
这说明如果你将构造器单元函数绑定到一个单子上,结果就等同于那个单子。
单子公理 3
bind ( bind($monad, $f1), $f2) == bind ($monad, function($i) {return bind($f1($i), $f2($i)})
现在这个才是最难的!
左边说,“将一个函数$f1绑定到一个单子,将另一个函数$f2绑定到结果单子。”
右边说这相当于取一个单子$monad,绑定一个返回单子的函数,单子是绑定函数$f2($i)应用于$function $f1($i)的结果,其中$f1是返回单子的函数。
如果您没有遵循或理解任何公理(尤其是最后一条),也不要担心。在实践中,如果你需要使用它们,它总是作为一个测试,你可以直接应用它们。如果你真的想更好地理解它们,请看本章后面的“进一步阅读”部分。
测试单子公理
所以,让我们看看这个身份单子到底是不是真的单子(见清单 4-7 和清单 4-8 )。一个简单的测试是创建一个测试值和函数,创建一个单子,然后采用前面的公理并用合适的 PHP 编写它们,测试每个公理的计算结果是否为真。
<?php
require('MonadPHP/Monad.php');
require('MonadPHP/Identity.php');
use MonadPHP\Identity;
# 1\. bind( unit($i), $func ) == $func( $i )
// define some test variables and functions
$i = 10;
$func = function ($i) { return $i*2; };
// create a new monad to test
$monad = Identity::unit($i);
// see if the 1st Axiom holds (should output true)
var_dump ( $monad->bind($func)->extract() == $func($i) );
# 2\. bind($monad, unit) == $monad
// and see if the 2nd Axiom also holds
var_dump ( $monad->bind(Identity::unit) == $monad );
# 3\. bind ( bind($monad, $f1), $f2) ==
# bind ($monad, function($i) { return bind( $f1($i), $f2($i) } )
// create some more test functions
$f1 = function ($i) { return Identity::unit($i); }; // returns a monad
$f2 = function ($i) { return $i*6; };
// and see if Axiom 3 holds
var_dump (
$monad->bind($f1)->bind($f2) ==
$monad->bind(function ($i) use ($f1, $f2)
{ return $f1($i)->bind($f2); }
)
);
Listing 4-7.monad_test.php
bool(true)
bool(true)
bool(true)
Listing 4-8.monad_test-output.txt
嗯,看起来都不错。功能稍微强一点的单子怎么样?你将以完全相同的方式处理这个问题(参见清单 4-9 和清单 4-10 )。
<?php
require('MonadPHP/Monad.php');
require('MonadPHP/Maybe.php');
use MonadPHP\Maybe;
# 1\. bind( unit($i), $func ) == $func( $i )
// define some test variables and functions
$i = 10;
$func = function ($i) { return $i*2; };
// create a new monad to test
$monad = Maybe::unit($i);
// see if the 1st Axiom holds (should output true)
var_dump ( $monad->bind($func)->extract() == $func($i) );
# 2\. bind($monad, unit) == $monad
// and see if the 2nd Axiom also holds
var_dump ( $monad->bind(Maybe::unit) == $monad );
# 3\. bind ( bind($monad, $f1), $f2) ==
# bind ($monad, function($i) { return bind( $f1($i), $f2($i) } )
// create some more test functions
$f1 = function ($i) { return Maybe::unit($i); }; // returns a monad
$f2 = function ($i) { return $i*6; };
// and see if Axiom 3 holds
var_dump (
$monad->bind($f1)->bind($f2) ==
$monad->bind(function ($i) use ($f1, $f2)
{ return $f1($i)->bind($f2); }
)
);
Listing 4-9.maybe_test.php
bool(true)
bool(true)
bool(true)
Listing 4-10.maybe_test-output.txt
这也通过了所有三个公理试验。当然,这不是一个全面的测试;公理必须适用于传递给 monad 的任何和所有(构造良好的)输入/函数,但是如果您想要更彻底地测试您创建的 monad 的行为,它应该会为您提供一些关于如何进行的线索。
其他有用的单子
您已经了解了什么是单子,以及它如何成为组合函数的通用方式。在本章的介绍中,我说过我将解释如何使用单子来解决函数式编程中的一些问题,例如处理否则会被归类为副作用的操作。这就是我在这一节要做的事情。
首先,你会看到一个常见的单子,称为作家单子。编写软件时的一个常见任务是随着程序的进展将信息记录到磁盘上。这可以是从调试信息或审计日志到跟踪信息和事务记录的任何内容。通常,发送到日志的消息在被记录时被直接或通过调用日志记录函数写入磁盘。在函数式程序中,你可以创建一个日志记录函数,但是如果它向磁盘写入任何东西,它就不是一个纯粹的函数。如果没有的话,它作为一个日志功能就没有多大用处了!
用于处理副作用的一种技术(或者更好的称呼是妥协)是将所有“不纯的”行为推到程序运行的末尾。通过这种方式,程序的大部分是“适当的”功能性的,能够被完全测试,并且容易推理,至少如果混乱不纯的部分有问题,它都在一个地方,并且你知道它发生在哪里。
那么,如何将所有的日志写到程序的末尾呢?一种方法是创建一个全局变量来收集要记录的信息,最后将它一次性写入磁盘。然而,正如我已经讨论过的,在函数式编程中,全局状态通常被认为是一个坏主意,因为您在函数流之外引入了状态,而您在任何时候都不能(容易地)推理或确信这一点。另一种方法是在进行过程中沿着函数链传递日志信息;每个函数都可以从上一个函数的输入中获取“日志”,添加自己的日志记录,并将日志作为其返回值的一部分传递给链中的下一个函数,只有在函数链中有最终值时才写入磁盘。这将是一个非常好的函数方式,除了这意味着您需要改变每个函数的签名来接受传递这些额外的数据,可能使用数组或类似的东西来保持日志信息和实际函数输出的分离和组织。你可能已经猜到了,问题的答案是作家莫纳德。
Writer monad 提供了一种方法,可以像您期望的那样编写和组合函数,而不必改变它们接受的参数。在后台,编写器 monad 用“写入的”信息(例如,在这种情况下,要记录的字符串)构建单独的数据结构,并且在 monad 函数链的末端,返回两个值(正常返回值和日志数据)。为了在实践中演示这一点,这次您将使用一个不同的单子库,名为php-fp-writer,由 Tom Harding 编写,您可以从 https://github.com/php-fp/php-fp-writer 下载。你可以用 Composer 安装它,或者简单地包含我在这里展示的文件。
但是,在实现 Writer 示例之前,您需要创建一个称为幺半群的结构。在运行函数链时,monad 中需要一个结构来“收集”和保存日志消息(或者 Writer monad 将为您处理的任何数据),monad 本身处理实际的函数返回值,就像前面的例子一样。
什么是幺半群?它的两个关键特性是,它有一个“关联二元运算”和一个“单位元素”在某些方面,幺半群感觉有点像单子;它包装一个值(保持静态),应用一个函数,并返回一个新的幺半群作为输出,而不是对自身进行变异。数学家桑德斯·麦克兰恩在《工作的数学家》一书中说:
总之,X 中的幺半群只是 X 的内函子范畴中的幺半群,其乘积 X 被内函子的复合所取代,单位集被单位内函子所取代
对于不知道内函子是什么的外行人来说,解释这段引文,单子实际上只是更一般的幺半群的一个特例。像单子一样,你将创建单子作为对象。因为它们要简单得多,你将编写自己的幺半群类(见清单 4-11 )。您的对象将要执行的“关联二进制操作”是连接,在这种情况下,您将把(日志条目的)字符串连接到一个数组中。identity 元素是一个元素(或值),当对其他值执行操作时,这些值保持不变。对于串联(到一个数组中),identity 元素是一个空数组。在这种情况下,您不打算使用 identity 元素,所以为了清楚起见,您将省略它,但是如果您愿意,您可以添加一个empty()方法,该方法返回一个以空数组作为其封装值的幺半群。
<?php
class Monoid {
public function __construct($value) {
$this->value = $value;
}
public function concat($to) {
return new Monoid(array_merge($to->value, $this->value));
}
};
Listing 4-11.monoid.php
您将使用幺半群作为添加和保存日志数据的结构。然后你要写你的“有用的”纯函数,所以下面的事情发生了:
- 返回值是一个写者单子。
- monad 是通过调用执行以下操作的
Writer::tell静态方法创建的:- 创建一个附加了幺半群的编写器幺半群对象
- 绑定实际“有用”的函数,准备在主函数值上调用
这允许您使用 monad 的 chain 方法将函数链接在一起。
为了使这一点更清楚,请看清单 4-12 (以及清单 4-13 )。您将使用上一章中的冰淇淋温度示例,该示例将温度作为一个整数,作为函数链的返回值。您还将收到第二个返回值,这是一组准备记录到磁盘或屏幕上的语句。正如您将注意到的,这个库创建和链接 monads 的方法与您看到的第一个库略有不同,这表明有多种方法可以为 monad cat 换肤,但它应该足够熟悉,以便理解正在发生的事情。如果你愿意,你总是可以用你之前看到的公理来测试以这种方式创建的单子,以确保你很高兴这就是你所使用的。
<?php
include('src/Writer.php');
include('monoid.php');
use PhpFp\Writer\Writer;
function double($number) {
$log = new Monoid(["Doubling $number"]);
return Writer::tell($log)->map(
function () use ($number)
{
return $number * 2;
}
);
};
function negate($number) {
$log = new Monoid(["Negating $number"]);
return Writer::tell($log)->map(
function () use ($number)
{
return -$number;
}
);
};
function add_two($number) {
$log = new Monoid(["Adding 2 to $number"]);
return Writer::tell($log)->map(
function () use ($number)
{
return $number + 2;
}
);
};
list ($mango_temp, $log) = double(6)->chain('negate')->chain('add_two')->run();
echo $mango_temp."°C\nLog :\n";
print_r($log->value);
Listing 4-12.
writer_monad.php
-10°C
Log :
Array
(
[0] => Doubling 6
[1] => Negating 12
[2] => Adding 2 to -12
)
Listing 4-13.writer_monad-output.txt
查看前面的输出,您可以看到您获得了正确的-10°c。您还获得了第二个数组,包含作为您调用的三个函数的结果的三个“log”字符串,您现在可以在您的“不纯”代码中使用它(例如,通过写入磁盘等)).这两个输出被包装到一个数组中,所以使用list语言构造将数组分成两个变量($mango_temp和$log)。
关于前面的代码,需要注意的另一个有趣的地方是在函数链的末尾使用了run()方法。如果您忽略这一点并运行脚本,您会发现您的链中没有一个函数被真正调用。这种类型的 monad 构建函数链,然后只在构建后“运行”它。这对于测试非常有用,是典型的单子,可以帮助您处理潜在的副作用,您将在下一种单子 IO 单子中看到这一点。
木卫一单子
在前面的例子中,Writer monad,你把所有不纯的操作推到脚本的末尾,把你想操作的信息收集到一个数组中,一直带着它直到所有纯函数完成,然后把它和你的脚本的主返回值一起返回,以处理写入磁盘或类似操作的讨厌的副作用。这是处理副作用的好方法,但是在很多情况下,等到代码结束才开始与外部系统对话是不现实的。例如,您可能需要从外部来源(API、文件、数据库)收集输入,这些来源会根据某个纯函数中途进行的计算以及其他函数的计算而变化。你需要另一个工具来利用不纯的动作,这个工具就是 IO monad。您将使用 Tom Harding 的php-fp-io库,可从 GitHub 的 https://github.com/php-fp/php-fp-io 获得,它是早期php-fp-writer的姐妹库,因此遵循相同的结构和风格。
看一看下面的代码。该模式在 Writer monad 示例中应该很熟悉,但是正如您将看到的,您调用了三个被认为是不纯的函数/语言构造(它们有副作用),您需要在进行过程中调用它们。
random_bytes:这引入了来自外部状态源的值(在 Linux 上通常是/dev/urandom),当用于创建函数的返回值时,显然意味着从给定的输入参数集你将无法确定返回值。- 这个函数(就像大多数文件系统函数一样)不能保证没有副作用,即使你没有从中读取未知的值。例如,如果文件或文件系统没有处于您期望的状态,就会产生错误和异常,因此您不能可靠地推断您的函数执行了预期的操作。
- 由于它只是输出到屏幕上(或网络服务器上),也许你认为 echo 不会出什么问题,不会对你的功能造成问题/副作用?考虑一下,如果你的脚本有一个名为
fclose(STDOUT)的地方,或者STDOUT流在你不知情的情况下从你的程序外部被关闭了;那么调用echo会导致你的程序在没有警告的情况下终止。
当然,还有许多其他函数具有与 I/O 相关的不同类型的副作用,您可以使用它们来演示这里的原理。
那么,让我们来看看你的剧本。你得到 100 个随机字节,并把它们转换成一个十六进制字符串(为了更容易印在书里!),将它们写入一个名为random.txt的文件,最后向屏幕输出一条消息,确认你已经完成了任务。但是实际上你要把它分成两个脚本。第一个是io_monad.php,它设置了完成任务所需的所有函数,并创建了一个函数链。然后它“返回”最后一行的链。如果你没有见过像这样使用return(例如,在函数之外),不用担心。您这样写是为了在第二个脚本中可以“包含”第一个脚本作为函数体。注意run_io.php中 include 调用周围的括号;这些构成了一个立即调用的函数表达式(IIFE ),它执行 tin 上所说的内容:它立即调用括号内的代码,就好像它是一个被调用的函数一样。这是 PHP RFC 关于统一变量语法的一部分,是作为 PHP 7 的一部分引入的。因为第一个文件中的代码被作为函数调用,return语句现在应该更有意义了!参见清单 4-14 ,清单 4-15 ,清单 4-16 ,清单 4-17 。
<?php
include('src/IO.php');
use PhpFp\IO\IO;
# Some functions that define how to create
# some other, impure functions
# Make a random string of hex characters from $length random bytes
$string_maker = function($length) {
return new IO( function () use ($length) {
return bin2hex(random_bytes($length));
}
);
};
# Write a string to $filename on disk
$file_writer = function($filename) {
return function ($string) use ($filename) {
return new IO( function () use ($filename,$string) {
file_put_contents($filename,$string);
}
);
};
};
# Send ($string) to STDOUT
$printer = function($string) {
return function () use ($string) {
return new IO( function () use ($string) {
echo($string."\n");
}
);
};
};
# Chain those functions together, and return the resulting
# monad
return $string_maker(100)
->chain($file_writer('random.txt'))
->chain($printer('All done'));
Listing 4-14.io_monad.php
<?php
# Start an IIFE
(
# Execute the io_monad.php file to get the monad
require('io_monad.php')
# At this stage, we have a monad full of functions
# that have not been called (and so haven't done)
# any "impure" work
# Finally call the unsafePerform() method on the monad to
# call the "impure" functions
)->unsafePerform();
Listing 4-15.run_io.php
All done
Listing 4-16.run_io-output.txt
935998b29780e9f8f56435120208f7196854f677a666abcc510fee8a7162d12f6d923e470b4373f232dfbb0bf1a9da28e9b8a3f84af15273fc516ccf74c493ebce3931922a59d83ba80d77cfc41e8c76ffd90d79d91e32bcf2fbdf15a85ec38b1c5186cc
Listing 4-17.random.txt
所以,这里发生的事情是,在io_monad.php中,你使用 IO 单子以类似于 Writer 单子的方式设置你的函数链。在第二个文件run_io.php中,您实际上使用名副其实的unsafePerform()方法调用了这个函数链。为什么要这样构造呢?第一个文件中的所有函数在实际运行之前都是“纯”的。第一个文件中的函数仅仅构造不纯的函数(通常称为延迟函数);他们实际上并不运行它们,也不做任何 I/O,所以它们就像积雪一样纯净。这意味着io_monad.php,就其本身而言,是一个纯粹的函数式程序,可以进行全面的测试、推理等等。如果这看起来有点像欺骗,那是因为它是。测试的价值可能是有限的,因为它可能不会测试你的程序的“肉”,其中大部分功能依赖于不纯的动作。当然,您并不局限于以这种方式使用不纯函数;你可以巧妙地将它们与纯调用和其他单子混合在一起,所以当你的程序更多的是纯的而不是不纯的时候,像这样构造它的可测试性就会增加。
了解更多关于单子的信息
如果前几节已经让你对单子感到兴奋,那么接下来的“进一步阅读”部分将帮助你了解单子背后的数学细节和理论。警告:有时阅读量会很大,所以先给自己冲杯咖啡。如果你对单子不感兴趣,那么你会对第七章感兴趣,这一章着眼于结构化应用(剧透:我建议完全可以忽略单子及其同类)。
进一步阅读
- 单子的维基百科条目,给出了一般背景
- [
en.wikipedia.org/wiki/Monad_(functional_programming)](en.wikipedia.org/wiki/Monad_…
- [
- 维基百科中关于幺半群的条目,再次给出了一般背景
- 单子和生殖腺道格拉斯·克洛克福特的谷歌技术演讲视频,他用 JavaScript 解释单子,以一种合理的可理解的方式
- 不要害怕单子,布莱恩·贝克曼解释单子的 MSDN 视频,这次用了更多的数学
- Haskell 手册中关于单子的部分(Haskell 负责在编程中推广单子,这是对单子相当简洁的处理。)
- 三个单子公理的(面向 Haskell 的)解释
- 单子的物理类比,来自 Haskell wiki,通过 Wayback 机器
- 相当全面的 monad 教程和相关文章的时间表;虽然它在 Haskell wiki 上,但它不是 Haskell 特有的
蹦床递归
如果你认为蹦床只对孩子有意思,你显然不是一个函数式程序员!在前一章中,你已经看到了递归,它是一种非常有用的程序控制形式,在很多情况下可以用来代替传统的命令式循环。我还提到了一个主要的缺点,那就是(潜在地)无限的资源使用导致了堆栈溢出等等。
在递归中,你创建一个函数,然后这个函数调用它自己。PHP(和大多数编程语言)的工作方式是,对于每个仍然活动的函数,关于该函数的信息保存在调用堆栈中。每次你的函数调用它自己,一个新的函数就被激活;因此,另一帧信息被添加到堆栈中,使用更多的内存来保存程序的当前状态。原始函数仍然是活动的,等待它刚刚调用的自身复制的结果,所以在复制完成之前它不能从堆栈中移除,依此类推。只有当递归函数的最内层调用完成时,堆栈才会展开,然后所有先前的函数也可以完成。如果在此之前用完了分配的内存,就会出现堆栈溢出错误(或者机器崩溃,这取决于对堆栈大小的任何强制限制)。与简单的while或for循环相比,这里唯一保持的状态是任何相关变量的当前状态。每个循环可能会改变它们,但(通常)不会增加所保存的状态信息量。
因此,避免递归问题的一个方法是不使用它,重写任何递归函数,这些函数可能会像命令循环一样破坏堆栈。然而,这不是很实用,意味着你错过了编写递归代码的好处。当然,您可以在代码中进行一些硬限制检查,以确保您的递归只在那些您可以保证它将在可用堆栈/内存限制内完成的值上调用,但是这些可能很难预先确定,这意味着一些计算根本无法完成。
许多语言提供了一种叫做尾部调用优化(TCO)的解决方案。这是编译器使用的一个技巧,让它将某些递归函数展平成命令式的循环结构。当递归调用(对自身的实际调用)是函数中的最后一次调用(尾部调用),只调用自身时,会发生这种情况。此时,编译器可以重用包含函数状态的帧,而不是创建额外的帧,因为它知道调用函数没有其他操作要执行。这极大地减少了存储的信息,实际上使递归变成了一种循环。
不幸的是,PHP 虚拟机不使用 TCO。如果是的话,你可以确保你的递归函数是在函数的末尾用递归调用编写的。相反,您可以使用一个 trampoline 函数来完成基本相同的任务。在计算中,trampoline 是一个自动创建另一个函数来帮助调用另一个函数的函数,在这种情况下“弹跳”您的函数以避免递归!
在看蹦床函数之前,有必要全面了解一下什么是尾调用。尾部呼叫具有以下特征:
- 是递归的(即,它必须是调用自身的函数)
- 必须是返回语句(即
return this_function()) - 必须只返回它自己
- 必须是最后执行的函数,如果它是一个“返回”值
- 调用中必须没有其他操作发生(即不返回
$something+this_function())
如果你的功能不满足这些标准,那么它就不能被 TCO'd,你的蹦床就不能工作。有很多方法可以将大多数递归函数重写为尾调用递归函数(TCR ),如果需要的话,可以搜索一下。
对于这个例子,您将使用大多数 TCO 文章使用的经典例子——阶乘函数。如果你不熟悉,一个数 x 的阶乘(通常写成 x!)是 x * (x-1) * (x-2) * … * (1)。换句话说,5!= 5 × 4 × 3 × 2 × 1 = 120.这可以在标准递归函数中实现,并且总是有一个递归尾调用。您将使用 Gilles Crettenand 编写的函数式 PHP 库中的一个蹦床实现,该实现可从 GitHub 的 https://github.com/functional-php/trampoline 获得;可以通过 Composer 安装,也可以直接包含。参见清单 4-18 和清单 4-19 。
<?php
# Include and use the trampoline library
include('trampoline/src/Trampoline.php');
include('trampoline/src/functions.php');
use FunctionalPHP\Trampoline as T;
# First define our standard recursive function
$factorial = function ($i, $total = 1) use (&$factorial) {
# if $i is 1, return the total, otherwise
# recursively call the function on $i-1,
# multiplying the accumulating total by $i
return $i == 1 ? $total : $factorial($i - 1, $i * $total);
# note that $factorial is the tail call here
# when it is returned
};
# Now the same function again, but this time using the
# trampoline function. The only difference (other than
# the name!) is that we wrap the tail call in T\bounce()
$bounced_factorial = function ($i, $total = 1) use (&$bounced_factorial) {
return $i == 1 ? $total : T\bounce($bounced_factorial, $i - 1, $i * $total);
};
# We use T\trampoline() to call the "bounced" function.
# We'll wrap it in a helper function called $trampolined
# for ease of use
$trampolined = function ($i) use ($bounced_factorial) {
return T\trampoline($bounced_factorial, $i);
};
# We'll create a function to time how long our
# function runs take, in seconds
$timer = function($func, $params) {
$start_time = microtime(true);
call_user_func_array($func,$params);
return round(microtime(true) - $start_time,5);
};
# So let's run our normal recursive function
# and the trampolined version, both to
# calculate the factorial of one hundred thousand.
# The result will be the same, we're only
# interested in the time they take here.
var_dump ( $timer($factorial, [100000]) );
var_dump ( $timer($trampolined, [100000]) );
# Now let's limit the memory we're working with
# and run them again, this time to calculate
# the factorial of one million. We'll run the
# trampolined first, for reasons that you will
# see.
ini_set('memory_limit','100M');
var_dump ( $timer($trampolined, [1000000]) );
var_dump ( $timer($factorial, [1000000]) );
Listing 4-18.
bounce.php
float(0.0254)
float(0.07143)
float(0.63219)
PHP Fatal error: Allowed memory size of 104857600 bytes exhausted (tried to allocate 262144 bytes) in bounce.php on line 18
Listing 4-19.bounce-output.txt
如果您看一下输出,您会看到标准递归函数(第一行)的运行速度要比蹦床版本(第二行)快得多。然而,当您开始处理大数字时,尽管践踏版本需要更长的时间来运行(第三行),递归版本(第四行)会耗尽内存并关闭脚本。
因此,使用蹦床的代价是较低的性能(就执行时间而言)。在某些情况下,这可能意味着您坚持使用普通的递归版本。但是要记住,完成速度较慢的函数通常比完成速度较快的函数要好。考虑你的脚本的用户将会有哪些可用的资源,以及你期望你的脚本处理哪些输入,并且在适当的时候使用一个蹦床版本来安全地运行它。
递归 Lambdas
有趣的是,注意我之前写阶乘函数的方式。我使用闭包而不是命名函数,这在像这样的玩具程序中纯粹是出于选择,但是在真实的程序中,您可能有很好的理由这样做,以允许您获得我在本书前面谈到的闭包带来的优势。现在,递归函数(无论是命名函数还是闭包)需要能够调用自身。对于命名函数来说,这很简单;它在全局空间中有一个名字,所以你可以很容易地从它内部调用它。然而,在一个闭包里,这就不那么简单了。因为从技术上来说,它是一个对象,这里你通过把它赋给一个全局变量,在全局范围内声明了它,它不存在于它本身的范围内。您可能认为您只需要“使用”您赋予它的变量,就像您在闭包中使用任何变量一样。如果您尝试这样做,您将得到一个错误,因为您只能“使用”一个存在的变量(因此有一个值,因为默认情况下您在 PHP 中通过值传递)。变量直到闭包创建后才存在,在此期间,错误将会发生,因为变量不存在!正如您将从我的代码中看到的,解决方法是通过引用use子句来传递它(在变量前放置一个&符号),这回避了还没有值被传入的事实。
这种递归闭包通常被称为递归 lambda (lambda 是匿名函数的另一种说法)或匿名递归。传统的编程智慧(不管是什么)宣称这是不可取的,要在命名函数上实现递归以保持代码清晰易懂。然而,许多语言都支持匿名递归,比如 JavaScript,它提供了反射功能使之变得更容易,正如你所看到的,我喜欢它!一如既往地务实,看看什么最适合你的代码。
PHP 类型系统
您可能知道,PHP 是一种动态类型语言,与静态类型语言相反。它也是弱类型的而不是强类型的。这意味着您不必指定类型(整数、字符串、布尔值等。)声明变量时(在编译时)。该类型是隐式的,由运行时分配给变量的值决定。弱类型意味着您可以通过为其分配不同的值来更改类型。当需要处理变量时,PHP 会自动将变量的类型转换为所需的类型,例如,当您试图添加一个整数和一个字符串时。在 PHP 中使用弱动态类型非常有用,但这也是很多新手(特别是那些来自静态或强类型语言的人)犯的错误,并导致 PHP 的一些负面关注。为什么 PHP 是动态/弱类型的?好吧,回到时间的迷雾中,当拉斯马斯·勒德尔夫创建 PHP 时,它的目的是作为一种简单、直接的方式来创建交互式网站,而不是一种完全成熟的通用编程语言。当时使用弱动态类型似乎是显而易见的;毕竟,它是为处理网站而设计的,对于 HTTP 来说,没有整数和布尔值的概念——一切都是字符串!因此,如果脚本中的所有内容都是强静态类型的,那么要获得任何有用的值作为输入并将任何内容作为输出发送回来,您的脚本将需要进行大量的类型争论。那么,为什么不把所有的麻烦都去掉,让它变得弱而有活力呢?
相比之下,在大多数函数式编程语言中,强静态类型非常流行。以 Haskell 为例;它有一个强大的静态类型系统,尽管这种被推崇为函数式编程典范的语言也用其恰当命名的动态类型向动态语言的实用性致敬。静态类型在函数式编程中占重要地位的主要原因可以追溯到函数式编程带来的一个主要优势:轻松阅读和推理代码的能力。思路是,如果您被迫在代码中显式声明变量的类型(包括函数的参数类型),那么在给定特定函数的定义的情况下,您可以很容易地推断出当您通读函数实现时,任何给定的输入将会发生什么。当然,除了函数式编程之外,静态类型化还有其他很好的理由,比如编译器/解释器能够在程序开始运行之前发现某些类型的错误。
当然,也有不利的一面,并不是每个人(包括我)都认为静态类型在函数式编程中完全是一件好事,尤其是在 PHP 中。正如您马上会看到的,尽管它本质上是动态的,PHP 确实有一个类型声明系统,以前称为类型提示,可用于建议或强制函数参数和返回类型的特定类型。但是,使用这种语法工具只能带来有限的好处。它不是强类型,所以当变量作为参数传入时,虽然您(和编译器)可以推断变量的类型,但是一旦您在函数中对变量做了任何事情,任何显式类型保证都将失效。并且假设您从一个打开严格类型检查的文件中调用该函数;否则,它只不过是“指导”,在任何阶段都不提供任何保证,只是试图将一种类型扯到另一种类型。尽管好处不多,但声明每种类型的额外语法降低了代码的简洁性和可读性。但是没有输入信息,你怎么能对你的代码进行推理呢?我建议你用同样的方式来思考这个问题,即使你使用了类型声明。如前所述,函数的内部(因此,代码库的很大一部分)将不会考虑您所做的类型声明。以清单 4-20 (和清单 4-21 为例。
<?php
declare(strict_types=1);
function my_function(bool $a) {
var_dump($a);
$a = $a * 22;
var_dump($a);
};
my_function(true);
Listing 4-20.types1.php
bool(true)
int(22)
Listing 4-21.types1-output.txt
如您所见,您已经用declare语句打开了严格类型,并且使用了类型声明来声明$a是一个布尔值。在第一个var_dump中一切都是好的,但是等等,第二个var_dump告诉你$a是一个整数。“当然,”你说,“当你把一个布尔值乘以一个数时,PHP 把这个布尔值转换成一个int,true变成 1,所以 1 * 22 是 22,这是一个int,所以这是意料之中的。”很好。我刚刚展示了在使用类型声明时如何对代码进行推理,这是通过理解 PHP 使用的动态类型系统来实现的。您可以使用这些知识以同样的方式推理非类型化函数。事实上,你不一定知道你开始的类型,这并不会让你远远落后于你知道的情况。事实上,如果您的函数可能被不使用严格类型的其他人使用,那么假设您的类型声明会受到尊重而不是防御性地编码可能是危险的。PHP 只对用declare(strict_types=1)语句从文件中调用的函数执行严格的类型检查,不管定义该函数的文件是否有该声明。因此,如果您的函数假设它将接收一个整数,因为您已经将int指定为类型声明,并在您的文件中打开了严格类型,但是其他人包含了不使用严格类型的文件,那么您的函数很可能会改为使用float来调用。当发生这种情况时,在许多情况下,PHP 可以将一种类型强制转换为另一种类型,不会抛出类型错误。float 会被悄悄地强制成整数值(截断,有人吗?).这种强制是在正常的 PHP 规则下发生的,当它需要争论类型时就会发生,但是您的代码不知道它已经发生了,即使您预料到了,您也不能测试它。如果没有类型声明,您的函数将获得 float,因此您可以测试它,并选择当您想要的是整数时如何处理它。
另外,纯函数式编程语言(和/或程序员)经常在代码中避免赋值,部分是为了确保不变性。例如,在这种情况下,不为变量赋值将保证类型保持不变。但是,没有分配导致疯狂,所以你不会在这里练习那种黑暗的艺术。
在使用类型声明进行函数式编程时,另一个问题(尽管更具理论性)是 PHP 只允许在函数之外捕捉和处理类型错误(例如,通过将函数调用封装在try / catch块中),这意味着这种错误实际上是副作用。如果你回头看看第二章中关于副作用的讨论,你会发现如果你在函数本身内部捕捉并处理一个错误,那么就没有副作用。最好的方法是在函数中处理参数和任何必要的测试/转换,这意味着不要使用类型声明。
正如您所猜测的,许多开发人员和专家认为,即使是 PHP 的有限类型强制也是利大于弊的,归根结底,决定您是否在特定程序或函数中使用类型声明的通常是个人选择或实用考虑。在本书的其余部分中,您不会使用它们来保持代码的整洁并专注于其他主题,但是在这里,如果您想要或需要,我将概述如何使用 PHP 类型系统。
类型声明
在 PHP 5 中,类型声明被称为类型提示。5 和 7 之间的主要区别(除了名称更改)如下:
- 在 5 中,违反类型提示会导致可恢复的致命错误;在 7 中,不符合类型声明会导致类型错误。
- 在 5 中,提示支持的类型只有
class/interface、self、array和callable。7 中的声明添加了标量(bool、float、int、string)类型。 - 仅在 7 中支持严格类型。
- 仅在 7 中支持返回类型。
注意,根本不支持类型别名,所以例如,您不能使用boolean来指定bool类型。PHP 将别名视为类名,所以它会假设类型为boolean的参数期待来自boolean类的对象,而不是bool(真/假)标量变量。
您也可以将参数设置为“可空”也就是说,它将接受空值以及指定类型的值。为此,通过在参数名称后添加=null将参数的默认值设置为 null。
除了为每个参数指定类型之外,还可以为返回值指定类型。参数类型在每个参数之前,返回类型在参数列表之后用冒号和 type 指定。
打开严格类型(在 PHP 7 中可用)后,与指定类型不匹配的参数或返回值将导致类型错误。关闭严格类型(这是默认设置),在 PHP 的正常规则下,参数和返回值将被强制转换为指定的类型,而不会抛出错误。如果强制是不可能的(例如,从字符串“hello”到整数),则抛出类型错误。
您可以在同一个函数中混合和匹配带有和不带声明类型的参数。同样,指定返回类型是可选的,不依赖于指定参数类型。
让我们看看一些示例代码中的这些要点(参见清单 4-22 ,清单 4-23 ,清单 4-24 ,清单 4-25 )。
<?php
# Examples of non-strict typing
# Our function accepts two nullable ints, and returns an int
$add = function (int $a = null, int $b = null) : int {
return $a + $b;
};
var_dump( $add(7, 3) ); #10
var_dump( $add(2.5, 4.9) ); #6, not 7.4
var_dump( $add("5Three", "6Four") ); #11, plus Notices thrown
var_dump( $add(true, false) ); #1 (true == 1, false == 0)
var_dump( $add(null, null) ); # 0 (null is coerced to 0)
var_dump( $add("Three", "Four") ); # Type Error
Listing 4-22.types2.php
int(10)
int(6)
PHP Notice: A non well formed numeric value encountered in types2.php on line 7
PHP Notice: A non well formed numeric value encountered in types2.php on line 7
int(11)
int(1)
int(0)
PHP Fatal error: Uncaught TypeError: Argument 1 passed to {closure}() must be of the type integer, string given, called in types2.php on line 23 and defined in types2.php:7
Stack trace:
#0 types2.php(23): {closure}('Three', 'Four')
#1 {main}
thrown in types2.php on line 7
Listing 4-23.types2-output.txt
<?php
# Turn on strict typing
declare(strict_types=1);
# A function which accepts $a of any type,
# and a nullable int $b, and return a
# value of type int
$divide = function ($a, int $b = null) : int {
if ( ($a / $b) == intdiv($a, $b) ) {
return intdiv($a, $b); # returns an integer
} else {
return $a / $b; # returns a float (not good!)
}
};
# As we'll be experiencing a lot of errors, lets create
# a function to catch and deal with the errors so the
# script can complete all of our calls without dying
function run($func, $args) {
try {
# run the function and var_dump the return result
var_dump( call_user_func_array($func, $args) );
} catch ( Error $e ) {
# print the error message if one occurs
echo "Caught : ".$e->getMessage()."\n";
}
};
run( $divide, [10, 2] ); # int(5)
run( $divide, ["10","2"]); # Type Error, as no type coercion
run( $divide, [10, 2.5] ); # Type Error, as no type coercion
run( $divide, [true, false] ); # Type Error, as no type coercion
run( $divide, [23, null] ); # Division by zero warning & intdiv type error.
# Note that our input parameter is declared an int, and intdiv requires
# an int. But we still get an error, because ints are nullable in
# user function parameters, but not in all PHP function parameters
run( $divide, [10,3]); # Return Type Error (float 3.3333333...)
run( $divide, [6.4444 % 4.333, 9.6666 % 2.0003]); # int(2)
# all that matters is the type of the value of an expression passed
# as a parameter, not the types of the operands of that expression.
Listing 4-24.types3.php
int(5)
Caught : Argument 2 passed to {closure}() must be of the type integer, string given, called in types3.php on line 36
Caught : Argument 2 passed to {closure}() must be of the type integer, float given, called in types3.php on line 36
Caught : Argument 2 passed to {closure}() must be of the type integer, boolean given, called in types3.php on line 36
PHP Warning: Division by zero in types3.php on line 13
Caught : intdiv() expects parameter 2 to be integer, null given
Caught : Return value of {closure}() must be of the type integer, float returned
int(2)
Listing 4-25.types3-output.txt
因此,正如你所看到的,如果你使用类型声明,最好确保你知道所有关于它们如何操作的注意事项,不要让自己陷入虚假的安全感。例如,如果您打开了严格类型,声明函数的返回类型为float,并尝试返回一个int,您认为会发生什么?还记得我说过 PHP 不会试图强制类型吗?所以,你应该得到一个返回类型错误,对不对?让我们试试(见清单 4-26 和清单 4-27 )。
<?php
# Turn on strict typing
declare(strict_types=1);
# Declare two functions that are EXACTLY
# the same apart from the return type (and name).
# intdiv returns an integer. (int) casting
# ensures that even if we've somehow messed
# up, intdiv returns an int into $a, and
# the return value is forced to int.
$the_func_int = function () : int {
$a = (int)intdiv(10,2);
return (int)$a;
};
$the_func_float = function () : float {
$a = (int)intdiv(10,2);
return (int)$a;
};
var_dump( $the_func_int() ); # int(5). As expected.
var_dump( $the_func_float() ); # float(5). Errr?!
Listing 4-26.types4.php
int(5)
float(5)
Listing 4-27.types4-output.txt
没有错误,当它离开你的手的时候,返回一个肯定是int的东西,从另一端出来就是一个 float!考虑到这一点,这是有意义的,因为 PHP 中任何有效的int都可以表示为一个有效的 float。但是假设严格的类型化坚持使用完全相同的类型名,例如,var_dump,就会带来麻烦。你可以在 PHP 手册中了解更多关于类型声明的细节。
进一步阅读
- PHP 手册中的类型声明
- PHP 手册中的返回类型声明
作为一个有点轻率的事后想法,认为这个世界上没有什么新的东西。在 Perl、PHP 和 Python 这样的新贵带着他们时髦的现代弱动态系统出现之前,强静态类型是多年前类型管理的主要形式,这是未来的发展方向。读一读最近的 PHP“最佳实践”网站,或者听听伟大的 PHP 改革家们讲述他们希望如何发展这门语言,你很快就会发现,对于 PHP 来说,要真正完成向现代范式的转变,它需要有适当的强大的静态类型……于是轮子又转了。
摘要
在这一章中,你看了几个更高级的函数式编程主题。当您得知许多纯函数式语言的程序员将这些视为一般的主题时,您不会感到惊讶,事实上,您可能会发现它们在解决许多函数式“问题”时非常有用鉴于一些函数式语言缺乏命令性和“不纯”的功能,它们是完成任何实际工作所必需的。然而,由于 PHP 提供了将函数式代码与命令式代码混合和匹配的灵活性,并且假设您不是执意只编写函数式代码,那么您通常可以在不使用这些技术的情况下编写函数式代码。用你作为程序员的判断和经验来决定什么时候它们会增强你的程序,什么时候你只是为了“功能性”而写这样的代码。
五、高性能应用的策略
在本书的第一部分,我介绍了函数式编程背后的理论,概述了如何用函数式风格编写代码。我提到了函数式编程的一些好处,但在第二部分中,我将通过创建一些程序来展示函数式编程可以简化的一些实际任务,从而使这些好处加倍。
本章着眼于使用函数式编程来提高脚本的性能。在过去,PHP 在性能部门受到了不好的评价,这并不完全令人惊讶,因为它本质上是一种高级解释语言。然而,在过去的几个主要版本中,性能有了很大的提高,尤其是版本 7,它的性能远远超过了 PHP 以前的任何版本。所以,在你开始学习本章的技术之前,确保你运行的是最新版本的 PHP,因为这可能是提高性能最简单的方法。也就是说,即使您已经升级到了最新最好的版本,也经常会有这样的时候,您可以对有问题的脚本进行额外的性能提升。您将看到函数式编程技术,如记忆化和惰性求值,它们有助于提高脚本速度,以及函数式编程如何帮助您利用并行编程来提高性能。但是在开始之前,理解我所说的性能是什么以及如何衡量它是很重要的。
了解和衡量绩效
当您的脚本运行时,它将使用一定数量的内存和 CPU 和/或墙时间。其中任何一项的降低通常都被认为是性能的提高。有时,其他资源,如磁盘空间、网络带宽或 API 调用,也被认为是性能问题。您的特定性能要求将决定在什么情况下您认为性能得到了提高。例如,在具有大量磁盘空间但处理器有限的 NAS 机器上,您可能认为以增加缓存磁盘空间为代价来优化较低的内存和 CPU 使用是理想的。但是,如果您的目标是配备小型固态硬盘的高性能笔记本电脑,您可能会考虑使用内存,因为缓存存储是一种更好的权衡。在这一章中,你将主要关注内存使用和墙时间,这是 PHP 脚本经常遇到的两个最大的性能问题。
衡量绩效:剖析
您可能遇到过运行缓慢的脚本(通常是您自己的脚本!),通常第一反应是开始寻找提高 PHP 速度的方法。编译、缓存、重构代码、加速器——这些都是在搜索 PHP 性能或速度问题时很容易出现的话题。你可能已经读到过它们,并想尝试一下。
我的建议(来自痛苦的个人经历)是立即停止。对您的代码抛出一个又一个性能技巧(通常您可以在网上或类似的好书中找到),即使它们看起来很合理,并且您可以看到其中的逻辑,也可能会使您的代码变得复杂,或者无缘无故地增加依赖性。为什么呢?因为当你不知道问题的根本原因时,你就不知道一个特定的解决方案,不管在理论上有多好,是否能解决你在特定情况下遇到的问题。即使它看起来确实有效,你也不知道这是否是解决它的最简单的方法,因此当你不需要的时候,你是否会让自己背上额外的“技术债务”。
你经常错过的一步是直接问你的剧本“你怎么跑这么慢?”如果您的脚本告诉您,那么您可以尝试在不使用外部工具(如编译器和缓存系统)的情况下修复这个问题。那么,你如何向你的剧本提出为什么的问题呢?通过侧写。
分析器在软件运行时监视它(通常从“内部”),并分解程序每个部分所用的时间(有时是资源)。概要文件信息通常被报告到单独的代码行或函数调用的级别。这有助于您准确定位脚本变慢的地方。是因为复杂数据库查询吗?写得不好的循环?一个被调用次数超过预期的函数?磁盘或网络访问暂停执行?无论是什么问题,侧写员都会告诉你。一旦你知道了减速的确切原因,解决方案通常是显而易见的(或者至少,你可以排除实际上不会解决问题的潜在解决方案)。这可能只是意味着重写几行代码或缓存一些数据,而不是重复生成数据。剖析器可能会指出 PHP 外部的问题,比如缓慢的数据库服务器或落后的网络连接或资源。当然,在某些情况下,从 PHP 编程的角度来看,您可能最终会遇到一个棘手的问题,这确实需要加速器或外部缓存系统的帮助。在任何情况下,通过在开始尝试什么之前使用一个分析器来询问为什么,您可能会节省时间并防止对您的代码或部署环境进行不必要的更改。
对于 PHP,在进行概要分析时,您有几种选择。您可以通过将分析/测量语句直接添加到您的代码库中来手动分析您的代码,或者您可以使用一个工具来自动分析您的代码。如果您大致知道问题出在代码的什么地方,那么前者简单快捷,不需要改变您的开发环境。后者虽然需要安装和配置该工具,并在第一次学习如何使用它,但它提供了更全面的分析。它也不依赖于您知道您的问题可能在哪里,并且通常只需要对您的代码库进行最小的修改或者不需要修改。在接下来的小节中,您将会看到这两个选项。
手动剖析
手动分析需要在源代码中添加代码,以便直接从脚本中测量时间或资源。清单 5-1 展示了一个测量不同代码行执行时间的例子,输出如清单 5-2 所示。
<?php
# A script to do some "busywork", filling
# some strings with some characters.
# Let's create a "checkpoint" by recording the current time and memory
# usage
$time1 = microtime(true);
$memory1 = memory_get_usage();
# Now let's do a loop 10 times, having a quick usleep and
# adding just a little data to our variable each time
$a_string = (function () {
$output = '';
for ($counter = 0; $counter < 10; $counter++) {
usleep(10);
$output .= 'a';
};
return $output;
})(); //we execute the function straight away
# Now create a second checkpoint
$memory2 = memory_get_usage();
$time2 = microtime(true);
# Let's do this second loop 1000 times, having a longer
# sleep and adding lots of data to our variable each time
$b_string = (function () {
$output = '';
for ($counter = 0; $counter < 10; $counter++) {
usleep(100);
$output .= str_repeat('abc',1000);
};
return $output;
})(); //again we execute straightaway
# and create a final checkpoint
$memory3 = memory_get_usage();
$time3 = microtime(true);
# Now let's output the time and memory used after each function.
echo "1st function : ".($time2-$time1)." secs, ".
($memory2-$memory1)." bytes\n";
echo "2nd function : ".($time3-$time2)." secs, ".
($memory3-$memory2)." bytes\n";
echo ("Peak memory usage : ". memory_get_peak_usage()." bytes\n");
Listing 5-1.manual.php
1st function : 0.0007178783416748 secs, 40 bytes
2nd function : 0.0016269683837891 secs, 32768 bytes
Peak memory usage : 392504 bytes
Listing 5-2.manual-output.txt
如您所见,第二个函数比第一个函数花费的时间长得多。你现在知道了使你的脚本变慢的问题是第二个循环,你可以通过删除usleep语句或者删除整个循环并用str_repeat('abc',1000000)填充你的字符串来修复它。
在查看每个函数调用前后使用的内存量时,您需要稍微谨慎一些。如您所见,第二个函数后内存使用的差异比第一个函数大得多,这是意料之中的,因为您已经返回了一个大字符串。但是,该脚本的内存使用峰值高于两个单独测量值的总和。函数在运行时会使用内存,但是一旦它们返回,内存通常会被释放(静态变量和生成器不会被释放),只留下返回值所占用的内存(假设您选择了捕获它)。即使您在您的return语句之前添加了一个memory_get_usage()调用,如果您在函数执行时销毁或替换变量值,它也可能不会捕获函数使用的所有内存。在进行过程中,您需要仔细考虑您的脚本正在做什么,以及放置手动分析语句的最佳位置。
这显然是一个简单的、人为的例子,但是这些原则也适用于真实世界的代码。正如您所看到的,对于这里或那里的几行代码或一个函数,手动分析是快速而简单的。然而,分析较大的代码库很快会变得很麻烦,如果不小心的话,会大大增加代码库的大小。在寻找一个特定的问题时,您可以分析代码的较大部分,当发现有问题的较大部分时,您可以将它分析成较小的部分,等等,直到找到问题代码(有效地进行二分搜索法)。如果您花费大量的时间来这样做,那么实现和学习像下面详述的那些剖析工具所必需的时间可能会是值得的。
另一件要记住的事情是,手动分析代码会增加脚本的性能损失——虽然通常很小,但累积起来会很大,特别是在您不断重复地将分析信息记录到磁盘的情况下。因此,在代码投入生产之前(可能作为构建/部署过程的一部分),考虑剥离或禁用概要分析代码是值得的。当然,在某些情况下,有意识地将概要分析代码添加到产品代码库可能会有所帮助(例如,当从您的终端用户那里收集概要分析信息是必要的/有用的时候,这些用户可能没有安装专用的概要分析软件)。自动分析工具通常也会增加一些开销,尽管它通常更小(它们通常是用低级语言编写的,并且通常直接与 PHP 解释器集成),并且通常更容易打开和关闭。这些自动化工具通常只在开发环境中使用,而不在实际生产机器上使用,因此任何开销都仅限于开发工作。
分析工具
PHP 有几个可用的分析工具。虽然流行的 Xdebug 调试器提供了一些分析选项(如果您已经安装了用于调试的工具,那么值得一看),但是最常见和最全面的工具是 XHProf。最初由脸书开发,它是 PECL 的扩展,因此可以简单方便地安装。数据收集端是用 C 编写的,提供了一个图形化的 PHP 接口,用于查看收集到的概要数据,包括调用图(哪些函数调用了哪些函数的可视化图形),如果你安装了 Graphviz 的话。两个相关的项目 XHProf UI 和 XHGui 提供了一个扩展的可视化界面,在一个 MySQL 或 MongoDB 中存储多个运行,并提供对多个运行进行排序和比较的访问。这些需要安装和配置的工作要多一点,但是如果您经常在生产系统上分析开发代码或实时代码,它们会提供很大的灵活性。不过,对于在开发代码中发现明显问题的基本剖析,XHProf 本身是一个很好的起点。
领先的商业 PHP 分析工具是 Blackfire,它提供了相当全面的分析服务,并且在撰写本文时有一个合理的免费层。请注意,虽然分析客户端在您的系统上运行,但数据会报告给 Blackfire 服务器后端,因此可能不适合某些用途。
进一步阅读和工具
- “对速度的需求:用 XHProf 和 XHGui 剖析 PHP ”,作者 Matt Turland
- Adam Culp 的“XHProf PHP 剖析”
- Lorna Mitchell 的“用 XHGui 分析 PHP 应用”
XHProf 老师
函数级分层 PHP 分析器
- 主网站:
https://github.com/phacility/xhprof - 安装信息:
www.php.net/manual/en/xhprof.setup.php - 主要文档:
www.php.net/xhprof - 可视化函数图形工具:
http://graphviz.org/
xh 教师 UI
基于 XHProf 的扩展分析器
XHGUI
基于 MongoDB 的 XHProf 数据图形界面
Xdebug
内置分析器的综合调试器
- 主网站和文档:
https://xdebug.org/
KCachegrind
轮廓数据可视化工具。与 Xdebug 一起使用以获取可视配置文件信息。
Webgrind
Xdebug 的另一个基于 web 的概要分析前端,它实现了 KCachegrind 功能的子集
逆火
商业 PHP 分析服务
低级剖析
当你真的需要“深入”你的脚本时,有时你需要看的不是你的代码在做什么,而是 PHP 本身在做什么。明确地说,我们大多数人永远不需要这样做来解决性能问题,尽管看一看 PHP 如何将您的代码翻译成对运行它的系统的调用是非常有趣和有益的。PHP 本身是一个编译成二进制可执行文件的 C 程序,这意味着您可以使用 strace(显示系统调用和信号)、ltrace(显示库调用)和 gdb(类似 PHP 本身的 C 程序调试器)等通用工具来查看幕后发生的事情。如果你对此感兴趣,可以看看吴镇男·雷森斯的以下教程。作为 Xdebug 的作者,他在某种程度上是 PHP 机制方面的专家。
所以,现在你已经剖析了你的代码,你知道你的瓶颈在哪里,你解决了任何简单的新手错误。您已经运行了最新版本的 PHP,并且您在顶层硬件上。因此,您相当确信您的问题出在代码库的特定部分,并且您需要以更有效的方式编写一些算法。函数式编程能提供一些模式来帮助加速你的代码吗?当然可以;否则,这将是一个非常短的章节!下面几节介绍了一些功能性技术,这些技术适用于许多情况,即使您的整个代码库都不起作用。
进一步阅读
- “PHP 在做什么?”作者吴镇男·雷森斯
记忆化
如果你已经编程一段时间了,特别是在 web 领域,你会遇到缓存的概念。缓存是这样一个过程,即获取“昂贵”计算的结果,存储结果,然后在下次调用该计算时使用存储的结果,而不是再次运行计算本身。昂贵意味着运行时间长,占用大量内存,进行大量外部 API 调用,或者执行任何其他出于成本或性能原因而希望最小化的操作。缓存失效是您选择从缓存中移除项目的过程。例如,如果生成新闻网站的首页需要很大的努力,那么您会希望缓存该页面,这样就不需要在每次访问者访问您的网站时都生成它。然而,一旦下一个突发事件发生,您将希望更新您的首页,并且您不希望您的访问者点击缓存版本并获得旧新闻,因此您将“无效”缓存并重新生成页面。如果您曾经参与编写或使用过缓存系统,您无疑会熟悉下面这句话(或者至少理解它的出处),这句话引自 Phil Karlton:
"在计算机科学中只有两件困难的事情:命名、缓存失效和一个接一个的错误."
您已经看到了递归如何减少一个接一个的错误,而且没有人有希望解决事物命名的问题,那么如何解决缓存失效呢?如果你认为函数编程有锦囊妙计,请举手。很好,金星给你!确实如此,诀窍就是永远不要让缓存失效。问题解决了!
我其实是认真的。函数式编程提供了一种称为记忆化的技术,这种技术植根于纯函数固有的属性。在前面的理论章节中,你看到了纯函数是如何透明引用的。给定一组特定的输入参数,一个纯函数将总是产生相同的返回值,并且(对于该组输入)该函数可以简单地用返回值替换。这听起来应该有点像缓存:对于一组给定的输入(比如,您的新闻故事),您希望用返回值(缓存的输出)替换(运行起来很昂贵的)函数。获取一个纯函数的输出并缓存它的过程是记忆化,是缓存的一个特例。
假设您正在记忆一个昂贵的函数,并将结果缓存到磁盘。每次使用不同的参数运行该函数时,可能会得到不同的结果。您想要消除的是对相同参数多次运行函数的成本,因为每次您的(纯)函数都保证给您相同的结果。因此,您可以缓存结果,例如,创建一个散列来表示所使用的输入参数,并使用它作为文件名来存储该运行的返回值。下次运行该函数时,再次散列输入参数,查看是否存在同名的缓存文件,如果存在,则返回内容(而不是重新运行代价高昂的函数)。
到目前为止,这是典型的缓存。但是,如何避免让缓存失效呢?答案是你没有;记忆能有效地帮你做到这一点。你的功能是纯的,这意味着没有副作用。因此,如果在你虚构的新闻网站上出现了一个新的故事,这个故事的细节将只影响(纯)函数,该函数通过输入参数创建你的首页。例如,您可以将一组标题作为一个参数。突然,参数的哈希值发生了变化,所以 memoized 函数将无法在磁盘上找到具有该哈希值的文件,因此将运行完整的函数,并将新结果缓存到磁盘上以新哈希值命名的文件中。概括地说,由于您唯一的输入是参数,如果没有任何参数发生变化,您必须确定可以使用缓存。但是,如果参数已经改变,那么就没有相应的缓存文件,所以没有必要使它无效。当然,旧的缓存文件仍然会在那里,所以当您不小心发布了一个谎称这本书是垃圾的故事时,您可以立即收回它,函数将返回到使用旧的缓存文件,因为哈希将再次匹配参数。
目前为止,一切顺利。然而,函数式编程并没有因为它的优点而止步,哦,不。如果你正在考虑如何编写你的函数来实现记忆,那就停下来吧。一般来说,你不需要。您可以简单地将您的函数包装在另一个自动记忆它的函数中。这样的包装函数很容易编写,因为你所关心的只是你的纯函数的输入和输出,而不是它在内部做什么。
让我们来看一个记忆的例子。在清单 5-3 中,您将把 pure 函数的结果缓存到磁盘中。为了简洁起见,您将把那些不纯的磁盘函数分离成单独的函数,而不是编造一些 IO 单子,但是如果您愿意,当然也可以这样做。
<?php
# We're going to cache our results on disk, so let's
# define a directory and file prefix
define('CACHE_PREFIX', sys_get_temp_dir().'/memo-cache-');
# This is a helper function to read a cached file
# from disk. I've broken it out as a separate function
# as it is necessarily impure. You can replace it
# with an IO monad or similar in production if you wish
$get_value = function($hash) {
# return null if the file doesn't exist
if (!file_exists(CACHE_PREFIX.$hash)) { return null; }
# read the file into $value
$value = file_get_contents(CACHE_PREFIX.$hash);
# return null if the file exists but couldn't be read
if ($value === false) { return null; }
# return our value if all is good
return $value;
};
# Likewise, this is an impure helper function to write
# the value to a cache file.
$store_value = function($hash, $value) {
if (file_put_contents(CACHE_PREFIX.$hash, $value) === false) {
$value = null;
}
# return the value that was stored, or null if the
# storage failed
return $value;
};
# Finally, this is our actual memoization function.
# It returns a closure which is a "memoized" version
# of the function you call it on, i.e. a version
# of your function which automatically caches return
# values and automatically uses those cached values
# without further coding from you.
# $func is the function (closure or other callable) that
# you want to memoize
$memoize = function($func) use ($get_value, $store_value)
{
# We're returning a memoized function
return function() use ($func, $get_value, $store_value)
{
# Get the parameters you (the end user) call
# your memoized function with
$params = func_get_args();
# Get a unique hash of those parameters, to
# use as our cache's key. We needs to convert
# the params array to a string first, we use
# json_encode rather than serialize here as
# it is a lot faster in most cases
$hash = sha1( json_encode( $params ) );
# Check the cache for any return value that
# has already been cached for that particular
# set of input parameters (as identified by
# its hash)
$value = $get_value($hash);
# If there was no pre-cached version available,
# $value will be null. We check this with the ??
# null coalescing operator, returning either :
# a) the cached $value if it's not null, or
# b) the results of actually calling the user
# function. Note that we wrap the call in the
# $store_value function to cache the results,
# and $store_value passes the value back
# through as its result and so it is also
# returned to the user in this case
return $value ?? $store_value(
$hash, call_user_func_array($func, $params)
);
};
};
Listing 5-3.
memoize.php
首先,memoize 函数通过将输入参数编码成 JSON 来制作输入参数的惟一字符串表示。例如,如果您想知道为什么不简单地使用implode("|", $params),请考虑以下两个函数调用:
func("Hello","|There");
func("Hello|","There");
这将导致两者都被编码为Hello||There,因此当它们实际上不同时,被视为相同的参数集。如果你能保证这个字符不会出现在你的参数中,你可以使用带有粘合字符的implode,但是为了以防万一,通常编写防御性代码并使用适当的序列化函数是个好主意。您可以使用 PHP 的serialize()函数来代替json_encode,因为对于某些工作负载,它可能会更快。两者都有边缘情况,你可能想在选择之前熟悉一下,比如serialize()不能处理某些类型的对象。关于这两者的更多信息,请参见 PHP 手册。
一旦有了输入的字符串表示,就需要将它转换成适合用作文件名的另一个字符串。您的 JSON 字符串可能包含对文件名无效的字符,因此您将为它创建一个 SHA1 散列。MD5 散列的创建速度稍快,但发生散列冲突的可能性更大(为两个不同的输入生成相同的散列)。即使是 SHA1 也会发生碰撞,尽管风险通常很低。如果您肯定无法处理冲突,那么您将需要编写一些代码来解析序列化的字符串,并以一致的方式替换无效字符,等等,确保您保持在缓存介质的其他限制内(例如,写入磁盘的文件名长度)。
现在您有了自己的散列(或者其他描述输入参数的独特方式)。然后,您尝试从缓存中加载一个以 hash 作为名称的文件的内容。如果您无法读取它(通常是因为它不存在,因为这是您第一次使用这些参数进行调用),您可以使用call_user_func_array()运行 pure 函数,获取它的返回值并创建缓存文件,最后将获取的值作为返回值返回。如果您可以读取该文件,您只需将内容作为返回值返回,并跳过函数的执行。您会注意到这里没有使用任何形式的严格类型。如果你的 pure 函数的返回值是一个int(比方说),当你第一次运行 pure 函数时,你将把它写到磁盘,并把int返回给调用者。但是,在随后的运行中,您将缓存文件的内容作为一个字符串获取并返回,因此您的返回值是一个字符串。如果输入在应用中很重要,您可以将值序列化到磁盘中,并在读回时再次将其取消序列化。
现在让我们看一个例子,看看如何实际使用这个 memoize 函数。您将使用另一个经典的示例任务,一个生成斐波那契数列的算法。我使用它是因为它是一个简单易懂的函数,而且是递归的。记忆化对任何函数都有效,不管是递归的还是非递归的,但它通常特别有用,因为递归函数经常会占用大量资源,正如您前面看到的那样。如果你不熟悉斐波那契数列,它是一个数列,其中前两个(或者三个,如果你从零开始)后面的每个数字都是前面两个数字的和,所以:
0,1,1,2,3,5,8,13,21,34,55,89,144,233,377,610,987,1597,2584,4181,6765,10946 等等…
该算法取一个整数 n,并计算序列中的第 n 个数。因此,$fibonacci(7)将返回 13 (13 是前面序列中的第 7 个数字,从 0 开始)。
您将创建两个函数:一个标准版本的函数和一个包装在早期的$memoize函数中的函数。通常你只需要创建一个函数并把它包装在$memoize中。然而,因为我想演示一个递归版本,它递归地调用有记忆的版本(并与无记忆的形式进行对比),所以您将在这里创建两个。由于斐波那契对于现代电脑来说并不是一项特别繁重的任务,你会以usleep语句的形式添加一些人为的“费用”来使每次计算花费更长的时间。这将展示记忆化对真正长期运行的函数的影响。参见清单 5-4 和清单 5-5 。
<?php
# Get our memoize function and helpers
require('memoize.php');
# Define a plain old recursive fibonacci function
$fibonacci =
function ($n) use (&$fibonacci) {
usleep(100000); # make this time-expensive!
return ($n < 2) ? $n : $fibonacci($n - 1) + $fibonacci($n - 2);
};
# Define the same fibonacci function again in exactly the
# same way (except for the name), but this time wrap the
# function body in a call to $memoize to get a memoized version
$memo_fibonacci = $memoize(
function ($n) use (&$memo_fibonacci) {
usleep(100000);
return ($n < 2) ? $n : $memo_fibonacci($n - 1) + $memo_fibonacci($n - 2);
}
);
# Let's define a timer function, to time a run of a function,
# and return the parameters, results and timings.
$timer = function($func, $params) {
$start_time = microtime(true);
$results = call_user_func_array($func, $params);
$time_taken = round(microtime(true) - $start_time, 2);
return [ "Param" => implode($params),
"Result" => $results,
"Time" => $time_taken ];
};
# And now let's do a set of runs of both our
# ordinary function and it's memoized sister.
# I've added an extra * parameter to the
# non-memoized runs so that you can spot them
# easier in the output (the '*' isn't used
# by the fibonacci functions, it's just passed
# through to the output of the timer function)
print_r( $timer( $fibonacci, [6, '*'] ) );
print_r( $timer( $memo_fibonacci, [6] ) );
print_r( $timer( $fibonacci, [6, '*'] ) );
print_r( $timer( $memo_fibonacci, [6] ) );
print_r( $timer( $memo_fibonacci, [10] ) );
print_r( $timer( $memo_fibonacci, [11] ) );
print_r( $timer( $memo_fibonacci, [8] ) );
Listing 5-4.
memo_example.php
Array
(
[Param] => 6*
[Result] => 8
[Time] => 2.5
)
Array
(
[Param] => 6
[Result] => 8
[Time] => 0.7
)
Array
(
[Param] => 6*
[Result] => 8
[Time] => 2.5
)
Array
(
[Param] => 6
[Result] => 8
[Time] => 0
)
Array
(
[Param] => 10
[Result] => 55
[Time] => 0.4
)
Array
(
[Param] => 11
[Result] => 89
[Time] => 0.1
)
Array
(
[Param] => 8
[Result] => 21
[Time] => 0
)
Listing 5-5.memo_example-output.txt
如果您查看清单 5-5 中第一次运行的输出,您会发现标准函数计算第 6 个斐波那契数需要 2.5 秒,而记忆化版本只需要 0.7 秒。当然,它们在第一次运行时应该是相同的,因为还没有任何东西被缓存。因为你的函数是递归的,你实际上在每次计算中多次调用这个函数,当你的内存化版本多次调用相同的参数时,你的缓存将被使用。
第三次运行演示了用参数 6 再次调用标准函数仍然需要 2.5 秒,这是显而易见的,因为它没有缓存。但是,在 6 上调用 memoized 版本需要 0 秒(四舍五入!)因为在计算中每次递归调用都会命中缓存。
接下来计算第 10 个数,你只需要 0.4 秒。这比计算第 6 个数字要快,因为它们共享一些步骤(每个步骤都需要计算第 1、第 2、第 3 等数字),这些步骤已经被缓存,第 10 个数字只需要实际计算第 7、第 8、第 9 和最后第 10 个数字。下一次运行进一步证明了这一点;计算第 11 个数字现在只需要 0.1 秒(因为它只有一个对函数的未缓存调用),计算第 8 个数字的最后一次运行在 0 秒内,因为它已经在您生成第 10 个数字时的缓存中。
如果您第二次调用该脚本,您会发现所有使用 memoized 函数的运行都在 0 秒内完成,因为您的缓存已经为所有需要的值准备好了,因为您之前至少生成过一次这些值。除非有人改变数学的基本原理,否则您可以永久保持缓存不变,因为对于给定的输入,缓存的结果总是正确的。如果您想知道缓存是什么样子,运行更多的/tmp/memo-cache-*会给出清单 5-6 中的输出。如您所见,有 12 个文件,这是有意义的,因为您计算了第 11 个斐波那契数(从 0 开始计数),因此调用了具有 12 个不同参数的 memoized 函数。
::::::::::::::
/tmp/memo-cache-10ae24979c5028fa873651bca338152dc0484245
5
::::::::::::::
/tmp/memo-cache-1184f5b8d4b6dd08709cf1513f26744167065e0d
0
::::::::::::::
/tmp/memo-cache-1fb0856518ee0490ff78e43d1b6dae12ad6ec686
21
::::::::::::::
/tmp/memo-cache-2499831338ca5dc8c44f3d063e076799bea9bdff
1
::::::::::::::
/tmp/memo-cache-3ad009a144b1e8e065a75ca775c76b2fc2e5ff76
89
::::::::::::::
/tmp/memo-cache-4a0a63ce33cc030f270c607ea7bf90a6717572bb
8
::::::::::::::
/tmp/memo-cache-7a60554107407bfe358bedce2bfcb95c90a8ea0d
34
::::::::::::::
/tmp/memo-cache-8f4e345e7cd51e4e633816f5a52a47df465da189
3
::::::::::::::
/tmp/memo-cache-bd703dc0b11593277a5a82dd893f2880b8d0f32a
13
::::::::::::::
/tmp/memo-cache-e9310b0c165be166c43d717718981dd6c9379fbe
55
::::::::::::::
/tmp/memo-cache-f1e31df9806ce94c5bdbbfff9608324930f4d3f1
2
::::::::::::::
/tmp/memo-cache-f629ae44b7b3dcfed444d363e626edf411ec69a8
1
Listing 5-6.
cache_files.txt
在这些例子中,您缓存到磁盘,这允许您创建一个持久的缓存,它可以在重新启动后继续存在,并由多个进程使用。但是,有时磁盘太慢,如果您的函数参数经常变化,您可能只想在单个脚本运行期间进行缓存。另一种方法是在内存中进行缓存,事实上 PHP 提供了一种创建变量的方法,这些变量的行为类似于全局变量,但仅限于给定的函数,非常适合在一次脚本运行中进行缓存。这些被称为静态变量,如果你不熟悉它们,清单 5-7 (和清单 5-8 )是静态变量($sta)的一个例子,与全局变量($glo)、参数变量($par)和普通函数范围变量($nor)相比。
<?php
$my_func = function ($par) {
static $sta;
global $glo;
var_dump( "static : ". $sta += 1 );
var_dump( "global : ". $glo += 1 );
var_dump( "param : ". $par += 1 );
var_dump( "normal : ". $nor += 1 );
return $sta;
};
while ( $my_func(1) < 5) { echo "-----\n"; };
echo "*****\n";
var_dump( "static : ". $sta );
var_dump( "global : ". $glo );
var_dump( "param : ". $par );
var_dump( "normal : ". $nor );
Listing 5-7.
static.php
string(10) "static : 1"
string(10) "global : 1"
string(10) "param : 2"
string(10) "normal : 1"
-----
string(10) "static : 2"
string(10) "global : 2"
string(10) "param : 2"
string(10) "normal : 1"
-----
string(10) "static : 3"
string(10) "global : 3"
string(10) "param : 2"
string(10) "normal : 1"
-----
string(10) "static : 4"
string(10) "global : 4"
string(10) "param : 2"
string(10) "normal : 1"
-----
string(10) "static : 5"
string(10) "global : 5"
string(10) "param : 2"
string(10) "normal : 1"
*****
string(9) "static : "
string(10) "global : 5"
string(9) "param : "
string(9) "normal : "
Listing 5-8.static-output.txt
如你所见,即使你每次都用相同的参数(1)调用my_func,但是$sta的值每次都不一样。因此,虽然您不能从函数之外的任何作用域访问它,但它通常仍被视为“副作用”,因为对于函数的任何特定调用,您都无法确定它将处于什么状态(在这种情况下,不知道函数已经被调用了多少次)。那么,如何在函数式程序中使用静态变量呢?答案是,小心翼翼。让我们来看一个例子(参见清单 5-9 )。您将创建一个版本的 memoize 函数,它使用一个静态数组来保存您的缓存,而不是写入磁盘。
<?php
$memoize = function($func)
{
return function() use ($func)
{
static $cache;
$params = func_get_args();
$hash = sha1( json_encode( $params ) );
$cache["$hash"] = $cache["$hash"] ??
call_user_func_array($func, $params);
return $cache["$hash"];
};
};
Listing 5-9.
memoize-mem.php
所以,你放入$cache数组的所有内容,以及后来从中读取的内容,都完全由你调用函数所用的参数(通过散列)决定,而你放入其中的是那个函数的值。您对静态变量的使用实际上是透明的,所以在这种情况下,您不会产生任何潜在的副作用。如果您像以前一样调用相同的memoize-example.php脚本,但是使用这个基于内存的 memoize 函数,您将得到清单 5-10 中的输出。
Array
(
[Param] => 6*
[Result] => 8
[Time] => 2.51
)
Array
(
[Param] => 6
[Result] => 8
[Time] => 0.7
)
Array
(
[Param] => 6*
[Result] => 8
[Time] => 2.51
)
Array
(
[Param] => 6
[Result] => 8
[Time] => 0
)
Array
(
[Param] => 10
[Result] => 55
[Time] => 0.4
)
Array
(
[Param] => 11
[Result] => 89
[Time] => 0.1
)
Array
(
[Param] => 8
[Result] => 21
[Time] => 0
)
Listing 5-10.memo_mem_example-output.txt
如您所见,这与基于文件的示例的输出完全相同。它实际上运行得稍微快一点,因为你不做磁盘 I/O,但你在这里四舍五入到最近的 0.1 秒。与基于磁盘的示例相比,另一个唯一的区别是,如果您第二次运行该脚本,您将再次得到这个输出(而不是对于内存化的调用都是零),因为用于缓存的静态变量在脚本结束时被销毁。
除了磁盘和基于会话的内存缓存之外,还有一种替代方案,即简单的 RAM 磁盘。在 Linux 类型的系统上,有一个名为 tmpfs 的文件系统,它允许您创建和使用存储在内存中而不是磁盘上的文件。这些虚拟文件的行为和操作就像磁盘上的普通文件一样,因此可以允许不同的 PHP 进程读写文件中的缓存数据,就像处理普通的“磁盘上”文件一样。tmpfs 带来的优势是双重的;一是快,二是一切都是暂时的。因为文件保存在内存中,没有机械硬盘等待,所以 I/O 非常快。因为它们保存在内存中,所以它们只是暂时的,如果您没有删除它们,它们会在重新启动时消失。另一个优点是,作为普通文件,它们不是 PHP 特有的技术,因此可以根据需要从其他软件访问。您可以像访问普通文件和流一样访问 tmpfs 文件系统上的文件;它们在内存中的事实对 PHP 脚本是透明的。前面的基于文件的例子在 RAM 磁盘上运行得非常好。
要在 Linux 上创建 tmpfs 文件系统,首先在磁盘上创建一个目录,用于将内存设备“连接”到您的文件系统。然后在该位置安装内存设备并开始使用。清单 5-11 中的 shell 脚本(清单 5-12 中的输出)给出了一个安装和移除 tmpfs RAM 磁盘的示例。
#!/usr/bin/env bash
mkdir /tmp/myMemoryDrive
sudo mount -t tmpfs /mnt/tmpfs /tmp/myMemoryDrive
php -r "file_put_contents('/tmp/myMemoryDrive/test.txt',\"Hello\n\");"
cat /tmp/myMemoryDrive/test.txt
sudo umount /mnt/tmpfs
cat /tmp/myMemoryDrive/test.txt
Listing 5-11.
ramdisk.sh
Hello
cat: /tmp/myMemoryDrive/test.txt: No such file or directory
Listing 5-12.ramdisk-output.txt
在清单 5-11 中,您在/tmp/myMemoryDrive处创建一个目录来连接内存设备,然后将它安装在那里。您执行一行 PHP 来演示创建一个内存文件,就像创建任何其他文件一样,然后cat这个文件,它应该输出Hello。最后,你umount了设备并试图再次cat文件,但正如你所料,文件不见了;它永远不会保存到物理磁盘上。您可以使用mount命令挂载 tmpfs 设备,如前面所示,每次启动系统时或任何您想要使用它们的时候,或者您可以在 fstab 文件中添加一个条目,以便在每次系统启动时自动创建它。无论您以何种方式安装它,当您关闭或重新启动时,请始终记住,它以及其中的所有文件都将被销毁。
由于 tmpfs 以与普通文件系统相同的方式运行,您需要确保设置了相关的文件权限,以允许您的所有应用访问它(或者防止那些不应该干预它的应用访问它)。还要记住,如果您的系统内存不足,可能会发生内存交换到磁盘的情况,因此在这些情况下,您的数据可能会暂时接触到您的硬盘,在某些情况下,之后可能会从磁盘中恢复。始终考虑您选择的任何缓存系统的安全含义。
如果出于性能原因考虑使用 tmpfs 而不是物理硬盘,您还应该记住,现代操作系统(包括现代 Linux)可以使用积极的内存缓存进行磁盘访问。这意味着操作系统透明地将经常读取的基于磁盘的文件缓存到动态分配的未使用的内存中(通常您甚至不知道),以提高明显的物理磁盘性能。在这些情况下,当从 tmpfs 内存磁盘读取一些文件并在其上遍历目录树时,您可能看不到预期的性能提高。写入磁盘和不经常访问的文件通常不会被缓存,所以在这些情况下,tmpfs 仍然可以为您带来预期的优势。
在 Windows 中,没有内置的方法来创建基于内存的文件系统。有各种各样的第三方软件来创建 RAM 磁盘,但这并不是标准化的,大多数应用需要一个 GUI 来在每个系统上手动设置磁盘。如果你仍然感兴趣的话,下面列出的维基百科页面会给你更多的提示。
进一步阅读
维基百科上的第三方 RAM 磁盘软件列表
记忆化的缺点
如你所见,通过记忆化进行缓存通常是一件好事,但正如我母亲常说的,“好东西可以有太多。”默认情况下,开始记忆所有函数的诱惑可能会悄然而至,但是和所有事情一样,首先要考虑一些权衡。您的内存化函数会有一点开销,用于在每次运行时检查缓存版本是否可用,以及获取或存储生成的任何缓存版本。如果您正在进行内存化以加速脚本的执行,并且您的缓存驻留在磁盘上,就像前面的主示例一样,那么磁盘 I/O 的额外时间(与内存存储或实际上许多纯计算函数相比,它通常很慢)可能比运行一个低到中等复杂度的函数要长。当然,如果缓存是为了优化低内存系统,减少对外部 API 的调用次数,或者最小化其他与时间无关的资源使用,这可能是一种可以接受的折衷。
在使用记忆化进行缓存时,需要考虑的另一个问题是,相对于成本而言,某些数据的短暂性是否会限制您从中获得的价值。例如,如果您的函数的参数之一是客户 ID,但是您的客户很少对您的在线商店进行一次以上的访问/购买,则该函数的任何缓存很可能只在那一次访问期间有益。与更一般的缓存情况相比,纯函数内存化的一个好处是,您永远不必担心缓存失效,因为您的缓存永远不会失效。然而,这导致了简单地忘记缓存并让它保持原样的诱惑,从编程的角度来看这是非常好的;您的代码将继续运行良好,输出正确。但是,您的系统管理员可能很快就会过来,开始询问您是否真的需要昂贵的 SAN 上的所有磁盘空间。磁盘空间的成本可能超过脚本的有限加速。在这些情况下,你有三个选择。
- 放弃记忆:接受一些运行时间更长的脚本。
- 缓存到内存或磁盘上的每个会话文件,而不是长期的磁盘存储:这加快了同一访问中的多个调用的速度,但会暂时消耗一些内存。
- 执行某种形式的缓存驱逐:删除缓存的文件,比方说,超过一个月。
懒惰评估
懒惰评估是一种艺术,只做尽可能少的工作就能得到你需要的结果。这对一个 PHP 程序员来说应该是很自然的!考虑下面的伪代码:
- if ( do_something_easy()或 do_something_hard() ) { return }
这段代码表示“如果do_something_easy()或do_something_hard()为真,则返回。”所以,为了确定你是否应该返回,你可以调用两个函数,如果其中一个返回 true,那么你就知道要返回。然而,考虑到如果do_something_easy()返回 true,那么do_something_hard()返回什么并不重要,因为在任何情况下你都会返回。所以,在运行了do_something_easy()之后,运行第二个函数调用实际上是没有意义的,您可以节省这样做的开销。相反,如果它返回 false,您将需要运行第二个函数,但是这并不比您首先自动调用这两个函数更糟糕。这叫懒评;你只评估你需要的东西,而不是更多的陈述。
PHP 在计算布尔表达式时使用一种称为短路计算的惰性计算,这取决于逻辑运算符的先例。所以,在这里你没有什么要做的,除了记下手册后面的内容,如果你在这样的表达式中调用函数,以确保你没有短路!
进一步阅读
PHP 手册:
- 逻辑运算符:
http://php.net/manual/en/language.operators.logical.php - 运算符优先级:
http://php.net/manual/en/language.operators.precedence.php
发电机
但是,您可以将这种惰性求值的概念应用到您的函数中,以加快速度。在你在前面章节看到的函数组合的例子中,你通常接受一个数据数组,对它做一些事情,把数组传递给下一个函数,做一些其他的事情,等等。即使您实际上并不需要数组中的所有数据,您通常也会传递整个数组,并对整个数组应用您的函数和转换。你看了一下array_filter,它使用一些过滤函数将数组的大小减少到特定的元素,但即使这样,过滤函数也应用于数组的每个元素。如果您只需要前 10 个匹配元素,而有 100 个匹配元素,那么在找到前 10 个元素后应用过滤函数就浪费了时间,并且您需要一个额外的步骤,比如使用array_slice将结果 100 个元素减少到 10 个。
PHP 有一个很有用的语言工具叫做生成器,它是在 PHP 5.5 中引入的。生成器允许您创建返回类似数组的函数,但其数据是在访问元素时“实时”生成的。您可以使用生成器来创建只做最少必要工作的惰性函数。
当您将生成器函数链接在一起时,执行会向后进行。考虑如下三个标准函数的伪链:
array_filter some_function();array_filter another_function();array_slice 0, 10;
首先过滤整个数组,然后再次过滤整个结果,然后第二个结果将减少到 10 项。在基于生成器的系统中,您可以像这样编写一个链:
lazy_filter some_function();lazy_filter another_function();lazy_slice 0, 10;
它看起来是一样的,但是当您执行它时,这个动作实际上从lazy_slice开始,它通过链向上拉值。slice 函数从第二个过滤器请求值,直到它有十个值。每次第二个过滤器得到一个值的请求,它从第一个过滤器请求值,并对它们应用another_function(),直到它有一个匹配。每次第一个过滤器收到一个值的请求时,它从数组中取值,并对它们应用some_function(),直到得到一个匹配。因此,当lazy_slice得到它的 10 个值时,两个lazy_filter函数调用它们的(潜在昂贵的)过滤函数的次数只够产生这 10 个值,而不一定是原始数据的所有项。
一会儿你会看到一个发电机的基本例子。但在此之前,让我们创建一个函数来重复调用一个函数。当您查看计时时,同一台 PC 上不相关的任务可能会暂时降低脚本运行速度。多次运行脚本或函数可以限制这种暂时波动对基准计时数字的影响。参见清单 5-13 。
<?php
# For benchmarking results, it's best to repeatedly run the
# function to minimize the effect of any external slowdowns.
# The following function simply calls a function $func $n times
# with arguments $args, and returns the return value of the last
# call.
$repeat = function ($func, $n, ...$args) {
for ($i=0; $i < $n; $i++) {
$result = $func(...$args);
}
return $result;
};
Listing 5-13.
repeat.php
现在让我们来看一个简单的发电机示例(参见清单 5-14 ,输出如清单 5-15 所示)。生成器是一个函数,它有一个yield语句,而不是一个return语句。与返回时会丢失状态的普通函数不同,生成的函数会保持其状态,直到下一次被调用。
PHP 有一个名为range()的本地函数,它返回一个从$start到$end的数字数组,并带有一个可选的$step值。您将创建一个生成器版本,gen_range(),它产生相同的输出,但是很慢。您将使用相同的参数调用这两个函数,以生成 1 到 1000 万之间的每四个数字,然后当您得到一个可被 123 整除的数字时,退出您正在运行的函数。
<?php
# Get our repeat function
require('repeat.php');
# PHP's native function range() takes a
# $start int, $end in and $step value, and
# returns an array of ints from $start to $end
# stepping up by $step each time. We'll create
# a generator version that takes the same
# parameters and does the same task, called gen_range()
function gen_range($start, $end, $step) {
for ($i = $start; $i <= $end; $i += $step) {
# yield turns this function into a generator
yield $i;
}
};
# We'll create a function to run either range() or
# gen_range() (as specified in $func) with the
# same paramters, and to iterate through the
# returned values until we find a number exactly
# divisible by 123 (which in this case is 369)
$run = function ($func) {
# Get a range from 1 to ten million in steps of 4,
# so 1,4,9,13,18,...,9999989,9999993,9999997
foreach ( $func(1, 10000000, 4) as $n ) {
if ($n % 123 == 0) {
# exit the function once we've found one, reporting
# back the memory in use (as it will be freed once
# we have returned).
return memory_get_usage();
};
};
};
# A function to get the time/memory use for the runs
$profile = function ($func, ...$args) {
$start = ["mem" => memory_get_usage(), "time" => microtime(true)];
$end = ["mem" => $func(...$args), "time" => microtime(true)];
return [
"Memory" => $end["mem"] - $start["mem"],
"Time" => $end["time"] - $start["time"]
];
};
# Finally let's run each of range() and gen_range() 100 times,
# and output the time taken for each and memory used
Echo "*** range() ***\n";
print_r ( $profile($repeat, $run, 100, 'range') );
Echo "*** gen_range() ***\n";
print_r ( $profile($repeat, $run, 100, 'gen_range') );
Listing 5-14.
generators.php
*** range() ***
Array
(
[Memory] => 134222280
[Time] => 8.9564578533173
)
*** gen_range() ***
Array
(
[Memory] => 4952
[Time] => 0.0016660690307617
)
Listing 5-15.generators-output.txt
所以,你可以看到,懒惰版本使用的内存量比普通的range()函数少得多。这是因为在用foreach开始遍历它们之前,range()必须生成整个值数组,而gen_range()只保存序列中的当前值。gen_range()花费的时间也少得多,因为一旦你达到 369,你就完成了,而range()甚至在你开始之前就必须生成序列中的每一个值。
请注意,使用的内存是当$run函数返回时memory_get_usage返回的值,对于您的函数来说,这可能是每个函数中使用的最高内存量。
这就是发电机的样子。现在让我们看看如何在函数组合中使用它们,以最小化函数链所要做的工作量。您将创建一个脚本,它获取名副其实的(公共领域)莎士比亚全集(以纯文本文件的形式),获取提到单词 hero 的行,获取长度超过 60 个字符的行,然后返回前三个匹配项。
清单 5-16 展示了如何以一种非懒惰的方式来做这件事,输出如清单 5-17 所示。
<?php
# Borrow some functions from Chapter 3,
# and our repeat function
require('../Chapter 3/compose.php');
require('../Chapter 3/partial_generator.php');
require('repeat.php');
# A helper function to fix parameters from the right,
# as we'll otherwise call partial(reverse()) a lot below.
$partial_right = function ($func, ...$params) {
return partial(reverse($func), ...$params);
};
# Get the start time, to see how long the script takes
$start_time = microtime(true);
# A function to return true if $word is in $str
# (not comprehensive, but matches a word bounded
# by non-A-Z chars, so matches "hero" but not "heroes")
$match_word = function($word, $str) {
return preg_match("/[^a-z]${word}[^a-z]/i", $str);
};
# A function to return true if $str is longer than $len chars
$longer_than = function($len, $str) {
return strlen($str) > $len;
};
# A partial function, fixing hero as the word to search for
$match_hero = partial($match_word, 'hero');
# Another partial function, picking out strings longer than 60 chars
$over_sixty = partial($longer_than, 60);
# A partial function which uses array_filter to apply $match_hero
# to all elements of an array and return only those with 'hero' in
$filter_hero = $partial_right('array_filter', $match_hero );
# Similarly, we'll filter an array with the $over_sixty function
$filter_sixty = $partial_right('array_filter', $over_sixty );
# A function to grab the first 3 elements from an array
$first_three = $partial_right('array_slice', 3, 0);
# Let's now compose the function above to create a
# function which grabs the first three long
# sentences mentioning hero.
$three_long_heros = compose(
$filter_hero,
$filter_sixty,
$first_three
);
# Finally, let's actually call our composed function 100 times
# on the contents of all_shakespeare.txt
# Note that calling file() as a parameter means that it is
# only evaluated once (and not 100 times), so the time for disk
# IO won't be a major element of our timings
$result = $repeat(
$three_long_heros,
file('all_shakespeare.txt'),
100
);
# Print out the result of the last call (which should be the
# same as all of the rest, as all of our composed functions are
# pure and are called on exactly the same input parameter)
print_r($result);
# and the time taken
echo 'Time taken : '.(microtime(true) - $start_time);
Listing 5-16.
filter.php
Array
(
[0] => Enter DON PEDRO, DON JOHN, LEONATO, FRIAR FRANCIS, CLAUDIO, BENEDICK, HERO, BEATRICE, and Attendants
[1] => Sweet Hero! She is wronged, she is slandered, she is undone.
[2] => Think you in your soul the Count Claudio hath wronged Hero?
)
Time taken : 6.2691030502319
Listing 5-17.filter-output.txt
这给了你你正在寻找的三条线,在我的弱不禁风的笔记本电脑上运行 100 次大约需要 6 秒钟。清单 5-18 以一种懒惰的方式重写了这个脚本,输出如清单 5-19 所示。
<?php
# Again we'll borrow some functions from Chapter 3,
# and our repeat function
require('../Chapter 3/compose.php');
require('../Chapter 3/partial_generator.php');
require('repeat.php');
# and start timing
$start_time = microtime(true);
# We'll now define a lazy version of array_filter, using
# a generator (note the yield statement)
$lazy_filter = function ($func, $array) {
# Loop through the array
foreach ($array as $item) {
# Call the function on the array item, and
# if it evaluates to true, return the item
if ( $func($item) ) { yield $item; }
};
};
# The following functions are exactly the same as
# in the non-lazy filter.php example
$match_word = function($word, $str) {
return preg_match("/[^a-z]${word}[^a-z]/i", $str);
};
$longer_than = function($len, $str) {
return strlen($str) > $len;
};
$match_hero = partial($match_word, 'hero');
$over_sixty = partial($longer_than, 60);
# Our $filter_hero function is almost the same,
# but note that it calls $lazy_filter instead of
# array_filter (and it uses partial() rather than
# $partial_right, as I've implemented $lazy_filter
# with the parameters in the opposite order to
# array_filter.
$filter_hero = partial($lazy_filter, $match_hero );
# Again $filter_sixty uses $lazy_filter rather than array_filter
$filter_sixty = partial($lazy_filter, $over_sixty );
# As the output from filter_sixty will be a generator object
# rather than an array, we can't use array_slice to
# get the first three items (as data doesn't exist in a
# generator until you call for it). Instead, we'll create
# a $gen_slice function which calls the generator $n times
# and returns the $n returned values as an array. We'll take
# advantage of that fact that a generator is an iterable object,
# and so has current() and next() methods to get each value.
# We'll practice our recursion, rather than just using
# a for loop!
$gen_slice = function ($n, $output = [], $generator) use (&$gen_slice) {
$output[] = $generator->current();
$generator->next();
if ($n > 1) {
$output = $gen_slice(--$n, $output, $generator);
}
return $output;
};
# $first_three uses $gen_slice rather than array_slice
$first_three = partial($gen_slice, 3, []);
# We'll compose them together, repeatedly call them
# and output the results using exactly the same
# code as in the non-lazy version
$three_long_heros = compose(
$filter_hero,
$filter_sixty,
$first_three
);
$result = $repeat( $three_long_heros, file('all_shakespeare.txt'), 100 );
print_r($result);
echo 'Time taken : '.(microtime(true) - $start_time);
Listing 5-18.
lazy_filter.php
Array
(
[0] => Enter DON PEDRO, DON JOHN, LEONATO, FRIAR FRANCIS, CLAUDIO, BENEDICK, HERO, BEATRICE, and Attendants
[1] => Sweet Hero! She is wronged, she is slandered, she is undone.
[2] => Think you in your soul the Count Claudio hath wronged Hero?
)
Time taken : 2.1842160224915
Listing 5-19.lazy_filter-output.txt
你得到了同样的结果,但是只用了微不足道的两秒钟,大约快了三倍。那么,这是如何工作的呢?嗯,你的lazy_filter不返回任何数据,而是“产生”一个生成器对象。该对象实现了 PHP 的迭代器接口,因此像foreach这样的函数自动知道如何使用它,就像它是任何其他可迭代的数据类型一样。当您使用gen_slice()函数时,这一点变得非常明显,它不是假装您正在使用一个数组,而是简单地调用生成器对象的current()和next()方法来请求接下来的三段数据。如果你不熟悉迭代器,PHP 手册的下一节将会帮你解决。
进一步阅读
- PHP 手册中的
Iterator类
顺便说一句,当我写前面的脚本时,我从compose语句开始,命名它链接在一起的三个函数,然后向后工作,找出实现它们需要什么函数。这是您在函数式编程时经常使用的模式;声明性的本质适合于程序设计的自顶向下的方法。
懒惰评估的缺点
生成器很棒,一般来说惰性评估是一个非常有用的工具。然而,正如你所料,值得注意的是,这也有不好的一面。如果您再次运行您的generators.php示例,但是这次不是寻找一个可被 123 整除的数,而是使用值 9999989,清单 5-20 和清单 5-21 显示了会发生什么。
*** range() ***
Array
(
[Memory] => 134222280
[Time] => 26.05708694458
)
*** gen_range() ***
Array
(
[Memory] => 4952
[Time] => 41.604923009872
)
Listing 5-20.generators2-output.txt
标准的range()函数需要 26 秒,但是你的懒惰的gen_range()函数几乎翻倍,达到 41 秒。为什么呢?嗯,发电机有一个固有的开销。寻找一个能被 9999989 整除的数(在这种情况下,就是它本身)意味着你必须一直走到数列的末尾才能找到它。但是你必须对序列中的每个数字调用一个函数(通过foreach),而不是对range()调用一个函数,并且每个函数调用都有少量的开销。此外,您调用的函数是由您用 PHP 编写的,而不是由整个 PHP 核心开发团队用 C 编写的,因此不太可能是高度优化的代码。因此,通常会有这样一个时刻,生成器的时间效率比首先进行完整的评估要低。它通常是最小的,并且接近评估过程的末尾,如果您的运行有一个均匀的输入值“分布”,那么您通常会在总体上领先,即使有几个确实比完整的评估方法花费更长的时间。不过,考虑您的用例,并根据真实世界的数据来分析您的代码总是值得的。
不过,也不全是坏消息。如果您看一下内存使用数据,您会发现它们与第一个示例中寻找可被 123 整除的数字完全相同。在这种情况下,如果您在内存受限的设备上工作,您可能会认为每次改变值(而不是预先生成它们)所导致的内存减少值得偶尔的额外执行时间。
并行程序设计
在漫长的写书过程中,我常常希望我的每只手都能同时写下不同的章节;那样我会以两倍的速度完成这本书。不幸的是,当我意识到我弱小的大脑一次只能记住一组单词时,我狡猾的计划受挫了。幸运的是,现代计算机不像我这样受限,可以同时执行和跟踪许多任务。计算机以各种方式做到这一点(并行计算、多任务处理、多线程、多重处理等)。),但都归结为一点:同一时间做的越多,完成事情越快。
然而,事情并不都是美好的,即使当你在同一时间做不同的事情时,现代个人电脑的智能也能让事情井井有条。资源争用、死锁、竞争条件:这些都是当多个线程或进程试图访问相同的资源(变量、数据、文件、硬件等)时发生的事情。)同时。也许像这样编程最难的部分是考虑当你的脚本在不同的路径上执行时可能发生的所有可能性。
函数式编程可以使这变得更容易。当你的程序需要做并行任务的时候,他们会剥离一些线程、子进程或者类似的来完成任务,他们往往会在线程或者进程返回的时候,把结果组合起来或者采取一些行动。如果您使用本书中介绍的功能原则编写这些任务工人,每个任务工人都可以成为一个纯函数链,其中:
- 任务只依赖于给定的输入(比如函数的参数),而不依赖于任何外部状态。
- 这项任务可以很容易地单独推理,因为它不受其他任务的影响。
这意味着你不必担心(太多)其他任务正在做什么,它们可能正在使用哪些你想要的资源,等等。当您的任务被调用时,它需要的所有东西都作为输入的一部分提供,并且它返回它的输出供父脚本处理/存储等。即使严格来说它不是一个函数,您也可以像它一样编写您的 worker 脚本,接受来自父级的输入,就像它是参数一样,并在最后像返回值一样向父级返回一个值。
PHP 并不适合并行编程,但是有许多方法可以实现并行计算,在需要时可以付诸实施。也许最简单的方法是使用 PHP 内置的进程控制函数并行启动多个 PHP 脚本来完成这项工作。让我们看一个以这种方式使用过程控制的例子。
你要创建一个程序,对莎士比亚全集做一些分析。您将创建一个以正常的线性方式进行分析的函数,以及一个生成多个“客户端”PHP 工作脚本来并行进行分析的函数。首先你会看到你的主parallel.php控制脚本,然后你会看到在并行版本中使用的client.php脚本,最后你会看到functions.php脚本,它包含了各种分析和并行化功能。您的脚本将从文本中挑选出满足特定条件的单词,对这些单词在整个文本中出现的次数进行求和,然后报告该集合中出现的前十个单词。您将重复每个函数 100 次来对它们进行基准测试。
<?php
# Get a set of functions that we'll look at shortly
require('functions.php');
# The text to work on.
$shakespeare = file_get_contents('all_shakespeare.txt');
# How many times we're going to run each function, for
# benchmarking purposes
$repeats = 100;
# Compose our single process "standard" function.
$analyze_single = compose(
$only_letters_and_spaces, # simplify the text
'strtolower', # all lowercase, please
$analyze_words, # do the analysis
$sort_results, # sort the results
'array_reverse', # get the results in descending order
$top_ten # return the top ten results
);
# Run the single process version $repeats time on $shakespeare input
# Time the runs
$checkpoint1 = microtime(true);
print_r( $repeat($analyze_single, $repeats, $shakespeare) );
$checkpoint2 = microtime(true);
# Now create a parallel process version
$analyze_parallel = compose (
$launch_clients, # Launch a set of client processes to do
# the analysis
$report_clients, # Tell us how many clients were launched
$get_results, # Get the results back from the clients
$combine_results, # Combine their results into one set
$sort_results, # sort the combined results
'array_reverse', # get the results in descending order
$top_ten # return the top ten results
);
# Run the parallel version and time it
$checkpoint3 = microtime(true);
print_r ( $repeat($analyze_parallel, $repeats, $shakespeare) );
$checkpoint4 = microtime(true);
# Finally, dump the timings for comparison
var_dump( 'Single : '.($checkpoint2 - $checkpoint1));
var_dump( 'Parallel : '.($checkpoint4- $checkpoint3));
Listing 5-21.
parallel.php
在$analyse_parallel组合中,$launch_clients函数将并行启动清单 5-22 中脚本的多次运行。
<?php
require('functions.php');
# Get the chunk of text for the client to analyze
# by reading the contents of STDIN which are piped to
# this script by the fwrite($clients[$key]["pipes"][0], $string)
# line in the $launch_clients function in the parent process
$string = stream_get_contents(STDIN);
# Compose a function to do the analysis. This is the same
# as the first three steps of the single process analysis
# function, with a step to encode the results as JSON at
# the end so we can safely pass them back
$client_analyze = compose(
$only_letters_and_spaces,
'strtolower',
$analyze_words,
'json_encode'
);
# Run the function and write the results to STDOUT,
# which will be read by the stream_get_contents($client["pipes"][1])
# line in the $get_results function in the parent process. In most cases
# you can use echo to write to STDOUT, but sometimes it can be
# redirected, and so explicitly writing like this is better practice
fwrite(STDOUT, $client_analyze($string) );
Listing 5-22.
client.php
最后,清单 5-23 显示了functions.php脚本,它实现了您在前面的脚本中编写的所有功能。我把它们分开是为了让脚本更容易阅读,同时也是因为两个脚本都可以访问它们。
<?php
# Borrow some utility functions from previous examples
require('../Chapter 3/compose.php');
require('repeat.php');
# To simplify our analysis, replace anything that's not
# a letter with a space.
$only_letters_and_spaces = function($string) {
return preg_replace('/[^A-Za-z]+/', ' ', $string);
};
# This is the "expensive" deliberately un-optimized function
# that does our "analysis".
$analyze_words = function ($string) {
# Split our text into an array, one word per element
$array = preg_split('/ /i', $string, -1, PREG_SPLIT_NO_EMPTY);
# Filter our array for words that...
$filtered = array_filter($array, function ($word) {
return (
# ... contain any of the letters from the word shakespeare
preg_match('/[shakespeare]/', $word) != false)
# ... AND has at least 1 character in common with this sentence
&& (similar_text($word, 'William is the best bard bar none') > 1)
# ... AND sound like the word "bard"
&& (metaphone($word) == metaphone('bard'))
# ... AND have more than three characters in them
&& ( (strlen($word) > 3 )
);
});
# Finally, count up the number of times each of the filtered
# words appears in the analyzed text, and return that
return array_count_values($filtered);
};
# Slice the top 10 items off the top of the array
$top_ten = function ($array) {
return array_slice($array, 0 ,10);
};
# Sort the results numerically
# asort mutates the array, so we wrap it in a function
$sort_results = function($array) {
asort($array, SORT_NUMERIC);
return $array;
};
# The following functions manage the execution of parallel client scripts
# A function to split the text into chunks and launch the
# appropriate number of clients to process it
$launch_clients = function ($string) {
# Split the string into chunks of 1 million characters,
# a value which I found by trial and error to give the
# best results on this machine for this process
$strings = str_split($string, 1000000);
# An array to hold the resource identifiers for the client scripts
$clients = [];
# Descriptors for "pipes" to read/write the data to/from our client
# scripts
$descriptors = [
0 => ["pipe", "r"], #STDIN, to get data
1 => ["pipe", "w"] #STDOUT, to send data
];
# Iterate through the chunks...
foreach ($strings as $key => $string) {
# $key will be the array index, 0, 1, 2, 3... etc.
# We'll use it as a handy way to number our clients
# Define the command that runs the client
$command = "php client.php";
# Open the clients with proc_open. This returns a resource identifier.
# We'll store it, although our script won't actually use it.
$clients[$key]["resource"] = proc_open( $command,
$descriptors,
$clients[$key]["pipes"]
);
# Note the third parameter above is a variable passed by reference.
# This is used by proc_open to store an array of file pointers
# identifying PHP's end of the pipes that are created.
# We use that info here to write our text chunk to. This writes
# it to STDOUT, and our client script reads it in through STDIN
# at its end of the pipe.
fwrite($clients[$key]["pipes"][0], $string);
# Close the pipe now we're done writing to this client.
fclose($clients[$key]["pipes"][0]);
};
# Once all of the clients have been launched, return their
# resource identifiers and pipe details
return $clients;
};
# Simple impure function to report how many clients were
# launched. You could use a writer monad instead if you wanted
$report_clients = function ($clients) {
# The escape code at the end minimizes our output when
# when running the script many times, by going up one line
# and overwriting the output each time.
echo("Launched ".sizeof($clients)." clients\n\033[1A");
return $clients;
};
# A function to get the results back from the clients.
# The clients will send a JSON encoded array back to us
$get_results = function ($clients) {
# An array to gather the results. Each clients' result
# will be stored as an element of the array
$results = [];
# Iterate through the client resource identifiers
foreach ($clients as $key => $client) {
# Clients write output to STDOUT, which corresponds to the
# STDIN Pipe at our end. We'll read that JSON data and
# decode it to a PHP array. Each client's results will be
# stored as a separate element of the $results array.
$results[] = json_decode(
stream_get_contents($client["pipes"][1]),
true);
# We've done reading from the client, so we can close the pipe.
fclose($clients[$key]["pipes"][1]);
};
# And finally return all of the results from all of the clients
return $results;
};
# This function takes the results array from $get_results above and
# combines it into a single array
$combine_results = function ($results) {
# Reduce and return the input array by...
return array_reduce($results, function($output, $array) {
#... iterating over each individual clients results array
# and either creating or adding the count for each word to
# the output depending on whether that word already exists in
# the output
foreach ($array as $word => $count) {
isset($output[$word]) ?
$output[$word] += $count :
$output[$word] = $count ;
}
# return $output through to the next iteration of array_reduce
return $output;
}, []); # starting with a blank array [] as output
};
Listing 5-23.
functions.php
让我们运行parallel.php看看会发生什么(参见清单 5-24 )。
Array
(
[beard] => 76
[bright] => 43
[buried] => 43
[bred] => 36
[breed] => 35
[bird] => 34
[bride] => 30
[broad] => 15
[bread] => 15
[board] => 15
)
Launched 4 clients
AArray
(
[beard] => 76
[bright] => 43
[buried] => 43
[bred] => 36
[breed] => 35
[bird] => 34
[bride] => 30
[broad] => 15
[bread] => 15
[board] => 15
)
string(24) "Single : 48.808692932129"
string(25) "Parallel : 25.10250711441"
Listing 5-24.parallel-output.txt
正如您所看到的,您从分析的单个过程和并行过程版本中获得了相同的结果,但是并行版本花费了大约一半的时间来执行。像这样对文本进行分块,可以让四个客户端进程并行地分析所有文本。考虑到该函数的两个版本使用了完全相同的昂贵函数($analyze_words),您可能会奇怪为什么在四个客户端的情况下,它没有在四分之一的时间内完成。原因是并行运行时有大量的设置工作要做,包括:
- 将文本分成几大块
- 启动新的 PHP 进程
- 写入和读取过程管道
- 最后将结果组合在一起
因此,如果您想进一步加快速度,难道不能简单地并行启动更多的客户端吗?让我们试一试,将文本分成 100,000 个字符的块,这需要 38 个客户端并行计算(参见清单 5-25 )。
Array
(
[beard] => 76
[bright] => 43
[buried] => 43
[bred] => 36
[breed] => 35
[bird] => 34
[bride] => 30
[broad] => 15
[bread] => 15
[board] => 15
)
Launched 38 clients
Array
(
[beard] => 76
[bright] => 43
[buried] => 43
[bred] => 36
[breed] => 35
[bird] => 34
[bride] => 30
[broad] => 15
[bread] => 15
[board] => 15
)
string(24) "Single : 49.230798959732"
string(26) "Parallel : 145.74519586563"
Listing 5-25.parallel-output2.txt
在这种情况下,您的速度从两倍增加到将近三倍长!这也是因为协调所有客户端并将结果汇集在一起的开销。因此,使用这种技术,在给出最大结果的并行进程的数量上,通常有一个最佳点。这在很大程度上取决于手头的任务,对于具有以下特征的函数,您可能会获得更好的结果:
- 结果不需要大量后处理的函数(例如,来自不同客户端的结果的顺序或内容无关紧要)
- 设置成本低廉的功能(例如,拆分输入数据的最少处理,向客户端传输的最少数据)
- 运行时间较长的函数(与函数执行时间相比,时间开销最小)
如您所见,如果没有大量额外的代码来管理并行化,速度就不会提高。在进入代码并行化阶段之前,您可以做许多事情来加快执行速度,包括:
- 使用惰性评估,首先对单词进行计数和排序(廉价操作),然后将分析作为生成器函数的一部分进行应用
- 重新排序
array_filter中的操作,以利用 PHP 的惰性求值,在调用更昂贵的preg_match之前,先用便宜的函数如strlen来缩减数据 - 预先计算
metaphone('bard')并存储在变量中,而不是每次都计算 - 用更便宜的
strpbrkPHP 函数替换preg_match
如果这还不足以达到您的性能目标,并且您需要进行并行,那么您可以做一些其他的事情来加速并行版本(为了保持代码简单并节省本书的篇幅,我没有这样做)。
- 在每个脚本中只包含您需要的函数,也许使用一个构建步骤来内联它们。
- 直接在共享内存中传递数据,而不是通过管道,这样会更快。
- 不要等到每个客户端都发送了数据之后才继续从下一个客户端读取数据,要以非阻塞的方式反复循环,直到每个客户端都为您准备好了数据。
对于并行脚本来说,惰性评估可能很困难,因为每个脚本都按照适合其本地输入的顺序返回数据,而不一定代表整个数据。例如,使用这个脚本,每个客户端都可以计算自己的最佳结果,但是您不能只接受收到的前十个结果,因为它们可能不是整个莎士比亚作品的前十个结果,而仅仅是那些首先被分析和返回的数据块。正如您所看到的,并行化工作需要一些思考,即使函数式编程通过消除考虑副作用的额外负担来帮助您。也考虑到我甚至还没有谈到如果你的一个客户没有完成或者挂起该怎么办,你就会明白为什么你应该只在真正必要的时候才考虑这种技术。
多线程编程
多线程编程的工作方式类似于您在上一节中看到的多进程示例。关键的区别在于并行执行发生在同一个进程中,而不是在不同的进程中。PHP 不是多线程的;但是,使用 Pthreads 扩展可以实现多线程。Pthreads 是一个健壮的基于 OOP 的实现,性能比多进程脚本要好得多;然而,由于共存于同一进程中的线程的性质,它比多进程代码实现起来更复杂。还要注意,Pthreads 扩展只能用于 PHP 的“线程安全”版本,这与许多 PHP 扩展不兼容。Linux 上的大多数包管理器不包含线程安全版本,因此需要您手动编译 PHP(如果您想了解自己编译 PHP 的信息,请参见附录 A),或者对于 Windows,您需要从 PHP 网站下载线程安全的可执行文件。
尽管如此,采用前面介绍的函数式编程原则将有助于您避开多线程编程中常见的一些问题。关于扩展和使用示例的更多信息可以在 Pthreads 网站上找到。
进一步阅读
- Pthreads 网址:
http://pthreads.org/ - PHP 手册中的 Pthreads 部分:
http://php.net/manual/en/book.pthreads.php
标准 PHP 库(SPL)
在这一章的开始,我讨论了一个事实,即 PHP 的一些明显的性能问题是由于为用户提供易于使用和通用的数据结构和函数所必需的开销。如果您发现这种开销开始限制您的脚本,那么标准 PHP 库(SPL)是一个核心 PHP 扩展,包含常见和深奥的数据结构和函数。这些都是为解决常见的编程问题而设计的,尽管比 PHP 更常见的结构(如普通的 PHP 数组类型)需要更多的思考。在 SPL 中没有什么是函数式编程独有的,而是有一些有用的函数和结构可以用在你在本书中看到的函数式技术中。
因此,举例来说,如果您发现传递大型数据数组会导致您的脚本达到内存极限,那么您可能希望查看一下SplFixedArray类。它有一些限制(您只能使用整数作为索引,并且必须预先指定数组的长度),但它提供了一个比普通数组使用更少内存的更快的实现。如果您不熟悉 SPL 中的一些数据结构(如堆、链表等)。),那么最基本的计算机科学入门(或者用更传统的语言编程)应该能帮到你。SPL 还包含用于常见的基于迭代器的任务的函数和类,您可以将这些函数和类与您之前看到的生成器一起使用。
清单 5-26 中的示例脚本让您领略了iterator_to_array函数、SplFixedArray结构和FilterIterator类。
<?php
# Borrow our simple generator example
function gen_range($start, $end, $step) {
for ($i = $start; $i <= $end; $i += $step) {
yield $i;
}
};
# Call the generator...
$gen_obj = gen_range(1,10,1);
# ... and check what we have is a generator object
print_r($gen_obj);
# Generators are iterators, so when we need a full array
# of data instead of a generator, we can convert
# it to an array using SPL's iterator_to_array function
$array = iterator_to_array($gen_obj);
print_r($array);
# An SplFixedArray is SPLs fixed size array data structure.
# Let's create an empty SPL fixed array and a standard PHP array.
# Note we need to specify a size for the SPL array
$spl_array = new SplFixedArray(10000);
$std_array = [];
# Let's create a function to fill an array with data. As both
# array types can be written to in the same way, we can
# use the same function here for both
$fill_array = function($array, $i = 0) use (&$fill_array) {
# recursively fill the $array with data
if ($i < 10000) {
$array[$i] = $i * 2;
return $fill_array($array, ++$i);
};
return ($array);
};
# Let's do some operations with the arrays. We'll measure
# the memory in use before and after each operation.
$mem1 = memory_get_usage();
# Fill the standard array with data
$std_array = $fill_array($std_array);
$mem2 = memory_get_usage(); # 528384 bytes
# Fill the SPL array with data
$spl_array = $fill_array($spl_array);
$mem3 = memory_get_usage(); # 0 bytes
# It took no memory to fill!
# This is because this type of array allocates all of its memory
# up-front when you create it
# Create a new SPL array and fill with data
$spl_array2 = new SplFixedArray(10000);
$spl_array2 = $fill_array($spl_array2);
$mem4 = memory_get_usage(); # 163968 bytes
# This time it did, as we declared it within the section we
# were measuring
# Create a new empty standard array
$std_array2 = [];
$mem5 = memory_get_usage(); # 56 bytes - a small amount
# Create a new empty SPL array
$spl_array3 = new SplFixedArray(10000);
$mem6 = memory_get_usage(); # 163968 bytes - for an empty array!
# This shows that you need to use it with care. A Standard
# array may use more memory for the same amount of data, but
# the memory also shrinks with the array contents too.
echo "Filled Standard Array : ".($mem2 - $mem1). " bytes \n";
echo "1st Filled SPLFixedArray : ".($mem3 - $mem2). " bytes \n";
echo "2nd Filled SPLFixedArray : ".($mem4 - $mem3). " bytes \n";
echo "Empty Standard Array : ".($mem5 - $mem4). " bytes \n";
echo "Empty SPLFixedArray : ".($mem6 - $mem5). " bytes \n";
# The SPL provides various iterator classes that you can extend
# to work with iterable structures like the SPLFixedArray and
# generators
# Let's create a class to filter for values that are divisible by three
class by_three extends FilterIterator {
# We extend the FilterIterator class, and implement the accept() class
# with your filtering function
public function accept()
{
$value = $this->current();
if ($value % 3 == 0) {
# return true to include the value in the output
return true;
}
# or false to filter it out
return false;
}
};
# Let's use it to filter our previous SPL array
$nums = new by_three($spl_array);
var_dump(iterator_count($nums)); # int(3334) (∼third of the array is returned)
Listing 5-26.spl.php
SPL 中还有更多可用的类、函数和数据结构。查看 PHP 手册了解更多细节。
进一步阅读
- PHP SPL 文档
结论
在本章中,您了解了函数式编程在性能改进领域的一些常见应用。即使您没有完全用功能代码编写应用,挑选出导致瓶颈的关键功能,并根据功能原则重写它们,也可以让您将这些性能增强技术应用到这些代码部分。当然,如果你用函数式风格从头开始编写你的应用,当你发现一个有问题的函数时,应用诸如记忆化之类的技术是快速而简单的。