五、函子、应用和单子

前一章介绍了第一个真正的函数技术,如函数合成和 curry。在本章中,我们将通过介绍单子的概念,再次深入探讨更多的理论概念。不会有太多的实际应用,因为我们有很多地方要覆盖。然而,第 6 章现实生活中的单子将使用我们在这里学到的一切来解决实际问题。

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

你可能不会在第一次得到所有的东西。单子是一个高度抽象的概念,即使你在本章末尾对这个主题似乎很清楚,你以后可能会偶然发现一些东西,这些东西会阻碍你对单子的直觉。

我会尽力把事情解释清楚,但如果你觉得我的解释不够,我会在本章末尾的进一步阅读一节中添加关于该主题的参考资料。在本章中,我们将介绍以下主题:

  • 函子与相关定律
  • 应用函子及其相关定律
  • 幺半群及其相关定律
  • 单子及相关法律

只有实施这些概念,才会有很多理论内容。在第 6 章现实生活中的单子之前,不要期望有太多的例子。

函子

在直接谈论单子之前,让我们先从一开始说起。为了理解单子是什么,我们需要介绍一些相关的概念。第一个是函子。

更复杂的是,在命令式编程中使用术语函子来描述函数对象,这是完全不同的。在 PHP 中,具有__invoke方法的对象,正如我们在第 1 章中看到的,作为一等公民的函数就是这样一个函数对象。

然而,在函数规划中,函子是从范畴论的数学领域中提取和改编的一个概念。细节对于我们的目的来说并不重要;可以说函子是一种模式,允许我们将函数映射到上下文中包含的一个或多个值。此外,为了使定义尽可能完整,我们的函子必须遵守一些定律,我们将在后面描述和验证这些定律。

我们已经在集合上多次使用 map,这使它们成为事实上的函子。但是如果你没记错的话,我们还命名了我们的方法,将一个函数应用到包含在。原因是函子可以被视为容器,具有将函数应用于包含值的方法。

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

<?php 

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

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

为什么要为这么简单的想法大惊小怪?因为,尽管这个想法本身很简单,但它允许我们从不同的角度来思考正在发生的事情,并可能有助于理解和重构代码。

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

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

函子允许我们共享一个公共接口,即我们的map方法或函数,以对各种数据类型执行类似的操作,同时隐藏实现的复杂性。与函数式编程一样,由于同一操作没有多个名称,因此认知负担也会减少。例如,“apply”、“perform”和“walk”等函数和方法名称通常用于描述同一事物。

身份功能

我们最后关心的是这个概念中的两个函子定律。但在介绍它们之前,我们需要绕一小段关于身份功能的弯路,或者通常是id。这是一个非常简单的函数,只返回其参数:

<?php 

function id($value) 
{ 
    return $value; 
} 

为什么有人需要一个函数来做这么少的事情?首先,我们将在稍后需要它来证明本章中提出的各种抽象的规律。但现实世界的应用也存在。

例如,当你对数字进行折叠时,比如说求和,你将使用初始值0。当折叠功能时,id功能将具有相同的作用。实际上,compose 函数是使用functional-php库中的id函数实现的。

另一个用途可能是来自另一个库的函数,该函数执行您感兴趣的操作,但也调用对结果数据的回调。如果回调是强制性的,但您不想对数据做任何其他操作,只需传递id,您将得到不变的数据。

让我们使用新函数为任何函数f声明compose函数的属性,只使用一个参数:

compose(id, f) == compose(f, id) 

这基本上是说,如果你首先应用参数id然后f,你将得到与第一次应用f然后id时完全相同的结果。在这一点上,这对你来说应该是显而易见的。如果没有,我鼓励你重温最后一章,直到你清楚地理解为什么会这样。

函子定律

现在我们已经讨论了身份函数,让我们回到我们的法律。它们之所以重要,有两个原因:

  • 它们给了我们一组约束来保证函子的有效性
  • 它们允许我们执行被证明正确的重构

