Перевод статьи James Coglan Callbacks are imperative, promises are functional: Node’s biggest missed opportunity
"Главная особенность обещаний в том, что они невосприимчивы к изменяющимся обстоятельствам"
Франк Андервуд, "Карточный домик"
Возможно, вы часто слышите, что JavaScript это "функциональный" язык программирования. Его так называют просто потому, что функции в нём - объекты первого рода: многие другие особенности, присущие функциональным языкам, такие как неизменяемые данные, предпочтение циклам рекурсии, алгебраические системы типов, избежание побочных эффектов - полностью отсутствуют. И, хотя функции высшего порядка, безусловно, полезны и позволяют программирвать в функциональном стиле, но представление, что JS это функциональный язык, зачастую затмевает основной аспект функционального программирования - программирование со значениями.
Сам термин "функциональное программирование" сбивает с толку тем, что заставляет множество людей считать его "программированием с функциями", как противопоставление программированию с объектами. Но, если объектно-ориентированное программирование рассматривает всё как объект, функциональное программирование рассматривает как значение - не только функции, а абсолютно всё. Это, конечно, включает в себя такие очевидные вещи, как числа, строки, списки и прочие данные, но также некоторые другие вещи, о которых мы, поклонники ООП, обычно не думаем как о значениях: операции ввода-вывода и прочие побочные эфекты, потоки событий GUI, проверки на NULL, даже понятие последовательности вызова функций. Если вы когда-либо слышали фразу "программируемая точка с запятой", то вы понимаете, о чём я говорю.
И в лучшем проявлении, функциональное программирование декларативно. В императивном программировании, мы пишем последовательности инструкций, которые сообщают машине, как сделать то, что мы хотим. В функциональом программировании мы описываем зависимости между значениями, которые сообщают машине, что мы хотим вычислить, и машина предлагает последовательности команд, необходимых для этого вычисления.
Если вы когда либо пользовались Excel, вы использовали и функциональное программирование: вы моделировали проблему, описывая в виде графа как связаны между собой различные значения. Когда добавлялись новые данные, Excel выясняет, как это отразится на графе данных и обновляет для вас всё самостоятельно, не заставляя вас писать для этого последовательности из инструкций.
Имея такое определение, я хочу пояснить, что, по моему мнению, является основной ошибкой дизайна, внесённой Node.JS: решение, сделаное в самом начале его развития: решение использовать API на основе коллбеков, вместо "обещаний" (promises).
Все используют [коллбеки]. Если вы опубликуете модуль, который возвращает "обещания", никому это не будет интересно. Никто не будет пользоваться им.
Если я напишу свою небольшую библиотеку, которая будет общаться с Redis, и это последнее, что она делает, то я могу просто передать ей коллбек, который направит меня к редису. ***????*** И когда мы встретим такие проблемы, как коллбек-хелл, я открою вам секрет: существует так же и корутин-хелл и монад-хелл и ад для любых абстракций, если вы используете их достаточно много.
Для 90% случаев у нас имеется этот супер простой интерфейс, так что когда вам нужно сделать одну вещь, вам достатчно сделать небольшой шажок, и всё готово. Но если у вас сложный случай, то вы пойдёте и установите "async", так же, как и другие 827 модулей, которые зависят от него на npm.
Mikeal Rogers, LXJS 2012
Эта цитата из недавнего выступления Mikeal Rogers, которое охватывает различные аспекты философии дизайна Node.JS:
В свете заявленной цели дизайна Node - сделать разработку быстрых, конкурентных, сетевых программ простой для не-экспертов, я считаю такое отношение контрпродуктивным. "Обещания" позволяют гораздо проще конструировать корректные, максимально конкурентные програмы, делая управление потоком исполнения чем-то, чем будет заниматься рантайм языка, вместо того, чтобы заставлять пользователя реализовывать его самостоятельно.
Написание корректных конкурентных программ обычно сводится к тому, чтобы совершать столько операций параллельно, сколько возможно, и при этом быть уверенным, что операции продолжают выполняться в правильном порядке. Несмотря на то, что JavaScript однопоточен, мы всё ещё можем получить race-condition из за асинхронности: любое действие, которое включает в себя IO, может освободить ресурсы процессора для других действий, пока оно ожидает срабатывания коллбека. Множество конкуррентных действий могут работать с общими данными в памяти, или выполнять перекрывающиеся последовательности команд в базе данных или DOM. Что я надеюсь показать в этой статье, "обещания" предоставляют способ описать прблемы, используя взаимозависимости между значениями, как в Экселе, так что ваши инструменты смогут корректно оптимизировать решение за вас, вместо того, чтобы вам придумывать управление потоками самостоятельно.
Я надеюсь развеять недопонимание, будто "обещания" это что то, что даёт более аккуратный синтаксис для основанной на коллбеках асинхронной работы. На самом деле, "обещания" позволяют моделировать вашу проблему фундаментально иным способом; они глубже, чем синтаксис, и влияют на то, как вы решаете проблемы на уровне семантики.
Для начала, я бы хотел вернуться к статье, которую я написал несколько лет назад, о том, что "обещания" это монады асинхронного программирования. Главный урок той статьи был в том, что монады это инструмент, который помогает вам соединять функции, т.е. строить цепочки, где результаты одних функций становятся входом для других. Это достигается использоваием структурных связей между значениями, и эти значения и их связи вновь будут играть здесь важную роль.
Я собираюсь использовать нотации типов Haskell далее, чтобы проиллюстрировать некоторые вещи. В Haskell запись
foo :: bar
обозначает "
foo
является значением типа
bar
". Запись
foo :: Bar -> Qux
обозначает "
foo
является функцией, которая принимает значение типа
Bar
и возвращает значение типа
Qux
". Если конкретные типы ввода/вывода функции не важны, мы используем одиночные строчные буквы
foo :: a -> b
. Если
foo
принимает несколько аргументов, мы добавляем больше стрелок, т.е.
foo :: a -> b -> c
означает, что
foo
принимает два аргумента с типами
a
и
b
и возвращает что-то с типом
c
.
Давайте, для примера, рассмотрим функцию Node.JS
fs.readFile()
. Она принимает имя файла в виде строки (
String
) и коллбек, и не возвращает ничего. Коллбек принимает ошибку (
Error
, которая может быть
null
) и
Buffer
, в котором находится содержимое файла, и так же не возвращает ничего. Мы можем сказать, что тип функции
readFile
это:
readFile :: String -> Callback -> ()
()
в нотации Haskell это тип
null
. Коллбек сам по себе это функция, описание типа которой:
Callback :: Error -> Buffer -> ()
Совместив всё, мы можем сказать, что
readFile
принимает String и функцию, которая вызывается с Buffer:
readFile :: String -> (Error -> Buffer -> ()) -> ()
Теперь, давайте представим, что Node использует "обещания". В этой ситуации, ReadFile будет просто принимать
String
и возвращать "обещание Buffer"
readFile :: String -> Promise Buffer
В более общем виде, мы можем сказать, что функции, использующие коллбеки принимают какие-то входные данные и коллбек, который будет вызван с какими-то выходными данными, а функции, использующие "обещания" принимают какие-то входные данные и возвращают "обещание" каких-то выходных данных.
callback :: a -> (Error -> b -> ()) -> ()
promise :: a -> Promise b
Эти null-значения, возвращаемые функциями, использующими коллбеки и есть причина того, что программирование с коллбеками сложное: такие функции ничего не возвращают, из за чего их сложно объединять. Функция, которая ничего не возвращает, вызывается только ради её сайд-эффекта, т.к. функция без сайд-эффекта и без возвращаемого значения - это просто чёрная дыра. Так что, программирование с коллбеками императивно по своей сути, речь идёт об упорядочивании выполнения полных сайд-эффектов процедур, вместо отображения входящих данных в выходящие посредством применения функций. Речь идёт ***!!!???*** о ручном управлении потоком исполнения, вместо решения проблем через взаимосвязь значений. И это именно то, что делает написание корректных конкурентных программ сложным.
В отличие от этого, функции, основаннные на "обещаниях" всегда позволяют вам воспринимать результат функции как значение, в виде, не зависящем от времени. Когда вы вызываете функцию с коллбеком, существует некотрый промежутк времени между моментом, когда вы вызываете функцию и моментом, когда будет вызван коллбек. И в течение этого промежутка нет никакого представления результата нигде в программе.
fs.readFile('file1.txt',
// проходит какое-то время...
function(error, buffer) {
// результат начинает существовать
}
);
Получение результата из коллбека или event-based функции как правило означает "быть в правильном месте в нужное время". Если вы добавляете ваш обработчик события после того как событие уже сработало, либо у вас нет нужного обработчика для события, то вам не повезло - вы упустили результат. Такого рода вещи мешают писать HTTP сервра на Node. Если ваш поток управления не настроен как следует, ваша программа ломается.
"Обещания", с другой стороны, не зависят от времени или последовательности. Вы можете добавить обработчиков к "обещанию" как до, так и после того, как оно сработает, и вы сможете получить из него значение. Таким образом, функции, которые возвращают "обещания", сразу дают вам значение, представляющее результат, которое вы можете использовать в качестве значений первого рода и передавать в другие функции. Здесь отсутствуют "ожидание в сторонке", пока отработает коллбек, или какая-то возможность упустить событие. Пока у вас есть ссылка на "обещание", вы можете получить из него значение.
var p1 = new Promise();
p1.then(console.log);
p1.resolve(42);
var p2 = new Promise();
p2.resolve(2013);
p2.then(console.log);
// напечатает:
// 42
// 2013
Таким образом, хотя имя метода
then()
подразумевает что-то, связанное с последовательностью операций - и на самом деле это является побочным эффектом её работы ***???*** - вы можете думать о ней, как о функции
unwrap()
("развернуть"). "Обещание" - это контейнер для "пока ещё неизвестного значения", и задача
then()
- извлечь значение из "обещания" и передать его другой функции: это функция
bind
для монад. Приведённый выше код ничего не говорит о том, когда значение стало доступно, или в каком порядке происходят события, он просто описывает некоторые зависимости: для того, чтобы залоггировать значение, вы должны сперва узнать это значение. Порядок исполнения программы автоматически вытекает из этой информации о зависимости. Это довольно тонкое различие, но мы увидим его более явно, когда будем обсуждать "ленивые обещания" в конце этой статьи.
Пока что все наши примеры были довольно тривиальными; маленькие функции, которые почти не взаимодействуют друг с другом. Чтобы понять, почему "обещания" более мощные, давайте попробуем что-нибудь посложнее. Скажем, у нас есть код, который получает время создания файла множества файлов, используя
fs.stat()
. Если бы это было синхронным, мы бы просто вызвали
paths.map(fs.stat)
, но, т.к. мэппинг с асинхронными функциями сложен, мы попробуем модуль
async
.
var async = require('async'),
fs = require('fs');
var paths = ['file1.txt', 'file2.txt', 'file3.txt'];
async.map(paths, fs.stat, function(error, results) {
// используем результат
});
(Да, я знаю, что у функций модуля
fs
есть синхронные версии, но большая часть IO не имеют такой опции. Так что подыграйте мне.)
Всё это выглядит хорошо и правильно, пока мы не захотим узнать размер первого файла для некоторой отдельной задачи. Мы можем вызвать для него
fs.stat()
снова:
var paths = ['file1.txt', 'file2.txt', 'file3.txt'];
async.map(paths, fs.stat, function(error, results) {
// используем результат
});
fs.stat(paths[0], function(error, stat) {
// используем stat.size
});
Это работает, но теперь мы вызываем
fs.stat()
дважды. Это может быть нормальным для операций с локальными файлами, но если мы загружаем некие большие файлы по HTTPS, это может стать большой проблемой. Мы решаем, что хотим дёрнуть файл только один раз, так что мы откатываемся к предыдущей версии, но обрабатываем первый файл отдельно:
var paths = ['file1.txt', 'file2.txt', 'file3.txt'];
async.map(paths, fs.stat, function(error, results) {
var size = results[0].size;
// используем size
// используем остальные результаты
});
Это работает, но теперь наша вторая задача (использующая размер первого файла) блокируется в ожидании, пока обработается весь список. И, если возникнет ошибка с любым элементом из списка, мы всё равно не получим результат для первого элемента. Это не хорошо, так что мы пробуем следующий подход: мы отделяем первый файл от общего списка и обрабатываем его отдельно:
var paths = ['file1.txt', 'file2.txt', 'file3.txt'],
file1 = paths.shift();
fs.stat(file1, function(error, stat) {
// используем stat.size
async.map(paths, fs.stat, function(error, results) {
results.unshift(stat);
// используем результат
});
});
Это так же работает, но теперь наша программа работает не параллельно: она будет работать дольше, т.к. мы не начинаем обрабатывать список файлов, пока не обработаем первый. До этого все они обрабатывались параллельно. Помимо этого, мы были вынуждены совершать некоторые манипуляции с массивами, чтобы учесть тот факт, что мы обрабатывали первый файл отдельно от остальных.
Ок, последний рывок к успеху. Мы знаем, что мы хотим получить
stat
для всех файлов, дёргая каждый файл только один раз, проделать какую-то работу над первым результатом, если он будет успешно получен и, если целый список так же будет успешно обработан, мы хотим проделать какую-то работу для этих результатов. Имея эти знания о зависимостях, попробуем выразить всё, используя
async
.
var paths = ['file1.txt', 'file2.txt', 'file3.txt'],
file1 = paths.shift();
async.parallel([
function(callback) {
fs.stat(file1, function(error, stat) {
// используем stat.size
callback(error, stat);
});
},
function(callback) {
async.map(paths, fs.stat, callback);
}
], function(error, results) {
var stats = [results[0]].concat(results[1]);
// используем stats
});
Теперь всё правильно: каждый файл дёргается всего один раз, работа делается полностью параллельно, мы можем получить результаты для первого файла независимо от остальных и зависимые задачи выполняются как только это становится возможным. Миссия выполнена!
Ну, не совсем. Я уверен, что всё это выглядит весьма уродливо и это, несомненно, не масштабируется в случае, если проблема станет более сложной. Было проделано много работы, чтобы просто сделать работу программы корректной, намерения такого дизайна становятся неочевидными, так что дальнейшая поддержка скорее всего что-то сломает, последующие задачи смешиваются со стратегией того, как продолжать выполнять требования ***???***, и мы по прежнему имеем некоторые лишние перемешивания массива, чтобы сгладить последствия добавленного нами специального случая. Фу!
Все эти проблемы связаны с тем, что мы используем поток управления в качестве основного средства решения проблемы, вместо того, чтобы использовать зависимости между данными. Вместо того, чтобы сказать "для того, чтобы выполнить эту задачу, мне нужны вот эти данные" и позволить рантайму решить, как оптимизировать это, мы явно говорим рантайму, что должно быть распараллелено а что должно работать последовательно. И это приводит к крайне хрупким решениям.
Но как же "обещания" могут исправить ситуацию? Ну, прежде всего нам нужны функции для работы с файловой системой, которые будут возвращать "обещания", вместо того, чтобы принимать коллбеки. Но, вместо того, чтобы писать такие функции руками, попробуем использовать мета-программирование для того, чтобы сконвертировать для нас таким образом любую функцию ***!!!***. Например, пусть оно принимает функцию с типом
String -> (Error -> Stat -> ()) -> ()
и возвращает другую функцию с типом
String -> Promise Stat
Вот такая функция:
// promisify :: (a -> (Error -> b -> ()) -> ()) -> (a -> Promise b)
var promisify = function(fn, receiver) {
return function() {
var slice = Array.prototype.slice,
args = slice.call(arguments, 0, fn.length - 1),
promise = new Promise();
args.push(function() {
var results = slice.call(arguments),
error = results.shift();
if (error) promise.reject(error);
else promise.resolve.apply(promise, results);
});
fn.apply(receiver, args);
return promise;
};
};
(Это решение не совсем общее, но оно будет работать для нашего случая.)
Теперь мы можем заново смоделировать нашу проблему. Всё, что мы делаем теперь - просто маппинг списка имён файлов на список "обещаний"
stat
:
var fs_stat = promisify(fs.stat);
var paths = ['file1.txt', 'file2.txt', 'file3.txt'];
// [String] -> [Promise Stat]
var statsPromises = paths.map(fs_stat);
Такой подход приносит первые плоды сразу же: если с использованием
async.map()
у вас нет данных для обработки, до тех пор, пока не будет обработан весь список, то имея список "обещаний" вы можете просто взять первый элемент и работать с ним:
statsPromises[0].then(function(stat) { /* используем stat.size */ });
Таким образом, используя "обещания", мы уже решили большую часть нашей проблемы: мы выполнили
stat
для каждого файла параллельно и получили независимый доступ не только к первому файлу, но и к любому файлу, который мы выберем, просто получая элементы массива. При предыдущем нашем подходе мы были вынуждены явно описывать обработку первого файла способами, которые не ложатся так уж тривиально на представление того, какой именно файл вам нужен ***???***. Но, используя списки "обещаний", это становится простым.
Неодстающая часть нашего решения - как поступить, когда нам нужен список всех результатов
stat
. В предыдущих попытках мы заканчивали на том, что у нас есть список всех объектов
Stat
, но сейчас у нас есть только список объектов
Promise Stat
. Мы хотим подождать, пока все "обещания" сработают, и затем вернуть список всех
Stat
объектов. Другими словами, мы хотим превратить список "обещаний" в "обещание списка".
Давайте сделаем это, просто расширив массивы методами "обещаний", так, что список обещаний сам становится "обещанием", которое сработает тогда, когда сработают все его элементы.
// list :: [Promise a] -> Promise [a]
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 += 1;
if (done === promises.length) promises.resolve(results);
}, function(error) {
promises.reject(error);
});
});
if (promises.length === 0) promises.resolve(results);
return promises;
};
(Эта функция похожа на функцию
jQuery.when()
, которая принимает список обещаний и возвращает новое обещание, которое срабатывает тогда, когда срабатывают все входящие аргументы.)
Теперь мы можем подождать все результаты, просто обернув наш массив в "обещание":
list(statsPromises).then(function(stats) { /* используем stats */ });
Таким образом, наше решение целиком уменьшилось до:
var fs_stat = promisify(fs.stat);
var paths = ['file1.txt', 'file2.txt', 'file3.txt'],
statsPromises = list(paths.map(fs_stat));
statsPromises[0].then(function(stat) {
// используем stat.size
});
statsPromises.then(function(stats) {
// используем stats
});
Это описание решения, несомненно, более ясное. Используя немного обобщённого "клея" (наши функции-хелперы для "обещаний") и уже существующие методы массивов, мы решили нашу проблему корректным, эффективным методом, к тому же программа легко поддаётся изменениям. Нам не нужны специализированные методы и коллекции модуля
async
, мы просто берём независимые идеи "обещаний" и массивов и комбинируем их крайне эффективным способом.
Кстати, обратите внимание, что эта пргорамма ничего не говорит о том, что будет выполняться параллельно или последовательно. Она просто говорит что мы хотим получить, и от чего это зависит. А всеми оптимизациями занимается библиотека "обещаний".
По факту, многие операции с коллекциями модуля
async
могут быть с лёгкостью заменены операциями над списками обещаний. Мы уже видели как работает
map
; следующий код:
async.map(inputs, fn, function(error, results) {});
эквивалентен коду:
list(inputs.map(promisify(fn))).then(
function(results) {},
function(error) {}
);
async.each()
это просто
async.map()
, но вы выполняете функции только ради их сайд-эффектов и просто выбрасываете возвращаемые значения; вы можете использовать
map()
вместо него.
async.mapSeries()
(и, по аналогии с предыдущим примером,
async.eachSeries()
) это эквивалент выполнения
reduce
над списком "обещаний". Так и есть, вы берёте ваш список входных значений, и вызываете
reduce
, чтобы получить обещание, в котором каждое действие зависит от от успешности предыдущего. Давайте рассмотрим пример: реализуем эквивалент
rm -rf
на основе
fs.rmdir()
. Этот код:
var dirs = ['a/b/c', 'a/b', 'a'];
async.mapSeries(dirs, fs.rmdir, function(error) {});
эквивалентен следующему:
var dirs = ['a/b/c', 'a/b', 'a'],
fs_rmdir = promisify(fs.rmdir);
var rm_rf = dirs.reduce(function(promise, path) {
return promise.then(function() { return fs_rmdir(path) });
}, unit());
rm_rf.then(
function() {},
function(error) {}
);
Где
unit()
представляет собой функцию, которая возвращает уже сработавшее "обещание", необходимое для того, чтобы начать цепочку (если вы понимаете монады, это функция
return
для обещаний):
// unit :: a -> Promise a
var unit = function(a) {
var promise = new Promise();
promise.resolve(a);
return promise;
};
Этот
reduce()
подход просто берёт каждую пару последовательных путей в списке и использует
promise.then()
, чтобы сделать удаление пути зависимым от успешности пердыдущего шага. Оно обрабатывает удаление непустых директорий за вас: если предыдущее "обещание" было отменено из за такого рода ошибки, цепочка просто обрывается. Использование зависимости от значений для управления порядком исполнения - это основная идея того, как функциональные языки используют монады для обработки сайд-эффектов.
Этот последний пример несколько более многословный, по сравнению с кодом на
async
, но пусть это вас не смущает. Основная идея в том, что мы объединили отдельные идеи: "обещания" и операции с массивами, вместо того, чтобы полагаться на отдельные библиотеки управления потоком исполнения. Как мы видели ранее, этот подход позволяет писать более лёгкие для восприятия программы.
И они легче для восприятия именно потому, что мы переложили часть нашего мыслительного процесса на машину. При использовании
async
ход наших мыслей такой:
* A. Задачи в этой программе зависят друг от друга
так то,
* B. В связи с этим, операции должны быть упорядочены
вот так
* C. В связи с этим, давайте напишем код, чтобы описать B.
Использование графов зависимостей "обещаний" позволяет вам пропустить шаг B целиком. Вы пишете код, который описывает зависимости задач и позволяете компьютеру управлять потоком исполнения. Иначе говоря, коллбеки используют явный контроль потока исполнения, для того чтобы объединить множество маленьких значений, тогда как "обещания" используют явные зависимости между значениями, чтобы собирать вместе маленькие кусочки потока исполнения. Коллбеки импервтивны, "обещания" функциональны.
Обсуждение этой темы было бы неполным без одного финального применения "обещаний" и ключевой идеи функционального программирования: ленивости. Haskell - ленивый язык, что означает, что вместо рассмотрения вашей программы как скрипта, который исполняется сверху-вниз, он начинает с выражения, которое описывает вывод (ответ) программы - что программа пишет в stdio, базы данных и т.п. - и работает в обратном порядке. Он смотрит от каких выражений зависит это финальное выражение и двигается по этому графу в обратном порядке до тех пор, пока не вычислит всё, что нужно программе для того, чтобы сгенерировать вывод (ответ). Вычисления происходят только в том случае, когда они действительно нужны программе для того, чтобы выполнить свою работу.
Зачастую, лучшее решение какой-либо проблемы в computer science состоит в том, чтобы найти правильную структуру данных, моделирующую эту проблему. И у JavaScript имеется одна проблема очень похожая на тольо что мной описанную: загрузка модулей. Вы хотите загружать только те модули, которые действительно нужны вашей программе, и вы хотите делать это как можно эффективнее.
До того, как у нас появились CommonJS и AMD, котрые фактически являются способами управления зависимостями ***???***, у нас была кучка библиотек загрузки скриптов. Они в основном работали так же, как наш пример выше, где вы явно указываете, какие файлы могут быть загружены параллельно, а какие должны быть упорядочены. Вы, в основном, должны были изложить стратегию загрузки, что значительно труднее сделать корректно и эффективно, в отличие от того, чтобы просто описать зависимости между скриптами и позволить загрузчику оптимизировать всё за вас.
Давайте введём понятие
LazyPromise
. Это объект "обещание", который содержит функцию, которая выполняет какую то, возможно асинхронную, работу. Функция выполняется только один раз, когда кто-то вызывает метод
then()
"обещания": мы начинаем её выполнять только тогда, когда кому-то понадобится её результат. Это достигается переопределением
then()
таким образом, чтобы она начинала выполнять работу, если та ещё не стартовала.
var Promise = require('rsvp').Promise,
util = require('util');
var LazyPromise = function(factory) {
this._factory = factory;
this._started = false;
};
util.inherits(LazyPromise, Promise);
LazyPromise.prototype.then = function() {
if (!this._started) {
this._started = true;
var self = this;
this._factory(function(error, result) {
if (error) self.reject(error);
else self.resolve(result);
});
}
return Promise.prototype.then.apply(this, arguments);
};
Например, следующая программа не делает ничего: т.к. мы не требуем результат "обещания", никакой работы сделано и не будет. (как жизненно! прим. пер.)
var delayed = new LazyPromise(function(callback) {
console.log('Started');
setTimeout(function() {
console.log('Done');
callback(null, 42);
}, 1000);
});
Но если мы добавим следующую строук, то программа напечатает
Started
, подождёт секунду, после чего напечатает
Done
и
42
.
delayed.then(console.log);
И, т.к. работа будет проделана только один раз, многократный вызов
then()
выдаёт результат каждый раз но не проделывает работу заново:
delayed.then(console.log);
delayed.then(console.log);
delayed.then(console.log);
// печатает:
// Started
// -- ждёт 1 секунду --
// Done
// 42
// 42
// 42
Используя эту крайне простую обобщённую абстракцию, мы можем построить оптимизирующую модульную структуру за минимальное время. Представьте, что мы хотим сделать кучу вот таких модулей: каждый модуль создаётся с именем, списком модулей, от которых он зависит и фабрикой, которая, если её вызвать со списком загруженных зависимостей, возвращает API модуля. Это очень похоже на то, как работает AMD.
// файл a.js
var A = new Module('A', [], function() {
return {
logBase: function(x, y) {
return Math.log(x) / Math.log(y);
}
};
});
// файл b.js
var B = new Module('B', [A], function(a) {
return {
doMath: function(x, y) {
return 'B result is: ' + a.logBase(x, y);
}
};
});
// файл c.js
var C = new Module('C', [A], function(a) {
return {
doMath: function(x, y) {
return 'C result is: ' + a.logBase(y, x);
}
};
});
// файл d.js
var D = new Module('D', [B, C], function(b, c) {
return {
run: function(x, y) {
console.log(b.doMath(x, y));
console.log(c.doMath(x, y));
}
};
});
У нас получился ромб: D зависит от B и C, каждый из которых зависит от A. Это означает, что мы можем загрузить A, затем параллельно B и C. Далее, когда они все загрузятся, мы можем загружать D. Но мы хотим, чтобы наша утилита сделала всё за нас, вместо того, чтобы описывать эту стратегию самостоятельно.
Мы легко можем сделать это, смоделировав модуль как подтип
LazyPromise
. Его конструктор просто спрашивает значения своих зависимостей используя написанный ранее хелпер
list
, затем, после таймаута, который симулирует задержку на асинхронную загрузку, создаёт модуль с этими зависимостями.
var DELAY = 1000;
var Module = function(name, deps, factory) {
this._factory = function(callback) {
list(deps).then(function(apis) {
console.log('-- module LOAD: ' + name);
setTimeout(function() {
console.log('-- module done: ' + name);
var api = factory.apply(this, apis);
callback(null, api);
}, DELAY);
});
};
};
util.inherits(Module, LazyPromise);
Т.к.
Module
это
LazyPromise
, простое их определение, как в примере выше, не загружает ни один из них. Мы начинаем загружать их только тогда, когда пытаемся ими воспользоваться:
D.then(function(d) { d.run(1000, 2) });
// напечатает:
//
// -- module LOAD: A
// ..ждём 1 секунду..
// -- module done: A
// -- module LOAD: B
// -- module LOAD: C
// ..ждём 1 секунду..
// -- module done: B
// -- module done: C
// -- module LOAD: D
// ..ждём 1 секунду..
// -- module done: D
// B result is: 9.965784284662087
// C result is: 0.10034333188799373
Как видим,
A
загружается первым, затем, когда загрузка завершится,
B
и
C
начинают загружаться одновременно, и, после того, как оба они загрузятся, загружается
D
- в точности как мы и хотели. Если вы попробуете вызвать просто
C.then(function() {})
, то увидите, что загрузятся только A и C; модули, которые не находятся в графе необходимых нам, не загружаются.
Таким образом, мы создали корректный оптимизирующий загрузчик модулей практически не написав кода, просто используя граф ленивых "обещаний". Мы воспользовались функциональным подходом - использование зависимостей значений, вместо явного потока исполнения для решения задачи и это было намного легче, чем если бы мы решили описать поток исполнения самостоятельно в качестве основного способа в нашем решении ***???***. Вы можете передать этой библиотеке любой ациклический граф зависимостей, и она оптимизирует для вас поток исполнения.
Такова реальная сила "обещаний". Они не просто способ избежать пирамиды отступов на уровне синтаксиса. Они предоставляют вам абстракцию, которая позволяет вам моделировать проблемы на более высоком уровне и перекладывать больше работы на ваши инструменты. И, на самом деле, это именно то, что все мы должны требовать от нашего ПО. Если Node действительно серьёзно хочет сделать конкуррентное программирование простым, они должны дать обещаниям второй шанс.
James Coglan Callbacks are imperative, promises are functional: Node’s biggest missed opportunity