Site menu Javascript: Promises, async e await
e-mail icon
Site menu

Javascript: Promises, async e await

e-mail icon

Já escrevi sobre o Node.js e sua filosofia assíncrona. Argumentos pró e contra este paradigma já foram devidamente expostos por lá. Neste texto pretendo falar sobre Promises e as novas palavras-chave async e await, que facilitam (em tese) a programação assíncrona em Javascript.

Já existem inúmeros artigos sobre o assunto, mas considerando o número de dúvidas que aparecem no StackOverflow em português, e a aparente inexistência de um texto "tudo-em-um" sobre o assunto, acabei escrevendo alguma coisa.

Todos os exemplos de código podem ser encontrados neste Zip.

Inferno de callbacks

O código a seguir implementa duas operações assíncronas, op1 e op2. A demora é simulada com setTimeout() e falhas são simuladas de forma aleatória. Embora sejam completamente "artificiais", as operações funcionam da mesma forma que inúmeras APIs assíncronas baseadas em callback.

// Fonte dentro do Zip: cb.js

function cb_op1(callback)
{
	setTimeout(() => {
		let n = Math.random();
		if (n >= 0.75) {
			callback("Failed");
		} else {
			callback(null, n);
		}
	}, 1000);
}

function cb_op2(x, callback)
{
	setTimeout(() => {
		let n = Math.random();
		if (n <= 0.25) {
			callback("Failed");
		} else {
			callback(null, Math.floor(x / 0.75 * 1000));
		}
	}, 1000);
}

console.log("Start");

cb_op1((err, res) => {
	if (err) {
		console.log("Error op1: " + err);
		return;
	}
	cb_op2(res, (err, res) => {
		if (err) {
			console.log("Error op2: " + err);
			return;
		}
		console.log("Success: " + res);
	});
});

Os callbacks são fáceis de entender num primeiro momento, mas o código fica enrolado bem depressa quando existe uma cadeia de dependências. No caso acima, op2 depende de op1 e os callbacks foram aninhados. Se fossem quinze operações, seria impossível aninhar os callbacks; precisaríamos criar funções auxiliares, e o fluxo de execução ficaria cada vez mais difícil de entender.

Diversas técnicas e bibliotecas foram desenvolvidas para tentar dourar a pílula. O padrão que emergiu desse esforço foi o Promise (promessa). Existem inúmeras implementações de Promise mas hoje em dia não é mais necessário selecionar uma; versões recentes dos browsers e do Node.js já incluem esta classe.

Segue o código baseado em callbacks, reescrito para usar promessas:

// Fonte dentro do Zip: promise.js

function op1()
{
	return new Promise((fulfill, reject) => {
		setTimeout(() => {
			let n = Math.random();
			if (n >= 0.75) {
				reject("op1 failed");
			} else {
				fulfill(n);
			}
		}, 1000);
	});
}

function op2(x)
{
	return new Promise((fulfill, reject) => {
		setTimeout(() => {
			let n = Math.random();
			if (n <= 0.25) {
				reject("op2 failed");
			} else {
				fulfill(Math.floor(x / 0.75 * 1000));
			}
		}, 1000);
	});
}

console.log("Start");

op1()
.then(op2)
.then((res) => {
	console.log("Success: " + res);
})
.catch((err) => {
	console.log("Error: " + err);
});

O objetivo das promessas é facilitar a vida para o cliente de uma API, principalmente quando existe uma longa cadeia de dependências. Basta ir encadeando: op1.then(op2).then(op3).then(op4)... No exemplo acima, a última operação é simplesmente exibir o resultado, então passamos uma função anônima comum.

As promessas não necessariamente facilitam a vida de quem desenvolve a API. Por exemplo, na implementação de op1 e op2 existe um "descasamento de impedância" entre setTimeout() e a Promise porque setTimeout() é uma API do "estilo antigo", que chama um callback em vez de retornar uma promessa.

(A propósito, existem bibliotecas que implementam timeouts como promessas, então na prática não seria necessário fazer esta conciliação manualmente. Fiz isto aqui para que o exemplo ficasse didático.)

Finalmente, há o método .catch(), responsável pelo tratamento de uma promessa não cumprida. Pode haver mais de um catch() na cadeia, até mesmo um por promessa, para o caso de ser necessário um tratador de erro especializado para cada ponto da cadeia. No exemplo acima, há apenas um ao final da cadeia, responsável por tratar todos os erros.

