Sliby se maj plnit o Vánocích, promise v JavaScriptu nemusí

JavaScript je jazyk, ve kterém se dají bez problémů používat postupy z funkcionálního programování. Jedním takovým a hojně používaným je návrhový vzor promise. V článku projdeme stručně jeho historii, pak se podíváme na jeho vztah k monádám a na závěr ho porovnáme s callbackovým asynchronním API, které v současnosti používá především Node.js.

Trocha historie

Návrhový vzor promise je v javascriptovém světě znám už nějaký ten pátek. Jednu z prvních, ne-li vůbec první implementaci měla knihovna Dojo ve verzi 0.3 v roce 2008. Do širšího povědomí se však nejvíce dostal zásluhou jQuery – v roce 2011 byla vydána verze 1.5, jejíž součástí se stala implementace pod názvem Deferred Object a bylo pomocí ní přepsáno celé Ajaxové API. Mezitím vznikl návrh specifikace Promises/A a z něj vycházející otevřený standard Promises/A+ pro implementaci vzoru promise v JavaScriptu. Verze 1.0 standardu vyšla v prosinci 2012, aktualizovaná 1.1 letos v září. Jednu miniaturní implementaci dle tohoto standardu vytvořil například Ondřej Žára. Kromě samostatných knihoven pro práci s promise – Q, when.js (druhá vyhovuje Promises/A+), má vlastní implementaci snad již každá JavaScriptová knihovna – $q v AngularJS, Ember.Deferred, Futures v Dartu, WinJS.Promise, již zmíněné Dojo a jQuery atd.

Jistou dobu existovala specifikace DOM Promises, kterou nedávno převzala technická komise ECMA TC39 a promise by měly být součástí ECMAScript 6 a v budoucnu tedy nativně dostupné v každém prohlížeči. Jejich specifikace se dolaďuje posledních pár měsíců a Chrome Canary a noční buildy Firefoxu ji s různými odchylkami již experimentálně implementují. Pokud byste je chtěli začít používat již nyní, je k dispozici polyfill.

Sliby se nemusí plnit

Co to tedy je promise? Zjednodušeně řečeno je to hodnota, která vyjadřuje příslib, že někdy v budoucnu dostaneme očekávanou návratovou hodnotu funkce, nebo důvod, proč jsme ji dostat nemohli. Používá se na asynchronní API, protože funkce může promise vrátit okamžitě, a tudíž neblokuje vykonávání dalších instrukcí čekáním na vyřízení eventuálně dlouho trvající operace.

Představme si to na jednoduchém příkladu. Pomocí funkce ajax provedeme dotaz na server, návratová hodnota funkce bude tělo odpovědi serveru, kterou budeme parsovat pomocí funkce JSON.parse. Synchronní kód je jednoduchý alert(JSON.parse(ajax(url))), má však podstatnou nevýhodu v tom, že proces zde bude čekat na vyřízení AJAXového požadavku. Řešením je tedy upravit funkci ajax, aby vracela promise a k parsování odpovědi serveru dojde až v okamžiku, kdy bude dostupná.

Ovšem, jak s takovou hodnotou pracovat? Máme-li funkci JSON.parse, která na vstupu očekává hodnotu typu řetězec, co si počneme s hodnotou „promise řetězce“, tedy příslibem, že ten řetězec někdy dostaneme (pokud nedojde k chybě)? Musíme tuto a všechny funkce přepsat tak, aby na vstupu místo skutečných hodnot očekávaly promise?

Ne, není to vůbec nutné, ke slovu přicházejí monády s jednoduchým řešením. K pochopení návrhového vzoru promise samozřejmě termínem monáda vůbec pracovat nemusíme. Záměrem je ukázat vzájmenou souvislost a třeba tím střípkem příspět k pochopení monád, které mají daleko širší uplatnění.

Od monád k promise

Monády nejsou v praxi žádná velká věda, dokázal byste je nejspíš vymyslet každý, nebo možná že i nevědomě vymyslel. Pocházejí z funkcionálního programování a řeší problém, kdy máte třídu funkcí, která na vstupu přijímá určitý typ a vrací hodnoty jiného typu, přitom chcete, aby tyto funkce šly skládat.

Jako příklad uveďme následující funkci children, která má parametr typu HTMLElement a vrací pole všech jeho dětí v DOM. Návratová hodnota má tedy typ Array.<HTMLElement> (v článku budeme používat typové anotace pro Google Closure).

/**
 * @param {HTMLElement}
 * @return {Array.<HTMLElement>}
 */
var children = function (node) {
	var children = node.childNodes, a = [];
	for (var i = 0; i < children.length; i++) {
		a.push(children[i]);
	}
	return a;
}

