编程

PHP: 数组上的生成器 Generator

807 2024-03-09 13:45:00

我喜欢❤️ 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() 也会关闭迭代器,因此你无法计数后遍历。或许你应该在迭代时就进行计数。

总结

很明显数组有其时间和空间。我不会使用生成器去创建一个简单的列表。但是,每当我使用对象或实体模型时,我都希望使用它们来限制内存使用。

 

PHP