不言而喻,它们是:

  1. 地图(id)=id
  2. 合成(map(f),map(g))==map(合成(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情况,我们必须切换到一个非严格等式,因为否则我们会得到一个 false 的结果,因为 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; 
    } 
} 

与 identity 函数一样,这种函子的用途并不明显。然而,这个想法是一样的,当你有一个函子作为参数的函数时,你可以使用它,但是你不想修改你的实际值。

在本书的以下章节中,这应该会变得更有意义。同时,我们将使用恒等函子来解释一些更高级的概念。

结束语

让我再次重申,函子是一个非常简单的抽象,但也是一个非常强大的抽象。我们只看到了其中的两个,但是有无限多的数据结构可以很容易地转换成函子。

任何允许您将给定函数映射到上下文中的一个或多个值的函数或类都可以被视为函子。恒等函子或数组就是这种上下文的简单例子;其他示例包括我们前面讨论的MaybeEither类型,或者任何具有map方法的类,该方法允许您将函数应用于包含的值。

我无法鼓励您尝试实现这种映射模式,并验证这两条定律是否适用于创建新类或数据结构的任何地方。这将使您更容易理解代码可以执行什么,并且您将能够在保证重构正确的情况下使用组合执行优化。

应用函子

让我们以我们的身份函子为例,它包含一些整数和一个add函数的 curryyid 版本:

<?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 实现。其他实现有时使用名称单元。pure 用于从任何callable创建新的应用。

apply函数将存储的函数应用于给定参数。参数的类型必须相同,以便实现知道如何访问内部值。遗憾的是,PHP 类型系统不允许我们执行此规则,我们必须默认为Applicative

对于 map 的定义,我们有同样的问题,它必须将返回类型保持为Functor。我们需要这样做,因为 PHP 类型引擎不支持名为返回类型协方差的功能。如果有,我们可以指定一个更专门化的类型(即子类型)作为返回值。

map功能使用上述功能实现。首先,我们使用pure方法封装callable,并将此新应用应用于实际值。没什么特别的。

让我们测试一下我们的实现:

<?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 实现似乎是正确的。

与函子一样,我们可以创建最简单的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 的第一个重要属性是它们在 composition 下被关闭,这意味着 applicative 将返回相同类型的新 applicative。此外,apply 方法采用自己类型的 applicative。我们无法使用 PHP 类型系统强制执行这一点,因此您需要小心,否则可能会在某个时候出现问题。

要有一个合适的应用函子,还需要遵守以下规则。我们将首先详细介绍它们,然后在稍后的IdentityApplicative课程中验证它们是否适用。

地图

纯(f)>应用==map(f)

使用 Applications 应用函数与映射此函数相同。这个定律简单地告诉我们,我们可以在以前使用函子的任何地方使用应用。通过切换到应用,我们不会失去电源。

事实上,这并不是一条真正的定律,因为它可以从以下四条定律中推断出来。但由于这一点并不明显,为了让事情更清楚,让我们陈述一下。

身份

纯(id)>应用($x)=id($x)

应用 identity 函数不会导致值发生更改。与函子的恒等式法则一样,这确保了apply方法只应用函数。在我们背后没有发生隐藏的转变。

同态

纯(f)>应用($x)=纯(f($x))

创建一个 applicative functor 并将其应用于一个值,其效果与首先对该值调用函数,然后将其装箱到 functor 中的效果相同。

这是一条重要的定律,因为我们深入应用的第一个动机是使用 curryed 函数而不是一元函数。这条定律确保我们可以在任何阶段创建应用,而不需要立即将函数装箱。

互通式立交

纯(f)>应用($x)=纯(函数($f){$f($x);})>应用(f)

这个有点棘手。它声明对值应用函数与创建具有提升值的应用函子并将其应用于函数相同。在这种情况下,提升值是围绕将对其调用给定函数的值的闭包。该定律保证纯函数除了对给定值进行装箱外,不进行任何修改。

组成

纯(组合)>应用(f1)>应用(f2)>应用($x)=纯(f1)>应用(纯(f2)>应用($x))

