三、PHP 的函数基础

在第一章介绍了 PHP 中的函数之后,在第二章介绍了函数编程的理论方面,我们将最终开始编写真正的代码。我们将从 PHP 中可用的函数开始,这些函数允许我们编写函数代码。一旦基本技术被很好地理解,我们将继续学习各种图书馆,它们将在本书中帮助我们。

在本章中,我们将介绍以下主题:

  • 映射、折叠、缩小和压缩
  • 递归
  • 为什么异常会破坏引用透明性
  • 使用 Maybe 和 any 类型处理错误的更好方法
  • PHP 可用的函数库

一般建议

在前面的章节中,我们描述了函数式应用必须具备的重要属性。然而,我们从未真正讨论过如何实现这一目标。除了我们稍后将学习的各种技巧外,还有一些简单的建议可以立即帮助您。

明确所有输入

在上一章中,我们讨论了纯度和隐藏的输入,或副作用。到目前为止,应该非常清楚的是,函数的所有依赖项都应该作为参数传递。然而,这个建议更进一步。

避免向函数传递对象或复杂的数据结构。尽量把你的输入限制在必要的范围内。这样做将使您的功能范围更容易理解,并且可以轻松确定功能如何运行。它还有以下好处:

  • 打电话比较容易
  • 测试它将需要更少的存根数据

避免临时变量

正如你们可能已经了解到的,国家是邪恶的,尤其是全球国家。然而,局部变量是一种局部状态。一旦你开始在代码中添加它们,你就在慢慢打开蠕虫的罐子。在像 PHP 这样的语言中尤其如此,因为所有变量都是可变的。如果值在此过程中发生变化,会发生什么情况?

每次声明变量时,如果要了解代码的其余部分是如何工作的,就必须记住它的值。这大大增加了认知负担。此外,由于 PHP 是动态类型化的,因此可以使用完全不同的数据重用变量。

当使用临时变量时,总是存在以某种方式修改或在不明显的情况下重用它的风险,从而导致难以调试的错误。

在几乎所有情况下,使用函数都比使用临时变量好。功能具有相同的优点:

  • 通过命名中间结果提高可读性
  • 避免重复你自己
  • 缓存长时间操作的结果(这需要使用记忆,我们将在第 8 章性能效率中讨论)

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

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

更小的功能

我们已经提到函数就像构建块。通常,您希望您的构建块具有通用性和坚固性。如果您编写的小函数只专注于做好一件事,那么这两个属性都会得到更好的执行。

如果您的函数做得太多,则很难重用。我们将在下一章中介绍组合函数,以及如何利用所有小型实用程序函数来创建具有更大范围的新函数。

此外,阅读较小的代码片段并对其进行推理也更容易。其含义更容易理解,并且通常有较少的边缘情况,使函数更容易测试。

参数顺序事项

选择函数的参数顺序似乎不太重要,但事实上非常重要。高阶函数是函数编程的核心特征;这意味着您将传递许多函数。

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

我们还将在第 4 章合成函数中进一步讨论此主题。将多个函数链接在一起时,每个函数的第一个参数是前一个函数的返回值。这意味着您在选择哪些参数优先时必须特别小心。

地图功能

map,或 PHP 中的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函数的第一个参数是任何形式的可调用参数,第二个参数必须是实数组。不能传递迭代器或 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 
) 

最后,遗憾的是,不可能轻松访问每个项目的密钥。然而,您的 callable 可以是一个闭包,因此您可以使用任何可从上下文访问的变量。使用此方法,可以映射数组的键,并使用闭包检索如下值:

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

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

过滤功能

filter,或 PHP 中的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);

折叠或缩小功能

折叠是指使用组合函数将集合减少为返回值的过程。根据语言的不同,此操作可以有多个名称,如折叠、减少、累积、聚合或压缩。与其他与数组相关的函数一样,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 类型的函数签名是无效的类型。

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

您不一定需要使用元素本身来生成值。例如,您可以使用 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循环或任何其他更重要的方法更好地实现这两个功能。然而,它们有几个优点:

  • 如果您使用的是 PHP7 标量类型暗示,则会对每个项目强制使用这些类型,从而使您的软件更加健壮。您可以通过在用于max2方法的数组中放入一个字符串来验证这一点。
  • 您可以对传递给array_reduce方法的函数进行单元测试,或者对array_maparray_filter函数进行单元测试,以确保其正确性。
  • 如果有这样的体系结构,您可以在多个线程或网络节点之间分配大阵列的缩减。如果使用foreach循环,这将更加困难。
  • max3函数所示,这种方法允许您重用现有方法,而不是编写自定义循环来操作数据。

