Nenechte si podrazit nohy iterátory v PHP

This post is Tested

5 min | by Honza Kuchař


Iterátory v PHP jsou občas zrádné. V některých kolekcích se chovají neintuitivně. Zjistěte proč a vyhnete se tím hodinám zbytečného hledání chyb.

Při programování a používání kolekcí v doménovém modelu jsem narazil na velmi podivné chování SplObjectStorage (2. příklad) při vnořeném iterování. V jednom příkladu dokonce XDebug mění chování kódu (3. příklad). Nenechejte se napálit a pochopte sémantiku iterátorů v PHP.

$a = [];
$a[0] = 'first-value';
$a[1] = 'second-value';

$accumulator = [];

// Act
foreach($a as $key1 => $val1) {
    foreach($a as $key2 => $val2) {
        $accumulator[] = [$val1, $val2];
    }
}

Kolik prvků bude v $accumulator?

Dva vnořené cykly do sebe by měly vytvořit kartézský součin. Tedy 4 řádky. ...a ono se tak opravdu stane! Nic překvapivého.

Nyní nahradím obyčejné pole za SplFixedArray. Kolik bude prvků v $accumulator teď?

$a = new SplFixedArray(2);
$a[0] = 'first-value';
$a[1] = 'second-value';

$accumulator = [];

// Act
foreach($a as $key1 => $val1) {
    foreach($a as $key2 => $val2) {
        $accumulator[] = [$val1, $val2];
    }
}

Kolik jste čekali? Čtyři? Budou tam dva!

Teď si asi říkáte, k čemu je dobré iterovat dvakrát ten samý objekt v sobě. Vnořené iterování se umí občas pěkně schovat. Koukněme na další příklad:

$object = new class(2) extends SplFixedArray {
    public function __debugInfo()
    {
        $ret = [];
        foreach($this as $key => $val) {
            $ret[(string) $key] = (string) $val;
        }
        return $ret;
    }

};

$object[0] = 'first-value';
$object[1] = 'second-value';

$accumulator = [];

foreach($object as $key1 => $val1) {
    $accumulator[] = $val1;                // (1)
}

Takovýto kód napíšete běžně. Pojďme jej spusit... V $accumulator budou položky dvě. Jak byste čekali.

Nyní si zkuste dát breakpoint na řádek (1) a jakmile se program zastaví, deje pokračovat v běhu. Kolik je položek v $accumulator?

Jeden?! Co se stalo? XDebug se pokusil vypsat obsah lokálních proměnných a zavolal metodu __debugInfo(). Abychom však rozkryli, v čem je ten zakopaný pes, koukněme na kousek teorie.

Co je to foreach?

$arr = ["one", "two", "three"];
foreach($arr as $key => $value) {
    echo "Key: $key; Value: $value\n";
}

je naprosto identické tomuto kódu: (zdroj)

$arr = ["one", "two", "three"];
reset($arr);
while(list($key, $value) = each($arr)) {
    echo "Key: $key; Value: $value\n";
}

Aha! foreach tedy nastaví vždy na začátku pozici iterátoru na začátek a projde iterátorem až do konce. Pokud mám jeden iterator a dva foreach v sobě stane se toto:

  1. vnější foreach nastaví ukazatel na začátek
  2. vnější foreach přečte první prvek (a posune se na další)
  3. vnitřní foreach nastaví ukazatel na začátek
  4. vnitřní foreach přečtě první prvek (a posune se na daší)
  5. vnitřní foreach přečtě druhý prvek (a posune se na daší)
  6. vnitřní foreach zjistí, že již v iterátoru nic není, končí
  7. vnější foreach zjistí, že v iterátoru již nic není, končí

A tak jsme došli ke dvěma prvkům místo čtyřem.

Rychlá oprava

Takže PHP má objekty rozbité a iterování nad datovými strukturami pořádně nepodporuje? Na php.net o tom nic moc nepíší.

Vrátím se tedy k příkladu s SplFixedArray. foreach mění interní pozici iterátoru a protože dva foreache prochází jen jeden iterátor, dostaneme jen dva prvky na výstupu. Potřebujeme tedy uchovávat pozici pro každý foreach zvlášť. Co zkusit objekt klonovat?

// Arrange
$a = new SplFixedArray(2);
$a[0] = 'first-value';
$a[1] = 'second-value';

$accumulator = [];

// Act
foreach(clone $a as $key1 => $val1) {
    foreach(clone $a as $key2 => $val2) {
        $accumulator[] = [$val1, $val2];
    }
}

Nyní dostaneme prvky čtyři. Hurá!

Tímto však kopírujeme celý objekt i s jeho hodnotami, což může být pomalé. Navíc ne všechny objekty počítají s tím, že budou klonovány.

Pokud objekt podporuje clone, jako rychlé řešení je tento přístup použitelný. Tento přístup však jen obchází příčinu chyby, neřeší ji.

Cesta k jádru pudla

Nyní jsem nahradil v původním příkladu s SplFixedArray náš objekt za ArrayObject. Kolik bude teď prvků v $accumulator?

$a = new ArrayObject();
$a[0] = 'first-value';
$a[1] = 'second-value';