这条定律的一个更简单的版本可以用pure(compose(f1,f2))->apply($x)写在左手边。它只是声明,作为函子的合成法则,您可以对您的值应用两个函数的合成版本,或者单独调用它们。这确保了可以对函子执行相同的优化。

验证法律是否有效

正如我们在函子中看到的,我们非常建议您测试您的实现是否适用于所有定律。这可能是一个非常乏味的过程,特别是如果你有四个。因此,与其手动执行检查,不如编写一个帮助程序:

<?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 和一个 lifted 值,并在必要时使用这些参数。

让我们来验证一下我们的IdentityApplicative类:

<?php 

print_r(check_applicative_laws( 
IdentityApplicative::pure('strtoupper'), 
    'trim', 
    ' Hello World! ' 
)); 
// Array 
// ( 
//     [identity] => 1 
//     [homomorphism] => 1 
//     [interchange] => 1 
//     [composition] => 1 
//     [map] => 1 
// ) 

太好了,一切似乎都很好。如果要使用此帮助器,则需要选择兼容的函数,因为您可能会遇到一些不清晰的错误消息,因为我们无法确保第一个函数的返回值类型与第二个函数的第一个参数类型匹配。

由于这种自动检查非常有帮助,让我们快速为函子编写相同类型的函数:

<?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 
// ) 

很好,一切都很好。

使用应用

正如我们已经看到的,数组是函子,因为它们有一个map函数。但集合也可以很容易地成为应用。让我们实现一个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 
// ) 

这里发生了什么?在我们的应用中有一个函数列表,我们将其应用于一个数字列表。结果是一个新列表,每个函数应用于每个数字。

这个小例子不是很有用,但是这个想法可以应用于任何事情。假设您有某种图像库应用,用户可以在其中上载一些图像。您还需要对这些图像进行各种处理:

  • 限制最终图像的大小,因为用户倾向于上传过大的图像
  • 为索引页创建缩略图
  • 为移动设备创建小型版本

你唯一需要做的就是创建一个包含所有函数的数组,一个上传图像的数组,然后应用我们刚才对数字所做的相同模式。然后,您可以使用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 
//         ) 
// 
//) 

在这个阶段,你可能渴望得到更多,但你需要耐心。让我们先结束本章中的理论,我们很快就会在下一章中看到更有力的例子。

幺半群

现在,我们已经了解了应用函子,我们需要在讨论单子(monad,monoid)之前添加最后一块。这一概念再次取自范畴理论的数学领域。

幺半群是任何类型和该类型上的二进制操作与相关标识元素的组合。例如,这里有一些您可能从未想到的组合是幺半群:

  • 整数和加法运算,标识为 0,因为$i+0==$i
  • 整数和乘法运算,标识为 1,因为$i1==$i*
  • 数组和合并操作,标识为空数组,因为数组 _merge($a,[])==a
  • 字符串和连接操作,标识为空字符串,因为$s.''==$s

在本章的其余部分中,我们将我们的操作称为op和标识元素idop调用来自 operation 或 operator,在跨多种语言的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 无法做到这一点,因为 monoid 的概念并不在核心中。

那何必费心呢?因为幺半群可以与高阶函数一起使用,我们稍后将发现的一些构造可以充分利用它们的定律。此外,由于 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库中的 each 和某些函数的 monoidic 版本。这两个幺半群的作用与求和幺半群和乘积幺半群相同,因为 PHP 不允许我们使用布尔运算符作为函数:

<?php 

echo Any::concat([true, false, true, false]) ? 'true' : 'false'; 
// true 

echo All::concat([true, false, true, false]) ? 'true' : 'false'; 
// false 

当您需要以编程方式创建一系列条件时,它们可能会非常有用。与其迭代所有结果,只需将它们输入到Monoid即可。你也可以写一个幺半群作为练习,看看你是否理解这个概念。

使用幺半群

使用新的幺半群最明显的方法之一是折叠一组值:

