PHP 语法进阶 12|生成器与迭代器:让 2GB 文件不再撑爆内存
问题的样子
<?php
$lines = file("access.log"); // 一次性全读进内存
foreach ($lines as $line) {
parse($line);
}
file() 返回整个文件内容组成的数组。一个 2GB 的日志文件,在 PHP 里会膨胀到远超 2GB(每行都是一个 zval 加 bucket),memory_limit 直接报 Allowed memory size exhausted。
换成流式读取就解决了:
<?php
function readLines(string $path): Generator
{
$fp = fopen($path, 'r');
if ($fp === false) {
throw new RuntimeException("打不开:{$path}");
}
try {
while (($line = fgets($fp)) !== false) {
yield $line;
}
} finally {
fclose($fp); // 无论正常结束还是异常中断,都会执行
}
}
foreach (readLines("access.log") as $line) {
parse($line);
}
无论文件多大,内存里始终只有一行。这就是生成器的全部价值。
yield 到底是什么
yield 让函数变成一个"可以暂停和恢复"的东西。函数从 function 变成"生成器工厂",调用它不执行函数体,而是返回一个 Generator 对象;每次迭代推进一次,遇到 yield 就交出一个值并暂停。
<?php
function countdown(int $n): Generator
{
echo "开始\n";
while ($n > 0) {
yield $n;
$n--;
}
echo "结束\n";
}
$g = countdown(3); // 这时什么都没打印!
foreach ($g as $v) {
echo "拿到 {$v}\n";
}
输出顺序:
开始
拿到 3
拿到 2
拿到 1
结束
关键认知:yield 之前的代码要等到第一次迭代才执行。 这带来两个后果:
- 生成器函数里的参数校验,在创建对象时不生效,要到迭代才生效。所以在返回生成器之前先做校验。
- 抛出异常的位置会变,调试时注意。
生成器是一次性的
<?php
$g = countdown(3);
foreach ($g as $v) { } // 走完了
foreach ($g as $v) { } // ✗ Exception: Cannot traverse an already closed generator
想重复遍历,两个办法:重新调用函数生成新实例,或者把结果存进数组(但那就失去省内存的意义了)。
另外,生成器不能 clone、不能序列化。
内存差距有多大
<?php
// 方式一:数组
function numbersArray(int $n): array {
$out = [];
for ($i = 1; $i <= $n; $i++) $out[] = $i;
return $out;
}
// 方式二:生成器
function numbersGen(int $n): Generator {
for ($i = 1; $i <= $n; $i++) yield $i;
}
$m0 = memory_get_usage();
foreach (numbersArray(1_000_000) as $v) {}
printf("数组: %.1f MB\n", (memory_get_usage() - $m0) / 1048576);
$m1 = memory_get_usage();
foreach (numbersGen(1_000_000) as $v) {}
printf("生成器: %.3f MB\n", (memory_get_usage() - $m1) / 1048576);
量级上:数组要几十 MB,生成器基本在零点几 MB(只多了一个 Generator 对象)。
send():把值送回去
生成器是双向的,yield 表达式本身有返回值,可以用 send() 注入:
<?php
function accumulator(): Generator
{
$total = 0;
while (true) {
$add = yield $total; // 交出当前总额,并接收外部送进的数
if ($add === null) {
return;
}
$total += $add;
}
}
$acc = accumulator();
$acc->current(); // 让生成器跑到第一个 yield
$acc->send(10); // 加上 10
$acc->send(20); // 再加上 20
echo $acc->current(); // 30
注意第一次 current() 是为了"启动"生成器——在第一次迭代之前,send() 送进去的值会被丢弃。
send() 在真实项目里用得不多,更多见于协程实现(Swoole、ReactPHP 的早期版本)。日常开发记住"能 foreach 就 foreach"就够了。
yield from:委托给另一个生成器
<?php
function inner(): Generator {
yield 1;
yield 2;
return "inner-return"; // 生成器可以 return 一个值
}
function outer(): Generator {
$r = yield from inner(); // 转发所有 yield
yield $r; // "inner-return"
yield 3;
}
foreach (outer() as $v) echo $v, PHP_EOL;
// 1 2 inner-return 3
yield from 的价值在于组合。比如把多个数据源拼成一个流:
<?php
function mergeSources(array $files): Generator
{
foreach ($files as $f) {
yield from readLines($f); // 逐个文件转发,内存不叠加
}
}
生成器做管道:把处理步骤串起来
<?php
function gen_yield(array $items): Generator {
foreach ($items as $i) yield $i;
}
function gen_map(iterable $src, callable $fn): Generator {
foreach ($src as $k => $v) yield $k => $fn($v);
}
function gen_filter(iterable $src, callable $pred): Generator {
foreach ($src as $k => $v) {
if ($pred($v)) yield $k => $v;
}
}
function gen_take(iterable $src, int $n): Generator {
$i = 0;
foreach ($src as $k => $v) {
if ($i++ >= $n) return;
yield $k => $v;
}
}
// 组合成一个"惰性管道":不产生中间数组
$pipeline = gen_take(
gen_filter(
gen_map(gen_yield(range(1, 100)), fn($x) => $x * 2),
fn($x) => $x % 3 === 0
),
5
);
foreach ($pipeline as $v) echo $v, ' '; // 6 12 18 24 30
这套写法的意义是:每一步都是惰性的,数据不会在中间步骤被物化成数组。处理几十万条记录时,内存曲线是平的一条线,而不是阶梯状。
注意 yield $k => $v 保留了键,所以 gen_filter 之后的键可能不连续——要输出 JSON 还得 array_values 或改用 yield $v。
手写 Iterator:比生成器重,但能表达更多
生成器只能表达"顺序产出",如果需要 rewind()(回到开头)、count()、或者把状态暴露出去,就得实现接口。
<?php
class CsvFile implements Iterator
{
private ?SplFileObject $file = null;
private int $line = -1;
private array $row = [];
public function __construct(private string $path, private string $sep = ",") {}
public function rewind(): void
{
$this->file = new SplFileObject($this->path, 'r');
$this->file->setFlags(SplFileObject::READ_CSV | SplFileObject::SKIP_EMPTY);
$this->file->setCsvControl($this->sep);
$this->line = -1;
$this->next();
}
public function current(): array { return $this->row; }
public function key(): int { return $this->line; }
public function next(): void
{
$this->row = $this->file->fgetcsv() ?: [];
$this->line++;
}
public function valid(): bool
{
return $this->row !== [] && $this->file->valid();
}
}
foreach (new CsvFile("data.csv") as $i => $row) {
echo $i, ": ", implode(' | ', $row), PHP_EOL;
}
Iterator 的五个方法要严格按协议实现:rewind() 重置到开头,valid() 判断当前位置是否有效,current()/key() 取值,next() 前进。
顺序很重要:foreach 的执行顺序是 rewind → valid → current/key → 循环体 → next → valid → ...。valid() 返回 false 就结束。
比 Iterator 更省事的做法是 IteratorAggregate:只需要实现 getIterator(),把迭代逻辑交给内部的生成器或数组:
<?php
class Users implements IteratorAggregate
{
public function __construct(private array $rows) {}
public function getIterator(): Generator
{
foreach ($this->rows as $r) {
yield $r['id'] => $r;
}
}
}
foreach (new Users([['id' => 1, 'name' => '张三']]) as $id => $u) {
echo $id, $u['name'];
}
SPL 里现成能用的迭代器
不用自己写的几个:
<?php
// 目录递归遍历(找所有 .log 文件)
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator("/var/log", FilesystemIterator::SKIP_DOTS)
);
foreach ($it as $file) {
if ($file->getExtension() === "log") {
echo $file->getPathname(), PHP_EOL;
}
}
// 限制数量
$limited = new LimitIterator(new ArrayIterator(range(1, 100)), 10, 5);
foreach ($limited as $v) echo $v; // 11~15
// 过滤
$filter = new CallbackFilterIterator(
new ArrayIterator([1, 2, 3, 4]),
fn($v) => $v % 2 === 0
);
RecursiveIteratorIterator + RecursiveDirectoryIterator 是处理文件目录的标准组合,比自己写递归安全(软链接死循环的情况它也能处理)。
分页拉取的生成器写法
一个很实用的模式:用生成器把"分页请求"包装成一个连续的流。
<?php
function fetchAllUsers(PDO $pdo, int $pageSize = 500): Generator
{
$offset = 0;
while (true) {
$st = $pdo->prepare("SELECT id, name FROM users ORDER BY id LIMIT :lim OFFSET :off");
$st->bindValue(':lim', $pageSize, PDO::PARAM_INT);
$st->bindValue(':off', $offset, PDO::PARAM_INT);
$st->execute();
$rows = $st->fetchAll(PDO::FETCH_ASSOC);
if (!$rows) return;
foreach ($rows as $r) yield $r;
$offset += $pageSize;
}
}
foreach (fetchAllUsers($pdo) as $u) {
// 处理一千万用户也不会爆内存
}
调用方看起来像"一次拿到了所有用户",实际是分批拉的。这是生成器最能改变工程结构的地方——把"流"伪装成"集合"。
坑清单
- 生成器只能遍历一次,重复
foreach会抛异常。 yield之前的代码要等第一次迭代才执行,参数校验别放在里面。- 生成器不能
clone/serialize。 send()在首次迭代前送的值会丢。yield $k => $v后过滤,键可能不连续,输出 JSON 要处理。- 生成器里
return的值要靠getReturn()或yield from拿,foreach拿不到。 - 用
Iterator手写时valid()实现错,foreach会死循环或漏最后一项。 - 生成器函数返回的是对象,
count()用不了,需要iterator_count()(注意它会耗掉迭代)。