二、纯函数、引用透明性和不变性
那些阅读过函数式编程附录的人会发现它围绕着纯函数,或者换句话说,只使用输入产生结果的函数。
确定一个函数是否纯似乎很容易。这只是检查你是否不称之为任何全球国家,对吗?可悲的是,事情并没有那么简单。函数也有多种产生副作用的方式。其中一些很容易被发现;其他的则更难。
本章将不介绍使用函数式编程的好处。如果您对这些好处感兴趣,我建议您阅读附录,其中深入讨论了这个主题。然而,我们将讨论不变性和引用透明性所提供的优势,因为它们非常具体,在附录中被忽略了。
在本章中,我们将介绍以下主题:
- 隐藏输入和输出
- 功能纯度
- 不变性
- 参考透明度
两套输入输出
让我们从一个简单的函数开始:
<?php
function add(int $a, int $b): int
{
return $a + $b;
}
这个函数的输入和输出非常明显。我们有两个参数和一个返回值。我们可以毫无疑问地说,这个函数是纯函数。
参数和返回值是函数可以具有的第一组输入和输出。但还有第二盘,通常更难发现。请查看以下两个功能:
<?php
function nextMessage(): string
{
return array_pop($_SESSION['message']);
}
// A simple score updating method for a game
function updateScore(Player $player, int $points)
{
$score = $player->getScore();
$player->setScore($score + $points);
}
第一个函数没有明显的输入。但是,很明显,我们从$_SESSION
变量中获取一些数据来创建输出值,因此我们有一个隐藏的输入。我们在会话中还有一个隐藏的副作用,因为array_pop
方法会从消息列表中删除我们刚刚收到的消息。
第二种方法没有明显的输出。但是,更新玩家的分数显然是一个副作用。此外,我们从播放器获得的$score
可能会被视为该函数的第二个输入。
在这样简单的代码示例中,隐藏的输入和输出非常容易发现。然而,它很快变得更加困难,尤其是在面向对象的代码库中。毫无疑问,任何像这样隐藏的东西,即使是以最明显的方式,都可能产生如下后果:
- 增加认知负担。现在你必须考虑在
Session
或Player
类中发生了什么。 - 对于相同的输入参数,测试结果可能会有所不同,因为软件的某些其他状态已经改变,导致难以理解的行为。
- 函数签名或 API 不清楚您可以从函数中得到什么,因此有必要阅读它们的代码或文档。
这两个外观简单的函数的问题在于,它们需要读取和更新程序的现有状态。本章的主题还不是向您展示如何更好地编写它们,我们将在第 6 章、现实生活单子中介绍。
对于习惯于依赖项注入的读者,第一个函数使用静态调用,可以通过注入会话变量的实例来避免。这样做可以解决隐藏的输入问题,但修改$_SESSION
变量的状态仍然是一个副作用。
本章的其余部分将试图教您如何识别不纯函数,以及为什么它们对函数编程和代码质量都很重要
在本书的其余部分,我们将使用术语副作用表示隐藏输入,使用术语副作用表示隐藏输出。这种二分法并不总是被使用,但我认为它有助于我们在讨论隐藏的依赖项或我们将要讨论的代码的隐藏输出时能够更准确地描述。
尽管是一个更广泛的概念,可用的功能文献可能使用术语自由变量来指代副作用原因。维基百科关于该主题的文章陈述如下:
在计算机编程中,术语自由变量是指函数中使用的变量,这些变量既不是局部变量,也不是该函数的参数。在这种情况下,术语非局部变量通常是同义词。
根据这个定义,使用 use 关键字传递给 PHP 闭包的变量可以称为自由变量;这就是为什么我更喜欢使用“侧因”一词来明确区分两者。
纯函数
假设您有函数签名函数getCurrentTvProgram (Channel $channel
。如果没有任何关于函数纯度的指示,您就不知道这种函数背后可能隐藏的复杂性。
您可能会得到在给定频道上实际播放的节目。您不知道的是,如果您登录到系统,该功能是否已检查。也许有某种数据库更新用于分析目的。该函数可能会返回异常,因为日志文件处于只读状态。你不能确定,所有这些都是副作用或副作用。
考虑到与这些隐藏依赖项相关的所有复杂性,您面临三个选项:
- 深入了解文档或代码,以了解正在发生的一切
- 使依赖关系变得明显
- 什么也不做,祈求最好
最后一个选择显然在短期内更好,但你可能会被狠狠地咬一口。第一个选项似乎更好,但是如果您的同事也需要在应用的其他地方使用此功能,他们是否需要遵循与您相同的路径?
第二种选择可能是最困难的,在开始时需要付出巨大的努力,因为我们根本不习惯这样做。但是一旦你完成了,好处就会开始堆积起来。有了经验,事情就容易多了。
封装怎么样?
封装是关于隐藏实现细节的。纯洁就是让隐藏的依赖性为人所知。两者都是有用的、良好的实践,并且没有任何冲突。如果足够小心,您可以同时实现这两个目标,这通常是函数式程序员努力追求的目标。他们喜欢干净、简单的解决方案。
要简单地解释这一点:
- 封装是关于隐藏内部实现的
- 避免副作用的原因就是让外部输入为人所知
- 避免副作用就是让外界知道变化
发现侧面原因
让我们回到我们的getCurrentTvProgram
功能。下面的实现不纯粹,你能找出原因吗?
为了帮助您,我将告诉您,我们到目前为止对纯函数的了解意味着,当使用相同的参数调用它们时,它们总是返回相同的结果:
<?php
function getCurrentTvProgram(Channel $channel ): string
{
// let's assume that getProgramAt is a pure method.
return $channel->getProgramAt(time());
}
知道了?我们的嫌疑犯是对time()
方法的调用。因此,如果在不同的时间调用函数,将得到不同的结果。让我们解决这个问题:
<?php
functiongetTvProgram(Channel $channel, int $when): string
{
return $channel->getProgramAt($when);
}
我们的功能现在不仅是纯粹的,这本身显然是一项成就,而且我们刚刚获得了两个好处:
- 我们现在可以在一天中的任何时间获取程序,正如名称更改所暗示的那样
- 现在可以测试该函数,而无需使用某种魔术来更改当前时间
让我们快速看一下其他一些副作用的例子。在阅读之前,试着自己找出问题,作为练习:
<?php
$counter = 0;
function increment()
{
global $counter;
return ++$counter;
}
function increment2()
{
static $counter = 0;
return ++$counter;
}
function get_administrators(EntityManager $em)
{
// Let's assume $em is a Doctrine EntityManager allowing
// to perform DB queries
return $em->createQueryBuilder()
->select('u')
->from('User', 'u')
->where('u.admin = 1')
->getQuery()->getArrayResult();
}
function get_roles(User $u)
{
return array_merge($u->getRoles(), $u->getGroup()->getRoles());
}
global
关键字的使用使第一个函数使用全局范围内的一些变量变得非常明显,从而使函数变得不纯。这个例子的关键是 PHP 范围规则对我们有利。任何你能发现这个关键词的函数都很可能是不纯的。
第二个示例中的 static 关键字是一个很好的指示器,表明我们可以尝试在函数调用之间存储状态。在本例中,它是每次运行时递增的计数器。这个功能显然是不纯洁的。然而,与global
变量相反,使用static
关键字可能只是在调用之间缓存数据的一种方式,因此在得出结论之前,您必须检查为什么使用它。
第三个功能毫无疑问是不纯的,因为进行了一些数据库访问。如果只允许使用纯函数,您可能会问自己如何从数据库或用户获取数据。如果您想编写纯粹的函数式代码,第六章将深入研究这个主题。如果你不能或不想一直使用函数,我建议你尽可能地重新组合你的不纯调用,然后尝试从那里只调用纯函数,以限制你有副作用和副作用的地方。
关于第四个函数,你不能仅仅通过观察它来判断它是否是纯函数。您必须查看所调用方法的代码。这是在大多数情况下您将遇到的,一个调用其他函数和方法的函数,为了确定纯度,您还必须阅读这些函数和方法。
发现副作用
通常,发现副作用比发现副作用要容易一些。无论何时,只要您更改一个对外部有可见影响的值,或者在更改时调用另一个函数,都会产生副作用。
如果我们回到之前定义的两个increment
函数,你会怎么说?考虑下面的代码:
<?php
$counter = 0;
function increment()
{
global $counter;
return ++$counter;
}
function increment2()
{
static $counter = 0;
return ++$counter;
}
第一个显然对全局变量有副作用。但是第二个版本呢?变量本身不能从外部访问,所以我们可以认为函数没有副作用吗?答案是否定的。由于更改意味着对函数的后续调用将返回另一个值,因此这也可以作为副作用。
让我们看看一些函数,看看您是否能够发现副作用:
<?php
function set_administrator(EntityManager $em, User $u)
{
$em->createQueryBuilder()
->update('models\User', 'u')
->set('u.admin', 1)
->where('u.id = ?1')
->setParameter(1, $u->id)
->getQuery()->execute();
}
function log_message($message)
{
echo $message."\n";
}
function updatePlayers(Player $winner, Player $loser, int $score)
{
$winner->updateScore($score);
$loser->updateScore(-$score);
}
第一个函数显然有副作用,因为我们更新了数据库中的一个值。
第二种方法将某些内容打印到屏幕上。通常,这被认为是一种副作用,因为函数对其范围之外的东西(在我们的例子中是屏幕)有影响。
最后,最后一个功能可能有副作用。根据方法的名称,这是一个很好的、有根据的猜测。但是,在我们看到验证它的方法的代码之前,我们不能确定。当发现副作用的原因时,为了确定它是否会引起副作用,你通常需要比单一功能更深入地挖掘。
对象方法呢?
在纯函数式语言中,只要您需要更改对象、数组或任何类型集合中的值,实际上您将返回一个具有新值的副本。这意味着任何方法,例如updateScore
方法,都不会修改对象的内部属性,而是返回一个具有新分数的新实例。
这似乎一点也不实际,考虑到 PHP 提供的开箱即用的可能性,我同意你的看法。然而,我们将看到,有一些功能性技术确实有助于管理这一问题。
另一种选择是确定实例在更改后不是相同的值。在某种程度上,PHP 就是这样。考虑下面的例子:
<?php
class Test
{
private $value;
public function __construct($v)
{
$this->set($v);
}
public function set($v) {
$this->value = $v;
}
}
function compare($a, $b)
{
echo ($a == $b ? 'identical' : 'different')."\n";
}
$a = new Test(2);
$b = new Test(2);
compare($a, $b);
// identical
$b->set(10);
compare($a, $b);
// different
$c = clone $a;
$c->set(5);
compare($a, $c);
在两个对象之间进行简单的相等比较时,PHP 会考虑内部值,而不是实例本身来进行比较。需要注意的是,只要使用严格的比较(例如,使用===
运算符),PHP 就会验证两个变量是否持有相同的实例,并在所有三种情况下返回'different'
字符串。
然而,这与我们将在本章后面讨论的引用透明的概念是不相容的。
结束语
正如我们在前面的例子中所展示的,在一开始尝试确定一个函数是纯函数还是非纯函数可能很棘手。但是当你开始感觉到它时,你会更快更舒服。
检查函数是否纯的最佳方法是验证以下内容:
- 使用 global 关键字是一种彻底的放弃
- 检查是否使用了任何不是函数本身参数的值
- 验证您调用的所有函数也是纯函数
- 对外部存储的任何访问都是不纯净的(数据库和文件)
- 特别注意返回值取决于外部状态的函数(
time
、random
)
既然您知道了如何识别那些不纯净的函数,您可能想知道如何使它们纯净。遗憾的是,这个请求没有简单的答案。以下章节将尝试给出避免杂质的配方和模式。
不变性
我们说一个变量是不可变的,如果一旦给它赋值,你就不能改变它的内容。在函数纯度之后,这是函数编程的第二个最重要的事情。
在一些学术语言中,例如Haskell,您根本无法声明变量。一切都必须是一个函数。由于所有这些函数都是纯函数,这意味着您可以免费获得不变性。其中一些语言提供了某种类似于变量声明的语法糖,以避免总是声明函数的潜在繁琐性。
大多数函数式语言只允许声明用于相同目的的不可变变量或构造。这意味着您有一种存储值的方法,但无法在初始赋值后更改值。还有一些语言允许您为每个变量选择所需的内容。例如,在 Scala 中,var
关键字用于声明传统可变变量,而val
关键字用于声明不可变变量。
然而,与 PHP 一样,大多数语言都没有变量不变性的概念。
为什么不变性很重要?
首先,它有助于减轻认知负担。要记住算法中涉及的所有变量已经相当困难了。如果没有不变性,您还需要记住所有的值更改。对人类来说,将一个值与一个特定的标签(即变量名)相关联要容易得多。如果您可以确定该值不会改变,那么对正在发生的事情进行推理就会容易得多。
此外,如果你有一些无法摆脱的全局状态,只要它是不可变的,你就可以在你附近的一张纸上记下这些值,并将其保留以供参考。无论在执行过程中发生什么,写入的始终是程序的当前状态,这意味着您不必启动调试器或回显变量以确保值没有更改。
假设您将一个对象传递给一个函数。您不知道函数是否为纯函数,这意味着对象属性可能会更改。这会在你的头脑中引入忧虑,分散你的注意力。您必须扪心自问内部状态是否发生了变化,这降低了您对代码进行推理的能力。如果你的对象是不可变的,你可以 100%确信它和以前一模一样,加快你对正在发生的事情的理解。
您还具有线程安全和并行化方面的优势。如果您的所有状态都是不可变的,则更容易确保您的程序能够同时在多个内核或计算机上运行。大多数并发问题的发生是因为某些线程修改了一个值而没有与其他线程正确同步。这会导致它们之间的不一致,并且常常导致计算错误。如果变量是不可变的,只要所有线程都发送相同的数据,这种情况就不太可能发生。然而,这并不是很有用,因为 PHP 主要用于非线程场景。
数据共享
不变性的另一个好处是,当它由语言本身强制执行时,编译器可以执行名为数据共享的优化。由于 PHP 还不支持这一点,我将仅用几句话来介绍它。
数据共享是为包含相同数据的多个变量共享公共内存位置的过程。这允许更小的内存占用,并且几乎不需要任何成本就可以将数据从一个变量“复制”到另一个变量。
例如,想象以下代码:
<?php
//let's assume we have some big array of data
$array= ['one', 'two', 'three', '...'];
$filtered = array_filter($array, function($i) { /* [...] */ });
$beginning = array_slice($array, 0, 10);
$final = array_map(function($i) { /* [...] */ }, $array);
在 PHP 中,每个新变量都是数据的新副本。这意味着,阵列越大,内存和时间成本就越高,这可能成为问题。
函数式语言可能使用巧妙的技术,只在内存中存储一次数据,然后使用另一种方法描述每个变量包含的数据部分。这仍然需要一些计算,但对于大型结构,您将获得大量内存和时间。
这种优化也可以在非不变语言中实现。但这通常没有做到,因为您必须跟踪每个变量的每次写入访问,以确保数据一致性。编译器隐含的复杂性被认为超过了这种方法的好处。
然而,在 PHP 中,时间和内存的损失还不足以保证避免使用不变性。PHP 有一个非常好的垃圾收集器,这意味着当一个对象不再使用时,内存会被非常有效地清理。此外,我们通常使用相对较小的数据结构,这意味着创建几乎相同的数据相当快。
使用常数
可以使用常量和类常量来实现某种不变性,但它们仅适用于标量值。您目前无法将它们用于对象或更复杂的数据结构。因为这是唯一可用的开箱即用的选项,我们还是来看看吧。
可以声明包含任何标量值的全局可用常量。从 PHP5.6 开始,当使用const
关键字时,您还可以在常量中存储标量值数组,并且由于 PHP7,它还可以使用 define 语法。
常量名称必须以字母或下划线开头,而不是数字。通常,常数都是大写的,所以很容易被发现。也不鼓励以下划线开头,因为它可能与 PHP 核心定义的任何常量冲突:
<?php
define('FOO', 'something');
const BAR=42;
//this only works since PHP 5.6
const BAZ = ['one', 'two', 'three'];
// the 'define' syntax for array work since PHP 7
define('BAZ7', ['one', 'two', 'three']);
// names starting and ending with underscores are discouraged
define('__FOO__', 'possible clash');
可以使用函数的结果填充常量。但是,这只有在使用定义的语法时才可能实现。如果使用const
关键字,则必须直接使用标量值:
<?php
define('UPPERCASE', strtoupper('Hello World !'));
如果您试图访问一个不存在的常量,PHP 将假定您实际上试图将该值用作字符串:
<?php
echo UPPERCASE;
//display 'HELLO WORLD !'
echo I_DONT_EXISTS;
//PHPNotice: Use of undefined constant
I_DONT_EXISTS
//- assumed'I_DONT_EXISTS'
//display 'I_DONT_EXISTS'anyway
这可能会产生相当大的误导,因为假定的字符串将计算为true
,如果您希望常量包含false
值,则可能会破坏代码。
如果要避免这个陷阱,可以使用 defined 或 constant 函数。遗憾的是,这会增加很多冗长的内容:
<?php
echo constant('UPPERCASE');
// display 'HELLO WORLD !'
echo defined('UPPERCASE') ? 'true' : 'false';
// display 'true'
echo constant('I_DONT_EXISTS');
// PHP Warning: constant(): Couldn't find constant I_DONT_EXISTS
// display nothings as 'constant' returns 'null' in this case
echo defined('I_DONT_EXISTS') ? 'true' : 'false';
// display 'false'
PHP 还允许您在类内声明常量:
<?php
class A
{
const FOO='some value';
public static function bar()
{
echo self::FOO;
}
}
echo A::FOO;
// display 'some value'
echo constant('A::FOO');
// display 'some value'
echo defined('A::FOO') ? 'true' : 'false';
// display 'true'
A::bar();
// display 'some value'
遗憾的是,这样做时只能直接使用标量值;无法使用函数的返回值,例如define
关键字:
<?php
class A
{
const FOO=uppercase('Hello World !');
}
// This will generate an error when parsing the file :
// PHP Fatal error: Constant expression contains invalid operations
但是,从 PHP5.6 开始,您可以使用任何标量表达式或之前声明的带有const
关键字的常量:
<?php
const FOO=6;
class B
{
const BAR=FOO*7;
const BAZ="The answer is ": self::BAR;
}
除了不变性之外,常量和变量之间还有另一个基本区别。通常的范围规则不适用。声明常量后,您可以在代码中的任何位置使用该常量:
<?php
const FOO='foo';
$bar='bar';
function test()
{
// here FOO is accessible
echo FOO;
// however, if you want to access $bar, you have to use
// the 'global' keyword.
global $bar;
echo $bar;
}
在撰写本文时,PHP7.1 仍处于测试阶段。该版本计划于 2016 年秋季末发布。此新版本将引入类常量可见性修饰符:
<?php
class A
{
public const FOO='public const';
protected const BAR='protected const';
private const BAZ='private const';
}
// public constants are accessible as always
echo A::FOO;
// this will however generate an error
echo A::BAZ;
// PHP Fatal error: Uncaught Error: Cannot access private const A::BAR
最后一句警告。尽管常数是不可变的,但它们是全局的,这使它们成为应用的一种状态。任何使用常数的函数事实上都是不纯的,所以使用它们时要小心。
RFC 正在路上
正如我们刚才看到的,常数在不变性方面充其量只是一条木头腿。他们可以存储简单的信息,比如我们希望每页显示的项目数。但是,一旦您想要拥有一些复杂的数据结构,您就会陷入困境。
幸运的是,PHP 核心团队的成员非常清楚不变性很重要,目前正在 RFC 上进行一些工作,以将其包括在语言级别(https://wiki.php.net/rfc/immutability )。
对于那些不了解新 PHP 功能所涉及流程的人来说,征求意见(RFC)是核心团队成员提出的建议,旨在为 PHP 添加新内容。这个命题首先要经过一个起草阶段,在这个阶段,它被编写出来,并完成了一些示例实现。之后,有一个讨论阶段,其他人可以提供建议和建议。最后,投票决定是否在下一个 PHP 版本中包含该特性。
在撰写本文时,不可变类和属性RFC 仍处于起草阶段。无论是赞成还是反对,都没有真正的理由。只有时间会告诉我们是否接受它。
价值对象
从https://en.wikipedia.org/wiki/Value_object :
在计算机科学中,价值对象是一个小对象,表示一个简单实体,其相等性不是基于同一性:即两个价值对象具有相同的价值时相等,不一定是同一对象。
[…】
值对象应该是不可变的:这是隐式契约所必需的,即创建的两个值对象应保持相等。值对象不可变也很有用,因为客户机代码不能将值对象置于无效状态或在实例化后引入错误行为。
因为在 PHP 中没有实现真正不变性的方法,所以通常通过拥有私有属性和类上没有 setter 来实现。因此,当开发人员想要修改一个值时,他们必须创建一个新对象。该类还可以提供实用方法来简化新对象的创建。让我们看一个简短的例子:
<?php
class Message
{
private $message;
private $status;
public function __construct(string $message, string $status)
{
$this->status = $status;
$this->message = $message;
}
public function getMessage()
{
return $this->message;
}
public function getStatus()
{
return $this->status;
}
public function equals($m)
{
return $m->status === $this->status &&
$m->message === $this->message;
}
public function withStatus($status): Message
{
$new = clone $this;
$new->status = $status;
return $new;
}
}
这种模式可用于创建从数据使用者的角度来看是不变的数据实体。但是,您必须特别注意确保类上的所有方法不会破坏不变性;否则你所有的努力都将毫无意义。
除了不变性,使用值对象还有其他好处。您可以在对象内添加一些业务或域逻辑,从而将所有相关的内容保持在同一位置。此外,如果使用它们而不是数组,则可以:
- 将它们用作类型提示,而不是简单的数组
- 避免由于数组键拼写错误而导致的任何可能的错误
- 强制某些项目的存在或格式
- 提供为不同上下文设置值格式的方法
值对象的一个常见用途是存储和操作与货币相关的数据。你可以看看http://money.rtfd.org 这是一个很好的例子,说明了如何有效地使用它们。
对于一段非常重要的代码,值对象的另一个用途是PSR-7:“HTTP 消息接口”。该标准为框架和应用引入并形式化了一种以互操作方式管理 HTTP 请求和响应的方法。所有主要框架都有核心支持或插件可用。我邀请您阅读他们关于为什么应该对 PHP 生态系统中如此重要的一部分使用不变性的全部理由:http://www.php-fig.org/psr/psr-7/meta/#why-价值对象。
本质上,将 HTTP 消息建模为值对象可以确保消息状态的完整性,并防止需要双向依赖关系,这通常会导致不同步或导致调试或性能问题。
总之,值对象是在 PHP 中获得某种不变性的好方法。你并没有得到所有的好处,特别是那些与表现相关的好处,但大部分认知负担都被消除了。进一步探讨这个话题超出了本书的范围;如果你想了解更多,有一个专门的网站:http://www.phpvalueobjects.info/ 。
用于不可变集合的库
如果您想进一步了解不可变性,至少有两个库提供不可变集合:Laravel collections和immutable.php。
这两个库协调了与数组相关的 PHP 函数(如array_map
和array_filter
)的参数顺序方面的差异。它们还提供了与任何类型的Iterable
或Traversable
合作的可能性;与大多数需要真实数组的 PHP 函数相反。
本章仅简要介绍这些库。使用示例将在第 3 章、PHP中的 Functional Basis 中给出,以便它们可以与允许执行相同任务的其他库一起显示。此外,我们还没有详细介绍映射或折叠等技术,因此示例目前可能还不太清楚。
拉雷维尔收藏
Laravel 框架包含一个名为Collection
的类来取代 PHP 数组。该类在内部使用一个简单数组,但可以使用 collect helper 函数从任何类似集合的变量创建该数组。然后,它提出了许多非常有用的方法来处理数据,主要是以函数的方式。这也是 Laravel 的核心部分,因为 ORM雄辩的将数据库实体作为Collection
实例返回。
如果您不使用 Laravel,但仍然希望从这个伟大的库中获益,您可以使用https://github.com/tightenco/collect ,这只是为了保持小型而与 Laravel 支持包的其余部分分离的收集部分。您也可以参考 Laravel 收藏的官方文件(https://laravel.com/docs/5.3/collections )。
Immutable.php
这个库定义了ImmArray
类,它实现了一个类似于集合的不可变数组。
ImmArray
类是SplFixedArray
类的包装器,通过提供通常希望在集合上执行的性能操作方法来修复其 API 的一些缺点。在后台使用SplFixedArray
类的优点是,实现是用 C 编写的,而且性能和内存效率都很高。有关 Immutable.php 的更多信息,请访问 GitHub 存储库,网址为https://github.com/jkoudys/immutable.php 。
参照透明度
如果可以随时用其输出替换表达式,而不改变程序的行为,则称表达式是引用透明的。为了对代码库的所有表达式都做到这一点,所有函数都必须是纯函数,所有变量都必须是不可变的。
我们从引用透明度中获得了什么?再一次,它有助于减少认知负担。假设我们有以下函数和数据:
<?php
// The Player implementation is voluntarily simple for brevity.
// Obviously you would use immutable.php in a real project.
class Player
{
public $hp;
public $x;
public $y;
public function __construct(int $x, int $y, int $hp) {
$this->x = $x;
$this->y = $y;
$this->hp = $hp;
}
}
function isCloseEnough(Player $one, Player $two): boolean
{
return abs($one->x - $two->x) < 2 &&
abs($one->y - $two->y) < 2;
}
function loseHitpoint(Player $p): Player
{
return new Player($p->x, $p->y, $p->hp - 1);
}
function hit(Player $p, Player $target): Player
{
return isCloseEnough($p, $target) ?
loseHitpoint($target) :
$target;
}
现在让我们模拟两个人之间的一场非常简单的争吵:
<?php
$john=newPlayer(8, 8, 10);
$ted =newPlayer(7, 9, 10);
$ted=hit($john, $ted);
上面定义的所有函数都是纯函数,因为我们没有可变的数据结构,所以它们通过扩展也是引用透明的。现在,为了更好地理解我们的代码,我们可以使用一种称为等式推理的技术。这个想法很简单,你只需用equals 代替equals 就可以对代码进行推理。在某种程度上,这就像手动评估代码。
让我们从内联我们的isCloseEnough
函数开始。这样,我们的 hit 函数可以转换为:
<?php
return abs($p->x - $target->x) < 2 && abs($p->y - $target->y) < 2 ?
loseHitpoint($target) :
$target;
由于数据是不可变的,我们现在可以简单地使用以下值:
<?php
return abs(8 - 7) < 2 && abs(8 - 8) < 2 ?
loseHitpoint($target) :
$target;
让我们做一些数学:
<?php
return 1<2 && 0<2 ?
loseHitpoint($target) :
$target;
条件的计算结果显然为 true,因此我们只能保留正确的分支:
<?php
return loseHitpoint($target);
让我们继续进行剩下的函数调用:
<?php
return newPlayer($target->x, $target->y, $target->hp-1);
我们再次替换这些值:
<?php
return newPlayer(8, 7, 10-1);
最后,我们的初始函数调用变成:
<?php
$ted = newPlayer(8, 7, 9);
通过使用可以将引用透明表达式替换为其结果值的事实,我们能够通过对简单对象创建的多个函数调用来减少相对较长的代码段。
这种应用于重构或理解代码的能力非常有用。如果您在理解某些代码时遇到困难,并且您知道其中的某些部分是纯代码,那么您可以在尝试理解代码时简单地将其替换为结果。这可能会帮助你找到问题的核心。
不严格或懒惰的评价
引用透明性的一个巨大好处是编译器或解析器可以懒散地计算值。例如,Haskell 允许您拥有一个由数学函数定义的无限列表。该语言的惰性确保仅当您需要该值时才计算列表的值。
在术语表中,我们将非严格语言定义为评估延迟进行的语言。事实上,懒惰和不严格之间有着细微的区别。如果您对细节感兴趣,可以前往https://wiki.haskell.org/Lazy_vs._non-strict 并阅读相关内容。在本书中,我们将互换使用这些术语。
你可能会问自己这有什么用处。让我们来讨论一下用例。
性能
通过使用延迟计算,您可以确保只有效地计算所需的值。让我们看一个简单的例子来说明这个好处:
<?php
function wait(int $value): int
{
// let's imagine this is a function taking a while
// to compute a value
sleep(10);
return $value;
}
function do_something(bool $a, int $b, int $c): int
{
if($a) {
return $b;
} else {
return $c;
}
}
do_something(true, sleep(10), sleep(8));
由于 PHP 不会对函数参数执行延迟求值,因此在调用do_something
时,您必须先等待两次 10 秒,然后才能开始执行函数。如果 PHP 是一种非严格的语言,那么只计算我们需要的值,从而将所需时间除以 2。它变得更好,因为返回值甚至没有保存在新变量中,所以可能根本不执行该函数。
有一种情况是 PHP 执行一种惰性计算:布尔运算符短路。当您进行一系列布尔运算时,只要 PHP 能够确定结果,它就会停止执行:
<?php
// 'wait' will never get called as those operators are short- circuited
$a= (false && sleep(10));
$b = (true || sleep(10));
$c = (false and sleep(10));
$d = (true or sleep(10));
我们可以重写前面的示例来利用这一点。但正如您在下面的示例中所看到的,这是以牺牲可读性为代价的。此外,我们的示例非常简单,而不是在实际应用代码中遇到的情况。想象一下,对具有多个可能分支的复杂函数也这样做?以下代码段显示了这一点:
<?php
($a && sleep(10)) || sleep(8);
前面的代码还有两个更大的问题:
- 如果出于任何原因,第一次调用 sleep 返回假值,第二次调用也将执行
- 方法的返回值将自动转换为布尔值
代码可读性
当您的变量和函数计算是惰性的时,您可以花更少的时间考虑哪一个是最好的声明顺序,甚至考虑您正在计算的数据是否会被使用。相反,您可以专注于编写可读代码。想象一下,一个博客应用每年都有大量的帖子、标签、类别和存档。您希望为每个页面编写自定义查询,还是使用惰性求值,如下所示:
<?php
// let's imagine $blogs is a lazily evaluated collection
// containing all the blog posts of your application order by date
$posts = [ /* ... */ ];
// last 10 posts for the homepage
return $posts->reverse()->take(10);
// posts with tag 'functional php'
return $posts->filter(function($b) {
return $b->tags->contains('functional-php');
})->all();
// title of the first post from 2014 in the category 'life'
return $posts->filter(function($b) {
return $b->year == 2014;
})->filter(function($b) {
return $b->category == 'life';
})->pluck('title')->first();
要明确的是,如果我们将所有帖子加载到$posts
中,这段代码可能工作得很好,但性能会非常差。然而,如果我们有懒惰的评估和足够强大的 ORM,数据库查询可能会延迟到最后一刻。到那时,我们将准确地知道我们需要的数据,SQL 将自动为这个精确的页面定制,从而为我们提供易于阅读的代码和出色的性能。
据我所知,这个想法纯粹是假设的。我目前还不知道有哪种 ORM 强大到足以达到这种懒惰程度,即使是在大多数功能性语言中。但如果是这样,那不是很好吗?
如果您想知道示例中使用的语法,那么它的灵感来自我们前面讨论的 Laravel 集合的 API。
无限列表或流
惰性计算允许您创建无限列表。在 Haskell 中,要获得所有正整数的列表,只需执行[1..]
。然后,如果你想要前十个数字,你可以选择10 [1..]
。我承认这个例子并不令人兴奋,但更复杂的例子更难理解。
PHP 从版本 5.5 开始就支持生成器。通过使用它们,您可以实现类似于无限列表的功能。例如,我们的所有正整数列表如下所示:
<?php
function integers()
{
$i=0;
while(true) yield $i++;
}
然而,惰性无限列表和我们的生成器之间至少有一个显著的区别。例如,您可以使用 Haskell 版本计算集合的长度并对其进行排序来执行通常对集合执行的任何操作。然而,我们的生成器是一个Iterator
,如果您尝试在其上使用 sayiterator_to_array
,PHP 进程很有可能会挂起,直到内存耗尽。
你怎么计算一个无限列表的长度,或者按照你的要求对它进行排序?其实很简单,;Haskell 将只计算列表值,直到有足够的数据来执行计算。假设我们有 PHP 中的条件count($list) < 10
,即使您有一个无限列表,Haskell 也会在您达到 10 时停止计算项目,因为它会在那时为比较提供答案。
代码优化
查看下一段代码,并尝试确定哪一段更快:
<?php
$array= [1, 2, 3, 4, 5, 6 /* ... */];
// version 1
for($i = 0; $i < count($array); ++$i) {
// do something with the array values
}
// version 2
$length = count($array);
for($i = 0; $i < $length; ++$i) {
// do something with the array values
}
版本 2 应该快得多。因为您只计算一次数组的长度,而在版本 1 中,PHP 必须在每次验证 for 循环的条件时计算长度。这个例子非常简单,但在某些情况下,这样的模式更难发现。如果您具有引用透明性,这并不重要。编译器可以自行执行此优化。任何引用透明的计算都可以在不改变程序结果的情况下移动。这是可能的,因为我们保证每个函数的执行不依赖于全局状态。因此,在不改变结果的情况下,移动计算以获得更好的性能是可能的。
另一个可能的改进是执行公共子表达式消除或 CSE。编译器不仅可以更自由地移动部分代码,还可以将共享公共计算的某些操作转换为使用中间值。想象一下下面的代码:
<?php
$a= $foo * $bar + $u;
$b = $foo * $bar * $v;
如果计算$foo * $bar
的成本很大,编译器可以决定使用中间值对其进行转换:
<?php
$tmp= $foo * $bar;
$a = $tmp + $u;
$b = $tmp * $v;
同样,这是一个非常简单的例子。这种优化可以在整个代码库范围内执行。
回忆录
记忆化是一种技术,在这种技术中,您可以缓存给定参数集的函数结果,这样您就不必在下次调用时再次执行它。我们将在第 8 章、性能效率中详细介绍。现在,我只想说,如果您的语言只拥有引用透明的表达式,它可以在需要时自动执行记忆。
这意味着它可以根据调用频率和各种其他参数来决定是否值得在不需要开发人员任何干预或提示的情况下自动记忆函数。
所有这些都是 PHP 吗?
如果 PHP 开发人员只能从它的一小部分优势中获益,那么为什么还要为纯函数、不变性以及最终的引用透明性而烦恼呢?
首先,与 RFC for immutability 一样,事情正朝着正确的方向发展。这意味着,最终,PHP 引擎将开始合并一些高级编译器技术。当这种情况发生时,如果您的代码库已经使用了这些功能性技术,您将获得巨大的性能提升。
其次,在我看来,这一切的主要好处是减少了认知负担。当然,适应这种新的编程风格需要一些时间。但一旦你练习了一点,你会很快发现你的代码更容易阅读和推理。由此推论,您的应用将包含更少的 bug。
最后,如果您愿意使用一些外部库,或者如果您能够处理不总是很好修饰的语法,那么您现在就可以从其他改进中获益。显然,我们无法改变 PHP 的核心来添加前面提到的编译器优化,但我们将在下面的章节中看到如何模拟引用透明性的一些好处。
总结
这一章包含了很多理论。我希望你不太介意。有必要为我们建立一个共同的词汇基础,并解释为什么概念是重要的。您现在已经很清楚什么是纯度和不变性,并且学习了一些发现不纯函数的技巧。我们还讨论了这两个属性如何导致所谓的引用透明性—好处是什么。
我们还了解到,遗憾的是,PHP 不支持大部分的本地好处。然而,关键在于使用函数式方法可以减少理解代码的认知负担,从而使代码更易于阅读。其最大的好处是,现在您的代码将更易于维护和重构,并且您可以快速发现和修复 bug。通常,纯函数也更容易测试,这也会导致更少的 bug。
现在我们已经有了充分讨论的理论基础,下一章将重点讨论帮助我们在软件中实现纯度和不变性的技术。
版权属于:月萌API www.moonapi.com,转载请注明出处