使用折叠的映射和过滤功能

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

<?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函数,以及array_reverse函数。您还可以尝试编写 head 和 tail 方法,它们分别返回数组的第一个和最后一个元素,通常在函数式语言中可以找到。

正如你所看到的,折叠非常强大,它背后的思想是许多功能技术的核心。这就是为什么我更喜欢谈论折叠而不是减少,我觉得这有点减少,双关语的意图。

在继续之前,请确保您了解折叠是如何工作的,因为它将使其他一切变得更容易。

左右折叠

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

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

如果应用于值的操作是可交换的,则左变量和右变量将产生相同的结果。交换运算的概念来自数学,并在第 7 章函数技术和主题中进行了解释。

对于允许无限列表的语言,例如 Haskell,根据列表的生成方式,两个折叠中的一个可以计算值并停止。此外,如果语言实现尾部调用消除,我们将在第 7 章功能技术和主题中讨论这个主题,选择右侧开始折叠可能会避免堆栈溢出,并允许操作完成。

由于 PHP 既不执行无限列表,也不执行尾部调用消除,所以在我看来,并没有理由对其进行区分。如果您感兴趣,array_reduce函数从左边折叠,实现从右边折叠的函数应该不会太复杂。

MapReduce 模型

您可能已经听说过名称MapReduce编程模型。起初,它指的是谷歌开发的专有技术,但现在有多种语言的多种实现。

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

在实现 MapReduce 以分析数据时,您在本书中学习的每一项技术都可以帮助您。但是,该主题超出了范围,因此如果您想了解更多信息,可以访问从 Wikipedia 页面开始 https://en.wikipedia.org/wiki/MapReduce

卷积还是压缩

卷积,或者更常见的 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_merge函数之前,使用array_slice函数将所有数组缩减为相同的大小。

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

<?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循环来实现第二个循环,并提前计算第一个循环的项。

相反,让我们围绕一个更有趣的挑战来思考,河内塔。对于那些不知道这个游戏的人来说,传统版本的特点是三根棒,不同大小的圆盘叠在一起,最小的放在顶部。在游戏开始时,所有的光碟都放在最左边的杆上,目标是把它们放到最右边的杆上。游戏遵循以下规则:

  • 一次只能移动一个光盘
  • 只能移动杆的最顶部圆盘
  • 光盘不能放在较小的光盘上

此游戏的设置如下所示:

Recursion

如果我们想解决游戏,较大的光盘必须首先放在最后一根棒上。为了做到这一点,我们需要移动所有其他光盘的中间杆第一。按照这条思路,我们可以得出我们必须实现的三大步骤:

  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 的情况并非如此,因此让我们使用正确的工具来完成这项工作。如果您可以把这个问题看作是一些较小的类似问题的组合,那么通常使用递归就很容易了。例如,试图找到河内的塔楼的迭代解决方案需要仔细思考。或者,您可以尝试只使用循环重写目录扫描函数,以说服自己。

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

  • 为具有多个级别的菜单生成数据结构
  • 遍历 XML 文档
  • 呈现一系列可能包含子组件的 CMS 组件

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

尽管递归通常更容易阅读,但一旦掌握了它,它就会带来内存开销。在大多数应用中,您应该不会遇到任何困难,但我们将在第 10 章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 循环时,传递循环计数器。显然,当前的计算状态也必须始终传递。

通常,递归本身是在 helper 函数中完成的,以避免使用用于执行循环的可选参数混淆签名。为了保持全局名称空间干净,在原始函数中声明了此帮助程序。以下是一个例子:

<?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关键字来传递包含函数的变量。这是因为我们已经讨论过一个事实。传递给闭包的变量在声明时被绑定,但当函数声明时,赋值尚未发生,变量为空。但是,如果我们通过引用传递变量,赋值完成后它将被更新,我们将能够在匿名函数中使用它作为回调。

例外情况

错误管理是编写软件时面临的最棘手的问题之一。通常很难决定哪段代码应该处理错误。在低级功能中执行此操作,您可能无法访问工具以显示错误消息或足够的上下文来决定最佳的操作过程。如果在更高的级别执行此操作,则可能会对数据造成严重破坏,或使应用处于不可恢复的状态。

