Pull to refresh

Генераторы в действии

Reading time 8 min
Views 98K

Небольшое вступление


Не так давно я решил для себя, что пора восполнить большой пробел в знаниях и решил прочитать про переходы между версиями PHP, т.к. понимал, что остался где-то между 5.2 и 5.3 и этот пробел необходимо как-то устранить. До этого я читал про namespaces, traits и т.д, но дальше чтения не уходило. И вот тут я заметил генераторы, почитал документацию, одну из статей на хабре на этот счет и после этого возникла мысль — а как раньше без них жили-то?

Данным переводом хочу помочь хотя бы новичкам, поскольку на php.net документация по генераторам на английском и, на мой взгляд, должным образом не раскрывает всю идею и места применения. Текста много, кода чуть меньше, картинок нет. Потребуются общие знания, например, про итераторы. Очевидный код комментировать не буду, а вот сложные для понимания примеры постараюсь объяснить в силу своих знаний.

UPD1: Изменил расплывчатую формулировку, про которую говорили в комментариях.
UPD2: Добавил решение с принудительным break.


Теория


Сразу скажу главную вещь — генераторы никоим образом не позволят сделать что-то новое, чего нельзя было сделать раньше, поскольку генераторов до PHP 5.5 нет. Это лишь новая возможность, которая несколько меняет обычное поведение языка. Везде, где используются генераторы, можно также использовать итераторы. Теперь, зная об этом, сразу взглянем на пример. Скажем, нам необходимо пройтись по строкам в файле. В процедурном стиле это можно сделать как-то так:

$f = fopen($file, 'r');
while ($line = fgets($f)) {
    doSomethingWithLine($line);
}


Это обычное решение, ничего странного тут нет. Но что если нам нужно что-то более абстрактное? Скажем, генерировать строки из абстрактного источника. Да, сегодня это может быть файл, но завтра мы решим, что более удачным решением будет база данных или вообще что-то иное.

Сейчас у нас есть два пути решения данной задачи — мы можем вернуть массив или итератор. Но возвращая массив есть несколько проблем: во-первых мы не знаем сколько нам нужно памяти (вдруг файл у нас размером 30 гб?), а во-вторых, возможно, мы и вовсе не сможем описать наш источник как массив (например, мы можем возвращать бесконечные порции данных и попробуй угадай когда этот поток закончится, если ты клиент).

Итак, остаются итераторы. Наш пример очень просто описать через итератор. Тем более, что в PHP уже есть готовый класс для этого — SPLFileObject. Но давайте оставим его и напишем что-то свое.

class FileIterator implements Iterator {
    protected $f;
    public function __construct($file) {
        $this->f = fopen($file, 'r');
        if (!$this->f) throw new Exception();
    }
    public function current() {
        return fgets($this->f);
    }
    public function key() {
        return ftell($this->f);
    }
    public function next() {
    }
    public function rewind() {
        fseek($this->f, 0);
    }
    public function valid() {
        return !feof($this->f);
    }
}


Совсем просто, не так ли? Хорошо, не совсем, но уже что-то. Хотя если мы взглянем на пример внимательнее, то увидим, что мы не совсем точно описали итератор, поскольку двойной вызов метода current() не даст нам ожидаемый результат в виде одного и того же значения.
Я (автор статьи, не «переводчик») сделал это специально, чтобы показать, что замена процедуры на итератор не всегда является простой задачей, поскольку в реальных ситуациях все куда сложнее. Давайте сделаем правильный итератор для нашего файла.

class FileIterator implements Iterator {
    protected $f;
    protected $data;
    protected $key;
    public function __construct($file) {
        $this->f = fopen($file, 'r');
        if (!$this->f) throw new Exception();
    }
    public function __destruct() {
        fclose($this->f);
    }
    public function current() {
        return $this->data;
    }
    public function key() {
        return $this->key;
    }
    public function next() {
        $this->data = fgets($this->f);
        $this->key++;
    }
    public function rewind() {
        fseek($this->f, 0);
        $this->data = fgets($this->f);
        $this->key = 0;
    }
    public function valid() {
        return false !== $this->data;
    }
}


Боже, как много всего для, казалось бы, простой задачи типа обхода файла, да и основная работа все равно спрятана внутри функций работы с файлами. Теперь, представим, что нам нужно сделать реализовать более сложный алгоритм. Если продолжать текущий подход, то он может стать еще сложнее и понять его работу будет труднее. Давайте решим нашу проблему с помощью генераторов.

function getLines($file) {
    $f = fopen($file, 'r');
    if (!$f) throw new Exception();
    while ($line = fgets($f)) {          
        yield $line;
    }
    fclose($f);
}


