kapolos/pramda

PHP实用函数式编程工具包

dev-master 2017-04-17 10:21 UTC

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::mapP::flatten & P::toArray)上添加一层薄薄的模板。

在以《爱丽丝梦游仙境》(3700行)作为输入的快速测试中, memory_get_peak_usage1 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 代替。

映射,而不是迭代

是时候忘记 forforeachwhile 以及所有那些通过列表循环的万种方法了。相反,你应该这样想:“我可以应用什么函数来处理这个值集合,从而得到一个新的集合,它将像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 您应该阅读相关的说明。

说明

  1. countBy不是惰性的,因为它返回一个array而不是生成器但是它也是惰性的,因为它不会首先将输入转换为数组,因此它不会在处理大量输入时耗尽内存。但这假设输入在处理后的结果不会很大。
  2. 为了使flip与柯里化函数一起工作,您必须将其arity作为第二个参数传递,否则它将无法正确检测它 - 示例:$appendTo = P::flip('P::append', 2);。通常,即使对于非柯里化函数,您也应该更喜欢指定arity,因为arity检测是通过Reflection进行的,这在速度上是有问题的。
  3. 返回一个生成器。
  4. 它在将生成器转换为数组时是急切的。

版本说明

初始发布版本是0.9.0。它将保持小于1.0,直到从使用中获得足够的反馈以允许最终确定API。因此,请期待一些变化。

更多?

如果您在阅读此内容,您可能对在您的工作中调查Pramda的使用感兴趣。我将在我的网站上发布更新、示例和其他有用的资源。将自己添加到通知系统中,这不到9.73秒。