在 OOP 代码库中管理错误的常用方法是使用异常。您在库或实用程序代码中抛出一个异常,并在准备按需要管理它时捕获它。

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

  • 任何外部源(数据库访问、文件系统错误、不可用的外部资源、无效的用户输入等)引发的异常本质上是不纯的,因为访问这些源已经是一个次要原因。
  • 由于逻辑错误(索引越界、无效类型或数据等)引发的异常通常被认为是纯粹的,因为它可以被认为是函数的有效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,这显然改变了调用函数的结果。通过简单地尝试应用等式推理,我们证明了捕获异常可以破坏引用透明性。

如果你不能抓住例外,那么例外又有什么好处呢?不多这就是为什么我们在本书中不使用它们的原因。但是,你可以认为它们是一个副作用,然后应用我们将在第 6 章中看到的技巧——第 1 章,To1 T1。例如,Haskell 允许抛出异常,只要它们是使用 IO Monad 捕获的。

另一个问题是认知负担。一旦你使用它们,你就无法确定它们何时会被抓住;它们甚至可能直接显示给最终用户。这就破坏了你独立思考一段代码的能力,因为你现在必须思考更高层次上会发生什么。

这个问题通常就是为什么你会听到这样的建议,比如只对错误使用异常,而不是流控制。这样,您至少可以确保您的异常将用于显示某种类型的错误,而不必怀疑应用处于何种状态。

PHP7 和例外情况

即使我们主要从负面的角度讨论异常,也让我借此机会介绍新 PHP 版本中与该主题相关的改进。

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

PHP7.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() 

如果您使用的是 PHP5.6 或更低版本,则消息将显示以下内容:

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

然而,使用 PHP7.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 允许您配置显示的错误级别。在大多数情况下,通知只是隐藏的,因此没有人会看到应用中的某个地方发生了错误。

公平地说,有一种方法可以在运行时通过使用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,但也可能返回计算结果为 FALSE 的非布尔值。有关更多信息,请阅读布尔值部分。使用===运算符测试此函数的返回值。

PHP 仅在出现错误时使用错误代码,但不会像 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 值,否则您将面临 Tony Hoare 所称的十亿美元错误,一个 null 指针引用。或者,正如 PHP 中所报告的,在 null上调用成员函数 XXX()。

在这则轶事中,Tony Hoare 是 1965 年将空值引入世界的人,因为它很容易实现。后来,他对这一决定深感遗憾,并认定这是他数十亿美元的错误。如果你想了解更多的原因,我邀请你观看他在上的演讲 https://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; 
}); 

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

我们还可以想象将初始参数连同故障信息一起传递给回调,以便错误处理程序可以相应地执行操作。在某种程度上,这种方法有点像我们前面看到的所有东西的组合,因为它允许您:

  • 指定您选择的默认返回值
  • 显示或记录所需的任何类型的错误消息
  • 如果您愿意,请返回更复杂的数据结构,并返回错误代码

选项/可能和任一类型

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

让我们从这两种联合类型中最简单的一种开始,我们将在本章中看到。与往常一样,命名在计算机科学中是一件困难的事情,人们用不同的名称来表示大致相同的结构:

  • 哈斯克尔称之为“可能类型”,就像伊德里斯一样
  • Scala 将其称为选项类型,就像OCamlRustML一样
  • 从版本 8 开始,java 有一个可选类型,如 SWIFT 和 C++下一个规范一样。

就我个人而言,我更喜欢面额,也许我认为这是另一种选择。因此,本书的其余部分将使用此选项,除非特定库具有名为Option的类型。

Maybe 类型的特殊性在于,它可以保存特定类型的值,也可以保存与nothing等价的值,或者如果您愿意,可以保存空值。在 Haskell 中,这两个可能的值被称为JustNothing。在 Scala 中,它是SomeNone,因为Nothing已经用于指定值 null 的类型等价物。

PHP 中只存在实现 Maybe 或 Option 类型的库,本章后面介绍的一些库也附带了此类类型。但为了正确理解它们是如何工作的以及它们的力量,我们将实施我们自己的。

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

  • 实施错误管理,以便最终用户不会发现错误
  • 避免样板文件或复杂的代码结构
  • 在我们职能部门的签名上刊登广告
  • 避免将错误误认为正确结果的任何风险

如果您使用我们稍后将创建的类型键入 hint 函数返回值,那么您就完成了我们的第三个目标。存在两种不同的可能性,JustNothing值确保您不会将有效结果误认为错误。为了确保我们不会在行的某个地方得到错误的值,我们必须确保在没有指定默认值的情况下无法从新类型中获取值(如果它是Nothing值)。关于我们的第二个目标,我们将看看我们是否能写些好东西:

