Lazy evaluation v PHP

Lazy evaluation („líné vyhodnocování“) je programovací technika, která může ušetřit mnoho paměti a času. Některé jazyky pro ni mají speciální konstrukce; v PHP ale musí být nahrazena jinak. Jedno z možných řešení, které využívá k lazy evaluation PHP iterátory, si ukážeme v následujícím článku.

Článek je překladem článku Lazy evaluation with PHP, autor Juozas “Joe” Kaziukėnas, CEO Web Species Ltd. Originál je zveřejněn pod licencí CC-BY-SA, a pod stejnou licencí zveřejňujeme i tento překlad.

Před nedávnem jsem potřeboval zpracovat obrovské pole dat, a protože proměnné v PHP, a speciálně pole, nejsou příliš paměťově šetrná, skončilo vše chybou „Out of memory“. Bohužel jsem nemohl použít v tu chvíli jiný nástroj, byl jsem tak donucen vymyslet přijatelné řešení. Postup řešení, inspirovaného procedurálními jazyky, je obsahem tohoto článku.

V teorii programovacích jazyků je lazy evaluation nebo call-by-need („volání při potřebě“) vyhodnocovací strategie, která odkládá samotné vyhodnocení výrazu až do doby, kdy je jeho hodnota zapotřebí (non-strict evaluation) a zároveň odstraňuje opakované vyhodnocení téhož výrazu (sharing). Sdílení (sharing) může snížit čas běhu některých funkcí exponenciálně v porovnání s jinými non-strict evaluation strategiemi, jako je call-by-name.

Dále popisované řešení není zaměřené zrovna na rychlost, většina prováděcích časů zůstává plus mínus stejná. Cílem bylo snížit paměťovou náročnost, jak jen to půjde. Pokud máte milion řádků ke zpracování, každý po 10kB, bude zabraná paměť stále okolo 10kB. Což je samosebou čistá teorie, protože PHP není v uvolňování paměti až tak dobré.

Problém

Představme si, že potřebujete zpracovat milion databázových záznamů – jak to uděláte? No, obyčejně bývá první krok jejich vyzvednutí z databáze. Stop! Už ani nevím, kolikrát jsem viděl, jak lidé dělají tuhle chybu… Proč? Odpověď je „předčasné vyhodnocení“ – podívejte se na význam toho termínu. Počkejte, je potřeba, abyste opravdu věděli, o čem je řeč, takže si to vysvětlení „Eager Evaluation“ na Wikipedii opravdu přečtěte.

Vše stojí a padá s použitím metodyfetchAll() – skript načítal všechny objednávky v daném časovém období z databáze. fetchAll() dělá to, že vrací pole se všemi hodnotami, co vyhovují dotazu, ale pokud je počet takových záznamů v tisících, tak to už nějakou paměť zabere. O kousek dál skript cosi počítal a vytvářel druhé pole, tentokrát se zpracovanými daty. Na konci celé smyčky bylo v paměti zaplněno pořet řádků * velikost řádku v bajtech * 2 bajtů, tedy hodně velké číslo.

Případy, kdy je potřeba udělat vše přesně takhle, jsou poměrně zřídkavé – většinou můžeme zpracovávat data řádek po řádku, čímž si ušetříme problémy spojené s potřebou ukládat si vše v paměti. Někdy to SQL dotaz neumožňuje, ale v takovém případě většinou můžete změnit postup tak, aby to šlo – snažte se pracovat s daty v podobě proudu (stream). Jakmile máte data v datovém proudu, můžete je zpracovávat postupně.

Funkcionální jazyky

Než se podíváme na řešení v PHP, ukážeme si, jak by se takový problém řešil ve funkcionálním jazyku. Představme si funkci pro výpočet celé Fibonacciho posloupnosti, konkrétně jeho implementaci v Haskellu (zdokumentace):

magic :: Int -> Int -> [Int]
magic 0 _ = []
magic m n = m: (magic n (m+n))

Zavoláním magic 1 1 získáme seznam hodnot [1,1,2,3,5,8,…]. Ale to nejdůležitější je, že tento vrácený seznam je nekonečný. Není tu žádné omezení jako „vrať prvních sto čísel“, funkce vrátí všechna čísla. Možná si říkáte, že to není možné, a máte svým způsobem pravdu, obzvlášť pokud jste nikdy předtím s takovým jazykem nepracovali.

Protože Haskell používá lazy („líné“) vyhodnocování (s možností zapnout striktní), nepočítá tato funkce při zavolání ve skutečnosti nic. Namísto toho si vytvoří zdroj podobný generátoru, z něhož může číst čísel, kolik chce. Dokud budeme čísla číst, do té doby bude počítat další. Tedy s funkcí jako:

getIt :: [Int] -> Int -> Int
getIt [] _ = 0
getIt (x:xs) 1 = x
getIt (x:xs) n = getIt xs (n-1)

