PHP 제너레이터

PHP 제너레이터 (Generators)

(PHP 5 >= 5.5.0, PHP 7, PHP 8)

제너레이터 개요 (Generators overview)

제너레이터(generator)는 간단한 이터레이터를 아주 쉽게 만들 수 있게 해주는 방법이에요. Iterator 인터페이스를 구현하는 클래스를 처음부터 작성하는 번거로움이나 복잡함을 겪지 않아도 되죠.

제너레이터는 foreach 루프에 데이터를 공급할 때 편리한 방법이에요. 미리 배열을 통째로 메모리에 만들어 두지 않아도 되거든요. 배열을 미리 만들려면 메모리 한도를 넘어버리거나, 값을 만드는 데 상당한 처리 시간이 걸릴 수 있어요. 대신 제너레이터 함수를 쓰면 되는데, 제너레이터 함수는 일반 함수와 똑같아요. 다만 값을 한 번 return 하는 대신, 순회할 값들을 필요할 때마다 마음껏 yield 할 수 있다는 점만 달라요. 이터레이터와 마찬가지로 임의 접근(random access)은 불가능해요.

간단한 예시로 range() 함수를 제너레이터로 다시 구현해 볼게요. 표준 range() 함수는 모든 값을 담은 배열을 만들어서 반환해야 하기 때문에, 값이 많아지면 배열이 커질 수밖에 없어요. 예를 들어 range(0, 1000000)을 호출하면 메모리를 10MB 넘게 사용하게 되죠.

대안으로 xrange() 제너레이터를 구현할 수 있는데, 이쪽은 Iterator 객체를 만들고 내부적으로 제너레이터의 현재 상태만 추적하면 되므로 1킬로바이트도 안 되는 메모리만 쓰면 돼요.

예제 #1 — range()를 제너레이터로 구현하기

<?php
function xrange($start, $limit, $step = 1) {
    if ($start <= $limit) {
        if ($step <= 0) {
            throw new LogicException('Step must be positive');
        }

        for ($i = $start; $i <= $limit; $i += $step) {
            yield $i;
        }
    } else {
        if ($step >= 0) {
            throw new LogicException('Step must be negative');
        }

        for ($i = $start; $i >= $limit; $i += $step) {
            yield $i;
        }
    }
}

/*
 * Note that both range() and xrange() result in the same
 * output below.
 */

echo 'Single digit odd numbers from range():  ';
foreach (range(1, 9, 2) as $number) {
    echo "$number ";
}
echo "\n";

echo 'Single digit odd numbers from xrange(): ';
foreach (xrange(1, 9, 2) as $number) {
    echo "$number ";
}

위 예제는 다음과 같이 출력돼요.

Single digit odd numbers from range():  1 3 5 7 9
Single digit odd numbers from xrange(): 1 3 5 7 9

제너레이터 객체 (Generator objects)

제너레이터 함수를 호출하면 내부 Generator 클래스의 새 객체가 반환돼요. 이 객체는 전방향 이터레이터(forward-only iterator)처럼 Iterator 인터페이스를 구현하는데, 제너레이터에 값을 보내거나 값을 받아오는 등 상태를 조작하는 메서드들도 함께 제공해요.

제너레이터 문법 (Generator syntax)

제너레이터 함수는 일반 함수처럼 생겼어요. 다만 값을 return 하는 대신 필요할 때마다 값을 yield 한다는 점만 달라요. yield를 포함하는 함수는 전부 제너레이터 함수가 돼요.

제너레이터 함수를 호출하면 순회할 수 있는 객체가 반환돼요. 그 객체를 순회할 때(예를 들어 foreach 루프로), PHP는 값이 필요할 때마다 객체의 순회 메서드를 호출하고, 제너레이터가 yield로 값을 내놓는 순간 그 상태를 저장해요. 그래서 다음 값이 필요할 때 이어서 재개(resume)할 수 있죠.

더 이상 내놓을 값이 없으면 제너레이터는 그냥 return 하면 돼요. 그러면 호출한 쪽 코드는 마치 배열에 값이 다 떨어진 것처럼 자연스럽게 이어서 진행돼요.

참고: 제너레이터는 반환값을 가질 수 있는데, Generator::getReturn()으로 꺼내 쓸 수 있어요.