A não ser que haja absoluta certeza que todas as promessas serão cumpridas, deve haver pelo menos um catch() ao final da cadeia. Versões mais recentes do Node.js emitem uma exceção em caso de promessa rejeitada não tratada (o padrão anterior era ignorar rejeições não tratadas).

Fazendo promessas

É interessante olhar mais de perto como se faz uma promessa:

function op1()
{
	return new Promise((fulfill, reject) => {
		... operação assíncrona ...
		... se operação bem-sucedida ...
		fulfill(resultado);
		... se operação falhou ...
		reject("falha");
	});
}

O construtor da Promise espera receber um "callback" que aceite duas funcões como argumentos, que eu chamei de fulfill ("cumprir") e reject ("rejeitar"). Os nomes não importam; diferentes estilos de código usam diferentes nomenclaturas.

As duas funções vêm lá das entranhas de Promise, mas isso também não importa. Tudo que importa é que a operação assíncrona chame fulfill passando o resultado como argumento, ou então reject com um objeto ou uma string que descreva o que deu errado. (Se não chamar nem uma nem outra, o elo seguinte da cadeia nunca será executado.)

Assim que a promessa é criada, o callback já é invocado, portanto a operação assíncrona já está em execução. É por isso que a cadeia de promessas é formada como op1().then(op2)... A promessa op1 é criada imediatamente, mas op2 só pode ser criada se op1() for cumprida.

Como gosto de usar tabs para indentação, e tudo que a função op1() faz é criar e retornar uma promessa, meu estilo particular é declarar a função-promessa desta forma:

function op1() { return new Promise((fulfill, reject) => {
	... código ...
});}

Casamento de impedâncias

Existem inúmeras bibliotecas Javascript e Node.js que usam callbacks do "estilo antigo". A tendência é as bibliotecas irem migrando para promessas, porém ainda haverá muitas situações onde será necessário compatibilizar APIs diferentes.

O exemplo abaixo ilustra como embutir manualmente uma operação assíncrona baseada em callback (cb_op1) em forma de promessa (op1).

// Fonte dentro do Zip: cbpromise.js

function cb_op1(callback)
{
	setTimeout(() => {
		let n = Math.random();
		if (n >= 0.75) {
			callback("op1 failed");
		} else {
			callback(null, n);
		}
	}, 1000);
}

function op1()
{
	return new Promise((fulfill, reject) => {
		cb_op1((err, res) => {
			if (err) {
				reject(err);
			} else {
				fulfill(res);
			}
		});
	});
}

Porém, não é necessário fazer isto de forma manual: ao menos o Node.js inclui um método utilitário util.promisify() que faz a mesma coisa:

// Fonte dentro do Zip: cbpromisify.js

let op1 = util.promisify(cb_op1);

Você ainda precisará escrever promessas manualmente se a API adaptada desviar do padrão — se por exemplo o callback não tiver a assinatura (err, res) ou algo assim. Mas sempre existe uma chance de haver uma adaptação pronta ao alcance de um npm install, como é o caso do setTimeout().

Também pode acontecer de ser necessária a adaptação inversa: você tem uma API que retorna uma promessa, mas precisa que ela funcione com um callback. Segue um exemplo:

// Fonte dentro do Zip: promisecbadapt.js

function op1()
{
	return new Promise((fulfill, reject) => {
		setTimeout(() => {
			let n = Math.random();
			if (n >= 0.75) {
				reject("op1 failed");
			} else {
				fulfill(n);
			}
		}, 1000);
	});
}

function cb_op1(cb)
{
	op1()
		.then((res) => { cb(null, res) })
		.catch((err) => { cb(err) });
}

Novamente, essa adaptação manual geralmente não é necessária, pois o método util.callbackify() faz isto por nós:

// Fonte dentro do Zip: promisecbfy.js

let cb_op1 = util.callbackify(op1);

Outro truque de adaptação é baseado no fato de que uma promessa é apenas um objeto que implementa os métodos .then() e .catch(), não precisa necessariamente ser uma instância de Promise. (Até porque existem diversas implementações de Promise e pode acontecer de bibliotecas diferentes usarem implementações diferentes.)

O código abaixo é um exemplo primitivo de "promessa artificial" que pode ser mesclado com promessas "de verdade" e async/await:

// Fonte dentro do Zip: async2.js