Co kdybychom nyní chtěli získat děti dětí daného HTML elementu? Bývalo by stačilo jen složit dvě funkce children, pokud bychom bývali tuto funkci navrhli tak, aby měla stejný typ vstupního parametru a typ návratovové hodnoty. Protože nemá, můžeme postupovat tak, že výsledek jednoho volání children budeme procházet for cyklem a uvnitř něj volat children podruhé. Tento for cyklus je však zbytečný boilerplate, který vůbec nesouvisí s problémem, který řešíme. Navíc se stejný for cyklus bude vyskytovat všude tam, kde bychom chtěli skládat funkce se signaturou function(HTMLElement): Array.<HTMLElement>.

K obecnému vyřešení potřebujeme dva kroky. Neprve implementujeme funkci bind, která z funkce, která jako parametr akceptuje HTMLElement, udělá funkci, která akceptuje Array.<HTMLElement>. V druhém kroku implementujeme funkci unit, která z hodnoty typu HTMLElement udělá hodnotu typu Array.<HTMLElement>.

/**
 * @param {function(HTMLElement): Array.<HTMLElement>}
 * @return {function(Array.<HTMLElement>): Array.<HTMLElement>}
 */
var bind = function (f) {
	return function (list) {
		var out = [];
		for (var i = 0; i < list.length; i++) {
			out = out.concat(f(list[i]));
		}
		return out;
	}
};

/**
 * @param {HTMLElement}
 * @return {Array.<HTMLElement>}
 */
var unit = function (x) {
	return [x];
};

Nyní už máme vše připraveno k tomu, abychom mohli funkce složit:

var grandchildren = bind(children)(bind(children)(unit(node)));

kde pak proměnná grandchildren obsahuje pole dětí dětí (vnuků) HTML elementu node.

Tento zápis je trochu nepraktický, vhodnější a standardní způsob je funkci bind definovat jako

function(Array.<HTMLElement>, function(HTMLElement): Array.<HTMLElement>): Array.<HTMLElement>

jejíž implementace bude vypadat takto

/**
 * @param {Array.<HTMLElement>}
 * @param {function(HTMLElement): Array.<HTMLElement>}
 * @return {Array.<HTMLElement>}
 */
var bind = function (list, f) {
	var out = [];
	for (var i = 0; i < list.length; i++) {
		out = out.concat(f(list[i]));
	}
	return out;
};

Potom

var grandchildren = bind(bind(unit(node), children), children);

Mimochodem právě jsme implementovali List monad z Haskellu.

Monádou se nazývá typ návratové hodnoty funkcí bind a unit (a prvního parametru funkce bind). V našem konkrétním případě to bylo pole HTMLElement. Obecně má funkce bind signaturu

function(Monad.<A>, function(A): Monad.<B>): Monad.<B>

Všimněte si, že A a B mohou být obecně různé, v našem případě byly totožné.

Obecně si monádu můžeme představit jako jakousi „zabalenou hodnotu“. Volání funkce bind pak v této představě vypadá jako vybalení hodnoty, na kterou zavoláme funkci, která opět vrací zabalenou hodnotu. Pro ještě lepší názornost zkuste monády na obrázcích.

Nyní se vraťme k promise. Zde pracujeme s funkcemi, které vrací Promise.<něco>, a jiné funkce mají zase typ něco jako vstupní parametr. V našem konkrétním případě funkce ajax vrací Promise.<string> a funkce JSON.parse má parametr typu string.

Jak jsme si již řekli, promise reprezentuje nějakou eventuální budoucí hodnotu asynchronní operace. Předpokládejme implementaci podle standardu Promises/A+. Objekt Promise má pak metodu Promise.then(onFulfilled), která registruje callback onFulfilled, který je zavolán v okamžiku, kdy je přislíbená hodnota dostupná. Metoda then zároveň vrací nový promise pro návratovou hodnotu funkce onFulfilled. Dále předpokládejme, že objekt Promise má metodu Promise.fulfill(value), kterou vyvoláme všechny registrované callbacky na promise a předáme jim jako parametr value. Pro jednoduchost neuvažujeme druhý parametr onRejected metody then, což je callback, který je zavolán, pokud získání slíbené hodnoty z nějakého důvodu selže.

Implementujme funkce bind a unit, pro které bude Promise monádou.

/**
 * @param {Promise.<A>}
 * @param {function(A): Promise.<B>}
 * @return {Promise.<B>}
 */

var bind = function (promise, f) {
	return promise.then(f);
};

/**
 * @param {A}
 * @return {Promise.<A>}
 */
var unit = function (x) {
	var promise =  new Promise();
	promise.fulfill(x);
	return promise;
};