<?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 规范的始作俑者之一,也是单子使用的支持者,可以在的上下文中找到 http://james-iry.blogspot.com/2009/05/brief-incomplete-and-mostly-wrong.html

如果没有一些范畴理论的知识,就很难清楚地解释这段引语是关于什么的,特别是因为它是虚构的,而且自愿含糊到足以搞笑。只需说单子与幺半群相似,因为它们共享大致相同的一组定律。此外,它们与函子和应用的概念直接相关。

单子就像函子一样,充当值的某种容器。此外,与 applicative 一样,您可以将函数应用于封装的值。这三种模式都是将一些数据放入上下文中的一种方式。然而,两者之间存在一些差异:

  • 应用封装了一个函数。单子和函子封装了一个值。
  • 应用使用返回非提升值的函数。monad 使用返回相同类型 monad 的函数。

由于函数也是有效值,这并不意味着两者都不兼容,它只意味着我们需要为 monad 类型定义一个新的 API。但是,我们可以自由扩展 Applicationive,因为它在一元上下文中包含完全有效的方法:

<?php 

abstract class Monad extends Applicative 
{ 
    public static function return($value): Monad 
    { 
        return static::pure($value); 
    } 

    public abstract function bind(callable $f): Monad; 
} 

我们的实现非常简单。我们将 pure 别名为 return,因为 Haskell 在 monad 中使用这个术语,这样人们就不会迷路。请注意,它与您习惯的 return 关键字无关;它实际上只是把值放在 monad 的上下文中。我们还定义了一个新的 bind 函数,该函数以callable类型作为参数。

由于我们不知道内部值将如何存储,并且由于 PHP 类型系统的限制,我们无法实现applybind函数,尽管它们应该非常相似:

  • apply方法获取包装在Applicative类中的值,并将存储的函数应用于该值
  • bind方法获取一个函数并将其应用于存储的值

两者的区别在于bind方法需要直接返回值,而apply方法首先使用purereturn函数再次包装值。

正如你可能已经了解到的,使用不同语言的人对事物的命名往往有点不同。这就是为什么您有时会看到名为平面图bind方法,具体取决于您正在查看的实现。

单子定律

你现在知道该怎么做了;还有一些单子被称为单子必须遵守的法则。这些定律与幺半群恒等式和结合性的定律相同。所以幺半群的所有有用性质对于单子也是如此。

然而,正如您将看到的,我们将描述的定律似乎与我们所看到的幺半群的恒等式和结合性定律没有任何共同之处。这与我们定义bindreturn函数的方式有关。使用一种叫做Kleisli复合运算符的方法,我们可以变换这些定律,使它们的读数有点像我们以前看到的。然而,这有点复杂,对我们的目的毫无用处。如果您想了解更多信息,我可以指导您访问https://wiki.haskell.org/Monad_laws

左身份

返回(x)>绑定(f)=f(x)

该定律规定,如果您获取一个值,将其包装在 monad 的上下文中,并将其绑定到f,则结果必须与直接对该值调用函数的结果相同。它确保了bind方法除了应用之外,对功能和价值没有副作用。

这只能是因为,与apply方法相反,bind方法不会将函数的返回值再次包装到 monad 中。这是函数的工作。

正确身份

m->绑定(返回)=m

该定律规定,如果您将返回值绑定到 monad,您将获得 monad。它确保返回的效果不亚于将值放入 monad 的上下文中。

结合性

m->结合(f)>结合(g)=m->结合(函数($x){f($x)>结合(g);})

该定律表明,您可以先将 monad 中的值绑定到f然后再绑定到g或者将其绑定到第一个函数与第二个函数的组合。我们需要一个中介函数来模拟它,就像我们在应用的交换法则中需要的那样。

这条定律与以前的结合定律和合成定律具有相同的优点。这种形式有点奇怪,因为 monad 保存的是值,而不是函数或运算符。

验证我们的单子

让我们编写一个函数来检查 monad 的有效性:

<?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 
// ) 