<?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; 
    } 
} 

为了使实用程序方法具有某种一致的命名,我们使用与处理集合的函数相同的名称。在某种程度上,你可以考虑一个类似于一个列表的任意 T0 类型。基于相同的假设,让我们添加一些其他实用方法:

<?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 类本身。
  • filter方法将给定的谓词应用于该值。如果谓词返回真值,则保留该值;否则,我们返回值Nothing

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

<?php 
$user = getCurrentUser(); 

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

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

在这里,我们只使用名称,因此我们可以将自己限制为一个空检查。如果我们需要用户提供更多信息,通常的方法是使用一种有时称为空对象模式的模式。在本例中,我们的 Null 对象将是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 

我们的 To0t0 类型的力量是,我们从来没有考虑过该实例是否包含一个值。我们只能将函数应用到它,直到最后,使用getOrElse方法提取最终值。

起重功能

我们已经看到了我们新型Maybe的威力。但事实是,您要么没有时间重写所有现有函数以支持它,要么根本无法重写,因为这些函数位于外部第三方。

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

为此,我们需要一个新的 helper 函数。为了使事情或多或少简单,我们还将假设,如果提升函数的任何参数计算为值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类型的函数,您可以重写它或为它制作自定义包装。

作为结束语,起重过程不保留给可能的类型。您可以提升任何函数来接受任何类型的容器。我们的助手的更好名称可能是liftMaybe,或者我们可以将其作为静态方法添加到Maybe类中,以使事情更清楚。

任何一种类型

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

假设正确的值是有效的,则实现提出的 API 与我们为Maybe类提供的 API 相同。您应该能够在任何地方使用Either类而不是Maybe类,而无需更改逻辑。唯一的区别是检查我们处于哪种情况的方法,并将该方法更改为新的getRightgetLeft方法。

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

<?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 的上找到 https://github.com/lstrojny/functional-php 。如果您习惯于通过编写以下命令来使用 Composer,那么它应该很容易安装:

composer require lstrojny/functional-php.

出于性能原因,该库过去既可以在 PHP 中实现,也可以作为 C 扩展的一部分实现。但是最近 PHP 核心在速度和维护两个代码基的负担方面的改进使得扩展过时了。

很多助手函数已经实现了,我们现在没有足够的空间来详细介绍它们。如果您感兴趣,可以查看文档。然而,我们将快速介绍重要的例子,本书的其余部分将包含使用更多例子的例子。

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

如何使用这些功能

第 1 章所述,在 PHP中作为一等公民运行,因为 PHP5.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函数检查集合中的所有元素是严格为真还是严格为假。
  • truthyfalsy功能与之前相同,但比较不严格。
  • const_function函数返回一个新函数,该函数将始终返回给定值。这可以用来模拟不可变的数据。

扩展 PHP 函数

PHP 函数倾向于只在数组上工作。以下函数将其行为扩展到可以使用foreach循环迭代的任何对象。所有函数的参数顺序也保持相同:

  • contains 方法检查该值是否包含在给定集合中。第三个参数控制比较是否应该严格。
  • sort方法对集合进行排序,但返回一个新数组,而不是按引用排序。您可以决定是否保留这些密钥。
  • map方法将array_map方法行为扩展到所有集合。
  • sum、maximum 和 minimum 方法执行与 PHP 对应方法相同的任务,但在任何类型的集合上都可以执行。除此之外,该库还包含乘积、比率、差值和平均值。
  • 当您不传递函数时,zip 方法执行与array_map方法相同的工作。但是,您也可以传递回调来确定如何合并各个项。
  • reduce_leftreduce_right方法从左侧或右侧折叠集合。

使用谓词

使用集合时,通常需要检查某些、所有或没有元素是否验证特定条件并相应地执行操作。为此,您可以使用以下功能:

  • 如果集合的所有元素对谓词都有效,every函数返回真值
  • 如果至少有一个元素对谓词有效,some函数返回值 true
  • 如果没有元素对谓词有效,none函数返回值 true

这些函数不会修改集合。它们只是检查元素是否符合某个条件。如果需要筛选某些图元,可以使用以下帮助程序:

  • selectfilter函数只返回对谓词有效的元素。
  • reject函数只返回对谓词无效的元素。
  • 第一个或head函数返回对谓词有效的第一个元素。
  • 最后一个函数返回对谓词有效的最后一个元素。
  • drop_first函数从集合的开始移除元素,直到给定回调为true。一旦回调返回 false,就停止删除元素。
  • drop_last函数与前一个函数相同,但从末尾开始。

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