function op1()
{
	let p = {};

	setTimeout(() => {
		let n = Math.random() * 0.75;
		p.handler(n);
	}, Math.random() * 1000);

	p.then = (handler) => {
		console.log("p.then called");
		p.handler = handler;
	};

	return p;
}

Antes de prosseguir para a sintaxe async/await, é importantíssimo que você compreenda bem as promessas, porque a base da nova sintaxe continua sendo a promessa, e ainda é preciso declarar promessas explicitamente de vez em quando por limitações da especificação atual.

Async e await

Goste-se ou não, a programação assíncrona tem granjeado adeptos e suporte em diversas linguagens, geralmente na forma de co-rotinas. As novas palavras-chave async e await permitem criar e usar Promises de forma mais "limpa" e mais parecida com outras linguagens. (Embora ainda haja espaço para melhorias, conforme veremos.)

O exemplo abaixo implementa o nosso velho exemplo, com leves modificações, usando async/await em vez de promessas onde foi possível. Uma única modificação de fluxo em relação aos exemplos anteriores: agora op2 invoca op1 diretamente. Isto nos deu a oportunidade de usar await:

// Fonte dentro do Zip: async.js

function op1()
{
	return new Promise((fulfill, reject) => {
		setTimeout(() => {
			let n = Math.random();
			if (n >= 0.75) {
				reject("op1 failed");
			} else {
				fulfill(n);
			}
		}, 1000);
	});
}

async function op2()
{
	let x = await op1();

	let n = Math.random();
	if (Math.random()  <= 0.25) {
		throw "op2 failed";
	}

	return Math.floor(x / 0.75 * 1000);
}

console.log("Start");

op2()
	.then((res) => {
		console.log("Sucess: " + res)
	})
	.catch((err) => {
		console.log("Error: " + err)
	});

Alguns pontos sobre o exemplo acima:

O escopo principal do programa (no exemplo, é onde invocamos op2()) não é async e portanto não pudemos fazer await op2() como seria natural tentar fazer. Mas declarar e invocar uma função async anônima:

// Fonte dentro do Zip: async3.js

console.log("Start");

(async () => {
	try {
		let res = await op2();
		console.log("Sucess: " + res)
	} catch (err) {
		console.log("Error: " + err)
	}
})();

No exemplo abaixo, modificamos ligeiramente a implementação de op2 de modo que a cadeia de dependências op1-op2 volte a ser estabelecida pelo escopo principal:

// Fonte dentro do Zip: async4.js

async function op2(x)
{
	let n = Math.random();
	if (Math.random()  <= 0.25) {
		throw "op2 failed";
	}

	return Math.floor(x / 0.75 * 1000);
}

console.log("Start");

(async () => {
	try {
		let x = await op1();
		let res = await op2(x);
		console.log("Sucess: " + res)
	} catch (err) {
		console.log("Error: " + err)
	}
})();

Na função acima, a função op2 é síncrona, mas como ela é declarada async, deve ser tratada como uma promessa. Invocá-la com await resolve o caso.

Embora uma função async sempre retorne um objeto Promise nativo, pode-se fazer await em qualquer implementação de promessa. Qualquer objeto que possua o método .then() é aceito.

A função passada a uma Promise pode ser async, por exemplo algo como

function op() { return new Promise(async (fulfill, reject) => {
	... código ...
});}

Isto permite que o código interno da promessa possa usar await. Mas está claro que, se você chegar a uma definição de promessa tão enrolada como esta, um refactoring cairia bem: transformar op() em async e eliminar a criação explícita da promessa.

O exemplo a seguir tenta ser uma versão refatorada dos anteriores, confinando promessa explícita e callback a um único lugar, e usando async/await no resto do código.

// Fonte dentro do Zip: async5.js

function Timeout(to)
{
	return new Promise((fulfill, reject) => {
		setTimeout(() => { fulfill(); }, to);
	});
}

async function op1()
{
	await Timeout(1000);
	let n = Math.random();
	if (n >= 0.75) {
		throw("op1 failed");
	}
	return n;
}

async function op2()
{
	let x = await op1();
	if (Math.random() <= 0.25) {
		throw "op2 failed";
	}
	return Math.floor(x / 0.75 * 1000);
}

console.log("Start");

(async () => {
	try {
		let res = await op2();
		console.log("Sucess: " + res)
	} catch (err) {
		console.log("Err: " + err)
	}
})();