为什么是单子?

第一个原因是一个实际的原因。使用应用应用函数时,结果会自动放入应用的上下文中。这意味着,如果有一个函数返回一个应用并应用它,那么结果将是应用中的一个应用。看过《盗梦空间》这部电影的人都知道,把东西放进去,放进去,放进去并不总是一个好主意。

单子是避免这种不必要嵌套的一种方法。bind函数将封装返回值的任务委托给函数,这意味着您只有一个深度级别。

单子也是执行流控制的一种方式。正如我们所看到的,函数式程序员倾向于避免使用循环或任何其他类型的控制流,例如使代码更难推理的if条件。monad 是一种强大的方式,它可以以一种真正有表现力的方式进行序列转换,同时保持代码的整洁。

像 Haskell 这样的语言也有特定的语法糖来处理 monad,比如do符号,这使代码更容易阅读。在我看来,有些人曾尝试在 PHP 中实现这样一个东西,但没有取得多大成功。

然而,要真正理解 monad 抽象的威力,您必须了解一些具体的实现,我们将在下一章中介绍。它们将允许我们以纯功能的方式执行IO操作,将日志消息从一个函数传递到另一个函数,甚至用纯功能计算随机数。

又一次挑战单子

我们决定实现我们的Monad类,留下applybind方法的抽象。我们别无选择,因为在Monad类中存储值的方式将只在child类中决定。

然而,正如我们已经说过的,bind方法有时在 Scala 中被称为 flatMap。顾名思义,这只是一个映射和一个名为flatten的函数的组合。

你明白我的意思了吗?还记得嵌套应用的问题吗?我们可以在Monad类中添加flatten函数,或者按照 Haskell 的说法加入方法,而不是将bind作为抽象方法,我们可以使用map和我们的新方法来实现它。

我们仍然有两种方法要实现,但不是两种方法都做大致相同的工作,用一个值调用函数,一种方法将继续这样做,另一种方法将负责取消嵌套Monad实例。

由于这样一个函数对外部世界的使用有限,我决定使用本文介绍的实现。用flatten函数来代替它是一个很好的练习,您可以尝试解决它,以便更好地理解 monad 的工作原理。

一个简单的单子示例

假设我们需要使用read_file函数读取文件内容,然后使用 post 函数将其发送到Web 服务。我们将创建两个版本的 upload 函数,如下所示:

  • 第一个版本将使用传统函数,在出现错误时返回布尔值false
  • 函数版本将采用返回Either单子实例的 curryied 函数。我们将在下一章进一步描述这个单子;让我们假设它的工作方式与我们之前看到的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

进一步阅读

如果在完成本章后,您仍然感到有点迷茫,因为这是一个如此重要的主题,请毫不犹豫地阅读以下文章之一或您发现的其他文章:

总结

这一章当然是满嘴的,但不用担心,这是最后一章了。从现在起,我们将通过实际应用解决更多的实际问题。第 6 章现实生活中的单子将介绍我们刚刚了解的抽象概念的一些有用用途。

抽象,如函子、applicative 和 monad,是函数世界的设计模式。它们是高层抽象,可以在许多不同的地方找到,您需要一些时间才能识别它们。然而,一旦您了解了它们,您可能会意识到它们无处不在,这将极大地帮助您从操纵数据的角度进行思考。

支配我们抽象概念的法则确实很普遍。在编写代码时,您可能已经假定了它们,而没有注意到它们。在进行重构或编写算法时,能够识别我们所了解的模式会给您带来更多的信心,因为您的直觉总是怀疑的东西会得到事实的支持。

如果您想了解本章的概念,我只能建议您开始使用我们在第 3 章中介绍的functional-php库,PHP 中的函数基础。它包含许多定义各种代数结构的接口,数学家给函子、单子等起了一个奇特的名字。有些方法名称与我们使用的方法名称不完全相同,但您应该能够充分理解它们背后的思想。由于图书馆名称有点难找到,这里再次链接,https://github.com/widmogrod/php-functional