调用函数

要在回调中调用函数时立即声明匿名函数是很麻烦的。这些助手将使用更简单的语法为您做到这一点:

  • invoke助手对集合中的所有对象调用一个方法,并返回一个新集合及其结果
  • invoke_firstinvoke_last助手分别对集合的第一个和最后一个对象调用方法
  • 如果第一个参数是有效的对象,invoke_if帮助程序将对其调用给定的方法。您可以传递方法参数和默认值。
  • invoker助手返回一个新的可调用函数,该函数使用给定的参数调用给定的方法。

您可能还希望调用函数,直到获得值或达到某个阈值。图书馆为你提供了保护:

  • retry库调用函数,直到它停止返回异常或达到尝试次数为止
  • poll库调用该函数,直到返回 truthy 值或达到给定的超时

操纵数据

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

  • pluck函数从给定集合中的所有对象获取一个属性,并返回一个包含这些值的新集合。
  • pick函数根据给定的键从数组中选择一个元素。如果元素不存在,则可以提供默认值。
  • first_index_oflast_index_of函数分别返回与给定值匹配的元素的第一个索引和最后一个索引。
  • indexes_of函数返回与给定值匹配的所有索引。
  • flatten函数将嵌套集合的深度减少为单个平面集合。

有时,如果给定谓词或某个分组值,还需要将集合拆分为多个部分:

  • partition方法接受一个谓词列表,集合中的每一项都基于第一个有效谓词放入给定的组中
  • group方法根据每个元素回调返回的每个不同值创建多个组

收工

正如您所看到的,functional-php库提供了许多不同的帮助程序和实用功能。现在,你如何才能充分利用它们可能并不明显,但我希望本书的其余部分能让你对你能取得的成就略知一二。

另外,不要忘记,我们并没有给出所有函数,因为其中一些函数首先需要一些理论解释。在适当的时候。

php 选项库

我们在早些时候创建了自己的Maybe类型。这个库提出了一个更完整的实现。然而,Scala 使用的命名是被选中的。源代码位于 GitHub 上的https://github.com/schmittjoh/php-option 。最简单的安装方法是使用 Composer 编写以下命令:

composer require phpoption/phpoption

一个有趣的补充是LazyOption方法,它接受回调而不是值。只有在需要值时才会执行回调。当您使用orElse方法给出备选值以防前一个值无效时,这一点尤其有趣。在这种情况下,通过使用LazyOption方法,可以避免在一个值有效时进行不必要的计算。

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

拉雷维尔系列

正如第一章中已经提到的,Laravel 提供了一个伟大的图书馆来管理藏品。它声明了一个名为Collection的类,它们的 ORM 内部使用该类,雄辩的,其他大部分部分依赖于集合。

在内部,使用了一个简单的数组,但它的包装方式促进了数据的不变性,并提供了一种处理数据的函数方法。为了实现这一目标,向开发人员提出了 60 到 70 种方法。

如果您已经在使用 Laravel,那么您可能已经熟悉该支持类提供的各种可能性。如果您正在使用任何其他框架,您仍然可以通过从获取提取的部分来从中受益 https://github.com/tightenco/collect

该文件可在 Laravel 的官方网站上查阅 https://laravel.com/docs/collections 。我们不会详细描述每种方法,因为它们有很多。如果您正在使用 Laravel,并想了解更多关于其系列提供的所有可能性,您可以前往https://adamwathan.me/refactoring-to-collections/

与拉威尔的收藏品合作

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

  • map方法将函数应用于所有元素并返回新值
  • filter方法使用谓词过滤集合
  • reduce方法使用给定的回调折叠集合
  • pluck从所有元素中获取给定的属性
  • groupBy方法使用每个元素的给定值对集合进行分区

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

操作完成后,可以使用 all 方法以数组的形式获取当前值。

不可变的 php 库

由于标准 PHP库中的SplFixedArray方法的各种抱怨,这个提出了不可变数据结构的库诞生了,大部分是由于其难以使用的 API。在其核心,immutable-php库使用上述数据结构,但有一套很好的方法来包装它。

SplFixedArray方法是固定大小数组的具体实现,它只允许数字索引。这些约束允许真正快速的阵列结构。

您可以在的 GitHub 项目页面上查看 https://github.com/jkoudys/immutable.php 或使用 Composer 编写以下命令进行安装:

