PHP 5.5부터 추가된 Generators 기능에 대한 위키 문서의 번역.

소개

Generator는 Iterator 인터페이스를 쉽고 유연하게 구현할 수 있는 방법을 제공한다. 예를 들어 file() 함수를 직접 구현 한다면 아래와 같은 모양일텐데,

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

    $lines = [];
    while (false !== $line = fgets($fileHandle)) {
        $lines[] = $line;
    }

    fclose($fileHandle);

    return $lines;
}

$lines = getLinesFromFile($fileName);
foreach ($lines as $line) {
    // do something with $line
}

이 코드는 전체 파일의 내용을 모두 메모리 안으로 읽어들이므로 파일 용량에 따라 최대 메모리 허용량을 넘어갈 가능성이 존재한다. 이런 상황을 원하는 경우는 없을 것이므로 파일을 한 줄씩 읽어오도록 수정해야 한다. 이런 경우에 Iterator 인터페이스는 가장 적합한 방법이다.

하지만 Iterator 인터페이스를 구현하기 위해서는 작성해야 할 코드의 양이 만만치가 않다. 위의 코드를 Iterator 인터페이스로 구현한 아래의 코드를 보자.

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);
	}
}

$lines = new LineIterator($fileName);
foreach ($lines as $line) {
	// $line 변수를 가지고 작업하기
}

보다시피 아주 단순한 일을 하는 코드이지만 Iterator 인터페이스를 구현하려면 코드가 꽤 복잡해진다. Generator는 이런 문제를 해결하여 Iterator를 아주 쉽게 구현할 수 있게 해준다.

function getLinesFromFile($fileName) {
	if (!$fileHandle = fopen($fileName, 'r')) {
		return;
	}
 
	while (false !== $line = fgets($fileHandle)) {
		yield $line;
	}
 
	fclose($fileHandle);
}
 
$lines = getLinesFromFile($fileName);
foreach ($lines as $line) {
	// $line 변수를 가지고 작업하기
}

위의 코드는 처음에 나왔던 배열을 사용하여 구현한 것과 비슷한 모양이다. 가장 큰 차이점은 읽어들인 내용을 배열에 넣는 대신 yield 시켰다는 것이다. 이렇게 Generator는 yield 부분에서 실행을 멈추고 호출한 코드 쪽으로 제어권을 다시 넘겨주는 역할을 한다. 맨 처음 ($lines = getLinesFromFile($fileName)) 코드를 호출했을 때 $lines 변수에 할당된 값은 파일을 읽어들인 결과가 아닌 Generator 객체다. 이 객체는 Iterator 인터페이스를 구현한 객체이며 foreach문 안에서 반복적으로 사용된다. Iterator::next() 메소드가 호출되면 Generator 함수 안에서 마지막으로 실행하던 문장 부터 yield문을 만날때 까지 실행이 재개된다. 그리고 Iterator::current()문이 호출됐을 때 yield문에 사용됐던 값이 리턴된다.

Generator 메소드는 IteratorAggregate 인터페이스를 쉽게 구현하는데에도 사용될 수 있다.

class Test implements IteratorAggregate {
	protected $data;

	public function __construct(array $data) {
		$this->data = $data;
	}

	public function getIterator() {
		foreach ($this->data as $key => $value) {
			yield $key => $value;
		}
		// 또는 이 클래스가 가지고 있는 다른 순회 로직을 실행
	}
}

$test = new Test(['foo' => 'bar', 'bar' => 'foo']);
foreach ($test as $k => $v) {
	echo $k, ' => ', $v, "\n";
}

이 외에도 좀 더 고급 기능의 Generator, 역순 Generator, Coroutine 등을 사용하여 데이터를 생성하고 사용하는 곳에 사용할 수 있다. Coroutine은 좀 더 발전된 컨셉으로 짧은 예제 코드만으로 설명하기는 어렵고 on how to parse streaming XML using coroutines 페이지에서 Coroutine 코드의 예제를 볼 수 있다. 좀 더 많은 내용을 알고싶다면 이 프레젠테이션을 확인해볼 것.