yield 키워드

제너레이터 함수의 핵심은 yield 키워드예요. 가장 단순한 형태의 yield 문은 return 문과 비슷해 보이는데, 핵심 차이가 있어요. return은 함수 실행을 멈추고 값을 돌려주지만, yield는 제너레이터를 순회하는 코드에 값을 건네주고 함수 실행을 잠시 멈추기만 해요.

예제 #1 — 값을 yield 하는 간단한 예시

<?php
function gen_one_to_three() {
    for ($i = 1; $i <= 3; $i++) {
        // Note that $i is preserved between yields.
        yield $i;
    }
}

$generator = gen_one_to_three();
foreach ($generator as $value) {
    echo "$value\n";
}

위 예제는 다음과 같이 출력돼요.

1
2
3

참고: 내부적으로 yield 된 값에는 비연관 배열처럼 순차적인 정수 키가 자동으로 붙어요.

키와 함께 값 yield 하기

PHP는 연관 배열도 지원하고, 제너레이터도 마찬가지예요. 위처럼 단순한 값만 yield 할 수 있는 게 아니라, 키와 값을 동시에 yield 할 수도 있어요.

키/값 쌍을 yield 하는 문법은 연관 배열을 정의할 때 쓰는 문법과 아주 비슷해요.

예제 #2 — 키/값 쌍 yield 하기

<?php
/*
 * The input is semi-colon separated fields, with the first
 * field being an ID to use as a key.
 */

$input = <<<'EOF'
1;PHP;Likes dollar signs
2;Python;Likes whitespace
3;Ruby;Likes blocks
EOF;

function input_parser($input) {
    foreach (explode("\n", $input) as $line) {
        $fields = explode(';', $line);
        $id = array_shift($fields);

        yield $id => $fields;
    }
}

foreach (input_parser($input) as $id => $fields) {
    echo "$id:\n";
    echo "    $fields[0]\n";
    echo "    $fields[1]\n";
}

위 예제는 다음과 같이 출력돼요.

1:
    PHP
    Likes dollar signs
2:
    Python
    Likes whitespace
3:
    Ruby
    Likes blocks

null 값 yield 하기

인자 없이 yield를 호출하면 자동 키와 함께 null 값을 yield 할 수 있어요.

예제 #3 — null 값 yield 하기

<?php
function gen_three_nulls() {
    foreach (range(1, 3) as $i) {
        yield;
    }
}

var_dump(iterator_to_array(gen_three_nulls()));

위 예제는 다음과 같이 출력돼요.

array(3) {
  [0]=>
  NULL
  [1]=>
  NULL
  [2]=>
  NULL
}

참조로 yield 하기

제너레이터 함수는 값으로도, 참조로도 yield 할 수 있어요. 함수에서 참조를 반환(return)하는 것과 같은 방식으로, 함수 이름 앞에 앰퍼샌드(&)를 붙이면 돼요.

예제 #4 — 참조로 값 yield 하기

<?php
function &gen_reference() {
    $value = 3;

    while ($value > 0) {
        yield $value;
    }
}

/*
 * Note that we can change $number within the loop, and
 * because the generator is yielding references, $value
 * within gen_reference() changes.
 */
foreach (gen_reference() as &$number) {
    echo (--$number).'... ';
}

위 예제는 다음과 같이 출력돼요.

2... 1... 0...

yield from 을 통한 제너레이터 위임 (Generator delegation)

yield from 키워드를 쓰면 다른 제너레이터, Traversable 객체, 또는 배열에서 값을 yield 해올 수 있어요. 바깥 제너레이터는 안쪽 제너레이터·객체·배열의 모든 값을 끝까지 yield 하고, 더 이상 유효하지 않게 되면 바깥 제너레이터의 실행을 이어서 진행해요.

제너레이터에 yield from을 쓰면, yield from 표현식은 안쪽 제너레이터가 return 한 값도 함께 돌려줘요.

주의: yield from은 키를 초기화하지 않아요. Traversable 객체나 배열이 돌려준 키를 그대로 보존하죠. 그래서 어떤 값들은 다른 yieldyield from과 같은 키를 공유할 수 있는데, 배열에 넣을 때 그 키에 해당하는 기존 값을 덮어써 버릴 수 있어요.

