Пользователь
0,0
рейтинг
12 августа 2013 в 15:00

Разработка → Генераторы в действии из песочницы tutorial

PHP*

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


Не так давно я решил для себя, что пора восполнить большой пробел в знаниях и решил прочитать про переходы между версиями 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. В процессе перевода была потеряна идея работы буфера в одном из примеров и, чтобы никого не путать, от невнятных комментариев к коду решил воздержаться. Буду рад, если кто подтвердит мои догадки и я таки допишу комментарий.
Захаров Кирилл @yTko
карма
37,7
рейтинг 0,0
Пользователь
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама

Самое читаемое Разработка

Комментарии (25)

  • 0
    Yield вполне можно перевести как «передать» или «отдать». А в целом – весьма хороший перевод.
    • 0
      Спасибо!
      Сам бы себе инвайт я бы не дал за этот пост, но раз кидают в избранное — значит это кому-нибудь нужно.
  • 0
    Сразу скажу главную вещь — генераторы никоим образом не добавляют новых возможностей языку.
    Если так смотреть, то можно вообще программировать на Брейфаке, ничего нового добавить уже нельзя.
    • +1
      Ну автор статьи (не я) имел в виду то, что коренным образом ничего нового языку генераторы не добавили, а лишь упростили доступ к итераторам и их использование.
      Здесь скорее моя ошибка в переводе. Возможность новая (так она и заявлена), но она двоякая из-за того, что это скорее дополнение к итераторам.
  • +4
    > Сразу скажу главную вещь — генераторы никоим образом не добавляют новых возможностей языку.

    yield ставит на паузу выполнение функции, полностью сохраняя её состояние. Что это, как не новая возможность. В частности, именно на иелдах работает асинхронный питоновский фреймворк tornado.
    • +1
      да, чуть выше согласился, что это моя ошибка в переводе
    • 0
      php-скрипт рано или поздно завершается, и что происходит с этой паузой? Дальнейший код выполняется один раз? Не выполняется никогда?
      • 0
        если я правильно понял вопрос, то тогда вроде второй вариант — не выполняется никогда, поскольку последующий запуск генератора не будет знать о предыдущем запуске ничего. каждый новый запуск генератора выполняется с самого начала.
        или вы имели в виду принудительную остановку генератора во время его работы?
    • +1
      поменял формулировку, теперь вроде как новые возможности действительно добавляют новые возможности)
  • +1
    Спасибо за статью. Планируем как раз скоро переход на 5.5.
    Меня правда несколько коробит, когда вижу code-style, не соответствующий PSR-2. Почему люди не хотят приучаться к хорошему…
    • 0
      Наверное потому, что спор насчет положения {} никогда не закончится :) (впрочем расположение скобок () и отступов в декларациях функций там еще более спорное).
  • 0
    >Не добавляют нового функционала в язык
    ну да, только это в php так) например в ruby .each, .with_index, .each_with_obj и прочее, одни из базовых концепций) ну т.е. итераторы/генераторы и прочее, что составляет базис. эх, может когда нибудь в php появятся фишки типа loop+generator как в ruby и пара фишек с closure, вроде как все необходимое для этого впилили. :)

    p.s. сам похапе-программист, да :)

    >йелдануть
    lol =D
  • 0
    У меня вопрос тем, кто уже хорошо разобрался в генераторах. Пусть у нас есть первый генератор из этой статьи — getLines, который выдаёт строки из файла. Мы вставили его в цикл foreach, и внутри цикла при каком-то условии делаем break. Ну, предположим, нам надо дочитать только до определённой строки, а что дальше в файле, нам не интересно.

    Вопрос: я правильно понимаю, что файл после этого останется незакрытым (fclose никогда не вызовется)? Если да, то как с этим бороться?
    • 0
      Неправильно, вызовется. break прерывает выполнение цикла точно так же как без генераторов. После цикла fclose никуда не денется. Вот если вы вставите return, тогда да. Но опять же, никаких различий со случаем без yield.
      • 0
        Хм, странно. Начал пробовать до вашего комментария, ответа, честно, не знал и решил затестить.
        В голове возникло 2 решения обхода «возможной проблемы»:
        1. Передать в генератор не имя файла, а сам fopen. Но могли быть проблемы во времени выполнения.
        2. Бросить исключение и внутри catch закрыть файл.

        Покажется странным, но когда делается break, fclose почему-то не срабатывает в таком случае.
        В варианте передачи fopen нужно самому делать fclose, вне генератора.
        C исключением, как мне показалось, самый толковый вариант — оборачиваем цикл в try, дальше либо в catch закрываем fopen, либо catch оставляем пустым и дальше генератор доходит до самого конца сам, тем самым закрывая fopen.

        Все 3 варианта опробовал тут kocou.yTko.ws/gen_test.php || pastebin.com/zuqQd0LT

        Поправьте, если где ошибаюсь где-то

        • 0
          Warning как раз из-за того, что сначала fclose сработал в catch, а затем в конце генератора.
        • 0
          Я не так понял david_mz и решил, что он говорит о цикле внутри генератора. Значит мой ответ не верен. В вашем примере с исключением нужно оставить пустой catch, тогда fclose будет вызываться один раз.
    • 0
      Файл остается незакрытым, да. После чтения RFC на генераторы придумался следующий вариант:
          function getLines($filename)
          {
              try {
                  $fp = fopen($filename, 'r+');
                  while (!feof($fp)) {
                      yield fgets($fp);
                  }
              } finally {
                  var_dump('gonna close');
                  fclose($fp);
              }
          }
      

      Основанный на вот этой фразе: «If the generator contains (relevant) finally blocks those will be run»
      • 0
        Тоже интересное решение, в таком случае (принудительный break) выполняется только блок finally и не выполняет остальной код генератора.
        Если же просто заканчивается перебор то выполняется блок finally и остальной код генератора. Чуть позже попробую добавить это в статью.
      • 0
        Вау! Это прекрасное решение. Удивительно, что в основной доке про это ни слова, и надо лезть в RFC.

        Мне кажется (это уже к yTko), этот паттерн — освобождение ресурсов и памяти строго в finally — должен обязательно упоминаться в любой статье про генераторы. Потому что генераторы в foraech — обычное дело, а про то, что юзер в любой момент может сказать break, легко забыть.
        • 0
          Добавил в пост
  • 0
    Добавьте перевод через edit.php.net, там его просмотрят и сапрувят. Думаю что многим эта статья будет полезна! Спасибо!
  • 0
    Хорошая статья, спасибо!
  • 0
    Сломал мозг на примере с функцией fetchBytesFromFile
    там два yield, один из них принимает значение, второй нет

    В строчке ниже мы всегда передаем значение
    while ($buffer .= $byteGenerator->send($bytesNeeded)) { # всегда считываем порцию разного размера


    Правильно ли я понимаю, что нет смысла все время передавать туда значение для $length, ведь оно сетается лишь в начале и потом уже не меняется?
    • 0
      В цикле же меняется:
              $length = yield fread($f, $length);       # выбрасываем блок данных
      

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.