函数式 PHP(四)
原文:
zh.annas-archive.org/md5/542d15e7552f9c0cf0925a989aaf5fc0译者:飞龙
第十章:PHP 框架和 FP
现在我们已经看到了功能编程如何可以用来解决常见的编程问题,是时候在使用框架开发时应用这些技术了。本章将介绍使用一些最常见的 PHP 框架的各种方法。
在我们开始之前,有一个小免责声明。我绝对不是我们将在这里讨论的每个框架的专家。我在不同层面上都与它们一起工作过,但这并不意味着我知道有关它们的一切。因此,尽管在撰写本章时进行了研究,但我可能不会呈现最新的最佳实践。
话虽如此,在本章中我们不会写很多代码。我们主要会看看如何将现有的功能代码与框架结构进行接口化,以及如何利用各种框架功能来帮助您以功能方式编写代码。我们还将讨论每个框架在功能编程方面的利弊。
在本章中,我们将看看以下框架:
-
Symfony
-
Laravel
-
Drupal
-
WordPress
我听到一些人在背景中低声说 Drupal 和 WordPress 不是框架,而是内容管理系统。我同意你的观点,但请记住,人们正在使用它们来创建具有电子商务和其他功能的完整应用程序,因此它们在这里有其位置。
此外,CodeIgniter框架没有出现在列表中,因为我从未使用过它。但是,您可能可以使用将在这里介绍的大部分建议与任何框架一起使用,包括 CodeIgniter。
实际上,每个部分中的大部分建议在各种情境中都是有用的。这就是为什么我强烈建议您阅读关于每个框架的部分。这将使我避免重复太多。
Symfony
专注于依赖注入,Symfony 框架非常适合编写功能代码。Symfony 开发人员习惯于以一种明确定义其依赖关系的方式声明其控制器和服务。
我们可以争论整个容器的注入有点问题。在严格意义上,控制器和服务仍然可以是纯粹的,但显然认知负担会更重一些,因为您需要阅读代码才能确切知道使用了哪些依赖。
在本部分中,我们将讨论 Symfony 的哪些部分非常适合功能编程,以及您需要注意什么。我们无法覆盖所有内容,因为 Symfony 是一个非常完整的框架,具有��多组件,但它应该足以让您入门。
处理请求
原始的Request类不符合我们已经讨论过的PSR-7 HTTP消息接口。这意味着它不是不可变的,正如规范所建议的那样。但是,如果您使用的是SensioFrameworkExtraBundle框架的至少 3.0.7 版本,那么很容易获得请求的 PSR 版本。您只需要使用 Composer 安装所需的依赖项,并稍微更改您的控制器操作签名:
**composer require sensio/framework-extra-bundle
composer require symfony/psr-http-message-bridge
composer require zendframework/zend-diactoros**
您的控制器需要在其方法签名中使用新的ServerRequestInterface类,而不是更传统的Request类:
<?php
namespace AppBundle\Controller;
use Psr\Http\Message\ServerRequestInterface;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Zend\Diactoros\Response;
class DefaultController extends Controller
{
public function indexAction(ServerRequestInterface $request)
{
return new Response();
}
}
如果出于任何原因,您需要获取与 Symfony 接口兼容的Request或Response实例,您可以使用Symfony PSR-7桥接器。
如果您在控制器和服务中正确注入了您需要的依赖项,并且使用了新的符合 PSR-7 标准的 HTTP 消息,那么您现在已经准备好为 Symfony 应用程序编写功能代码了。
数据库实体
当编写完全纯函数代码时,您可能会遇到的一个挑战是数据库访问。通常,开发人员在 Symfony 中使用Doctrine,据我所知,Doctrine 目前还没有任何设施来帮助编写引用透明的代码。
在框架的上下文中使用像 IO 单子这样的东西也很麻烦,因为在函数的每个入口和出口点,你都必须封装参数或将结果转换为框架期望的格式。
我们将尝试看看如何使用各种技术来减轻这个问题。在此过程中,我们还将学习如何在使用 Doctrine 时利用Maybe类型。
可嵌入对象
虽然与函数式编程没有严格相关,但价值对象的概念可以用来实现实体的某种不可变性。这也是一个值得探索的想法,因为它本身也有一些好处。
这是我们在第二章中已经讨论过的想法,纯函数、引用透明度和不可变性。然而,我将借此机会给出一个有些不同的定义,这个定义来自领域驱动设计:
-
实体:具有与其属性无关的身份的东西。
-
价值对象:没有与其属性分开的身份的东西。
一个常见的例子是一个人有一个地址。人是一个实体,地址是一个值对象。如果你改变一个人的姓名、地址或任何其他属性,它仍然是同一个人。然而,如果你改变地址上的任何东西,它就成了完全不同的地址。
Doctrine 在可嵌入对象的名称下实现了这个想法。这个术语来自于价值对象总是与实体相关联或嵌入的事实,因为它本身没有存在的意义。你可以在官方网站上找到文档docs.doctrine-project.org/en/latest/tutorials/embeddables.html。
虽然我不建议利用这个特性,来为你拥有的每个关系实现不可变性,但我强烈建议你在设计实体时考虑可嵌入对象,并在可以使用时使用它们。这将帮助你在功能上编码和提高数据模型的质量。
避免使用 setter
如果你开始寻找关于使用 Doctrine 和大多数 ORM 的最佳实践,很有可能有一天你会发现有人建议我们避免创建 setter 方法。通常会有许多很好的理由这样做。在我们的情况下,我们只会集中在其中一个上——我们希望不可变的实体帮助我们编写纯函数式代码。
在大多数情况下,摆脱 setter 的建议解决方案将是以任务为思考方式。例如,不是在BlogPost类上有一个setState和一个setPublicationDate方法,而是有一个publish方法,该方法将依次更改这两个字段。
这是一个很好的建议,因为它允许你将大部分业务逻辑放在实体内,避免因为开发人员没有采取所有必要的步骤而使对象处于一种奇怪的状态。一个传统的具有 setter 的类可能是以下的样子:
<?php
class BlogPost
{
private $status;
private $publicationDate;
public function setStatus(string $s)
{
$this->status = $s;
}
public function setPublicationDate(DateTime $d)
{
$this->publicationDate = $d;
}
}
它可以转换为以下实现:
<?php
class BlogPost2
{
private $status;
private $publicationDate;
public function publish(DateTime $d)
{
$this->status = 'published';
$this->publicationDate = $d;
}
}
正如你所看到的,我们在原地修改值,留下了副作用。我们可能天真地认为,在publish方法中克隆当前对象并返回具有修改属性的新版本就足以获得我们方法的不可变版本;遗憾的是,这种解决方案并不起作用。
Doctrine 存储了哪些实体由其工作单元管理,克隆的实体不处于受控状态。我们可以使用一些技巧附加实体,但然后我们将处于以下两种情况之一:
-
两个实体都是受控的,这可能会导致 Doctrine 内部元数据的一致性问题
-
只有最新的实体是受控的,这意味着我们对
publish方法的调用具有将先前的实体从 Doctrine 中分离的副作用
这是一个问题的关键在于目前没有可用的 API 来在实体内部执行此操作。这就是为什么我不建议在写作时使用当前 Doctrine 版本(即版本 2.5.5)追求不可变实体。
无论如何,避免在实体上创建 setter 已经是朝着引用透明代码库的方向迈出了一大步。这也将帮助您将所有业务逻辑集中在一个地方,而不会出现实体处于无效状态的可能性。
为什么要使用不可变实体?
不用多说,让我们用一个简单的例子来演示。Doctrine 在与日期和时间相关的任何事务中使用DateTime类的实例。由于DateTime类是可变的,这可能会导致非常难以准确定位的问题。
<?php
$date = $post->getPublicationDate();
// for any reason you modify the date
$date->modify('+14 days');
var_dump($post->getPublicationDate() == $date);
// bool(true)
$entityManager->persist($post);
$entityManager->flush();
// nothing changes in the database :(
第一个问题是你在实体内部存储了对同一对象的引用。这意味着如果出于任何原因你对它进行了更改,日期也会在帖子内部发生变化。这可能是你想要的,但毫无疑问这是一个副作用。特别是如果你将$date变量返回给潜在的调用者。它怎么知道修改日期会导致修改实体呢?
第二个问题更加棘手。由于 Doctrine 使用对象标识而不是其值来确定是否发生了变化,它将不知道日期现在已经不同,将帖子保存回数据库将毫无意义。
GitHub 上有一个可用的包(github.com/VasekPurchart/Doctrine-Date-Time-Immutable-Types)来解决这个特定问题,但是,任何时候你使用可变实例而不是可嵌入的或任何其他类型的值对象,你都可能遇到类似的问题。请尽量使用不可变性。
Symfony ParamConverter
我们已经讨论了修改已经实例化的实体并将其持久化到数据库中。但是首先如何获取它们呢?SensioFrameworkExtraBundle框架包含一个名为@ParamConverter的很好的小注解,它允许我们让框架来完成这项工作,并将从数据库获取实体的副作用放在我们的代码库之外。
这里有一个小例子,以便你了解如何使用这个注解(如果你想了解更多,你可以在 Symfony 网站的官方文档中阅读:symfony.com/doc/current/bundles/SensioFrameworkExtraBundle/annotations/converters.html):
<?php
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
class PostController extends Controller
{
/**
* @Route("/blog/{id}")
* @ParamConverter("post", class="SensioBlogBundle:Post")
*/
public function showAction(Post $post)
{
// do something here
}
}
使用路由中的信息以及定义的参数转换,框架能够直接给你Post实例,或者在找不到时生成404 错误。
使用注解,你的方法不需要再执行数据库访问,因为它将直接接收数据。我们可能会争论说不纯的代码存在于其他地方,这是正确的,但 Symfony 本来就不应该是一个纯净的代码库。我们已经将不纯度从我们自己的代码中挤出去了,这对我们来说是最重要的。
在这种特殊情况下,类型提示已经足够与路由相关。ParamConverter注解将在函数签名引用实体类时自动进入操作。如果你觉得更清晰,保留这个注解也没有坏处,或者你可以决定只在更复杂的情况下使用它。
显然会有情况下这种机制不够强大。我知道一些包提供了类似的功能,更加灵活;你可能能够找到一个适合你需求的包。如果其他方法都不起作用,你仍然可以自己执行查询,或者使用 IO 单子来代替你执行查询。
也许有一个实体
Doctrine 可以很容易地适应返回 Collection 和 Maybe 单子的实例。第一步是创建一个新的存储库:
<?php
use Widmogrod\Monad\Maybe as m;
use Widmogrod\Monad\Collection;
class FunctionalEntityRepository extends EntityRepository
{
public function find($id, $lockMode = null, $lockVersion = null)
{
return m\maybeNull(parent::find($id, $lockMode, $lockVersion));
}
public function findOneBy(array $criteria, array $orderBy = null)
{
return m\maybeNull(parent::findOneBy($criteria, $orderBy));
}
public function findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
{
return Collection::of(parent::findBy($criteria, $orderBy, $limit, $offset));
}
public function findAll()
{
return Collection::of(parent::findAll());
}
}
然后,您需要配置 Symfony 以使用这个新类作为默认存储库;这可以通过将以下键添加到您的 YAML 配置文件中轻松完成:
doctrine:
orm:
entity_managers:
default_em:
default_repository_class: MyBundly\MyNamespace\FunctionalEntityRepository
如果您不使用 Symfony,可以在 Doctrine 的Configuration类实例上使用setDefaultRepositoryClassName方法来实现相同的效果。
根据我们讨论的有关 Doctrine 的一切,当涉及到数据库时,您将无法拥有纯粹的功能性代码,但您已经准备好获得大部分的好处。
组织您的业务逻辑
官方 Symfony 最佳实践包含一些建议,指导如何以及在哪里编写业务逻辑。我们将对其进行扩展,以便更容易编写功能性代码。
第一个建议是避免在与框架本身相关的部分(路由和控制器)中编写任何逻辑。这些部分应尽可能简单明了。遵循这个建议是个好主意,因为这样做的话,如果您被迫在控制器中进行一些数据库访问,也不会那么重要。
我建议您将所有与数据库相关的操作都放在控制器内部,这样具有副作用的内容就会被隔离在那里,您的业务逻辑可以遵循适当的功能性技术。
此外,您应该只在控制器和服务中注入您需要的依赖项,而不是使用服务容器。这将大大减轻认知负担,因为您的方法和构造函数的签名足以确定业务逻辑的依赖关系。
我还建议您避免使用 setter 注入,因为调用 setter 将修改服务的状态,从而破坏不可变性,并且如果多次调用 setter 可能会导致潜在问题。
通过决定将副作用限制在控制器中,您可以集中精力在实体和服务中编写功能性代码。这将使实体和服务易于理解和测试。然后,由于控制器不应包含自己的逻辑,您可以使用集成和功能测试来测试最终部分,并迅速对应用程序获得信心。
Flash 消息、会话和其他具有副作用的 API
Flash 消息通常用于以不显眼的方式向用户传达信息。Symfony 提出的管理 API 遗憾地不是引用透明的,因为您需要在控制器上调用一个方法来将消息添加到队列中。会话数据管理也是如此。
这个问题可以通过在Response对象内部以某种方式集成它们来解决。然而,这需要在框架级别完成。这些更改要么需要上游整合,要么需要大量的维护。
一种解决方案是在服务中利用 Writer 或 State 单子来保存信息,然后在控制器中持久化它们,因为我们已经决定在控制器中处理与数据库相关的副作用。
然而,我不建议使用 IO 单子,因为在没有框架级别的支持的情况下,它将变得复杂,特别是因为 PHP 缺乏 Haskell 中可用的do notation注释的良好替代方案。这只会使您的代码变得更加复杂,而没有真正的好处。
Form API 是另一个具有大量内部状态和不纯方法的实例。然而,它足够声明性,以至于这个事实不会带来太多问题。您可以将表单创建抽象成自己的类,这也有助于考虑它是无副作用的。
我强烈建议您在可能的情况下创建Form类型,并尽可能将生成的Form实例视为不可变对象。
结束语
尽管 Symfony 在面向对象设计方面有着很强的重点,但它为我们提供了一个很好的基础来编写函数式代码。特别是在涉及数据库访问和框架提供的非函数式 API 时,需要做出一些让步,但您自己编写的代码基本上可以从头到尾都是函数式的,除了控制器本身。
使用函数式方法的一个缺点可能是您发现自己创建了更多的服务和类,以将所有副作用隔离在请求生命周期的一个单独部分中。
然而,这样做的好处是拥有一个明确定义的代码库,甚至可以在 Symfony 之外得到重复使用,如果得到适当的关注。您还将能够更容易地分开测试每个部分。
在您的应用程序的某些部分和服务中逐渐应用函数式技术也没有任何阻碍。通过能够慢慢地将您的代码迁移到更加函数式的东西,您能够立即应用这些技术,这也有助于其他人的学习曲线。您可以使用以下资源更好地理解函数式技术的应用:
Laravel
正如我们已经讨论过的,Laravel 的collection API 是一个很好的不可变数据结构的例子,它在其顶部具有很好的函数式方法。返回Collection类实例的数据库层真的有助于简化其使用。
然而,如果您想保持函数的纯净,框架提出的Facade模式是不可取的。一旦您使用一个 Facade,您就会使用一个未在函数签名中声明的外部依赖。
无论您对这种模式的看法如何,如果您想编写引用透明的代码,您必须摆脱它们。幸运的是,Laravel 为大多数常见任务提供了辅助函数和访问容器的方法。正如我们将看到的那样,因此使用不同的东西并不那么困难。
由于 Laravel 也是一个基于面向对象原则和 MVC 模式的框架,Symfony 部分的所有一般建议也适用,特别是关于解耦各个部分并尝试将副作用隔离在每个请求的一个唯一位置的建议,通常是控制器。
实现这一点的方式可能有所不同,但并不会有太大差异;这就是为什么我鼓励您阅读前一节,如果您还没有这样做的话,因为那些建议将不会在这里重复。
数据库结果
正如已经提到的,Laravel 有一个非常好的不可变集合实现。返回多个实体的所有数据库查询都将返回它的一个实例。有一本非常好的书详细介绍了您可以利用其功能的所有方法。您可以在作者的网站上找到它,以及相关的视频教程和其他教程,网址为adamwathan.me/refactoring-to-collections/。集合可能是不可变的,但其中的对象不是。由于 Laravel 的 ORM,Eloquent,与 Doctrine 非常不同,因此我们有可能使它们成为不可变的。您只需在一个Model类上扩展方法,而不是使用Repository模式和UnitOfWork模式,这意味着可以以一种使实体不可变的方式实现您的方法,而不会遇到有关 Doctrine 内部状态的问题:
<?php
use Illuminate\Database\Eloquent\Model;
class BlogPost extends Model
{
private $status;
private $publicationDate;
public function publish(DateTime $d)
{
$new = clone $this;
$new->status = 'published';
$new->publicationDate = $d;
return $new;
}
}
只要不修改用于主键的字段,对对象的save方法的任何调用都将更新数据库中的当前行。但是,如果您的对象具有非标量属性,则可能需要在对象上添加__clone方法。 PHP 默认只执行浅复制,这意味着所有引用将保持不变。这可能是您想要的,但您需要确保它。
如果您想强制某些属性的不可变性,GitHub 上有一个可用的软件包(github.com/davidmpeace/immutability)可以做到这一点。如果您严格遵守规定,则不需要,但在传统和功能部分都存在的遗留代码库中,这可能是一个不错的功能。
使用 Maybe
与 Doctrine 一样,也可以返回Maybe的实例,而不是null。由于结构有些不同,我们首先需要创建一个新的Builder类:
<?php
use Illuminate\Database\Eloquent\Builder as BaseBuilder;
use Widmogrod\Monad\Maybe as m;
class FunctionalBuilder extends BaseBuilder
{
public function first($columns = array('*'))
{
return m\maybeNull(parent::first($columns));
}
public function firstOrFail($columns = array('*'))
{
return $this->first($columns)->orElse(function() {
throw (new ModelNotFoundException)- >setModel(get_class($this->model));
});
}
public function findOrFail($id, $columns = array('*'))
{
return $this->find($id, $columns)->orElse(function() {
throw (new ModelNotFoundException)- >setModel(get_class($this->model)); });
}
public function pluck($column)
{
return $this->first([$column])->map(function($result) {
return $result->{$column};
});
}
}
由于first函数的使用,多个方法被重新定义,该函数现在返回Maybe类型的实例,而不是null。但是,无需返回Collection的实例,因为 Laravel 版本已经是一个很好的实现,即使它不是一个 monad。
现在我们需要我们自己的Model类,它将使用我们的FunctionalBuilder方法:
{
return $this->find($id, $columns)->orElse(function() {
throw (new ModelNotFoundException)- >setModel(get_class($this->model));
});
}
public function pluck($column)
{
return $this->first([$column])->map(function($result) {
return $result->{$column};
});
}
}
这两个新类在大多数情况下应该能够正常工作,但由于我们正在重新定义框架本身使用的方法,可能会遇到问题。如果是这种情况,我很乐意听取您的意见,以便改进实现以避免问题。
您可能还希望修改 Collection monad,以便在适当的各种方法中返回 Maybe monad 的实例,而不是null。但是,这将需要比我们迄今为止所做的更多的修改。据我所知,目前没有提供此功能的软件包。
摆脱 facade
Facades 的概念可能对新手减少学习曲线有所帮助,并且可能有助于使用 Laravel 提供的服务。但无论您对它们的看法如何,它们都不是一点功能,因为一旦使用它们,就会引入副作用。
通过在控制器和服务中注入依赖项,可以很容易地摆脱它们,这是 Symfony 世界中的常见做法。除了允许您编写功能代码之外,停止使用 facade 还有一个隐藏的好处-您的代码将与 Laravel 的联系更少,因此您可能能够更多地重用它。
Laravel 提供了一个名为自动注入的功能,它将允许您通过 Facade 非常轻松地获取各种组件。它使用类型提示在类实例化时自动注入所需的依赖项。它在多种上下文中都有效-例如控制器、事件监听器和中间件。
获取UserRepository类的实例就像下面这样简单:
<?php
namespace App\Http\Controllers;
use App\Users\Repository as UserRepository;
class UserController extends Controller
{
protected $users;
public function __construct(UserRepository $users)
{
$this->users = $users;
}
}
可以通过参考文档中提供的表格轻松找到要使用的类型提示laravel.com/docs/5.3/facades#facade-class-reference。
然而,这个巧妙的机制并没有真正将您与框架解耦,因为您需要使用正确的类型提示。通过项目的bootstrap/start.php手动注入依赖项的另一种方法在这篇文章中有描述programmingarehard.com/2014/01/11/stop-using-facades.html/。
HTTP 请求
与 Symfony 一样,使用 PSR-7 中定义的接口而不是框架中的接口非常容易。 Laravel 使用与 Symfony 相同的桥梁执行转换。您只需要使用 Composer 安装两个软件包:
**composer require symfony/psr-http-message-bridge
composer require zendframework/zend-diactoros**
然后,当你想要获取 PSR-7 版本的实例时,只需使用ServerRequestInterface类作为类型提示,而不是Request方法。如果你的控制器动作返回 PSR-7 版本,Laravel 会自行将Response方法转换为自己的格式。
结束语
Laravel 核心开发人员所做的实现决策有两面性。其中一些,比如不可变集合实现,在功能性编程方面非常出色。而另一些,比如使用 Facades,会让我们的生活变得有些困难。
然而,将我们的代码转换为更加功能性的方法是相当简单的。你可能会遇到的唯一困难是阅读文档或教程时,它们通常描述我们试图避免的模式和实践。
总的来说,当涉及到编写功能性代码时,Laravel 和 Symfony 一样出色。前面提到的关于其集合实现的书籍也是学习如何使用一些功能性技术与集合单子实现相关的绝佳方式。据我所知,Symfony 没有这种资源。
Drupal
Drupal 模块直到版本 7 都依赖于钩子来执行操作。Drupal 钩子是遵循特定命名模式的函数,当 Drupal 在响应请求时发生各种事件时,会调用这些函数来修改生成的网页的各个方面。
在理想的世界中,所有的钩子都会接收到执行工作所需的所有信息,并且修改某些东西的方式将使用返回值。这在某些模块 API 的部分是基本正确的。不幸的是,有一些函数会接收通过引用传递的参数,比如hook_block_list_alter函数。此外,有时你需要访问全局变量,例如获取当前语言。
Drupal 8 转向了基于类的方法。现在应该在控制器中创建内容,以便更接近 Symfony 的术语。原因是这个新版本现在使用了 Symfony 的一些核心组件。这并不意味着不再可能使用功能性编程,只是事情有些不同。
本书的角色并不是详细解释从版本 7 到版本 8 发生了什么变化。有很多教程在做这方面的出色工作。这里将呈现的大部分内容都是足够通用并且对 Drupal 的两个版本都适用的。
数据库访问
在 Drupal 7 中,你可以使用多个函数来执行数据库查询并访问结果。通常,你会从db_query函数开始,该函数返回一个带有各种方法来检查和处理数据的结果对象。Drupal 8 的首选方式不是在模块或服务中注入数据库连接并以更面向对象的方式使用它。
这种变化实际上并不影响我们,因为在这两种情况下,都不可能以引用透明的方式查询数据库。此外,人们通常不会在 Drupal 中使用 ORM;大多数对数据库的请求都是直接使用 SQL 进行的。
这就是为什么我们不会在这个主题上停留,除了重复强调尽可能隔离数据库访问,以便代码的其余部分可以是功能性的。
处理需要副作用的钩子
最小的 Drupal 模块由两个文件组成,info文件和module文件。info文件的格式从 Drupal 7 中的特定文本文件变为 Drupal 8 中的 YAML 文件,但文件仍然包含有关模块的信息。module文件是模块的主要 PHP 文件。
正如我们所看到的,一些钩子需要副作用来执行它们的工作,几乎没有其他方法。我可以建议的是,将模块文件作为保存所有非严格功能性代码的文件,并将所有计算放在其他地方。
在 Drupal 8 的情况下,一些控制器方法可能也需要具有副作用。在这种情况下,我会给您与 Laravel 和 Symfony 相同的建议:将这些保留在控制器中,并使用外部服务/助手来执行引用透明计算。
我们如何为我们之前讨论过的hook_block_list_alter函数做到这一点?首先,这仅适用于 Drupal 7,因为在下一个版本中,块是通过类管理的,这在这种特定情况下解决了引用透明性的问题。
我的建议只是在您的模块中创建第二个 PHP 文件,其中只包含纯函数。例如,这个文件可以包含一个new_blocks函数,它只接受当前块和语言作为参数。
然后,在模块文件中,您可以执行以下操作:
function my_module_block_list_alter(&$blocks) {
global $language;
$blocks = new_blocks($blocks, $language);
}
这个函数显然既有副作用又有副作用;我们对此无能为力。然而,new_blocks函数可以是纯的,这意味着您可以轻松地对其进行推理和测试,就像我们在前几章中看到的那样。
这种方法几乎可以应用于任何事物。一旦您遇到副作用或副作用,就在模块文件中执行这些操作,然后使用不同的文件来保存您的纯函数,这些函数将进行必要的处理和计算。如果您使用 Drupal 8,可以使用控制器,而不是使用module文件,就像我们已经讨论过的 Symfony 和 Laravel 一样。
钩子顺序
Drupal 的美妙之处在于所有可用的各种模块。这是如此真实,以至于有些人提出了苹果营销口号的变体之一:有一个模块可以做到!。然而,这也带来了一些困难。然而,当涉及到质量时,并非所有模块都是平等的,通常您会为任何给定的应用程序得到一堆模块。
其推论是,您自己的钩子接收到的信息可能已经被之前的模块修改过。比如,您正在编写一个模块来重新排列页面上的一些块;很可能您期望存在的一些块已经被移除了。或者您在关联数组中使用的键已经被注册或将被覆盖。
这可能会导致一些问题,有点难以准确地确定。由于您的函数将是纯净的,因此相对容易检测到它来自其他地方的事实,方法是通过明确添加一个测试来确保它针对给定数据集按预期工作。
关于这个问题的一个很好的建议是,不要假设您从 Drupal 接收的任何内容中可能存在或不存在的内容。始终应用某种检查来确保您接收的数据结构正确并且存在。
结束语
出于历史原因,一些 Drupal 钩子需要具有副作用才能执行其职责。此外,并非所有信息都作为参数传递给它们,这要求我们访问全局范围以获取它们。这个事实要求我们找到解决方法,以保持尽可能多的纯净代码。
通过引入更多的面向对象的方法以及服务注入,Drupal 8 使事情变得更容易。与我们在 Symfony 或 Laravel 中的经验相当,但事情仍然不完美。
如果您在至少两个文件中严格区分您的不纯函数和纯函数,那么您编写功能代码的体验将非常好。如果您想要纯函数,那么实现一个钩子需要创建两个函数可能看起来很麻烦,但这是您必须付出的代价,而且在我看来,这是值得的。
正如我们讨论过的,即使您的纯代码经过了彻底测试,由于一些钩子的调用顺序,您仍然可能在最终页面呈现时遇到问题,但是如果您对函数有信心,这些问题通常更容易发现。
Drupal 的功能开发者体验并不完美,但它接近。您将不得不做一些让步,但您可以将它们绑定到少数文件中,以限制它们对代码其余部分的影响。
WordPress
WordPress 也有一个钩子系统,尽管与 Drupal 的不同。您不是创建具有特定名称的函数,而是将函数注册到特定的钩子上。遗憾的是,根据定义,大多数这些钩子需要具有副作用。例如,我们可以通过wp_footer钩子来实现这一点:
此钩子不提供参数。您可以通过让您的函数向浏览器输出内容,或者让它执行后台任务来使用此钩子。您的函数不应返回,也不应该带任何参数。
没有返回值,没有参数;我们被迫创建一个具有副作用的函数。这意味着您将不得不在您的代码周围创建包装函数,甚至比我们刚刚在 Drupal 中演示的更多。
幸运的是,WordPress 还允许您在插件中拥有多个文件。因此,建议是一样的-将所有不纯的代码放在主文件中。从全局上下文中获取您需要的所有信息,并在那里执行任何类型的具有副作用的操作。一旦您获得所需的一切,调用您的纯函数进行处理和计算。
一些 WordPress 教程将面向对象编程呈现为开发人员掌握更为程序化的插件编写方式后的下一个进化阶段。如果您打算使用功能技术,这并不重要。您可以仅使用函数组织您的代码,也可以将它们分组到类中。我建议您坚持您更熟悉的方法。
数据库访问
在 WordPress 插件中,基本上有两种访问数据库的方式。您可以直接使用WP_Query方法及其面向对象的接口。或者您可以使用辅助函数,如get_posts和get_pages。
您可能已经在某处听说或读到,当使用类编写插件时,最好使用WP_Query函数,而在使用函数时使用辅助函数。从功能的角度来看,这并不重要。它们都不是引用透明的。您可以使用您喜欢的或更适合您需求的任何一个。
在 WordPress 代码库中,关于数据库访问并没有太多可说的。问题与其他框架相同-目前没有纯粹的方法来执行它们。
话虽如此,建议仍然是一样的-尽量将任何具有副作用的代码与副作用隔离到插件的单个文件中,然后将计算和处理委托给其他地方的纯函数。
功能方法的好处
我在本部分的介绍中说过,无论您使用函数还是对象来组织您的代码都无关紧要。这只是部分正确。WordPress 缺乏像 Symfony 或 Laravel 等框架的所有注入功能。这意味着,如果您使用对象,您将遇到在各处共享实例的困难。
如果您的对象仅用于保存不使用任何内部状态的纯方法,那么这并不是问题,但正如我们所看到的,有时需要做出让步。如果您需要与此类状态共享实例,您唯一的解决方案是将其全局可用。这样的变量的问题在于它可能被重新分配给其他内容,从而在以后引起问题。
相反,函数可以从任何地方使用,您无法重新定义它。这会导致更健壮的代码,因为您限制了副作用的可能性。
结束语
Drupal 的第一个版本可以追溯到 2000 年,使其成为这里介绍的最古老的工具。WordPress 诞生于 2003 年。然而,Drupal 自那时起已经被重写,而 WordPress 的代码库大多是在没有完全重写的情况下进行扩展。
为什么我要告诉你这些?因为在尝试在 WordPress 中编写函数式代码时,你遇到的大部分问题都与其遗留代码库有关。2000 年编写软件的方式与我们现在期望的最佳实践有些不同。
WordPress 进行了大量的现代化工作,但你能做的也只有这么多。特别是当重点不是使框架对函数式开发者友好时。然而,如果你愿意跳过一些障碍来隔离具有副作用的部分,仍然可以编写函数式代码。
WordPress 主要基于钩子,大部分 API 由函数组成。其中一些是引用透明的,而另一些则完全不是。你需要一些严谨性来清晰地将它们与你的其余代码隔离开来。好处总是一样的:
-
减少认知负担
-
促进代码重用
-
更容易的测试
缺点是,你的主要插件文件将主要由一些非常小的函数组成,这些函数只是作为 WordPress API 的不纯函数的包装器,然后调用你的引用透明函数。
如果你的包装器的名称与它们的 WordPress 对应物足够接近,那么阅读你的代码并在其中导航对于任何熟悉 WordPress 的人来说应该是相当容易的。最终,尽可能多地编写函数式代码仍然是一个好主意。
总结
正如我们之前讨论过的,没有一个主流框架在其核心具有函数式方法。在本章中,我们试图看到我们学到的技术如何更多或更少地应用于一些可用的框架和 CMS。
正如我们所看到的,至少在某个层面上总是可以使用函数式编程。遗憾的是,根据框架的不同,你将不得不在某个时候创建非引用透明的代码。
正如我在介绍中所说的,我并不是本章讨论的所有库的专家,所以要持保留态度。更有经验的开发者可能会有不同的做法。然而,这些示例为任何想尝试函数式编程的人提供了一个很好的起点。
此外,当这样做时,请记住,这首先是一种思维方式。最重要的是你解决问题的方式。如果在某个时候,你需要创建非纯代码来适应外部依赖或你正在使用的框架,那就这样吧。这不会改变你编写的函数式代码所能获得的好处。
现在我们已经看到了如何在现有框架或遗留代码库中使用函数式编程,下一章将涵盖使用一种称为函数响应式编程或 FRP 的范式来设计整个应用程序。
第十一章:设计一个函数式应用程序
创建一个完全遵守函数式编程原则的应用程序可能看起来像是一个不可能的任务。如果不能有任何副作用,你怎么能编写任何有意义的软件呢?为了执行任何计算,你至少需要一些输入和显示结果。
函数式语言有各种机制来规避这些限制。我们将快速介绍其中一些,这样你就可以更好地了解如何以纯函数式的方式编写应用程序。
然后我们将更深入地学习一种称为函数式响应式编程(FRP)的范式,作为设计具有用户界面的应用程序的一种方式。我们将奠定在 PHP 中使用这种技术的基础,看看是否可能用它来编写一个完整的应用程序。
在本章中,你将学习以下主题:
-
在纯函数式语言中编写一个完整的应用程序
-
函数式响应式编程
-
使用 FRP 设计 PHP 应用程序
纯函数式应用程序的架构
应用程序就像函数。如果你有一个没有任何输入的应用程序,它的结果将始终相同。你可以修改源代码中的一些值并重新编译软件以改变其结果,但这与我们编写应用程序的主要原因相悖。
这就是为什么你需要一种方法来向应用程序提供数据,以便它执行任何有意义的计算。这些输入可以是多种类型的:
-
命令行参数
-
文件内容
-
数据库内容
-
图形界面中的字段
-
第三方服务
-
网络请求
在所有这些中,只有第一个可以被认为不会破坏我们整个应用程序的引用透明性。如果你将你的应用程序视为一个大函数,通过命令行输入数据可以被视为其参数,因此保持一切都是纯粹的。所有其他类型的输入都是事实上不纯的,因为对数据的两次连续检索可能导致不同的值。
解决这个问题的 Haskell 的标准方法是使用IO 单子。IO 单子不会立即执行其操作,而是将所有步骤存储在队列中。如果你将这个 IO 操作命名为main,Haskell 将知道在执行编译后的程序时必须运行它。
显然,如果在单子内部执行任何 IO 操作,应用程序本身就不再是纯的了。然而,代码本身可以以引用透明的方式编写。当 IO 单子运行时,Haskell 运行时将执行所有不纯操作,然后传递各种获得的值。利用这个技巧,你可以用所有它带来的好处编写纯函数式代码,并执行 IO 操作。
这种方法在 Haskell 中是可用的,因为你可以使用单子变换器来组合多个单子。do 表示法也通过在 IO 单子中编写封装的代码而不带有与之相关的所有开销来帮助很多。例如,这里是一个在终端中读取行并以相反顺序打印单词的小程序:
main = do
line <- getLine
if null line
then return ()
else do
putStrLn $ reverseWords line
main
reverseWords :: String -> String
reverseWords = unwords . map reverse . words
它读起来大多像执行相同任务的任何命令式源代码。PHP 缺乏语法糖,也没有单子变换器的实现,所以这样做相当困难。这就是为什么我们要做出妥协,正如前一章所讨论的,或者我们需要一些其他方法,正如我们将在下一节中看到的那样。
所涉及的想法可以被概括。任何不纯的函数都可以分解为两个函数,一个是纯的,一个是封装了副作用和影响的。这正是我们在前一章中所指的,当我们说大多数不纯的函数应该包含在 MVC 应用程序的控制器中时。
如果你有一个以A为参数并返回B的不纯函数f,你可以创建以下两个函数:
-
一个纯函数
g,它接受A并返回D参数。参数D是对需要执行的 IO 操作的描述。 -
一个不纯的函数
h接受D并执行描述的操作,就像一个解释器会做的那样。
如果我们以 Haskell 应用程序为例,Haskell 运行时本身将是我们不纯的h函数。如果我们的源代码返回 IO 单子的一个实例,就像我们上面的例子所做的那样,它将被用作D参数,并且副作用将被解释。
如果你正在使用Symfony框架编写 Web 应用程序,我们可以将框架视为不纯的h函数,D参数将是执行你的控制器的结果。另一种可能性是在我们的函数代码周围添加自定义的不纯包装器。
主要思想是将诸如h之类的函数数量减少到最低。Haskell 强制你只能有一个这样的函数,甚至隐藏在运行时内部。如果你在使用 PHP,那么你需要尽可能有效地强制执行这个规则。
拥有计算的描述和一个解释器来执行它们的概念是函数世界中许多更高级技术的核心。它在整个计算机编程中也非常重要。如果我们稍微远离一点,我们可以看到以下情况:
-
描述就像一个抽象语法树(AST)
-
解释器接受 AST 并运行它
这就是大多数现代编译器的工作方式,首先它们解析源代码将其转换为 AST,然后解释它以创建二进制文件。在大多数复杂应用程序中,你也会一次又一次地发现相同的模式。
使用这种结构的一个高级构造是free monad。这个单子目前在函数世界中是一个热门话题,它的使用正在迅速增长。我们在这里缺少了相当多的理论来接近这个话题,但如果你感兴趣,你肯定会在互联网上找到很多信息,例如,underscore.io/blog/posts/2015/04/14/free-monads-are-simple.html。
然而,当你在应用程序的生命周期中接受用户交互时,这种模式是有问题的。由于主要思想是通过描述来延迟执行有效的计算,你不能执行部分计算来显示用户界面,然后对用户输入做出反应。这是 FRP 试图解决的问题之一。
从函数式反应动画到函数式反应编程
在涉及函数式编程时,通常情况下,所涉主题的基础有点过时。1997 年,Conal Elliott 和 Paul Hudak 发表了一篇名为Functional Reactive Animation, or Fran的论文。
Fran 的主要目标是使用称为behaviors和events的两个概念来建模动画。行为是基于当前时间的值,事件是基于外部或内部刺激的条件。这两个概念允许我们在任何时间点表示任何类型的动画,尽管动画本身是连续的。
与其直接创建动画的表示,通常情况下,你使用行为和事件来描述它。然后,解释和因此表示留给底层实现。这与我们刚刚描述的情况类似。由于 Fran 可以编码诸如键盘输入或鼠标点击之类的事件,你正在创建的模型允许纯函数应用程序对外部输入做出响应。
反应式编程
在我们进一步讨论之前,让我们稍微谈谈在编程世界中reactive意味着什么。这个想法在过去几年里得到了相当多的关注。
首先,有响应式宣言(www.reactivemanifesto.org/),它提出了对任何软件都非常有趣的一些属性。这些属性包括:响应性、弹性、弹性和消息驱动。
维基百科(en.wikipedia.org/wiki/Reactive_programming)的定义表明了一些完全不同的东西:
在计算中,响应式编程是围绕数据流和变化传播的编程范式。这意味着应该能够在所使用的编程语言中轻松表达静态或动态数据流,并且底层执行模型将自动通过数据流传播更改。
然后给出了表达式a = b + c的示例,其中当b或c的任何一个发生变化时,a的值会自动更新。
JavaScript 世界对这个想法很兴奋,有诸如Bacon.js或RxJS等库。所有这些库共享的核心思想都围绕事件或事件流。
我们可以看到,关于响应式编程有多种定义。遗憾的是,它们中没有一个真正符合我们刚刚学到的有关 Fran 的知识。自至少上世纪七十年代以来,我们将在本章的其余部分保留的定义是学术界的定义,可以在维基百科上找到。
我并不是说其他的定义无效,只是我们需要在这里有一个共同的基础。另外,下次与他人谈论响应式编程时,首先确保您对该主题的理解是一致的。
作为响应式编程的最后一个例子,让我们考虑以下代码片段:
<?php
$a = 10;
$b = 5;
$c = $a + $b;
echo $c;
// 15
$a = 23;
echo $c;
在传统的命令式语言中,最后一行仍然会显示 15。然而,如果我们的应用程序遵循响应式编程的规则,$a的新值也会影响$c的值,程序将显示 28。
函数式响应式编程
正如您可能猜到的那样,当进行其他更改时,值随时间变化远非引用透明。此外,某些函数语言完全缺少变量的概念。我们如何调和响应式和函数式编程呢?
核心思想是在需要时将时间组件和先前事件参数化为函数。这正是 Fran 提出的行为和事件。时间和事件通常被提议作为流进行消耗。使用函数映射和过滤,您可以决定流中哪些事件对您感兴趣。
您的函数从此流中获取一个或多个输入以及应用程序的当前状态。然后它们必须返回应用程序的新状态。运行时将负责在事件发生时调用各个注册的函数。
你可能会觉得它类似于事件驱动编程。在某种程度上是,但有一个很大的区别。在传统的事件驱动应用程序中,事件被触发,但处理程序的返回值通常并不重要;它们需要具有副作用来执行某些操作。
在进行 FRP 时,运行时负责编排所有已注册的处理程序。保持应用程序的当前状态,将其传递给每个处理程序,并使用它们的结果进行更新。这允许函数是纯的。
可能比事件驱动编程更接近的另一种编程范式是演员模型。我不会在这里描述它,因为这将超出本书的范围,但对于了解它的人来说,我只想说有两个主要区别:
-
由于您拥有纯函数而不是演员,因此您无法拥有私有状态影响您对给定消息或事件的响应方式。
-
运行时管理事件流;处理程序无法向应用程序的其他部分发送新消息。
时间旅行
FRP 还有另一个好处。如果您记录了导致特定应用程序状态的事件序列,您可以重放它们。更好的是,您可以实现所谓的时间旅行调试器。由于您的应用程序使用纯函数,您可以回到任何时间点,并获得与以前完全相同的状态。
这种调试器还允许您向前向后重放任意数量的步骤,直到您可以准确地确定发生了什么。此外,您可以对代码进行更改,并播放相同的事件,以查看您的修改如何影响您的软件。
如果您想看到这样的调试器在实际操作中,您可以前往 Elm 语言提供的一个,特别是他们在线版本的一个对 Mario 平台游戏的天真实现(debug.elm-lang.org/edit/Mario.elm)。
Elm 调试器可能是其种类中最早的之一。尽管类似的想法已经在传统语言中实现过,但命令式编程的本质要求我们记录的不仅仅是事件流。这就是为什么这是一个非常昂贵的操作,会大大减慢程序的执行速度。
您还需要从头开始重新启动程序,以确保达到相同的状态。然而,在纯应用程序中,您可以以更简单的方式实现这一点。类似于 Elm 中发现的实现现在正在被创建,例如用于 React JavaScript 库。
免责声明
有 FRP 和 FRP,但是我不打算改编这个想法的创造者,让我来引用他的话:
在过去几年中,FRP 的某些特性引起了程序员的极大兴趣,激发了在各种编程语言中实现的所谓“FRP”系统。然而,这些系统大多缺乏 FRP 的两个基本属性。
您可以在 GitHub 上看到完整的文本以及相关幻灯片和视频(github.com/conal/talk-2015-essence-and-origins-of-frp)。
通常情况下,学术界和人们对研究结果的使用之间存在某种分歧。我不会在细节上纠缠,因为这应该只是一个介绍性的章节。然而,你需要意识到这一点很重要。
争议的主要点在于 FRP 涉及连续时间,而大多数实现只考虑离散事件或值。如果您想了解更多关于这些差异的信息,我强烈建议您观看之前链接的视频,该视频可在 Fran 和 FRP 的创建者 Elliot Conal 的 GitHub 存储库上找到。
更进一步
关于函数式响应式编程还有很多其他事情要说。事实上,整本书都是专门讨论这个主题的。然而,这只是一个介绍,所以我们就到此为止。如果您想要一个与特定语言无关的主题的一般方法,我可以推荐 Stephen Blackheath 和 Anthony Jones 新出版的Functional Reactive Programming。
在实现方面,ReactiveX 项目试图整合多个项目中可用的库。您可以在官方网站上找到更多信息reactivex.io/。在撰写本文时,涵盖了以下语言:Java、Swift、Python、PHP、Scala、JavaScript、Ruby、Clojure、Rust、Go、C#、C++和 Lua。
如前所述的免责声明和 ReactiveX 网站上的介绍,目前存在学术概念 FRP 与今天程序员所指的术语之间的混淆。前述书籍和 ReactiveX 库都谈到了后者而不是原始含义。这并不意味着这些都是坏主意,恰恰相反;只是这不是真正的 FRP。
ReactiveX 入门
Rx*库选择通过将经典的观察者模式扩展到Observable模型来实现函数式响应式范式。对于给定的值流,由Observable模型的实例表示,您可以定义最多三个不同的处理程序:
-
每当有新值可用时,将调用
onNext处理程序 -
当异常发生时,将调用
onError处理程序 -
当流关闭时,将调用
onCompleted处理程序
这种方法使得可以轻松处理多个异步事件,而无需编写复杂的样板代码来管理它们之间的依赖关系。与传统的观察者模式相反,信号流的结束和错误的能力被添加到与可迭代对象接口协调的接口中。
ReactiveX 还定义了一堆操作符,用于操作可观察对象及其值。有助手方法可以创建各种类型的流,从范围到数组,通过无限重复值和定时释放事件。
您还可以通过将函数映射到每个发出的值,将它们分组为新的可观察对象或值数组来操作流本身。您还可以过滤值,跳过或获取一定数量的值,限制一定时间内的发射次数,并抑制重复项。
文档(reactivex.io/documentation/operators.html)列出了可用的所有操作,以及一个很好的决策树,根据上下文决定使用哪个操作。
RxPHP
在我们开始查看一些 RxPHP 示例之前,我想指出 Packt Publishing 还出版了一本完整的关于这个主题的书籍,PHP Reactive Programming。您可以在他们的网站上找到更多信息www.packtpub.com/web-development/php-reactive-programming。这就是为什么我们只会探索一些基本示例,以让您感受一下使用该库可能会是什么样子。如果您对这个主题感兴趣,我强烈建议您阅读专门的书籍。
在对 ReactiveX 进行了非常简要的介绍之后,让我们看看它如何被使用。首先,我们需要安装所需的库。我们将使用一个小的包装器来包装 ReachPHP 的流库,以使其可以与 RxPHP 一起使用,这样我们就可以演示访问磁盘上的文件。以下composer调用应该安装所有所需的依赖项:
**composer require rx/stream**
现在库已安装,您可以从任何 PHP 流中解析数据。例如,CSV 文件:
<?php
use \Rx\React\FromFileObservable;
use \Rx\Observer\CallbackObserver;
$data = new FromFileObservable("11-example.csv");
$data = $data
->cut()
->map('str_getcsv')
->map(function (array $row) { return $row; });
$data->subscribe(new CallbackObserver(
function ($data) { echo $data[0]."\n"; },
function ($e) { echo "error\n"; },
function () { echo "done\n"; }
));
我们首先为要读取的文件创建一个流 Observable,然后应用一些转换:按行分隔输入,将 CSV 字符串解析为数组,并应用您可能想要的任何其他数据处理。正如您可以从我们将结果重新分配给$data变量的事实推断出来,该操作不是就地进行的,而是每次返回一个新实例。
然后,我们可以订阅处理程序到我们的流。在我们的例子中,我们只是打印每个元素的第一行。不是真正的功能,但对于一个小例子来说足够有效。
如果您使用PostgreSQL,则存在一个允许您使用 Rx 访问数据库的包。您可以使用它使用流检索数据。您可以使用以下composer调用进行安装:
**composer require voryx/pgasync**
创建查询非常容易。只需创建一个带有连接凭据的客户端,然后在其上调用其中一个方法以创建一个 Observable 实例,您可以订阅该实例:
<?php
$client = new PgAsync\Client([ "user" => "user", "database" => "db" ]);
$client->query('SELECT * FROM my_table')->subscribe(new CallbackObserver(
function ($row) { },
function ($e) { },
function () { }
));
以下是一个最终示例,演示了 Rx 在流本身上提供的一些更高级的过滤和转换可能性。在运行之前,试着猜测输出会是什么:
<?php
use \React\EventLoop\StreamSelectLoop;
use \Rx\Observable;
use \Rx\Scheduler\EventLoopScheduler;
// Those are needed in order to create a timed interval
$loop = new StreamSelectLoop();
$scheduler = new EventLoopScheduler($loop);
// This will emit an infinite sequence of growing integer every 50ms. $source = Observable::interval(50, $scheduler);
$first = $source
->throttle(150, $scheduler) // do not emit more than one item per 150ms
->filter(function($i) { return $i % 2 == 0; }) // keep only odd numbers
->bufferWithCount(3) // buffer 3 items together before emitting them
->take(3); // take the 10 first items only
$second = $source
->throttle(150, $scheduler)
->take(10);
$first->merge($second) // merge both observable
->subscribe(new CallbackObserver(
function ($i) { var_dump($i); },
function ($e) { },
function () { }
));
$loop->run();
如果你尝试运行这段代码的最后一部分,你需要安装 RxPHP 的开发版本,因为 throttle 最近才实现。如果你的最小稳定性参数设置为 dev 版本,你可以使用以下命令安装它:
**composer require reactivex/rxphp:dev-master**
实现引用透明度
正如示例所示,创建流并订阅它们是相当简单的。想象如何可以将处理程序因式分解,以便在多个可观察实例之间实现重用也是非常容易的。
然而,Rx 无法为我们解决的问题是实现尽可能多的引用透明度所需的应用程序架构。仅仅创建一个新的数据库查询作为 Observable 是不够纯粹的。
我可以给你的建议与上一章中听到的一样,就是尽量将所有不纯的代码隔离在一个地方。在我们的情况下,可以通过在一个唯一的文件中创建所有流来实现这一点,比如你的 index.php 文件,并在其他地方声明处理程序。
各种处理程序可以被孤立地测试,你可以很快对它们建立信心,因为它们将是引用透明的。集成和功能测试将负责测试流本身和整个应用程序。
如果你尝试在现有框架中使用 Rx,你可以在控制器中声明流,并像之前描述的那样保持处理程序分离。
总结
函数式响应式编程使我们能够将纯函数与事件管理相协调。这意味着可以创建需要用户输入或访问第三方服务和外部数据源的应用程序。随着越来越多的网站使用 Web 套接字和其他类似技术不断向用户推送数据,这一点尤为重要。
除了访问数据源之外,FRP 在处理用户界面工作时非常出色。通常在 Web 上使用 JavaScript 来执行任务,因为 PHP 主要用于处理请求本身并提供 HTML 响应。然而,PHP 可能会在桌面上更多地被使用,比如在 PHP 7 的 beta 版本中可用的 libui 包装器(github.com/krakjoe/ui)。
PHP 中的桌面应用程序是社区中一个相当新的话题,现在可能是一个根据最新的函数式响应式编程创建一些最佳实践的好时机。
我们只是浅尝辄止了这种新的应用程序设计方式,要完全做到这一点需要远不止一章的篇幅。如果你想更多地了解这个主题,之前提到的两本书是一个很好的起点。
在本章中,我们了解了 FRP 的历史。我们还试图发现传统响应式编程和其函数式对应之间的区别。我们迅速谈到了时光旅行调试,然后展示了一些 PHP 的例子。
你刚刚完成了本书的最后一章。我希望你阅读它和我写作它一样有趣。我也希望我能够引起你对函数式编程的兴趣,并且你将尝试在未来的项目中实现我们在本书中看到的各种技术。对我来说,没有比知道我能够让一个同行开发者对这个美妙的主题感兴趣更好的回报了。
在我们分别之前,我可以建议你阅读 附录,我们谈论函数式编程时在谈论什么。它包含了对函数式编程的更全面定义,它的好处以及历史。你还会在最后找到一个词汇表,解释了各种术语,其中一些在本书中看到,其他一些是新的。
再见,感谢所有的鱼。
第十二章:当我们谈论函数式编程时,我们在谈论什么
函数式编程在过去几年中获得了很多关注。各大科技公司已经开始使用函数式语言:
-
Twitter 使用 Scala:
www.artima.com/scalazine/articles/twitter_on_scala.html -
WhatsApp 使用 Erlang 编写:
www.fastcompany.com/3026758/inside-erlang-the-rare-programming-language-behind-whatsapps-success -
Facebook 使用 Haskell:
code.facebook.com/posts/302060973291128/open-sourcing-haxl-a-library-for-haskell/1
在编译为 JavaScript 的函数式语言上已经做了一些非常出色和成功的工作:Elm和PureScript语言,这只是其中的一部分。有人正在努力创建新的语言,这些语言要么扩展,要么编译为一些更传统的语言;我们可以引用Hy和Coconut语言用于 Python。
甚至苹果的 iOS 开发新语言Swift中也集成了多个函数式编程概念。
然而,本书不是关于使用新语言,而是关于在不必改变整个堆栈或学习全新技术的情况下从函数式技术中获益。通过将一些原则应用到我们日常的 PHP 中,我们可以极大地改善我们的生活和代码质量。
但在进一步之前,让我们从一个对函数式范式的温和介绍开始,解释它到底是什么以及它来自哪里。
函数式编程到底是什么?
如果你尝试在互联网上搜索函数式编程的定义,很有可能你会在某个时候找到维基百科的文章(en.wikipedia.org/wiki/Functional_programming)。除其他事项外,函数式编程被描述如下:
在计算机科学中,函数式编程是一种编程范式-一种构建计算机程序的结构和元素的风格,它将计算视为数学函数的评估,并避免改变状态和可变数据。
Haskell 维基(wiki.haskell.org/Functional_programming)这样描述:
在函数式编程中,程序通过评估表达式来执行,与命令式编程相反,在命令式编程中,程序由改变全局状态的语句组成。函数式编程通常避免使用可变状态。
尽管我们的看法可能有些不同,但我们可以从中概述一些函数式编程的关键定义:
-
评估数学函数或表达式
-
避免可变状态
从这两个核心思想中,我们可以得出许多有趣的属性和好处,这些你将在本书中发现。
函数
你可能知道编程语言中的函数是什么,但它与数学函数有何不同,或者像 Haskell 称之为表达式有何不同?
数学函数不关心外部世界或程序的状态。对于给定的输入集,输出将始终完全相同。为了避免混淆,开发人员通常在这种情况下使用术语纯函数。我们在第二章中讨论了这一点,纯函数,引用透明度和不可变性。
声明式编程
另一个区别是,函数式编程有时也被称为声明式编程,与命令式编程相对。这些被称为编程范式。面向对象编程也是一种范式,但它与命令式编程紧密相连。
不必冗长解释差异,让我们通过一个例子来演示。首先是使用 PHP 的命令式方法:
<?php
function getPrices(array $products) {
// let's assume the $products parameter is an array of products. $prices = [];
foreach($products as $p) {
if($p->stock > 0) {
$prices[] = $p->price;
}
}
return $prices;
}
现在让我们看看如何使用 SQL 来完成相同的操作,它是除其他外,还是一种声明性语言:
SELECT price FROM products WHERE stock > 0;
注意到区别了吗?在第一个例子中,您逐步告诉计算机要做什么,自己负责存储中间结果。第二个例子只描述您想要的内容;然后,数据库引擎将返回结果。
在某种程度上,函数式编程看起来更像 SQL,而不像我们刚才看到的 PHP 代码。
没有任何解释,以下是您可以使用 PHP 以更加函数式的方式完成的方法:
<?php
function getPrices2(array $products) {
return array_map(function($p) {
return $p->price;
}, array_filter(function($p) {
return $p->stock > 0;
}));
}
我很乐意承认,这段代码可能并不比第一段更清晰。通过使用专用库,可以改进这一点。我们还将详细了解这种方法的优势。
避免可变状态
正如名称本身所暗示的那样,函数是函数式编程的最重要的构建模块。最纯粹的函数式语言只允许您使用函数,根本不允许使用变量,因此避免了任何与状态和其变异有关的问题,同时也使任何一种命令式编程都变得不可能。
尽管这个想法很好,但并不实际;这就是为什么大多数函数式语言允许您拥有某种类型的变量。然而,这些变量通常是不可变的,意味着一旦分配了值,它们的值就不能改变。
为什么函数式编程是软件开发的未来?
正如我们刚才看到的,函数式世界正在发展,企业界对其采用正在增长,甚至新的命令式语言也从函数式语言中汲取灵感。但为什么呢?
减轻开发人员的认知负担
您可能经常读到或听到程序员不应该被打断,因为即使是小的打断也会导致失去几十分钟。其中我最喜欢的一个例子是下面的漫画:
这在一定程度上是由于认知负担,或者换句话说,您必须记住的信息量,以便理解手头的问题或函数。
如果我们能够减少这个问题,那么好处将是巨大的:
-
代码理解所需的时间将更少,更容易推理
-
打断将导致思维过程中的干扰减少
-
由于遗忘了一些信息而引入的错误将更少
-
对于项目新手来说,学习曲线较小
我认为函数式编程可以极大地帮助。
保持状态远离
在认知负担方面的主要竞争者之一,正如之前所展示的漫画中所描述的那样,是在试图理解代码的一部分时,记住所有这些小的状态信息。
每次访问变量或在对象上调用方法时,您都必须问自己它的值是什么,并将其记住,直到您达到当前正在阅读的代码段的末尾。
通过使用纯函数,几乎所有这些问题都会消失。所有参数都在函数签名中。此外,您可以绝对确定,任何使用相同参数的后续调用都会产生完全相同的结果,因为您的函数不依赖于外部数据或任何对象状态。
为了进一步强调这一点,让我们引用本·莫斯利和彼得·马克斯的《Out of the Tar Pit》:
[...] 我们相信,当今大多数大型系统中复杂性的最大原因是状态,我们能够限制和管理状态的越多,就越好。
您可以在shaffner.us/cs/papers/tarpit.pdf上阅读整篇论文。
小的构建模块
当您进行函数式编程时,通常会创建许多小函数。然后您可以像积木一样组合它们。这些小代码片段通常比试图做很多事情的大杂乱方法更容易理解。
我并不是说所有的命令式代码都是一团糟,只是函数式思维真的鼓励编写更小、更简洁的函数,更容易处理。
关注点的局部性
让我们看看以下两个例子:
命令式与函数式-关注点的分离
如前面在两个虚构的代码片段中所示,函数式技术有助于以鼓励关注的方式组织代码。在这些片段中,我们可以将关注点分开如下:
-
创建一个列表
-
从文件中获取数据
-
过滤所有以ERROR文本开头的行
-
获取前 40 个错误
第二个片段明显对每个关注点有更好的局部性;它们没有分散在代码中。
有人可能会说第一个代码不够优化,可以重写以达到相同的结果。是的,这可能是真的。但就像前面提到的,函数式思维从一开始就鼓励这种架构。
声明式编程
我们看到,声明式编程关注的是“做什么”而不是“怎么做”。这有助于更好地理解新代码,因为我们的大脑更容易思考我们想要的东西,而不是如何去做。
当您在线或在餐厅订购东西时,您不会想象您想要的东西将如何被创建或交付,您只会考虑您想要什么。函数式编程也是一样-您从一些数据开始,然后告诉语言您想要对其进行什么操作。
这种代码对非程序员或语言经验较少的人来说通常也更容易理解,因为我们可以可视化数据将会发生什么。以下是《Out of the Tar Pit》中的另一段引文,说明了这一点:
当程序员被迫(通过使用具有隐式控制流的语言)指定控制时,他或她被迫指定系统应该如何工作的一个方面,而不仅仅是所需的内容。实际上,他们被迫过度指定问题
更少错误的软件
我们已经看到,函数式编程减少了认知负担,使您的代码更容易理解。这在处理错误时已经是一个巨大的优势,因为它将使您能够快速发现问题,因为您将花费更少的时间理解代码的工作原理,而更多地关注它应该做什么。
但我们刚刚看到的所有好处还有另一个优势。它们也使测试变得更容易!如果您有一个纯函数,并使用一组给定的值进行测试,您可以绝对确定它在生产中总是会返回完全相同的结果。
你有多少次认为你的测试没问题,结果发现在应用程序的某些特定情况下触发了一些隐蔽状态的隐藏依赖?使用纯函数,这种情况应该会少得多。
我们还将在本书的后面学习关于基于属性的测试。尽管这种技术可以用于任何命令式代码库,但其背后的理念来自函数式世界。
更容易的重构
重构从来都不容易。但由于纯函数的唯一输入是其参数,唯一输出是返回值,事情变得更简单了。
如果您重构的函数继续为给定输入返回相同的输出,您可以保证您的软件将继续工作。您不会忘记在对象的某个地方设置一些状态,因为您的函数是无副作用的。
并行执行
我们的计算机拥有越来越多的核心,云计算使跨多个节点共享工作变得更加容易。然而,挑战在于确保计算可以分布。
诸如映射和折叠等技术,再加上不可变性和状态的缺失,使这变得相当容易。
当然,你仍然会遇到与分布式计算本身相关的问题,比如分区和故障检测,但将计算分成多个工作负载会变得更加容易!如果你想了解更多关于分布式系统的知识,我可以推荐这篇文章(videlalvaro.github.io/2015/12/learning-about-distributed-systems.html)。
强制执行良好的实践
这本书证明了函数式编程更多地关乎我们做事情的方式,而不是特定的语言。你可以在几乎任何具有函数的语言中使用函数式技术。你的语言仍然需要具有某些属性,但不需要太多。我喜欢谈论拥有函数式思维。
如果是这样,为什么公司要转向函数式语言呢?因为这些语言强制执行我们将在本书中学到的最佳实践。在 PHP 中,你必须始终记住使用函数式技术。在 Haskell 中,你不能做其他任何事情;语言强制你编写纯函数。
当然,你仍然可以在任何语言中写糟糕的代码,即使是最纯净的代码。但通常,人们,尤其是开发人员,喜欢选择最不费力的路径。如果这条路径是通向高质量代码的路径,他们会选择它。
函数式世界的简史
从历史上看,函数式编程起源于学术界。直到最近几年,更多的主流公司才开始使用它来开发面向消费者的应用程序。现在甚至有些人在大学以外进行这个领域的新研究。但让我们从一开始开始。
最初的几年
我们的故事始于 20 世纪 30 年代,当时阿隆佐·丘吉尔正式化了λ演算,这是一种使用接受其他函数作为参数的函数来解决数学问题的方法。尽管这是函数式编程的基础,但直到 1958 年约翰·麦卡锡发布了Lisp,这个概念才首次被用于实现编程语言。公平地说,被认为是第一种编程语言的Fortran是在 1957 年发布的。
尽管 LISP 被认为是一种多范式语言,但它经常被引用为第一种函数式语言。很快,其他人也领会了这个暗示,并开始围绕函数式编程的思想进行工作,导致了APL(1964)、Scheme(1970)、ML(1973)、FP(1977)等许多其他语言的诞生。
FP 本身现在基本上已经死了,但约翰·巴克斯在演讲中提出的概念对函数式范式的研究至关重要。这可能不是最容易阅读的,但仍然非常有趣。我建议你尝试阅读整篇论文,网址是worrydream.com/refs/Backus-CanProgrammingBeLiberated.pdf。
Lisp 家族
Scheme,于 1970 年首次发布,是试图修复 Lisp 的一些缺点。与此同时,Lisp 诞生了一个编程语言家族或方言:
Common Lisp(1984 年):试图编写一个语言规范,以重新统一当时正在编写的所有 Lisp 方言。
Emacs Lisp(1985 年):用于定制和扩展 Emacs 编辑器的脚本语言。
Racket(1994 年):最初创建为围绕语言设计和创建的平台,现在被用于多个领域,如游戏脚本、教育和研究。
Clojure(2007 年):由 Rich Hickey 在长时间反思后创建的,旨在创建完美语言。Clojure 以Java 虚拟机(JVM)为目标。有趣的是,Clojure 现在也可以有其他目标,例如 JavaScript(ClojureScript)和.NET 虚拟机。
Hy(2013 年):一个以 Python 运行时为目标的方言,允许使用所有 Python 库。
ML
ML 也产生了一些后代,最著名的是Standard ML和OCaml(1996 年),至今仍在使用。它也经常被引用为许多现代语言设计的影响。举几个例子:Go、Rust、Erlang、Haskell 和 Scala。
爱尔兰语的崛起
我之前说过,函数式语言的主流使用是在最近几年开始发生的。这并不完全正确。爱立信早在 1986 年就开始研究爱尔兰语,对函数式语言所承诺的稳定性和健壮性感兴趣。
起初,爱尔兰语是在Prolog之上实现的,但证明速度太慢,1992 年改用将 Erlang 编译为 C 的虚拟机进行重写,使得爱立信能够在 1995 年早期在生产电话系统上使用 Erlang。自那时起,它已经被全球电信公司使用,并被认为是高可用性时最好的语言之一。
Haskell
1990 年标志着 Haskell 的首次发布,这是全球学术界进行规范工作的结果,旨在创建第一个围绕惰性纯函数式语言的开放标准。其想法是将现有的函数式语言整合成一个共同的语言,以便成为进一步研究函数式语言设计的基础。
此后,Haskell 已经从纯学术语言发展成为领先的函数式语言之一。
Scala
Scala 的开发始于 2001 年,由前 Java 核心开发人员 Martin Odersky 发起。主要思想是通过将函数式编程与更传统的命令式概念混合在一起,使函数式编程更易接近。2004 年的首次公开发布同时面向 JVM 和.NET 使用的通用运行时语言(CRM)(第二个目标在 2012 年后被放弃)。
Scala 源代码可以与目标虚拟机的语言结构一起使用。直接使用现有的 Java 库以及能够回退到命令式风格是 Scala 迅速在企业界获得地位的原因之一。
由于 Android 使用了与 Java 兼容的虚拟机,Scala 非常适合移动开发,还有一个将其编译成 JavaScript 的倡议,这意味着你可以在服务器和客户端都使用它进行 Web 开发。
新来者
如今,函数式编程语言开始在主流中获得更多认可,并且新的语言也在学术界之外被创造出来。以下是世界各地人们正在积极开发的一些语言的快速概述。
Elm是一次认真的尝试,旨在创建一个编译成 JavaScript 的函数式语言,除了 ClojureScript 之外。这是 Evan Czaplicki 的论文的结果,试图创建一种函数式响应式语言,这是我们将在本书的最后一章中探讨的概念。几年前首次展示了一个时间旅行调试器(debug.elm-lang.org/),这个想法后来在 JavaScript 框架如React中以更多的痛苦实现。通过在线编辑器、非常好的教程以及可以使用npm进行安装,大大降低了入门门槛。
PureScript是另一种编译成 JavaScript 的函数式语言。它比 Elm 更接近 Haskell,并遵循更数学化的方法。社区规模较小,但正在进行大量工作,使语言更加用户友好。PureScript 编译器是用 Haskell 编写的,开始起步有点困难,但如果你想要健壮的客户端代码,这是值得的。
Idris在我看来,实际上还没有准备好在生产环境中大放异彩。然而,它在这个列表中有它的位置,因为它是实现依赖类型的更先进的函数语言之一。依赖类型是一个高级的类型概念,主要出现在纯学术语言中。这本书的范围超出了详细解释它的范围,但让我们做一个快速的例子:一对整数是一个类型;第二个整数大于第一个整数的一对整数是一个依赖类型,因为类型取决于变量的值。这样的类型系统的优势在于您可以更彻底地证明您的数据是正确的,因此您的软件的结果也是正确的。然而,这是一种非常高级的技术,这样的语言很少,而且很难学习。
函数式编程术语
像其他领域一样,函数式编程也有自己的术语。这个小词汇表的目标是使阅读本书更容易,同时为您提供更多对您在网上找到的资源的理解。
Arity
函数接受的参数数量。术语 nullary、unary、binary 和 ternary 也用于表示分别接受 0、1、2 和 3 个参数的函数。另请参见可变参数。
高阶函数
返回另一个函数的函数。《第一章》PHP 中的函数作为一等公民进一步解释了高阶函数的概念,因为这是函数式编程的基础之一。
副作用
任何影响当前函数外部世界的事物:改变全局状态,通过引用传递的变量,对象中的值,写入屏幕或文件,接受用户输入。这个概念是重要的,并将在本书的多个章节中进一步探讨。
纯度
如果一个函数只使用显式参数并且没有副作用,则称该函数是纯的。纯函数是一个在使用相同参数调用时总是产生完全相同结果的函数。纯语言是只允许纯函数的语言。这个概念是函数式编程的基石,正如《第二章》纯函数、引用透明度和不可变性中所讨论的那样。
函数组合
组合函数是一种有用的技术,可以重用各种函数作为构建块来实现更复杂的操作。您可以组合两个函数来创建一个新函数h,而不是总是在函数f的结果上调用函数g。《第四章》组合函数演示了如何使用这个想法。
不可变性
一旦赋值就不能更改的不可变变量。
部分应用
将给定值分配给函数的某些参数的过程,以创建一个较小 arity 的新函数。这有时被称为固定或绑定一个值到一个参数。这在 PHP 中有点难以实现,但《第四章》组合函数给出了一些如何做到这一点的想法。
柯里化
类似于部分应用,柯里化是将具有多个参数的函数转换为多个一元函数组合以实现相同结果的过程。柯里化的原因和思想在《第四章》组合函数中有介绍。
折叠/减少
将集合减少到单个值的过程。这是函数式编程中经常使用的概念,在《第三章》PHP 中的函数基础中有详细演示。
映射
在集合的所有值上应用函数的过程。这是函数式编程中经常使用的概念,并且在第三章,“PHP 中的函数基础”中得到了详细展示。
函子
任何类型的值或集合都可以应用映射操作。函子在给定函数时负责将其应用于其内部值。据说函子包装值。这个概念在第五章,“函子、应用程序和单子”中被提出。
应用程序
包含上下文中的函数的数据结构。应用程序在给定值时负责将“内部”函数应用于它。据说函子包装函数。这个概念在第五章,“函子、应用程序和单子”中被提出。
半群
任何类型,您可以将两个值关联起来。例如,字符串是一个半群,因为您可以将它们连接起来。
整数有多个半群:
-
加法半群,其中将整数相加
-
乘法半群,其中将整数相乘
单子
单子是一个同时具有标识值的半群。标识值是一个值,当与相同类型的对象关联时不会改变其值。整数的加法标识是 0,字符串的标识是空字符串。
单子还要求多个值的关联顺序不会改变结果,例如,(1 + 2) + 3 == 1 + (2 + 3)。
单子
单子既可以作为函子,也可以作为应用程序;有关更多信息,请参阅专用第五章,“函子、应用程序和单子”。
Lift/LiftA/LiftM
将某物放入函子、应用程序或单子的过程。
态射
转换函数。我们可以区分多种形态:
-
自同态:输入和输出的类型保持不变,例如,将字符串大写。
-
同构:类型改变,但数据保持不变,例如,将包含坐标的数组转换为坐标对象。
代数类型/联合类型
将两种类型组合成一种新类型。Scala 称这些为任一类型。
选项类型/可能类型
包含有效值和等效空值的联合类型。当函数不确定返回有效值时使用这种类型。第三章,“PHP 中的函数基础”解释了如何使用这些简化错误管理。
幂等性
如果重新应用函数到其结果不会产生不同的结果,则称函数是幂等的。如果将幂等函数与自身组合,它仍将产生相同的结果。
Lambda
匿名函数的同义词,即分配给变量的函数。
谓词
对于给定的一组参数返回 true 或 false 的函数。谓词经常用于过滤集合。
引用透明性
如果表达式可以被其值替换而不改变程序的结果,则称表达式是引用透明的。这个概念与纯度紧密相关。第二章,“纯函数、引用透明和不可变性”探讨了两者之间的细微差别。
惰性评估
如果表达式的结果只有在需要时才计算,则称语言是惰性评估的。这允许您创建无限列表,并且只有在表达式是引用透明时才可能。
非严格语言
非严格语言是一种所有构造都是惰性评估的语言。只有少数语言是非严格的,主要是因为语言必须是纯粹的才能是非严格的,并且它带来了非平凡的实现问题。最著名的非严格语言可能是 Haskell。
几乎所有常见的语言都是严格的:C、Java、PHP、Ruby、Python 等等。
可变元
具有动态元数的函数称为可变元。这意味着函数接受可变数量的参数。