PHP: 数组上的生成器 Generator
我喜欢❤️ PHP 生成器(Generator)。它像高功率数组,当使用正确时,可以保留内存。自从我了解了它之后,我就一直在使用 iterable
类型提示而不是数组。
生成器是回调迭代器
生成器的是简单函数。但是,在正常函数将返回(return
)单个值或者 void
的情况下,生成器可以返回多个结果。要将函数更改为生成器,唯一需要做的就是将 return
替换为 yield
并调用它。
生成器是可迭代的(iterable
),这意味着你必须在它们上循环才能检索结果。你可以简单地在生成器上进行 foreach
,它会返回它遇到的每一个 yield
。
function exampleGenerator() {
yield 1;
yield 2;
yield 3;
}
$generator = exampleGenerator();
foreach ($generator as $value) {
echo $value;
}
// will echo out: 123
请注意,我们可以调用该函数返回生成器。本例中,很明显我们需要这样做,但也可以使用保存在 $generator
变量中的匿名函数。你能会意外地尝试对其进行迭代。
$generator = function() {
yield 1;
yield 2;
yield 3;
};
// 错误: $generator 现在是一个未调用的函数。
foreach ($generator as $value) // ...
// Correct: $generator() 现在是一个 `Generator` 对象。
foreach ($generator() as $value) // ...
生成器相对于数组的优势
虽然创建一个 yield 1,2,3 的函数是非常令人印象深刻的;这并不实用。因此,让我们来看看你可能会考虑使用生成器的一些原因。
生成器仅在开始迭代时执行
这看起来可能不是什么大不了的事,但确实是。假设你有一个 ChoiceField
对象,它有array $options
选项,并且你必须从数据库中检索这些选项。渲染字段时,显然需要显示这些选项。但是,当这些选项没有在该请求中渲染时,仍将执行数据库调用来实例化字段。
但是,如果将 array $options
更改为 iterable $options
,并通过生成器提供这些选项,则只有在对这些选项进行 foreach
时才会执行数据库调用。
$options = function() {
foreach (DB::query('retrieve the options') as $option) {
yield $option;
}
};
$field = new ChoiceField($options());
因此调用该函数将会返回生成器,但不会执行除非你开始迭代。
小技巧: 如果你已经迭代了结果集,比如
array
或者其他iterable
对象,你可以使用yield
生成$result
。从本质上讲,这将foreach
遍历所有结果,并yield
每一个结果。
// Use `yield from` instead of looping the results.
$options = (function() {
yield from DB::query('retrieve the options');
})(); // Notice we called the function directly to return the generator.
// Or shorthand
$options = (fn() => yield from DB::query('retrieve the options'))();
生成器保留内存
除了在不迭代的情况下不预先形成任何任务外,生成器一次只生成一个结果,这意味着它在内存中始终只有一个引用。
$options = (function() {
$results = DB::query('retrieve the options');
foreach ($results as $result) {
// This way there is only one `Option` in memory at all times.
yield Option::createFromResult($result);
}
unset($results);
})();
本例中我们从数据库查询检索了一个简单的结果集。只有当我们 yield
结果时,才会创建表示该结果的 Option
模型。这能节省许多内存。
代码可以在返回结果后执行
你可能已经注意到了,我们在返回结果后随意调用了 unset($results)
。这是因为生成器会保持工作除非不再生成(yield)任何结果,不像 return
语句会立即结束该函数。这个功能很棒。以这种方式,你甚至可以在生成器完成后清理一些剩余内存消耗。
可以重用键
当 yield
生成结果时,有一个隐式基于 0 的键名迭代结果。不过你也可以添加 =>
箭头生成键值。
// Without keys.
function fruits() {
yield 'apple';
yield 'banana';
yield 'peach';
}
foreach (fruits() as $key => $fruit) ... // Here key will be 0, 1, 2
// With keys.
function fruits() {
yield 'zero' => 'apple';
yield 'one' => 'banana';
yield 'two' => 'peach';
yield 'two' => 'lime';
}
foreach (fruits() as $key => $fruit) // Here $key will be 'zero',' one', 'two', 'two'
注意到我们生成了两次同样的键名了吗?与数组不同的是,迭代时这不是问题。不过,如果你想使用 iterator_to_array()
将生成器改回数组,该键名只能出现一次,保留该键名最后一次结果。
在数组上使用生成器时需要考虑的事项
虽然生成器的行为和数组很像,它也不是 array
类型。这意味着可能会遇到这些警告。
数组函数不能对生成器使用
PHP 的 array_
函数要求的是真正的数组。因此你不能对生成器简单地调用 array_map()
函数。要解决此问题,你可以使用 iterator_to_array()
将生成器转成数组。不过这将重新引入数组的内存使用。
小技巧: 你可以使用
iterator_apply
在生成的结果上执行回调,不过不推荐这么使用,因为该函数不会返回迭代器本身或者任何结果,它只会让每次迭代执行回调,不过该回调不会检索结果。你必须将该迭代器作为参数,然后才可以检索当前(current()
)的迭代。这不值得。
生成器的 count
没有预定义
由于我们可以 yield
任意多的结果,并且生成器一次在内存中只有一个引用,因此不可能在不遍历结果的情况下对结果进行计数。为了简化这个过程,你可以使用 iterator_count()
。这将遍历每个结果并返回实际计数。
一个生成器实例只能遍历一次
当生成器完成时,它自己关闭了。一旦完成,你无法再次编译。如果想再次尝试遍历,你会碰到异常:Cannot traverse an already closed generator
。
一种方案是再次调用生成器函数。不过,或许你应该重构代码避免出现这种情况。
注意: iterator_count()
也会关闭迭代器,因此你无法计数后遍历。或许你应该在迭代时就进行计数。
总结
很明显数组有其时间和空间。我不会使用生成器去创建一个简单的列表。但是,每当我使用对象或实体模型时,我都希望使用它们来限制内存使用。