$accumulator = [];

// Act
foreach($a as $key1 => $val1) {
    foreach($a as $key2 => $val2) {
        $accumulator[] = [$val1, $val2];
    }
}

Tentokrát budou čtyři! Proč? Magie?

Příčina neleží v tom, jestli je iterovaný předmět objekt nebo pole.

Pojďme se těmto interface kouknout na zoubek.

interface Iterator extends Traversable {
    function current();
    function key();
    function next(): void;
    function rewind(): void;
    function valid(): bool;
}
  • mluví vždy o instanci sama sebe
  • zná pozici v procházení
  • jeho metody závisející na aktuálním stavu (na aktuální pozici)
interface IteratorAggregate extends Traversable {
    function getIterator(): Traversable;
}
  • getIterator() je továrna
    • při každém zavolání musí vracet novou instanci Traversable
  • objekt implementující rozhraní neuchovává žádný stav související s iterací
    • uchování stavu deleguje do vráceného Traversable (což může být třeba Iterator)

Sémantika Iterator a IteratorAggregate

Iterator je pohled na data. Například přes DirectoryIterator je možné procházet obsah složky. Stejně tak můžete procházet obsah pole přes ArrayIterator nebo obsah kolekce přes vlastní iterátory. Z pohledu uživatele iterátoru v tom není rozdíl.

IteratorAggregate říká, že objekt implementující toto rozhraní, je možné procházet pomocí iterátoru, který je dostupný přes metodu getIterator().

Iterátor jako pohled na data

Iterátory je možné skládat do sebe. Kdy každý iterátor může pohled na data upravit a vychází u toho z pohledu na data iterátoru předchozího. Například:

$iterator = new CallbackFilterIterator(
    $collection->getIterator(),
    function($value, $key) { return rand(0,100) < 50; }
);
foreach($iterator as $key => $value) { /* ... */ }

Tu jsme vyšli z výchozího pohledu dostupného přes ->getIterator(), CallbackFilterIterator poté přefiltroval obsah. foreach tedy projde jen ty položky, kde closure vrátí TRUE.

Kolekci nemusím procházet přes její výchozí pohled dostupný přes ->getIterator() jako výše. Mohu vytvořit úplně vlastní pohled. Třeba takto:

$iterator = new MyAwesomeIterator($collection);
foreach($iterator as $key => $value) { /* ... */ }

Všimněte si, že MyAwesomeIterator (implementuje Iterator) bere jako parametr přímo kolekci = zprostředkovává pohled na kolekci.

A proč je tedy možné foreach s IteratorAggregate procházet zanořeně?

foreach v PHP je chytrý. Pokud procházený objekt implementuje rozhraní IteratorAggregate, vždy před začátkem procházení vytáhne "nový pohled" (zavolá ->getIterator()).

Když foreach procházející IteratorAggregate přepíšu jako while, vypadá to takto:

$collection = /* implementuje IteratorAggregate */;

foreach($collection as $key => $value) { /* ... */ }

// je funkčně stejný jako:

$iterator = $collection->getIterator();
$iterator->rewind();
while($iterator->valid()) {
    $key = $iterator->key();
    $value = $iterator->current();

    /* ... */

    $iterator->next();
}

Budou-li tedy dva foreache v sobě, každý bude mít svoji instanci iterátoru a kód bude fungovat podle očekávání.

Co si z toho odnést? (TL;DR)

  • Nikdy neprocházejte jednu instanci Iterator zanořeně
  • Pokud implementujete kolekci (objekt, který drží nějaká data), vždy implementujte rozhraní IteratorAggregate.
  • Pokud implementujete pohled na data, implementujte rozhraní Iterator.
  • pokud chcete, aby se struktura, kterou dostanete na vstupu chovala stejně jako pole, vyžadujte rozhraní IteratorAggregate.
  • Ke kolekci může existovat více Iteratorů - tedy více pohledů, na ta stejná data.
  • Kolekce by neměla implementovat Iterator přímo, protože...
    • tím říká, že na ni v jednu chvíli existuje jen jeden pohled.
    • má poté dvě zodpovědnosti - uchování dat a zprostředkování pohledu na data v ní uložené.
  • Dejte si pozor na SplFixedArray, SplObjectStorage a další kolekce, které implementují Iterator.
  • Použijte raději phpds, kde jsou datové struktury implementovány správně.
  • Pokud kolekce, kterou používáte implementuje přímo rozhraní Iterator, podporuje klonování a nemůžete použít jinou kolekci, která implementuje IteratorAggregate, můžete zkusit foreach(clone $collection as $key => $value) { /* .. */ }.
    • Mějte však na paměti, že je to pomalé.
    • A všude kde iterujete kolekci budete muset navíc ještě psát i clone.

Bonusový úkol

Koukněte na tento kód:

$object = new SplFixedArray(2); // implements Iterator
$object[0] = 'first-value';
$object[1] = 'second-value';

foreach ($object as $key1 => $val1) {
    foreach ($object as $key2 => $val2) {
        break;
    }
}

Co se stane, když tento kód spustíte?

Spustit! Proč se to děje? Přepište foreach na while (viz výše) a zjistěte, co se stalo.