Намного проще! Да, это почти как первый пример с функцией, только появилось исключение и ключевое слово yield.

Итак, как оно работает?


Очень важно понимать, что в примере выше изменяется возвращаемое значение функции. Это не null, как может показаться с первого взгляда. Наличие yield говорит о том, что PHP вернет нам специальный класс — генератор. Генератор ведет себя также, как и итератор, поскольку он реализует его. И использовать генератор можно аналогично итераторам.

foreach (getLines("someFile") as $line) {
    doSomethingWithLine($line);
}


Вся фишка здесь в том, что мы можем писать код как угодно и просто выбрасывать (yield, йелднуть, йелдануть… не знаю как перевести правильнее, когда есть бросание исключений) каждый раз новое значение когда нам это надо. Итак, как же оно работает? Когда мы вызываем функцию getLines(), PHP выполнит код до первой встречи ключевого слова yield, на котором он запомнит это значение и вернет генератор. Затем, будет вызов метода next() у генератора (который описан нами или итератором), PHP снова выполнит код, только начнет его не с самого начала, а начиная с прошлого значения, которое мы благополучно выбросили и забыли о нем, и опять, до следующего yield или же конца функции, или return. Зная этот алгоритм, теперь можно сделать полезный генератор:

function doStuff() {
    $last = 0;
    $current = 1;
    yield 1;                                               
    while (true) {                                     
        $current = $last + $current;
        $last = $current - $last;
        yield $current;                              
    }
}


Возможно, с первого взгляда не совсем понятно что это, да и вообще бесконечный цикл все испортит. Да, эта функция и будет работать как бесконечный цикл. Но посмотрите внимательнее — это ведь числа Фибоначчи.

Нужно отметить, что генераторы не являются заменой итераторам. Это лишь простой путь их получения. Итераторы по-прежнему являются мощным инструментом.

Сложный пример


Нам нужно сделать собственный ArrayObject. Вместо того, чтобы делать итератор, сделаем небольшой трюк с генератором. Интерфейс IteratorAggregate требует от нас всего один метод — getIterator(). Так как генератор возвращает объект, реализующий итератор, то мы можем переопределить этот метод таким образом, чтобы он возвращал генератор. Все просто:

class ArrayObject implements IteratorAggregate {
    protected $array;
    public function __construct(array $array) {
        $this->array = $array;
    }
    public function getIterator() {
        foreach ($this->array as $key => $value) {
            yield $key => $value;
        }
    }
}


В точку! Теперь мы можем перебрать все свойства нашего массива через генератор или через обычный синтаксис обращения по ключу.

Отправляем данные обратно


Генераторы позволяют отправлять себе данные, используя метод send(). В некоторых случаях это может быть очень удобно. Например, когда надо сделать какой-то лог-файл. Вместо того, чтобы писать целый класс для него, можно просто воспользоваться генераторами:

function createLog($file) {
    $f = fopen($file, 'a');
    while (true) {          # да, опять бесконечный цикл;
        $line = yield;      # бесконечно "слушаем" метод send() для установки нового значения $line;
        fwrite($f, $line);
    }
}
$log = createLog($file);
$log->send("First");
$log->send("Second");
$log->send("Third");


Довольно просто и быстро. Чтобы немного усложнить задачу, посмотрим пример, где функции работают совместно, перекидывая управление между собой при помощи генераторов. Нам нужно построить очередь, которая получает и отправляет данные пакетами. Иногда такие задачи появляются, когда мы читаем бинарный поток и нужно контролировать размер пакета.

function fetchBytesFromFile($file) {           # функция возвращает генератор, который считывает данные разной длины из файла
    $length = yield;                                          # в начале установим длину
    $f = fopen($file, 'r');
    while (!feof($f)) {                                        # проверка на конец файла
        $length = yield fread($f, $length);       # выбрасываем блок данных
    }
    yield false;                                                    
}
function processBytesInBatch(Generator $byteGenerator) {              
    $buffer = '';
    $bytesNeeded = 1000;
    while ($buffer .= $byteGenerator->send($bytesNeeded)) {           # всегда считываем порцию разного размера
       // проверяем, достаточно ли данных в буфере
        list($lengthOfRecord) = unpack('N', $buffer);
        if (strlen($buffer) < $lengthOfRecord) {
            $bytesNeeded = $lengthOfRecord - strlen($buffer);
            continue;
        }
        yield substr($buffer, 1, $lengthOfRecord);                                    
        $buffer = substr($buffer, 0, $lengthOfRecord + 1);
        $bytesNeeded = 1000 - strlen($buffer);
    }
}
$gen = processBytesInBatch(fetchBytesFromFile($file));
foreach ($gen as $record) {
    doSomethingWithRecord($record);
}


