函数式-PHP(全)

函数式 PHP(全)

原文:zh.annas-archive.org/md5/542d15e7552f9c0cf0925a989aaf5fc0

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

函数式编程是每年会议上都会出现的范式。JavaScript 社区可能是最早接触这个主题的社区之一,但现在这个主题也在使用其他各种语言的开发人员中讨论,比如 Ruby,Python 和 Java。

PHP 具有大多数开发所需的功能,可以开始使用函数式方法。你没有理由被置于一边,这本书旨在教授函数式编程的基础知识。

如果你完全是函数式编程的新手,或者想要复习基础知识并了解一些历史和好处,我建议你从附录开始。虽然它不是本书的第一章,因为内容与 PHP 没有直接关系,但它将帮助你将各种主题放入上下文,并更好地了解本书涵盖的主题。

本书涵盖的内容

第一章,“PHP 中的函数作为一等公民”,讨论了函数式编程,正如其名称所暗示的那样,围绕函数展开。在本章中,你将学习它们在 PHP 中可以被声明和使用的各种方式。

第二章,“纯函数,引用透明度和不可变性”,涵盖了任何函数式代码库的基石。你将学习它们的含义以及如何应用它们来获益。

第三章,“PHP 中的函数式基础”,讨论了函数式编程,就像任何范式一样,依赖于一些核心概念。本章将以简单的方式呈现它们,然后进一步讨论。

第四章,“组合函数”,描述了函数如何经常被用作构建块,使用函数组合。在这一章中,你将学习如何在 PHP 中进行函数组合,以及在这样做时需要牢记的重要性。

第五章,“函子,应用函子和单子”,从更简单的概念开始,如函子和应用函子,然后逐渐建立我们的知识,最终介绍单子的概念,消除一些围绕这个术语的恐惧。

第六章,“现实生活中的单子”,帮助你了解单子抽象的一些实际用法,以及如何使用它来编写更好的代码。

第七章,“函数式技术和主题”,涉及类型系统、模式匹配、无点风格等来自函数式编程广阔领域的主题。

第八章,“测试”,教你函数式编程不仅有助于编写更易理解和维护的代码,而且还有助于简化测试。

第九章,“性能效率”,让你知道在 PHP 中使用函数式技术是有成本的。我们将首先讨论它,然后看看它如何在其他与性能相关的主题中发挥作用。

第十章,“PHP 框架和 FP”,介绍了一种可以应用于改进任何项目中代码的技术,因为目前 PHP 中没有专门的函数式编程框架。

第十一章,“设计一个函数式应用程序”,将为你提供一些建议,如果你想使用尽可能多的函数式代码来开发整个应用程序。你还将了解函数式响应式编程和 RxPHP 库。

附录我们谈论函数式编程时在谈论什么?,是关于函数式编程的介绍和历史,以及它的好处和术语表。这实际上是你应该阅读的书的第一部分,但由于我们不是从 PHP 的角度来探讨这个主题,所以它被呈现为附录。

本书需要什么

你需要有一台安装了 PHP 的电脑。如果你知道如何使用命令行,那会更容易,但所有的例子也应该在浏览器中运行,也许需要做一些小的调整。

在学习函数式编程的同时,我还推荐使用一个 REPL(Read-Eval-Print-Loop)。在写这本书时,我个人使用了Boris。你可以在github.com/borisrepl/boris找到它。另一个很好的选择是PsySHpsysh.org)。

虽然不是必需的,但 REPL 将允许你快速测试你的想法,并在不必在编辑器和命令行之间切换的情况下玩弄本书中将介绍的各种概念。

我也假设你已经安装了 Composer,并且知道如何使用它来安装新的包;如果没有,你可以在getcomposer.org找到它。本书将介绍多个库,并且推荐使用 Composer 来安装它们。

本书中的所有代码都在 PHP 7.0 上进行了测试,这是推荐的版本。然而,它也应该在任何更新的版本上运行。在进行一些小的调整后,大多数示例也应该在 PHP 5.6 上运行。我们将在整本书中使用 PHP 7.0 引入的新标量类型提示功能,但如果你移除它们,代码应该可以轻松兼容较低版本。

这本书是为谁准备的

这本书不需要对函数式编程有任何了解;但需要有先前的编程经验。另外,面向对象编程的基本概念不会被深入讨论。

深入了解 PHP 语言并不是必需的,因为不常见的语法将会被解释。这本书应该可以被理解为一个从未写过一行 PHP 代码的人,只要付出一些努力。

这本书可以被视为一本关于 PHP 函数式编程的入门书,这意味着我们将逐步建立知识。然而,由于主题非常广泛,而且页面有限,我们有时会快速移动。这就是为什么我鼓励你在学习这些概念时玩一下,并在每章结束时花一些时间确保你正确理解了它。

约定

在本书中,你会发现一些文本样式,用于区分不同类型的信息。以下是一些这些样式的例子和它们含义的解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名都显示如下:“下面的代码行读取链接并将其分配给BeautifulSoup函数。”

代码块设置如下:

<?php
function getPrices(array $products) {
  $prices = [];
  foreach($products as $p) {
    if($p->stock > 0) {
      $prices[] = $p->price;
    }
  }
  return $prices;
}

当我们希望引起你对代码块的特定部分的注意时,相关的行或项目会以粗体显示:

<?php
function getPrices(array $products) {
  $prices = [];
  foreach($products as $p) {
    **if($p->stock > 0) {**
**      $prices[] = $p->price;**
**    }** }
  return $prices;
}

任何命令行输入或输出都是这样写的:

**composer require rx/stream** 

新术语重要单词以粗体显示。在屏幕上看到的单词,例如菜单或对话框中的单词,会在文本中以这种方式出现:“点击下一步按钮会将你移动到下一个屏幕。”

注意

警告或重要提示会以这样的方式出现。

提示

提示和技巧会以这种形式出现。

第一章:PHP 中的函数作为一等公民

函数式编程,顾名思义,围绕函数展开。为了有效地应用函数式技术,语言必须支持函数作为一等公民,也称为第一类函数

这意味着函数被视为任何其他值。它们可以被创建并作为参数传递给其他函数,并且它们可以作为返回值使用。幸运的是,PHP 就是这样一种语言。本章将演示函数可以被创建和使用的各种方式。

在这一章中,我们将涵盖以下主题:

  • 声明函数和方法

  • 标量类型提示

  • 匿名函数

  • 闭包

  • 将对象用作函数

  • 高阶函数

  • 可调用类型提示

开始之前

由于 PHP 7 的首次发布发生在 2015 年 12 月,因此本书中的示例将使用该版本。

然而,由于这是一个相当新的版本,每次使用新功能时,都将清楚地概述和解释。此外,由于并非每个人都能立即迁移,我们将尽可能提出在 PHP 5 上运行代码所需的更改。

撰写时可用的最新版本是 7.0.9。所有代码和示例都经过了这个版本的验证。

编码标准

本书中的示例将遵守PSR-2PHP 标准推荐 2)及其父推荐标准 PSR-1 的编码风格,大多数所介绍的库也应该如此。对于不熟悉它们的人,以下是最重要的部分:

  • 类位于命名空间中,使用首字母大写的驼峰命名法

  • 方法使用驼峰命名法,首字母不大写

  • 常量使用全大写字母书写

  • 类和方法的大括号在新行上,其他大括号在同一行上

此外,尽管没有在 PSR-2 中定义,但做出了以下选择:

  • 函数名称使用蛇形命名法

  • 参数、变量和属性名称使用蛇形命名法

  • 属性尽可能是私有的

自动加载和 Composer

示例还将假定存在一个符合 PSR-4 的自动加载器。

由于我们将使用 Composer 依赖管理器来安装所介绍的库,我们建议将其用作自动加载器。

函数和方法

尽管本书不是为 PHP 初学者设计的,但我们将快速介绍一些基础知识,以确保我们共享一个共同的词汇。

在 PHP 中,通常使用function关键字声明函数:

<?php 

function my_function($parameter, $second_parameter) 
{ 
    // [...] 
} 

在类内声明的函数称为方法。它与传统函数不同,因为它可以访问对象属性,具有可见性修饰符,并且可以声明为静态的。由于我们将尽量编写尽可能纯净的代码,我们的属性将是private类型:

<?php 

class SomeClass 
{ 
   private $some_property; 

   // a public function 
   public function some_function() 
   { 
       // [...] 
   } 

   // a protected static function 
   static protected function other_function() 
   { 
       // [...] 
   } 
} 

PHP 7 标量类型提示

在 PHP 5 中,您已经可以为类、可调用函数和数组声明类型提示。PHP 7 引入了标量类型提示的概念。这意味着您现在可以声明您想要stringintfloatbool数据类型,无论是参数还是返回类型。语法与其他语言中的语法大致相似。

与类类型提示相反,您还可以在严格模式和非严格模式之间进行选择,后者是默认模式。这意味着 PHP 将尝试将值转换为所需的类型。如果没有信息丢失,转换将会悄无声息地发生,否则将引发警告。这可能导致与字符串到数字转换或 true 和 false 值相同的奇怪结果。

以下是一些此类强制转换的示例:

<?php 

function add(float $a, int $b): float { 
    return $a + $b; 
} 

echo add(3.5, 1); 
// 4.5 
echo add(3, 1); 
// 4 
echo add("3.5", 1); 
// 4.5 
echo add(3.5, 1.2); // 1.2 gets casted to 1 
// 4.5 
echo add("1 week", 1); // "1 week" gets casted to 1.0 
// PHP Notice:  A non well formed numeric value encountered 
// 2 
echo add("some string", 1); 
// Uncaught TypeError Argument 1 passed to add() must be of the type float, string given 

function test_bool(bool $a): string { 
    return $a ? 'true' : 'false'; 
} 

echo test_bool(true); 
// true 
echo test_bool(false); 
// false 
echo test_bool(""); 
// false 
echo test_bool("some string"); 
// true 
echo test_bool(0); 
// false 
echo test_bool(1); 
// true 
echo test_bool([]); 
// Uncaught TypeError: Argument 1 passed to test_bool() must be of the type Boolean 

如果您想避免强制转换的问题,可以选择启用严格模式。这样,PHP 将在值不完全符合所需类型时引发错误。为此,必须在文件的第一行之前添加declare(strict_types=1)指令。它之前不能有任何内容。

PHP 允许的唯一转换是从intfloat,通过添加.0来实现,因为绝对不会有数据丢失的风险。

以下是与之前相同的示例,但启用了严格模式:

<?php 

declare(strict_types=1); 

function add(float $a, int $b): float { 
    return $a + $b; 
} 

echo add(3.5, 1); 
// 4.5 
echo add(3, 1); 
// 4 
echo add("3.5", 1); 
// Uncaught TypeError: Argument 1 passed to add() must be of the type float, string given 
echo add(3.5, 1.2); // 1.2 gets casted to 1 
// Uncaught TypeError: Argument 2 passed to add() must be of the type integer, float given 
echo add("1 week", 1); // "1 week" gets casted to 1.0 
// Uncaught TypeError: Argument 1 passed to add() must be of the type float, string given 
echo add("some string", 1); 
// Uncaught TypeError: Argument 1 passed to add() must be of the type float, string given 

function test_bool(bool $a): string { 
    return $a ? 'true' : 'false'; 
} 

echo test_bool(true); 
// true 
echo test_bool(false); 
// false 
echo test_bool(""); 
// Uncaught TypeError: Argument 1 passed to test_bool() must be of the type boolean, string given 
echo test_bool(0); 
// Uncaught TypeError: Argument 1 passed to test_bool() must be of the type boolean, integer given 
echo test_bool([]); 
// Uncaught TypeError: Argument 1 passed to test_bool() must be of the type boolean, array given 

尽管此处未进行演示,但返回类型也适用相同的转换规则。根据模式的不同,PHP 将愉快地执行相同的转换,并显示与参数提示相同的警告和错误。

另一个微妙之处是应用的模式是在进行函数调用的文件顶部声明的模式。这意味着当您调用在另一个文件中声明的函数时,不会考虑该文件的模式。只有当前文件顶部的指令才重要。

关于类型引发的错误,我们将在第三章中看到,PHP 中的函数基础,PHP 7 中的异常和错误处理发生了变化,您可以使用它来捕获这些错误。

从现在开始,只要有意义,我们的示例将使用标量类型提示,以使代码更健壮和可读。

强制类型可以被视为繁琐,并且在开始使用时可能会导致一些恼人的问题,但从长远来看,我可以向您保证,它将使您免受一些讨厌的错误。解释器可以执行的所有检查都是您无需自行测试的内容。

这也使得你的函数更容易理解和推理。查看你的代码的人不必问自己一个值可能是什么,他们确切地知道他们必须传递什么样的数据作为参数,以及他们将得到什么。结果是认知负担减轻了,你可以利用时间思考解决问题,而不是记住代码的琐碎细节。

匿名函数

您可能已经很熟悉我们看到的声明函数的语法。您可能不知道的是,函数不一定需要有名称。

匿名函数可以分配给变量,用作回调并具有参数。

在 PHP 文档中,匿名函数一词与闭包一词可以互换使用。正如我们将在下面的代码片段中看到的,匿名函数甚至是Closure类的一个实例,我们将讨论这一点。根据学术文献,尽管这两个概念相似,但有些不同。闭包一词的第一个用法是在 1964 年 Peter Landin 的《表达式的机械评估》中。在这篇论文中,闭包被描述为具有环境部分和控制部分。我们将在本节中声明的函数不会有任何环境,因此严格来说,它们不是闭包。

为了避免阅读其他作品时产生混淆,本书将使用匿名函数一词来描述没有名称的函数,就像本节中所介绍的那样:

<?php 

$add = function(float $a, float $b): float { 
    return $a + $b; 
}; 
// since this is an assignment, you have to finish the statement with a semicolon 

前面的代码片段声明了一个匿名函数,并将其分配给一个变量,以便我们稍后可以将其重用为另一个函数的参数或直接调用它:

$add(5, 10); 
$sum = array_reduce([1, 2, 3, 4, 5], $add, 0); 

如果您不打算重复使用,也可以直接将匿名函数声明为参数:

<?php 
$uppercase = array_map(function(string $s): string { 
  return strtoupper($s); 
}, ['hello', 'world']); 

或者您可以像返回任何其他类型的值一样返回一个函数:

<?php 

function return_new_function() 
{ 
  return function($a, $b, $c) { /* [...] */}; 
} 

闭包

正如我们之前所看到的,闭包的学术描述是指具有对外部环境的访问权限的函数。在本书中,尽管 PHP 使用后一种术语来称呼匿名函数和闭包,但我们将坚持这种语义。

您可能熟悉 JavaScript 的闭包,其中您可以简单地使用外部范围的任何变量而无需进行任何特殊操作。在 PHP 中,您需要使用use关键字将现有变量导入匿名函数的范围内:

<?php 

$some_variable = 'value'; 

$my_closure = function() use($some_variable) 
{ 
  // [...] 
}; 

PHP 闭包使用早期绑定方法。这意味着闭包内的变量将具有闭包创建时变量的值。如果之后更改变量,则闭包内将看不到更改:

<?php 

$s = 'orange'; 

$my_closure = function() use($s) { echo $s; }; 
$my_closure(); // display 'orange' 

$a = 'banana'; 
$my_closure(); // still display 'orange' 

你可以通过引用传递变量,以便变量的更改在闭包内部传播,但由于这是一本关于函数式编程的书,在这本书中我们尝试使用不可变数据结构并避免状态,因此如何做到这一点留给读者作为练习。

请注意,当你将对象传递给闭包时,对对象属性的任何修改都将在闭包内部可访问。PHP 在将对象传递给闭包时不会复制对象。

类内的闭包

如果你在类内声明任何匿名函数,它将自动通过通常的$this变量获得实例引用。为了保持词汇的一致性,该函数将自动变成一个闭包:

<?php 

class ClosureInsideClass 
{ 
    public function testing() 
    { 
        return function() { 
            var_dump($this); 
        }; 
    } 
} 

$object = new ClosureInsideClass(); 
$test = $object->testing(); 

$test(); 

如果你想避免这种自动绑定,你可以声明一个静态匿名函数:

<?php 

class ClosureInsideClass 
{ 
    public function testing() 
    { 
        return (static function() { 
            // no access to $this here, the following line 
            // will result in an error. var_dump($this); 
        }); 
    } 
}; 

$object = new ClosureInsideClass(); 
$test = $object->testing(); 

$test(); 

使用对象作为函数

有时,你可能希望将函数分成更小的部分,但这些部分不对所有人都可见。在这种情况下,你可以利用任何对象上的__invoke魔术方法,让你将实例作为函数使用,并将那个辅助函数隐藏为对象内部的私有方法:

<?php 

class ObjectAsFunction 
{ 
    private function helper(int $a, int $b): int 
    { 
        return $a + $b; 
    } 

    public function __invoke(int $a, int $b): int 
    { 
      return $this->helper($a, $b); 
    } 
} 

$instance = new ObjectAsFunction(); 
echo $instance(5, 10); 

__invoke方法将使用你传递给实例的任何参数进行调用。如果你愿意,你也可以为你的对象添加一个构造函数,并使用它包含的任何方法和属性。只需尽量保持纯净,因为一旦使用可变属性,你的函数将变得更难理解。

Closure

所有匿名函数实际上都是Closure类的实例。然而,正如文档中所述(php.net/manual/en/class.closure.php),这个类不使用前面提到的__invoke方法;这是 PHP 解释器中的一个特例:

除了这里列出的方法,这个类还有一个__invoke方法。这是为了与实现调用魔术的其他类保持一致,因为这个方法不用于调用函数。

类上的这个方法允许你更改$this变量在闭包内部绑定到哪个对象。你甚至可以将一个对象绑定到类外创建的闭包上。

如果你开始使用Closure类的特性,请记住call方法是在 PHP 7 中才被添加的。

高阶函数

PHP 函数可以将函数作为参数并返回函数作为返回值。执行任何这些操作的函数称为高阶函数。就是这么简单。

实际上,如果你阅读以下代码示例,你会很快发现我们已经创建了多个高阶函数。你还会发现,毫不奇怪,你将学到的大多数函数式技术都围绕着高阶函数。

什么是可调用?

callable是一种类型提示,可以用来强制函数的参数是可以调用的东西,比如一个函数。从 PHP 7 开始,它也可以用作返回值的类型提示:

<?php 

function test_callable(callable $callback) : callable { 
    $callback(); 
    return function() { 
        // [...] 
    }; 
} 

然而,类型提示无法强制可调用的参数数量和类型。但能够保证有可调用的东西已经很好了。

可调用可以采用多种形式:

  • 用于命名函数的字符串

  • 用于类方法或静态函数的数组

  • 匿名函数或闭包的变量

  • 带有__invoke方法的对象

让我们看看如何使用所有这些可能性。让我们从按名称调用一个简单的函数开始:

$callback = 'strtoupper'; 
$callback('Hello World !'); 

我们也可以对类内的函数做同样的操作。让我们声明一个带有一些函数的A类,并使用数组来调用它。

class A { 
    static function hello($name) { return "Hello $name !\n"; } 
    function __invoke($name) { return self::hello($name); } 
} 

// array with class name and static method name 
$callback = ['A', 'hello']; 
$callback('World'); 

使用字符串只对静态方法有效,因为其他方法将需要一个对象作为它们的上下文。对于静态方法,你也可以直接使用一个简单的字符串,但这只适用于 PHP 7 及更高版本;之前的版本不支持这种语法:

$callback = 'A::hello'; 
$callback('World'); 

您也可以轻松地在类实例上调用方法:

$a = new A(); 

$callback = [$a, 'hello']; 
$callback('World'); 

由于我们的A类具有__invoke方法,因此我们可以直接将其用作callable

$callback = $a; 
$callback('World'); 

您还可以使用任何变量,其中分配了一个匿名函数作为callable

$callback = function(string s) { 
    return "Hello $s !\n"; 
} 
$callback('World'); 

PHP 还为您提供了两个助手来调用函数,即call_user_func_arraycall_user_func。它们将可调用作为参数,并且您还可以传递参数。对于第一个助手,您传递一个包含所有参数的数组;对于第二个助手,您分别传递它们:

call_user_func_array($callback, ['World']); 

最后要注意的一点是,如果您使用了callable类型提示:任何包含已声明的函数名称的字符串都被视为有效;这有时会导致一些意外的行为。

一个有些牵强的例子是一个测试套件,您可以通过传递一些字符串来检查某些函数是否只接受有效的可调用对象,并捕获结果异常。在某个时候,您引入了一个库,现在这个测试失败了,尽管两者应该是无关的。发生的情况是,所涉及的库声明了一个与您的字符串完全相同的函数名称。现在,该函数存在,不再引发异常。

总结

在本章中,我们发现了如何创建新的匿名函数和闭包。您现在也熟悉了传递它们的各种方式。我们还了解了新的 PHP 7 标量类型提示,这有助于使我们的程序更加健壮,以及callable类型提示,这样我们就可以强制参数或返回值为有效函数。

对于那些已经使用 PHP 一段时间的人来说,本章可能没有什么新鲜的内容。但现在我们有了一个共同的基础,这将帮助我们进入函数式世界。

在 PHP 函数的基础知识介绍完毕后,我们将在下一章中了解有关函数式编程的基本概念。我们将看到,您的函数必须遵守某些规则,才能真正在函数式代码库中发挥作用。

第二章:纯函数、引用透明度和不可变性

阅读有关函数式编程的附录的人会发现,它围绕纯函数展开,换句话说,只使用其输入来产生结果的函数。

确定一个函数是否是纯的似乎很容易。只需检查是否调用了任何全局状态,对吗?遗憾的是,事情并不那么简单。函数产生副作用的方式也有多种。有些很容易发现,而其他一些则更难。

本章不会涵盖使用函数式编程的好处。如果您对好处感兴趣,我建议您阅读附录,其中深入讨论了这个主题。然而,我们将讨论不可变性和引用透明性所提供的优势,因为它们相当具体,并且在附录中只是粗略地提到。

在本章中,我们将涵盖以下主题:

  • 隐藏的输入和输出

  • 函数纯度

  • 不可变性

  • 引用透明度

两组输入和输出

让我们从一个简单的函数开始:

<?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可能被视为函数的第二个输入。

在这样简单的代码示例中,隐藏的输入和输出很容易发现。然而,随着时间的推移,尤其是在面向对象的代码库中,情况很快变得更加困难。不要误解,任何隐藏的东西,即使以最明显的方式隐藏,都可能产生后果,比如:

  • 增加认知负担。现在你必须考虑SessionPlayer类中发生了什么。

  • 相同的输入参数可能导致测试结果不同,因为软件的某些其他状态已经改变,导致难以理解的行为。

  • 函数签名或 API 并不清楚您可以从函数中期望什么,这使得有必要阅读它们的代码或文档。

这两个看起来简单的函数的问题在于它们需要读取和更新程序的现有状态。本章的主题还不是向您展示如何更好地编写它们,我们将在第六章《真实生活中的单子》中讨论这个问题。

对于习惯于依赖注入的读者来说,第一个函数使用了静态调用,可以通过注入 Session 变量的实例来避免。这样做将解决隐藏输入的问题,但我们修改$_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 作用域规则对我们有利。任何你能发现这个关键字的函数很可能是不纯的。

第二个示例中的静态关键字是一个很好的指示,表明我们可能会尝试在函数调用之间存储状态。在这个例子中,它是一个在每次运行时递增的计数器。该函数显然是不纯的。然而,与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'字符串。

然而,这与引用透明的概念是不兼容的,我们将在本章后面讨论。

结束语

正如我们在前面的例子中所尝试展示的,尝试确定一个函数是否是纯函数可能在开始时会有些棘手。但是当你开始对此有所感觉时,你会变得更快更舒适。

检查函数是否是纯函数的最佳方法是验证以下内容:

  • 使用全局关键字是一个明显的暴露

  • 检查是否使用了任何不是函数本身参数的值

  • 验证你的函数调用的所有函数也都是纯函数

  • 任何对外部存储的访问都是不纯的(数据库和文件)

  • 特别注意那些返回值依赖于外部状态(timerandom)的函数

现在你知道如何发现那些不纯的函数了,你可能想知道如何使它们成为纯函数。遗憾的是,对于这个请求并没有简单的答案。接下来的章节将尝试提供避免不纯性的配方和模式。

不可变性

我们说一个变量是不可变的,如果它一旦被赋值,就不能改变其内容。在函数纯度之后,这是函数式编程中第二重要的事情。

在一些学术语言中,比如Haskell,你根本无法声明变量。一切都必须是函数。由于所有这些函数也都是纯函数,这意味着你可以免费获得不可变性。其中一些语言提供了一些类似变量声明的语法糖,以避免总是声明函数可能带来的繁琐。

大多数函数式语言只允许声明不可变变量或具有相同目的的构造。这意味着你有一种存储数值的方式,但是在初始赋值后无法更改数值。也有一些语言让你为每个变量选择你想要的方式。例如,在 Scala 中,你可以使用var关键字声明传统的可变变量,使用val关键字声明不可变变量。

然而,大多数语言,比如 PHP,对变量没有不可变性的概念。

为什么不可变性很重要?

首先,它有助于减少认知负担。在算法中记住所有涉及的变量已经相当困难了。没有不可变性,你还需要记住所有值的变化。对于人类大脑来说,将一个值与特定标签(即变量名)关联起来要容易得多。如果你能确信数值不会改变,推理发生的事情就会容易得多。

另外,如果你有一些全局状态是无法摆脱的,只要它是不可变的,你可以在你附近的一张纸上记录数值并保留以供参考。无论执行过程中发生了什么,所写的内容始终是程序的当前状态,这意味着你不必启动调试器或回显变量来确保数值没有改变。

想象一下,你将一个对象传递给一个函数。你不知道这个函数是否是纯函数,也就是说对象的属性可能会被改变。这会让你感到担忧,分散你的思绪。你必须问自己内部状态是否改变,这降低了你推理代码的能力。如果你的对象是不可变的,你可以百分之百地确信它和以前一样,加快你对发生的事情的理解。

你还可以获得与线程安全和并行化相关的优势。如果你的所有状态都是不可变的,那么确保你的程序能够在多个核心或多台计算机上同时运行就会更容易得多。大多数并发问题发生在某个线程在没有正确与其他线程同步的情况下修改了一个值。这导致它们之间的不一致,通常会导致计算错误。如果你的变量是不可变的,只要所有线程都收到了相同的数据,这种情况发生的可能性就会小得多。然而,由于 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 有一个相当不错的垃圾收集器,这意味着当对象不再使用时,内存会被清理得相当有效。而且我们通常使用相对较小的数据结构,这意味着几乎相同数据的创建速度相当快。

使用常量

你可以使用常量和类常量来实现某种不可变性,但它们只适用于标量值。目前,你无法将它们用于对象或更复杂的数据结构。由于这是唯一可用的选项,让我们还是来看一下吧。

你可以声明包含任何标量值的全局可用常量。从 PHP 5.6 开始,当使用const关键字时,你还可以在常量中存储标量值的数组,并且自 PHP 7 开始,使用定义语法也可以。

常量名称必须以字母或下划线开头,不能以数字开头。通常,常量都是大写的,这样它们就很容易被发现。以下划线开头也是不鼓励的,因为它可能与 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 

然而,从 PHP 5.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; 
}

在撰写本文时,PHP 7.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 的工作,以在语言级别包含它(wiki.php.net/rfc/immutability)。

对于不了解新 PHP 功能涉及的流程的人来说,请求评论RFC)是核心团队成员提出向 PHP 添加新内容的建议。该建议首先经过草案阶段,在此阶段编写并进行了一些示例实现。之后,进行讨论阶段,其他人可以提供建议和建议。最后,进行投票决定是否将该功能包含在下一个 PHP 版本中。

在撰写本文时,不可变类和属性 RFC 仍处于草案阶段。对此既没有真正的赞成意见,也没有反对意见。只有时间会告诉我们它是否被接受。

值对象

来自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; 
    } 
} 

这种模式可以用于创建从数据使用者的角度来看是不可变的数据实体。但是,您必须特别小心,以确保类上的所有方法都不会破坏不可变性;否则,您所有的努力都将是徒劳的。

除了不可变性之外,使用值对象还有其他好处。您可以在对象内部添加一些业务或领域逻辑,从而将所有相关内容保持在同一位置。此外,如果您使用它们而不是数组,您可以:

  • 将它们用作类型提示,而不仅仅是数组

  • 避免由于拼写错误的数组键而导致任何可能的错误

  • 强制存在或格式化某些项目

  • 提供格式化值以适应不同上下文的方法

值对象的常见用途是存储和操作与货币相关的数据。您可以查看money.rtfd.org,这是一个很好的如何有效使用它们的示例。

另一个对于真正重要的代码片段使用值对象的例子是PSR-7: "HTTP 消息接口"。这个标准引入并规范了一种框架和应用程序以可互操作的方式管理 HTTP 请求和响应的方法。所有主要的框架都有核心支持或可用的插件。我邀请您阅读他们为什么应该在 PHP 生态系统的如此重要的部分使用不可变性的完整理由:www.php-fig.org/psr/psr-7/meta/#why-value-objects

从本质上讲,将 HTTP 消息建模为值对象可以确保消息状态的完整性,并且可以避免双向依赖的需要,这往往会导致不同步或导致调试或性能问题。

总的来说,值对象是在 PHP 中获得某种不可变性的好方法。您不会获得所有的好处,特别是与性能相关的好处,但大部分认知负担都被移除了。进一步探讨这个话题超出了本书的范围;如果您想了解更多,可以访问专门的网站:www.phpvalueobjects.info/

不可变集合的库

如果您想进一步走向不可变性之路,至少有两个库提供不可变集合:Laravel 集合immutable.php

这两个库都协调了与数组相关的 PHP 函数的参数顺序的差异,比如array_maparray_filter。它们还提供了与大多数 PHP 函数相反的工作任何类型的IterableTraversable的可能性;这些函数通常需要一个真正的数组。

本章将只是快速介绍这些库。示例用法将在第三章中给出,PHP 中的功能基础,以便它们可以与允许执行相同任务的其他库一起显示。此外,我们还没有详细介绍诸如映射或折叠等技术,因此示例可能不够清晰。

Laravel 集合

Laravel 框架包含一个名为Collection的类,用于取代 PHP 数组。这个类在内部使用一个简单的数组,但可以使用 collect 辅助函数从任何集合类型的变量创建。然后,它提供了许多非常有用的方法来处理数据,主要以一种功能性的方式。这也是 Laravel 的一个核心部分,因为Eloquent,ORM,将数据库实体作为Collection实例返回。

如果您不使用 Laravel,但仍希望从这个优秀的库中受益,您可以使用github.com/tightenco/collect,这只是从 Laravel 支持包的其余部分中分离出来的 Collection 部分,以保持小巧。您也可以参考 Laravel 集合的官方文档(laravel.com/docs/5.3/collections)。

Immutable.php

这个库定义了ImmArray类,它实现了一个类似数组的不可变集合。

ImmArray类是SplFixedArray类的包装器,用于修复其 API 的一些缺陷,提供了通常希望在集合上执行的性能操作的方法。在幕后使用SplFixedArray类的优势在于其实现是用 C 编写的,性能非常高且内存效率高。您可以参考 GitHub 存储库以获取有关 Immutable.php 的更多信息: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); 

上面定义的所有函数都是纯函数,由于我们没有可变的数据结构,它们也是引用透明的。现在,为了更好地理解我们的代码片段,我们可以使用一种称为等式推理的技术。这个想法非常简单,你只需要用等于替换等于来推理代码。在某种程度上,这就像手动评估代码。

让我们首先将我们的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 允许你通过数学函数定义无限列表。语言的惰性特性确保列表的值只在需要值时才计算。

在术语表中,我们将非严格语言定义为评估发生惰性的语言。事实上,惰性和非严格性之间有一些细微差别。如果你对细节感兴趣,你可以访问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 是一种非严格语言,只有我们需要的值才会被计算,从而将所需的时间减少了一半。情况甚至更好,因为返回值甚至没有保存在一个新变量中,可能根本不需要执行函数。

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 返回 false 值,第二次调用也将被执行

  • 你的方法的返回值将自动转换为布尔值

代码可读性

当你的变量和函数评估是惰性的时,你可以花更少的时间考虑声明的最佳顺序,甚至你计算的数据是否会被使用。相反,你可以专注于编写可读的代码。想象一个博客应用程序有很多帖子、标签、类别,并按年份存档。你是想为每个页面编写自定义查询,还是使用惰性评估,如下所示:

<?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 的 Collection 的 API 的启发。

无限列表或流

惰性求值允许你创建无限列表。在 Haskell 中,要获取所有正整数的列表,你可以简单地使用[1..]。然后,如果你想要前十个数字,你可以取10 [1..]。我承认这个例子并不是很令人兴奋,但更复杂的例子更难理解。

PHP 自版本 5.5 起支持生成器。你可以通过使用它们来实现类似无限列表的东西。例如,我们所有正整数的列表如下:

<?php 

function integers() 
{ 
    $i=0; 
    while(true) yield $i++; 
} 

然而,懒惰无限列表和我们的生成器之间至少有一个显著的区别。你可以对 Haskell 版本执行任何你通常对集合执行的操作-例如计算其长度和对其进行排序。而我们的生成器是一个Iterator,如果你尝试在其上使用iterator_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; 

再次强调,这只是一个很简单的例子。这种优化可以在整个代码库的范围内进行。

记忆化

记忆化是一种技术,它可以缓存给定参数集的函数的结果,这样你就不必在下一次调用时再次执行它。我们将在第八章性能效率中详细讨论这个问题。现在,让我只说一下,如果你的语言只具有引用透明表达式,它可以在需要时自动执行记忆化。

这意味着它可以根据调用的频率和其他各种参数来决定是否值得自动记忆函数,而无需开发人员的干预或提示。

PHP 中的一切?

如果 PHP 开发人员只能从其中的一小部分优势中受益,那么为什么要费心纯函数、不可变性,最终是引用透明呢?

首先,就像不可变性的 RFC 一样,事情正在朝着正确的方向发展。这意味着,最终,PHP 引擎将开始纳入一些先进的编译器技术。当这发生时,如果你的代码库已经使用了这些函数式技术,你将获得巨大的性能提升。

其次,在我看来,所有这些的主要好处是减少认知负担。当然,要适应这种新的编程风格需要一些时间。但一旦你练习了一下,你很快就会发现你的代码更容易阅读和理解。其结果是你的应用程序将包含更少的错误。

最后,如果你愿意使用一些外部库,或者如果你能够应对语法并不总是很完善的情况,你现在就可以从其他改进中受益了。显然,我们无法改变 PHP 的核心以添加我们之前谈到的编译器优化,但在接下来的章节中,我们将看到一些引用透明性的好处是如何被模拟的。

总结

这一章包含了很多理论。希望你不会介意太多。这是必要的,以奠定我们共同词汇的基础,并解释为什么这些概念很重要。你现在很清楚纯度和不可变性是什么,也学会了一些识别不纯函数的技巧。我们还讨论了这两个属性如何导致了所谓的引用透明性以及好处是什么。

我们也了解到,遗憾的是,PHP 并不原生支持大部分的好处。然而,关键的收获是使用函数式方法减少了理解代码的认知负担,从而使其更容易阅读。最终的好处是现在你的代码将更容易维护和重构,你可以快速找到并修复错误。通常,纯函数也更容易测试,这也会导致更少的错误。

现在我们已经很好地讨论了理论基础,接下来的章节将专注于帮助我们在软件中实现纯度和不可变性的技术。

第三章:PHP 中的功能基础

在第一章中介绍了 PHP 中的函数,接着是第二章中的函数式编程的理论方面,我们最终将开始编写真正的代码。我们将从 PHP 中可用的函数开始,这些函数允许我们编写功能性代码。一旦基本技术得到很好的理解,我们将转向各种库,这些库将在整本书中帮助我们。

在本章中,我们将涵盖以下主题:

  • 映射、折叠、减少和压缩

  • 递归

  • 为什么异常会破坏引用透明度

  • 使用 Maybe 和 Either 类型更好地处理错误的方法

  • PHP 中可用的功能性库

一般建议

在前几章中,我们描述了功能应用程序必须具有的重要属性。然而,我们从未真正讨论过如何实现它。除了我们将在以后学习的各种技术之外,还有一些简单的建议可以立即帮助您。

使所有输入明确

我们在上一章中大量讨论了纯度和隐藏输入,或者副作用。现在,应该很清楚,函数的所有依赖关系都应该作为参数传递。然而,这个建议还要进一步。

避免将对象或复杂数据结构传递给您的函数。尽量限制输入到必要的内容。这样做将使您的函数范围更容易理解,并且将有助于确定函数的操作方式。它还具有以下好处:

  • 调用将更容易

  • 测试它将需要较少的数据存根

避免临时变量

正如您可能已经了解的那样,状态是邪恶的,特别是全局状态。然而,局部变量是一种局部状态。一旦您开始在代码中频繁使用它们,您就慢慢地打开了潘多拉的魔盒。这在 PHP 这样的语言中尤其如此,因为所有变量都是可变的。如果值在途中发生变化会发生什么?

每次声明一个变量,你都必须记住它的值,才能理解代码的其余部分是如何工作的。这大大增加了认知负担。此外,由于 PHP 是动态类型的,一个变量可以被完全不同的数据重复使用。

使用临时变量时,总会存在某种方式修改或重复使用的风险,导致难以调试的错误。

在几乎所有情况下,使用函数比使用临时变量更好。函数允许获得相同的好处:

  • 通过命名中间结果来提高可读性

  • 避免重复自己

  • 缓存冗长操作的结果(这需要使用备忘录,我们将在第八章中讨论,性能效率

调用函数的额外成本通常是微不足道的,不会打破平衡。此外,使用函数而不是临时变量意味着您可以在其他地方重用这些函数。它们还可以使未来的重构更容易,并且可以改善关注点的分离。

正如最佳实践所期望的那样,有时使用临时变量会更容易一些。例如,如果您需要在一个短函数中存储一个返回值,以便在之后立即使用,以便保持行长度舒适,请毫不犹豫地这样做。唯一严格禁止的是使用相同的临时变量来存储各种不同的信息。

更小的函数

我们已经提到函数就像积木一样。通常,您希望您的积木多才多艺且坚固。如果您编写只专注于做一件事情的小函数,那么这两个属性都会得到更好的强化。

如果您的函数做得太多,很难重用。我们将在下一章中讨论如何组合函数,以及如何利用所有小型实用函数来创建具有更大影响力的新函数。

此外,阅读较小的代码片段并对其进行推理更容易。相关的影响更容易理解,通常情况下边界情况更少,使函数更容易测试。

参数顺序很重要

选择函数参数的顺序似乎并不重要,但实际上它很重要。高阶函数是函数式编程的核心特性;这意味着你将会传递很多函数。

这些函数可以是匿名的,这种情况下,出于可读性的考虑,你可能希望避免将函数声明作为中间参数。在 PHP 中,可选参数也受到签名末尾的限制。正如我们将看到的,一些函数构造可以接受具有默认值的函数。

我们还将在第四章组合函数中进一步讨论这个话题。当你将多个函数链接在一起时,每个函数的第一个参数是前一个函数的返回值。这意味着在选择哪些参数先传递时,你需要特别小心。

映射函数

在 PHP 中,map 或array_map方法是一个高阶函数,它将给定的回调应用于集合的所有元素。return值是按顺序排列的集合。一个简单的例子是:

<?php 

function square(int $x): int 
{ 
    return $x * $x; 
} 
$squared = array_map('square', [1, 2, 3, 4]); 
// $squared contains [1, 4, 9, 16] 

我们创建一个计算给定整数的平方的函数,然后使用array_map函数来计算给定数组的所有平方值。array_map函数的第一个参数可以是任何形式的 callable,第二个参数必须是一个真实的数组。你不能传递一个迭代器或一个 Traversable 的实例。

你也可以传递多个数组。你的回调将从每个数组中接收一个值:

<?php 

$numbers = [1, 2, 3, 4]; 
$english = ['one', 'two', 'three', 'four']; 
$french = ['un', 'deux', 'trois', 'quatre']; 

function translate(int $n, string $e, string $f): string 
{ 
    return "$n is $e, or $f in French."; 
} 
print_r(array_map('translate', $numbers, $english, $french)); 

这段代码将显示:

Array 
( 
    [0] => 1 is one, or un in French. [1] => 2 is two, or deux in French. [2] => 3 is three, or trois in French. [3] => 4 is four, or quatre in French. ) 

最长的数组将决定结果的长度。较短的数组将用 null 值扩展,以使它们的长度相匹配。

如果你将 null 作为函数传递,PHP 将合并这些数组:

<?php 

print_r(array_map(null, [1, 2], ['one', 'two'], ['un', 'deux'])); 

结果是:

Array 
( 
    [0] => Array 
        ( 
            [0] => 1 
            [1] => one 
            [2] => un 
        ) 
    [1] => Array 
        ( 
            [0] => 2 
            [1] => two 
            [2] => deux 
        ) 
) 

如果你只传递一个数组,键将被保留;但如果你传递多个数组,它们将丢失:

<?php 
  function add(int $a, int $b = 10): int 
  { 
      return $a + $b; 
  } 

  print_r(array_map('add', ['one' => 1, 'two' => 2])); 
  print_r(array_map('add', [1, 2], [20, 30])); 

结果是:

Array 
( 
    [one] => 11 
    [two] => 12 
) 
Array 
( 
    [0] => 21 
    [1] => 32 
) 

最后要注意的是,很遗憾,无法轻松访问每个项目的键。然而,你的回调可以是一个闭包,因此你可以使用来自你上下文的任何变量。利用这一点,你可以在数组的键上进行映射,并使用闭包来检索值:

$data = ['one' => 1, 'two' => 2];

array_map(function to_string($key) use($data) {
    return (str) $data[$key];
}, 
array_keys($data);

过滤函数

在 PHP 中,filter 或array_filter方法是一个高阶函数,它基于布尔谓词仅保留集合的某些元素。return值是仅包含谓词函数返回 true 的元素的集合。一个简单的例子是:

<?php

function odd(int $a): bool
{
    return $a % 2 === 1;
}

$filtered = array_filter([1, 2, 3, 4, 5, 6], 'odd');
/* $filtered contains [1, 3, 5] */

我们首先创建一个接受值并返回布尔值的函数。这个函数将是我们的谓词。在我们的例子中,我们检查一个整数是否是奇数。与array_map方法一样,谓词可以是任何callable,集合必须是一个数组。然而,请注意参数顺序是相反的;集合首先出现。

回调是可选的;如果你不提供一个,PHP 将过滤掉所有会被评估为 false 的元素,比如空字符串和数组:

<?php

$filtered = array_filter(["one", "two", "", "three", ""]); 
/* $filtered contains ["one", "two", "three"] */

$filtered = array_filter([0, 1, null, 2, [], 3, 0.0]); 
/* $filtered contains [1, 2, 3] */

你也可以传递第三个参数,作为一个标志,确定你想要接收键还是值,或者两者都要:

<?php

$data = [];
function key_only($key) { 
    // [...] 
}

$filtered = array_filter($data, 'key_only', ARRAY_FILTER_USE_KEY);

function both($value, $key) { 
    // [...] 
}

$filtered = array_filter($data, 'both', ARRAY_FILTER_USE_BOTH);

折叠或减少函数

折叠是指使用组合函数将集合减少为返回值的过程。根据语言的不同,这个操作可能有多个名称,如 fold、reduce、accumulate、aggregate 或 compress。与与数组相关的其他函数一样,在 PHP 中的版本是array_reduce函数。

你可能熟悉array_sum函数,它计算数组中所有值的总和。实际上,这是一种折叠操作,可以很容易地使用array_reduce函数来实现:

<?php

function sum(int $carry, int $i): int
{
    return $carry + $i;
}

$summed = array_reduce([1, 2, 3, 4], 'sum', 0);
/* $summed contains 10 */

array_filter方法一样,首先是集合;然后传递一个回调,最后是一个可选的初始值。在我们的情况下,我们被迫传递初始值 0,因为默认的 null 对于我们的 int 类型函数签名是无效的类型。

回调函数有两个参数。第一个是基于所有先前项目的当前减少值,有时称为carryaccumulator。第二个是当前正在处理的数组元素。在第一次迭代中,carry 等于初始值。

您不一定需要使用元素本身来生成值。例如,您可以使用 fold 实现in_array的简单替代:

<?php

function in_array2(string $needle, array $haystack): bool
{
    $search = function(bool $contains, string $item) use ($needle):bool 
    {
        return $needle == $item ? true : $contains;
    };
    return array_reduce($haystack, $search, false);
}

var_dump(in_array2('two', ['one', 'two', 'three']));
// bool(true)

reduce 操作从初始值 false 开始,因为我们假设数组不包含我们要找的项目。这也使我们能够很好地处理数组为空的情况。

对于每个项目,如果项目是我们正在搜索的项目,我们返回 true,这将是新的传递值。如果不匹配,我们只需返回累加器的当前值,它将是true(如果我们之前找到了项目)或false(如果我们没有找到)。

我们的实现可能比官方的慢一点,因为无论如何,我们都必须在返回结果之前遍历整个数组,而不能在遇到搜索项目时立即退出函数。

然而,我们可以实现一个 max 函数的替代方案,性能应该是相当的,因为任何实现都必须遍历所有值:

<?php

function max2(array $data): int
{
    return array_reduce($data, function(int $max, int $i) : int 
    {
        return $i > $max ? $i : $max;
    }, 0);
}

echo max2([5, 10, 23, 1, 0]);
// 23

这个想法和之前一样,只是使用数字而不是布尔值。我们从初始值0开始,我们的当前最大值。如果我们遇到更大的值,我们返回它,以便传递。否则,我们继续返回我们当前的累加器,已经包含到目前为止遇到的最大值。

由于 max PHP 函数适用于数组和数字,我们可以重用它来进行减少。然而,这将带来没有意义,因为原始函数已经可以直接在数组上操作:

<?php

function max3(array $data): int
{
    return array_reduce($data, 'max', 0);
}

只是为了明确起见,我不建议在生产中使用这些。语言中已经有更好的函数。这些只是为了教育目的,以展示折叠的各种可能性。

我完全理解,这些简短的示例可能不比foreach循环或其他更命令式的方法更好,来实现这两个函数。但是它们有一些优点:

  • 如果您使用 PHP 7 标量类型提示,每个项目的类型都会被强制执行,使您的软件更加健壮。您可以通过将字符串放入用于max2方法的数组来验证这一点。

  • 您可以对传递给array_reduce方法的函数进行单元测试,或者对array_maparray_filter函数进行测试,以确保其正确性。

  • 如果您有这样的架构,您可以在多个线程或网络节点之间分发大数组的减少。这在foreach循环中将会更加困难。

  • 正如max3函数所示,这种方法允许您重用现有方法,而不是编写自定义循环来操作数据。

使用 fold 的 map 和 filter 函数

目前,我们的fold只返回简单的标量值。但没有什么能阻止我们构建更复杂的数据结构。例如,我们可以使用fold来实现 map 和 filter 函数:

<?php 

function map(array $data, callable $cb): array 
{ 
    return array_reduce($data, function(array $acc, $i) use ($cb) { 
        $acc[] = $cb($i); 
        return $acc; 
    }, []);     
} 

function filter(array $data, callable $predicate): array 
{ 
  return array_reduce($data, function(array $acc, $i)  use($predicate) { 
      if($predicate($i)) { 
          $acc[] = $i; 
      } 
      return $acc; 
  }, []); 
} 

再次强调,这些大部分是为了演示使用折叠返回数组是可能的。如果您不需要操作更复杂的集合,原生函数就足够了。

作为读者的练习,尝试实现map_filterfilter_map函数,如果您愿意,还可以尝试编写 head 和 tail 方法,它们分别返回数组的第一个和最后一个元素,并且通常在函数式语言中找到。

正如您所看到的,折叠是非常强大的,其背后的思想对许多函数式技术至关重要。这就是为什么我更喜欢谈论折叠而不是缩减,我觉得这有点简化,双关语。

在继续之前,请确保您了解折叠的工作原理,因为这将使其他所有事情都变得更容易。

左折叠和右折叠

函数式语言通常实现了两个版本的折叠,foldlfoldr。区别在于第一个从左边折叠,第二个从右边折叠。

例如,如果你有数组[1, 2, 3, 4, 5],你想计算它的总和,你可以有(((1 + 2) + 3) + 4) + 5(((5 + 4) + 3) + 2) + 1。如果有一个初始值,它将始终是计算中使用的第一个值。

如果您应用于值的操作是可交换的,左右两个变体都将产生相同的结果。可交换操作的概念来自数学,在第七章 Functional Techniques and Topics中有解释。

对于允许无限列表的语言,比如 Haskell,取决于列表是如何生成的,两种折叠中的一种可能能够计算一个值并停止。此外,如果语言实现了尾递归消除,一个正确的折叠起始点可能会避免堆栈溢出并允许操作完成。

由于 PHP 不执行无限列表或尾递归消除,我认为没有理由去区分。如果您感兴趣,array_reduce函数从左边折叠,实现一个从右边折叠的函数不应该太复杂。

MapReduce 模型

你可能已经听说过MapReduce编程模型的名字。起初,它指的是 Google 开发的专有技术,但如今在各种语言中有多种实现。

尽管 MapReduce 背后的思想受到我们刚讨论的 map 和 reduce 函数的启发,但这个概念更广泛。它描述了使用并行和分布式算法在集群上处理大型数据集的整个模型。

本书中学到的每一种技术都可以帮助您实现 MapReduce 来分析数据。然而,这个话题超出了范围,所以如果您想了解更多,可以从维基百科页面开始访问en.wikipedia.org/wiki/MapReduce

卷积或 zip

卷积,或更常见的 zip 是将所有给定数组的每个第 n 个元素组合在一起的过程。事实上,这正是我们之前通过向array_map函数传递 null 值所做的:

<?php 

print_r(array_map(null, [1, 2], ['one', 'two'], ['un', 'deux'])); 

输出为:

Array 
( 
    [0] => Array 
        ( 
            [0] => 1 
            [1] => one 
            [2] => un 
        ) 
    [1] => Array 
        ( 
            [0] => 2 
            [1] => two 
            [2] => deux 
        ) 
) 

重要的是要注意,如果数组的长度不同,PHP 将使用 null 作为填充值:

<?php 

$numerals = [1, 2, 3, 4]; 
$english = ['one', 'two']; 
$french = ['un', 'deux', 'trois']; 

print_r(array_map(null, $numerals, $english, $french)); 
Array 
( 
    [0] => Array 
        ( 
            [0] => 1 
            [1] => one 
            [2] => un 
        ) 
    [1] => Array 
        ( 
            [0] => 2 
            [1] => two 
            [2] => deux 
        ) 
    [2] => Array 
        ( 
            [0] => 3 
            [1] => 
            [2] => trois 
        ) 
    [3] => Array 
        ( 
            [0] => 4 
            [1] => 
            [2] => 
        ) 
) 

请注意,在大多数编程语言中,包括 Haskell、Scala 和 Python,在 zip 操作中,将停止在最短的数组处,而不会填充任何值。您可以尝试在 PHP 中实现类似的功能,例如使用array_slice函数将所有数组减少到相同的大小,然后调用array_merge函数。

我们还可以通过从数组中创建多个数组来执行反向操作。这个过程有时被称为unzip。这里是一个天真的实现,缺少了很多检查,使其足够健壮用于生产:

<?php 

function unzip(array $data): array 
{ 
    $return = []; 

    $data = array_values($data); 
    $size = count($data[0]); 

    foreach($data as $child) { 
        $child = array_values($child); 
        for($i = 0; $i < $size; ++$i) { 
            if(isset($child[$i]) && $child[$i] !== null) { 
                $return[$i][] = $child[$i]; 
            } 
        } 
    } 

    return $return; 
} 

你可以像这样使用它:

$zipped = array_map(null, $numerals, $english, $french); 

list($numerals2, $english2, $french2) = unzip($zipped); 

var_dump($numerals == $numerals2); 
// bool(true) 
var_dump($english == $english2); 
// bool(true) 
var_dump($french == $french2); 
// bool(true) 

递归

在学术意义上,递归是将问题分解为相同问题的较小实例的想法。例如,如果您需要递归扫描一个目录,您首先扫描起始目录,然后扫描其子目录和子目录的子目录。大多数编程语言通过允许函数调用自身来支持递归。这个想法通常被描述为递归。

让我们看看如何使用递归扫描目录:

<?php 

function searchDirectory($dir, $accumulator = []) { 
    foreach (scandir($dir) as $path) { 
        // Ignore hidden files, current directory and parent directory 
        if(strpos($path, '.') === 0) { 
            continue; 
        } 

        $fullPath = $dir.DIRECTORY_SEPARATOR.$path; 

        if(is_dir($fullPath)) { 
            $accumulator = searchDirectory($path, $accumulator); 
        } else { 
            $accumulator[] = $fullPath; 
        } 
    } 
    return $accumulator; 
} 

我们首先使用scandir函数获取所有文件和目录。然后,如果遇到子目录,我们再次调用该函数。否则,我们只需将文件添加到累加器中。这个函数是递归的,因为它调用自身。

您可以使用控制结构来编写这个函数,但是由于您无法预先知道文件夹层次结构的深度,代码可能会变得更加混乱和难以理解。

有些书籍和教程使用斐波那契数列,或计算阶乘作为递归示例,但公平地说,这些示例相当差,因为最好使用传统的for循环来实现第二个示例,并且对于第一个示例,提前计算项更好。

相反,让我们思考一个更有趣的挑战,Hanoi Towers。对于不了解这个游戏的人来说,传统版本的游戏包括三根杆,上面堆叠着不同大小的圆盘,最小的在顶部。在游戏开始时,所有圆盘都在最左边的杆上,目标是将它们移到最右边的杆上。游戏遵循以下规则:

  • 一次只能移动一个圆盘

  • 只能移动杆的顶部圆盘

  • 不能将一个圆盘放在较小的圆盘上方

这个游戏的设置如下:

递归

如果我们想解决这个游戏,较大的圆盘必须首先放在最后一个杆上。为了做到这一点,我们需要先将所有其他圆盘移到中间的杆上。沿着这种推理方式,我们可以得出我们必须实现的三个大步骤:

  1. 将所有的圆盘移动到中间,除了最大的一个。

  2. 将大圆盘移到右边。

  3. 将所有圆盘移动到最大的圆盘上方。

步骤 13是初始问题的较小版本。这些步骤中的每一个又可以被缩小为更小的版本,直到我们只有一个圆盘需要移动-递归函数的完美情况。让我们尝试实现这一点。

为了避免在我们的函数中使用与杆和圆盘相关的变量,我们将假设计算机会向某人发出移动的命令。在我们的代码中,我们还将假设最大的圆盘是编号 1,较小的圆盘编号较大:

<?php 

function hanoi(int $disc, string $source, string $destination,  string $via) 
{ 
    if ($disc === 1) { 
        echo("Move a disc from the $source rod to the $destination  rod\n"); 
    } else { 
        // step 1 : move all discs but the first to the "via" rod         hanoi($disc - 1, $source, $via, $destination); 
        // step 2 : move the last disc to the destination 
        hanoi(1, $source, $destination, $via); 
        // step 3 : move the discs from the "via" rod to the  destination 
        hanoi($disc - 1, $via, $destination, $source); 
    } 
} 

使用hanoi(3, 'left', 'right', 'middle')输入进行三个圆盘的移动,我们得到以下输出:

Move a disc from the left rod to the right rod 
Move a disc from the left rod to the middle rod 
Move a disc from the right rod to the middle rod 
Move a disc from the left rod to the right rod 
Move a disc from the middle rod to the left rod 
Move a disc from the middle rod to the right rod 
Move a disc from the left rod to the right rod 

想要以递归的方式思考而不是使用更传统的循环需要一段时间,显然递归并不是解决您尝试解决的所有问题的银弹。

一些函数式语言根本没有循环结构,强制您使用递归。PHP 不是这种情况,所以让我们使用正确的工具来解决问题。如果您能将问题视为较小类似问题的组合,通常使用递归会很容易。例如,尝试找到Towers of Hanoi的迭代解决方案需要仔细思考。或者您可以尝试仅使用循环来重写目录扫描函数,以说服自己。

递归有用的其他领域包括:

  • 生成具有多个级别的菜单的数据结构

  • 遍历 XML 文档

  • 渲染一系列可能包含子组件的 CMS 组件

一个很好的经验法则是,当您的数据具有树状结构,具有根节点和子节点时,尝试使用递归。

尽管阅读起来通常更容易,但一旦您掌握了它,递归就会带来内存成本。在大多数应用程序中,您不应遇到任何困难,但我们将在第十章中进一步讨论这个话题,PHP 框架和 FP,并提出一些避免这些问题的方法。

递归和循环

一些函数式语言,如 Haskell,没有任何循环结构。这意味着迭代数据结构的唯一方法是使用递归。虽然在函数世界中不鼓励使用 for 循环,因为当您可以修改循环索引时会出现所有问题,但使用foreach循环等并没有真正的危险。

为了完整起见,以下是一些替换循环为递归调用的方法,如果您想尝试或需要理解用另一种没有循环结构的语言编写的代码。

替换while循环:

<?php 

function while_iterative() 
{ 
    $result = 1; 
    while($result < 50) { 
        $result = $result * 2; 
    } 
    return $result; 
} 

function while_recursive($result = 1, $continue = true) 
{ 
    if($continue === false) { 
        return $result; 
    } 
    return while_recursive($result * 2, $result < 50); 
} 

或者for循环:

<?php 

function for_iterative() 
{ 
    $result = 5; 

    for($i = 1; $i < 10; ++$i) { 
        $result = $result * $i; 
    } 

    return $result; 
} 

function for_recursive($result = 5, $i = 1) 
{ 
    if($i >= 10) { 
        return $result; 
    } 

    return for_recursive($result * $i, $i + 1); 
} 

如您所见,诀窍在于使用函数参数将循环的当前状态传递给下一个递归。在 while 循环的情况下,您传递条件的结果,当您模拟 for 循环时,您传递循环计数器。显然,计算的当前状态也必须始终传递。

通常,递归本身是在辅助函数中完成的,以避免在签名中使用可选参数来执行循环。为了保持全局命名空间的清洁,这个辅助函数在原始函数内声明。以下是一个示例:

<?php 

function for_with_helper() 
{ 
    $helper = function($result = 5, $i = 1) use(&$helper) { 
        if($i >= 10) { 
            return $result; 
        } 

        return $helper($result * $i, $i + 1); 
    }; 

    return $helper(); 
} 

请注意,您需要使用use关键字通过引用传递包含函数的变量。这是由于我们已经讨论过的一个事实。传递给闭包的变量在声明时绑定,但当函数声明时,赋值尚未发生,变量为空。但是,如果我们通过引用传递变量,它将在赋值完成后更新,我们将能够在匿名函数内部使用它作为回调。

异常

错误管理是编写软件时面临的最棘手的问题之一。很难决定哪段代码应该处理错误。在低级函数中执行,您可能无法访问显示错误消息的设施或足够的上下文来决定最佳操作。在更高层次上执行可能会在数据中造成混乱或使应用程序陷入无法恢复的状态。

在面向对象编程代码库中管理错误的常规方法是使用异常。您在库或实用程序代码中抛出异常,并在准备好按照您的意愿处理它时捕获它。

抛出异常和捕获是否可以被视为副作用或副原因,甚至在学术界也是一个有争议的问题。有各种观点。我不想用修辞论证来使您感到厌烦,所以让我们坚持一些几乎每个人都同意的观点:

  • 由任何外部来源(数据库访问,文件系统错误,不可用的外部资源,无效的用户输入等)引发的异常本质上是不纯的,因为访问这些来源已经是一个副原因。

  • 由于逻辑错误(索引超出范围,无效类型或数据等)引发的异常通常被认为是纯的,因为它可以被视为函数的有效return值。但是,异常必须清楚地记录为可能的结果。

  • 捕获异常会破坏引用透明性,因此任何带有 catch 块的函数都会变得不纯。

前两个语句应该很容易理解,但第三个呢?让我们从一小段代码开始演示:

<?php 
function throw_exception() 
{ 
    throw new Exception('Message'); 
} 

function some_function($x) 
{ 
    $y = throw_exception(); 
    try { 
        $z = $x + $y; 
    } catch(Exception $e) { 
        $z = 42; 
    } 

    return $z; 
} 

echo some_function(42); 
// PHP Warning: Uncaught Exception: Message 

很容易看出,我们对some_function函数的调用将导致未捕获的异常,因为对throw_exception函数的调用位于try ... catch块之外。现在,如果我们应用引用透明性的原则,我们应该能够用其值替换加法中的$y参数。让我们试试看:

<?php 

try { 
    $z = $x + throw_exception(); 
} catch(Exception $e) { 
    $z = 42; 
} 

现在$z参数的值是多少,我们的函数将返回什么?与以前不同,我们现在将返回值为42,显然改变了调用我们的函数的结果。通过简单地尝试应用等式推理,我们刚刚证明了捕获异常可能会破坏引用透明性。

如果你无法捕获异常,那么异常有什么用呢?不多;这就是为什么在整本书中我们都会避免使用它们。然而,你可以将它们视为副作用,然后应用我们将在第六章中看到的技术,真实的 Monad,来管理它们。例如,Haskell 允许抛出异常,只要它们使用 IO Monad 捕获。

另一个问题是认知负担。一旦你使用它们,你就无法确定它们何时会被捕获;它们甚至可能直接显示给最终用户。这破坏了对代码片段本身进行推理的能力,因为现在你必须考虑更高层发生的事情。

这通常是你听到诸如仅将异常用于错误,而不是流程控制之类的建议的原因。这样,你至少可以确定你的异常将被用来显示某种错误,而不是想知道你将应用程序置于哪种状态。

PHP 7 和异常

即使我们大多数时候在负面情况下讨论异常,让我趁此机会介绍一下在新的 PHP 版本中关于这个主题所做的改进。

以前,某些类型的错误会生成致命错误或错误,这些错误会停止脚本的执行并显示错误消息。你可以使用set_error_handler异常来定义一个自定义处理程序来处理非致命错误,并最终继续执行。

PHP 7.0 引入了一个Throwable接口,它是异常的新父类。Throwable类还有一个新的子类叫做Error类,你可以用它来捕获以前无法处理的大多数错误。仍然有一些错误,比如解析错误,显然是无法捕获的,因为这意味着你的整个 PHP 文件在某种程度上是无效的。

让我们用一段代码来演示这一点,试图在对象上调用一个不存在的方法:

<?php 
class A {} 

$a = new A(); 

$a->invalid_method(); 

// PHP Warning: Uncaught Error: Call to undefined method  A::invalid_method() 

如果你使用的是 PHP 5.6 或更低版本,消息将会说类似于:

Fatal error: Call to undefined method A::invalid_method()

然而,使用 PHP 7.0,消息将是(重点是我的):

Fatal error: **Uncaught Error**: Call to undefined method A::invalid_method()

区别在于 PHP 通知你这是一个未捕获的错误。这意味着你现在可以使用通常的try ... catch语法来捕获它。你可以直接捕获Error类,或者如果你想更广泛地捕获任何可能的异常,你可以使用Throwable接口。然而,我不建议这样做,因为你将失去关于你究竟有哪种错误的信息:

<?php class B {} 

$a = new B(); 

try { 
    $a->invalid_method(); 
} catch(Error $e) { 
    echo "An error occured : ".$e->getMessage(); 
} 
// An error occured : Call to undefined method B::invalid_method() 

对我们来说也很有趣的是,TypeError参数是Error类的子类,当使用错误类型的参数调用函数或返回类型错误时会引发它:

<?php 
function add(int $a, int $b): int 
{ 
    return $a + $b; 
} 

try { 
    add(10, 'foo'); 
} catch(TypeError $e) { 
    echo "An error occured : ".$e->getMessage(); 
} 
// An error occured : Argument 2 passed to add() must be of the type integer, string given 

对于那些想知道为什么在新的Error类旁边创建了一个新的接口,主要是出于两个原因:

  • 清楚地将Exception接口与以前的内部引擎错误分开

  • 为了避免破坏现有代码捕获Exception接口,让开发人员选择是否也要开始捕获错误

异常的替代方案

正如我们刚才看到的,如果我们想保持代码的纯净,我们就不能使用异常。那么我们有哪些选择来确保我们可以向我们函数的调用者表示错误呢?我们希望我们的解决方案具有以下特点:

  • 强制错误管理,以便没有错误会冒泡到最终用户

  • 避免样板或复杂的代码结构

  • 在我们函数的签名中宣传

  • 避免任何将错误误认为是正确结果的风险

在本章的下一节中,我们将介绍一个具有所有这些好处的解决方案,让我们先看看命令式语言中是如何进行错误管理的。

为了测试各种方式,我们将尝试实现我们之前已经使用过的max函数:

<?php 
function max2(array $data): int 
{ 
    return array_reduce($data, function(int $max, int $i) : int { 
        return $i > $max ? $i : $max; 
    }, 0); 
} 

因为我们选择了初始值 0,如果我们用一个空数组调用函数,我们将得到结果 0。0 真的是一个空数组的最大值吗?如果我们调用与 PHP 捆绑的版本,max([])方法会发生什么?

**Warning: max(): Array must contain at least one element**

此外,还返回了 false 值。我们的版本使用值 0 作为默认值,我们可以将 false 视为错误代码。PHP 版本还会显示警告。

既然我们有一个可以改进的函数,让我们尝试一下我们可以使用的各种选项。我们将从最差的选项到最好的选项。

记录/显示错误消息

正如我们刚才看到的,PHP 可以显示警告消息。我们也可以选择通知或错误级别的消息。这可能是您可以做的最糟糕的事情,因为调用您的函数的人无法知道发生了错误。消息只会在日志中或在应用程序运行时显示在屏幕上。

此外,在某些情况下,错误是可以恢复的。由于您不知道发生了什么,因此在这种情况下无法做到这一点。

更糟糕的是,PHP 允许您配置显示哪个错误级别。在大多数情况下,通知只是隐藏的,所以没有人会看到应用程序中发生了错误。

公平地说,PHP 有一种方法可以在运行时捕获这些警告和通知,即使用set_error_handler参数声明自定义错误处理程序。但是,为了正确管理错误,您必须找到一种方法在处理程序内部确定生成错误的函数,并相应地采取行动。

如果您有多个函数使用这些类型的消息来表示错误,您很快要么会有一个非常大的错误处理程序,要么会有很多较小的错误处理程序,这使整个过程容易出错且非常繁琐。

错误代码

错误代码是 C 语言的遗产,它没有任何异常的概念。这个想法是一个函数总是返回一个代码来表示计算的状态,并找到其他一些方法来传递返回值。通常,代码 0 表示一切顺利,其他任何代码都是错误。

在涉及数字错误代码时,PHP 没有使用它们作为返回值的函数,据我所知。然而,该语言有很多函数在发生错误时返回false值,而不是预期的值。只有一个潜在值来表示失败可能会导致传递有关发生了什么的信息的困难。例如,move_uploaded_file的文档说明:

成功返回 TRUE。

如果文件名不是有效的上传文件,则不会发生任何操作,move_uploaded_file()将返回 False。

如果文件名是有效的上传文件,但由于某种原因无法移动,则不会发生任何操作,move_uploaded_file()将返回 False。此外,将发出警告。

这意味着当您发生错误时会收到通知,但是除非阅读错误消息,否则您无法知道它属于哪个错误类别。即使这样,您也会缺少重要信息,例如为什么上传的文件无效。

如果我们想更好地模仿 PHP 的max函数,我们可以这样做:

<?php 
function max3(array $data) 
{ 
    if(empty($data)) { 
        trigger_error('max3(): Array must contain at least one  element', E_USER_WARNING); 
        return false; 
    } 

    return array_reduce($data, function(int $max, int $i) : int { 
        return $i > $max ? $i : $max; 
    }, 0); 
} 

由于现在我们的函数需要在发生错误时返回 false 值,我们不得不删除返回值的类型提示,从而使我们的签名不太自我说明。

其他函数,通常是包装外部库的函数,也会在发生错误时返回false值,但是在形式上具有X_errnoX_error的伴随函数,它们返回有关上次执行的函数的错误的更多信息。一些示例包括curl_execcurl_errnocurl_error函数。

这些辅助程序允许更精细的错误处理,但代价是您必须考虑它们。错误管理不是强制的。为了进一步证明我的观点,让我们注意一下官方文档中curl_exec函数的示例甚至没有设置检查返回值的最佳实践:

<?php 

/* create a new cURL resource */ 
$ch = curl_init(); 

/* set URL and other appropriate options */ 
curl_setopt($ch, CURLOPT_URL, "http://www.example.com/"); 
curl_setopt($ch, CURLOPT_HEADER, 0); 

/* grab URL and pass it to the browser */ 
curl_exec($ch); 

/* close cURL resource, and free up system resources */ 
curl_close($ch); 

在像 PHP 这样执行松散类型转换的语言中,将false值用作失败的标记也会产生另一个后果。正如前面的文档中所述,如果您不执行严格的相等比较,您可能会将一个作为 false 的有效返回值误认为是错误:

警告:此函数可能返回布尔值 FALSE,但也可能返回一个非布尔值,该值会被视为假。请阅读布尔值部分以获取更多信息。使用===运算符来测试此函数的返回值。

PHP 仅在出现错误时使用 false 错误代码,但不像 C 语言通常情况下会返回true0。您不必找到一种方法将返回值传递给用户。

但是,如果您想要使用数字错误代码来实现自己的函数,以便有可能对错误进行分类,您必须找到一种方法来同时返回代码和值。通常,您可以使用以下两种选项之一:

  • 使用通过引用传递的参数来保存结果;例如,preg_match参数就是这样做的,即使出于不同的原因。只要参数明确标识为返回值,这并不严格违背函数的纯度。

  • 返回一个数组或其他可以容纳两个或更多值的数据结构。这个想法是我们将在下一节中作为我们的函数解决方案的开端。

默认值/空值

在认知负担方面,与错误代码相比,默认值要好一点。如果您的函数只有一组可能导致错误的输入,或者如果错误原因不重要,您可以考虑返回一个默认值,而不是通过错误代码指定错误原因。

然而,这将引发新的问题。确定一个好的默认值并不总是容易的,在某些情况下,您的默认值也将是一个有效值,这将使确定是否存在错误变得不可能。例如,如果在调用我们的max2函数时得到 0 作为结果,您无法知道数组是空的还是只包含值为 0 和负数。

默认值也可能取决于上下文,这种情况下,您将不得不向函数添加一个参数,以便在调用时也可以指定默认值。除了使函数签名变得更大之外,这也会破坏我们稍后将学习的一些性能优化,并且,尽管完全纯净和引用透明,但会增加认知负担。

让我们向我们的max函数添加一个默认值参数:

<?php 

function max4(array $data, int $default = 0): int 
{ 
    return empty($data) ? $default : 
      array_reduce($data, function(int $max, int $i) : int 
      { 
          return $i > $max ? $i : $max; 
      }, 0); 
} 

由于我们强制默认值的类型,我们能够恢复返回值的类型提示。如果您想将任何东西作为默认值传递,您还必须删除类型提示。

为了避免讨论的一些问题,有时会使用 null 值作为默认返回值。尽管 null 并不是一个真正的值,但在某些情况下,它并不属于错误代码类别,因为在某些情况下它是一个完全有效的值。比如说,如果你在一个集合中搜索一个项目,如果什么都没找到,你会返回什么?

然而,使用 null 值作为可能的返回值有两个问题:

  • 您不能将返回类型提示为 null,因为 null 不会被视为正确类型。此外,如果您计划将该值用作参数,它也不能被类型提示,或者必须是带有 null 值作为默认值的可选参数。这将迫使您要么删除类型提示,要么将参数设为可选的。

  • 如果您的函数通常返回对象,您将不得不检查 null 值,否则您将面临托尼·霍尔所说的十亿美元错误,即空指针引用。或者,如 PHP 中所述,在 null 上调用成员函数 XXX()

顺便说一句,Tony Hoare 是在 1965 年引入空值的人,因为它很容易实现。后来,他非常后悔这个决定,并决定这是他的十亿美元错误。如果你想了解更多原因,我邀请你观看他在www.infoq.com/presentations/Null-References-The-Billion-Dollar-Mistake-Tony-Hoare上的演讲。

错误处理程序

最后一种方法在 JavaScript 世界中被广泛使用,因为回调函数随处可见。这个想法是每次调用函数时传递一个错误回调。如果允许调用者传递多个回调,每种错误都可以有一个回调,那么它甚至可以更加强大。

尽管它缓解了默认值的一些问题,比如可能将有效值与默认值混淆,但你仍然需要根据上下文传递不同的回调,使得这种解决方案只能略微好一些。

这种方法对我们的函数会是什么样子呢?考虑以下实现:

<?php 

function max5(array $data, callable $onError): int 
{ 
    return empty($data) ? $onError() : 
      array_reduce($data, function(int $max, int $i) : int { 
          return $i > $max ? $i : $max; 
      }, 0); 
} 

max5([], function(): int { 
    // You are free to do anything you want here. // Not really useful in such a simple case but 
    // when creating complex objects it can prove invaluable. return 42; 
}); 

同样,我们保留了返回类型提示,因为我们与调用者的契约是返回一个整数值。正如评论中所述,在这种特殊情况下,参数的默认值可能就足够了,但在更复杂的情况下,这种方法提供了更多的功能。

我们还可以想象将初始参数传递给回调,同时传递有关失败的信息,以便错误处理程序可以相应地采取行动。在某种程度上,这种方法有点像我们之前看到的所有东西的组合,因为它允许你:

  • 指定你选择的默认返回值

  • 显示或记录任何你想要的错误消息

  • 如果你愿意,可以返回一个更复杂的数据结构和错误代码

Option/Maybe 和 Either 类型

如前所述,我们的解决方案是使用一个包含所需值或在出现错误时包含其他内容的返回类型。这种数据结构称为联合类型。联合可以包含不同类型的值,但一次只能包含一个。

让我们从本章中将要看到的两种联合类型中最简单的开始。一如既往,命名在计算机科学中是一件困难的事情,人们提出了不同的名称来指代基本上相同的结构:

  • Haskell 称之为 Maybe 类型,Idris也是如此

  • Scala 称之为 Option 类型,OCamlRustML也是如此

  • 自 Java 8 以来,Java 就有了 Optional 类型,Swift 和下一个 C++规范也是如此

就个人而言,我更喜欢称之为 Maybe,因为我认为选项是另一回事。因此,本书的其余部分将使用这个术语,除非特定的库有一个名为Option的类型。

Maybe 类型在某种意义上是特殊的,它可以保存特定类型的值,也可以是nothing的等价物,或者如果你愿意的话,是空值。在 Haskell 中,这两种可能的值被称为JustNothing。在 Scala 中,它是SomeNone,因为Nothing已经被用来指定值 null 的类型等价物。

只实现了 Maybe 或 Option 类型的库存在于 PHP 中,本章后面介绍的一些库也带有这些类型。但为了正确理解它们的工作原理和功能,我们将实现自己的类型。

让我们首先重申我们的目标:

  • 强制错误管理,以便没有错误会冒泡到最终用户

  • 避免样板代码或复杂的代码结构

  • 在我们函数的签名中进行广告

  • 避免任何错误被误认为是正确的结果

如果您使用我们将在接下来创建的类型对函数返回值进行类型提示,那么您已经照顾到了我们的第三个目标。JustNothing值的存在确保您不会将有效结果误认为错误。为了确保我们不会在某个地方得到错误的值,我们必须确保在没有指定默认值的情况下,不能从我们的新类型中获取值。关于我们的第二个目标,我们将看到我们是否可以写出一些好东西:

<?php 

abstract class Maybe 
{ 
    public static function just($value): Just 
    { 
        return new Just($value); 
    } 

    public static function nothing(): Nothing 
    { 
        return Nothing::get(); 
    } 

    abstract public function isJust(): bool; 

    abstract public function isNothing(): bool; 

    abstract public function getOrElse($default); 
} 

我们的类有两个静态辅助方法,用于创建我们即将到来的子类的两个实例,代表我们的两种可能状态。Nothing值将作为单例实现,出于性能原因;因为它永远不会持有任何值,这样做是安全的。

我们类中最重要的部分是一个抽象的getOrElse函数,它将强制任何想要获取值的人也传递一个默认值,如果我们没有值则返回该默认值。这样,我们可以强制在错误的情况下返回一个有效的值。显然,您可以将 null 值作为默认值传递,因为 PHP 没有强制执行其他机制,但这就像是在自己的脚上开枪:

<?php 
final class Just extends Maybe 
{ 
    private $value; 

    public function __construct($value) 
    { 
        $this->value = $value; 
    } 

    public function isJust(): bool 
    { 
        return true; 
    } 

    public function isNothing(): bool 
    { 
        return false; 
    } 

    public function getOrElse($default) 
    { 
        return $this->value; 
    } 
} 

我们的Just类非常简单;一个构造函数和一个 getter:

<?php 
final class Nothing extends Maybe 
{ 
    private static $instance = null; 
    public static function get() 
    { 
        if(is_null(self::$instance)) { 
            self::$instance = new static(); 
        } 

        return self::$instance; 
    } 

    public function isJust(): bool 
    { 
        return false; 
    } 

    public function isNothing(): bool 
    { 
        return true; 
    } 

    public function getOrElse($default) 
    { 
        return $default; 
    } 
} 

如果您不考虑成为单例的部分,Nothing类甚至更简单,因为getOrElse函数将始终返回默认值。对于那些好奇的人,保持构造函数公开是一个有意为之的选择。如果有人想直接创建Nothing实例,这绝对没有任何后果,那又何必费心呢?

让我们测试一下我们的新的Maybe类型:

<?php 

$hello = Maybe::just("Hello World !"); 
$nothing = Maybe::nothing(); 

echo $hello->getOrElse("Nothing to see..."); 
// Hello World ! var_dump($hello->isJust()); 
// bool(true) 
var_dump($hello->isNothing()); 
// bool(false) 

echo $nothing->getOrElse("Nothing to see..."); 
// Nothing to see... var_dump($nothing->isJust()); 
// bool(false) 
var_dump($nothing->isNothing()); 
// bool(true) 

一切似乎都运行得很顺利。尽管需要样板代码,但可以改进。在这一点上,每当您想要实例化一个新的Maybe类型时,您需要检查您拥有的值,并在SomeNothing值之间进行选择。

还可能会出现这样的情况,您需要在将值传递给下一个步骤之前对其应用一些函数,但在这一点上不知道最佳的默认值是什么。由于在创建新的Maybe类型之前,使用一些临时默认值获取值会很麻烦,让我们也尝试解决这个方面:

<?php 

abstract class Maybe 
{ 
    // [...] 

    public static function fromValue($value, $nullValue = null) 
    { 
        return $value === $nullValue ? self::nothing() : 
            self::just($value); 
    } 

    abstract public function map(callable $f): Maybe; 
} 

final class Just extends Maybe 
{ 
    // [...] 

    public function map(callable $f): Maybe 
    { 
        return new self($f($this->value)); 
    } 
} 

final class Nothing extends Maybe 
{ 
    // [...] 

    public function map(callable $f): Maybe 
    { 
        return $this; 
    } 
} 

为了使实用方法的命名有些连贯性,我们使用与处理集合的函数相同的名称。在某种程度上,您可以将Maybe类型视为一个只有一个或没有值的列表。让我们基于相同的假设添加一些其他实用方法:

<?php abstract class Maybe 
{ 
    // [...] 
    abstract public function orElse(Maybe $m): Maybe; 
    abstract public function flatMap(callable $f): Maybe;
    abstract public function filter(callable $f): Maybe;
} 

final class Just extends Maybe 
{ 
    // [...] 

    public function orElse(Maybe $m): Maybe 
    { 
        return $this; 
    } 

    public function flatMap(callable $f): Maybe 
    { 
        return $f($this->value); 
    } 

    public function filter(callable $f): Maybe 
    { 
        return $f($this->value) ? $this : Maybe::nothing(); 
    } 
} 

final class Nothing extends Maybe 
{ 
    // [...] 

    public function orElse(Maybe $m): Maybe 
    { 
        return $m; 
    } 

    public function flatMap(callable $f): Maybe 
    { 
        return $this; 
    } 

    public function filter(callable $f): Maybe 
    { 
        return $this; 
    } 
  } 

我们已经向我们的实现添加了三个新方法:

  • orElse方法如果有值则返回当前值,如果是Nothing则返回给定值。这使我们能够轻松地从多个可能的来源获取数据。

  • flatMap方法将一个可调用对象应用于我们的值,但不会将其包装在 Maybe 类中。可调用对象有责任自己返回一个 Maybe 类。

  • filter方法将给定的断言应用于值。如果断言返回 true 值,我们保留该值;否则,我们返回Nothing值。

现在我们已经实现了一个可工作的Maybe类型,让我们看看如何使用它轻松摆脱错误和空值管理。假设我们想要在应用程序的右上角显示有关已连接用户的信息。如果没有Maybe类型,您可能会做以下操作:

<?php 
$user = getCurrentUser(); 

$name = $user == null ? 'Guest' : $user->name; 

echo sprintf("Welcome %s", $name); 
// Welcome John 

在这里,我们只使用名称,因此我们可以限制自己只进行一次空值检查。如果我们需要从用户那里获取更多信息,通常的方法是使用一种有时被称为空对象模式的模式。在我们的情况下,我们的空对象将是AnonymousUser方法的一个实例:

<?php 

$user = getCurrentUser(); 

if($user == null) { 
   $user = new AnonymousUser(); 
} 

echo sprintf("Welcome %s", $user->name); 
// Welcome John 

现在让我们尝试使用我们的Maybe类型做同样的事情:

<?php 

$user = Maybe::fromValue(getCurrentUser()); 

$name = $user->map(function(User $u) { 
  return $u->name; 
})->getOrElse('Guest'); 

echo sprintf("Welcome %s", $name); 
// Welcome John 

echo sprintf("Welcome %s", $user->getOrElse(new AnonymousUser())->name); 
// Welcome John 

第一个版本可能不会好多少,因为我们不得不创建一个新的函数来提取名称。但让我们记住,在需要提取最终值之前,你可以对对象进行任意数量的处理。此外,我们稍后介绍的大多数函数库都提供了更简单地从对象中获取值的辅助方法。

你还可以轻松地调用一系列方法,直到其中一个返回一个值。比如你想显示一个仪表板,但这些可以根据每个组和每个级别重新定义。让我们比较一下我们的两种方法的表现。

首先,空值检查方法:

<?php 

$dashboard = getUserDashboard(); 
if($dashboard == null) { 
    $dashboard = getGroupDashboard(); 
} 
if($dashboard == null) { 
    $dashboard = getDashboard(); 
} 

现在,使用Maybe类型:

<?php 

/* We assume the dashboards method now return Maybe instances */ 
$dashboard = getUserDashboard() 
             ->orElse(getGroupDashboard()) 
             ->orElse(getDashboard()); 

我认为更易读的那个更容易确定!

最后,让我们演示一个小例子,说明我们如何可以在Maybe实例上链式调用多个调用,而无需检查我们当前是否有值。所选择的例子可能有点愚蠢,但它展示了可能的情况:

<?php 

$num = Maybe::fromValue(42); 

$val = $num->map(function($n) { return $n * 2; }) 
         ->filter(function($n) { return $n < 80; }) 
         ->map(function($n) { return $n + 10; }) 
         ->orElse(Maybe::fromValue(99)) 
         ->map(function($n) { return $n / 3; }) 
         ->getOrElse(0); 
echo $val; 
// 33 

我们的Maybe类型的强大之处在于,我们从未考虑过实例是否包含值。我们只能将函数应用于它,直到最后,使用getOrElse方法提取最终值。

提升函数

我们已经看到了我们新的Maybe类型的强大之处。但事实是,你要么没有时间重写所有现有的函数来支持它,要么根本无法这样做,因为它们是外部第三方的。

幸运的是,你可以提升一个函数,创建一个新的函数,它以Maybe类型作为参数,将原始函数应用于其值,并返回修改后的Maybe类型。

为此,我们将需要一个新的辅助函数。为了保持事情相对简单,我们还将假设,如果提升函数的任何参数的值评估为Nothing,我们将简单地返回Nothing

<?php 

function lift(callable $f) 
{ 
    return function() use ($f) 
    { 
        if(array_reduce(func_get_args(), function(bool $status, Maybe $m) { 
            return $m->isNothing() ? false : $status; 
        }, true)) { 
            $args = array_map(function(Maybe $m) { 
                // it is safe to do so because the fold above  checked 
                // that all arguments are of type Some 
                return $m->getOrElse(null); 
            }, func_get_args()); 
            return Maybe::just(call_user_func_array($f, $args)); 
        } 
        return Maybe::nothing(); 
    }; 
} 

让我们试试:

<?php 
function add(int $a, int $b) 
{ 
    return $a + $b; 
} 

$add2 = lift('add'); 

echo $add2(Maybe::just(1), Maybe::just(5))->getOrElse('nothing'); 
// 6 

echo $add2(Maybe::just(1), Maybe::nothing())- >getOrElse('nothing'); 
// nothing 

现在你可以提升任何函数,以便它可以接受我们的新Maybe类型。唯一需要考虑的是,如果你想依赖函数的任何可选参数,它将不起作用。

我们可以使用反射或其他手段来确定函数是否具有可选值,或者将一些默认值传递给提升的函数,但这只会使事情变得更加复杂,并使我们的函数变得更慢。如果你需要使用带有可选参数和Maybe类型的函数,你可以重写它或为它制作一个自定义包装器。

最后,提升的过程并不局限于 Maybe 类型。你可以提升任何函数以接受任何类型的容器。我们的辅助程序更好的名称可能是liftMaybe,或者我们可以将其添加为Maybe类的静态方法,以使事情更清晰。

Either 类型

Either类型是我们Maybe类型的泛化。与其有值和无值不同,你有左值和右值。由于它也是一个联合类型,这两种可能的值中只能有一个在任何给定时间被设置。

当只有少数错误来源或错误本身并不重要时,Maybe类型的工作效果很好。使用Either类型,我们可以通过左值提供任何我们想要的错误信息。右值用于成功,因为这是一个明显的双关语。

这是Either类型的一个简单实现。由于代码本身相当无聊,书中只介绍了基类。你可以在 Packt 网站上访问两个子类:

<?php 
abstract class Either 
{ 
    protected $value; 

    public function __construct($value) 
    { 
        $this->value = $value; 
    } 

    public static function right($value): Right 
    { 
        return new Right($value); 
    } 

    public static function left($value): Left 
    { 
        return new Left($value); 
    } 

    abstract public function isRight(): bool; 
    abstract public function isLeft(): bool; 
    abstract public function getRight(); 
    abstract public function getLeft(); 
    abstract public function getOrElse($default); 
    abstract public function orElse(Either $e): Either; 
    abstract public function map(callable $f): Either; 
    abstract public function flatMap(callable $f): Either; 
    abstract public function filter(callable $f, $error): Either; 
} 

该实现提议与我们为Maybe类提供的 API 相同,假设右值是有效的。你应该能够在不改变逻辑的情况下,到处使用Either类而不是Maybe类。唯一的区别是检查我们处于哪种情况的方法,并将方法更改为新的getRightgetLeft方法。

也可以为我们的新类型编写提升:

<?php 
function liftEither(callable $f, $error = "An error occured") 
{ 
    return function() use ($f) 
    { 
        if(array_reduce(func_get_args(), function(bool $status, Either $e) { 
            return $e->isLeft() ? false : $status; 
        }, true)) { 
            $args = array_map(function(Either $e) { 
                // it is safe to do so because the fold above  checked 
                // that all arguments are of type Some 
                return $e->getRight(null); 
            }, func_get_args()); 
            return Either::right(call_user_func_array($f, $args)); 
        } 
        return Either::left($error); 
    }; 
} 

然而,这个函数比自定义包装器要少一些用处,因为你无法指定一个特定于可能的错误的错误消息。

图书馆

现在我们已经介绍了 PHP 中已有的各种功能性技术的基础知识,是时候看看各种库了,这些库将使我们能够专注于我们的业务代码,而不是编写辅助函数和实用程序函数,就像我们使用新的MaybeEither类型一样。

功能性 PHP 库

functional-php库可能是与 PHP 相关的功能性编程中最古老的库之一,因为它的第一个版本可以追溯到 2011 年 6 月。它与最新的 PHP 版本良好地发展,并且甚至去年切换到了 Composer 进行分发。

该代码可在 GitHub 上找到github.com/lstrojny/functional-php。如果您习惯使用 Composer,安装应该非常容易,只需写入以下命令:

**composer require lstrojny/functional-php.**

该库曾经在 PHP 中实现,并作为 C 扩展的一部分出于性能原因。但是,由于 PHP 核心在速度方面的最新改进以及维护两个代码库的负担,该扩展已经过时。

实现了许多辅助函数-我们现在没有足够的空间详细介绍每一个。如果您感兴趣,可以查看文档。但是,我们将快速介绍重要的函数,本书的其余部分将包含更多的示例。

此外,我们还没有讨论库相关函数涵盖的一些概念,我们将在处理这些主题时进行介绍。

如何使用这些函数

正如在第一章中已经讨论的那样,自 PHP 5.6 以来,您可以从命名空间导入函数。这是使用该库的最简单方法。您还可以导入整个命名空间,并在调用函数时添加前缀:

<?php 
require_once __DIR__.'/vendor/autoload.php'; 

use function Functional\map; 

map(range(0, 4), function($v) { return $v * 2; }); 

use Functional as F; 

F\map(range(0, 4), function($v) { return $v * 2; }); 

还要注意的是,大多数函数接受数组和实现Traversable接口的任何内容,例如迭代器。

通用辅助函数

这些函数可以帮助您在各种情境下,而不仅仅是在功能性方面:

  • truefalse函数检查集合中的所有元素是否严格为 True 或严格为 False。

  • truthyfalsy函数与以前相同,但比较不是严格的。

  • const_function函数返回一个新函数,该函数将始终返回给定值。这可以用于模拟不可变数据。

扩展 PHP 函数

PHP 函数倾向于仅在真实数组上工作。以下函数将它们的行为扩展到任何可以使用foreach循环进行迭代的内容。所有函数的参数顺序也保持一致:

  • contains方法检查给定集合中是否包含该值。第三个参数控制比较是否应该是严格的。

  • sort方法对集合进行排序,但返回一个新数组,而不是通过引用进行排序。您可以决定是否保留键。

  • map方法将array_map方法的行为扩展到所有集合。

  • summaximumminimum方法在任何类型的集合上执行与它们的 PHP 对应方法相同的工作。除此之外,该库还包含 product、ratio、difference 和 average。

  • 当您不传递函数时,zip方法执行与array_map方法相同的工作。但是,您也可以传递回调函数来确定如何合并各个项目。

  • reduce_leftreduce_right方法从左侧或右侧折叠集合。

使用谓词

在处理集合时,通常希望检查某些、全部或没有元素是否满足某个条件,并相应地采取行动。为了做到这一点,您可以使用以下函数:

  • every函数如果集合的所有元素都对谓词有效,则返回 true 值

  • some函数如果至少有一个元素对谓词有效,则返回 true 值

  • none函数如果没有元素对于谓词有效,则返回 true

这些函数不会修改集合。它们只是检查元素是否符合某个条件。如果需要过滤一些元素,可以使用以下辅助函数:

  • selectfilter函数仅返回对于谓词有效的元素。

  • reject函数仅返回对于谓词无效的元素。

  • head函数返回对于谓词有效的第一个元素。

  • 最后一个函数返回对于谓词有效的最后一个元素。

  • drop_first函数从集合的开头删除元素,直到给定的回调为true。一旦回调返回 false,停止删除元素。

  • drop_last函数与上一个函数相同,但是从末尾开始。

所有这些函数都返回一个新的数组,原始集合保持不变。

调用函数

当您想在回调中调用函数时,声明匿名函数是很麻烦的。这些辅助函数将为您提供更简单的语法:

  • invoke辅助函数在集合中的所有对象上调用方法,并返回具有结果的新集合

  • invoke_firstinvoke_last辅助函数分别在集合的第一个和最后一个对象上调用方法

  • invoke_if辅助函数如果第一个参数是有效对象,则调用给定的方法。您可以传递方法参数和默认值。

  • invoker辅助函数返回一个新的可调用对象,它使用给定的参数调用给定的方法。

您可能还希望调用函数,直到获得一个值或达到某个阈值。该库已经为您做好了准备:

  • retry库调用函数,直到它停止返回异常或达到尝试次数

  • poll库调用函数,直到它返回真值或达到给定的超时时间

操作数据

之前的函数组是关于使用辅助函数调用函数;这个函数组是关于获取和操作数据,而不必每次都求助于匿名函数:

  • pluck函数从给定集合中的所有对象中提取属性,并返回具有这些值的新集合。

  • pick函数根据给定的键从数组中选择一个元素。如果元素不存在,可以提供默认值。

  • first_index_oflast_index_of函数分别返回匹配给定值的元素的第一个和最后一个索引。

  • indexes_of函数返回所有匹配给定值的索引。

  • flatten函数将嵌套集合的深度减少为单个平面集合。

有时,您也希望根据谓词或某个分组值将集合分成多个部分:

  • partition方法接受一组谓词-根据第一个谓词的有效性,将集合的每个项目放入给定的组中。

  • group方法根据每个元素的回调返回的每个不同值创建多个组

总结

正如您所看到的,functional-php库提供了许多不同的辅助函数和实用函数。现在可能不明显您如何充分利用它们,但我希望本书的剩余部分能让您一窥您可以实现的内容。

另外,不要忘记我们没有介绍所有的函数,因为其中一些需要一点理论解释。一切都在适当的时间。

php-option 库

我们之前创建了自己版本的Maybe类型。这个库提出了一个更完整的实现。选择了 Scala 使用的命名,然而。源代码在 GitHub 上github.com/schmittjoh/php-option。最简单的安装方法是使用 Composer 写入以下命令:

**composer require phpoption/phpoption**

一个有趣的补充是LazyOption方法,它接受一个回调而不是一个值。只有在需要值时才会执行回调。当您使用orElse方法为前一个无效值提供替代时,这是特别有趣的。在这种情况下使用LazyOption方法,可以避免在一个值有效时进行不必要的计算。

您还可以使用各种辅助程序来帮助您仅在值有效时调用方法,例如,还提供了多种实例化可能性。该库还提供了一个 API,更类似于您习惯于集合的 API。

Laravel 集合

如第一章所述,Laravel 提供了一个很好的库来管理集合。它声明了一个名为Collection的类,该类在其 ORM Eloquent和大多数其他依赖于集合的部分内部使用。

在内部,使用了一个简单的数组,但以一种促进数据的不可变性和功能性方法来包装它。为了实现这个目标,为开发人员提供了 60 到 70 种方法。

如果您已经在使用 Laravel,您可能已经熟悉此支持类提供的可能性。如果您正在使用其他任何框架,仍然可以从中受益,方法是从github.com/tightenco/collect获取提取的部分。

文档可在 Laravel 官方网站laravel.com/docs/collections上找到。我们不会详细描述每个方法,因为它们有很多。如果您正在使用 Laravel 并想了解其集合提供的所有可能性,可以前往adamwathan.me/refactoring-to-collections/

使用 Laravel 的集合

第一步是使用 collect 实用程序函数将数组或Traversable接口转换为Collection类的实例。然后您将可以访问类提供的各种方法。让我们快速列出到目前为止我们已经以另一种形式遇到的那些方法:

  • map方法将函数应用于所有元素并返回新值

  • filter方法使用谓词过滤集合

  • reduce方法使用给定的回调函数折叠集合

  • pluck方法从所有元素中获取给定的属性

  • groupBy方法使用每个元素的给定值对集合进行分区

所有这些方法都返回Collection类的新实例,保留原始实例的值。

完成操作后,您可以使用 all 方法将当前值作为数组获取。

immutable-php 库

这个提出不可变数据结构的库是由于对标准 PHP库中的SplFixedArray方法的各种抱怨,主要是其难以使用的 API。在其核心,immutable-php库使用前面提到的数据结构,但使用一组很好的方法来包装它。

SplFixedArray方法是一个具有固定大小并且只允许数字索引的数组的特定实现。这些约束允许实现一个非常快速的数组结构。

您可以在 GitHub 项目页面github.com/jkoudys/immutable.php上查看或通过使用 Composer 编写以下命令来安装它:

**composer require qaribou/immutable.php.**

使用 immutable.php

使用专用的静态助手fromArrayfromItemsTraversable类的任何实例创建新实例非常容易。您新创建的ImmArray实例可以像任何数组一样访问,使用foreach循环进行迭代,并使用count方法进行计数。但是,如果尝试设置一个值,将会收到异常。

一旦你有了不可变数组,你可以使用各种方法来应用你现在应该习惯的转换:

  • map 方法将函数应用于所有项目并返回新值

  • filter 方法创建仅包含谓词有效项目的新数组

  • reduce 方法使用回调折叠项目

你还有其他帮手:

  • join 方法连接字符串集合

  • sort 方法使用给定的回调返回排序后的集合

你的数据也可以很容易地以传统数组形式检索或编码为 JSON 格式。

总的来说,这个库提供的方法比 Laravel 的 Collection 更少,但性能更好,内存占用更低。

其他库

由于 PHP 核心缺乏很多实用函数和功能来进行适当的函数式编程,很多人开始致力于实现缺失部分的库。这就是为什么如果你开始寻找,你会发现很多这样的库。

以下是一份不完整且无序的库列表,如果之前介绍的那些不符合你的需求。

Underscore.php 库

基于 Underscore.js 库的 API 存在多种用于 PHP 的端口。我个人不太喜欢 Underscore.js 库,因为函数参数经常顺序错误,无法有效地进行函数组合。这一点在这个视频中有很好的解释:www.youtube.com/watch?v=m3svKOdZijA

然而,如果你习惯使用它,这是一个各种端口的简短列表:

Saber

Saber 严格遵循最新的 PHP 版本作为要求。它使用强类型、不可变对象和惰性求值。为了使用它的各种方法,你必须将你的值装箱到库提供的类中。这可能有些麻烦,但它提供了安全性并减少了错误。

它似乎受到 C# 和主要是 F# 的启发,后者是在 .NET 虚拟机上运行的函数语言,或者用其真实名称 CLR 来称呼它。你可以在 GitHub 上找到源代码和文档:github.com/bluesnowman/fphp-saber

Rawr

Rawr 不仅仅是一个函数库。它试图以更一般的方式修复 PHP 语言的缺陷。与 Saber 一样,它提供了一个新类来装箱你的标量值;然而,类型的使用更接近 Haskell。你还可以将你的匿名函数包装在一个类中,以在其周围获得更好的类型安全性。

该库还添加了更多 Smalltalk 风格的面向对象、单子,并允许你执行一些类似 JavaScript 的基于原型的编程。

遗憾的是,该库似乎停滞不前,文档与源代码不同步。然而,您可以在那里找到一些灵感。您可以在 GitHub 上找到代码github.com/haskellcamargo/rawr

PHP 功能性

这个库主要围绕我们将在第五章中看到的 Monad 的概念。承认的灵感来自 Haskell,该库实现了:

  • State Monad

  • IO Monad

  • 集合 Monad

  • Either Monad

  • Maybe Monad

通过Collection Monad,该库提供了我们期望的各种方法mapreducefilter方法。

由于受 Haskell 的启发,您可能会发现在开始时使用它有点困难。然而,最终它应该会更加强大。您可以在 GitHub 上找到代码github.com/widmogrod/php-functional

功能性

最初创建为一个学习游乐场,这个库已经发展成为一个相对小型但有用的东西。主要的想法是提供一个框架,以便您可以在代码中删除所有循环。

最有趣的特点是所有函数都可以部分应用而无需进行任何特殊操作。部分应用对于函数组合非常重要。我们将在第四章中发现这两个主题,组合函数

该库还具有所有传统的竞争者,如映射和减少。代码和文档可在 GitHub 上找到github.com/sergiors/functional

PHP 函数式编程工具

这个库试图走与我们在前几页中介绍的functional-php库相同的道路。据我所知,它目前的功能略少。对于想要更小、可能更容易学习的人来说,这可能是一个有趣的库。代码在 GitHub 上github.com/daveross/functional-programming-utils

非标准 PHP 库

这个库并不严格是一个功能性的库。这个想法更多的是通过各种辅助和实用函数来扩展标准库,以使处理集合更加容易。

它包含一些有用的功能,例如帮助轻松验证函数参数,无论是使用已定义的约束还是自定义的约束。它还扩展了现有的 PHP 函数,以便它们可以处理任何Traversable接口的内容,而不仅仅是数组。

该库创建于 2014 年,但直到 2015 年底工作再次开始才几乎停滞不前。现在它可能是我们之前介绍的任何库的替代品。如果您感兴趣,请在 GitHub 上获取代码github.com/ihor/Nspl

总结

在这一长章节中,我们介绍了我们将在整本书中使用的所有实用构建块。希望这些例子不会显得太枯燥。有很多内容要涵盖,而页面数量有限。接下来的章节将在我们学到的基础上进行更好的示例。

您首先阅读了一些关于编程的一般建议,这对于功能代码库尤其重要。然后,我们发现了基本的功能技术,如映射、折叠、过滤和压缩,所有这些都可以直接在 PHP 中使用。

接下来是对递归的简要介绍,这是一种解决特定问题集的技术,也是避免使用循环的方法。在一本关于功能性语言的书中,这个主题可能值得一整章,但由于 PHP 有各种循环结构,它的重要性稍低一些。此外,我们将在接下来的章节中看到更多的递归示例。

我们还讨论了异常以及它们在功能代码库中引发的问题,并在讨论其他方法的利弊后,编写了 Maybe 和 Either 类型的实现,作为更好地管理错误的方法。

最后,我们介绍了一些提供功能构造和辅助功能的库,这样我们就不必自己编写了。

第四章:组合函数

在之前的章节中,我们谈了很多关于构建模块和小纯函数。 但到目前为止,我们甚至没有暗示这些如何用来构建更大的东西。 如果你不能使用构建模块,那么构建模块有什么用呢? 答案部分地在于函数组合。

尽管这一章完成了前一章,但这种技术是任何函数式程序的一个不可或缺的重要部分,因此它值得有自己的一章。

在本章中,我们将涵盖以下主题:

  • 函数组合

  • 部分应用

  • 柯里化

  • 参数顺序的重要性

  • 这些概念的现实应用

函数组合

正如在函数式编程中经常发生的那样,函数组合的概念是从数学中借来的。 如果您有两个函数fg,您可以通过组合它们来创建第三个函数。 数学中的通常表示法是(f g)(x),这相当于依次调用它们f(g(x))

您可以使用一个包装函数非常容易地组合任何两个给定的函数与 PHP。 比如说,您想以大写字母显示标题,并且只保留安全的 HTML 字符:

<?php 

function safe_title(string $s) 
{ 
    $safe = htmlspecialchars($s); 
    return strtoupper($safe); 
} 

您也可以完全避免临时变量:

<?php 

function safe_title2(string $s) 
{ 
    return strtoupper(htmlspecialchars($s)); 
} 

当您只想组合几个函数时,这样做效果很好。 但是创建很多这样的包装函数可能会变得非常繁琐。 如果您能简单地使用$safe_title = strtoupper htmlspecialchars这行代码会怎么样呢? 遗憾的是,PHP 中不存在这样的运算符,但我们之前介绍的functional-php库包含一个compose函数,它正是这样做的:

<?php 
require_once __DIR__.'/vendor/autoload.php'; 

use function Functional\compose; 

$safe_title2 = compose('htmlspecialchars', 'strtoupper'); 

收益可能看起来并不重要,但让我们在更多的上下文中比较一下使用这种方法:

<?php 

$titles = ['Firefly', 'Buffy the Vampire Slayer', 'Stargate Atlantis', 'Tom & Jerry', 'Dawson's Creek']; 

$titles2 = array_map(function(string $s) { 
    return strtoupper(htmlspecialchars($s)); 
}, $titles); 

$titles3 = array_map(compose('htmlspecialchars', 'strtoupper'),  $titles); 

就个人而言,我发现第二种方法更容易阅读和理解。 而且它变得更好了,因为您可以将两个以上的函数传递给compose函数:

<?php 

$titles4 = array_map(compose('htmlspecialchars', 'strtoupper', 'trim'), $titles); 

一个可能会误导的事情是函数应用的顺序。 数学表示法f ∘ g首先应用g,然后将结果传递给f。 然而,functional-php库中的compose函数按照它们在compose('first', 'second', 'third')参数中传递的顺序应用函数。

这可能更容易理解,取决于您的个人偏好,但是当您使用另一个库时要小心,因为应用的顺序可能会被颠倒。 一定要确保您已经仔细阅读了文档。

部分应用

您可能想设置函数的一些参数,但将其中一些参数留到以后再分配。 例如,我们可能想创建一个返回博客文章摘录的函数。

设置这样一个值的专用术语是绑定参数绑定参数。 过程本身称为部分应用,新函数被设置为部分应用。

这样做的天真方式是将函数包装在一个新函数中:

<?php 
function excerpt(string $s) 
{ 
    return substr($s, 0, 5); 
} 

echo excerpt('Lorem ipsum dolor si amet.'); 
// Lorem 

与组合一样,总是创建新函数可能会很快变得繁琐。 但再一次,functional-php库为我们提供了帮助。 您可以决定从左侧、右侧或函数签名中的任何特定位置绑定参数,分别使用partial_leftpartial_rightpartial_any函数。

为什么有三个函数? 主要是出于性能原因,因为左侧和右侧版本的性能会更快,因为参数将被一次性替换,而最后一个将使用在每次调用新函数时计算的占位符。

在上一个例子中,占位符是使用函数...定义的,它是省略号 Unicode 字符。 如果您的计算机没有简便的方法输入它,您也可以使用Functional命名空间中的placeholder函数,它是一个别名。

柯里化

柯里化经常被用作部分应用的同义词。 尽管这两个概念都允许我们绑定函数的一些参数,但核心思想有些不同。

柯里化的思想是将接受多个参数的函数转换为接受一个参数的函数序列。由于这可能有点难以理解,让我们尝试对substr函数进行柯里化。结果被称为柯里化函数

<?php 

function substr_curryied(string $s) 
{ 
    return function(int $start) use($s) { 
        return function(int $length) use($s, $start) { 
            return substr($s, $start, $length); 
        }; 
    }; 
} 

$f = substr_curryied('Lorem ipsum dolor sit amet.'); 
$g = $f(0); 
echo $g(5); 
// Lorem 

正如你所看到的,每次调用都会返回一个接受下一个参数的新函数。这说明了与部分应用的主要区别。当你调用部分应用的函数时,你会得到一个结果。但是,当你调用柯里化函数时,你会得到一个新的函数,直到传递最后一个参数。此外,你只能按顺序从左边开始绑定参数。

如果调用链看起来过长,你可以从 PHP 7 开始大大简化它。这是因为 RFC 统一变量语法已经实现(详见wiki.php.net/rfc/uniform_variable_syntax):

<?php 

echo substr_curryied('Lorem ipsum dolor sit amet.')(0)(5); 
// Lorem 

柯里化的优势可能在这样的情况下并不明显。但是,一旦你开始使用高阶函数,比如mapreduce函数,这个想法就变得非常强大了。

你可能还记得functional-php库中的pluck函数。这个想法是从对象集合中检索给定的属性。如果pluck函数被实现为柯里化函数,它可以以多种方式使用:

<?php 

function pluck(string $property) 
{ 
    return function($o) use($property) { 
        if (is_object($o) && isset($o->{$propertyName})) { 
            return $o->{$property}; 
        } elseif ((is_array($o) || $o instanceof ArrayAccess) &&  isset($o[$property])) { 
            return $o[$property]; 
        } 

        return false; 
    }; 
} 

我们可以轻松地从任何类型的对象或数组中获取值:

<?php 

$user = ['name' => 'Gilles', 'country' => 'Switzerland', 'member'  => true]; 
pluck('name')($user); 

我们可以从对象集合中提取属性,就像functional-php库中的版本一样:

<?php 

$users = [ 
    ['name' => 'Gilles', 'country' => 'Switzerland', 'member' =>  true], 
    ['name' => 'Léon', 'country' => 'Canada', 'member' => false], 
    ['name' => 'Olive', 'country' => 'England', 'member' => true], 
]; 
pluck('country')($users); 

由于我们的实现在找不到内容时返回false,我们可以用它来过滤包含特定值的数组:

<?php 

array_filter($users, pluck('member')); 

我们可以组合多个用例来获取所有成员的名称:

<?php 

pluck('name', array_filter($users, pluck('member'))); 

如果没有柯里化,我们要么需要编写一个更传统的pluck函数的包装器,要么创建三个专门的函数。

让我们再进一步,结合多个柯里化函数。首先,我们需要创建一个包装函数,包装array_mappreg_replace函数:

<?php 

function map(callable $callback) 
{ 
    return function(array $array) use($callback) { 
        return array_map($callback, $array); 
    }; 
} 

function replace($regex) 
{ 
    return function(string $replacement) use($regex) { 
        return function(string $subject) use($regex, $replacement)  
{ 
            return preg_replace($regex, $replacement, $subject); 
        }; 
    }; 
} 

现在我们可以使用这些来创建多个新函数,例如,一个将字符串中所有空格替换为下划线或所有元音字母替换为星号的函数:

<?php function map(callable $callback) 
{ 
    return function(array $array) use($callback) { 
        return array_map($callback, $array); 
    }; 
} 

function replace($regex) 
{ 
    return function(string $replacement) use($regex) { 
        return function(string $subject) use($regex, $replacement)  
{ 
            return preg_replace($regex, $replacement, $subject); 
        }; 
    }; 
} 

在 PHP 中进行柯里化函数

我希望你现在已经相信了柯里化的力量。如果没有,我希望接下来的例子能说服你。与此同时,你可能会认为围绕现有函数编写新的实用程序函数来创建新的柯里化版本真的很麻烦,你是对的。

在 Haskell 等语言中,所有函数默认都是柯里化的。不幸的是,PHP 中并非如此,但这个过程足够简单和重复,我们可以编写一个辅助函数。

由于 PHP 中可能有可选参数的可能性,我们首先要创建一个名为curry_n的函数,该函数接受你想要柯里化的参数数量。这样,你也可以决定是否要对所有参数进行柯里化,还是只对其中一些进行柯里化。它也可以用于具有可变参数数量的函数:

<?php 

function curry_n(int $count, callable $function): callable 
{ 
    $accumulator = function(array $arguments) use($count,  $function, &$accumulator) { 
        return function() use($count, $function, $arguments,  $accumulator) { 
            $arguments = array_merge($arguments, func_get_args()); 

            if($count <= count($arguments)) { 
                return call_user_func_array($function,  $arguments); 
            } 

            return $accumulator($arguments); 
        }; 
    }; 
    return $accumulator([]); 
} 

这个想法是使用一个内部辅助函数,将已传递的值作为参数,然后使用这些创建一个闭包。当调用时,闭包将根据实际值的数量决定我们是否可以调用原始函数,或者我们是否需要使用我们的辅助函数创建一个新函数。

请注意,如果你给出的参数计数高于实际计数,所有多余的参数将被传递到原始函数,但可能会被忽略。此外,给出较小的计数将导致最后一步期望更多的参数才能正确完成。

现在我们可以创建第二个函数,使用reflection变量来确定参数的数量:

<?php 

function curry(callable $function, bool $required = true):  callable 
{ 
    if(is_string($function) && strpos($function, '::', 1) !==  false) { 
        $reflection = new \ReflectionMethod($f); 
    }  
    else if(is_array($function) && count($function) == 2)  
    { 
        $reflection = new \ReflectionMethod($function[0],  $function[1]); 
    }  
    else if(is_object($function) && method_exists($function,  '__invoke'))  
    { 
        $reflection = new \ReflectionMethod($function,  '__invoke'); 
    }  
    else  
    {         
        $reflection = new \ReflectionFunction($function); 
    } 

    $count = $required ? $reflection->getNumberOfRequiredParameters() : 
        $reflection->getNumberOfParameters(); 

    return curry_n($count, $function); 
} 

正如你所看到的,没有简单的方法来确定函数期望的参数数量。我们还必须添加一个参数来确定我们是否应该考虑所有参数,包括具有默认值的参数,还是只考虑必填参数。

您可能已经注意到,我们并没有创建严格只接受一个参数的函数;相反,我们使用了func_get_args函数来获取所有传递的参数。这使得使用柯里化函数更加自然,并且与函数式语言中所做的事情相当。我们对柯里化的定义现在更接近于一个函数,直到接收到所有参数才返回一个新函数

本书其余部分的示例将假定此柯里化函数可用并准备好使用。

在撰写本文时,functional-php库上有一个待处理的拉取请求,以合并此函数。

参数顺序非常重要!

正如你可能还记得第一章所述,array_maparray_filter函数的参数顺序不同。当然,这使它们更难使用,因为你更容易出错,但这并不是唯一的问题。为了说明参数顺序的重要性,让我们创建这两个函数的柯里化版本:

<?php 

$map = curry_n(2, 'array_map'); 
$filter = curry_n(2, 'array_filter'); 

我们在这里使用curry_n函数有两个不同的原因:

  • array_map函数接受可变数量的数组,因此我们强制将值设为 2 以确保安全

  • array_filter函数有一个名为$flag的第三个参数,其可选值是可以接受的

还记得我们新的柯里化函数的参数顺序吗?$map参数将首先获取回调函数,而$filters参数期望首先获取集合。让我们尝试创建一个新的有用函数,了解这一点:

<?php 

$trim = $map('trim'); 
$hash = $map('sha1'); 

$oddNumbers = $filter([1, 3, 5, 7]); 
$vowels = $filter(['a', 'e', 'i', 'o', 'u']); 

我们的映射示例确实非常基础,但有一定用途,而我们的过滤示例只是静态数据。我敢打赌,你可以找到一些方法来使用$trim$hash参数,但你需要一个奇数或元音字母的列表来进行过滤的可能性有多大呢?

本章稍早前的另一个例子可以从这里得到-还记得我们对substr函数的柯里化示例吗?

<?php 

function substr_curryied(string $s) 
{ 
    return function(int $start) use($s) { 
        return function(int $length) use($s, $start) { 
            return substr($s, $start, $length); 
        }; 
    }; 
} 

$f = substr_curryied('Lorem ipsum dolor sit amet.'); 
$g = $f(0); 
echo $g(5); 
// Lorem 

我可以向你保证,如果我们首先定义开始和长度来创建,那将会更有用。例如,一个$take5fromStart函数;而不是拥有这些尴尬的$substrOnLoremIpsum参数,我们只需在示例中调用$f参数。

这里重要的是,你想要操作的数据,也就是你的“主题”,必须放在最后,因为这大大增加了对柯里化函数的重用,并且让你可以将它们作为其他高阶函数的参数使用。

就像上一个例子一样,假设我们想要创建一个函数,该函数获取集合中所有元素的前两个字母。我们将尝试使用一组两个函数来实现,其中参数的顺序不同。

函数的实现留作练习,因为这并不重要。

在第一个例子中,主语是第一个参数:

<?php 

$map = curry(function(array $array, callable $cb) {}); 
$take = curry(function(string $string, int $count) {}); 

$firstTwo = function(array $array) { 
    return $map($array, function(string $s) { 
        return $take($s, 2); 
    }); 
} 

参数顺序迫使我们创建包装函数。事实上,即使函数是柯里化的,也无关紧要,因为我们无法利用这一点。

在第二个例子中,主语位于最后:

<?php 

$map = curry(function(callable $cb, array $array) {}); 
$take = curry(function(int $count, string $string) {}); 

$firstTwo = $map($take(2)); 

事实上,精心选择的顺序也对函数组合有很大帮助,正如我们将在下一节中看到的那样。

最后,关于主题的说明,为了公平起见,使用参数顺序相反的函数版本可以使用functional-php库中的partial_right函数编写,并且您可以使用partial_any函数来处理参数顺序奇怪的函数。但即便如此,解决方案也不像参数顺序正确的解决方案那样简单:

<?php 

use function Functional\partial_right; 

$firstTwo = partial_right($map, partial_right($take, 2)); 

使用组合来解决真正的问题

举个例子,假设你的老板走进来,希望你制作一个新报告,其中包含过去 30 天内注册的所有用户的电话号码。我们假设有以下类来表示我们的用户。显然,一个真正的类将存储和返回真实数据,但让我们只定义我们的 API:

<?php 

class User { 
    public function phone(): string 
    { 
        return ''; 
    } 

    public function registration_date(): DateTime 
    { 
        return new DateTime(); 
    } 
} 

$users = [new User(), new User(), new User()]; // etc. 

对于没有任何函数式编程知识的人来说,你可能会写出这样的代码:

<?php 

class User { 
    public function phone(): string 
    { 
        return ''; 
    }  
    public function registration_date(): DateTime 
    { 
        return new DateTime(); 
    } 
} 

$users = [new User(), new User(), new User()]; // etc. 

我们的函数的第一眼看告诉我们它不是纯的,因为限制是在函数内部计算的,因此后续调用可能导致不同的用户列表。我们还可以利用mapfilter函数:

<?php 

function getUserPhonesFromDate($limit, $users) 
{ 
    return array_map(function(User $u) { 
        return $u->phone(); 
    }, array_filter($users, function(User $u) use($limit) { 
        return $u->registration_date()->getTimestamp() > $limit; 
    })); 
} 

根据你的喜好,现在代码可能会更容易阅读一些,或者完全不容易,但至少我们有了一个纯函数,我们的关注点也更加分离。然而,我们可以做得更好。首先,functional-php库有一个函数,允许我们创建一个调用对象方法的辅助函数:

<?php 

use function Functional\map; 
use function Functional\filter; 
use function Functional\partial_method; 

function getUserPhonesFromDate2($limit, $users) 
{ 
    return map( 
        filter(function(User $u) use($limit) { 
            return $u->registration_date()->getTimestamp()  >$limit; 
        }, $users), 
        partial_method('phone') 
    ); 
} 

这样会好一些,但如果我们接受需要创建一些新的辅助函数,我们甚至可以进一步改进解决方案。此外,这些辅助函数是我们将能够重用的新构建块:

<?php 

function greater($limit) { 
    return function($a) { 
        return $a > $limit; 
    }; 
} 

function getUserPhonesFromDate3($limit, $users) 
{ 
    return map( 
        filter(compose( 
            partial_method('registration_date'), 
            partial_method('getTimestamp'), 
            greater($limit) 
          ), 
          $users), 
        partial_method('phone') 
    ); 
} 

如果我们有filtermap函数的柯里化版本,甚至可以创建一个只接受日期并返回一个新函数的函数,这个新函数可以进一步组合和重用:

<?php 

use function Functional\partial_right; 

$filter = curry('filter'); 
$map = function($cb) { 
    return function($data) use($cb) { 
        return map($data, $cb); 
    }; 
}; 

function getPhonesFromDate($limit) 
{ 
    return function($data) use($limit) { 
        $function = compose( 
            $filter(compose( 
            partial_method('getTimestamp'), 
                partial_method('registration_date'), 
                greater($limit) 
            )), 
            $map(partial_method('phone')) 
        ); 
        return $function($data); 
    }; 
} 

作为一个关于拥有良好参数顺序的必要性的良好提醒,由于functional-php库中的map函数具有与 PHP 原始函数相同的签名,我们不得不手动进行柯里化。

我们的结果函数比原始的命令式函数稍长一些,但在我看来,它更容易阅读。你可以轻松地跟踪发生了什么:

  1. 使用以下方式过滤数据:

  2. 注册日期

  3. 从中,你可以得到时间戳。

  4. 检查它是否大于给定的限制。

  5. 在结果上映射phone方法。

如果你觉得partial_method这个名字不太理想,并且调用compose函数的存在在某种程度上让人有点难以理解,我完全同意。事实上,在一个具有compose运算符、自动柯里化和一些语法糖来推迟对方法的调用的假设语言中,它可能看起来像这样:

getFromDate($limit) = filter( 
  (->registration_date) >> 
  (->getTimestamp) >> 
  (> $limit) 
) >> map(->phone) 

现在我们有了我们的函数,你的老板又走进你的办公室,提出了新的要求。实际上,他只想要过去 30 天内最近的三次注册。很简单,让我们只是用一些更多的构建块来组合我们的新函数:

<?php 

use function Functional\sort; 
use function Functional\compare_on; 

function take(int $count) { 
    return function($array) use($count) { 
        return array_slice($array, 0, $count); 
    }; 
}; 

function compare($a, $b) { 
    return $a == $b ? 0 : $a < $b ? -1 : 1; 
} 

function getAtMostThreeFromDate($limit) 
{ 
    return function($data) use($limit) { 
        $function = compose( 
            partial_right( 
                'sort', 
                compare_on('compare',  partial_method('registration_date')) 
            ), 
            take(3), 
            getPhonesFromDate($limit) 
        ); 
        return $function($data); 
    }; 
} 

为了从数组的开头获取一定数量的项目,我们需要在array_slice函数周围创建一个take函数。我们还需要一个比较值的函数,这很简单,因为DateTime函数重载了比较运算符。

再次,functional-php库对sort函数的参数顺序搞错了,所以我们需要部分应用而不是柯里化。而compare_on函数创建了一个比较器,给定一个比较函数和一个“reducer”,它在比较每个项目时被调用。在我们的情况下,我们想要比较注册日期,所以我们重用了我们的不同方法应用。

我们需要在过滤之前执行排序操作,因为我们的getPhonesFromDate方法只返回电话号码,正如其名称所示。我们的结果函数本身是其他函数的柯里化组合,因此可以轻松重用。

我希望这个小例子已经说服你使用小函数作为构建块并将它们组合起来解决问题的力量。如果不是这样,也许我们将在接下来的章节中看到的更高级的技术之一会说服你。

最后一点,也许你从例子中已经知道,PHP 遗憾地缺少了很多实用函数,以便让函数式编程者的生活变得更容易。而且,即使是广泛使用的functional-php库,也会出现一些参数顺序错误,并且缺少一些重要的代码,比如柯里化。

通过结合多个库,我们可以更好地覆盖所需的功能,但这也会增加大量无用的代码和一些不匹配的函数名称,这并不会让你的生活变得更轻松。

我可以建议的是保留一个文件,记录你在学习过程中创造的所有小技巧,很快你就会拥有自己的助手编译,真正适合你的需求和编码风格。这个建议可能违反了围绕着大型社区的可重用包的最佳实践,但在有人创建正确的库之前,它会有很大帮助。谁知道,也许你就是有足够精力创建功能 PHP 生态系统中缺失的珍珠的人。

总结

本章围绕函数组合展开,一旦你习惯了它,这是一个非常强大的想法。通过使用小的构建模块,你可以创建复杂的过程,同时保持短函数提供的可读性和可维护性。

我们还谈到了部分应用和柯里化的最强大概念,它们使我们能够轻松地创建现有函数的更专业化版本,并重写我们的代码以使其更易读。

我们讨论了参数顺序,这是一个经常被忽视但非常重要的话题,一旦你想使用高阶函数时就会变得重要。柯里化和正确的参数顺序的结合使我们能够减少样板代码和包装函数的需求,这个过程有时被称为 eta-reduction。

最后,通过前面提到的所有工具,我们试图演示一些你在日常编程中可能遇到的问题和难题的解决方案,以帮助你写出更好的代码。

第五章:函子、应用函子和单子

上一章介绍了第一个真正的函数式技术,比如函数组合和柯里化。在本章中,我们将再次深入介绍更多的理论概念,介绍单子的概念。由于我们有很多内容要涵盖,实际应用将不会很多。然而,第六章真实生活中的单子将使用我们在这里学到的一切来解决真实问题。

你可能已经听说过单子这个术语。通常,它与非函数式程序员的恐惧感联系在一起。单子通常被描述为难以理解,尽管有无数关于这个主题的教程。事实上,它们很难理解,写这些教程的人经常忘记了他们正确理解这个想法花了多少时间。这是一个常见的教学陷阱,可能在这篇文章中更好地描述了byorgey.wordpress.com/2009/01/12/abstraction-intuition-and-the-monad-tutorial-fallacy/

你可能不会一次性理解所有内容。单子是一个非常抽象的概念,即使在本章结束时,这个主题对你来说似乎很清楚,你可能在以后遇到一些东西,它会使你对单子的真正理解感到困惑。

我会尽力清楚地解释事情,但如果你觉得我的解释不够,我在本章末尾的进一步阅读部分添加了关于这个主题的其他材料的参考。在本章中,我们将涵盖以下主题:

  • 函子及相关法则

  • 应用函子及相关法则

  • 幺半群及相关法则

  • 单子及相关法则

将会有很多理论内容,只有概念的实现。在第六章真实生活中的单子之前,不要期望有很多例子。

函子

在直接讲述单子之前,让我们从头开始。为了理解单子是什么,我们需要介绍一些相关概念。第一个是函子。

为了让事情变得复杂一些,术语函子在命令式编程中用来描述函数对象,这是完全不同的东西。在 PHP 中,一个具有__invoke方法的对象,就像我们在第一章中看到的那样,函数作为一等公民,就是这样一个函数对象。

然而,在函数式编程中,函子是从范畴论的数学领域中借用并改编的概念。细节对我们的目的并不那么重要;它足以说,函子是一种模式,允许我们将函数映射到一个或多个值所包含的上下文中。此外,为了使定义尽可能完整,我们的函子必须遵守一些法则,我们将在稍后描述和验证。

我们已经多次在集合上使用了 map,这使它们成为了事实上的函子。但是如果你记得的话,我们也以相同的方式命名了我们的方法来将一个函数应用于 Maybe 中包含的值。原因是函子可以被看作是具有一种方法来将函数应用于包含的值的容器。

在某种意义上,任何实现以下接口的类都可以被称为函子

<?php 

interface Functor 
{ 
    public function map(callable $f): Functor; 
} 

然而,这样描述有点简化了。一个简单的 PHP 数组也是一个函子(因为存在array_map函数),只要你使用functional-php库和它的 map 函数,任何实现Traversable接口的东西也是一个函子。

为什么对于一个如此简单的想法要大惊小怪?因为,尽管这个想法本身很简单,它使我们能够以不同的方式思考正在发生的事情,并可能有助于理解和重构代码。

此外,map函数可以做的远不止盲目地应用给定的callable类型,就像数组一样。如果你记得我们的Maybe类型实现,在值为Nothing的情况下,map函数只是简单地保持返回Nothing值,以便更简单地管理空值。

我们还可以想象在我们的函子中有更复杂的数据结构,比如树,其中给map函数的函数应用于所有节点。

函子允许我们共享一个共同的接口,我们的map方法或函数,对各种数据类型执行类似的操作,同时隐藏实现的复杂性。就像函数式编程一样,认知负担减少了,因为你不需要为相同的操作有多个名称。例如,"apply"、"perform"和"walk"等函数和方法名称通常用来描述相同的事情。

恒等函数

我们最终关注的是与这个概念相关的两个函子定律。但在介绍它们之前,我们需要稍微偏离一下,讨论一下恒等函数,通常是id。这是一个非常简单的函数,只是简单地返回它的参数:

<?php 

function id($value) 
{ 
    return $value; 
} 

为什么有人需要一个做得这么少的函数?首先,我们以后会需要它来证明本章中介绍的各种抽象的定律。但现实世界中也存在应用。

例如,当你对数字进行折叠运算,比如求和,你会使用初始值0id函数在对函数进行折叠时起着相同的作用。事实上,functional-php库中的 compose 函数是使用id函数实现的。

另一个用途可能是来自另一个库的某个函数,它执行你感兴趣的操作,但也在结果数据上调用回调。如果回调是必需的,但你不想对数据做其他任何操作,只需传递id,你将得到未经改变的数据。

让我们使用我们的新函数来声明我们的compose函数的一个属性,对于任何只接受一个参数的函数f

compose(id, f) == compose(f, id) 

这基本上是说,如果你先应用参数id然后是f,你会得到与先应用f然后是id完全相同的结果。到这一点,这对你来说应该是显而易见的。如果不是,我鼓励你重新阅读上一章,直到你清楚地理解为什么会这样。

函子定律

现在我们已经涵盖了我们的恒等函数,让我们回到我们的定律。它们有两个重要原因:

  • 它们给了我们一组约束条件,以确保我们的函子的有效性。

  • 它们允许我们进行经过验证的重构

话不多说,它们在这里:

  1. map(id) == id

  2. compose(map(f), map(g)) == map(compose(f, g))

第一定律规定,将id函数映射到包含的值上,与直接在函子本身上调用id函数完全相同。当这个定律成立时,这保证了我们的 map 函数只将给定的函数应用于数据,而不进行任何其他类型的处理。

第二定律规定,首先在我们的值上映射f函数,然后是g函数,与首先将fg组合在一起,然后映射结果函数完全相同。知道这一点,我们可以进行各种优化。例如,我们可以将它们组合在一起,只进行一次循环,而不是对我们的数据进行三种不同方法的三次循环。

我可以想象现在对你来说并不是一切都很清楚,所以不要浪费时间试图进一步解释它们,让我们验证它们是否适用于array_map方法。这可能会帮助你理解它的要点;以下代码期望之前定义的id函数在作用域内:

<?php 

$data = [1, 2, 3, 4]; 

var_dump(array_map('id', $data) === id($data)); 
// bool(true) 

function add2($a) 
{ 
    return $a + 2; 
} 

function times10($a) 
{ 
    return $a * 10; 
} 

function composed($a) { 
    return add2(times10($a)); 
} 

var_dump( 
array_map('add2', array_map('times10', $data)) === array_map('composed', $data) 
); 
// bool(true) 

组合是手动执行的;在我看来,在这里使用柯里化只会使事情变得更加复杂。

正如我们所看到的,array_map方法符合这两个定律,这是一个好迹象,因为这意味着没有隐藏的数据处理在背后进行,我们可以避免在数组上循环两次或更多次,当只需要一次时。

让我们尝试一下我们之前定义的Maybe类型:

<?php 

$just = Maybe::just(10); 
$nothing = Maybe::nothing(); 

var_dump($just->map('id') == id($just)); 
// bool(true) 

var_dump($nothing->map('id') === id($nothing)); 
// bool(true) 

我们不得不切换到非严格相等的方式来处理$just情况,因为否则我们会得到一个错误的结果,因为 PHP 比较对象实例而不是它们的值。Maybe类型将结果值包装在一个新对象中,PHP 只在非严格相等的情况下执行内部值比较;上面定义的add2times10composed函数预期在范围内。

<?php 

var_dump($just->map('times10')->map('add2') == $just->map('composed')); 
// bool(true) 

var_dump($nothing->map('times10')->map('add2') === $nothing->map('composed')); 
// bool(true) 

很好,我们的Maybe类型实现是一个有效的函数器。

身份函数器

正如我们在关于身份函数的部分讨论的那样,还存在一个身份函数器。它充当一个非常简单的函数器,除了保存值之外不对值进行任何操作:

<?php 

class IdentityFunctor implements Functor 
{ 
    private $value; 

    public function __construct($value) 
    { 
        $this->value = $value; 
    } 

    public function map(callable $f): Functor 
    { 
        return new static($f($this->value)); 
    } 

    public function get() 
    { 
        return $this->value; 
    } 
} 

与身份函数一样,这种函数器的用途并不立即明显。然而,思想是一样的-当您有一个函数以函数器作为参数时,您可以使用它,但不想修改您的实际值。

这应该在本书的后续章节中变得更加清晰。与此同时,我们将使用身份函数器来解释一些更高级的概念。

结束语

让我再次重申,函数器是一个非常简单的抽象概念,但也是一个非常强大的概念。我们只看到了其中两个,但有无数的数据结构可以非常容易地转换为函数器。

任何允许您将给定函数映射到上下文中保存的一个或多个值的函数或类都可以被视为函数器。身份函数器或数组是这种上下文的简单示例;其他示例包括我们之前讨论过的MaybeEither类型,或者任何具有map方法的类,该方法允许您将函数应用于包含的值。

我无法鼓励您足够尝试实现这种映射模式,并验证无论您创建一个新的类或数据结构,这两个定律是否成立。这将使您更容易理解您的代码可以执行什么,并且您将能够使用组合进行优化,并保证您的重构是正确的。

应用函数器

让我们拿一个我们的身份函数器的实例,保存一些整数和一个柯里化版本的add函数:

<?php 

$add = curry(function(int $a, int $b) { return $a + $b; }); 

$id = new IdentityFunctor(5); 

现在,当我们尝试在我们的函数器上映射$add参数时会发生什么?考虑以下代码:

<?php 

$hum = $id->map($add); 

echo get_class($hum->get()); 
// Closure 

你可能已经猜到了,我们的函数器现在包含一个闭包,代表一个部分应用的add参数,其值为5作为第一个参数。您可以使用get方法检索函数并使用它,但实际上并不是很有用。

另一种可能性是映射另一个函数,以我们的函数作为参数,并对其进行操作:

<?php 

$result = $hum->map(function(callable $f) { 
    return $f(10); 
}); 
echo $result->get(); 
// 15 

但我想我们都会同意,这并不是执行这样的操作的一种非常有效的方式。更好的方法是能够简单地将值10或者另一个函数器传递给$hum并获得相同的结果。

进入应用函数器。顾名思义,这个想法是应用函数器。更准确地说,是将函数器应用于其他函数器。在我们的情况下,我们可以将包含函数的函数器$hum应用于另一个包含值10的函数器,并获得我们想要的值15

让我们创建一个扩展版本的IdentityFunctor类来测试我们的想法:

<?php 

class IdentityFunctorExtended extends IdentityFunctor 
{ 
    public function apply(IdentityFunctorExtended $f) 
    { 
        return $f->map($this->get()); 
    } 
} 

$applicative = (new IdentityFunctorExtended(5))->map($add); 
$ten = new IdentityFunctorExtended(10); 
echo $applicative->apply($ten)->get(); 
// 15 

甚至可以创建一个只包含函数的Applicative类,并在之后应用这些值:

<?php 

$five = new IdentityFunctorExtended(5); 
$ten = new IdentityFunctorExtended(10); 
$applicative = new IdentityFunctorExtended($add); 

echo $applicative->apply($five)->apply($ten)->get(); 
// 15 

应用抽象

现在我们能够使用我们的IdentifyFunctor类作为柯里化函数的持有者。如果我们能够将这个想法抽象出来,并在Functor类的基础上创建一些东西会怎样?

<?php 

abstract class Applicative implements Functor 
{ 
    public abstract static function pure($value): Applicative; 
    public abstract function apply(Applicative $f): Applicative; 
    public function map(callable $f): Functor 
    { 
        return $this->pure($f)->apply($this); 
    } 
} 

正如你所看到的,我们创建了一个新的抽象类而不是一个接口。原因是因为我们可以使用pureapply方法来实现map函数,所以强制每个想要创建Applicative类的人都要实现它是没有意义的。

pure函数之所以被称为如此,是因为Applicative类中存储的任何东西都被认为是纯的,因为没有办法直接修改它。这个术语来自 Haskell 实现。其他实现有时使用名称unit。pure 用于从任何callable创建一个新的 applicative。

apply函数将存储的函数应用于给定的参数。参数必须是相同类型的,以便实现知道如何访问内部值。遗憾的是,PHP 类型系统不允许我们强制执行这个规则,我们必须默认为Applicative

我们对 map 的定义也有同样的问题,必须将返回类型保持为Functor。我们需要这样做,因为 PHP 类型引擎不支持一种称为返回类型协变的特性。如果支持的话,我们可以指定一个更专门的类型(即子类型)作为返回值。

map函数是使用上述函数实现的。首先我们使用pure方法封装我们的callable,然后将这个新的 applicative 应用于实际值。没有什么特别的。

让我们测试我们的实现:

<?php 

$five = IdentityApplicative::pure(5); 
$ten = IdentityApplicative::pure(10); 
$applicative = IdentityApplicative::pure($add); 

echo $applicative->apply($five)->apply($ten)->get(); 
// 15 

$hello = IdentityApplicative::pure('Hello world!'); 

echo IdentityApplicative::pure('strtoupper')->apply($hello)->get(); 
// HELLO WORLD! echo $hello->map('strtoupper')->get(); 
// HELLO WORLD! 

一切似乎都运行正常。我们甚至能够验证我们的 map 实现似乎是正确的。

与 functor 一样,我们可以创建最简单的Applicative类抽象:

<?php 

class IdentityApplicative extends Applicative 
{ 
    private $value; 

    protected function __construct($value) 
    { 
        $this->value = $value; 
    } 

    public static function pure($value): Applicative 
    { 
        return new static($value); 
    } 

    public function apply(Applicative $f): Applicative 
    { 
        return static::pure($this->get()($f->get())); 
    } 

    public function get() 
    { 
        return $this->value; 
    } 
} 

Applicative 法则

applicative 的第一个重要属性是它们是封闭的组合,意味着 applicative 将返回相同类型的新 applicative。此外,apply 方法接受自己类型的 applicative。我们无法使用 PHP 类型系统来强制执行这一点,所以你需要小心,否则可能会在某个时候出现问题。

还需要遵守以下规则才能拥有一个正确的 applicative functor。我们将首先详细介绍它们,然后稍后验证它们对我们的IdentityApplicative类是否成立。

映射

纯(f)->应用 == map(f)

使用 applicative 应用函数与对其进行映射是相同的。这个法则简单地告诉我们,我们可以在以前使用 functor 的任何地方使用 applicative。切换到 applicative 不会使我们失去任何权力。

实际上,这并不是一个法则,因为它可以从以下四个法则中推导出来。但由于这并不明显,为了让事情更清晰,让我们来陈述一下。

身份

纯(id)->应用(\(x)== id(\)x)

应用恒等函数不会改变值。与 functor 的身份法则一样,这确保apply方法除了应用函数之外不会发生任何隐藏的转换。

同态

纯(f)->应用(\(x)==纯(f(\)x))

创建一个 applicative functor 并将其应用于一个值与首先在值上调用函数,然后在 functor 中封装它具有相同的效果。

这是一个重要的法则,因为我们深入研究 applicative 的第一个动机是使用柯里化函数而不是一元函数。这个法则确保我们可以在任何阶段创建我们的 applicative,而不需要立即封装我们的函数。

交换

纯(f)->应用(\(x)==纯(function(\)f){ \(f(\)x); })->应用(f)

这个有点棘手。它声明对值应用函数与创建一个提升值的 applicative functor 并将其应用于函数是相同的。在这种情况下,提升值是围绕该值的闭包,它将在其上调用给定的函数。该法则确保纯函数除了封装给定值之外不执行任何修改。

组合

纯(组合)->应用(f1)->应用(f2)->应用(\(x)==纯(f1)->应用(纯(f2)->应用(\)x))

这种法律的简化版本可以用pure(compose(f1, f2))->apply($x)来写在左边。它简单地陈述了 functors 的组合法则,即你可以将两个函数的组合版本应用到你的值上,或者分别调用它们。这确保你可以对 functors 执行相同的优化。

验证法律是否成立

正如我们对 functors 所看到的,强烈建议测试你的实现是否符合所有法律。这可能是一个非常乏味的过程,特别是如果你有四个。因此,我们不要手动执行检查,让我们写一个辅助程序:

<?php 

function check_applicative_laws(Applicative $f1, callable $f2, $x) 
{ 
    $identity = function($x) { return $x; }; 
    $compose = function(callable $a) { 
        return function(callable $b) use($a) { 
            return function($x) use($a, $b) { 
                return $a($b($x)); 
            }; 
        }; 
    }; 

    $pure_x = $f1->pure($x); 
    $pure_f2 = $f1->pure($f2); 

    return [ 
        'identity' => 
            $f1->pure($identity)->apply($pure_x) == 
            $pure_x, 
        'homomorphism' => 
            $f1->pure($f2)->apply($pure_x) == 
            $f1->pure($f2($x)), 
        'interchange' => 
            $f1->apply($pure_x) == 
            $f1->pure(function($f) use($x) { return $f($x); })->apply($f1), 
        'composition' => 
            $f1->pure($compose)->apply($f1)->apply($pure_f2)->apply($pure_x) == 
            $f1->apply($pure_f2->apply($pure_x)), 
        'map' => 
            $pure_f2->apply($pure_x) == 
            $pure_x->map($f2) 
    ]; 
} 

identitycompose函数在辅助程序中声明,因此它是完全自包含的,你可以在各种情况下使用它。此外,functional-php库中的compose函数不适用,因为它不是柯里化的,它接受可变数量的参数。

此外,为了避免有很多争论,我们使用Applicative类的一个实例,这样我们就可以有一个第一个函数和要检查的类型,然后是一个callable和一个将被提升到 applicative 并在必要时使用的值。

这种选择限制了我们可以使用的函数,因为值必须与两个函数的参数类型匹配;第一个函数还必须返回相同类型的参数。如果这对你来说太过约束,你可以决定扩展辅助程序,以接受另外两个参数,第二个 applicative 和一个提升的值,并在必要时使用它们。

让我们验证我们的IdentityApplicative类:

<?php 

print_r(check_applicative_laws( 
IdentityApplicative::pure('strtoupper'), 
    'trim', 
    ' Hello World! ' 
)); 
// Array 
// ( 
//     [identity] => 1 
//     [homomorphism] => 1 
//     [interchange] => 1 
//     [composition] => 1 
//     [map] => 1 
// ) 

很好,一切似乎都很好。如果你想使用这个辅助程序,你需要选择兼容的函数,因为你可能会遇到一些缺乏清晰度的错误消息,因为我们无法确保第一个函数的返回值类型与第二个函数的第一个参数类型匹配。

由于这种自动检查可以极大地帮助,让我们迅速地为 functors 编写相同类型的函数:

<?php 

function check_functor_laws(Functor $func, callable $f, callable $g) 
{ 
    $id = function($a) { return $a; }; 
    $composed = function($a) use($f, $g) { return $g($f($a)); }; 

    return [ 
        'identity' => $func->map($id) == $id($func), 
        'composition' => $func->map($f)->map($g) == $func->map($composed) 
    ]; 
} 

并检查我们从未测试过的IdentityFunctor

<?php 

print_r(check_functor_laws( 
    new IdentityFunctor(10), 
    function($a) { return $a * 10; }, 
    function($a) { return $a + 2; } 
)); 
// Array 
// ( 
//     [identity] => 1 
//     [composition] => 1 
// ) 

好的,一切都很好。

使用 applicatives

正如我们已经看到的,数组是 functors,因为它们有一个map函数。但是一个集合也很容易成为 applicative。让我们实现一个CollectionApplicative类:

<?php 

class CollectionApplicative extends Applicative implements IteratorAggregate 
{ 
    private $values; 

    protected function __construct($values) 
    { 
        $this->values = $values; 
    } 

    public static function pure($values): Applicative 
    { 
        if($values instanceof Traversable) { 
            $values = iterator_to_array($values); 
        } else if(! is_array($values)) { 
            $values = [$values]; 
        } 

        return new static($values); 
    } 

    public function apply(Applicative $data): Applicative 
    { 
        return $this->pure(array_reduce($this->values, 
            function($acc, callable $function) use($data) { 
                return array_merge($acc, array_map($function, $data->values) ); 
            }, []) 
        ); 
    } 

    public function getIterator() { 
        return new ArrayIterator($this->values); 
    } 
} 

正如你所看到的,这一切都相当容易。为了简化我们的生活,我们只需将不是集合的任何东西包装在一个数组中,并将Traversable接口的实例转换为真正的数组。这段代码显然需要一些改进才能用于生产,但对于我们的小演示来说已经足够了:

<?php 

print_r(iterator_to_array(CollectionApplicative::pure([ 
  function($a) { return $a * 2; }, 
  function($a) { return $a + 10; } 
])->apply(CollectionApplicative::pure([1, 2, 3])))); 
// Array 
// ( 
//     [0] => 2 
//     [1] => 4 
//     [2] => 6 
//     [3] => 11 
//     [4] => 12 
//     [5] => 13 
// ) 

这里发生了什么?我们的 applicative 中有一个函数列表,我们将其应用到一个数字列表。结果是一个新的列表,每个函数都应用到每个数字上。

这个小例子并不是真正有用的,但这个想法可以应用到任何事情上。想象一下,你有一种图库应用,用户可以上传一些图像。你还有各种处理你想对这些图像进行的处理:

  • 限制最终图像的大小,因为用户倾向于上传过大的图像

  • 为索引页面创建一个缩略图

  • 为移动设备创建一个小版本

你唯一需要做的就是创建一个包含所有函数的数组,一个包含上传图像的数组,并将我们刚刚对数字做的相同模式应用到它们。然后你可以使用functional-php库中的 group 函数将你的图像重新分组在一起:

<?php 

use function Functional\group; 

function limit_size($image) { return $image; } 
function thumbnail($image) { return $image.'_tn'; } 
function mobile($image) { return $image.'_small'; } 

$images = CollectionApplicative::pure(['one', 'two', 'three']); 

$process = CollectionApplicative::pure([ 
  'limit_size', 'thumbnail', 'mobile' 
]); 

$transformed = group($process->apply($images), function($image, $index) { 
    return $index % 3; 
}); 

我们使用转换后的数组中的索引来将图像重新分组。每三个图像是限制的,每四个是缩略图,最后是移动版本。结果如下所示:

<?php 

print_r($transformed); 
// Array 
// ( 
//     [0] => Array 
//         ( 
//             [0] => one 
//             [3] =>one_tn 
//             [6] =>one_small 
//         ) 
// 
//     [1] => Array 
//         ( 
//             [1] => two 
//             [4] =>two_tn 
//             [7] =>two_small 
//         ) 
// 
//     [2] => Array 
//         ( 
//             [2] => three 
//             [5] =>three_tn 
//             [8] =>three_small 
//         ) 
// 
//) 

在这个阶段,你可能会渴望更多,但你需要耐心。让我们先完成本章的理论,我们很快就会在下一章看到更有力的例子。

单子

现在我们对应用函子有了一定的了解,在谈论单子之前,我们需要在这个谜题中增加最后一块,即单子。再次,这个概念来自范畴论的数学领域。

单子是任何类型和该类型上的二元操作的组合,具有关联的身份元素。例如,以下是一些组合,您可能从未预料到它们是单子:

  • 整数和加法操作,其身份是 0,因为$i + 0 == $i

  • 整数和乘法操作,其身份是 1,因为$i * 1 == $i

  • 数组和合并操作,其身份是空数组,因为array_merge($a, []) == $a

  • 字符串和连接操作,其身份是空字符串,因为$s . '' == $s

在本章的其余部分,让我们称我们的操作为op,身份元素为idop调用来自操作或操作员,并在多种语言的Monoid实现中使用。Haskell 使用术语memptymappend以避免与其他函数名称冲突。有时使用零代替id或身份。

单子还必须遵守一定数量的法则,确切地说是两个。

身份法则

$a op id == id op $a == $a

第一个法则确保了身份可以在操作符的两侧使用。身份元素只有在作为操作符的右手或左手侧应用时才能起作用。例如,对矩阵的操作就是这种情况。在这种情况下,我们谈论左和右身份元素。在Monoid的情况下,我们需要一个双侧身份,或者简单地说是身份。

对于大多数身份法则,验证Monoid实现可以确保我们正确应用操作符而没有其他副作用。

结合律

($a op $b) op $c == \(a op (\)b op $c)

这项法律保证了我们可以按任何顺序重新组合我们对操作员的呼叫,只要其他一些操作没有交错。这很重要,因为它允许我们推理可能的优化,并确保结果是相同的。

知道一系列操作是可结合的;您还可以将序列分成多个部分,将计算分布到多个线程、核心或计算机上,当所有中间结果出现时,将它们之间的操作应用以获得最终结果。

验证法则

让我们验证一下我们之前谈到的单子的法则。首先是整数加法:

<?php 

$a = 10; $b = 20; $c = 30; 

var_dump($a + 0 === $a); 
// bool(true) 
var_dump(0 + $a === $a); 
// bool(true) 
var_dump(($a + $b) + $c === $a + ($b + $c)); 
// bool(true) 

然后,整数乘法:

<?php 

var_dump($a * 1 === $a); 
// bool(true) 
var_dump(1 * $a === $a); 
// bool(true) 
var_dump(($a * $b) * $c === $a * ($b * $c)); 
// bool(true) 

然后数组合并如下:

<?php 

$v1 = [1, 2, 3]; $v2 = [5]; $v3 = [10]; 

var_dump(array_merge($v1, []) === $v1); 
// bool(true) 
var_dump(array_merge([], $v1) === $v1); 
// bool(true) 
var_dump( 
array_merge(array_merge($v1, $v2), $v3) === 
array_merge($v1, array_merge($v2, $v3)) 
); 
// bool(true) 

最后,字符串连接:

<?php 

$s1 = "Hello"; $s2 = " World"; $s3 = "!"; 

var_dump($s1 . '' === $s1); 
// bool(true) 
var_dump('' . $s1 === $s1); 
// bool(true) 
var_dump(($s1 . $s2) . $s3 == $s1 . ($s2 . $s3)); 
// bool(true) 

很好,我们所有的单子都遵守这两个法则。

减法或除法呢?它们也是单子吗?很明显,0 是减法的身份,1 是除法的身份,但结合性呢?

考虑以下检查减法或除法的结合性:

<?php

var_dump(($a - $b) - $c === $a - ($b - $c));
// bool(false)
var_dump(($a / $b) / $c === $a / ($b / $c));
// bool(false) 

我们清楚地看到,减法和除法都不是可结合的。在处理这种抽象时,始终重要的是使用法则来测试我们的假设。否则,重构或调用某个期望Monoid的函数可能会出现严重问题。显然,对于函子和应用函子也是如此。

单子有什么用?

老实说,单子本身并不是真正有用的,特别是在 PHP 中。最终,在一种语言中,您可以声明新的操作符或重新定义现有的操作符,您可以确保它们的结合性和存在单子。但即使如此,也没有真正的优势。

另外,如果语言可以自动分配使用Monoid的运算,那将是加快漫长计算的一个很好的方法。但我不知道任何语言,即使是学术语言,目前都能做到这一点。一些语言执行操作重新排序以提高效率,但仅此而已。显然,PHP 不能做任何这些,因为幺半群的概念不在核心中。

那么为什么要费心呢?因为幺半群可以与高阶函数和一些我们将在后面发现的构造一起使用,以充分利用它们的法律。此外,由于 PHP 不允许我们像 Haskell 那样使用现有的运算符作为函数,例如,我们之前不得不定义add之类的函数。相反,我们可以定义一个Monoid类。它将具有与我们的简单函数相同的效用,并添加一些很好的属性。

冒昧地说,明确声明一个操作是幺半群可以减轻认知负担。使用幺半群时,您可以确保操作是可结合的,并且遵守双边单位。

一个幺半群的实现

PHP 不支持泛型,因此我们无法正式地编码我们的Monoid的类型信息。您将不得不选择一个不言自明的名称或者清楚地记录这是什么类型。

另外,由于我们希望我们的实现能够替换诸如add之类的函数,我们需要在我们的类上添加一些额外的方法来允许这种用法。让我们看看我们能做些什么:

<?php 

abstract class Monoid 
{ 
    public abstract static function id(); 
    public abstract static function op($a, $b); 

    public static function concat(array $values) 
    { 
        $class = get_called_class(); 
        return array_reduce($values, [$class, 'op'], [$class, 'id']()); 
    } 

    public function __invoke(...$args) 
    { 
        switch(count($args)) { 
            case 0: throw new RuntimeException("Except at least 1 parameter"); 
            case 1: 
                return function($b) use($args) { 
                    return static::op($args[0], $b); 
                }; 
            default: 
                return static::concat($args); 
        } 
    } 
} 

显然,我们的idop函数声明为抽象,因为它们将是我们每个幺半群的特定部分。

拥有Monoid的一个主要优势是可以轻松地折叠具有Monoid类类型的值的集合。这就是为什么我们创建concat方法作为一个辅助方法来做到这一点。

最后,我们有一个__invoke函数,以便我们的Monoid可以像普通函数一样使用。该函数以一种特定的方式进行柯里化。如果您在第一次调用时传递了多个参数,concat方法将被用于立即返回结果。否则,只有一个参数,您将得到一个等待第二个参数的新函数。

既然我们在这里,让我们编写一个检查法律的函数:

<?php 

function check_monoid_laws(Monoid $m, $a, $b, $c) 
{ 
    return [ 
        'left identity' => $m->op($m->id(), $a) == $a, 
        'right identity' => $m->op($a, $m->id()) == $a, 
        'associativity' => 
            $m->op($m->op($a, $b), $c) == 
            $m->op($a, $m->op($b, $c)) 
    ]; 
} 

我们的第一个幺半群

让我们为我们之前看到的情况创建幺半群,并演示我们如何使用它们:

<?php 

class IntSum extends Monoid 
{ 
    public static function id() { return 0; } 
    public static function op($a, $b) { return $a + $b; } 
} 

class IntProduct extends Monoid 
{ 
    public static function id() { return 1; } 
    public static function op($a, $b) { return $a * $b; } 
} 

class StringConcat extends Monoid 
{ 
    public static function id() { return ''; } 
    public static function op($a, $b) { return $a.$b; } 
} 

class ArrayMerge extends Monoid 
{ 
    public static function id() { return []; } 
    public static function op($a, $b) { return array_merge($a, $b); } 
} 

让我们验证它们的法律:

<?php 

print_r(check_monoid_laws(new IntSum(), 5, 10, 20)); 
// Array 
// ( 
//     [left identity] => 1 
//     [right identity] => 1 
//     [associativity] => 1 
// ) 

print_r(check_monoid_laws(new IntProduct(), 5, 10, 20)); 
// Array 
// ( 
//     [left identity] => 1 
//     [right identity] => 1 
//     [associativity] => 1 
// ) 

print_r(check_monoid_laws(new StringConcat(), "Hello ", "World", "!")); 
// Array 
// ( 
//     [left identity] => 1 
//     [right identity] => 1 
//     [associativity] => 1 
// ) 

print_r(check_monoid_laws(new ArrayMerge(), [1, 2, 3], [4, 5], [10])); 
// Array 
// ( 
//     [left identity] => 1 
//     [right identity] => 1 
//     [associativity] => 1 
// ) 

举个例子,让我们尝试创建一个减法的幺半群并检查法律:

<?php 

class IntSubtraction extends Monoid 
{ 
    public static function id() { return 0; } 
    public static function op($a, $b) { return $a - $b; } 
} 

print_r(check_monoid_laws(new IntSubtraction(), 5, 10, 20)); 
// Array 
// ( 
//     [left identity] => 
//     [right identity] => 1 
//     [associativity] => 
// ) 

如预期的那样,结合律失败了。我们还有一个左单位的问题,因为 0 - \(a == -\)a。所以让我们不要忘记测试我们的幺半群,以确保它们是正确的。

关于布尔类型,可以创建两个有趣的幺半群:

<?php 

class Any extends Monoid 
{ 
    public static function id() { return false; } 
    public static function op($a, $b) { return $a || $b; } 
} 

class All extends Monoid 
{ 
    public static function id() { return true; } 
    public static function op($a, $b) { return $a && $b; } 
} 

print_r(check_monoid_laws(new Any(), true, false, true)); 
// Array 
// ( 
//     [left identity] => 1 
//     [right identity] => 1 
//     [associativity] => 1 
// ) 

print_r(check_monoid_laws(new All(), true, false, true)); 
// Array 
// ( 
//     [left identity] => 1 
//     [right identity] => 1 
//     [associativity] => 1 
// ) 

这两个幺半群使我们能够验证是否至少满足一个条件或所有条件。这些是functional-php库中everysome函数的幺半群版本。这两个幺半群与求和和乘积的作用相同,因为 PHP 不允许我们将布尔运算符用作函数:

<?php 

echo Any::concat([true, false, true, false]) ? 'true' : 'false'; 
// true 

echo All::concat([true, false, true, false]) ? 'true' : 'false'; 
// false 

当您需要以编程方式创建一系列条件时,它们可能会很有用。只需将它们提供给Monoid,而不是迭代所有条件来生成结果。您还可以编写一个none幺半群作为练习,以查看您是否理解了这个概念。

使用幺半群

使用我们的新幺半群最明显的方法之一是折叠一组值:

<?php 

$numbers = [1, 23, 45, 187, 12]; 
echo IntSum::concat($numbers); 
// 268 

$words = ['Hello ', ', ', 'my ', 'name is John.']; 
echo StringConcat::concat($words); 
// Hello , my name is John. $arrays = [[1, 2, 3], ['one', 'two', 'three'], [true, false]]; 
print_r(ArrayMerge::concat($arrays)); 
// [1, 2, 3, 'one', 'two', 'three', true, false] 

这个属性非常有趣,以至于大多数函数式编程语言都实现了Foldable类型的想法。这样的类型需要有一个关联的幺半群。借助我们刚刚看到的属性,该类型可以很容易地折叠。然而,将这个想法移植到 PHP 是困难的,因为我们将缺少改进使用concat方法所需的语法糖。

您还可以将它们用作callable类型,并将它们传递给高阶函数:

<?php 

use function Functional\compose; 

$add = new IntSum(); 
$times = new IntProduct(); 

$composed = compose($add(5), $times(2)); 
echo $composed(2); 
// 14 

显然,这不仅限于 compose 函数。您可以重写本书中使用add函数的所有先前示例,并使用我们的新Monoid代替。

随着我们在本书中的进展,我们将看到更多与我们尚未发现的功能技术相关联的单子的使用方式。

单子

我们开始学习函子,它是一组可以映射的值。然后我们介绍了应用函子的概念,它允许我们将这些值放入特定的上下文并对它们应用函数,同时保留上下文。我们还快速讨论了幺半群及其属性。

有了所有这些先前的知识,我们终于准备好开始单子的概念了。正如 James Iry 在编程语言简史中幽默地指出的那样:

单子是自函子范畴中的幺半群,有什么问题吗?

这句虚构的引语归功于 Philip Wadler,他是 Haskell 规范的最初参与者之一,也是单子使用的倡导者,可以在james-iry.blogspot.com/2009/05/brief-incomplete-and-mostly-wrong.html找到其上下文。

如果没有一些范畴论的知识,很难清楚地解释这句话到底是什么意思,特别是因为它是虚构的,故意模糊以至于有趣。可以说,单子类似于幺半群,因为它们大致共享相同的法则集。此外,它们直接与函子和应用相关联。

单子,就像函子一样,充当某种值的容器。此外,就像应用程序一样,您可以将函数应用于封装的值。这三种模式都是将一些数据放入上下文的一种方式。但是,两者之间有一些区别:

  • 应用封装了一个函数。单子和函子封装了一个值。

  • 应用程序使用返回非提升值的函数。单子使用返回相同类型的单子的函数。

由于函数也是有效值,这并不意味着两者不兼容,只是意味着我们需要为我们的单子类型定义一个新的 API。但是,我们可以自由地扩展 Applicative,因为它在单子上下文中包含完全有效的方法:

<?php 

abstract class Monad extends Applicative 
{ 
    public static function return($value): Monad 
    { 
        return static::pure($value); 
    } 

    public abstract function bind(callable $f): Monad; 
} 

我们的实现非常简单。我们将 pure 与 Haskell 中的 return 别名,这样人们就不会迷失。请注意,它与您习惯的 return 关键字无关;它只是将值放入单子的上下文中。我们还定义了一个新的绑定函数,它以callable类型作为参数。

由于我们不知道内部值将如何存储,并且由于 PHP 类型系统的限制,我们无法实现applybind函数,尽管它们应该是非常相似的:

  • apply方法接受一个包装在Applicative类中的值,并将存储的函数应用于它

  • bind方法接受一个函数并将其应用于存储的值

两者之间的区别在于bind方法需要直接返回值,而apply方法首先再次使用purereturn函数包装值。

正如您可能已经了解的那样,使用不同语言的人倾向于以稍有不同的方式命名事物。这就是为什么有时您会看到bind方法被称为chainflatMap,这取决于您正在查看的实现。

单子定律

你现在知道了;单子必须遵守一些法则才能被称为单子。这些法则与幺半群的法则相同-单位元和结合律。因此,单子的所有有用属性也适用于单子。

然而,正如你将看到的,我们将描述的法则似乎与我们之前为单子看到的幂等性和结合性法则没有任何共同之处。这与我们定义的bindreturn函数的方式有关。使用一种叫做Kleisli组合操作符,我们可以转换这些法则,使它们看起来有点像我们之前看到的那些。然而,这有点复杂,对我们的目的毫无用处。如果你想了解更多,我可以引导你到wiki.haskell.org/Monad_laws

左单位元

return(x)->bind(f) == f(x)

这个法则规定,如果你取一个值,将其包装在单子的上下文中,并将其绑定到f,结果必须与直接在值上调用函数的结果相同。它确保bind方法对函数和值除了应用之外没有任何副作用。

这只有在bind方法不像apply方法那样再次将函数的返回值包装在单子内时才成立。这是函数的工作。

右单位元

m->bind(return) == m

这个法则规定,如果你将返回的值绑定到一个单子,你将得到你的单子。它确保return除了将值放入单子的上下文之外没有其他影响。

结合性

m->bind(f)->bind(g) == m->bind((function(\(x) { f(\)x)->bind(g); })

这些法则规定,你可以先将单子内的值绑定到f,然后再绑定到g,或者你可以将其绑定到第一个函数与第二个函数的组合。我们需要一个中间函数来模拟这一点,就像我们在 applicatives 的交换法则中需要一个中间函数一样。

这个法则允许我们获得与之前的结合性和组合性法则相同的好处。这种形式有点奇怪,因为单子保存的是值,而不是函数或操作符。

验证我们的单子

让我们写一个函数来检查单子的有效性:

<?php 

function check_monad_laws($x, Monad $m, callable $f, callable $g) 
{ 
    return [ 
        'left identity' => $m->return($x)->bind($f) == $f($x), 
        'right identity' => $m->bind([$m, 'return']) == $m, 
        'associativity' => 
            $m->bind($f)->bind($g) ==             $m->bind(function($x) use($f, $g) { return $f($x)->bind($g); }), 
    ]; 
} 

我们还需要一个单位单子:

class IdentityMonad extends Monad 
{ 
    private $value; 

    private function __construct($value) 
    { 
        $this->value = $value; 
    } 

    public static function pure($value): Applicative 
    { 
        return new static($value); 
    } 

    public function get() 
    { 
        return $this->value; 
    } 

    public function bind(callable $f): Monad 
    { 
        return $f($this->get()); 
    } 

    public function apply(Applicative $a): Applicative 
    { 
        return static::pure($this->get()($a->get())); 
    } 
} 

最后我们可以验证一切是否成立:

<?php 

print_r(check_monad_laws( 
    10, 
IdentityMonad::return(20), 
    function(int $a) { return IdentityMonad::return($a + 10); }, 
    function(int $a) { return IdentityMonad::return($a * 2); } 
)); 
// Array 
// ( 
//     [left identity] => 1 
//     [right identity] => 1 
//     [associativity] => 1 
// ) 

为什么要使用单子?

第一个原因是实际的。当你使用 applicative 应用一个函数时,结果会自动放入 applicative 的上下文中。这意味着,如果你有一个返回 applicative 的函数,并应用它,结果将是一个 applicative 内部的 applicative。任何看过电影《盗梦空间》的人都知道,把东西放在东西里面并不总是一个好主意。

单子是一种避免这种不必要嵌套的方式。bind函数将封装返回值的任务委托给函数,这意味着你只会有一层深度。

单子也是一种执行流程控制的方式。正如我们所见,函数式程序员倾向于避免使用循环或任何其他类型的控制流,比如使你的代码更难以理解的if条件。单子是一种强大的方式,以一种非常表达性的方式来顺序转换,同时保持你的代码整洁。

像 Haskell 这样的语言还有特定的语法糖来处理单子,比如do符号,这使得你的代码更容易阅读。一些人尝试在 PHP 中实现这样的东西,但在我看来并没有取得太大的成功。

然而,要真正理解单子抽象的力量,你必须看一些具体的实现,就像我们将在下一章中所做的那样。它们将允许我们以纯函数的方式执行IO操作,将日志消息从一个函数传递到另一个函数,甚至使用纯函数计算随机数。

关于单子的另一种看法

我们决定实现我们的Monad类,将applybind方法都留为抽象的。我们别无选择,因为值在Monad类内部的存储方式将只在child类中决定。

然而,正如我们已经说过的,bind方法有时在 Scala 中被称为 flatMap。顾名思义,这只是 map 和一个叫做flatten的函数的组合。

你明白我要说什么了吗?还记得嵌套应用的问题吗?我们可以添加一个flatten函数,或者像 Haskell 称呼的那样,将它作为Monad类的方法,而不是将bind作为一个抽象方法,我们可以使用map和我们的新方法来实现它。

我们仍然需要实现两种方法,但是两者不再做大致相同的工作,调用一个带有值的函数,一个将继续执行,另一个将负责解除Monad实例的嵌套。

因此,这样的函数对外部世界的用途有限,我决定使用所提供的实现。使用flatten函数进行实现是一个不错的练习,您可以尝试解决以更好地理解单子的工作原理。

一个快速的单子示例

想象一下,我们需要使用read_file函数读取文件的内容,然后使用post函数将其发送到webservice。我们将创建两个版本的上传函数来实现这一点:

  • 第一个版本将使用传统函数,在出现错误的情况下返回布尔值false

  • 功能版本将假定返回Either单子实例的柯里化函数。我们将在下一章中进一步描述这个单子;让我们假设它的工作原理与我们之前看到的Either类型相同。

在成功的情况下,必须调用给定的回调函数,并返回post方法返回的状态码:

<?php 

function upload(string $path, callable $f) { 
    $content = read_file(filename); 
    if($content === false) { 
        return false; 
    } 

    $status = post('/uploads', $content); 
    if($status === false) { 
        return $false; 
    } 

    return $f($status); 
} 

现在是功能版本,如下所示:

<?php 

function upload_fp(string $path, callable $f) { 
    return Either::pure($path) 
      ->bind('read_file') 
      ->bind(post('/uploads')) 
      ->bind($f); 
} 

我不知道你更喜欢哪一个,但我的选择很明确。使用Either而不是Maybe的选择也不是无辜的。这意味着在出现错误的情况下,功能版本还可以返回详细的错误消息,而不仅仅是false

进一步阅读

如果在完成本章后,您仍然感到有些迷茫,因为这是一个如此重要的话题,不要犹豫阅读以下文章或您自己找到的其他文章:

总结

这一章肯定是一个艰深的话题,但不要害怕,这是最后一个。从现在开始,我们将处理更多实际的主题和真实的应用。第六章,“真实的单子”将介绍我们刚刚学到的抽象的一些有用用途。

抽象,如函子,应用和单子,是函数世界的设计模式。它们是高级抽象,可以在许多不同的地方找到,您需要一些时间才能辨别它们。但是,一旦您对它们有了感觉,您可能会意识到它们无处不在,并且这将极大地帮助您思考如何操纵数据。

我们抽象的法则确实很普遍。在编写代码时,您可能已经在不知不觉中假设了它们。能够识别我们学到的模式将使您在重构或编写算法时更加自信,因为您的直觉总是会得到事实的支持。

如果您想玩玩本章的概念,我只能建议您开始使用我们在第三章中介绍的functional-php库进行实验。它包含许多定义各种代数结构的接口,这是数学家给予函子、单子等的花哨名称。一些方法名称可能不完全与我们使用的名称相同,但您应该能够理解它们背后的思想。由于库的名称有点难以找到,这里再次提供链接,github.com/widmogrod/php-functional

第六章:现实生活中的单子

在上一章中,我们涵盖了关于各种抽象的许多理论基础,引导我们到单子的概念。现在是时候应用这些知识,通过介绍一些单子的实例,这些实例将在您日常编码中证明有用。

每个部分都将以解决给定单子的问题的介绍开始,然后是一些用法示例,以便您可以获得一些实践。正如本介绍末尾所解释的那样,书中不会呈现实现本身,而是集中于用法。

正如您将看到的,一旦理论问题解决了,大多数实现对您来说将会显得非常自然。此外,其实用性不仅限于函数式编程的范围。本章中学到的大部分内容都可以应用于任何开发环境。

将要介绍的大多数单子都与副作用的管理有关,或者说一旦它们明确包含在单子中就是影响。在进行函数式编程时,副作用是不受欢迎的。一旦包含,我们就可以控制它们,使它们仅仅成为我们程序的影响。

单子主要用于两个原因。第一个是它们非常适合执行流程控制,正如在上一章中已经解释的那样。第二个是它们的结构允许您轻松地封装效果并保护代码的其余部分免受杂质的影响。

然而,让我们记住,这只是单子的一个可能用途。您可以用这个概念做更多的事情。但是让我们不要过于急躁;我们将在途中发现这一点。

在本章中,我们将涵盖以下主题:

  • 单子辅助方法

  • Maybe 和 Either 单子

  • List 单子

  • Writer 单子

  • Reader 单子

  • State 单子

  • IO 单子

为了专注于使用单子,并且由于实现通常不是最重要的部分,我们将使用PHP Functional库提供的单子。显然,重要的实现细节将在书中突出显示。您可以使用composer调用在您的项目中安装它。

**composer require widmogrod/php-functional**

重要的是要注意,php-functional库的作者在方法命名和一些实现细节方面做出了其他选择:

  • apply方法简单地是ap

  • unitreturn关键字在类中被of替换

  • 继承树有点不同,例如,有PointedChain接口

  • 该库使用特征来共享代码

  • 一些辅助函数是在类外实现的,需要单独导入

单子辅助方法

在上一章中,我们谈到了flatten方法以及它如何用于压缩相同单子实例的多个嵌套级别。这个函数经常被提及,因为它可以用于以另一种方式重写单子。然而,还有其他有用的辅助函数。

filterM 方法

过滤是函数式编程中的一个关键概念,但是如果我们的过滤函数返回的是一个单子而不是一个简单的布尔值呢?这就是filterM方法的用途。该方法不是期望返回一个简单的布尔值的谓词,而是使用任何可以转换为布尔值并且还将结果集合包装在相同单子中的谓词:

<?php 

use function Functional\head; 
use function Functional\tail; 

use Monad\Writer; 

function filterM(callable $f, $collection) 
{ 
    $monad = $f(head($collection)); 

    $_filterM = function($collection) use($monad, $f, &$_filterM){ 
        if(count($collection) == 0) { 
            return $monad->of([]); 
        } 

        $x = head($collection); 
        $xs = tail($collection); 

        return $f($x)->bind(function($bool) use($x, $xs, $monad, $_filterM) { 
            return $_filterM($xs)->bind(function(array $acc) use($bool, $x, $monad) { 
                if($bool) { 
                    array_unshift($acc, $x); 
                } 

                return $monad->of($acc); 
            }); 
        }); 
    }; 
    return $_filterM($collection); 
} 

实现有点难以理解,所以我会尝试解释发生了什么:

  1. 首先,我们需要了解我们正在处理的单子的信息,因此我们提取我们的集合的第一个元素,并通过应用回调函数从中获取单子。

  2. 然后我们声明一个围绕单子和谓词的闭包。

  3. 闭包首先测试集合是否为空。如果是这种情况,我们将返回一个包含空数组的单子实例。否则,我们将在集合的第一个元素上运行谓词。

  4. 我们将一个包含当前值的闭包绑定到包含布尔值的结果单子上。

  5. 第二个闭包递归地遍历整个数组,如果需要的话。

  6. 一旦我们到达最后一个元素,我们就会绑定一个新的闭包,它将使用布尔值将值添加到累加器中,或者不添加。

这并不容易,但由于它主要是内部管道工作,再加上 PHP 缺乏语法糖,理解一切并不是必要的。为了比较,这里是使用 Haskell 模式匹配和do notation功能实现的相同代码:

filterM :: (Monad m) => (a -> m Bool) -> [a] -> m [a] 
filterM _ []     = return [] 
filterM f (x:xs) = do 
    bool <- f x 
    acc  <- filterM p xs 
    return (if bool then x:acc else acc) 

正如您所看到的,这样更容易阅读。我认为任何人都能理解发生了什么。不幸的是,在 PHP 中,我们必须创建嵌套的内部函数才能实现相同的结果。然而,这并不是真正的问题,因为最终的函数非常容易使用。然而,一些功能模式的内部工作有时在 PHP 中可能有点令人不快,并且它们本身并不完全功能。

随着我们发现一些单子,例子将随之而来。这个辅助函数的实现在php-functional库中可用。

foldM 方法

foldM方法是fold方法的单子版本。它接受一个返回单子的函数,然后产生一个也是单子的值。然而,累加器和集合都是简单的值:

<?php 

function foldM(callable $f, $initial, $collection) 
{ 
    $monad = $f($initial, head($collection)); 

    $_foldM = function($acc, $collection) use($monad, $f, &$_foldM){ 
        if(count($collection) == 0) { 
            return $monad->of($acc); 
        } 

        $x = head($collection); 
        $xs = tail($collection); 

        return $f($acc, $x)->bind(function($result) use($acc,$xs,$_foldM) { 
            return $_foldM($result, $xs); 
        }); 
    }; 

    return $_foldM($initial, $collection); 
} 

该实现比filterM方法的实现要小一点,因为我们只需要递归;不需要从布尔值到值的转换。同样,我们将在本章的后续部分展示一些例子,并且php-funcational库中也有实现。

结束语

存在多个其他函数可以增强为与单子值一起使用。例如,您可以使用zipWithM方法,它使用返回单子的合并函数合并两个集合。php-functional库有一个mcompose的实现,它允许您组合返回相同单子实例的函数。

当您在使用单子时发现某种重复模式时,不要犹豫将其因式分解为辅助函数。它可能经常会派上用场。

Maybe 和 Either 单子

您应该已经非常清楚我们已经多次讨论过的 Maybe 和 Either 类型。我们首先定义了它们,然后我们了解到它们实际上是一个函子的完美例子。

我们现在将更进一步,将它们定义为单子,这样我们将能够在更多情况下使用它们。

动机

Maybe单子代表了一种计算序列随时可能停止返回有意义值的想法,使用我们在前一章中定义的Nothing类。当转换链相互依赖并且某些步骤可能无法返回值时,它特别有用。它允许我们避免通常伴随这种情况的可怕的null检查。

Either单子大部分具有相同的动机。微小的区别在于步骤通常要么抛出异常,要么返回错误,而不是空值。操作失败意味着我们需要存储由Left值表示的错误消息,而不是Nothing值。

实现

Maybe 和 Either 类型的代码可以在php-functional库中找到。实现非常简单-与我们自己先前的实现的主要区别是缺少isJustisNothing等方法,并且实例是使用辅助函数构造而不是静态工厂。

重要的是要注意,php-functional库中实现的 Either 单子不幸地没有自己处理捕获异常。您要么应用的函数,要么绑定到它的函数必须自行正确处理异常。您还可以使用tryCatch辅助函数来为您执行此操作。

例子

为了更好地理解Maybe单子的工作原理,让我们看一些例子。php-functional库使用辅助函数而不是类上的静态方法来创建新实例。它们位于Widmogrod\Monad\Maybe命名空间中。

另一个非常有用的辅助函数是maybe方法,它是一个带有以下签名的柯里化函数-maybe($default, callable $fn, Maybe $maybe)命名空间。当调用时,它将首先尝试从$maybe变量中提取值,并默认为$default变量。然后将其作为参数传递给$fn变量:

<?php 

use Widmogrod\Monad\Maybe as m; 
use Widmogrod\Functional as f; 

$just = m\just(10); 
$nothing = m\nothing(); 

$just = m\maybeNull(10); 
$nothing = m\maybeNull(null); 

echo maybe('Hello.', 'strtoupper', m\maybe('Hi!')); 
// HI! echo maybe('Hello.', 'strtoupper', m\nothing()); 
// HELLO. 

既然辅助函数已经完成,我们将演示如何将Maybe单子与foldM方法结合使用:

<?php 

$divide = function($acc, $i) { 
    return $i == 0 ? nothing() : just($acc / $i); 
}; 

var_dump(f\foldM($divide, 100, [2, 5, 2])->extract()); 
// int(5) 

var_dump(f\foldM($divide, 100, [2, 0, 2])->extract()); 
// NULL 

使用传统函数和array_reduce方法来实现这一点,结果大多会非常相似,但它很好地演示了foldM方法的工作原理。由于折叠函数绑定到每次迭代的当前单子值,一旦我们有一个空值,接下来的步骤将继续返回空值,直到结束。同样的函数也可以用来返回其他类型的单子,以便还包含有关失败的信息。

我们之前已经看到单子类型如何用于在可能存在或不存在的值上链接多个函数。然而,如果我们需要使用这个值来获取另一个可能为空的值,我们将有嵌套的Maybe实例:

<?php 

function getUser($username): Maybe { 
  return $username == 'john.doe' ? just('John Doe') : nothing(); 
} 

var_dump(just('john.doe')->map('getUser')); 
// object(Monad\Maybe\Just)#7 (1) { 
//     ["value":protected]=> object(Monad\Maybe\Just)#6 (1) { 
//         ["value":protected]=> string(8) "John Doe" 
//     } 
// } 

var_dump(just('jane.doe')->map('getUser')); 
// object(Monad\Maybe\Just)#8 (1) { 
//     ["value":protected]=> object(Monad\Maybe\Nothing)#6 (0) { } 
// } 

在这种情况下,您可以使用flatten方法,或者简单地使用bind方法而不是map方法:

<?php 

var_dump(just('john.doe')->bind('getUser')); 
// object(Monad\Maybe\Just)#6 (1) { 
//     ["value":protected]=> string(8) "John Doe" 
// } 

var_dump(just('jane.doe')->bind('getUser')); 
// object(Monad\Maybe\Nothing)#8 (0) { } 

我同意Maybe单子的例子有点令人失望,因为大多数用法已经在之前的单子中描述过了,因此创建Maybe单子本身并不会增加功能,它只会允许我们使用其他期望单子的模式;功能与以前一样。

Either单子也可以做类似的情况;这就是为什么这里不会有新的例子。只需确保查看辅助函数,而不是在想要使用单子时重写管道。

列表单子

列表或集合单子代表了所有以集合作为参数并返回零个、一个或多个值的函数的范畴。该函数应用于输入列表中的所有可能值,并将结果连接起来生成一个新的集合。

重要的是要理解列表单子实际上并不代表一个简单的值列表,而是代表单子的所有不同可能值的列表。这个想法通常被描述为非确定性。正如我们在CollectionApplicative函数中看到的,当您将一组函数应用于一组值时,这可能会导致有趣的结果。我们将尝试在例子中扩展这个主题以澄清这一点。

动机

列表单子体现了这样一个观念,即在完整计算结束之前,您无法知道最佳结果。它允许我们探索所有可能的解决方案,直到我们有最终的解决方案。

实现

单子是在php-functional库中以Collection方法的名称实现的。这是以一种非常直接的方式完成的。然而,与我们自己以前的实现相比,有两种新方法可用:

  • reduce方法将对单子内存储的值执行折叠操作。

  • traverse方法将一个返回应用程序的函数映射到单子内存储的所有值。然后将应用程序应用于当前累加器。

例子

让我们从一些困难的事情开始,使用我们之前发现的filterM方法。我们将创建一个被称为集合的powersetpowerset集合是给定集合的所有可能子集,或者,如果您愿意,是其成员的所有可能组合:

<?php 

use Monad\Collection; 
use Functional as f; 

$powerset = filterM(function($x) { 
    return Collection::of([true, false]); 
}, [1, 2, 3]); 

print_r($powerset->extract()); 
// Array ( 
//     [0] => Array ( [0] => 1 [1] => 2 [2] => 3 ) 
//     [1] => Array ( [0] => 1 [1] => 2 ) 
//     [2] => Array ( [0] => 1 [1] => 3 ) 
//     [3] => Array ( [0] => 1 ) 
//     [4] => Array ( [0] => 2 [1] => 3 ) 
//     [5] => Array ( [0] => 2 ) 
//     [6] => Array ( [0] => 3 ) 
//     [7] => Array ( ) // ) 

注意

由于构造函数没有在实际数组内包装另一个数组,所以这目前无法使用 Collection/filterM 的实际实现。请参阅github.com/widmogrod/php-functional/issues/31

这里发生了什么?这似乎是某种黑魔法。事实上,这很容易解释。将函数绑定到集合会导致该函数应用于其所有成员。在这种特殊情况下,我们的过滤函数返回一个包含truefalse值的集合。这意味着filterM方法的内部闭包负责用值替换布尔值被运行两次,然后结果被附加到先前创建的所有集合上。让我们看看第一步以使事情更清晰:

  1. 过滤首先应用于值1,创建两个集合[][1]

  2. 现在过滤器应用于值2,创建两个新集合([][2]),需要附加到我们之前创建的集合上,创建四个集合[][1][2][1, 2]

  3. 每一步都会创建两个集合,这些集合将附加到先前创建的集合上,使得集合的数量呈指数级增长。

还不清楚吗?让我们看另一个例子。这一次,试着将集合想象成一棵树,其中每个初始值都是一个分支。当你绑定一个函数时,它被应用于每个分支,如果结果是另一个集合,它就会创建新的分支:

<?php 
use Monad\Collection; 
use Functional as f; 

$a = Collection::of([1, 2, 3])->bind(function($x) { 
    return [$x, -$x]; 
}); 
print_r($a->extract()); 
// Array ( 
//     [0] => 1 
//     [1] => -1 
//     [2] => 2 
//     [3] => -2 
//     [4] => 3 
//     [5] => -3 
// ) 

$b = $a->bind(function($y) { 
    return $y > 0 ? [$y * 2, $y / 2] : $y; 
}); 
print_r($b->extract()); 
// Array ( 
//     [0] => 2 
//     [1] => 0.5 
//     [2] => -1 
//     [3] => 4 
//     [4] => 1 
//     [5] => -2 
//     [6] => 6 
//     [7] => 1.5 
//     [8] => -3 
// ) 

为了让事情对你更加复杂一些,第二个函数根据给定的值返回可变数量的元素。让我们将其可视化为一棵树:

Examples

骑士可以去哪里?

现在我们对Collection单子的工作原理有了很好的理解,让我们来解决一个更困难的挑战。给定国际象棋棋盘上的起始位置,我们想知道骑士棋子在三步内可以到达的所有可能有效位置。

我希望你花一点时间想象一下你会如何实现它。一旦你完成了,让我们尝试使用我们的单子。我们首先需要一种方法来编码我们的骑士位置。一个简单的类就足够了。此外,国际象棋棋盘有八列和八行,所以让我们添加一个检查位置是否有效的方法:

<?php 

class ChessPosition { 
    public $col; 
    public $row; 

    public function __construct($c, $r) 
    { 
        $this->col = $c; 
        $this->row = $r; 
    } 

    public function isValid(): bool 
    { 
        return ($this->col > 0 && $this->col < 9) && 
               ($this->row > 0 && $this->row < 9); 
    } 
} 

function chess_pos($c, $r) { return new ChessPosition($c, $r); } 

现在我们需要一个函数,给定一个起始位置,返回骑士的所有有效移动:

<?php 

function moveKnight(ChessPosition $pos): Collection 
{ 
    return Collection::of(f\filter(f\invoke('isValid'), Collection::of([ 
        chess_pos($pos->col + 2, $pos->row - 1), 
        chess_pos($pos->col + 2, $pos->row + 1), 
        chess_pos($pos->col - 2, $pos->row - 1), 
        chess_pos($pos->col - 2, $pos->row + 1), 
        chess_pos($pos->col + 1, $pos->row - 2), 
        chess_pos($pos->col + 1, $pos->row + 2), 
        chess_pos($pos->col - 1, $pos->row - 2), 
        chess_pos($pos->col - 1, $pos->row + 2), 
    ]))); 
} 

print_r(moveKnight(chess_pos(8,1))->extract()); 
// Array ( 
//     [0] => ChessPosition Object ( [row] => 2 [col] => 6 ) 
//     [1] => ChessPosition Object ( [row] => 3 [col] => 7 ) 
// ) 

很好,看起来工作得很好。现在我们只需要连续三次绑定这个函数。而且,在此过程中,我们还将创建一个函数,检查骑士是否可以在三步内到达给定位置:

<?php 

function moveKnight3($start): array 
{ 
    return Collection::of($start) 
        ->bind('moveKnight') 
        ->bind('moveKnight') 
        ->bind('moveKnight') 
        ->extract(); 
} 

function canReach($start, $end): bool 
{ 
    return in_array($end, moveKnight3($start)); 
} 

var_dump(canReach(chess_pos(6, 2), chess_pos(6, 1))); 
// bool(true) 

var_dump(canReach(chess_pos(6, 2), chess_pos(7, 3))); 
// bool(false) 

唯一剩下的事情就是在真正的国际象棋棋盘上检查我们的函数是否正确工作。我不知道你是如何以命令式的方式做到这一点的,但是我的解决方案比我们现在得到的解决方案要不那么优雅。

如果你想再玩一会儿,你可以尝试参数化移动的次数,或者为其他棋子实现这个功能。正如你将看到的,这只需要进行最小的更改。

写作单子

如果你记得的话,纯函数不能有任何副作用,这意味着你不能在其中放置调试语句,例如。如果你像我一样,var_dump方法是你的调试工具,那么你只能违反纯度规则或使用其他调试技术。由于函数的所有输出必须通过其返回值,脑海中首先浮现的一个想法是返回一个值元组-原始返回值和你需要的任何调试语句。

然而,这个解决方案非常复杂。想象一下,你有一个函数,它可以将一个数值减半,返回减半后的值和接收到的输入,用于调试目的。现在,如果你想将这个函数与自身组合,创建一个新的函数,返回被四除的值,你还需要修改输入,以便它们可以接受你的新返回格式。这一过程一直持续下去,直到你相应地修改了所有的函数。这也会对柯里化造成一些问题,因为现在你有了一个多余的参数,如果你不关心调试语句,这个参数实际上并不实用。

你正在寻找的解决方案是Writermonad。遗憾的是,在撰写本文时,php-functional库中还没有实现。

动机

Writer monad 用于封装函数的主要返回值旁边的某种相关语句。这个语句可以是任何东西。它经常用于存储生成的调试输出或跟踪信息。手动这样做是繁琐的,可能会导致复杂的管理代码。

Writer monad 提供了一种干净的方式来管理这种副输出,并允许你在返回简单值的函数旁边插入返回这种信息的函数。在计算序列结束时,附加值可以被丢弃、显示或根据操作模式进行任何处理。

实现

由于 monad 需要连接输出值,任何 monoid 的实例都可以被用作这样。为了简化基于字符串的日志记录,任何字符串也可以被直接管理。显然,使用一个具有缓慢操作的 monoid 将导致性能成本。

php-functional库包括一个StringMonoid类的实现,每个字符串都将被提升到这个类中。然而,runWriter方法将始终返回一个StringMonoid类,因此对于使用它的人来说并不奇怪。除此之外,这个实现非常简单直接。

示例

正如我们刚才看到的,Writer非常适合日志记录。结合filter方法,这可以用来理解在过滤函数中发生了什么,而无需倾向于转储值:

<?php 

$data = [1, 10, 15, 20, 25]; 
$filter = function($i) { 
    if ($i % 2 == 1) { 
        return new Writer(false, "Reject odd number $i.\n"); 
    } else if($i > 15) { 
      return new Writer(false, "Reject $i because it is bigger than 15\n"); 
    } 

    return new Writer(true); 
}; 

list($result, $log) = filterM($filter, $data)->runWriter(); 

var_dump($result); 
// array(1) { 
//   [0]=> int(10) 
// } 

echo $log->get(); 
// Reject odd number 1\. // Reject odd number 15\. // Reject 20 because it is bigger than 15 
// Reject odd number 25\. 

正如我们所看到的,Writer monad 允许我们准确了解为什么某些数字被过滤掉。在这样一个简单的例子中,这可能看起来不像什么,但条件并不总是那么容易理解。

你也可以使用Writer来添加更传统的调试信息:

<?php 

function some_complex_function(int $input) 
{ 
    $msg = new StringMonoid('received: '.print_r($input,  true).'.'); 

    if($input > 10) { 
        $w = new Writer($input / 2, $msg->concat(new  StringMonoid("Halved the value. "))); 
    } else { 
        $w = new Writer($input, $msg); 
    } 

    if($input > 20) 
    { 
        return $w->bind('some_complex_function'); 
    } 

    return $w; 
} 

list($value, $log) = (new Writer(15))->bind('some_complex_function')->runWriter(); 
echo $log->get(); 
// received: 15\. Halved the value. list($value, $log) = some_complex_function(27)->runWriter(); 
echo $log->get(); // received: 27\. Halved the value. received: 13\. Halved the value. list($value, $log) = some_complex_function(50)->runWriter(); 
echo $log->get(); 
// received: 50\. Halved the value. received: 25\. Halved the value. received: 12\. Halved the value. 

这个 monad 非常适合跟踪有用的信息。此外,它经常避免在你的函数和库代码中留下一些不需要的var_dumpecho方法。一旦调试完成,留下这些消息,它们可能对其他人有用,然后移除runWriter方法返回的$log值的使用。

显然,你也可以使用Writermonad 来跟踪任何类型的信息。一个很好的用法是通过Writer实例始终返回执行时间,将性能分析直接嵌入到你的函数中。

如果你需要存储多种类型的数据,Writer monad 不仅限于字符串值,任何 monoid 都可以。例如,你可以声明一个包含执行时间、堆栈跟踪和调试消息的特定 monoid,并将其与你的 Writer 一起使用。这样,你的每个函数都能向调用它们的人传递有用的信息。

我们可以说,始终具有这种信息会减慢程序的运行速度。这可能是正确的,但我想在大多数应用程序中,这种优化是不需要的。

Reader monad

碰巧你有一堆函数,它们都应该接受相同的参数,或者给定值列表的一个子集。例如,你有一个配置文件,你的应用程序的各个部分需要访问其中存储的值。一个解决方案是有一种全局对象或单例来存储这些信息,但正如我们已经讨论过的,这会导致一些问题。在现代 PHP 框架中更常见的方法是使用一个叫做依赖注入DI)的概念。Reader 单子允许你以纯函数的方式做到这一点。

动机

提供一种共享公共环境的方式,例如配置信息或类实例,跨多个函数进行。这个环境对于计算序列是只读的。然而,它可以被修改或扩展,用于当前步骤的任何子计算。

实施

Reader类执行函数评估是懒惰的,因为当函数绑定时环境的内容还不知道。这意味着所有函数都被包裹在单子内部的闭包中,当调用runReader方法时才运行。除此之外,在php-functional库中可用的实现非常直接。

例子

使用Reader单子与我们到目前为止所见到的有些不同。绑定的函数将接收计算中前一步的值,并且必须返回一个持有接收环境的函数的新 reader。如果你只想处理当前值,使用map函数会更容易,因为它不需要返回一个Reader实例。然而,你将不会收到上下文:

<?php 
function hello() 
{ 
    return Reader::of(function($name) { 
        return "Hello $name!"; 
    }); 
} 

function ask($content) 
{ 
    return Reader::of(function($name) use($content) { 
        return $content. ($name == 'World' ? '' : ' How are you ?'); 
    }); 
} 

$r = hello() 
      ->bind('ask') 
      ->map('strtoupper'); 

echo $r->runReader('World'); 
// HELLO WORLD! echo $r->runReader('Gilles'); 
// HELLO GILLES! HOW ARE YOU ? 

这个不太有趣的例子只是提出了你可以做什么的基础知识。下一个例子将展示如何使用这个单子进行 DI。

注意

如果你使用过现代的 Web 框架,你可能已经知道什么是依赖注入,或者 DI。否则,这里是一个真正快速的解释,我可能会因此被烧死。DI 是一种模式,用于避免使用单例或全局可用的实例。相反,你声明你的依赖项作为函数或构造函数参数,一个依赖注入容器DIC)负责为你提供它们。

通常,这涉及让 DIC 实例化所有对象,而不是使用new关键字,但方法因框架而异。

我们如何使用Reader单子来做到这一点?很简单。我们需要创建一个容器来保存所有的服务,然后我们将使用我们的 reader 来传递这些服务。

举例来说,假设我们有一个用于连接数据库的EntityManager,以及一个发送电子邮件的服务。另外,为了保持简单,我们不会进行任何封装,而是使用简单的函数而不是类:

<?php 

class DIC 
{ 
    public $userEntityManager; 
    public $emailService; 
} 

function getUser(string $username) 
{ 
    return Reader::of(function(DIC $dic) use($username) { 
        return $dic->userEntityManager->getUser($username); 
    }); 
} 

function getUserEmail($username) 
{ 
    return getUser($username)->map(function($user) { 
        return $user->email; 

    }); 
} 

function sendEmail($title, $content, $email) 
{ 
    return Reader::of(function(DIC $dic) use($title, $content, $email) { 
        return $dic->emailService->send($title, $content, $email); 
    }); 
} 

现在我们想要编写一个在用户在我们的应用程序上注册后被调用的控制器。我们需要给他们发送一封电子邮件并显示某种确认。现在,让我们假设用户已经保存在数据库中,并且我们的理论框架提供了POST方法值作为参数的使用:

<?php 

function controller(array $post) 
{ 
    return Reader::of(function(DIC $dic) use($post) { 
        getUserEmail($post['username']) 
            ->bind(f\curry('sendEmail', ['Welcome', '...'])) 
            ->runReader($dic); 

        return "<h1>Welcome !</h1>"; 
    }); 
} 

好的,我们已经准备好进行快速测试。我们将创建一些面向服务的类,以查看管道是否正常工作:

<?php 

$dic = new DIC(); 
$dic->userEntityManager = new class() { 
    public function getUser() { 
      return new class() { 
          public $email = 'john.doe@email.com'; 
      }; 
    } 
}; 

$dic->emailService = new class() { 
    public function send($title, $content, $email) { 
        echo "Sending '$title' to $email"; 
    } 
}; 

$content = controller(['username' => 'john.doe'])->runReader($dic); 
// Sending 'Welcome' to john.doe@email.com 

echo $content; 
// <h1>Welcome !</h1> 

显然,我们还没有一个可用的框架,但我认为这很好地展示了Reader单子在 DI 方面提供的可能性。

关于需要执行的 IO 操作,以将新创建的用户存储到数据库中并发送邮件,我们将看到如何使用稍后将介绍的 IO 单子来实现。

状态单子

State 单子是读取器单子的一种泛化,因为每个步骤在调用下一步之前都可以修改当前状态。由于引用透明语言不能具有共享的全局状态,技巧是将状态封装在单子内部,并将其显式地传递给序列的每个部分。

动机

它提供了一个干净且易于使用的过程,可以在序列中的多个步骤之间传递共享状态。这显然可以手动完成,但这个过程容易出错,并且导致代码可读性较差。单子隐藏了复杂性,因此您可以简单地编写以状态作为输入并返回新状态的函数。

实现

php-functional库中提供的实现与我们刚刚讨论的Reader单子几乎相同,只有一个关键区别-每个绑定函数都可以更新状态。这导致了与绑定到单子的函数不同的函数-它们需要返回一个包含值作为第一个元素和新状态作为第二个元素的数组,而不是返回一个值。

示例

正如我们已经讨论过的,函数不可能返回当前时间或某种随机值。state单子可以通过提供一种干净的方式来传递state变量来帮助我们做到这一点,就像我们之前使用Reader环境一样:

function randomInt() 
{ 
    return s\state(function($state) { 
        mt_srand($state); 
        return [mt_rand(), mt_rand()]; 
    }); 
} 

echo s\evalState(randomInt(), 12345); 
// 162946439 

state单子的另一个用途是实现缓存系统:

<?php 

function getUser($id, $current = []) 
{ 
    return f\curryN(2, function($id, $current) { 
        return s\state(function($cache) use ($id, $current) { 
            if(! isset($cache[$id])) { 
                $cache[$id] = "user #$id"; 
            } 

            return [f\append($current, $cache[$id]), $cache]; 
        }); 
    })(...func_get_args()); 
} 

list($users, $cache) = s\runState( 
  getUser(1, []) 
    ->bind(getUser(2)) 
    ->bind(getUser(1)) 
    ->bind(getUser(3)), 
  [] 
); 

print_r($users); 
// Array ( 
//     [0] => user #1 
//     [1] => user #2 
//     [2] => user #1 
//     [3] => user #3 
// ) 

print_r($cache); 
// Array ( 
//     [1] => user #1 
//     [2] => user #2 
//     [3] => user #3 
// ) 

正如我们所看到的,用户列表中包含user 1两次,但缓存只有一次。这是一个非常基本的缓存机制,但它可能会派上用场。

state单子还有许多其他用途,但老实说,如果没有像 do 表示法这样的语法糖,我不太确定它是否适合 PHP 编程。如果您感兴趣,我相信您会在网上找到许多其他资源,但我们将在这里停止示例。

IO 单子

输入和输出是副作用的精髓。当您从外部源获取函数输出时,没有办法保证纯度,因为这些输出会随着输入无关地发生变化。并且一旦您输出了某些东西,无论是屏幕、文件还是其他任何地方,您都改变了与函数输出无关的外部状态。

函数社区中的一些人认为,例如,日志输出或调试语句不一定应被视为副作用,因为通常它们对于运行应用程序的结果没有影响。最终用户并不在乎是否将某些内容写入日志文件,只要它能得到想要的结果并且操作可以随意重复。说实话,我对这个问题的看法还没有完全形成,而且老实说,我并不在乎,因为写入单子让我们以巧妙的方式处理日志记录和调试语句。

然而,有时您需要从外部获取信息,通常,如果您的应用程序值得做任何事情,您需要在某个地方显示或写入最终结果。

我们可以想象在开始任何计算之前获取所有值,并使用某种巧妙的数据结构传递它们。这对于一些较简单的应用程序可能有效,但是一旦您需要根据一些计算出的值执行数据库访问,现实开始显现,您会意识到这在长期内根本行不通。

IO 单子提出的技巧是按照我们刚刚提出的方式进行,但是相反。您首先描述程序所需的所有计算步骤。您将它们封装在 IO 单子的实例中,当一切都清晰地定义为引用透明的函数调用时,您启动最终执行所有所需 IO 操作的程序,并调用每个描述的步骤。

这样,您的应用程序只由纯函数组成,您可以轻松测试和理解。与输入和输出相关的所有操作都是在最后执行的,复杂性被隐藏在 IO 单子内部。为了强制执行这一点,IO 单子被称为单向单子,意味着无法从中获取任何值。您只有两个选择:

  • 将计算或操作绑定到单子,以便稍后执行它们

  • 运行这些计算以获得应用程序的最终结果

我想如果您从未见过像这样创建的应用程序,可能会感到非常困惑。这些例子将尝试给您第一印象,以及我们将在第十一章,“设计一个功能应用程序”中深入探讨这个主题。

动机

IO 单子通过将所有 IO 操作限制在单子内部,解决了输入和输出破坏引用透明度和函数纯度的问题。应用程序所需的所有计算步骤首先以功能方式描述。完成这一点后,我们接受最终步骤无法无副作用,并运行存储在单子内部的所有序列。

实施

php-functional库提供的实现非常简单,因为没有真正的微妙之处。只需要一个小技巧,即在调用run方法时进行计算,而不是在函数绑定时进行计算。

此外,该库还提供了Widmogrod\Monad\IO命名空间下的辅助函数,以帮助您使用单子。您可以轻松地从命令行读取用户输入,在屏幕上打印文本,并读取和写入文件和环境变量。

例子

我们将利用mcompose方法来组合多个IO操作:

<?php 

use Widmogrod\Functional as f; 
use Widmogrod\Monad\IO; 
use Widmogrod\Monad\Identity; 

$readFromInput = f\mcompose(IO\putStrLn, IO\getLine, IO\putStrLn); 
$readFromInput(Monad\Identity::of('Enter something and press  <enter>'))->run(); 
// Enter something and press <enter> 
// Hi! // Hi! 

因此,我们首先创建一个使用putStrLn显示单子当前内容的函数,要求一些输入,并将结果显示回来。

如果要保持引用透明度,IO 单子需要包装整个应用程序的计算。这是因为您的输入需要通过它来检索,任何输出也必须通过单子完成。这意味着我们可以展示很多例子,而实际上并没有真正抓住其使用的真正本质。这就是为什么我们将在这里停下来,等到第十一章,“设计一个功能应用程序”,看看如何实现它。

总结

在本章中,我们已经看过多个单子及其实现。我希望这些例子清楚地说明了如何使用它们以及它们的好处是什么:

  • 当计算可能返回空时,可以使用 Maybe 单子

  • Either 单子可用于计算可能出错的情况

  • List 单子可用于计算有多个可能结果的情况

  • 当需要在返回值旁边传递一些辅助信息时,可以使用 Writer 单子

  • Reader 单子可用于在多个计算之间共享一个公共环境

  • State 单子是 Reader 单子的升级版本,其中环境可以在每次计算之间更新

  • IO 单子可用于以引用透明的方式执行 IO 操作

然而,还有其他多个计算可以使用单子简化。在编写代码时,我鼓励您退后一步,看看结构是否符合单子模式。如果是这样,您可能应该使用我们的Monad类来实现它,以从迄今为止学到的知识中受益。

另外,这些各种单子可以组合使用,实现复杂的转换和计算。我们将在第十章PHP 框架和 FP中讨论这个话题,其中我们将讨论单子变换器,以及第十一章设计一个函数式应用

在书的这一部分,你可能会对一些函数式技术印象深刻,但我想我们到目前为止看到的大部分东西都有点尴尬,函数式编程可能看起来很繁琐。这种感觉对于两个主要原因来说是完全正常的。

首先,这种尴尬往往是由于某种缺失的抽象或待发现的技术所致。如果这是一本关于 Haskell 的书,你会学到所有这些内容,并且你会有一些其他书来查找它们。然而,这本书是关于 PHP 的;我们将在后面的章节中学习一些更多的概念,但之后,你将大部分时间都是靠自己,就像一个先驱一样。

我只能鼓励你在遇到这些情况时坚持下去,寻找代码中的模式和共性因素。一步一步,你将打造一个强大的工具箱,事情会变得更容易。

其次,这一切对你来说可能都是新的。转换编程范式真的很难,可能会让人感到沮丧。但不要害怕,随着时间、练习和经验的积累,你会变得更加自信,收获也会开始超过成本。学习曲线越陡峭,回报就越大。

在下一章中,我们将发现一些新的函数式概念和模式,这将使我们能够充分利用我们到目前为止学到的各种技术。

第七章:函数式技术和主题

我们已经涵盖了与函数式编程相关的基础技术。但是,你可能可以想象,还有很多其他主题需要涵盖。在本章中,你将学习一些这些模式和想法。

一些主题将被深入讨论,其他主题将被提及,并指向外部资源,如果你想了解更多。由于这是一本关于 PHP 函数式编程的入门书,最先进的想法超出了范围。然而,如果你在某个地方遇到了关于这样一个主题的文章,你应该有足够的理解力,至少能够理解其要点。

本章的各节并不一定彼此关联。有些内容可能对你来说是新的,有些则与之前呈现的摘录相关。

在本章中,我们将涵盖以下主题:

  • 类型系统、类型签名及其用途

  • 无点风格

  • 使用const关键字来方便匿名函数的使用

  • 递归、堆栈溢出和跳板

  • 模式匹配

  • 类型类

  • 代数结构和范畴论

  • 单子变换器

  • 镜头

类型系统

免责声明:我并不打算在静态和动态类型的爱好者之间挑起争端。讨论哪种更好以及为什么并不是本书的目标,我会让你们每个人自己决定喜欢什么。如果你对这个主题感兴趣,我可以推荐阅读pchiusano.github.io/2016-09-15/static-vs-dynamic.html,这是一个很好的总结,尽管有点偏向静态类型。

话虽如此,类型和类型系统是函数式编程中的重要主题,即使这些类型并不是由语言强制执行的。函数签名的类型是一种元语言,可以简洁有效地传达有关函数目的的信息。

正如我们将看到的那样,清楚地声明函数的输入和输出的预期类型是其文档的重要部分。它不仅通过允许你跳过阅读函数代码来减轻认知负担,还允许你推断出关于正在发生的重要事实并推导出“自由定理”。

Hindley-Milner 类型系统

Hindley-Milner,也称为 Damas-Milner 或 Damas-Hindley-Milner,以最早理论化它的人的名字命名,是一种类型系统。类型系统是一组规则,定义变量或参数可以具有的类型以及不同类型如何相互作用。

Hindley-Milner 的主要特点之一是它允许类型推断。这意味着你通常不需要明确地定义类型;它可以从其他来源推断出,比如上下文或周围元素的类型。确切地说,类型推断是由一种叫做算法 W的算法完成的,它与 Hindley-Milner 类型系统有关,但并不完全相同。

它还允许多态性;这意味着如果你有一个返回列表长度的函数,列表元素的类型不需要被知道,因为它对计算没有影响。这类似于你可以在 C++或 Java 中找到的泛型,但并不完全相同,因为它更加强大。

大多数静态类型的函数式语言,如 Haskell、OCaml 和 F#,使用 Hindley-Milner 作为它们的类型系统,通常还会使用扩展来处理一些边缘情况。Scala 以其自己的类型系统而著称。

除了关于类型系统的理论,还有一种通常被接受的方法来描述函数的输入和输出参数,这正是我们感兴趣的。这与你可以在 PHP 中使用的类型提示非常不同,当语言不使用这种特定语法时,它通常被放在函数顶部的注释中。从现在开始,我们将把这样的类型注释称为函数的“类型签名”。

类型签名

作为第一个简单的例子,我们将从strtoupperstrlen PHP 函数的类型签名开始:

// strtoupper :: string -> string 
// strlen :: string -> int 

这很简单理解:我们从函数名开始,后面是参数的类型,一个箭头,和返回值的类型。

那么有多个参数的函数呢?考虑以下情况:

// implode :: string -> [string] -> string 

为什么有多个箭头?如果您考虑到柯里化,这可能会帮助您得到答案。如果我们使用括号编写相同的函数签名,这可能会进一步帮助您:

// implode :: string -> ([string] -> string) 

基本上,我们有一个接受string类型并返回一个接受字符串数组并返回字符串的新函数。最右边的类型始终是返回值的类型。所有其他类型都是按顺序的各种参数。括号用于表示函数。让我们看看它在有更多参数的情况下是什么样子的:

// number_format :: float -> (int -> (string -> (string -> string))) 

或者不使用括号:

// number_format :: float -> int -> string -> string -> string 

我不知道您的意见是什么,但我个人更喜欢后者,因为它的噪音较小,一旦您习惯了它,括号就不会带来太多信息。

如果您熟悉number_format函数,您可能已经注意到我提出的类型签名包含所有参数,甚至是可选参数。这是因为没有一种传达这些信息的标准方法,因为函数式语言通常不允许这样的参数。然而,Haskell 有一个Optional数据类型,用于模拟这一点。有了这些信息,具有默认值的参数有时会显示如下:

// number_format :: float -> Optional int -> Optional string -> Optional string -> string 

这很有效,并且很容易理解,直到你有一个名为Optional的数据类型。也没有一种常见的方法来表达默认值是什么。

据我所知,没有办法传达函数接受可变数量参数的信息。由于 PHP 7.0 引入了一个新的语法,我建议我们在本书的其余部分使用它:

// printf :: string -> ...string -> int 

我们之前看到括号用于表示函数的概念,但通常出于可读性的原因通常不使用。然而,当使用函数作为参数时,情况并非如此。在这种情况下,我们需要保留函数期望或返回另一个函数的信息:

// array_reduce :: [a] -> (b -> a -> b) -> Optional a -> b 
// array_map :: (a -> b) -> ...[a] -> [b] 

您可能会问自己,那些ab变量是什么?这是我们之前谈到的多态特性。array_reducearray_map函数不关心数组中包含的元素的真实类型是什么,它们不需要知道这些信息来执行它们的工作。我们可以像我们之前使用printf方法那样使用mixed函数,但那样我们将丢失一些有用的数据。

a变量是某种类型,b变量是另一种类型,或者也可以是相同的。像ab这样的变量有时被称为类型变量。这些类型签名所说的是,我们有一个具有某种类型(类型a)的值数组,一个接受这样一个值并返回另一个类型(类型b)的函数;显然,最终值与回调的值相同。

名称ab是一种约定,但您可以自由使用任何您想要的东西。在某些情况下,使用更长的名称或某些特定字母可能有助于传达更多信息。

注意

如果您对array_reduce函数的签名有困难,这是完全正常的。您还不熟悉语法。让我们尝试逐个接受参数:

  • 包含a类型元素的数组

  • 一个函数,接受类型b(累加器),类型a(当前值),并返回类型b(新的累加器内容)

  • 一个与数组元素相同类型的可选初始值

  • 返回值是b类型,与累加器相同类型

这个签名没有告诉我们ab的确切类型。就我们所知,b本身可能是一个数组,一个类,一个布尔值,真的可以是任何东西。类型ab也可以是相同的类型。

您还可以有一个唯一的类型变量,就像array_filter函数一样:

// array_filter :: (a -> bool) -> [a] -> [a] 

由于类型签名只使用了a类型,这意味着输入数组中的元素和返回的数组将具有完全相同的类型。由于a类型不是特定类型,这个类型签名也告诉我们array_filter函数适用于所有类型,这意味着它不能转换值。列表中的元素只能被重新排列或过滤。

类型签名的最后一个特点是,您可以缩小给定类型变量的可能类型。例如,您可以指定某个类型m应该是给定类的子类:

// filterM :: Monad m => (a -> m Bool) -> [a] -> m [a] 

我们刚刚引入了一个新的双箭头符号。您将始终在类型签名的开头找到它,而不会在中间找到。这意味着前面的内容定义了某种特定性。

在我们的例子中,我们将m类型变量约束为Monad类的后代。这使我们能够声明filterM方法首先接受一个返回布尔值的函数封装在一个 monad 中作为第一个参数,并且它的返回值将被封装在相同的 monad 中。

如果您愿意,可以指定多个约束。如果我们想象有两种类型,TypeATypeB类型,我们可以有以下类型签名:

// some_function :: TypeA a TypeB b => a -> b -> string 

仅仅通过查看类型签名就无法清楚地知道这个函数的作用,但我们知道它期望TypeA类型的一个实例和TypeB类型的一个实例。返回值将是一个字符串,显然是基于参数计算的结果。

在这种情况下,我们不能像array_filter方法那样做出相同的假设,即不会发生任何转换,因为我们对类型变量有约束。我们的函数很可能知道如何操作我们的数据,因为它们是某种类型或其子类的实例。

我知道有很多东西要理解,但正如前面的array_reduce函数示例所证明的那样,类型签名允许我们以简洁的方式编码大量信息。它们也比 PHP 类型提示更精确,因为它们允许我们说array_map方法可以从一种类型转换为另一种类型,而array_filter方法将保持数组中的类型。

如果您浏览过php-functional库的代码,您可能已经注意到作者在大多数函数的文档块中使用了这样的类型签名。您还会发现一些其他函数式库也在做同样的事情,这种习惯在 JavaScript 世界也在传播。

自由定理

类型签名不仅让我们了解函数的作用,还允许我们根据类型信息推导出有用的定理和规则。这些被称为自由定理,因为它们随类型签名免费提供。这个想法是由 Philip Walder 在 1989 年发表的论文Theorems for free!中发展起来的。

通过使用一个自由定理,我们可以肯定以下事实:

// head :: [a] -> a 
// map :: (a -> b) -> [a] -> [b] 
head(map(f, $x)) == f(head($x) 

这对您来说可能显而易见,因为您有一些常识并知道函数的作用,但计算机缺乏常识。因此,为了将等式的左边优化到右边,我们的编译器或解释器必须依赖自由定理来实现。

类型签名如何证明这个定理?还记得我们说过a类型是我们的函数一无所知的通用类型吗?推论是它们不能修改数组中的值,因为这样的通用函数不存在。唯一知道如何转换的函数是f,因为它需要符合 map 强制的(a -> b)类型签名。

由于headmap函数都不修改元素,我们可以推断先应用函数然后取第一个元素与先取第一个元素再应用函数是完全相同的。只是第二种方式更快:

// filter :: (a -> Bool) -> [a] -> [a] 
map(f, filter(compose(p, f), $x)) === filter(p, map(f, $x)) 

稍微复杂一些,这个自由定理说,如果你的谓词需要使用函数f转换一个值,然后你在结果上应用f函数,这与首先对所有元素应用f然后进行过滤完全相同。再次的想法是通过只应用一次f来优化性能,而不是两次。

当将函数组合在一起或在彼此之后调用它们时,尝试查看类型签名,看看是否可以通过推导一些自由定理来改进你的代码。

Haskell 用户甚至可以在www-ps.iai.uni-bonn.de/cgi-bin/free-theorems-webui.cgi上使用自由定理生成器。

结束语

类型签名为我们带来了很多东西,你可以找到基于它们的函数搜索引擎。像www.haskell.org/hoogle/scala-search.org/这样的网站允许你仅基于它们的类型签名搜索函数。

当使用函数式技术时,经常会出现这样的情况,你的数据有一定的结构,你需要将其转换为其他形式。由于大多数函数都是完全通用的,很难找到正确的关键字来搜索你要找的东西。这也是类型签名和Hoogle这样的搜索引擎派上用场的地方。只需输入你的输入的类型结构,想要的输出类型,然后浏览搜索引擎返回的函数列表。

PHP 是一种动态类型的语言,而且最近才引入了标量类型,显然还没有围绕类型签名的有用工具。但也许只是时间的问题,人们会想出一些东西。

无点风格

无点风格,也称为暗示编程,是一种编写函数的方式,其中你不明确定义参数或点,因此得名。根据语言的不同,可以在不同的层次上应用这种特定的风格。你如何有没有确定参数的函数?通过使用函数组合或柯里化。

事实上,在本书中我们之前已经进行了一些无点风格的编程。让我们使用第四章中的一个例子,组合函数,来说明它是关于什么的:

<?php 
// traditional 
function safe_title(string $s) 
{ 
    return strtoupper(htmlspecialchars($s)); 
} 

// point-free 
$safe_title = compose('htmlspecialchars', 'strtoupper'); 

第一部分是传统函数,其中你声明一个输入参数。然而,在第二种情况下,你看不到明确的参数声明;你依赖于组合函数的定义。第二个函数被称为无点风格。

PHP 语法要求我们将组合或柯里化的函数分配给一个变量,但在一些其他语言中并没有这样清晰的分离。以下是 Haskell 中的三个例子:

-- traditional 
sum (x:xs) = x + sum xs 
sum [] = 0 

-- using foldr 
sum xs = foldr (+) 0 xs 

-- point-free 
sum = foldr (+) 0 

正如我们所看到的,这三种情况下函数定义的结构是相同的。第一个是在不使用折叠的情况下定义sum方法的方式。第二个例子承认我们可以简单地对数据进行折叠,但仍然明确声明了参数。最后一个例子是无点风格的,因为没有任何参数的痕迹。

另一种语言中,函数和变量之间的区别比在 PHP 中更微妙的是 JavaScript。事实上,所有函数都是变量,由于没有变量的特殊语法,传统函数和分配给变量的匿名函数之间没有区别:

// traditional 
function snakeCase(word) { 
    return word.toLowerCase().replace(/\s+/ig, '_'); 
}; 

// point-free 
var snakeCase = compose(replace(/\s+/ig, '_'), toLowerCase); 

显然,这不是有效的 JavaScript,因为没有原生的compose函数,而且两个作用于字符串的函数不能这样简单地调用。然而,有多个库可以让你轻松编写这样的代码,比如Ramda,我强烈推荐。这个例子的重点只是为了证明你无法区分传统函数和 JavaScript 中的匿名函数,就像在 PHP 中一样。

使用这种风格有一些好处:

  • 通常你会有更简洁的代码,有些人认为这样更清晰,更容易阅读。

  • 它有助于以抽象的方式思考。JavaScript 示例中的参数名word暗示该函数只对单词起作用,而它实际上可以对任何字符串起作用。这对于更通用的函数尤其如此,比如那些在列表上工作的函数。

  • 它有助于开发人员以函数组合而不是数据结构的方式思考,这通常会导致更好的代码。

然而,也有一些可能的缺点:

  • 从定义中去掉显式参数可能会使事情更难理解;没有参数名,例如,有时会去掉有用的信息。

  • 长链的组合函数可能导致失去对数据结构和类型的视野。

  • 代码可能更难维护。当你有一个明确的函数时,你可以很容易地添加新的行,进行调试等。但是当函数组合在一起时,这几乎是不可能的。

有些反对者有时会使用术语无意义的风格来描述这种技术的结果。

阅读和使用无点代码肯定需要一些时间来适应。我个人对此没有强烈的意见。我建议你使用最适合你的风格,而且即使你更喜欢其中一种,也有一些情况下另一种可能更好,所以不要犹豫混合使用两种风格。

最后,我想提醒你,“参数顺序很重要!”就像我们在第四章中讨论的那样,组合函数。如果你想使用无点风格,这一点尤其重要。如果你需要处理的数据不是最后一个参数,你将无法使用。

使用 const 关键字来定义函数

这种技术与函数式编程无关,而是 PHP 本身的一个巧妙技巧。然而,它可能会帮助你很多,所以我们来试试。

如果你看过functional-php库的代码,你可能已经注意到几乎所有函数顶部都有常量的定义。这里有一个小例子:

<?php 
const push = 'Widmogrod\Functional\push'; 

function push(array $array, array $values) 
{ 
    // [...] 
} 

这背后的想法是允许更简单地使用函数作为参数。我们之前看到,你传递函数或方法作为参数的方式是使用一个叫做callable的东西,通常是一个字符串或一个由对象实例和要调用的方法的字符串组成的数组。

使用const关键字使我们更接近于在函数与变量不是分开构造的语言中找到的东西:

<?php const increment = 'increment'; 

function increment(int $i) { return $i + 1; } 

// using a 'callable' 
array_map('increment' [1, 2, 3, 4]); 

// using our const 
array_map(increment, [1, 2, 3, 4]); 

去掉我们函数名周围的尴尬引号。这看起来真的像你在传递函数本身,就像其他语言如 Python 或 JavaScript 中的情况一样。

如果你使用的是 IDE,情况会更好。你可以使用转到声明或等效的功能,定义const的文件将在定义它的行上打开。如果你将它声明在真正函数的顶部或底部,你将快速访问到它的定义。

一些我不知道的 IDE 可能会为callable提供相同的功能,但至少对我使用的这个来说并不是这样。然而,如果我在第二个例子中按下Ctrl +点击increment函数,它会聚焦在const声明上,这真的节省了很多时间。

当像这样声明常量时,你不仅限于影子函数;它也适用于静态对象方法。你还可以使用DocBlock注释来声明你的常量代表一个callable类型:

<?php 
class A { 
    public static function static_test() {} 
    public function test() {} 
} 

/** @var callable */ 
const A_static = ['A', 'static_test']; 

遗憾的是,这个技巧对存储在变量中的匿名函数或在对象实例上调用方法不起作用。如果你尝试这样做,PHP 会用一个响亮的Warning: Constants may only evaluate to scalar values or arrays警告来回报你。

尽管不是万能的,而且伴随着一些限制,这个小技巧将帮助你编写更清晰、更容易在 IDE 中导航的代码。

递归、堆栈溢出和跳板

我们首先在第三章中将递归作为解决编程问题的可能解决方案进行了介绍,PHP 中的函数基础。一些内存问题已经被暗示出来;现在是时候进一步调查了。

尾调用

在返回值之前执行的最后一个语句是称为尾调用的函数调用。让我们看一些例子来理解它的含义:

<?php 
function simple() { 
    return strtoupper('Hello!'); 
} 

毫无疑问,这是一个尾调用。函数的最后一个语句返回strtoupper函数的结果:

<?php 

function multiple_branches($name) { 
    if($name == 'Gilles') { 
        return strtoupper('Hi friend!'); 
    } 
    return strtoupper('Greetings'); 
} 

在这里,对strtoupper函数的两次调用都是尾调用。函数内的位置并不重要;重要的是在函数调用之后是否进行了任何操作。在我们的例子中,如果参数值是Gilles,函数将做的最后一件事是调用strtoupper函数,使其成为尾调用:

<?php 
function not_a_tail_call($name) { 
    return strtoupper('Hello') + ' ' + $name; 
} 

function also_not_a_tail_call($a) { 
    return 2 * max($a, 10); 
} 

这两个函数都没有尾调用。在这两种情况下,调用的返回值被用来计算最终值,然后函数返回。操作的顺序并不重要,解释器需要首先获取strtouppermax函数的值,以计算结果。

正如我们刚才看到的,发现尾调用并不总是容易的。你可以在一个非常长的函数的前几行中有一个尾调用,并且处于最后一行并不是一个充分的标准。

如果尾调用是对函数本身的调用,或者换句话说是递归调用,那么尾递归这个术语经常被使用。

尾调用消除

为什么要费心呢?也许你正在问自己?因为编译器和解析器可以执行一种称为尾调用消除或有时称为尾调用优化TCO)的东西。

程序不是执行一个新的函数调用并遭受所有相关的开销,而是简单地跳转到下一个函数,而不向堆栈添加更多信息并浪费宝贵的时间传递参数。

这在尾递归的情况下特别重要,因为它允许堆栈保持平坦,不使用比第一个函数调用更多的内存。

听起来很棒,但是像大多数高级编译技术一样,PHP 引擎并没有实现尾调用消除。然而,其他语言做到了:

  • 任何符合ECMAScript 6 标准的 JavaScript 引擎

  • 安装了 tco 模块后的 Python

  • 在 Scala 中,甚至有一个注解(@tailrec)可以触发编译器错误,如果你的方法不是尾递归的

  • Elixir

  • Lua

  • Perl

  • Haskell

Java 虚拟机JVM)级别也有正在进行的提案和工作来执行尾调用消除,但到目前为止在 Java 8 中还没有具体的实现,因为这不被认为是一个优先特性。

尾递归函数通常更容易处理,特别是在折叠方面;正如我们在本节中看到的,存在一些技术来缓解堆栈增长问题,但代价是一些处理能力。

从递归到尾递归

既然我们对我们正在谈论的内容有了更清晰的理解,让我们学习如何将递归函数转换为尾递归函数,如果还不是的话。

我们拒绝将计算阶乘作为第三章中好的递归示例,PHP 中的函数基础,但是因为它可能是最简单的递归函数,我们将从这个例子开始:

<?php 
function fact($n) 
{ 
    return $n <= 1 ? 1 : $n * fact($n - 1); 
} 

这个函数是尾递归的吗?不是,我们将一个值与我们递归调用fact方法的结果相乘。让我们更详细地看一下各个步骤:

fact(4) 
4 * fact(3) 
4 * 3 * fact(2) 
4 * 3 * 2 * fact(1) 
4 * 3 * 2 * 1 
-> 24 

你有什么想法可以将这个转换为尾递归函数吗?在继续阅读之前,花点时间玩一下一些想法。如果你需要提示,想一想用于折叠的函数是如何操作的。

当涉及到尾递归时,通常的答案是使用累加器:

<?php 
function fact2($n) 
{ 
    $fact = function($n, $acc) use (&$fact) { 
        return $n <= 1 ? $acc : $fact($n - 1, $n * $acc); 
    }; 

    return $fact($n, 1); 
} 

这里我们使用了一个内部辅助函数来隐藏累加器的实现细节,但我们也可以只使用一个唯一的函数来写它:

<?php 

function fact3($n, $acc = 1) 
{ 
    return $n <= 1 ? $acc : fact3($n - 1, $n * $acc); 
} 

让我们再次看一下这些步骤:

fact(4) 
fact(3, 4 * 1) 
fact(2, 3 * 4) 
fact(1, 2 * 12) -> 24 

太好了,在每个递归调用之后没有未决操作了;我们真的有一个尾递归函数。我们的fact函数非常简单。我们之前写的汉诺塔求解器呢?这里有它,这样你就不用再去找了:

<?php 
function hanoi(int $disc, string $source, string $destination,  string $via) 
{ 
    if ($disc === 1) { 
        echo("Move a disc from the $source rod to the $destination  rod\n"); 
    } else { 
        // step 1 : move all discs but the first to the "via" rod 
        hanoi($disc - 1, $source, $via, $destination); 
        // step 2 : move the last disc to the destination 
        hanoi(1, $source, $destination, $via); 
        // step 3 : move the discs from the "via" rod to the  destination 
        hanoi($disc - 1, $via, $destination, $source); 
    } 
} 

就像我们的阶乘计算一样,花点时间尝试将函数转换为尾递归函数:

<?php 
use Functional as f; 

class Position 
{ 
    public $disc; 
    public $src; 
    public $dst; 
    public $via; 

    public function __construct($n, $s, $d, $v) 
    { 
        $this->disc = $n; 
        $this->src = $s; 
        $this->dst = $d; 
        $this->via = $v; 
    } 
} 

function hanoi(Position $pos, array $moves = []) 
{ 
    if ($pos->disc === 1) { 
        echo("Move a disc from the {$pos->src} rod to the {$pos- >dst} rod\n"); 

        if(count($moves) > 0) { 
            hanoi(f\head($moves), f\tail($moves)); 
        } 
    } else { 
        $pos1 = new Position($pos->disc - 1, $pos->src, $pos->via,  $pos->dst); 
        $pos2 = new Position(1, $pos->src, $pos->dst, $pos->via); 
        $pos3 = new Position($pos->disc - 1, $pos->via, $pos->dst,  $pos->src); 

        hanoi($pos1, array_merge([$pos2, $pos3], $moves)); 
    } 
} 
hanoi(new Position(3, 'left', 'right', 'middle')); 

正如你所看到的,解决方案非常相似,即使在函数内部有多个递归调用时,我们仍然可以使用一个累加器。诀窍是使用一个数组而不是仅存储当前值。在大多数情况下,累加器将是一个堆栈,这意味着你只能在开头添加元素并从开头删除它们。堆栈被称为后进先出LIFO)结构。

如果你不太明白这个重构是如何工作的,我鼓励你写下两种变体的步骤,就像我们为fact方法所做的那样,这样你就可以更好地理解涉及的机制。

事实上,花时间写下递归算法的步骤通常是清楚地理解发生了什么以及如何重构为尾递归或修复错误的好方法。

堆栈溢出

我们为尾递归的汉诺塔求解器使用类似堆栈的数据结构并非巧合。当你调用函数时,所有需要的信息也会存储在内存中类似堆栈的结构中。在递归的情况下,它看起来会像这样:

堆栈溢出

这个堆栈有一个有限的大小。它通过memory_limit配置选项进行限制,即使你移除了限制,也无法超出系统中可用的内存。此外,诸如Xdebug之类的扩展引入了特定的机制,以避免有太多嵌套的递归调用。例如,你有一个名为xdebug.max_nesting_level的配置选项,默认值为 256,这意味着如果你递归调用一个函数超过这个值,就会引发错误。

如果 PHP 执行尾调用优化,而不是将函数信息的各个部分堆叠在一起,调用将会替换堆栈中的当前信息。这样做是安全的,因为尾调用的最终结果不依赖于函数局部变量。

由于 PHP 不执行这种优化,我们需要找到另一个解决方案来避免堆栈溢出。如果你遇到这个问题,并且愿意牺牲一些处理能力来限制内存使用,你可以使用trampolines

trampolines

我们避免堆栈增长的唯一方法是返回一个值而不是调用一个新函数。这个值可以保存执行新函数调用所需的信息,从而继续计算。这也意味着我们需要函数的调用者的一些合作。

这个有用的调用者就是 trampoline,这是它的工作原理:

  • trampoline 调用我们的函数f

  • f函数不再进行递归调用,而是返回封装在数据结构中的下一个调用和所有参数

  • trampoline 提取信息并对f函数进行新的调用

  • 重复最后两个步骤,直到f函数返回一个真实

  • trampoline 接收一个值并返回给真实的调用者

这些步骤也应该解释了这个技术的名称来源,每次函数返回到 trampoline 时,它都会以下一个参数弹回来。

为了执行这些步骤,我们需要一个数据结构,其中包含以callable形式调用的函数和参数。我们还需要一个辅助函数,它将继续调用数据结构中存储的任何内容,直到获得真实值:

<?php 
class Bounce 
{ 
    private $f; 
    private $args; 

    public function __construct(callable $f, ...$args) 
    { 
        $this->f = $f; 
        $this->args = $args; 
    } 

    public function __invoke() 
    { 
        return call_user_func_array($this->f, $this->args); 
    } 
} 

function trampoline(callable $f, ...$args) { 
    $return = call_user_func_array($f, $args); 

    while($return instanceof Bounce) { 
        $return = $return(); 
    } 
    return $return; 
} 

足够简单,让我们试试:

<?php 

function fact4($n, $acc = 1) 
{ 
    return $n <= 1 ? $acc : new Bounce('fact4', $n - 1, $n *  $acc); 
} 

echo trampoline('fact4', 5) 
// 120 

效果很好,代码也没有那么难读。然而,在使用蹦床时会有性能损失。在计算阶乘的情况下,蹦床版本在我的笔记本电脑上大约慢了五倍。这是因为解释器需要做的工作比简单调用下一个函数要多得多。

知道这一点,如果你的递归算法有一个有限的深度,并且你确信不会发生堆栈溢出,我建议你只执行传统的递归,而不是使用蹦床。然而,如果有疑问,不要犹豫,因为堆栈溢出错误可能对生产系统造成严重影响。

多步递归

蹦床甚至对执行不完全尾调用消除的语言也有用处。例如,当涉及两个函数时,Scala 无法执行这样的优化。不要试图解释我在说什么,让我们看一些代码:

<?php 

function even($n) { 
    return $n == 0 ? 'yes' : odd($n - 1); 
} 

function odd($n) { 
    return $n == 0 ? 'no' : even($n - 1); 
} 

echo even(10); 
// yes 

echo odd(10); 
// no 

这可能不是确定一个数字是奇数还是偶数的最佳最有效的方法,但它有简单说明我在谈论什么的优点。两个函数都在调用自己,直到数字达到 0 为止,这时我们可以决定它是奇数还是偶数。

根据你问的人,这可能是递归,也可能不是。它符合我们在第三章中给出的学术定义,PHP 中的函数基础

递归是将问题分解为相同问题的较小实例的想法。

然而,该函数并没有调用自身,所以这就是为什么有些人会尝试用其他术语来定义这里发生的事情。最终,我们称之为什么并不重要;在一个大数字上,我们将会遇到堆栈溢出。

正如我所说,Scala 执行不完全的尾调用消除,只有当函数调用自身作为最后一条指令时才会这样做,这会导致堆栈溢出错误,就像 PHP 会做的那样。这就是为什么即使在一些函数式语言中也会使用蹦床来解决堆栈溢出问题。

作为一个真正简单的练习,我邀请你使用蹦床来重写oddeven函数。

蹦床库

如果你想在自己的项目中使用蹦床,我邀请你使用composer命令安装以下库,因为它相对于我们的粗糙实现提供了一些帮助:

**composer require functional-php/trampoline**

数据结构和功能已经合并在同一个名为Trampoline的类中。助手以函数的形式可用:

  • bounce助手用于创建一个新的函数包装器。它接受一个callable和参数。

  • trampoline助手运行一个可调用对象直到完成。它接受一个callable和它的参数。该方法还接受Trampoline类实例作为参数,但在这种情况下,参数将被忽略,因为它们已经包装在实例中。

该类还定义了__callStatic,这允许我们直接在类上调用全局命名空间中的任何函数。

以下是从文档中摘取的一些示例:

<?php 

use FunctionalPHP\Trampoline as t; 
use FunctionalPHP\Trampoline\Trampoline; 

function factorial($n, $acc = 1) { 
    return $n <= 1 ? $acc : t\bounce('factorial', $n - 1, $n * $acc); 
}; 

echo t\trampoline('factorial', 5); 
// 120 

echo Trampoline::factorial(5); 
// 120 

echo Trampoline::strtoupper('Hello!'); 
// HELLO! 

还有另一个带有所有蹦床功能的可调用返回助手,它被称为trampoline_wrapper助手:

<?php 
$fact = t\trampoline_wrapper('factorial'); 

echo $fact(5); 
// 120 

作为练习,你可以尝试将我们的汉诺塔求解器转换为使用trampoline库,并看看是否得到相同的结果。

替代方法

除了使用蹦床来解决堆栈溢出问题之外,还可以使用队列来存储对我们函数的连续递归调用的所有参数。

原始函数需要包装在一个辅助函数中,该函数将保存队列并以链式调用所有参数调用原始函数。为了使其工作,递归调用需要在包装器而不是原始函数中进行:

  • 创建包装器函数

  • 使用第一个参数调用包装器的第一次调用

  • 当队列中有参数时,包装器进入一个循环,调用原始函数

  • 每次对包装器的后续调用都会将参数添加到队列中,而不是调用原始函数

  • 循环完成后(即所有递归调用都已完成),包装器返回最终值

为了使其工作,原始函数在进行递归调用时真的很重要。这可以通过在包装函数内部使用的匿名函数或使用Closure类的bindTo方法来完成,正如我们在第一章中讨论的那样,“PHP 中的一等公民函数”。

trampoline库使用后一种技术实现了这种方法。以下是您可以使用它而不是蹦床的方法:

<?php 

use FunctionalPHP\Trampoline as t; 

$fact = T\pool(function($n, $acc = 1) { 
    return $n <= 1 ? $acc : $this($n - 1, $n * $acc); 
}); 

echo $fact(5); 
// 120 

pool函数创建的包装器将Pool类的实例绑定到$this。该类具有一个__invoke方法,在我们的原始函数中可调用。这样做将再次调用包装器,但这次它将把参数添加到队列中,而不是调用原始函数。

从性能的角度来看,这种方法和蹦床之间没有区别,两者的性能应该大致相同。然而,使用pool函数时,不能进行多步递归,因为包装器只知道一个函数。

此外,直到 PHP 7.1 发布,由于一些困难将可调用的字符串形式转换为Closure类的实例以绑定类,此方法也仅限于匿名函数。PHP 7.1 将在Closure上引入一个新的fromCallable方法,允许解除此限制。

结束语

最后要注意的是,我们已经看到的蹦床和队列技术只有在递归函数是尾递归时才能解决堆栈溢出问题。这是一个强制条件,因为函数需要完全返回,以便辅助函数继续计算。

此外,由于蹦床方法的缺点较少,我建议使用它而不是pool函数实现。

模式匹配

模式匹配是大多数函数式语言中非常强大的功能。它嵌入在语言的各个层面。例如,在 Scala 中,您可以将其用作强化的switch语句,在 Haskell 中,它是函数定义的一个组成部分。

模式匹配是检查一系列标记与模式是否匹配的过程。它不同于模式识别,因为匹配需要精确。该过程不仅匹配,还分配值,就像 PHP 中的list构造一样,这个过程称为解构赋值

不要与正则表达式混淆。正则表达式只能操作字符串的内容,而模式匹配还可以操作数据的结构。例如,您可以匹配数组的元素数量。

让我们在 Haskell 中看一些例子,以便我们对其有所了解。最简单的模式匹配形式是匹配特定值:

fact :: (Integral a) => a -> a 
fact 0 = 1 
fact n = n * fact (n-1) 

这是您可以在 Haskell 中定义fact函数的方式:

  • 第一行是类型签名,应该让您想起本章前面看到的内容;Integral是一种比Integer类型不那么严格的类型,不详细介绍。

  • 第二行是如果参数的值为 0 的函数体。

  • 最后一行在所有其他情况下执行。该值分配给n变量。

如果您对某些值不感兴趣,可以使用_(下划线)通配符模式来忽略它们。例如,您可以轻松地定义函数来从元组中获取第一个、第二个和第三个值:

first :: (a, b, c) -> a 
first (x, _, _) = x 

second :: (a, b, c) -> b 
second (_, y, _) = y 

third :: (a, b, c) -> c 
third (_, _, z) = z 

注意

元组是一个具有固定数量元素的数据结构,与可以改变大小的数组相对。(1, 2)('a', 'b')元组的大小都是两个。在已知元素数量的情况下使用元组而不是数组的优势在于强制正确的大小和性能。

诸如 Haskell、Scala、Python、C#和 Ruby 之类的语言在其核心或标准库中都有元组类型。

您可以将值解构为不止一个变量。为了理解以下示例,您需要知道“:”(冒号)是将元素前置到列表的操作符。这意味着1:[2, 3]元组将返回列表[1, 2, 3]`:

head :: [a] -> a 
head [] = error "empty list" 
head (x:_) = x 

tail :: [a] -> [a] 
tail [] = error "empty list" 
tail (_:xs) = xs 

sum :: (Num a) => [a] -> a 
sum [] = 0 
sum (x:xs) = x + sum xs 

headtail变量具有相同的结构,如果列表为空,它们返回一个错误。否则,返回x,即列表开头的元素,或者xs,即列表的其余部分。sum变量也类似,但它同时使用xxs。顺便说一句,Haskell 将不允许定义这两个函数,因为它们已经存在:

firstThree :: [a] -> (a, a, a) 
firstThree (x:y:z:_) = (x, y, z) 
firstThree _ = error "need at least 3 elements" 

firstThree变量有点不同。它首先尝试匹配至少三个元素的列表,xyz。在这种情况下,_模式可以是空列表,也可以不是,模式将匹配。如果匹配不成功,我们知道列表少于三个元素,然后显示一个错误。

您还可以将模式匹配用作强化的开关语句。例如,这也是head的有效实现:

head :: [a] -> a 
head xs = case xs of []    -> error "empty lists" 
(x:_) -> x 

如果您想同时使用解构数据和整个值,可以使用as 模式

firstLetter :: String -> String 
firstLetter "" = error "empty string" 
firstLetter all@(x:_) = "The first letter of " ++ all ++ " is " ++ [x] 

最后,您还可以在进行模式匹配时使用构造函数。以下是使用Maybe类型的一个小例子:

increment :: Maybe Int -> Int 
increment Nothing = 0 
increment (Just x) = x + 1 

是的,您可以像这样轻松地获取 Monad 中的值,使用解构。

您可以有重叠的模式;Haskell 将使用第一个匹配的模式。如果它无法找到匹配的模式,它将引发一个错误,显示函数 XXX 中的非穷尽模式

我们可以大致展示 Scala、Clojure 或其他函数语言的相同类型的功能,但由于这只是一个了解模式匹配的示例,如果您对此话题感兴趣,我建议您阅读有关该主题的教程。相反,我们将尝试在 PHP 中模拟这一强大功能的一部分。

PHP 中的模式匹配

显然,我们永远无法像在 Haskell 中看到的那样声明函数,因为这需要在语言的核心实现。但是,一个库试图尽可能地模拟模式匹配,以创建一个更强大的开关语句版本,具有自动解构功能。

您可以使用 Composer 中的composer命令安装该库:

**composer require functional-php/pattern-matching**

为了尽可能地表达 Haskell 中的可用内容,它使用字符串来保存模式。以下是定义各种可能语法的表格:

名称 格式 示例
常量 任何标量值(整数、浮点数、字符串、布尔值) 1.042,"test"
变量 标识符 anameanything
数组 [<模式>, ..., <模式>] [][a][a, b, c]
Cons (标识符:列表标识符) (x:xs)(x:y:z:xs)
通配符 _ _
As 标识符@(<模式>) all@(x:xs)

在撰写本文时,尚不支持在 Monad 或其他类型内部自动解构值,也不支持约束我们匹配的特定项的类型的可能性。但是,关于这两个功能存在已打开的问题github.com/functional-php/pattern-matching/issues

由于在 PHP 中无法使用命名参数,参数将按照它们在模式中定义的顺序传递,并且不会根据它们的名称进行匹配。这使得有时使用该库会有点麻烦。

更好的 switch 语句

该库还可以用于执行更高级的switch语句,还可以使用结构并提取数据,而不仅仅是对值进行等价判断。由于函数是柯里化的,您还可以将它们映射到数组上,与switch语句相反。

<?php 

use FunctionalPHP\PatternMatching as m; 

$users = [ 
    [ 'name' => 'Gilles', 'status' => 10 ], 
    [ 'name' => 'John', 'status' => 5 ], 
    [ 'name' => 'Ben', 'status' => 0], 
    [], 
    'some random string' 
]; 

$statuses = array_map(m\match([ 
    '[_, 10]' => function() { return 'admin'; }, 
    '[_, 5]' => 'moderator', 
    '[_, _]' => 'normal user', 
    '_' => 'n/a', 
]), $users); 

print_r($statuses); 
// Array ( 
//    [0] => Gilles - admin 
//    [1] => John - moderator 
//    [2] => Ben - normal user 
//    [3] => incomplete array 
//    [4] => n/a 
// ) 

列表中匹配的第一个模式将被使用。如您所见,回调可以是一个函数,如第一个模式,也可以是一个将被返回的常量。显然,在这种情况下,它们都可以是常量,但这是为了举例。

传统的switch语句无法像您所看到的那样灵活,因为您不受数据结构的约束。在我们的例子中,我们为错误的数据创建了一个通用模式。使用switch语句,您需要过滤数据或执行某种其他数据规范化。

这个例子也可以使用解构来避免具有常量的三个模式(同时,我们还将使用数组中的名称)。

<?php 

$group_names = [ 10 => 'admin', 5 => 'moderator' ]; 

$statuses = array_map(m\match([ 
    '[name, s]' => function($name, $s) use($group_names) { 
        return $name. ' - '. (isset($group_names[$s]) ? $group_names[$s] : 'normal user'); 
    }, 
    '[]' => 'incomplete array', 
    '_' => 'n/a',]), $users); 
print_r($statuses); 
// Array ( 
//    [0] => admin 
//    [1] => moderator 
//    [2] => normal user 
//    [3] => incomplete array 
//    [4] => n/a 
// ) 

您还可以编写匹配各种不同结构的模式,并根据它确定要执行的操作。它还可以用于在 Web 应用程序内执行某种基本路由。

$url = 'user/10'; 

function homepage() { return "Hello!"; } 
function user($id) { return "user $id"; } 
function add_user_to_group($group, $user) { return "done."; } 

$result = m\match([ 
    '["user", id]' => 'user', 
    '["group", group, "add", user]' => 'add_user_to_group', 
    '_' => 'homepage', 
], explode('/', $url)); 

echo $result; 
// user 10 

显然,一个更专业的库会更好地执行路由并具有更好的性能,但牢记这种可能性会很方便,并且它展示了模式匹配的多功能性。

其他用途

如果您只对解构数据感兴趣,extract函数可以满足您的需求。

<?php 
$data = [ 
  'Gilles', 
  ['Some street', '12345', 'Some City'], 
  'xxx xx-xx-xx', 
  ['admin', 'staff'], 
  ['username' => 'gilles', 'password' => '******'], 
  [12, 34, 53, 65, 78, 95, 102] 
]; 

print_r(m\extract('[name, _, phone, groups, [username, _],  posts@(first:_)]', $data)); 
// Array ( 
//    [name] => Gilles 
//    [phone] => xxx xx-xx-xx 
//    [groups] => Array ( [0] => admin [1] => staff ) 
//    [username] => gilles 
//    [posts] => Array ( ... ) 
//    [first] => 12 
//) 

提取数据后,您可以使用 PHP 的extract函数将变量导入当前作用域。

如果您想创建类似我们在 Haskell 示例中看到的函数,可以使用func辅助方法。显然,语法不太好,但它可能会派上用场。

<?php 
$fact = m\func([ 
    '0' => 1, 
    'n' => function($n) use(&$fact) { 
        return $n * $fact($n - 1); 
    } 
]); 

请注意,函数创建仍处于测试阶段。存在一些问题,因此 API 可能会在将来更改。如果遇到任何问题,请参阅文档。

类型类

在阅读有关函数式编程的论文、帖子或教程时,您经常会遇到的另一个概念是类型类,特别是如果内容涉及 Haskell。

类型类的概念最初是在 Haskell 中引入的,作为一种实现可以轻松地为各种类型进行重载的操作符的方法。从那时起,人们发现了它们的许多其他用途。例如,在 Haskell 中,函子、应用函子和单子都是类型类。

Haskell 主要需要类型类,因为它不是面向对象的语言。例如,操作符的重载在 Scala 中是以不同的方式解决的。您可以在 Scala 中编写类型类的等效物,但这更像是一种模式而不是一种语言特性。在其他语言中,可以使用traitsinterfaces来模拟 Haskell 类型类的一些特性。

在 Haskell 中,类型类是一组需要在给定类型上实现的函数。最简单的例子之一是Eq类型类。

class Eq a where 
    (==) :: a -> a -> Bool 
    (/=) :: a -> a -> Bool 

任何实现Eq类的类型,都必须为==/=操作符实现相应的实现,否则将出现编译错误。这与类的接口非常相似,但适用于类型而不是类。这意味着您可以强制创建操作符,就像我们的情况一样,而不仅仅是方法。

您可以相当容易地为您的类型类创建实例;这是Maybe实例的一个例子。

instance (Eq m) => Eq (Maybe m) where 
    Just x == Just y = x == y 
    Nothing == Nothing = True 
    _ == _ = False 

这应该很容易理解。 左侧的两个Just值在其内部内容相等时是相等的,Nothing值等于自身,其他任何值都是不同的。 定义这些Eq实例允许您在 Haskell 代码中的任何地方检查两个单子的相等性。 实例定义本身只是强制要求存储在单子内的类型m变量也实现了Eq类型类。

正如您所看到的,类型类的函数并未在类型内部实现。 它是作为一个单独的实例来完成的。 这使您可以在代码的任何地方声明此实例。

甚至可以为任何给定类型拥有相同类型类的多个实例,并导入你需要的实例。 想象一下,例如,为整数有两个单子实例,一个是乘积,另一个是总和。 然而,这是不鼓励的,因为当导入两个实例时会导致冲突:

Prelude> Just 2 == Just 2 
True 
Prelude> Just 2 == Just 3 
False 
Prelude> Just 2 == Nothing 
False 

注意

Prelude>是 Haskell REPL 中的提示符,您可以在其中简单地运行 Haskell 代码,就像在 CLI 上运行 PHP 时使用-a参数一样。

我们可能认为类型类只是接口,或者更确切地说是特征,因为它们也可以包含实现。 但是,如果我们仔细看看我们的类型类及其单子实现,至少有三个缺点,任何我们在 PHP 中所做的事情,至少在合理的范围内,都会有。

为了证明这一点,让我们想象我们创建了一个名为Comparable的 PHP 接口:

<?php 
interface Comparable 
{ 
    /** 
     * @param Comparable $a the object to compare with 
     * @return int 
     *    0 if both object are equal 
     *    1 is $a is smaller 
     *    -1 otherwise 
     */ 
    public function compare(Comparable $a): int; 
} 

撇开 PHP 不允许像我们在 Haskell 中用==符号演示的那样进行运算符重载这一事实,试着想一想 Haskell 类型类允许的三个特性,这在 PHP 中几乎不可能模拟。

其中两个问题与强制正确类型有关。 Haskell 将自动为我们进行检查,但在 PHP 中,我们将不得不编写代码来检查我们的值是否是正确的类型。 第三个问题与可扩展性有关。 例如,考虑在外部库中声明的要比较的类。

第一个问题与compare函数期望与接口相同类型的值有关,这意味着如果您有两个不相关的类AB,都实现了Comparable接口,您可以比较类A的实例与类B的实例而不会出现 PHP 的任何错误。 显然这是错误的,这迫使您首先检查两个值是否是相同类型,然后再进行比较。

当您有一个类层次结构时,情况变得更加棘手,因为您真的不知道要测试什么类型。 然而,Haskell 将自动获取两个值共享的第一个公共类型,并使用相关的比较器。

第二个问题更加复杂。 如果我们在任何类型的容器上实现Comparable接口,我们将需要在比较运行时检查包含的值是否也是可比较的。 在 Haskell 中,类型签名(Eq m) => Eq (Maybe m)已经为我们处理了这个问题,如果您尝试比较包含不可比较值的两个单子,将自动引发错误。

Haskell 类型系统还强制要求单子内的值是相同类型,这与我们之前发现的第一个问题有关。

最后,第三个问题可能是关于在外部库或 PHP 核心的类上实现Comparable接口的可能性。 由于 Haskell 类型类实例位于类本身之外,您可以随时为任何类型添加一个实例,无论是现有类型类还是您刚刚创建的新类型类。

您可以创建适配器或包装类来包围这些对象,但然后您将不得不执行某种装箱/拆箱操作,以将正确的类型传递给使用对象的各种方法,这远非愉快的体验。

Scala 是一种面向对象的语言,没有对类型类提供核心支持,可扩展性问题通过使用语言特性隐式转换巧妙地解决了。广义上来说,这个想法是你定义了从类型A到类型B的转换,当编译器发现一个方法期望一个B类的实例,但你传递了一个A类的实例时,它会寻找这个转换并在可用时应用它。

这样,你可以创建我们之前提出的适配器或包装器,但是不需要手动执行转换,Scala 编译器会为你处理,使整个过程完全透明。

最后,由于我们谈到了比较对象和重新定义运算符,目前有两个关于 PHP 的 RFC 提出了关于这两个主题的讨论:

然而,目前还没有关于类型类或隐式类型转换的 RFC,就像我们在 Scala 中看到的那样。

代数结构和范畴论

到目前为止,我们一直避免谈论数学。本节将试图以轻松的方式这样做,因为大多数函数概念都源于数学,我们已经讨论过的许多抽象都是从范畴论领域借鉴的想法。本节的内容可能会帮助你更好地理解本书的内容,但并不需要完全理解它们才能使用函数式编程。

在本节中,遇到数学术语时将对其进行定义。重要的不是你理解所有的细微差别,而是给你一个大致的概念。

函数式编程的根源是λ演算,或λ-演算。它是数理逻辑中的一个图灵完备形式系统,用于表达计算。将 lambda 这个术语用于闭包和匿名函数是来自于这个。你写的任何代码都可以转换为λ演算。

注意

当一个语言或系统可以用来模拟图灵机时,就被称为图灵完备。一个同义词是计算通用。所有主要的编程语言都是图灵完备的,这意味着你可以在 C、Java、PHP、Haskell、JavaScript 等任何一种语言中编写的任何东西也可以在其他任何一种语言中编写。一个形式系统由以下元素组成:

  • 一组符号或关键词的有限集

  • 定义一个有效语法的语法

  • 一组公理,或者基本规则

  • 一组推理规则,用于从公理中推导出其他规则

代数结构是在其上定义了一个或多个操作的集合,并且有一系列公理或法则。我们在前几章中学习的所有抽象都是代数结构:幺半群、函子、应用函子和单子。这很重要,因为当一个新问题被证明遵循与现有集合相同的规则时,以前理论化的任何东西都可以被重用。

注意

集合是数学中的一个基本概念。它是一组不同对象的集合。你可以决定将任何东西组合在一起并称之为集合,例如,数字 5、23 和 42 可以形成一个集合{5, 23, 42}。集合可以明确地定义,就像我们刚刚做的那样,也可以使用规则来定义,例如,所有正整数。

范畴论是研究两个或多个数学结构之间关系的领域。它以对象和箭头或态射的集合来形式化它们。态射将一个对象或一个类别转换为另一个。一个范畴具有两个属性:

  • 能够组合态射以关联

  • 一个从一个对象或类别到自身的恒等态射

集合是满足这两个属性的多个可能类别之一。类别、对象和态射的概念可以非常抽象。例如,你可以将类型、函数、函子或幺半群视为类别。只要这两个属性得到遵守,它们可以是有限的或无限的,并且持有各种对象。

如果你对这个主题感兴趣,并想了解更多数学方面的知识,我可以推荐 Eugenia Cheng 写的书《蛋糕、卡斯塔和范畴论:理解复杂数学的简单配方》。这本书非常易懂,不需要先前的数学知识,而且读起来很有趣。

纯函数和引用透明的整个概念来自于λ演算。类型系统,特别是 Hindley-Milner,深深植根于形式逻辑和范畴论中。态射的概念与函数的概念非常接近,而组合处于范畴论的中心,导致了在过去几年对函数式语言的大多数进展在某种程度上与这个数学领域相关。

从数学到计算机科学

正如我们刚刚看到的,范畴论是函数式世界中的一个重要基石。你不必了解它来编写函数式代码,但它绝对有助于你理解基本概念并推理最抽象的东西。此外,关于函数式编程甚至库的论文中使用的许多术语都直接来自范畴论。因此,如果你对它有良好的直觉,通常会更容易理解你所阅读的内容。

类别实际上是一个非常简单的概念。它只是一些对象和它们之间的箭头。如果你能够使用这些想法以图形方式表示某些东西,那么它很可能是正确的,并且将极大地帮助你理解发生了什么。例如,如果我们决定表示函数组合,我们最终会得到类似这样的东西:

从数学到计算机科学

在图中,我们的三个形状是整个类别或给定类别中的对象并不重要。然而,立即清楚的是,如果我们有两个态射fg,如果我们按顺序应用它们或者应用组合版本,结果是相同的。函数可以在相同类型上工作,例如,我们从一个形状转换到另一个形状,或者三角形代表字符串,菱形代表字符,圆圈代表整数。这没有区别。

另一个例子是 applicative 的同态定律;pure(f)->apply($x) == pure(f($x))。如果我们将pure函数视为从一个类别到表示 applicative 可能对象的类别的态射,我们可以将这个定律可视化如下:

从数学到计算机科学

虚线箭头是pure函数,用于在 applicative 类别中移动xfy。当我们这样看时,这个定律显然是正确的。你觉得呢?顺便说一句,这两个图表都被称为可交换图表。在可交换图表中,每条具有相同起点和终点的路径在箭头之间使用组合时是等价的。

另外,你可以考虑每种类型都是一个类别,而态射是一个函数。你可以想象函数作为从一个类别中的对象到同一个类别或不同类别中的对象的态射。你可以通过更小的类别在更大的类别中表示类型和类别的层次结构。例如,整数和浮点数将是数值类别中的两个类别,并且它们在一些数字上重叠。

这可能不是最学术上正确的描述类型、函数和范畴论的方式,但这是一个容易理解的方式。它使得更容易将抽象概念(如函子或单子)与我们习惯的东西并行概念化。

你可以将更传统的函数可视化为对值本身的操作。例如,strtoupper函数是从string类别中的一个对象到同一类别中的另一个对象的态射,而count方法是从array类别中的一个对象到integer类别中的一个对象的态射。因此,这些都是从一个对象到另一个对象的箭头。

如果我们像在第二个图表中那样,从我们的基本类型中退后一步,我们可以想象函数作用于类型本身。例如,单子的pure函数接受某个类别,无论是类型还是函数,并将其提升到一个新的类别中,在这个类别中,所有对象现在都被包裹在单子的上下文中。

这个想法很有趣,因为你以前的任何箭头也可以被提升,并且将继续在它们的新上下文中产生相同的结果,正如我们刚刚可视化的同态法则所证明的那样。

这意味着,如果你在使用单子或任何抽象概念时遇到困难,只需在纸上使用类别、对象和箭头绘制操作,然后你可以通过删除或添加上下文将一切归结为其本质。

重要的数学术语

在阅读有关函数式编程时,你可能会遇到一些数学术语。它们被使用是因为它们让我们能够快速传达当前描述的结构是关于什么以及可以从中期望什么属性。

我们已经学习了一个这样的术语,那就是幺半群。你可以在数学定义中找到关于它的一个定义:幺半群是一个在关联二进制操作下封闭并具有单位元素的集合。在这一点上,你应该能够理解这个定义;然而,这里是一个关于字符串连接幺半群的快速概述:

  • 集合是所有可能的字符串值

  • 二进制操作是字符串连接运算符,.

  • 单位元素是空字符串,''

集合在操作下封闭的概念表明给定操作的结果也是集合的一部分。例如,对两个整数进行加法运算总是会得到一个整数。

以下是你可能在阅读中遇到的各种数学术语的快速词汇表。

  • 关联性:如果操作的顺序不重要,则操作是关联的;例如加法和乘法是 a + (b + c) === (a + b) + c

  • 交换性:如果你可以改变操作数的顺序,那么操作是可交换的。大多数关联操作也是可交换的,如 a + b === b + a`。一个不可交换的关联操作的例子是函数组合,如 f (g h) === (f g) h,但 f g != g f

  • 分配性:如果 a * (b + c) == (a * b) + (a * c),则两个操作是分配的。在这种情况下,乘法是“分配于”加法。在这个例子中,*+可以被任何二进制操作替换。

  • 半群:在一个关联操作下封闭的集合。

  • 幺半群:具有单位元素的半群。

  • :具有逆元素的幺半群。逆元是一个值,你可以将其添加到另一个元素中以获得身份元素。例如,10 + -10 = 0,-10是整数加法群中 10 的逆元。

  • 阿贝尔群:操作是可交换的群。

  • :具有第二个幺半群操作的阿贝尔群,该操作对第一个操作具有分配性。例如,整数是一个环,其中加法是第一个操作,乘法是第二个操作。

  • 半环:一个环,其中阿贝尔群被可交换的幺半群(即,逆元素不存在)所取代。

  • 余单子余函子余 XXX:单子、函子或任何东西的对偶范畴。对偶的另一个词是相反的。如果一个单子是将某物放入上下文的一种方式,那么它的余单子将是从中取出的一种方式。这是一个非常模糊的定义,没有解释用途,这需要一个章节来解释。

幻想乡

既然我们已经讨论了理论,我想向你介绍一个描述常见代数结构接口的 JavaScript 项目,名为Fantasy Land,网址为github.com/fantasyland/fantasy-land

它已经被社区广泛采用,每天有越来越多的项目实现了这个提议的接口,以便更好地在这些代数结构的各种实现之间进行互操作。在幻想乡命名空间下,你可以找到我们之前发现的各种单子的实现,以及许多其他更高级的函数构造。值得注意的是,还有Bilby库(bilby.brianmckenna.org/),它试图尽可能接近 Haskell 的哲学。

为什么我要谈论一个 JavaScript 库呢?因为php-functional库已经将幻想乡规范移植到了 PHPgithub.com/widmogrod/php-functional/tree/master/src/FantasyLand

我最希望其他项目以这些为基础,实现他们自己的函数代码,因为这将通过为开发人员提供更多可能使用的特性集,来增强 PHP 中的函数式编程。

在撰写本文时,有讨论要将幻想乡移植与库的其余部分分开,以便可以在不依赖其他一切的情况下使用。我希望在你阅读本文时,这项工作已经完成,我敦促你使用这套常见接口。

单子变换器

我们看到,如果你单独考虑每一个单子,它们已经是一个非常强大的想法。如果我告诉你,你可以将单子组合在一起,以便同时从它们的多个特性中受益,你会怎么想?例如,一个Maybe接口和一个Writer单子,可以告诉你为什么操作没有返回结果。

这正是单子变换器的意义所在。单子变换器在各个方面都类似于它所示范的单子,只是它不是一个独立的实体,而是修改另一个单子的行为。在某种程度上,我们可以想象这是在另一个单子的顶部添加一个新的单子层。当然,你可以将多个层堆叠在一起。

在 Haskell 中,大多数现有的单子都有对应的变换器。通常,它们的名称相同,只是后面加了一个TStateTReaderTWriterT。如果你要将一个变换器应用到恒等单子上,结果将具有与等效单子完全相同的特性,因为恒等单子只是一个简单的容器。

为了使其正确工作,StateReaderWriter和其他 Haskell 单子实际上是具有两个实例的类型类;一个是变换器,另一个是传统单子。

我们将在这里结束我们的探索,因为我所知道的 PHP 中没有这个概念的实现;尝试自己做这件事将是相当困难的,至少需要一个完整的章节。

至少你已经听说过这个想法,谁知道,也许将来会有人创建一个库,为 PHP 添加单子变换器。

镜头

当一切都是不可变的时,修改数据可能会变得非常麻烦,特别是如果你有一些复杂的数据结构。比如,假设你有一个用户列表,每个用户都有与他们关联的帖子列表,你需要在其中一个帖子上做一些改变。由于你不能直接改变任何东西,你需要复制或重新创建一切来修改你的值。

就像我说的,很繁琐。但与大多数事物一样,镜头有一个漂亮干净的解决方案。想象一下您的镜头就像双筒望远镜的一部分;它让您可以轻松地聚焦在数据结构的一部分上。然后,您可以轻松地修改它,并获得一个全新的闪亮数据结构,其中您的数据已更改为您想要的任何内容。

镜头是一种查找函数,它让您可以在数据结构的当前深度获取特定字段。您可以从镜头中读取,它将返回指定的值。您也可以向镜头写入,它将返回您修改后的整个数据结构。

镜头真正伟大的地方在于,由于它们是函数,您可以将它们组合在一起,因此如果您想深入到第一级之下,可以将第二个查找函数与第一个组合。您还可以在其上添加第三、第四等查找。在每一步,您都可以获取或设置值(如果需要)。

由于它们是函数,您可以像使用其他函数一样使用它们,可以映射它们,将它们放入应用函子,将它们绑定到单子。突然之间,一个非常繁琐的操作可以利用您的语言的所有功能。

遗憾的是,由于 PHP 中不常见不可变数据结构,没有人花时间为其编写镜头库。此外,这种可能性的细节有些混乱,需要相当长的时间来解释。这就是为什么我们现在将其留在这里的原因。

如果您感兴趣,Haskell 的lens库有一个网页,上面有大量信息和很好的介绍,尽管有一个真正具有挑战性的视频,网址是lens.github.io/

总结

在本章中,我们涵盖了许多不同的主题。其中一些概念在 PHP 开发中会很有用,比如在递归时避免堆栈溢出、模式匹配、无点风格以及使用const关键字使您的代码更易于阅读。

其他一些主题纯粹是理论性的,在 PHP 中目前没有使用,这些想法只是为了让您能够更好地理解有关函数式编程的其他写作,比如单子变换器、函数式编程与范畴论之间的联系以及镜头。

最后,有些主题在日常编码中有一定用处,但在 PHP 中实践起来有些困难,因为支持不足。即使不完美,您现在也了解了类型类、类型签名和代数结构。

我相信您并没有因为本章中主题的不断变化而感到太过困扰,并且学到了一些有价值和有趣的东西。我也希望这些内容激发了您对这些主题的进一步学习的兴趣,也许尝试一种函数式语言,看看所有这些暗示的好处是什么。

在下一章中,我们将回到一个更实际的话题,首先讨论测试功能代码,其次学习一种称为基于属性的测试的方法论,这并不严格属于函数式编程,但最初是在 Haskell 中理论化的。

第八章:测试

我们在整本书中已经多次断言纯函数更容易测试;现在是时候证明它了。在本章中,我们将首先介绍有关这个主题的小词汇表,以确保我们使用共同的语言。然后,我们将继续讨论功能性方法如何帮助传统测试。最后,我们将了解一种称为基于属性的测试的代码测试方法。

本章的主题并不严格限于函数式编程;您可以在任何传统代码库中使用任何内容。此外,这不是一本关于测试的书,所以我们不会详细介绍每个细节。还假定您对在 PHP 中测试代码有一些先验知识。

在本章中,我们将涵盖以下主题:

  • 小型测试词汇表

  • 测试纯函数

  • 测试并行化作为一种加速技术

  • 基于属性的测试

测试词汇表

我不会声称给你一个完整的所有与测试相关术语的词汇表,也不会解释每个术语的微妙差异和解释。这一部分的目的只是为了奠定一些共同的基础。

词汇表不会按字母顺序排列,而是根据类别分组。此外,绝对不能认为它是一个完整的词汇表。与测试相关的术语和技术远不止这里所呈现的内容,特别是如果包括所有与性能、安全性和可用性相关的测试方法:

  • 单元测试:针对每个单独的组件进行的测试。被视为单元的内容各不相同-可以是一个函数/方法、一个整个类、一个整个模块。通常会模拟对其他单元的依赖,以清晰地隔离每个部分。

  • 功能测试:以黑盒方式测试软件,以确保其符合规格。通常会模拟外部依赖。

  • 集成测试:针对整个应用程序及其依赖项(包括外部依赖项)进行的测试,以确保一切正确集成。

  • 验收测试:由最终客户/最终用户根据一组约定的标准进行的测试。

  • 回归测试:在进行某些更改后重复测试,以确保没有引入问题。

  • 模糊测试/ Fuzzing:通过输入大量(半)随机数据进行的测试,以使其崩溃。这有助于发现编码错误或安全问题。

  • 临时测试:在没有正式框架或计划的情况下进行的测试。

  • 组件测试:见单元测试

  • 黑盒测试:见功能测试

  • 行为测试:见功能测试

  • 用户验收测试UAT):见验收测试

  • Alpha 版本:通常是作为黑盒测试的第一个版本。它可能不稳定并导致数据丢失。

  • Beta 版本:通常是功能完整且足够好以发布给外部人员的第一个版本。它仍然可能存在严重问题,不应在生产环境中使用。

  • 发布候选版RC):被认为足够稳定以发布给公众进行最终测试的版本。通常最后一个 RC 会被“提升”为发布版本。

  • 模拟(mock):创建模拟软件或外部服务的组件,以仅测试手头的问题。

  • 存根stub):见模拟

  • 代码覆盖率:测试覆盖的应用程序代码或功能的百分比。可以有不同的粒度:按行、按函数、按组件等。

  • 仪器化:向应用程序添加代码以测试和监视行为或覆盖范围的过程。可以手动完成,也可以通过工具在源代码、编译形式或内存中完成。

  • 同行评审:一种同事检查所产出工作(如代码、文档或与发布相关的任何内容)的过程。

  • 静态分析:分析应用程序而无需运行它,通常由工具完成。它可以提供有关覆盖范围、复杂性、编码风格甚至发现问题的信息。

  • 静态测试:在不执行应用程序的情况下进行的所有测试和审查。参见同行审查静态分析

  • 冒烟测试:对应用程序的主要部分进行表面测试,以确保核心功能正常工作。

  • 技术审查:参见同行审查

  • 决策点:代码中的一个语句,控制流可以发生变化,通常是一个if条件。

  • 路径:从函数开始到结束执行的语句序列。根据其决策点,函数可以有多个路径。

  • 圈复杂度:代码复杂性的度量。有各种算法来计算它;其中一个是“决策点的数量+1”。

  • 缺陷、失败、问题或错误:在应用程序中未按预期工作的任何内容。

  • 假阳性:测试结果被视为缺陷,而实际上一切都正常。

  • 假阴性:测试结果被视为成功,而实际上存在缺陷。

  • 测试驱动开发(TDD):一种开发方法,您首先编写测试,然后编写最少量的代码使其通过,然后重复该过程。

  • 行为驱动开发(BDD):一种基于 TDD 的开发方法,其中您使用特定于领域的语言描述行为,而不是编写传统测试。

  • 类型驱动开发:在功能世界中的一个笑话,您可以使用强类型系统替换测试。取决于您问的人,这个想法可能会更或更不严肃。

  • 基于 X 的开发:每周都会出现一种新的最佳开发方法;网站devdriven.by/试图引用它们。

测试纯函数

正如我们在术语表中看到的那样,有很多潜在的测试应用程序的方法。在本节中,我们将限制自己只进行函数级别的测试;换句话说,我们将进行单元测试。

那么,纯函数为什么要容易得多呢?有多种原因;让我们从列举它们开始,然后我们将通过真实的测试用例来看为什么:

  • 模拟变得简单,因为您只需要提供输入参数。无需创建外部状态,也无需存根单例。

  • 对于给定的参数列表,重复调用将产生完全相同的结果,无论是白天还是之前运行的测试。无需将应用程序置于特定状态。

  • 函数式编程鼓励编写更小的函数,每个函数只做一件事。这通常意味着更容易编写和理解的测试用例。

  • 引用透明度通常意味着您需要更少的测试来获得对代码的相同信任水平。

  • 无副作用保证了您的测试不会对任何其他后续测试产生影响。这意味着您可以以任何您想要的顺序运行它们,而不必担心在每个测试之间重置状态或者独立运行它们。

这些声明中的一些可能对您来说似乎有点大胆,或者您可能不确定我为什么要这样做。让我们花点时间用例子来验证它们为什么是真的。我们将把我们的例子分成四个不同的部分,以便更容易跟踪。

所有输入都是显式的。

正如我们之前发现的,纯函数需要将其所有输入作为参数。您不能依赖于单例的某些静态方法,生成随机数,或者从外部来源获取任何可能发生变化的数据。

其推论是,您可以在一天中的任何时间,在任何环境中,对于任何给定的参数列表运行测试,输出将保持不变。这个简单的事实使得编写和阅读测试变得更容易。

想象一下,您需要测试以下函数:

<?php 

function greet() 
{ 
  $hour = (int) date('g'); 

  if ($hour >= 5 && $hour < 12) { 
    return "Good morning!"; 
  } elseif ($hour < 18) { 
    return "Good afternoon!"; 
  } elseif ($hour < 22) { 
    return "Good evening!"; 
  } 
  return "Good night!"; 
} 

问题在于,当你调用函数时,你需要知道现在是什么时间,这样你才能检查返回值是否正确。这一事实导致了一些问题:

  • 基本上,你必须在测试中重新实现函数逻辑,因此可能在测试和函数中都存在相同的错误。

  • 在你计算期望值并且函数再次返回结果之间,可能会有一分钟的时间流逝,改变当前的小时,从而改变函数的结果。这种假阳性的情况真的很头疼。

  • 在不以某种方式操纵系统时钟的情况下,你无法测试所有可能的输出。

  • 当前时间的依赖性被隐藏,阅读测试的人只能推断函数在做什么。

通过简单地将$hour变量作为参数传递,我们解决了之前提到的所有问题。

此外,如果你使用一个允许你为测试创建数据提供程序的测试运行器,比如PHPUnitatoum,测试函数就变得非常简单,只需要创建一个提供程序,生成与预期返回相关联的小时列表,然后将时间提供给函数并检查结果。这种测试比之前需要编写的任何其他内容都更简单、更易于理解和扩展。

引用透明性和无副作用

引用透明性确保你可以在代码的任何地方用计算结果替换函数调用(带有特定参数)。这对于测试也是一个有趣的特性,因为这基本上意味着你需要测试的内容更少,就能获得相同的信任。让我解释一下。

通常,在进行单元测试时,你会尽量选择最小的单元,以满足你对代码的信任。通常情况下,你会在模块、类或方法级别进行测试。显然,在进行函数式编程时,你会在函数级别进行测试。

你的函数显然会调用其他函数。在传统的测试设置中,你会尽量模拟尽可能多的函数,以确保你只测试当前单元的功能,而不会受到其他函数可能存在的错误的影响。

虽然在 PHP 中模拟函数并非不可能,但在我们的情况下有些麻烦。特别是对于像$title = compose('strip_tags', 'trim', 'capitalize');这样的组合函数,由于 PHP 中使用闭包实现组合的方式,这变得有些困难。

那么我们该怎么办呢?基本上什么都不做。单元测试的目标是对代码按预期方式的工作获得信心。在传统的命令式方法中,你会尽量模拟尽可能多的依赖项,原因如下:

  • 每个依赖项都可能依赖于你需要提供的某些状态,使你的工作更加困难。更糟糕的是,依赖项可能有自己的依赖项,也需要一些状态,依此类推。

  • 命令式代码可能会产生副作用,这可能导致你的函数或某些依赖项出现问题。这意味着,如果没有模拟,你不仅在测试你的函数,还在测试所有其他依赖项和它们之间的交互;换句话说,你在进行集成测试。

  • 控制结构引入决策点,这可能使对函数的推理变得复杂;这意味着,如果你将移动部件的数量减少到最低限度,你的函数就更容易测试。模拟其他函数调用可以减少这种复杂性。

在进行函数式编程时,第一个问题是无关紧要的,因为没有全局状态。你的依赖项所需的一切要么已经在被测试函数的参数中,要么将在途中计算。因此,模拟依赖项将使你做更多的工作,而不是更少。

由于我们的函数是纯函数且引用透明的,因此副作用不会对计算结果产生任何影响,这意味着即使我们有依赖关系,我们也不进行集成测试。当然,如果调用的函数中有错误,那么会导致错误,但希望它也会在另一个测试中被捕获,从而清楚地说明发生了什么。

关于复杂性,如果我们回到我们的组合函数,$title = compose('strip_tags', 'trim', 'capitalize');,我认为任何人都很容易理解发生了什么。如果所有三个函数都已经经过测试,那么即使我们在没有compose命令的情况下重新编写它,也不会出现太多问题。

<?php 

function title(string $string): string 
{ 
  $stripped = strip_tags($string); 
  $trimmed = trim($stripped); 
  return capitalize($trimmed); 
} 

这里没有太多需要测试的地方。显然,我们需要编写一些测试来确保我们将正确的临时值传递给每个函数,并且管道的工作符合预期,但是如果我们对所有三个被调用的函数都有信心,那么我们就可以非常有信心地认为这个函数也会工作。

这种推理是可能的,因为我们知道由于引用透明的属性,这三个函数中的任何一个都不会以一些微妙的方式影响其他任何一个,这意味着它们自己的单元测试给了我们足够的信任,即它们不会出错。

所有这些的结果通常是,您会为函数式代码编写更少的测试,因为您会更快地获得信任。但这并不意味着title函数不需要测试,因为您可能在某个地方犯了一个小错误。每个组件仍然应该被测试,但可能在正确隔离一切方面要小心一些。

显然,我们不是在谈论数据库访问,第三方 API 或服务;出于与任何测试套件相同的原因,这些都应该被模拟。

简化模拟

这可能已经很清楚了,但我真的想强调一点,您需要做的任何模拟都会大大简化。

首先,您只需要创建要测试的函数的输入参数。在某些情况下,这意味着创建一些相当大的数据结构或实例化复杂的类,但至少您不必模拟外部状态或注入到依赖项中的大量服务。

这可能在所有情况下都不是真的,但通常您的函数在较小的规模上运行,因为它们是更大东西的一小部分,这意味着任何一个函数只会接受一些非常精确和简洁的参数。

显然,会有例外,但不是很多,正如我们之前讨论的那样,由于构成整体的所有部分已经被测试。因此,您的信心程度应该比通常情况下更高。

构建模块

函数式编程鼓励创建小的构建模块,这些模块作为更大函数的一部分被重复使用。这些小函数通常只做一件事。这使它们更容易理解,也更容易测试。

函数的决策点越多,测试每个可能的执行路径就越困难。一个小的专门函数通常最多有两个这样的决策点,这使得它相当容易测试。

通常较大的函数不会执行任何控制流,它们只是以直接的方式由我们的较小模块组成。由于这意味着只有一条可能的执行路径,这也意味着它们很容易测试。

结束语

当然,我并不是说您不会遇到一些难以测试的纯函数。通常情况下,您编写测试时会遇到更少的麻烦,并且您也会更快地对代码产生信任。

随着行业越来越接近 TDD 等方法论,这意味着函数式编程确实非常适合现代应用。一旦你意识到,通过只使用函数式编程技术就已经强制执行了大部分关于编写“可测试代码”的建议,这一点尤其正确。

使用并行化加速

如果你曾经寻找加速测试套件的解决方案,很可能会找到关于测试并行化的内容。通常,PHPUnit 的用户会找到ParaTest实用工具,例如。

主要思想是同时运行多个 PHP 进程,以利用计算机的所有处理能力。这种方法主要有两个原因:

  • 单次测试运行存在瓶颈,比如源文件解析的磁盘速度或数据库访问。

  • 由于 PHP 是单线程的,像几乎所有现在的计算机一样,多核 CPU 在单次测试运行中没有得到充分利用。

通过并行运行多个测试,这两个问题都可以解决。然而,能够这样做的能力受到了一个限制,即每个测试套件都是独立的,这一特性在函数式代码库中已经通过引用透明性得到了强制执行。

这意味着,如果被测试的函数遵循函数式原则,你可以在不做任何调整的情况下并行运行所有的测试。在某些情况下,这可能会将整个测试套件所需的时间缩短十分之一,大大改善了你在开发过程中的反馈循环。

如果你使用 PHPUnit 实用工具,前面提到的 ParaTest 实用工具是最简单的入门方式之一。你可以在 GitHub 上找到它:github.com/brianium/paratest。我建议你使用-functional命令行参数,这样每个函数都可以同时进行测试,而不仅仅是测试用例。

PHPUnit 用户还有一个全新的实用工具叫做PHPChunkIt。我还没有机会测试它,但我听说它很有意思。你可以在 GitHub 上找到它:github.com/jwage/phpchunkit

另一个更灵活的选择是使用 Fastest,可以在github.com/liuggio/fastest找到。工具文档中显示的示例是针对 PHPUnit 的,但理论上它能够并行运行任何东西。

如果你使用的是 atoum 实用工具,那么默认情况下你的测试已经处于他们所谓的并发模式,这意味着它们是并行运行的。你可以根据执行引擎文档中的注释修改每个测试的行为:atoum-en.rtfd.org/en/latest/engine.html

behat框架的用户可以使用Parallel Runner扩展,也可以在 GitHub 上找到:github.com/shvetsgroup/ParallelRunner。如果你使用CodeCeption框架,要实现并行化可能有点困难;然而,文档(codeception.com/docs/12-ParallelExecution)中有多种可能的解决方案。

我强烈建议你研究一下并行化你的测试,因为这将是花费时间的好方法。即使你每次运行只能节省几秒钟,这种收益很快就会累积起来。更快的测试意味着你会更频繁地运行它们,这通常是改进代码质量的好方法。

基于属性的测试

约翰·休斯和科恩·克拉森厌倦了费时费力地编写测试用例,他们决定是时候改变一下了。15 年多前,他们写了一篇关于他们称之为QuickCheck的新工具的论文并发表了出来。

主要思想是,不是定义可能的输入值列表,然后断言结果是我们期望的,而是定义表征函数的属性列表。然后工具会自动生成所需的测试用例,并验证属性是否成立。

默认的操作模式是QuickCheck生成随机值并将其提供给您的函数。然后检查结果是否符合属性。如果检测到失败,工具将尝试将输入减少到生成问题的最小输入集。

拥有一个工具可以生成尽可能多的测试值是无价的,可以找到需要花费数小时才能想到的边缘情况。测试用例被减少到最小形式也很容易确定出了什么问题以及如何解决。偶然情况下,随机值并不总是测试某些东西的最佳方式。这就是为什么您还可以提供要使用的生成器。

此外,将测试视为一组需要保持真实的属性是一种更清晰地关注系统应该做什么而不是专注于查找测试值的好方法。这在进行 TDD 时尤其有帮助,因为您的测试将更像是规范。

如果你想了解更多关于这种方法的信息,原始论文可以在www.cs.tufts.edu/~nr/cs257/archive/john-hughes/quick.pdf上找到。作者在论文中使用 Haskell,但内容相当容易阅读和理解。

属性到底是什么?

属性是您的函数必须遵守的规则,以确定其正确性。它可以是非常简单的东西,比如函数添加两个整数的结果也需要是整数,也可以是更复杂的东西,比如验证单子定律。

通常,您希望创建的属性不是已经由其他属性或语言强制执行的。例如,如果我们使用 PHP 7 引入的标量类型系统,我们之前的整数示例就不需要了。

举个例子,我们将从论文中选取一些内容。假设我们刚刚编写了一个函数,用于反转数组中元素的顺序。作者建议这个函数应该具有以下属性:

  • reverse([x]) == [x] 属性,反转一个只有一个元素的数组应该产生完全相同的数组

  • reverse(reverse(x)) == x 属性,两次反转数组应该产生完全相同的数组

  • reverse(array_merge(x, y)) == array_merge(reverse(y), reverse(x)) 属性,反转两个合并的数组应该产生与将第二个数组反转后合并到第一个数组反转的结果相同

前两个属性将确保我们的函数不会干扰值。如果我们只有这两个属性,一个除了返回参数之外什么都不做的函数将会轻松通过测试。这就是第三个属性发挥作用的地方。它的写法确保我们的函数按我们期望的方式工作,因为没有其他方式属性会成立。

有趣的是这些属性在任何时候都不执行任何计算。它们很容易实现和理解,这意味着几乎不可能在其中引入错误。如果您通过某种方式重新实现它们正在进行的计算来测试您的函数,这将有点违背初衷。

尽管非常简单,这个例子完美地展示了找到有价值的属性既有意义又足够简单以确保它们不会有错误并不容易。如果您在找到好的属性方面有困难,我鼓励您以业务逻辑的角度来审视您的函数。不要以输入和输出为出发点,而是尝试看到更广阔的画面。

实现 add 函数

关于为什么基于属性的测试是一种有价值的工具的很好的解释可以在网上的幻灯片中找到www.slideshare.net/ScottWlaschin/an-introduction-to-property-based-testing。还有一个伴随的博客帖子,提供了更多信息fsharpforfunandprofit.com/posts/property-based-testing-2/。我将在这里快速总结它们。

要求开发人员编写一个添加两个值的函数,并进行一些测试。他编写了两个预期结果为 4 的测试;一切正常。要求函数的人要求进行更多的测试;它们失败的原因是函数总是返回值 4,而没有做任何有意义的事情。

开发人员重写函数,使测试再次通过,但新一轮的测试继续失败。实际上所做的是将结果合并到原始函数中的新测试中作为特殊情况。开发人员提出的借口是,他们遵循了 TDD 的最佳实践,即需要编写最小的代码来使测试通过

发生的事情可能对于这样一个简单的功能来说似乎很愚蠢,但是如果你用一些需要实现的复杂业务逻辑来替换它,这样的故事可能比你想象的更常见,也是 TDD 的一个缺点,正如其反对者所说的。如果你严格遵循 TDD,你的代码永远不会比你的测试更好。

幻灯片继续介绍了一些值为随机整数的测试,并通过将结果与x + y进行比较来测试函数。在这种情况下,开发人员无法使用函数中的特殊情况进行欺骗。然而,显然还有另一个问题,即在测试中重新实现了函数以验证结果。

进入基于属性的测试。首先实现的属性是add(x, y) == add(y, x)。开发人员将add属性实现为x * y,这样就能正确通过测试。

这意味着我们需要第二个属性,例如add(add(x, 1), 1) == add(x, 2)属性。这也可以通过实现x - y来实现,但在这种情况下,第一个测试将失败。这就是为什么开发人员的最新实现只是返回0

在这一点上,最后一个属性add(x, 0) == x被添加。开发人员最终被迫为我们的函数编写正确的实现,因为这一次他无法找到欺骗的方法。

如果我们回到我们的最后三个属性,并将它们与我们对数学中加法属性的了解进行比较,我们可以得出以下比较:

  • add(x, 0) == x属性中,0 是加法的单位元

  • add(x, y) == add(y, x)属性中,加法是交换的

  • add(add(x, 1), 1) == add(x, 2)属性中,加法是结合的

这三个属性实际上都是我们试图实现的操作的众所周知的属性。正如我们之前所说的,退一步反思“是什么”而不是“谁”,对于提出属性时是一个很好的帮助。

幻灯片的其余部分是一次很好且有趣的阅读,但是我不想剽窃整个内容,我更愿意鼓励你去网上阅读。我只会从中选取三条建议,因为我觉得它们真的很好,也很容易记住:

  • 不同的路径,同一个目的地:想出两种不同的方法来使用被测试的函数得到相同的结果,就像我们为reverse的第三个属性所做的那样。

  • 来回一趟:如果你的函数有一个反函数,尝试同时应用两者,看看是否能得到初始值,就像我们为reverse的第二个属性所做的那样。

  • 有些事情永远不会改变:如果您的输入的某些属性不会被函数改变,那么对它们进行测试,例如数组长度或数据类型。

有了这一切,现在您应该对如何为您的函数找到好的属性有了一个很好的想法。这仍然是一项困难的任务,但最终您可能会节省很多时间,因为您不必在找到它们时添加边缘情况。

如果您想要一个真实生活中由于基于属性的测试而被发现的错误的很好例子,约翰·休斯本人在vimeo.com/68383317上做了一个很棒的演讲,并举了一些很好的例子。

PhpQuickCheck 测试库

在我们已经看到了属性测试的理论方面之后,现在我们可以将注意力转向 PHP 特定的实现-PhpQuickCheck库。源代码可以在 GitHub 上找到github.com/steos/php-quickcheck,并且可以使用composer命令进行安装:

**composer require steos/php-quickcheck -stability dev**

您可能需要在您的composer.json文件中将minimum-stability设置为dev,或者根据 GitHub 页面上的说明手动添加依赖项,因为目前该库还没有稳定版本。

该项目始于 2014 年 9 月,大部分开发工作都在同年 11 月之前进行。自那时以来,没有添加太多新功能,主要是改进编码风格和一些小的改进。

虽然我们不能说该项目今天真的还很活跃,但它是 PHP 中第一个严肃尝试拥有QuickCheck库的项目之一,并且它具有一些功能,这些功能在其主要竞争对手中尚不可用,稍后将进行讨论。

但是,让我们不要急于行事;让我们回到我们的第一个例子,即反转函数。想象一下,我们编写了 PHP 中可用的array_reverse函数,并且我们需要对其进行测试。使用PhpQuickCheck库,它将如下所示:

<?php 

use QCheck\Generator; 
use QCheck\Quick; 

$singleElement = Quick::check(1000, Generator::forAll( 
    [Generator::ints()], 
    function($i) { 
        return array_reverse([$i]) == [$i]; 
    } 
), ['echo' => true]); 

$inverse = Quick::check(1000, Generator::forAll( 
    [Generator::ints()->intoArrays()], 
    function($array) { 
        return array_reverse(array_reverse($array)) == $array; 
    } 
), ['echo' => true]); 

$merge = Quick::check(1000, Generator::forAll( 
    [Generator::ints()->intoArrays(), Generator::ints()- >intoArrays()], 
    function($x, $y) { 
        return 
            array_reverse(array_merge($x, $y)) == 
            array_merge(array_reverse($y), array_reverse($x)); 
    } 
), ['echo' => true]); 

check静态方法接受需要生成的测试数据量作为第一个参数。第二个参数是Generator函数的实例;通常,您将使用Generator::forAll在示例中创建它。最后一部分是您可以传递的选项数组,包括随机生成器seed变量,生成的数据的max_size函数(此值的含义取决于所使用的生成器),或者最后的echo选项,它将显示一个点(.)表示每个通过的测试。

forAll实例接受一个表示测试参数和测试本身的数组。在我们的例子中,对于第一个测试,我们生成随机整数,对于另外两个测试,我们生成随机整数数组。测试必须返回一个布尔值:true表示通过,否则为false

如果您运行我们的小例子,它会显示每个生成的随机数据的一个点,因为我们传递了echo选项。结果变量包含有关测试结果本身的信息。在我们的情况下,如果您显示$merge,它将显示:

array(3) { 
  ["result"]=> bool(true) 
  ["num_tests"]=> int(1000) 
  ["seed"]=> int(1478161013564) 
} 

seed实例在每次运行时都会不同,除非您将其作为参数传递。重用seed实例允许您创建完全相同的测试数据。这对于检查特定边缘情况是否在被发现后被正确修复非常有用。

一个有趣的功能是根据类型注释自动确定要使用哪个生成器。您可以使用Annotation类上的方法来实现:

<?php 

/** 
 * @param string $s 
 * @return bool 
 */ 
function my_function($s) { 
    return is_string($s); 
} 

Annotation::check('my_function'); 

然而,这个功能目前只能与注释一起使用,类型提示将被忽略。

正如您在这些小例子中所看到的,PhpQuickCheck库在很大程度上依赖于静态函数。代码库本身有时也有点难以理解,而且该库缺乏良好的文档和活跃的社区。

总的来说,我认为我不会推荐使用这个选项,我们将在下面看到的选项可能更好。我只是想向您介绍这个库作为一个可能的替代方案,谁知道,它的状态可能会在未来发生变化。

Eris

Eris的开发始于 2014 年 11 月,大约是PhpQuickCheck库引入最后一个重大功能的时间。正如我们将看到的,编码风格明显更现代。一切都清晰地组织在命名空间中,辅助函数采用函数的形式而不是静态方法。

像往常一样,您可以使用composer命令获取 Eris:

**composer require giorgiosironi/eris**

文档可在线获取,网址为eris.rtfd.org/,并且非常完整。我对它唯一的抱怨是,唯一的示例是为使用 PHPUnit 运行其测试套件的人准备的。应该可以使用其他测试运行器,但目前尚未有文档记录。

如果我们想使用 Eris 来测试我们为array_reduce定义的属性,我们的测试用例将如下所示:

<?php 

use Eris\Generator; 

class ArrayReverseTest extends \PHPUnit_Framework_TestCase 
{ 
    use Eris\TestTrait; 

    public function testSingleElement() 
    { 
        $this->forAll(Generator\vector(1, Generator\nat())) 
             ->then(function ($x) { 
                 $this->assertEquals($x, array_reverse($x)); 
             }); 
    } 

    public function testInverse() 
    { 
      $this->forAll(Generator\seq(Generator\nat())) 
           ->then(function ($x) { 
               $this->assertEquals($x,  array_reverse(array_reverse($x))); 
           }); 
    } 

    public function testMerge() 
    { 
      $this->forAll( 
               Generator\seq(Generator\nat()), 
               Generator\seq(Generator\nat()) 
           ) 
           ->then(function ($x, $y) { 
               $this->assertEquals( 
                   array_reverse(array_merge($x, $y)), 
                   array_merge(array_reverse($y),  array_reverse($x)) 
               ); 
           }); 
    } 
} 

该代码与我们为PhpQuickCheck库编写的代码有些相似,但利用了由提供的 trait 添加到我们的测试用例和生成器函数中的方法,而不是静态方法。forAll方法接受表示测试函数参数的生成器列表。随后,您可以使用then关键字来定义函数。您可以访问 PHPUnit 提供的所有断言。

文档详细解释了您如何配置库的各个方面,例如生成的测试数据量,限制执行时间等。每个生成器也都有详细的说明,包括各种示例和用例。

让我们看看当我们有一个失败的测试用例时会发生什么。想象一下,我们想证明没有字符串也是一个数值;我们可以编写以下测试:

<?php 

class StringAreNotNumbersTest extends \PHPUnit_Framework_TestCase 
{ 
    use Eris\TestTrait; 

    public function testStrings() 
    { 
        $this->limitTo(1000) 
             ->forAll(Generator\string()) 
             ->then(function ($s) { 
        $this->assertFalse(is_numeric($s),"'$s' is a numeric  value.");}); 
    } 
} 

您可以看到我们使用limitTo函数将迭代次数从默认的 100 提高到 1,000。这是因为实际上有很多字符串并不是数值,如果不提高迭代次数,我只能得到三次测试中的一次失败。即使有了更高的限制,有时所有的测试数据仍可能通过测试而没有失败。

这是你会得到的输出类型:

PHPUnit 5.6.2 by Sebastian Bergmann and contributors. F 1 / 1 (100%) 
Reproduce with: 
ERIS_SEED=1478176692904359 vendor/bin/phpunit --filter  StringAreNotNumbersTest::testStrings 

Time: 42 ms, Memory: 4.00MB 

There was 1 failure: 

1) StringAreNotNumbersTest::testStrings 
'9' is a numeric value. Failed asserting that true is false. ./src/test.php:55 
./src/Quantifier/Evaluation.php:51 
./src/Quantifier/ForAll.php:154 
./src/Quantifier/ForAll.php:180 
./src/test.php:57 

FAILURES! Tests: 1, Assertions: 160, Failures: 1\. 

测试在 160 次迭代后失败,字符串为"9"。Eris 还会给出命令,如果您想通过手动设置随机生成器来精确重现此失败的测试:

**ERIS_SEED=1478176692904359 vendor/bin/phpunit -filter StringAreNotNumbersTest::testStrings".**

正如您所看到的,当您的测试是为 PHPUnit 编写时,该库非常易于使用。否则,您可能需要做一些调整,但我认为这值得您的时间。

结束语

QuickCheck库在严格类型的函数式编程语言中更容易使用,因为只需为某些类型声明生成器和一些函数的属性,几乎其他所有事情都可以自动完成。PhpQuickCheck库试图模拟这种行为,但结果有点麻烦。

然而,这并不意味着您不能有效地在 PHP 中使用基于属性的测试!一旦您创建了生成器,框架将使用它生成尽可能多的测试数据,可能会发现您从未想到的边缘情况。例如,在 PHP 中,DateTime方法的实现存在一个在闰年时出现的 bug,手动创建测试数据时很容易忽略。有关此问题的更多详细信息,请参阅 Eris 的创建者在www.giorgiosironi.com/2015/06/property-based-testing-primer.html中的测试语言部分。

编写属性可能具有挑战性,特别是在开始阶段。但往往它有助于您思考您正在实现的功能,并且可能会导致更好的代码,因为您花时间从不同的角度考虑它。

总结

在本章中,我们快速了解了在使用更功能化的编程方法时可以在测试方面做些什么。正如我们所看到的,功能化代码通常更容易测试,因为它强制执行了在进行命令式编码时被认为是最佳实践的测试。

通过没有副作用和明确的依赖关系,您可以避免在编写测试时通常遇到的大部分问题。这将导致测试时间减少,更多时间集中在应用程序上。

我们还发现了基于属性的测试,这是发现与边缘情况相关问题的好方法。它还允许我们退一步,思考您想要强制执行的函数属性,这类似于为它们创建规范。这种方法在进行 TDD 时特别有效,因为它迫使您思考您想要什么,而不是如何做。

现在我们已经讨论了测试以确保我们的函数执行应该做的事情,接下来我们将学习关于代码优化,以便在应用程序性能方面进行。经过充分测试的代码库将帮助您进行必要的重构,以实现更好的速度。

第九章:性能效率

现在我们已经涵盖了与函数式编程相关的各种技术,是时候分析它如何影响像 PHP 这样的语言的性能了,尽管每个版本都引入了越来越多的函数式特性,但 PHP 仍然在其核心是命令式的。

我们还将讨论为什么性能最终并不那么重要,以及我们如何利用记忆化和其他技术来在某些情况下缓解这个问题。

我们还将探讨两种由引用透明性启用的优化技术。第一种是记忆化,这是一种缓存类型。我们还将谈论如何在 PHP 中并行运行长时间的计算,以及您如何利用这一点。

在本章中,我们将涵盖以下主题:

  • 函数式编程的性能影响

  • 记忆化

  • 计算的并行化

性能影响

由于没有核心支持柯里化和函数组合等功能,它们需要使用匿名包装函数来模拟。显然,这会带来性能成本。此外,正如我们在关于尾调用递归的部分已经讨论过的那样,在 第七章 函数式技术和主题 中,使用跳板也更慢。但与更传统的方法相比,你会损失多少执行时间呢?

让我们创建一些函数作为基准,并测试我们可以实现的各种速度。该函数将执行一个非常简单的任务,即将两个数字相加,以确保我们尽可能有效地测量开销:

<?php 

use Functional as f; 

function add($a, $b) 
{ 
    return $a + $b; 
} 

function manualCurryAdd($a, $b = null) { 
    $func = function($b) use($a) { 
        return $a + $b; 
    }; 

    return func_num_args() > 1 ? $func($b) : $func; 
} 

$curryiedAdd = f\curry('add'); 

function add2($b) 
{ 
    return $b + 2; 
} 

function add4($b) 
{ 
    return $b + 4; 
} 

$composedAdd4 = f\compose('add2', 'add2'); 

$composerCurryedAdd = f\compose($curryiedAdd(2), $curryiedAdd(2)); 

我们创建了第一个函数 add 并对其进行了柯里化;这将是我们的第一个基准。然后我们将比较一个专门添加 4 到一个值的函数与两种不同的组合。第一个是两个专门函数的组合,第二个是两个 add 函数的柯里化版本的组合。

我们将使用以下代码来对我们的函数进行基准测试。它非常基础,但应该足以展示任何有意义的差异:

<?php 

use Oefenweb\Statistics\Statistics; 

function benchmark($function, $params, $expected) 
{ 
    $iteration   = 10; 
    $computation = 2000000; 

    $times = array_map(function() use($computation, $function,  $params, $expected) { 
        $start = microtime(true); 

        array_reduce(range(0, $computation), function($expected)  use ($function, $params) { 
            if(($res = call_user_func_array($function, $params))  !== $expected) { 
                throw new RuntimeException("Faulty computation"); 
            } 

            return $expected; 
        }, $expected); 

        return microtime(true) - $start; 
    }, range(0, $iteration)); 

    echo sprintf("mean: %02.3f seconds\n",  Statistics::mean($times)); 
    echo sprintf("std:  %02.3f seconds\n",  Statistics::standardDeviation($times)); } 

统计方法来自于可通过 composer 获取的 oefenweb/statistics 包。我们还检查返回的值是否符合预期,作为额外的预防措施。我们将连续运行每个函数 200 万次,每次运行 10 次,并显示 200 万次运行的平均时间。

让我们先运行柯里化的基准测试。显示的结果是针对 PHP 7.0.12。当尝试在 PHP 5.6 中运行时,所有基准测试都会变慢,但它们在各种函数之间表现出相同的差异:

<?php 

benchmark('add', [21, 33], 54); 
// mean: 0.447 seconds 
// std:  0.015 seconds 

benchmark('manualCurryAdd', [21, 33], 54); 
// mean: 1.210 seconds 
// std:  0.016 seconds 

benchmark($curryiedAdd, [21, 33], 54); 
// mean: 1.476 seconds 
// std:  0.007 seconds 

显然,结果将根据测试运行的系统而有所不同,但相对差异应该保持大致相同。

首先,如果我们看标准偏差,我们可以看到 10 次运行大多数情况下花费了相同的时间,这表明我们可以相信我们的数字是性能的良好指标。

我们可以看到柯里化版本明显更慢。手动柯里化效率稍微更高,但两个柯里化版本大部分情况下比简单函数版本慢三倍。在得出结论之前,让我们看看组合函数的结果:

<?php 

benchmark('add4', [10], 14); 
// mean: 0.434 seconds 
// std:  0.001 seconds 

benchmark($composedAdd4, [10], 14); 
// mean: 1.362 seconds 
// std:  0.005 seconds 

benchmark($composerCurryedAdd, [10], 14); 
// mean: 3.555 seconds 
// std:  0.018 seconds 

标准偏差足够小,以至于我们可以认为这些数字是有效的。

关于值本身,我们可以看到组合也大约慢了三倍,而柯里化函数的组合,毫不奇怪,慢了九倍。

现在,如果我们将最坏情况的 3.55 秒与最佳情况的 0.434 秒进行比较,这意味着在使用组合和柯里化时我们有 3 秒的开销。这重要吗?看起来失去了很多时间吗?让我们试着在一个 web 应用程序的背景下想象这些数字。

开销重要吗?

我们对我们的方法进行了两百万次执行,用了三秒钟。我最近参与的一个项目是一个奢侈品牌的电子商务应用程序,在 26 个国家和 10 多种语言中可用,完全是从零开始编写的,没有使用任何框架,渲染一个页面需要大约 25,000 次函数调用。

即使我们承认所有这些调用都是事先柯里化的组合函数,这意味着在最坏的情况下,开销现在大约为 40 毫秒。该应用程序大约需要 180 毫秒来显示一个页面,因此我们在性能上会有 20-25%的降低。

这仍然很多,但远不及我们之前看到的三倍慢的数字。与每个函数调用相关的函数式技术的开销将随着每个函数调用的增加而线性增长。在基准测试中看起来很好,因为执行的计算是微不足道的。在现实应用中,你会有外部瓶颈,比如数据库、第三方 API 或文件系统。你还有执行复杂计算的函数,需要比简单的加法更多的时间。在这种情况下,引入的开销将成为应用程序总执行时间的一个较小部分。

这也是一个最坏的情况,我们假设一切都是组合和柯里化的。在现实世界的应用程序中,你可能会使用传统的框架,其中包含没有开销的函数和方法。你还可以识别代码中的热点路径,并手动优化它们,使用显式的柯里化和组合而不是辅助函数。也不需要对所有东西都进行柯里化;你将有只有一个参数的函数不需要它,还有一些函数使用柯里化是没有意义的。

这些数字是考虑到缓存不热的应用程序。你已经采取的任何减少页面渲染时间的机制将继续发挥作用。例如,如果你有一个 Varnish 实例在运行,你的页面可能会以相同的速度提供。

不要忘记

我们将一个非常小的函数与组合和柯里化进行了比较。现代 PHP 代码库将使用类来保存业务逻辑和值。让我们使用以下add函数的实现来模拟这一点:

<?php 

class Integer { 
    private $value; 
    public function __construct($v) { $this->value = $v; } 
    public function get() { return $this->value; } 
} 

class Adder { 
    public function add(Integer $a, Integer $b) { 
        return $a->get() + $b->get(); 
    } 
} 

传统方法所需的时间会增加:

<?php 

benchmark([new Adder, 'add'], [new Integer(21), new Integer(33)], 54); 
// mean: 0.767 seconds 
// std:  0.019 seconds 

只需将所有东西封装在一个类中并使用 getter,执行时间几乎翻了一番,这意味着在基准测试中,函数式方法只比传统方法慢 1.5 倍,而我们示例应用程序中的开销现在已经是 10-15%,这已经好多了。

我们能做些什么吗?

遗憾的是,我们实际上没有什么可以做的。我们可以通过更有效地实现currycompose方法来节省一点时间,就像我们使用手动柯里化版本的add方法一样,但这不会带来太大的影响。

然而,将这两种技术作为 PHP 的核心部分实现,将带来很多好处,可能使它们与传统函数和方法持平,或者非常接近。但据我所知,目前没有计划这样做。

可能还可以创建一个 C 语言扩展程序,以更有效的方式实现这两个函数在 PHP 中的应用。然而,这将是不切实际的,因为大多数 PHP 托管公司不允许用户安装自定义扩展程序。

结束语

正如我们刚才看到的,使用柯里化和函数组合等技术对性能有一定影响,而这种影响很难自行减轻。在我看来,收益大于成本,但重要的是要有意识地转向函数式编程。

现在大多数 Web 应用程序都在 PHP 应用程序前面有某种缓存机制。因此,唯一的成本将是在填充此缓存时。如果你处于这种情况,我认为没有理由避免使用我们学到的技术。

记忆化

记忆化是一种优化技术,它会存储昂贵函数的结果,以便在任何后续具有相同参数的调用中直接返回。这是数据缓存的一个特例。

尽管它可以用于非纯函数,并具有与任何其他缓存机制相同的失效问题,但它主要用于所有函数都是纯函数的函数式语言,因此极大地简化了它的使用。

这个想法是用存储空间来换取计算时间。第一次使用给定的输入调用函数时,结果会被存储,下一次使用相同参数调用相同函数时,已经计算出的结果可以立即返回。在 PHP 中可以很容易地使用static关键字来实现这一点:

<?php 

function long_computation($n) 
{ 
    static $cache = []; 
    $key = md5(serialize($n)); 

    if(! isset($cache[$key])) { 
        // your computation comes here, the rest is boilerplate 
        sleep(2); 
        $cache[$key] = $n; 
    } 

    return $cache[$key]; 
} 

显然有很多种不同的方法来做类似的事情,但这种方法足够简单,可以让你了解它是如何工作的。人们也可以想象实现一种过期机制,或者,由于我们使用内存空间而不是计算时间,一种数据结构,在这种数据结构中,值在不使用时被擦除以为新的结果腾出空间。

另一个选择是将信息存储到磁盘上,以便在同一脚本的多次运行之间保留值,例如。PHP 中至少存在一个库(github.com/koktut/php-memoize)就是这样做的。

然而,该库不能很好地处理递归调用,因为函数本身没有被修改,因此值只会保存第一次调用,而不是递归调用。库自述文件中链接的文章(eddmann.com/posts/implementing-and-using-memoization-in-php/)更详细地讨论了这个问题并提出了解决方案。

有趣的是Hack有一个属性,可以自动记忆具有特定类型参数的函数的结果(docs.hhvm.com/hack/attributes/special#__memoize)。如果您正在使用 Hack 并希望使用注释,我建议您首先阅读Gotchas部分,因为它可能并不总是按照您的意愿进行操作。

注意

Hack 是一种在 PHP 基础上添加新功能并在 Facebook 编写的 PHP 虚拟机上运行的语言-HipHop 虚拟机HHVM)。任何 PHP 代码都与 Hack 兼容,但 Hack 添加了一些新的语法,使代码与原始的 PHP 解释器不兼容。有关更多信息,您可以访问hacklang.org/。

Haskell、Scala 和记忆化

Haskell 和 Scala 都不会自动执行记忆化。这两者都没有核心功能来执行记忆化,尽管你可以找到多个提供这一功能的库。

有一种误解认为 Haskell 默认情况下会对所有函数进行记忆化,这是因为这种语言是惰性的。实际上,Haskell 试图尽可能延迟函数调用的计算,并且一旦这样做了,它就使用了引用透明属性来用计算出的值替换其他类似的调用。

然而,有多种情况下,这种替换不能自动发生,除了重新计算值之外别无选择。如果您对这个话题感兴趣,这个Stack Overflow问题是一个很好的起点,其中包含所有正确的关键字stackoverflow.com/questions/3951012/when-is-memoization-automatic-in-ghc-haskell

我们将在这里结束讨论,因为这本书是关于 PHP 的。

结束语

这只是一个对记忆化的快速介绍,因为这种技术相当简单实现,实际上并没有太多可以说的。我只是想介绍一下,让你了解这个术语。

如果你有一些长时间运行的计算,会多次使用相同的参数,我建议你使用这种技术,因为它可以真正加快速度,并且不需要调用者做任何事情。使用起来非常透明。

但要注意,这并不是万能的。根据返回值的数据结构,它可能会很快地消耗内存。如果遇到这个问题,你可以使用一些机制来清理缓存中的旧值或者少用的值。

计算的并行化

拥有纯函数的另一个好处是,你可以将计算分成多个小部分,分发工作负载,并组装结果。对于任何映射、过滤和折叠操作都可以这样做。我们将看到,用于折叠的函数需要是单子的。用于映射和过滤的函数除了纯度之外没有特定的约束。

映射除了纯函数之外没有特定的约束。假设你有四个核心或计算机,你只需要按照以下步骤进行:

  1. 将数组分成四部分。

  2. 将部分任务发送到每个核心进行映射。

  3. 合并结果。

在这种特殊情况下,它可能比在单个核心上执行要慢,因为合并操作会增加额外的开销。然而,一旦计算时间变长,你就能够利用更多的计算能力,从而节省时间。

过滤操作与映射完全相同,只是你发送的是一个谓词而不是一个函数。

只有当你拥有一个单子操作时,折叠操作才能发生,因为每个拆分都需要从空值开始,否则可能会使结果产生偏差:

  1. 将数组分成四部分。

  2. 将部分任务发送到每个核心进行折叠,初始值为空值。

  3. 将所有结果放入一个新数组中。

  4. 在新数组上执行相同的折叠操作。

如果你的集合非常大,你可以将最终的折叠再次分成多个部分。

PHP 中的并行任务

PHP 是在计算机只有一个核心时创建的,自那时起,使用它的传统方式是为每个请求提供一个单独的线程。你可以在 web 服务器中声明多个工作进程来处理不同的请求,但一个请求通常只会使用一个线程,因此只会使用一个核心。

尽管 PHP 的线程安全版本存在,但由于前述原因,Linux 发行版通常会提供非线程安全版本。这并不意味着在 PHP 中无法并行执行任务,但这确实会使任务变得更加困难。

pthreads 扩展

PHP 7 发布了一个新版本的pthreads扩展,它允许你使用新设计的面向对象 API 并行运行多个任务。这真的很棒,甚至还有一个polyfill,如果扩展不可用,可以按顺序执行任务。

注意

术语polyfill起源于 JavaScript 开发。它是一小段代码,用于替换用户浏览器中未实现的功能。有时也会使用另一个术语shim。在我们的情况下,pthreads-polyfill提供了一个与扩展 API 在所有点上相似的 API,但是它是按顺序运行任务的。

遗憾的是,使用这个扩展有点挑战。首先,你需要一个线程安全的 PHP 二进制文件,也称为ZTS二进制文件,即Zend Thread-safe。正如我们刚才看到的,发行版通常不提供这个版本。据我所知,目前没有官方 PHP 软件包支持 ZTS。当你尝试为你的 Linux 发行版创建自己的 ZTS 二进制文件时,通常可以在谷歌上找到相关的指导。

Windows 和 Mac OS 用户则更加方便,因为你可以在www.php.net上下载 ZTS 二进制文件,并且在使用homebrew软件包管理器安装 PHP 时可以启用该选项。

另一个限制是该扩展将拒绝在 CGI 环境中加载。这意味着您只能在命令行上使用它。如果您对 pthreads 扩展的维护者为什么选择设置这个限制感兴趣,我建议您阅读他写的这篇博客文章,网址为blog.krakjoe.ninja/2015/09/the-worth-of-advice.html

现在,如果我们假设您能够拥有 PHP 的 ZTS 版本,并且只编写 CLI 应用程序,让我们看看如何使用pthreads扩展执行并行折叠。该扩展程序托管在 GitHub 上,网址为github.com/krakjoe/pthreads,安装说明可以在官方 PHP 文档中找到,网址为docs.php.net/manual/en/book.pthreads.php

显然,我们可以以多种方式实现使用线程进行折叠。我们将尝试采用一种通用的方法。在某些情况下,更专门的版本可能更快,但这应该已经涵盖了整个一系列用例:

<?php 

class Folder extends Thread { 
    private $collection; 
    private $callable; 
    private $initial; 

    private $results; 

    private function __construct($callable, $collection, $initial) 
    { 
        $this->callable = $callable; 
        $this->collection = $collection; 
        $this->initial = $initial; 
    } 

    public function run() 
    { 
        $this->results = array_reduce($this->collection, $this- >callable, $this->initial); 
    } 

    public static function fold($callable, array $collection,  $initial, $threads=4) 
    { 
        $chunks = array_chunk($collection, ceil(count($collection) / $threads)); 

        $threads = array_map(function($i) use ($chunks, $callable,  $initial) { 
            $t = new static($callable, $chunks[$i], $initial); 
            $t->start(); 
            return $t; 
        }, range(0, $threads - 1)); 

        $results = array_map(function(Thread $t) { 
            $t->join(); 
            return $t->results; 
        }, $threads); 

        return array_reduce($results, $callable, $initial); 
    } 
} 

实现非常简单;我们有一个简单的Thread执行每个块的减少,然后使用简单的array_reduce函数将它们组合在一起。我们本可以选择使用Pool实例来管理各个线程,但在这种简单情况下,这将使实现变得复杂。

另一种可能性是递归,直到生成的数组包含最多$threads个元素为止;这样,我们将利用我们手头的全部计算能力直到结束。但同样,这将使实现变得复杂。

您如何使用它?只需调用静态方法:

<?php 

$add = function($a, $b) { 
    return $a + $b; 
}; 

$collection = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; 

echo Folder::fold($add, $collection, 0); 
// 55 

如果您想尝试一下这个想法,一个小型库以并行方式实现了所有三个高阶函数(github.com/functional-php/parallel)。您可以使用 composer 安装它:

**composer require functional-php/parallel**

消息队列

PHP 中另一个并行化任务的选项是使用消息队列。消息队列提供了一种异步通信协议。您将拥有一个服务器,它将保存消息,直到一个或多个客户端检索它们。

我们可以通过让我们的应用程序向服务器发送 X 条消息来实现并行计算,每个分布式任务发送一条消息。然后,一定数量的工作线程将检索消息并执行计算,将结果作为新消息发送回应用程序。

有很多不同的消息队列实现可以使用。通常,队列本身并不是用 PHP 实现的,但它们大多数都有客户端实现可以使用。我们将使用RabbitMQphp-amqplib客户端。

解释如何安装服务器超出了本书的范围,但您可以在互联网上找到很多教程。我们也不会解释所有关于实现的细节,只解释与我们主题相关的内容。您可以使用 composer 安装 PHP 库:

**composer require php-amqplib/php-amqplib**

我们需要为我们的工作线程和应用程序都实现。让我们首先创建一个包含公共部分的文件,我们将其命名为09-rabbitmq.php

<?php 

require_once './vendor/autoload.php'; 
use PhpAmqpLib\Connection\AMQPStreamConnection; 

$connection = new AMQPStreamConnection('localhost', 5672, 'guest',  'guest'); 
$channel = $connection->channel(); 
list($queue, ,) = $channel->queue_declare($queue_name, false,  false, false, false); 

$fold_function = function($a, $b) { 
    return $a + $b; 
}; 

现在我们创建工作线程:

<?php 
use PhpAmqpLib\Message\AMQPMessage; 

$queue_name = 'fold_queue'; 
require_once('09-rabbitmq.php'); 

function callback($r) { 
    global $fold_function; 

    $data = unserialize($r->body); 

    $result = array_reduce($data['collection'], $fold_function,  $data['initial']); 

    $msg = new AMQPMessage(serialize($result)); 

    $r->delivery_info['channel']->basic_publish($msg, '', $r- >get('reply_to')); 
    $r->delivery_info['channel']->basic_ack($r- >delivery_info['delivery_tag']); 
}; 

$channel->basic_qos(null, 1, null); 
$channel->basic_consume('fold_queue', '', false, false, false,  false, 'callback'); 

while(count($channel->callbacks)) { 
    $channel->wait(); 
} 

$channel->close(); 
$connection->close(); 

现在我们创建应用程序本身:

<?php 
use PhpAmqpLib\Message\AMQPMessage; 

$queue_name = ''; 
require_once('09-rabbitmq.php'); 

function send($channel, $queue, $chunk, $initial) 
{ 
    $data = [ 
        'collection' => $chunk, 
        'initial' => $initial 
    ]; 
    $msg = new AMQPMessage(serialize($data), array('reply_to' =>  $queue)); 
    $channel->basic_publish($msg, '', 'fold_queue'); 
} 

class Results { 
    private $results = []; 
    private $channel; 

    public function register($channel, $queue) 
    { 
        $this->channel = $channel; 
        $channel->basic_consume($queue, '', false, false, false,  false, [$this, 'process']); 
    } 

    public function process($rep) 
    { 
        $this->results[] = unserialize($rep->body); 
    } 

    public function get($expected) 
    { 
        while(count($this->results) < $expected) { 
            $this->channel->wait(); 
        } 

        return $this->results; 
    } 
} 

$results = new Results(); 
$results->register($channel, $queue); 

$initial = 0; 

send($channel, $queue, [1, 2, 3], 0); 
send($channel, $queue, [4, 5, 6], 0); 
send($channel, $queue, [7, 8, 9], 0); 
send($channel, $queue, [10], 0); 

echo array_reduce($results->get(4), $fold_function, $initial); 
// 55 

显然,这是一个非常天真的实现。自 PHP 5 以来,要求这样的文件是不好的做法,而且代码非常脆弱,但它达到了演示消息队列提供的可能性的目的。

当您启动工作线程时,它会注册自己作为fold_queue队列的消费者。当接收到消息时,它会使用在公共部分声明的折叠函数对数据进行折叠,并将结果发送回作为回复的队列。循环确保我们等待传入的消息;根据代码,工作线程不应该自行退出。

应用程序有一个send函数,它在fold_queue队列上发送消息。Results类实例注册自己作为默认队列的消费者,以接收每个工作进程的结果。然后发送四条消息,并要求Results实例等待它们。最后,我们减少接收的数据以获得最终结果。

如果只启动一个工作进程,结果将按顺序发送;但是,如果启动多个工作进程,每个工作进程将从 RabbitMQ 服务器检索消息并处理它,从而实现并行化。

与使用线程相比,消息队列有多个好处:

  • 工作进程可以在多台计算机上

  • 工作进程可以在任何其他具有所选队列客户端的语言中实现

  • 队列服务器提供冗余和故障转移机制

  • 队列服务器可以在工作进程之间进行负载平衡

在可用时使用 pthreads 库可能会更容易一些,如果你计划只在唯一计算机的核心之间分配工作负载,但如果你想要更灵活性,消息队列是更好的选择。

其他选择

在 PHP 中启动并行计算的其他方法,但通常会使检索值比我们刚才看到的更加困难。

一种选择是使用curl_multi_exec函数异步执行多个 HTTP 请求。一般结构类似于我们在消息队列示例中使用的内容。但是,与完整消息系统的全部功能相比,可能也有限制。

你也可以使用多个相关函数之一创建其他 PHP 进程。在这种情况下,难点通常是在不丢失数据的情况下传递和检索数据,因为这样做的方式将取决于与环境相关的许多因素。如果你想这样做,popenexecpassthru函数可能是你最好的选择。

如果你不想做所有的苦力活,你也可以使用Parallel.php库,它可以将大部分复杂性抽象化。你可以使用 composer 安装它:

**composer require kzykhys/parallel**

文档可在 GitHub 上找到github.com/kzykhys/Parallel.php。由于该库使用 Unix 套接字,大部分与数据丢失相关的问题都已经解决。但是你无法在 Windows 上使用它。

结束语

正如我们所看到的,使用多个线程或进程在 PHP 中可能并不是最容易的事情,特别是在网页的上下文中。然而,这是可以实现的,并且可以大大加快长时间计算的速度。

随着 PHP 7 中 pthreads 的重写,我们可以希望更多的 Linux 发行版和托管公司将开始提供 ZTS 版本。

如果是这种情况,并且并行计算开始在 PHP 中变得真实,可能可以进行一些轻量级的大数据处理,而无需求助于其他语言的外部库,比如Hadoop 框架

我想用几句话来结束关于消息队列的话题。即使你不以功能方式使用它们来处理数据并获取结果,它们也是在网页请求的上下文中执行长时间操作的好方法。例如,如果你让用户上传一堆图片并且你需要处理它们,你可以将操作加入队列并立即返回给用户。排队的消息将在适当的时间被处理,你的用户不必等待。

总结

在本章中,我们发现当进行函数式编程时,很遗憾是需要付出代价的。由于 PHP 没有对柯里化和函数组合等功能的核心支持,因此在使用它们时会有与包装函数相关的开销。在某些情况下,这显然可能是一个问题,但缓存通常可以减轻这种成本。

我们谈到了记忆化,这是一种缓存技术,结合纯函数,可以加速对给定函数的后续调用,而无需使存储的结果失效。

最后,我们讨论了通过利用在集合上执行的任何纯操作可以在多个节点之间分发,而不必担心共享状态来并行化计算在 PHP 中的计算。

下一章将专门针对使用框架的开发人员,我们将发现如何在 PHP 世界中目前最常用的框架的背景下利用我们迄今为止学到的技术。

第十章: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 接口兼容的RequestResponse实例,您可以使用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_postsget_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 的主要目标是使用称为behaviorsevents的两个概念来建模动画。行为是基于当前时间的值,事件是基于外部或内部刺激的条件。这两个概念允许我们在任何时间点表示任何类型的动画,尽管动画本身是连续的。

与其直接创建动画的表示,通常情况下,你使用行为和事件来描述它。然后,解释和因此表示留给底层实现。这与我们刚刚描述的情况类似。由于 Fran 可以编码诸如键盘输入或鼠标点击之类的事件,你正在创建的模型允许纯函数应用程序对外部输入做出响应。

反应式编程

在我们进一步讨论之前,让我们稍微谈谈在编程世界中reactive意味着什么。这个想法在过去几年里得到了相当多的关注。

首先,有响应式宣言www.reactivemanifesto.org/),它提出了对任何软件都非常有趣的一些属性。这些属性包括:响应性、弹性、弹性和消息驱动。

维基百科(en.wikipedia.org/wiki/Reactive_programming)的定义表明了一些完全不同的东西:

在计算中,响应式编程是围绕数据流和变化传播的编程范式。这意味着应该能够在所使用的编程语言中轻松表达静态或动态数据流,并且底层执行模型将自动通过数据流传播更改。

然后给出了表达式a = b + c的示例,其中当bc的任何一个发生变化时,a的值会自动更新。

JavaScript 世界对这个想法很兴奋,有诸如Bacon.jsRxJS等库。所有这些库共享的核心思想都围绕事件或事件流。

我们可以看到,关于响应式编程有多种定义。遗憾的是,它们中没有一个真正符合我们刚刚学到的有关 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 的例子。

你刚刚完成了本书的最后一章。我希望你阅读它和我写作它一样有趣。我也希望我能够引起你对函数式编程的兴趣,并且你将尝试在未来的项目中实现我们在本书中看到的各种技术。对我来说,没有比知道我能够让一个同行开发者对这个美妙的主题感兴趣更好的回报了。

在我们分别之前,我可以建议你阅读 附录我们谈论函数式编程时在谈论什么。它包含了对函数式编程的更全面定义,它的好处以及历史。你还会在最后找到一个词汇表,解释了各种术语,其中一些在本书中看到,其他一些是新的。

再见,感谢所有的鱼。

第十二章:当我们谈论函数式编程时,我们在谈论什么

函数式编程在过去几年中获得了很多关注。各大科技公司已经开始使用函数式语言:

在编译为 JavaScript 的函数式语言上已经做了一些非常出色和成功的工作:ElmPureScript语言,这只是其中的一部分。有人正在努力创建新的语言,这些语言要么扩展,要么编译为一些更传统的语言;我们可以引用HyCoconut语言用于 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 MLOCaml(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 等等。

可变元

具有动态元数的函数称为可变元。这意味着函数接受可变数量的参数。

posted @ 2024-05-05 12:11  绝不原创的飞龙  阅读(41)  评论(0编辑  收藏  举报