composer require qaribou/immutable.php.

使用 immutable.php

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

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

  • map将函数应用于所有项并返回新值的方法
  • filter方法创建一个新数组,其中只包含对谓词有效的项
  • 使用回调折叠项目的reduce方法

您还有其他助手:

  • join方法连接字符串集合
  • sort方法返回使用给定回调排序的集合

您的数据也可以轻松地作为传统数组检索或编码为 JSON 格式。

总而言之,这个库提供的方法比 Laravel 的集合少,但您将有更好的性能和更低的内存占用。

其他图书馆

由于 PHP 核心缺少许多实用函数和特性来进行适当的函数编程,许多人开始研究实现缺失部分的库。这就是为什么如果你开始寻找,你会发现很多。

如果以前提供的这些库不适合您的需要,这里是一个不完整且无序的此类库列表。

下划线.php 库

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

但是,如果您习惯于使用它,以下是各种端口的简短列表:

军刀

Saber严格遵循最新 PHP 版本的要求。它使用强类型、不可变对象和惰性计算。为了使用它的各种方法,您必须在库提供的类中您的值。它可能很麻烦,但它提供了安全性并减少了 bug。

它似乎受到了 C#和 F#的启发,F#主要是运行在.NET 虚拟机上的函数式语言,或者叫它的真名为CLR。您可以在 GitHub 的上找到源代码和文档 https://github.com/bluesnowman/fphp-saber

罗尔

Rawr不仅仅是一个功能库。它试图以更一般的方式修复 PHP 语言的缺点。像 Saber 一样,它提供了一个新类来封装标量值;但是,这些类型的使用方式更接近 Haskell 所做的。您还可以将匿名函数封装在类中,以提高其键入安全性。

该库还添加了更具Smalltalk风格的面向对象 Monads,并允许您执行某种基于原型的编程,就像使用 JavaScript 一样。

遗憾的是,该库似乎处于停顿状态,文档与源代码不符。然而,你可以在那里找到一些灵感。您可以在 GitHub 上的找到代码 https://github.com/haskellcamargo/rawr

PHP 函数

本库主要围绕单子的概念展开,我们将在第 5 章现实生活中的单子中看到。公认的灵感来自 Haskell,该库从中实现:

  • 状态单子
  • 木卫一
  • 收集单子
  • 要么是单子
  • 也许是单子

通过Collection单子,该库提供了除mapreducefilter方法之外的各种方法。

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

功能性

这个图书馆最初是作为一个学习场所创建的,现在已经发展成为一个如果你想找一些相对较小的东西,它可能会被证明是有用的。其主要思想是提供一个框架,以便您可以删除代码中的所有循环。

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

该库还拥有所有传统的竞争者,如映射和缩减。代码和文档可在 GitHub 的上获得 https://github.com/sergiors/functional

PHP 函数式编程实用工具

这个库试图走与我们在前几页中介绍的functional-php库相同的道路。然而,据我所知,到目前为止,它的功能稍微少了一些。它可以成为一个有趣的图书馆,让人们想要一些更小、更容易学习的东西。代码位于 GitHub 上的https://github.com/daveross/functional-programming-utils

非标准 PHP 库

这个图书馆并不是严格意义上的功能性图书馆。这个想法更多的是通过各种助手和实用功能来扩展标准库,以使使用这些集合更加容易。

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

该图书馆创建于 2014 年,但在 2015 年底工作开始恢复强劲之前,几乎已经死亡。现在,它可以替代我们前面介绍的任何库。如果您感兴趣,请在 GitHub 上的获取代码 https://github.com/ihor/Nspl

总结

在这漫长的一章中,我们介绍了我们将在本书中使用的所有实用构建块。我希望这几个例子不要显得太枯燥。有很多内容要覆盖,只有有限的一组页面。下面的章节将以我们所学到的知识为基础,并结合更好的例子。

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

下一部分是对递归的简要介绍,它既是一种解决特定问题集的技术,也是一种避免使用循环的技术。在一本关于函数式语言的书中,这个主题可能值得一整章,但由于 PHP 有各种循环结构,所以它就不那么重要了。此外,我们将在下面的章节中看到更多递归示例。

我们还讨论了异常以及它们在功能代码库中引起问题的原因,并且在讨论了其他方法的优缺点之后,我们为 Maybe 和 other 类型编写了实现,作为管理错误的更好方法。

最后,我们介绍了一些库,它们提供了函数构造和帮助程序,这样我们就不必编写自己的函数。