기능 명세

Generator 함수의 정의

어떤 함수든지 yield문을 포함하고 있다면 Generator 함수다. Generator를 처음 만들때는 함수 선언에 별표를 붙여(function*) 구분하도록 하였는데 이렇게 하면 Generator 함수임을 명확히 알 수 있고 yield문이 없어도 Generator로 사용할 수 있는 장점을 가지고 있었다. 하지만 아래와 같은 이유 때문에 함수 선언에 별표를 붙이는 대신 자동으로 Generator 함수를 인식하도록 변경되었다.

  • HipHop for PHP에서 이미 자동 인식되는 Generator를 사용하기 있었기 때문에 호환성을 높일 수 있다.
  • Python, JavaScript 1.7, C# 등 내가 알고 있는 언어들 중 Generator를 사용하는 언어들은 모두 자동 인식을 사용하고 있다. 유일하게 ECMAScript Harmony 에서는 Generator를 다르게 정의하고 있는데 아직 어떤 브라우저에서도 이 방법대로 Generator 동작을 구현한 예는 없는 것으로 알고 있다.
  • 참조를 리턴하는 Generator 함수를 선언할 때 문법이 아름답지 못하다. (function *&gen())
  • yield문을 사용하지 않는 Generator 함수는 극히 일부의 경우에만 사용되고 이 때에도 if (false) yield; 같은 문장을 집어넣음으로써 자동 인식 되도록 할 수 있다.

기본 동작

Generator 함수가 호출되면 인자가 할당되고 Generator 객체가 리턴된 직후 실행을 멈추게 된다. Generator 객체는 아래의 Iterator 인터페이스를 구현한다.

final class Generator implements Iterator {
	void  rewind();
	bool  valid();
	mixed current();
	mixed key();
	void  next();

	mixed send(mixed $value);
	mixed throw(Exception $exception);
}

만약 Generator 객체가 생성만 되고 아직 Iterator 객체로서 사용되지 않았다면(즉, yield문이 아직 실행된 적이 없다면) rewindvalidcurrentkeynextsend 메소드들은 다음 yield문이 발견되는 부분까지 실행된다. 아래의 코드를 살펴보자.

function gen() {
	echo 'start';
	yield 'middle';
	echo 'end';
}

// 첫 번째 호출은 아무 것도 출력하지 않는다
$gen = gen();

// current() 메소드는 Generator를 계속 실행시켜서 "start"를 출력한다
// yield문을 만나면 "middle"이 리턴되어
// current() 메소드의 실행 결과로서 출력된다
echo $gen->current();

// Generator의 나머지 부분을 계속 실행해서 "end"를 출력한다
$gen->next();

이 코드에서 볼 수 있는 좋은 부수 효과는 Coroutine이 사용되기 전 next() 메소드가 호출되어야 할 필요가 없다는 것이다. (하지만 Python에서는 이 과정이 꼭 필요하기 때문에 Coroutine을 자동으로 생성할 때 데코레이터를 사용한다.)

아무튼 Generator 객체의 메소드들은 아래와 같이 동작한다.

  • rewind: 현재 실행 위치가 첫 번째 yield문 다음이라면 예외를 던진다. (Generator 되감기 참고)
  • valid: Generator가 종료되었다면 false, 아니라면 true를 리턴한다. (Generator 종료하기 참고)
  • currentyield 문의 값을 리턴한다. Generator가 종료되었거나 yield문에 아무 인자도 넘겨지지 않았다면 null이 리턴된다.
  • keyyield문의 키를 리턴한다. yield문에서 키가 지정되지 않았다면 자동 증가된 키를 리턴하고 Generator가 종료된 상태라면 null을 리턴한다. (키를 Yield 하기 참고)
  • next: Generator가 아직 종료되지 않은 경우라면 다음 실행을 계속한다.
  • send: Generator가 아직 종료되지 않은 경우라면 yield문의 리턴 값을 설정하고 나머지 실행을 계속한다. (값을 보내기 참고)
  • throw: Generator 함수의 현재 실행중이던 곳으로 예외를 던진다. (Generator로 예외 던지기 참고)

