kapolos / pramda
PHP实用函数式编程工具包
Requires
- php: >=5.5.0
Requires (Dev)
- phpunit/phpunit: ~4
This package is not auto-updated.
Last update: 2024-09-14 18:47:37 UTC
README
PHP实用函数式编程
附录1:自动柯里化函数
附录2:内置惰性评估
附录3:易于使用(功能丰富)
ELI5:什么是函数式编程?为什么我应该关心?
你是否曾经想过,你所做的工作可能以不同的方式完成?实际上,确实可以这样做,并且可以有多种方式——如果你喜欢更花哨的术语,那就是“范式”。其中一种范式就是函数式编程。
现在,如果你去读 Haskell 的函数式编程维基页面,你可能会觉得它非常复杂、奇怪、不必要的复杂……“东西”。这仅仅是因为关于函数式编程的文本——即使是入门级的——通常都是用干巴巴的“学术”方式写的,而大多数 PHP 程序员都不习惯这种方式。然而,实际上,函数式编程正是关于你在日常实践中已经做过的,并且推崇的事情,将其推向极致。
例如,你很可能已经使用过 Composer。包管理器如 Composer 的理念是创建独立的工件,然后你可以随意将它们组合成更大的东西。恭喜你,这正是函数式编程的核心——但与“包”不同,你处理的是函数。这样解释,就显得非常合理。如果你理解了组合包的好处,你就理解了与组合函数一起工作的好处。
就像创建和使用 Composer 包时需要遵循一些约定一样,处理函数时也需要遵循一些约定。好消息是,这就像骑自行车一样简单。你还记得刚开始有多难吗?而没过多久就变得很自然了吗?如果能在第一次跌倒后坚持下去,函数式编程也是如此。一开始,事情会感觉与你习惯的方式相比不自然。后来,你会意识到,就像自行车一样,函数式编程可以既高效又有趣。就像你知道不是所有情况下使用自行车都是最佳选择一样,你也会知道何时从工具箱中挑选函数式编程来打造你的杰作。
如果你想要我阅读下面的代码示例,请告诉我
任务
从一个文本文件中,确定使用频率最高的n个单词,并打印出这些单词及其频率的排序列表。
实现
$text = P::file('textfile.txt'); // Lazy read line by line with generators $onlyWords = function ($txt) { return preg_split("/[^A-Za-z]/", $txt, NULL, PREG_SPLIT_NO_EMPTY); }; // Here we go... composing simple functions into what we need $wordsPerLine = P::map(P::compose($onlyWords, P::unary('strtolower'))); $getFreq = P::countBy('P::identity'); $sortDesc = P::sort('P::negate'); $getWordsFrequencyDesc = P::compose( $sortDesc, $getFreq, 'P::flatten', $wordsPerLine ); $topFiveFreq = P::compose('P::toArray', P::take(5), $getWordsFrequencyDesc); // Just the top 5, for fun $printEm = P::each(function($value, $key) { echo $key . ": " . $value . "\n"; }); // And now, we apply to the data $results = $printEm($topFiveFreq($text));
这在幕后使用了惰性评估——这通常只需要在急切实现(通常是 P::map
、 P::flatten
& P::toArray
)上添加一层薄薄的模板。
在以《爱丽丝梦游仙境》(3700行)作为输入的快速测试中, memory_get_peak_usage
为 1 MB,而急切版本接近 6 MB。不错,而且不需要手动使用生成器来获得这些好处。
注意
那个包裹着 P::unary
的奇怪操作符 strtolower
?这是因为 P::map
总是带着两个参数(值,键)调用提供的函数。原生PHP函数期望1个参数时不会忽略额外的参数,因此我们需要在闭包中包裹它们,并且只传递一个参数给它们。P::unary
接收一个函数和任意数量的参数,并且只传递第一个参数给它。
理解函数式编程的乐趣与收获
我们将深入浅出地回顾函数式编程的概念。Pramda存在是为了让我们能够轻松地利用所有这些概念,而不需要冗长和样板代码的成本。但是,你仍然需要理解正在发生的事情。
组合
如果你对 (gof)(x) = g(f(x))
的理解不仅仅是ASCII IRC艺术,那么你在学校时一定很注意数学老师。你不需要了解数学意义上的组合来编写函数式编程。只需记住,下面你所读到的内容可以用上述关系简洁地表达。
假设我们有
function getTweets($username) { // returns array of tweets } function sortByDateAsc($list) { // returns sorted array }
然后我们可以这样做
$myTweets = getTweets('kapolos'); $mySortedTweets = sortByDateAsc($myTweets);
或者我们本来可以这样做的
$mySortedTweets = sortByDateAsc(getTweets('kapolos'));
我们不会做的是
// Function that generates a function that gets and sorts tweets $sortedTweetsWrapper = function() { return function($username) { return sortByDateAsc(getTweets($username)); }; }; $getSortedTweets = $sortedTweetsWrapper(); $mySortedTweets = $getSortedTweets('kapolos');
除了影响视觉效果外,我们关心的区别在于在这个代码片段中,$getSortedTweets
是通过调用一些奇怪的“包装”函数创建的函数。 $getSortedTweets
是一个与 sortByDateAsc(getTweets($username))
(这不是一个函数,而是一系列的“步骤”)完全相同的函数。
那么,这意味着什么呢?
因为它是一个函数,我们可以让它更通用
// Function that generates a function based on two other functions for our example $compose = function($sort, $fetch) { return function($username) use ($sort, $fetch) { return $sort($fetch($username)); }; }; $getSortedTweets = $compose('sortByDateAsc', 'getTweets'); $mySortedTweets = $getSortedTweets('kapolos');
它仍然做同样的事情,但现在我们可以将获取和排序函数作为参数传递。所有这些杂技都是为了这个原因。为了有一个函数,我们可以传递其他函数,然后它将为我们创建一个可以稍后使用的函数。
换句话说,我们刚刚描述了我们将如何将函数连接起来的方式。以这种方式(创建一个返回新函数的函数)将允许我们后来做一些酷的事情。
使用Pramda
$getSortedTweets = P::compose('sortByDateAsc', 'getTweets'); $mySortedTweets = $getSortedTweets('kapolos');
注意组合中的执行顺序是从右到左的。如果你更喜欢从左到右的组合,可以使用 P::pipe
代替。
映射,而不是迭代
是时候忘记 for
、foreach
、while
以及所有那些通过列表循环的万种方法了。相反,你应该这样想:“我可以应用什么函数来处理这个值集合,从而得到一个新的集合,它将像X”?是的,该函数仍然会逐个对每个项目进行转换。
而不是
你将这样做
$before = [1,2,3,4,5]; $after = []; foreach ($before as $num) { $after[] = $num * 2; }
或者
$before = [1,2,3,4,5]; $after = P::map(function($num) { return $num * 2; }, $before); /* P::toArray($after) //=> [2,4,6,8,10] */
所以,再次强调——为什么?答案(再次)是一样的。通过避免直接迭代,我们将使用函数,正如我们上面所看到的,我们可以将它们组合起来。太棒了!
$before = [1,2,3,4,5]; $doubleNum = function($num) { return $num * 2; }; $doubleAllNumbers = P::map($doubleNum); // Auto-currying ftw (see later on) $after = $doubleAllNumbers($before); /* P::toArray($after) //=> [2,4,6,8,10] */
注意:并不是所有的迭代都可以用 map/reduce
替换,但所有迭代都可以用递归替换。虽然PHP不支持尾递归,并且当达到深度100时,堆栈会被吹掉,但我们可以使用trampoline技术来避免在每次调用时使用堆栈。Pramda包含一个 trampoline
函数来帮助你这样做。
注:1:100 当启用XDebug时,否则确切数字取决于——手册建议不要做“100-200层递归”。
尾递归
Currying
Currying很酷。自动Currying超级酷。但是,它是怎么回事呢?
考虑这个简单的函数
$add = function ($a, $b) { return $a + $b; };
如果我们想使用上面的 add
函数创建一个增加其参数1的函数,我们会这样做
$inc = function($value) use ($add){ return $add($value, 1); }; /* $inc(2); //=> 3 */
如果 add
是一个curried函数,我们可以这样做
$inc = $add(1); /* $inc(2); //=> 3 */
换句话说,柯里化函数可以逐步执行。在上面的例子中,$add(1)
是一个接受一个参数的新函数。一旦它被调用,它将“1”和最后一个传入的值应用到原始的 $add
函数上。
默认情况下,P
函数是柯里化的(除非这样做没有意义,或者它们被明确标记为非柯里化)。
$inc = P::add(1); // P::add is an existing function
您还可以手动柯里化您的闭包。在我们的例子中,要获取 add
的柯里化版本,我们将有
$add = function ($a, $b) { return $a + $b; }; $curriedAdd = P::curry2($add); $inc = $curriedAdd(1); $inc(2); //=> 3
类似地,对于一个接受 3 个参数的函数
$add3 = function ($a, $b, $c) { return $a + $b + $c; }; $curriedAdd3 = P::curry3($add3); // One $add1 = $curriedAdd3(1); $add1(2,3); //=> 6 // Two $add1then2 = $curriedAdd3(1, 2); $add1then2(3); //=> 6 // Two again $add1then2 = $add1(2); $add1then2(3); //=> 6
柯里化与组合的结合使得 Pramda 优雅且易于使用。
不可变数据 & 无副作用
为了防止代码中出现任何意外,您需要记住以下指南
- 函数不应修改传递给它的数据
- 函数不应修改其作用域之外的变量
换句话说,不要通过引用传递值,也不要使用 global
。
人为的大 禁止 例子
$counter = 3; $number = 2; function toTheEighth(&$number) { global $counter; while ($counter-- > 0) { $number *= $number; } return $number; } /* toTheEighth($number) //=> 256 $number //=> 256 $counter //=> 0 */
惰性求值
当您需要加载小型/中型文本文件时,您可能使用 file
,它将整个文本加载到一个数组中。当您需要处理大型文件时,这种方法不起作用,您可以使用具有 fgets
的替代实现来逐行读取文件内容,以避免超过内存限制。
惰性求值是关于做同样的事情,但是使用实现了 Iterator
接口的对象,而不是 IO。
惰性求值
- 仅当需要时才使用列表的一部分。
- 每次当另一个函数在循环中请求列表时,只提供列表中的一个项目。
自 v5.5 版本以来,PHP 通过 Generator
原语支持惰性求值。
贪婪
$double = function($arr) { $out = []; foreach ($arr as $item) { $out[] = $item * 2; } return $out; } // $double([1,2,3]); /=> [2,4,6]
贪婪函数将在对其执行任何工作之前复制整个数组,然后它将计算一个新的数组并将该数组作为结果返回。
惰性
$double = function($arr) { foreach($arr as $item) { yield $item * 2; } }; $list = $double([1,2,3]); foreach($list as $item) { echo $item.' '; } //=> 2 4 6
惰性函数每次只需要数组中的一个项目,并以生成器值的返回结果。当我们(通常在经过一系列转换的末尾)需要使用实际值时,我们遍历生成器并获取每个项目的值。
生成器的一个巨大好处是内存使用量较低。由于不需要在每次函数调用时复制数组,所以将占用更少的内存。鉴于函数式编程全部关于将不可变数据传递给函数,惰性求值是一个巨大的胜利。
Pramda
名字的含义是什么?
我选择将这个库命名为 Pramda,原因有两个
它听起来像“实用”和“Lambda”的组合
“听起来”,因为“m”和“d”之间有一个讨厌的“b”。Pramda 就不合适了。Pabda 就很一般。Pbda 呢……好吧,那根本不行。有趣的是,希腊字母 λ
的名字是 λάμδα
- 没有那个“b”。
对 Ramda.js 的致敬
Pramda 开始是我的愿望,希望将 Ramda.js 从 JavaScript 世界带到 PHP 领域。在我看来,Ramda.js 在实用性和纯度/传统之间采取了非常平衡的方法,这使得库的使用变得很有趣。
明显的差异包括
- Pramda 在可能的情况下支持惰性求值。效率和内存使用是主要关注点。
- Pramda 不会移植/遵循 Fantasy-land 规范。
- 不是一对一移植有两个原因。首先,有些东西是 JavaScript 特有的,在 PHP 库中没有位置。此外,一些函数如果以不同的方式实现比 Ramda.js 更适合 PHP 使用。
- Prامدا的目标受众与Ramda.js的目标受众有不同的需求。因此,开发必须反映这一点。
用法
您在全局命名空间中获得了P
类。所有函数都是静态的,例如P::add
。
通过Composer
将以下内容添加到您的require
部分:"kapolos/pramda": "0.9.*@dev"
手动
require ('src/pramda.php');
兼容性
已在PHP 5.6上测试。我避免使用...args
,因此它也应该与5.5兼容,但我没有进行测试(待办)。
测试
Pramda使用备受推崇的PHPUnit进行测试。
# vendor\bin\phpunit.bat --debug
PHPUnit 4.8.21 by Sebastian Bergmann and contributors.
................................................................ 64 / 79 ( 81%)
...............
Time: 557 ms, Memory: 3.75Mb
OK (79 tests, 244 assertions)
示例
$planets = [ [ "name" => "Earth", "order" => 3, "has" => ["moon", "oreos"], "contact" => [ "name" => "Bob Spongebob", "email" => "bob@spongebob.earth" ] ], [ "name" => "Mars", "order" => 4, "has" => ["aliens", "rover"], "contact" => [ "name" => "Marvin Martian", "email" => "marvin@the.mars" ] ], [ "name" => "Venus", "order" => 2, "has" => ["golden apple"], // https://en.wikipedia.org/wiki/Golden_apple#The_Judgement_of_Paris "contact" => [ "name" => "Aphro Dite", "email" => "aphrodite@gods.venus" ] ], [ "name" => "Mercury", "order" => 1, "has" => [], "contact" => [ "name" => "Buzz Off", "email" => "no-reply@flames.mercury" ] ], ];
联系人是谁?
// Functions $nameOfContact = P::compose(P::prop('name'), P::prop('contact')); $getContactNames = P::map($nameOfContact); // Application $contacts = $getContactNames($planets); // Returns a generator P::toArray($contacts); //=> ["Bob Spongebob", "Marvin Martian", "Aphro Dite", "Buzz Off"]
基于行星的从小到大的顺序,联系人是谁?
// Functions (cont'd) $sortByOrderAsc = P::sort(P::prop('order')); // Returns an array (sort is eager) // Application $contacts = $getContactNames($sortByOrderAsc($planets)); // Returns a generator P::toArray($contacts)); //=> ["Buzz Off", "Aphro Dite", "Bob Spongebob", "Marvin Martian"]
按逆字母顺序排列的联系人是谁?
// Function (cont'd) $alphaDesc = P::compose('P::negate', 'ord'); $sortByAlphaDesc = P::sort($alphaDesc); // Application $contacts = P::apply(P::compose($sortByAlphaDesc, $getContactNames), [$planets]); // or equivalently $contacts = P::apply(P::compose($sortByAlphaDesc, $getContactNames), P::of($planets)); // or equivalently $contacts = $sortByAlphaDesc($getContactNames($planets)); //=> ["Marvin Martian", "Bob Spongebob", "Buzz Off", "Aphro Dite"]
但是等等,我的意思是按姓氏排序,而不是全名
// Functions (cont'd) $lastname = P::compose('P::takeLast', P::split(' ')); $lastnameAlphaDesc = P::compose($alphaDesc, $lastname); $sortByLastnameAlphaDesc = P::sort($lastnameAlphaDesc); // Application $contacts = $sortByLastnameAlphaDesc($getContactNames($planets)); //=> ["Bob Spongebob", "Buzz Off", "Marvin Martian", "Aphro Dite"]
Elvis在太阳系中吗?
$hasElvis = P::compose(P::contains('Elvis'), P::prop('has')); P::contains(TRUE, P::map($hasElvis, $planets)); //=> false
函数列表
详细的文档将在下面介绍。目前,请参阅以下列表以及源代码中的文档块。此外,您还可以在单元测试中查看如何使用每个函数的示例。
说明:
Yes
明确支持No
明确不支持-
该组合可能没有太多意义或其它原因。例如,converge
只处理闭包,因此数据评估不适用。Kinda
您应该阅读相关的说明。
说明
countBy
不是惰性的,因为它返回一个array
而不是生成器但是它也是惰性的,因为它不会首先将输入转换为数组,因此它不会在处理大量输入时耗尽内存。但这假设输入在处理后的结果不会很大。- 为了使
flip
与柯里化函数一起工作,您必须将其arity作为第二个参数传递,否则它将无法正确检测它 - 示例:$appendTo = P::flip('P::append', 2);
。通常,即使对于非柯里化函数,您也应该更喜欢指定arity,因为arity检测是通过Reflection
进行的,这在速度上是有问题的。 - 返回一个生成器。
- 它在将生成器转换为数组时是急切的。
版本说明
初始发布版本是0.9.0。它将保持小于1.0,直到从使用中获得足够的反馈以允许最终确定API。因此,请期待一些变化。
更多?
如果您在阅读此内容,您可能对在您的工作中调查Pramda的使用感兴趣。我将在我的网站上发布更新、示例和其他有用的资源。将自己添加到通知系统中,这不到9.73秒。