生成器是包含 yield 的函数。调用它时并不会执行函数体,而是返回一个 Generator 对象,可以用 foreach 遍历。每个 yield $value 把一个值交给循环,并让函数暂停,直到循环请求下一个值,所以值是逐个产生的,而不是先全部收集到数组里。
<?php
function countTo(int $n): Generator
{
for ($i = 1; $i <= $n; $i++) {
echo "(making $i) ";
yield $i;
}
}
foreach (countTo(3) as $number) {
echo "got $number\n";
}
输出在生成器和循环之间交替出现:每个值只有在循环请求时才会被生成。
生成器能节省内存
range(1, 1000000) 会在循环开始之前构建一个包含一百万个整数的数组。而 yield 同样这些数的生成器,任何时候都只持有其中一个。运行下面的代码,比较两种写法各自需要的内存:
<?php
function numbers(int $max): Generator
{
for ($i = 1; $i <= $max; $i++) {
yield $i;
}
}
$start = memory_get_usage();
$sum = 0;
foreach (range(1, 1_000_000) as $n) {
$sum += $n;
}
$arrayPeak = memory_get_peak_usage() - $start;
memory_reset_peak_usage();
$start = memory_get_usage();
$sum2 = 0;
foreach (numbers(1_000_000) as $n) {
$sum2 += $n;
}
$genPeak = memory_get_peak_usage() - $start;
printf("array: sum %d, extra memory %.1f MB\n", $sum, $arrayPeak / 1048576);
printf("generator: sum %d, extra memory %.1f MB\n", $sum2, $genPeak / 1048576);
两个循环输出的和相同,都是 500000500000,但数组版本报告 extra memory 16.0 MB,生成器版本是 extra memory 0.0 MB。数据越多,差距越大。正是同样的思路让生成器适合处理大文件、数据库查询结果和分页 API:处理一项,丢掉它,再取下一项。
yield 键和值
yield $key => $value 设置 foreach ($gen as $key => $value) 接收到的键。不指定键时,生成器会像列表一样从 0 开始给值编号。一个常见用法是带行号逐行读取文件:
<?php
file_put_contents('log.txt', "boot ok\nERROR disk full\nuser login\nERROR timeout\n");
function readLines(string $path): Generator
{
$handle = fopen($path, 'r');
$lineNo = 0;
while (($line = fgets($handle)) !== false) {
$lineNo++;
yield $lineNo => rtrim($line, "\n");
}
fclose($handle);
}
foreach (readLines('log.txt') as $no => $line) {
if (str_starts_with($line, 'ERROR')) {
echo "line $no: $line\n";
}
}
内存中每次只有一行,所以同一个函数也能处理 2 GB 的日志文件。这里用到的文件函数见 fopen() 和 fgets()。
无限序列和惰性管道
生成器可以无限循环,因为它只会运行到使用方请求的地方为止。可以用 break 停止,也可以把它包在另一个只取前几个值的生成器里。把小生成器串联起来,就得到一个每一步都是惰性的管道:
<?php
function fibonacci(): Generator
{
[$a, $b] = [0, 1];
while (true) {
yield $a;
[$a, $b] = [$b, $a + $b];
}
}
function take(iterable $items, int $n): Generator
{
if ($n <= 0) {
return;
}
foreach ($items as $item) {
yield $item;
if (--$n === 0) {
return;
}
}
}
function filter(iterable $items, callable $keep): Generator
{
foreach ($items as $item) {
if ($keep($item)) {
yield $item;
}
}
}
echo implode(' ', iterator_to_array(take(fibonacci(), 10), false)), "\n";
$evenFibs = take(filter(fibonacci(), fn($x) => $x % 2 === 0), 6);
echo implode(' ', iterator_to_array($evenFibs, false)), "\n";
fibonacci() 自己永远不会结束,但程序能正常结束:take() 取够数量后就不再请求,之后的值根本不会被计算。
yield from:委托给另一个生成器
yield from 会把数组、迭代器或另一个生成器的每个值依次传出去。用它可以把一个生成器拆成几个更小的生成器,或者递归遍历嵌套结构:
<?php
function flatten(array $items): Generator
{
foreach ($items as $item) {
if (is_array($item)) {
yield from flatten($item); // recurse
} else {
yield $item;
}
}
}
$nested = [1, [2, 3, [4, 5]], 6, [[7]]];
foreach (flatten($nested) as $v) {
echo $v, ' ';
}
echo "\n";
// Careful: keys from yield from can repeat
function letters(): Generator
{
yield from ['a', 'b'];
yield from ['c', 'd'];
}
print_r(iterator_to_array(letters())); // keys 0,1,0,1: values overwritten
print_r(iterator_to_array(letters(), false)); // a plain list
yield from 会保留内部的键,所以两个数组都会产生键 0 和 1。于是 iterator_to_array() 会覆盖掉 a 和 b。只要你只关心值,就把 false 作为第二个参数传入。
send() 和 getReturn()
生成器也可以接收值。$x = yield; 会暂停并等待;$gen->send($value) 让生成器继续运行,并把 $value 作为这个 yield 的结果。生成器还可以 return 一个最终值,在它结束后用 getReturn() 读取。
<?php
function runningAverage(): Generator
{
$total = 0;
$count = 0;
$average = null;
while (true) {
$value = yield $average; // send() puts a value here
if ($value === null) {
return $count; // final result
}
$total += $value;
$count++;
$average = $total / $count;
}
}
$avg = runningAverage();
$avg->current(); // run to the first yield
foreach ([10, 20, 60] as $n) {
echo "after $n: ", $avg->send($n), "\n";
}
$avg->send(null); // ends the loop
echo "values seen: ", $avg->getReturn(), "\n";
大多数代码都用不到 send();日常用法是用 foreach 惰性读取。它是协程库背后的基础构件。
生成器只能遍历一次
生成器只能向前运行,无法倒回。一旦某个 foreach 把它消费完,再次遍历同一个对象就会抛出异常:
<?php
// This throws an Exception on purpose: the generator is already finished
function colors(): Generator
{
yield 'red';
yield 'green';
}
$gen = colors();
foreach ($gen as $c) {
echo $c, "\n";
}
foreach ($gen as $c) { // second pass
echo $c, "\n";
}
解决办法是每次遍历都重新调用生成器函数,foreach (colors() as $c);如果确实需要用两次,就用 iterator_to_array(colors(), false) 一次性收集所有值。如果你发现自己经常这样做,说明这份数据更适合用普通数组。