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 之前的代码要等到第一次迭代才执行。 这带来两个后果:

  1. 生成器函数里的参数校验,在创建对象时不生效,要到迭代才生效。所以在返回生成器之前先做校验。
  2. 抛出异常的位置会变,调试时注意。

生成器是一次性的

<?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) {
    // 处理一千万用户也不会爆内存
}

调用方看起来像"一次拿到了所有用户",实际是分批拉的。这是生成器最能改变工程结构的地方——把"流"伪装成"集合"。

坑清单

  1. 生成器只能遍历一次,重复 foreach 会抛异常。
  2. yield 之前的代码要等第一次迭代才执行,参数校验别放在里面。
  3. 生成器不能 clone / serialize。
  4. send() 在首次迭代前送的值会丢。
  5. yield $k => $v 后过滤,键可能不连续,输出 JSON 要处理。
  6. 生成器里 return 的值要靠 getReturn() 或 yield from 拿,foreach 拿不到。
  7. 用 Iterator 手写时 valid() 实现错,foreach 会死循环或漏最后一项。
  8. 生成器函数返回的是对象,count() 用不了,需要 iterator_count()(注意它会耗掉迭代)。