Yield 구문

새로 추가된 구문인 yield 키워드(T_YIELD)는 Generator 안에서 값을 보내거나 받을 때 사용한다. 기본적인 사용 형태는 아래처럼 세 가지가 있다.

  • yield $key => $value$key 값을 키로 하여 $value 값을 Yield 한다.
  • yield $value: 자동 증가하는 정수 값을 키로 하여 $value 값을 Yield 한다.
  • yield: 자동 증가하는 정수 값을 키로 하여 null 값을 Yield 한다.

yield문 자체의 리턴 값은 Generator 객체에서 send() 메소드를 통해 보내진 값이다. foreach 반복 처럼 send() 메소드가 실행되지 않는 경우에는 null 값이 리턴된다.

앞의 두 형태는 표현식의 상황에 따라 모호할 수 있으므로 괄호로 묶어줄 필요가 있다. 아래 예제에서 괄호로 묶어야 하는 경우와 그럴 필요가 없는 경우를 확인해보자.

// 아래의 세 줄은 '문장(Statement)'이므로 괄호가 필요 없다
yield $key => $value;
yield $value;
yield;

// 아래의 경우는 '표현식(Expression)'이므로 괄호로 묶어야 한다
$data = (yield $key => $value);
$data = (yield $value);

// 굳이 (yield) 같이 써야될 필요는 없으므로 괄호가 필요 없다
$data = yield;

아래처럼 언어 구조 안에서 사용할 때는 원래 괄호로 감싸져 있으므로 중복해서 괄호를 넣을 필요가 없다

call(yield $value);
// 아래 대신 위의 형태를 사용한다
call((yield $value));

if (yield $value) { ... }
// 아래 대신 위의 형태를 사용한다
if ((yield $value)) { ... }

예외로 배열 선언 안에서 사용할 때는 괄호를 생략하면 모호한 경우가 생기므로 괄호를 사용해야 한다.

array(yield $key => $value)
// 위의 경우는 모호하므로 아래처럼 사용해야 한다
array((yield $key) => $value)
// 또는 아래와 같이 사용해도 된다
array((yield $key => $value)) 

Python에서도 yield문을 표현식으로 사용할 때는 괄호를 써야 한다. PHP에서와의 차이점이라면 값이 없는 yield를 사용할 때도 괄호를 필요로 한다는 점이다. (Python에서는 문장을 종료할 때 세미콜론을 사용하지 않으므로.)

키를 Yield 하기

Generator를 구현하고 있는 언어들은 키를 Yield 하는 기능이 없고 오직 값만 Yield 할 수 있다. 일반적으로 Iteration에서 키를 사용하는 기능을 지원하지 않기 때문이다. 하지만 PHP에서는 Iteration에서 키도 사용할 수 있는 구조이기 때문에 키를 Yield 하는 기능이 있는게 합당하다. 이 문법은 foreach 루프나 배열 선언에서의 경우와 비슷한 모양이다.

yield $key => $value; 

또 키를 명시적으로 지정하지 않았다고 하더라도 Generator는 키를 생성한다. 배열에서 키를 생략했을 때 처럼 키는 0부터 1씩 증가하는 정수값으로 자동 생성된다. 자동 생성 중인 키보다 더 큰 정수 값을 키로 지정했을 때는 큰 값이 새로운 자동 생성 키의 시작 값이 된다. 그 외의 키들은 키 자동 생성 메커니즘의 영향을 받지 않는다.

function gen() {
	yield 'a';
	yield 'b';
	yield 'key' => 'c';
	yield 'd';
	yield 10 => 'e';
	yield 'f';
}

foreach (gen() as $key => $value) {
	echo $key, ' => ', $value, "\n";
}