이게 문제가 되는 대표적인 경우가 iterator_to_array()예요. 기본적으로 키가 있는 배열을 반환하기 때문에 예상 밖의 결과가 나올 수 있죠. iterator_to_array()에는 두 번째 파라미터 preserve_keys가 있는데, false로 설정하면 제너레이터가 돌려준 키는 무시하고 모든 값을 모아줘요.

예제 #5 — iterator_to_array()와 함께 쓴 yield from

<?php
function inner() {
    yield 1; // key 0
    yield 2; // key 1
    yield 3; // key 2
}
function gen() {
    yield 0; // key 0
    yield from inner(); // keys 0-2
    yield 4; // key 1
}
// pass false as second parameter to get an array [0, 1, 2, 3, 4]
var_dump(iterator_to_array(gen()));

위 예제는 다음과 같이 출력돼요.

array(3) {
  [0]=>
  int(1)
  [1]=>
  int(4)
  [2]=>
  int(3)
}

예제 #6 — yield from의 기본 사용법

<?php
function count_to_ten() {
    yield 1;
    yield 2;
    yield from [3, 4];
    yield from new ArrayIterator([5, 6]);
    yield from seven_eight();
    yield 9;
    yield 10;
}

function seven_eight() {
    yield 7;
    yield from eight();
}

function eight() {
    yield 8;
}

foreach (count_to_ten() as $num) {
    echo "$num ";
}

위 예제는 다음과 같이 출력돼요.

1 2 3 4 5 6 7 8 9 10

예제 #7 — yield from과 반환값

<?php
function count_to_ten() {
    yield 1;
    yield 2;
    yield from [3, 4];
    yield from new ArrayIterator([5, 6]);
    yield from seven_eight();
    return yield from nine_ten();
}

function seven_eight() {
    yield 7;
    yield from eight();
}

function eight() {
    yield 8;
}

function nine_ten() {
    yield 9;
    return 10;
}

$gen = count_to_ten();
foreach ($gen as $num) {
    echo "$num ";
}
echo $gen->getReturn();

위 예제는 다음과 같이 출력돼요.

1 2 3 4 5 6 7 8 9 10

제너레이터와 Iterator 객체 비교 (Comparing generators with Iterator objects)

제너레이터의 가장 큰 장점은 단순함이에요. Iterator 클래스를 구현하는 것에 비해 작성해야 할 보일러플레이트 코드가 훨씬 적고, 코드도 대체로 훨씬 읽기 쉬워요. 아래의 함수와 클래스는 각자 같은 일을 하는 예시인데, 차이가 확 와닿을 거예요.

<?php
function getLinesFromFile($fileName) {
    if (!$fileHandle = fopen($fileName, 'r')) {
        return;
    }

    while (false !== $line = fgets($fileHandle)) {
        yield $line;
    }

    fclose($fileHandle);
}

// versus...

class LineIterator implements Iterator {
    protected $fileHandle;

    protected $line;
    protected $i;

    public function __construct($fileName) {
        if (!$this->fileHandle = fopen($fileName, 'r')) {
            throw new RuntimeException('Couldn\'t open file "' . $fileName . '"');
        }
    }

    public function rewind() {
        fseek($this->fileHandle, 0);
        $this->line = fgets($this->fileHandle);
        $this->i = 0;
    }

    public function valid() {
        return false !== $this->line;
    }

    public function current() {
        return $this->line;
    }

    public function key() {
        return $this->i;
    }

    public function next() {
        if (false !== $this->line) {
            $this->line = fgets($this->fileHandle);
            $this->i++;
        }
    }

    public function __destruct() {
        fclose($this->fileHandle);
    }
}

물론 이런 유연함에는 대가가 따라와요. 제너레이터는 전방향 이터레이터라서, 순회가 시작된 뒤에는 되감기(rewind)가 불가능해요. 그 말은 곧 같은 제너레이터를 여러 번 순회할 수 없다는 뜻이에요. 다시 순회하고 싶으면 제너레이터 함수를 다시 호출해서 새로 만들어야 하죠.

참고 자료 (See Also)

  • 객체 순회 (Object Iteration)