A sintaxe async/await ainda não é perfeita. Ela ainda não permite, por exemplo, esperar por diversas promessas concorrentes. Para fazer isto, ainda é preciso criar uma super-promessa (Promise.all) de forma explícita. A sintaxe proposta await* não foi aceita na última revisão do Javascript. O grande número de APIs que usam callbacks em vez de promessas também é um empecilho ao uso da nova sintaxe.

Bônus: como implementar uma promessa

Uma forma de entender melhor como funciona a classe Promise é implementá-la do zero. O código abaixo implementa o mesmo exemplo de sempre fazendo uso de uma versão "caseira" de promessa, denominada HomebrewPromise.

// Fonte dentro do Zip: brew.js

let IDLE = 0;
let RUNNING = 1;
let FULFILLED = 2;
let REJECTED = 3;

class HomebrewPromise {
	constructor(cmd, hold) {
		this.state = IDLE;
		this.result = null;
		this.err = null;
		this.cmd = cmd;
		this.then_promise = null;
		this.catch_cmd = null;
		if (! hold) {
			this.__run();
		}
	}

	__run() {
		this.state = RUNNING;
		let self = this;

		let fulfilled = (result) => {
			self.state = FULFILLED;
			self.result = result;
			self.__do_then();
		}

		let rejected = (err) => {
			self.state = REJECTED;
			self.err = err;
			self.__do_catch();
		}

		setTimeout(() => {
			this.cmd(fulfilled, rejected);
		}, 0);
	}

	then(cmd) {
		let self = this;

		this.then_promise = new HomebrewPromise(
				(fulfill, reject) => {
			let runcmd = cmd(self.result);
			if (! runcmd || ! runcmd.then) {
				// cmd not a Promise
				// pass result forward
				fulfill(self.result);
			} else {
				// cmd is a Promise
				runcmd.then(fulfill).catch(reject);
			}
		}, true);

		return this.then_promise;
	}

	catch(cmd) {
		this.catch_cmd = cmd;
		return this;
	}

	__do_then() {
		if (this.then_promise) {
			this.then_promise.__run();
		}
	}

	__do_catch() {
		if (this.catch_cmd) {
			this.catch_cmd(this.err);
		} else if (this.then_promise) {
			this.then_promise.__forwarded_error(this.err);
		} else {
			throw "Unhandled rejection: " + this.err;
		}
	}

	__forwarded_error(err) {
		this.state = REJECTED;
		this.err = err;
		this.__do_catch();
	}
}

function op1()
{
	return new HomebrewPromise((fulfill, reject) => {
		console.log("op1 started");
		setTimeout(() => {
			let n = Math.random();
			if (n >= 0.75) {
				reject("op1 failed");
			} else {
				fulfill(n);
			}
		}, 1000);
	});
}

function op2(x)
{
	return new HomebrewPromise((fulfill, reject) => {
		console.log("op2 started");
		setTimeout(() => {
			let n = Math.random();
			if (n <= 0.25) {
				reject("op2. failed");
			} else {
				fulfill(Math.floor(x / 0.75 * 1000));
			}
		}, 1000);
	});
}

console.log("Start");

op1()
.then(op2)
.then((res) => {
	console.log("Success: " + res);
})
.catch((err) => {
	console.log("Error: " + err);
});

A implementação de HomebrewPromise é tranquila, exceto por alguns pontos-chave.

O primeiro e grande pulo-do-gato é que .then(f) sempre retorna um novo objeto da nossa própria classe (HomebrewPromise) cuja execução é diferida (atrasada) até que a primeira promessa seja cumprida. Nossa classe precisa suportar dois tipos de promessa: de execução imediata e de execução diferida, sendo que o segundo tipo é apenas para uso interno.

O segundo truque é distinguir se a função f() passada via .then(f) é uma nova promessa ou uma função ordinária. Mas só podemos fazer esta distinção depois de executar f(). Por conta disso, mesmo que f() retorne uma promessa, não podemos retorná-la diretamente em .then(f).

O terceiro truque é passagem de um erro para a próxima promessa da cadeia, caso esta promessa não possua um catch(). Devido a then(f) sempre retornar uma instância da nossa própria classe, sabemos que todas as promessas "futuras" são da nossa classe, o que nos permite usar um método "privado" __forwarded_error() para empurrar a bomba adiante.

e-mail icon