// outputs:
0 => a
1 => b
key => c
2 => d
10 => e
11 => f

위의 코드는 키를 생성하는 방법에 있어 배열의 경우와 같다. 차이점은 숫자를 키로 줬지만 정수형이 아닐 경우인데 배열의 경우는 정수형으로 캐스팅 하지만 Generator에서는 그렇지 않다.

참조를 Yield 하기

Generator는 참조도 yield 할 수 있다. 함수 이름 앞에 & 수정자를 붙이는 것 만으로 함수는 참조를 리턴하게 된다. 아래의 예제는 일반적인 Iteration에서는 할 수 없는 일을 참조 값을 Iteration 하여 수행하는 방법을 보여준다.

class DataContainer implements IteratorAggregate {
	protected $data;

	public function __construct(array $data) {
		$this->data = $data;
	}

	public function &getIterator() {
		foreach ($this->data as $key => &$value) {
			yield $key => $value;
		}
	}
}

위의 클래스는 foreach 문에서 참조를 통한 Iteration이 가능하다.

$dataContainer = new DataContainer([1, 2, 3]);
foreach ($dataContainer as &$value) {
	$value *= -1;
}

// $this->data 변수가 [-1, -2, -3] 값으로 변경되었다

오직 & 수정자가 붙어있는 Generator만 참조로 사용할 수 있다. 그렇지 않은 Generator를 참조로 사용하려고 하면 E_ERROR 에러가 발생할 것이다.

값을 보내기

send() 메소드를 사용하면 Generator에 값을 할당할 수 있다. send($value) 라고 실행하면 현재 yield 표현식의 값을 $value 값으로 할당하고 중단된 나머지 부분을 실행한다. Generator가 다음 yield문을 만나면 yield의 리턴 값은 $value 값이 될 것이다. current() 메소드 호출에 추가적인 값을 저장하려 할 때 편리하게 사용할 수 있는 방법이다.

값은 참조가 아닌 값으로만 보낼 수 있다. & 수정자를 통한 참조는 yield의 리턴 값이 아닌 yield 되는 값에만 해당한다.

두 가지 방법으로 로깅을 구현한 아래 예제를 참고하자.

function echoLogger() {
	while (true) {
		echo 'Log: ' . yield . "\n";
	}
}

function fileLogger($fileName) {
	$fileHandle = fopen($fileName, 'a');
	while (true) {
		fwrite($fileHandle, yield . "\n");
	}
}

$logger = echoLogger();
// 또는
$logger = fileLogger(__DIR__ . '/log');

$logger->send('Foo');
$logger->send('Bar');

Generator에 예외를 던지기

Generator::throw() 메소드를 사용해 Generator 안으로 예외를 던지면 현재 실행이 중단된 곳에서 예외가 발생하고 나머지 부분을 실행하게 된다. 현재 실행이 멈춰진 부분의 yield문을 throw 문으로 바꿔지치 하는 것과 마찬가지라고 보면 된다. Generator가 이미 종료되었다면 예외는 Generator 안쪽이 아니라 throw() 메소드를 호출한 부분에서 발생하게 된다. 안쪽에서 예외가 캐치되었고 다른 예외가 발생하지 않았을 경우 throw() 메소드는 다음 yield문의 리턴 값을 리턴한다. 아래의 예제를 참고하자.

function gen() {
	echo "Foo\n";
	try {
		yield;
	} catch (Exception $e) {
		echo "Exception: {$e->getMessage()}\n";
	}
	echo "Bar\n";
}

$gen = gen();
$gen->rewind();                     // "Foo"가 출력된다
$gen->throw(new Exception('Test')); // "Exception: Test"가 출력되고,
                                    // "Bar"가 이어 출력된다

Generator 되감기