S pomocí bind již můžeme složit funkce ajax a JSON.parse takto: bind(bind(ajax(url), JSON.parse), alert); Vzhledem k tomu, že je implementace funkce bind triviální, obejdeme se bez ní –
ajax(url).then(JSON.parse).then(alert); na což se lze dívat tak, že v objektovém formalismu definujeme funkci bind přímo jako metodu na objektu reprezentujícím monádu. A má skutečně stejný význam, jejím úkolem je vybalit zatím neznámou přislíbenou hodnotu a předat ji jako parametr další funkci.

Callback vs. promise

Na závěr bychom rádi demonstrovali fundamentální rozdíly mezi funkcionálním přístupem vzoru promise a imperativním přístupem. Jako imperativní přístup máme na mysli, kdy asynchronní funkce přijímá callback jako jeden ze svých parametrů. Tento přístup byl výchozí v jQuery před verzí 1.5 a je na něm bohužel postaveno celé asynchronní API Node.js. Při imperativním přístupu píšeme přímo sady instrukcí a počítačí tedy přímo říkáme, jak chceme, aby dosáhl požadovaného výsledku. Naproti tomu funkcionální programování je deklarativní, což znamená, že popisujeme vztahy mezi hodnotami, a už je na počítači, aby přišel na to, jak se k výsledku dostat.

Například funkce ajax s callbackem má signaturu function (string, function (string, Error)), zatímco ajax vracející promise má signaturu function (string): Promise.<string>. To, že v prvním případě nemá funkce ani callback žádnou návratovou hodnotu, je to, co věci komplikuje, obě funkce jsou totiž volány čistě jen kvůli side efektům a znemožňují jakékoliv skládání. Zatímco v druhém případě máme návratovou hodnotu ihned k dispozici a můžeme s ní dále pracovat. Pomocí funkce then pak deklarujeme závislosti a, v jakém pořadí bude kód vykonáván, nemusíme vůbec řešit.

Uvažujme jednoduchou úlohu. V poli urls budeme mít seznam adres, ze kterých chceme stáhnout JSON dokumenty s uživatelskými daty, u prvního z nich navíc chceme stáhnout profilový obrázek, jehož adresa je v odpovědi na první dotaz. Řešení pomocí callbacků vypadá následovně:

var responses = [], done = 0;
urls.forEach(function (url, i) {
	ajax(urls[i], function (response, error) {
		if (error) continue;

		if (i === 0) {
			ajax(JSON.parse(response).imgUrl, function (response, error) {
				// zpracování obrázku
			});
		}
		responses[i] = response;
		done++;
		if (done === urls.length) {
			// zpracování responses
		}
	});
});

Ne příliš hezký výsledek, který se bude těžko udržovat při dalších úpravách zadání. Hlavní problém je, že úlohu řešíme pomocí control-flow, tedy for cyklem říkáme, co se má vykonávat paralelně, v callbacku testujeme, zda zpracováváme odpověď na první dotaz.

Pro řešení pomocí promise potřebujeme pomocnou funkci, která pole promisů transformuje na promise pole. Dá se použít funkce jQuery.when nebo následující.

/**
 * @param {Array.<Promise>}
 * @return {Promise.<Array>}
 */
var list = function (promises) {
	var listPromise = new Promise();
	for (var k in listPromise) promises[k] = listPromise[k];

	var results = [], done = 0;

	promises.forEach(function (promise, i) {
		promise.then(function (result) {
			results[i] = result;
			done++;
			if (done === promises.length) {
				promises.fulfill(results);
			}
		});
	});

	return promises;
};

Celé řešení pak vypadá následovně:

var promises = list(urls.map(ajax));
promises[0].then(JSON.parse).then(function(response) {
	ajax(response.imgUrl).then(function (response) {
		//zpracování obrázku
	});
});
promises.then(function (responses) {
	// zpracování responses
});

Vidíme, že okamžitě získáváme přístup k nezávislým promise všech odpovědí, a nemusíme tedy v kódu explicitně ošetřovat první url. Především je však z kódu jasně čitelné, jaký problém řešíme. A všimněte si, že nikde neříkáme, co se má vykonávat paralelně a co sériově. Jenom deklarujeme výsledek a vztah mezi daty, optimalizaci ponecháváme na vnitřní implementaci promise.

Jako malé cvičení si můžete zkusit rozmyslet, jak by řešení vypadalo, pokud bychom chtěli ošetřit i chyby.

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

Komentáře: 2

Přehled komentářů

honza Diky + dotazy na příklady
Jan Prachař Re: Diky + dotazy na příklady
Zdroj: https://www.zdrojak.cz/?p=10701