Немного сложно, но, надеюсь, вы поняли как это работает. Мы разделили обработку и получение данных определенного размера в нужный момент + остается возможность повторного использования кода.

Нужно боольше примеров!


Вообще генераторы можно применять во многих задачах. Одна из них — симуляция потоков. Сначала мы определяем каждый поток как генератор. Затем выбрасываем сигнал управления родителю, чтобы тот смог передать сигнал для работы следующему потоку. Построим такую систему, которая работает с разными источниками данных (работаем с неблокирующим вводом-выводом). Вот пример такой системы:

function step1() {
    $f = fopen("file.txt", 'r');
    while ($line = fgets($f)) {
        processLine($line);
        yield true;
    }
}
function step2() {
    $f = fopen("file2.txt", 'r');
    while ($line = fgets($f)) {
        processLine($line);
        yield true;
    }
}
function step3() {
    $f = fsockopen("www.example.com", 80);
    stream_set_blocking($f, false);
    $headers = "GET / HTTP/1.1\r\n";
    $headers .= "Host: www.example.com\r\n";
    $headers .= "Connection: Close\r\n\r\n";
    fwrite($f, $headers);
    $body = '';
    while (!feof($f)) {
        $body .= fread($f, 8192);
        yield true;
    }
    processBody($body);
}

// 3 потока (step) имеют схожий функционал - выбрасывают true, тем самым давая сигнал, что он еще занят

function runner(array $steps) {                    
    while (true) {                                                # снова бесконечный цикл, в котором перебираем потоки
        foreach ($steps as $key => $step) {  
             $step->next();                                    # возобновляем работу потока с с момента последнего yield
             if (!$step->valid()) {                           # проверяем, завершился ли поток и завершаем (удаляем) его
                 unset($steps[$key]);
             }
        }
        if (empty($steps)) return;                      # если потоков нет - завершаем работу
    }
}
runner(array(step1(), step2(), step3()));


Заключение


Генераторы — ОЧЕНЬ мощная штука. Они позволяют очень сильно упростить код. Подумайте только, вы можете написать функцию для диапазона чисел в одну строчку кода:

function xrange($min, $max) {
    for ($i = $min; $i < $max; $i++) yield $i;
}


Коротко и просто. Легко читается, легко понять как работает и очень производительно — быстрее, чем с итератором.

Оригинал статьи — Anthony Ferrara @ blog.ircmaxell.com

В комментариях возник популярный вопрос о том, что делать, когда генератор (вернее сказать, его перебор через foreach) принудительно завершает свою работу, например, через break. В таком случае, если мы имеем дело с перебором файла, как из первого примера, то есть риск того, что никогда не сработает fclose, так как генератор попросту «забывает» о нем. Одно из самых верных решений предложил weirdan (#) — использовать конструкцию try {… } finally {… }, где в блоке finally очищаем открытые ресурсы. Данный блок сработает всегда при завершении перебора генератора, но есть маленький нюанс: если перебор генератора отработал до конца (без break) нормально, то выполнится и код после блока finally.

Кратко о генераторах


— Не добавляют нового функционала в язык
— Быстрее*
— Возобновление работы генератора происходит с последнего «выброса» yield
— В генератор можно отправлять значения и исключения (через метод throw())
— Генераторы однонаправлены, т.е. нельзя вернуться назад
— Меньше кода в большинстве случаев, более простые для понимания конструкции

* Основываясь на этих результатах.
При больших масштабах перебора — генераторы быстрее. Примерно в 4 раза быстрее чем итераторы и на 40% быстрее обычного перебора. При небольшом количестве элементов могут быть медленнее обычного перебора, но все еще быстрее итераторов.

Если сообщество одобрит перевод и посчитает его хорошим (а главное, не утверждающим чепуху и не меняющим суть кода), мне будет интересно иногда переводить другие статьи.
Думаю, не будет лишним перевести и собрать в кучку статьи, публикуемые сейчас на phpmaster про структуры данных.
Также буду рад любым замечаниям, наставлениям, комментариям про ошибки как в тексте с кодом, так и в самом переводе.

P.S. В процессе перевода была потеряна идея работы буфера в одном из примеров и, чтобы никого не путать, от невнятных комментариев к коду решил воздержаться. Буду рад, если кто подтвердит мои догадки и я таки допишу комментарий.
Tags:
Hubs:
+51
Comments 25
Comments Comments 25

Articles