Přejít k navigační liště

Zdroják » PHP » Lazy evaluation v PHP

Lazy evaluation v PHP

Články PHP, Různé

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.

Komentáře

Subscribe
Upozornit na
guest
6 Komentářů
Nejstarší
Nejnovější Most Voted
Inline Feedbacks
View all comments
blizz

výborný článok, (palec hore)

Oldis

Pekny clanek
Samozrejme problematika neni tak zcela cernobila, ale kde to jde, tam by se mela davat prednost tomuto pristupu.

Radek Miček

Protože Haskell používá lazy („líné“) vyhodnocování (s možností zapnout striktní)

Dovolím si malé upřesnění. V Haskellu není možné zapnout striktní vyhodnocování, v Haskellu se vyhodnocuje jen to, co se vyhodnotit musí. To znamená, že pokud chcete něco striktně vyhodnocovat, tak musíte Haskell donutit, aby to tak vyhodnocovat musel. Například pattern matching způsobí vyhodnocení, ale pouze tak, aby se zjistilo, jestli výraz matchuje nebo nematchuje – ne víc.

Haskell podporuje strictness annotions při deklaraci datových typů. Například

data StrictPair a b = StrictPair !a !b

zajistí, že se a a b vyhodnotí, ale pouze do slabé hlavní normální formy (angl. weak head normal form – nebo jen WHNF).

Dále má Haskell určité konstrukce, které umožňují ovlivňovat pořadí vyhodnocování. Příkladem takové konstrukce je seq:

a `seq` b

zajistí, že se a vyhodnotí do WHNF před vyhodnocením b do WHNF. Z těchto konstrukcí je pak možné poskládat funkce, které vyhodnotí výraz do normální formy. Viz knihovna deepseq.

Leinad

Můžete sem hodit nějaký typický příklad praktického využití? Na fibonacciho posloupnosti se dobře osvětlí příklad, ale nedaří se mi tam najít využití. Nebo je toto vhodné jen pro vědecké výpočty?

Radek Miček

Přínosy líného vyhodnocování pěkně popsal Lennart Augustsson v zápisku More points for lazy evaluation. Dovolím si z tohoto zápisku vytáhnout dva body:

1) Možnost vytvářet vlastní řídící konstrukce: Toho se využívá v různých EDSL například atom nebo yices-painless.

2) Znovupoužitelnost kódu: Například vyhodnocování podvýrazu transformuj velkaDatovaStruk­tura výrazu

platiPodminka $ transformuj velkaDatovaStruktura

skončí ve chvíli, kdy funkce platiPodminka bude schopná rozhodnout, jestli podmínka platí. To znamená, že podvýraz transformuj velkaDatovaStruk­tura se nemusí vyhodnotit celý. V jazyce se strikním vyhodnocováním by se nejprve vyhodnotil celý podvýraz transformuj velkaDatovaStruk­tura a ten by se pak předal funkci platiPodminka.

Jedním ze způsobů, jak se v jazyce se striktním vyhodnocováním vyhnout úplnému vyhodnocení podvýrazu transformuj velkaDatovaStruk­tura, je napsat funkci platiPodminka­NaTransformova­neStrukture, která bude provádět transformaci a testovat podmínku – v této funkci pak ukončíme provádění transformace, když vidíme, že podmínka platí. Znovupoužitelnost kódu znamená, že tohle dělat nemusíme.

Příkladem takto dosažené úspory může být HXQ: A Compiler from XQuery to Haskell – zajímavá je sekce Performance.

Pavel Lang

Rozdíl a výhoda – fetch() vs. fetchAll() – načtení jednoho řádku nebo celé tabulky?

V případě, že použijete fetch na tabulku s mnoha řádky, můžete se rozhodnout, zda potřebujete dajší řádek načítat nebo zda výsledek stačí. V situaci, kdy zavoláte fetchAll plýtváte zbytečně pamětí a často i časem procesoru, výsledek je obzvlášť na hostingu viditelný na odezvě celého webu.

Zkombinování iterátoru s načtením dalšího rádku z kurzoru je výhodné a efektivní, na venek se kód jeví jako by se pracovalo s polem, vnitřně se spotřebuje zlomek paměti. V případě, že se view rozhodne zbytek dat ignorovat (pomocí break), iterátor (potažmo SQL kurzor) se uzavře a šetří se i CPU.

Příklady se jistě najdou v nějakém frameworku. Například v dibi se objekt třídy DibiResult dá použít ve foreach konstrukci díky DibiResultIte­ratoru

Enum a statická analýza kódu

Mám jednu univerzální radu pro začínající programátorty. V učení sice neexistují rychlé zkratky, ovšem tuhle radu můžete snadno začít používat a zrychlit tak tempo učení. Tou tajemnou ingrediencí je statická analýza kódu. Ukážeme si to na příkladu enum.