můžeme získat X-týčlen Fibonacciho posloupnosti – zavoláme getIt (magic 1 1) 5 a výstup by měl být 5, protože pátým prvkem Fibbonaciho řady je 5. Důležitý detail zde je ten, že předáváme výsledek funkce magic funkci getIt, a jak jsme si řekli výše, magic nemusí počítat před návratem nic. getIt přečte pak pět čísel z nekonečného seznamu a vrátí to poslední přečtené.

Jak na to v PHP?

Bohužel v PHP nic takového takto jednoduše udělat nelze, protože nemáme k dispozici ani líné vyhodnocování, ani generátory. Ale přesto můžeme improvizovat a získat funkční řešení. Pomohou nám k tomu…Iterátory ! Jedna z nejvíc opomíjených funkcí PHP.

Libovolná třída, která implementuje rozhraní Iterator , může být použita ve smyčce foreach jako zdroj dat. Ukažme si krátký příklad iterátoru, který generuje čísla od 0 do nekonečna:

<?php
class Numbers implements Iterator
 {
     private $position = 0;

     function rewind() {
         $this->position = 0;
     }
     function current() {
         return $this->position;
     }
     function key() {
         return $this->position;
     }
     function next() {
         ++$this->position;
     }
     function valid() {
         return true;
     }
 }

 $n = new Numbers;

 foreach($n as $value) {
     print $value . PHP_EOL;
 }

Spuštěním tohoto skriptu získáme nekonečnou řadu čísel. Můžeme ale udělat i pouhých 10 iterací a získat desátou hodnotu, aniž bychom si museli celou posloupnost ukládat v paměti. Samozřejmě je to pomalejší než prosté napočítání do deseti, ale jde o princip – zde o princip, jak můžeme zpracovat data po jednotlivých jednotkách, aniž bychom si museli všechno uložit do paměti.

Pokud potřebujete zobrazit data v pohledu (view), nezapomínejte na paměť – nepočítejte výsledek v modelu (nebo, bože chraň, v controlleru), neposílejte vypočítaná data do pohledu, namísto toho si udělejte v modelu iterátor, a pošlete pohledu ke zpracování ten. Pro pohled je to totéž – pole i iterátory jsou iterovatelné (=lze je procházet pomocí foreach), ale iterátor ušetří spoustu paměti, kterou byste s polem vyplýtvali.

Řešení na straně databáze

Agregace by mělavždy probíhat na straně databáze, pokud je to možné. Pokud potřebujete znát celkovou cenu všech položek u každé objednávky, prostě použijte funkci  SUM() namísto procházení výsledků. Pokud budete výsledky procházet, musíte se ve výsledcích posouvat dopředu a dozadu a tím si okamžitě rozbijete „líné vyhodnocení“.

V první řadě načítejte data pomocí normální metody fetch() ve smyčce while, nepoužívejte iterování nad všemi výsledky získanými z  fetchAll(). Pokud budete mít štěstí, tak se nic nerozbije, ale namísto vytvoření obrovského pole a následného zpracování bude systém zpracovávat vždy jen jeden řádek a pak uvolní paměť. MySQL vrátí kurzor k sadě výsledků a PHP ovladač jej použije k postupnému procházení.

Možná vás zajímá, jak vykreslit stránky, kde bude např. seznam objednávek s objednanými položkami. Je to poměrně jednoduché, stačí jen spojit tabulky objednávek a položek a výsledek setřídit podle  order id. Data, která dostanete zpět, budou vypadat nějak takto:

Order ID   Item ID   Item name
1          156       Mléko
1          897       Chléb
2          156       Mléko

Při vypisování jen procházíme výsledky, a jakmile se změní  order id, začneme novou HTML tabulku pro novou objednávku. Tímto způsobem můžeme vypsat celou historii všech objednávek se všemi informacemi o objednaných položkách, aniž bychom k tomu potřebovali kvanta paměti. Jediný omezující faktor je velikost výsledného HTML.

Závěr

PHP zjevně nebylo vytvářeno pro podobné kousky, ale přesto mohou nastat situace, kdy podobné řešení potřebujeme. Ujistěte se, že si nevytváříte v paměti žádné skladiště předzpracovaných dat nebo že nezjišťujete výsledky dřív, než je potřebujete. V příkladu uvedeném na začátku pomohl přístup „lazy evaluation“ vyřešit problém s pamětí. Skript fungoval, aniž by zbytečně alokoval velké množství paměti.

Pokud vám bude někdy chybět paměť, zkuste si projít skripty a zamyslet se nad tím, jestli někde nevyhodnocujete dřív, než je opravdu potřeba.

Začal programovat v roce 1984 s programovatelnou kalkulačkou. Pokračoval k BASICu, assembleru Z80, Forthu, Pascalu, Céčku, dalším assemblerům, před časem v PHP a teď by rád neprogramoval a radši se věnoval starým počítačům.

Věděli jste, že nám můžete zasílat zprávičky? (Jen pro přihlášené.)

Komentáře: 6

Přehled komentářů

blizz Re: Lazy evaluation v PHP
Oldis Re: Lazy evaluation v PHP
Radek Miček Haskell a striktni vyhodnocovani
Leinad příklad
Radek Miček Re: příklad
langpa Re: příklad
Zdroj: https://www.zdrojak.cz/?p=3503