Generator가 주로 한 번만 읽혀지는 데이터 소스에서 한 번만 사용되는 점에서 볼 때 되감기라는 동작은 이 컨셉에 다소 반하는 기능이라고 할 수 있다. 하지만 대부분의 Generator가 되감기가 가능하고 이 기능이 효과적으로 동작할 수 있다는 점에서 가능하다고 볼 수도 있다. Generator를 되감기 하는 일이 아주 나쁜 개발 습관이라는 주장도 있지만(Generator가 높은 비용의 연산을 해야 할 경우) 배열을 Iteration 하는 경우 처럼 비용이 낮은 경우에는 되감기를 허용할 수도 있을 것이다. 하지만 Generator의 처음으로 실행 위치를 점프하는 되감기는 아래의 경우처럼 예상하지 못한 동작을 하기도 한다.

function getSomeStuff(PDOStatement $stmt) {
	foreach ($stmt as $row) {
		yield doSomethingWith($row);
	}
}

위의 경우 되감기를 실행한 후에는 데이터를 다 꺼내 쓴 빈 Iterator처럼만 동작할 것이다. 이런 이유로 Generator에서는 되감기를 지원하지 않는다. 현재의 실행 위치가 첫 번째 yield문이 있는 곳이거나 그 앞에 있지 않은 상태에서 rewind()를 호출한다면 예외가 발생한다. 아래의 코드에서 이런 동작을 확인할 수 있다.

$gen = createSomeGenerator();

// rewind() 메소드를 여기에서 호출하는건 가능하다
// 아직 첫 번째 yield가 실행되기 전이기 때문이다
foreach ($gen as $val) { ... }

// 하지만 여기에서 rewind() 메소드를 실행한다면
// 예외가 발생한다
foreach ($gen as $val) { ... }

간단히 말해 rewind() 메소드의 호출은 Generator가 아무 것도 하기 전에만 가능하다는 것이다(Generator가 이미 초기 상태이므로). 그렇지 않은 경우에는 예외가 발생하므로 Generator가 재사용 되는 경우를 찾기는 쉬울 것이다.

Generator의 복제

Generator는 복제가 가능하여 같은 상태에 있는 두 개의 독립적인 Generator를 만들 수 있다. 이걸 이용해 어떤 Generator를 되감기가 가능한 Generator로 만들어 주는 클래스를 만들 수 있다.

class RewindableGenerator implements Iterator {
	protected $original;
	protected $current;

	public function __construct(Generator $generator) {
		$this->original = $generator;
		$this->current = null;
	}

	public function rewind() {
		if ($this->current) { $this->current->close(); }
		$this->current = clone $this->original;
		$this->current->rewind();
	}

	public function valid() {
		if (!$this->current) { $this->current = clone $this->original; }
		return $this->current->valid();
	}

	public function current() {
		if (!$this->current) { $this->current = clone $this->original; }
		return $this->current->current();
	}

	public function key() {
		if (!$this->current) { $this->current = clone $this->original; }
		return $this->current->key();
	}

	public function next() {
		if (!$this->current) { $this->current = clone $this->original; }
		$this->current->next();
	}

	public function send($value) {
		if (!$this->current) { $this->current = clone $this->original; }
		return $this->current->send($value);
	}

	public function close() {
		$this->original->close();
		if ($this->current) {
			$this->current->close();
		}
	}
}

function rewindable(Generator $generator) {
	return new RewindableGenerator($generator);
}

그리고 아래처럼 일반적인 Generator를 되감기 가능한 Generator로 만들 수 있다.

function xrange($start, $end, $step = 1) {
	for ($i = $start; $i <= $end; $i += $step) {
		yield $i;
	}
}

$range = rewindable(xrange(0, 5));
foreach ($range as $i) {
	echo $i, "\n";
}
foreach ($range as $i) {
	echo $i, "\n";
}

위의 코드를 실행하면 0부터 5까지의 숫자가 두 번 출력될 것이다.

Generator 종료하기

Generator가 종료되면 실행 상태와 가지고 있던 변수들을 모두 해제한다. 종료된 Generator에서 valid 메소드를 호출하면 false를 리턴하고 currentkey 메소드는 null 값을 리턴한다.

