四、构建函数

在前面的章节中,我们讨论了很多构建块和小型纯函数。但到目前为止,我们甚至还没有暗示如何利用这些来建造更大的东西。如果你不能使用积木,它有什么用?答案部分在于函数组合。

虽然本章完成了前一章,但该技术是任何功能程序不可或缺的重要组成部分,因此它有自己的章节。

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

  • 功能组合
  • 部分应用
  • 咖喱
  • 参数顺序重要性
  • 这些概念在现实生活中的应用

组合函数

正如函数式编程中经常出现的情况一样,函数组合的概念是从数学中借用的。如果您有两个函数,fg,您可以通过组合它们来创建第三个函数。数学中常用的符号是(fg)(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 

与组合一样,总是创建新函数很快就会变得很麻烦。但是,图书馆又一次覆盖了我们。您可以分别使用partial_leftpartial_rightpartial_any函数从函数签名的左侧、右侧或任何特定位置绑定参数。

为什么有三个功能?主要是出于性能方面的原因,因为左侧和右侧版本的执行速度要快得多,因为参数将被一次性替换,而最后一个版本将在每次调用新函数时使用占位符。

在最后一个示例中,占位符是使用函数...定义的,该函数是省略号 unicode 字符。如果您没有一种简单的方法在您的计算机上键入它,您也可以使用来自Functional名称空间的placeholder函数,该名称空间是一个别名。

咖喱

Currying常用作部分涂抹的同义词。虽然这两个概念都允许我们绑定函数的一些参数,但核心思想有点不同。

咖喱背后的思想是转换一个函数,将多个参数转换为一个函数序列,其中一个参数。因为这可能有点难理解,让我们尝试一下 currysubstr函数。结果称为一个当前函数

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

如您所见,每个调用都返回一个新函数,该函数接受下一个参数。这说明了部分应用的主要区别。当调用部分应用的函数时,将获得一个结果。但是,当您调用 curryied 函数时,您将得到一个新函数,直到您传递最后一个参数为止。此外,只能按从左开始的顺序绑定参数。

如果调用链看起来太长,您可以从 PHP7 开始大大简化它。这是因为实现了 RFC统一变量语法(参见https://wiki.php.net/rfc/uniform_variable_syntax 详情如下:

<?php 

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

咖喱的好处在这种情况下似乎并不明显。但是,一旦你开始使用高阶函数,比如mapreduce函数,这个想法就会变得非常强大。

您可能还记得functional-php库中的pluck函数。其思想是从对象集合中检索给定的属性。如果pluck函数是作为一个 curryed 函数实现的,那么它可以以多种方式使用:

<?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'))); 

如果不使用 curry,我们需要围绕一个更传统的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 中的 Currying 函数

我希望你现在相信了咖喱的力量。如果没有,我希望下面的例子可以帮助您。同时,您可能认为围绕现有的实用程序函数编写一个新的实用程序函数来创建一个新的通用版本非常麻烦,您是对的。

在 Haskell 等语言中,默认情况下所有函数都是 curryid。遗憾的是,在 PHP 中并非如此,但这个过程非常简单且重复性很强,因此我们可以编写一个 helper 函数。

由于 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函数获取所有传递的参数。这允许更自然地使用函数,也与函数语言所做的相当。我们对 currying 的定义现在更接近于一个函数,该函数返回一个新函数,直到它接收到它的所有参数

本书其余部分中的示例将假定此 curry 函数可用且随时可用。

在撰写本文时,functional-php库上有一个打开的 pull 请求来合并此功能。

参数顺序非常重要!

正如您在第一章中可能记得的,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); 
    }); 
} 

参数顺序迫使我们创建包装函数。事实上,函数是否通用都无关紧要,因为我们不能使用这个事实。

在示例 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. 使用以下方法筛选数据:
    1. 注册日期
    2. 从这里,你可以得到时间戳。
    3. 检查它是否大于给定的限制。
  2. 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函数获取的参数顺序错误,因此我们需要部分应用,而不是 curry。compare_on函数创建一个比较器,给出一个比较函数和一个“reducer”,在每个被比较的项目上调用该“reducer”。在我们的例子中,我们希望比较注册日期,因此我们重用不同的方法应用。

我们需要在过滤之前执行排序操作,因为我们的getPhonesFromDate方法只返回名称所示的电话号码。我们得到的函数本身是其他函数的通用组合,因此易于重用。

我希望这个小例子已经说服了您使用小函数作为构建块并组合它们来解决问题的能力。如果不是这样的话,也许我们将在下面几章中看到的一种更先进的技术可以为您做到这一点。

最后一点,正如您可能从示例中收集到的那样,令人遗憾的是,PHP 缺少了许多实用函数,使函数式程序员的生活变得简单。而且,即使是被广泛使用的functional-php库,也会错误地获取一些参数顺序,并且缺少一些重要的代码,比如咖喱。

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

我可以推荐的是,保留一个包含所有沿途创建的小宝石的文件,很快您将拥有自己的帮助程序编译,这些帮助程序真正符合您的需要和编码风格。这一建议可能与围绕着大型社区的可重用软件包的最佳实践背道而驰,但在有人创建正确的库之前,它会有很大帮助。谁知道呢,你可能就是那个有足够精力在功能性 PHP 生态系统中创建缺失珍珠的人。

总结

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

我们还讨论了部分应用和最强大的 curry 概念,这使我们能够轻松地创建现有函数的更专门的版本,并重写代码以提高可读性。

我们讨论了参数顺序,这是一个经常被忽略的话题,但一旦你想使用高阶函数,它就非常重要。currying 和正确的参数序列的结合允许我们减少对样板代码和包装函数的需要,这一过程有时被称为 eta 减少。

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