Generator는 아래의 두 가지 방법으로 종료된다.

  • 함수의 끝이나 리턴 문까지 도달했을 경우, 예외가 발생했지만 캐치되지 않았을 경우.
  • Generator 객체를 참조하는 변수가 모두 제거됐을 경우 가비지 컬렉션 과정에서 종료된다.

Generator가 종료될 때 그 안의 finally문은 실행이 된다. 가비지 컬렉터에 의해 Generator가 강제로 종료될 경우 finally 안에서 yield문을 사용할 수는 없다(Fatal Error가 발생). 이 외의 경우 finally 블록 안에서 yield 사용은 가능하다.

Generator가 종료되면서 아래의 리소스들이 해제된다.

  • 현재 실행중이던 상태 정보(execute_data).
  • Generator 호출을 위한 스택 인자와 그 것을 관리하기 위한 추가적인 상태 정보.
  • 현재 활성화된 심볼 테이블(심볼 테이블이 사용되지 않았다면 컴파일된 변수들).
  • $this 객체.
  • 메소드를 호출 중 종료되었다면 메소드가 호출된 객체(EX(object)).
  • 호출 중 종료되었다면 스택에 쌓인 인자.
  • 살아있는 모든 foreach 루프 변수(brk_cont_array에서 할당된).
  • Generator의 키와 값.

지금은 특정한 경우에 임시 변수들이 해제되지 않는 상황이 발생할 수 있다. 예외의 경우에도 이 문제를 가지고 있는데(PHP :: Bug #62210 :: Exceptions can leak temporary variables 참고) 이 버그가 픽스되면 Generator에서의 문제도 해결될 것이다.

오류 발생 조건

다음은 Generator와 관련된 오류 조건들이다

  • 함수 밖에서 yield문을 사용했을 때: E_COMPILE_ERROR
  • Generator 안에서 return 문에 값을 넘겨 리턴 했을 때: E_COMPILE_ERROR
  • Generator 클래스를 직접 생성하려고 할 때:: E_RECOVERABLE_ERROR (Closure 클래스의 경우와 비슷함)
  • 키가 아닌 값이나 정수가 아닌 키를 Yield 할 때: E_ERROR (Etienne의 arbitrary-keys 패치를 위한 예약된 조건)
  • 참조가 아닌 Generator를 참조하려고 할 때: Exception
  • 이미 종료된 Generator를 순회하려고 할 때: Exception
  • 첫 번째 yield를 실행한 뒤의 Generator를 되감기 할 때: Exception
  • 임시 변수나 상수 값을 참조로 리턴하려고 할 때: E_NOTICE (return문의 경우와 비슷함)
  • 문자열 오프셋을 참조로 리턴하려고 할 때: E_ERROR (return문의 경우와 비슷함)
  • 참조가 아닌 함수에서 참조를 리턴하려고 할 때: E_NOTICE(return문의 경우와 비슷함)

위 목록은 완전하지 않을 수 있다.

성능

Microbenchmark of generator implementation 페이지에서 간단한 벤치마크를 찾을 수 있다. 범위를 순회하는 몇 가지 방법을 테스트 하고 있다.

  • Generator 사용 (xrange)
  • Iterator 사용 (RangeIterator)
  • 배열로 직접 구현 (urange)
  • 기본 배열로 구현 (range)

큰 범위를 순회할 경우 Generator가 항상 빠르다. Iterator의 경우에 비해 4배 정도 빠르며 기본 배열로 구현된 경우에 비해서도 40%나 빠르다.

작은 범위(100개 정도의 원소)를 순회할 경우 결과의 차이는 더 높았다. 여러번 실행했을 경우 Generator는 기본 배열 구현의 경우에 비해 약간 느린 경향을 보여주었지만 Iterator 보다는 여전히 빨랐다.

이 테스트는 Ubuntu VM에서 실행되었는데 테스트가 완전히 정확했다고 확신할 수는 없다.