Статья написана для ювелиров, которым в силу роста популярности бижутерии пришлось оставить свою работу и заняться другим делом немного смежным с их предыдущим.
Всякий раз, сталкиваясь с новой концепцией в программировании, пытаюсь найти ей отражение в реальном мире, обещания — они же договоренности, что это такое? Возьму на себя смелость опередить обещание, как описание процесса, в котором кто-то собирается сделать что-то в будущем для кого-то. Далее попытаемся формализовать это описание в широком смысле. В нашем абстрактом воображении могут возникнуть сразу три множества объектов, первое из них, это те, кто обещают, второе это то, что обещают и третье кому обещают. Уже сложновато, три множества объектов, какие на них действуют отношения?, как они связаны,? одни вопросы. Первое что мы можем сделать, для упрощения, так объединить первое с третьем множеством, поскольку природа их одинакова, это могут быть люди, вычислительные узлы и т.д… В рамках рассматриваемой конкретной технологической среды, связки Node Js, библиотека Q мы вообще можем исключить объединенное множество замкнув его на себя. Ведь в нашем случае, мы обещания даем самим себе, когда говорим, что что-то там исполниться в коде. Вообще то задачи, где могло быть место сразу двум множествам существуют. Например, легко вообразить среду из взаимодействующих между собой программных узлов, которые проделывают совместные вычисления, и тогда без двух множеств никак. Но мы будем рассматривать обещания в среде Node.Js на примере библиотеки Q.
Итак, мы договорились, что все что мы можем делать так это обещать самому себе, что какое-то множество задач будет исполнено. Ну как только возникает множество объектов, в нашем случае множество задач, так мы сразу можем вводить на этом множестве отношения, как это делают математики. И тут сразу всплывает одно из отношений, которое как никак подходит для нашего случая это -отношение порядка, оно означает, что что-то одно следует за другим. И как мы скоро убедимся это отношение реализуется посредством метода “then” объекта promise.
Вернемся в реальный мир, можно ли пообещать вашему программному менеджеру, что-то типа такого:
«Саша, слушай, ну я сделаю модуль загрузки после того, как сделаю модуль инициализации?»
Понято, что Сашу не устроят такие обещания, ему нужно от чего-то отталкиваться, а не цепочки отношений, висящие в воздухе. Теперь посмотрим на обещания с другой стороны, когда мы обещаем мы автоматически строим некое будущее для себя, мы проецируем наши действия в конкретное будущее, гении его проецируют в вечность, ну мы же будем рассматривать ближайшее будущее, хотя бы потому что, концепции вычисления тоже изменяются. И вот тут возникают новые вопросы, а как нам вообразить конкретное будущее в среде Node Js.
В основе Node.js лежит событийно-ориентированное и асинхронное (или реактивное) программирование с неблокирующим вводом/выводом – это взял из wiki. Что это все значит для нас, с точки зрения обещаний. Вообразим часы со стрелкой, каждый раз, когда она делает свой «тик» управление передается нашему скрипту, а когда она делает «так» управление передается Node, но это не означает, что Node работает только в фазе «так», просто в фазе «так» будем считать, что он сообщает нам о, результатах своей деятельности, по вопросам возложенных нами на него асинхронных задач ввода-вывода. Мы сделали первую попытку структурировать будущее, мы его опять поделили на «тик-так», ну позвольте мы опять ввели на множестве тиков отношение порядка. Видимо никак от него не уйти, и слава богу, не знаю как бы люди тогда программировали без причинно-следственной связи. Переходя к модели «тик-так» мы тем самым сужаем рамки ответственности упрощая нашу модель будущего, делая наши обещания более исполняемыми.
Теперь мы знаем, что в нашем распоряжении в будущем всегда есть фаза «тик», мы полновластные хозяева в этом будущем. Отдавая нам управление Node надеется, что мы его загрузим задачками, и мы как правило это делаем. Как мы это делаем, начнем сначала, есть стартовый скрипт, он начинает свое исполнение в фазе «тик», результатом его исполнения будут задачи, которые формируются базовым API Node, например fs.readFile(filename, [options], callback)
, если таковых задач не будет, то программа закончит свою работу. Как только задача завершиться Node постарается спланировать исполнение callback в следующей фазе «тик», передавая управление ей (callback), а в следствии чего нам. А далее мы в ней уже ставим другие задачи Node. А поскольку все асинхронное API реализовано на callback-ах у Node, то и задачи которые мы описываем, располагаются в них же.
И эту ситуацию обозвали «адом функций обратного вызова». Кстати, обсуждая те или иные вопросы на форумах люди очень хорошо дружат с этим адом, ведь стиль обмена сообщениями на форумах отвечает всем стандартам, рассматриваемого стиля. А почему это может стать проблемой в коде, ах да, программистам платят только за длину кода, за расширение его по горизонтали никто не заплатит, или нет, быть может не у всех программистов широкие экраны, что-то как-то причины то меркантильные. Ну конечно же пишут о callback hell с точки зрения стиля кодирования и немного опускают то, что за таким стилем может скрывается.
Давайте рассмотрим вот такую ситуацию.
a(arg1, function() {
var x1;
b(arg2, function() {
var x2;
setInterval(function Task() {
console.log('tik');
}, interval);
});
})
Здесь, когда мы ставим задачу Node в интервалах с периодом interval что-то делать, вся ветка дерева вызовов, её же называют цепочкой, замыканием, продолжает существовать в памяти, до тех пор пока живет и исполняется Task, хотя в ней могут и не использоваться описанные выше переменные и аргументы x1, x2, arg1, arg2… И это уже заставляет задумываться, а стоит ли писать такие вложенные задачи. Например, в нашем случае Task может просто выводить слово «тик».
Но это вопросы javascript, и его природы, это супер динамичный язык, где все происходит на этапе исполнения, интерпретации его, поэтому конечно структура кода отражает семантику его исполнения. В статических, компилируемых языках переменные функций и аргументы, располагаются на стеке или в регистрах и сразу же высвобождаются как только функция завершает свою работу и такой проблемы там не будет. А поскольку если существуют ситуации, которые могут привести к бесполезному расходу памяти или потери производительности, то лучше их избегать, а как?
И вот появляется концепция promise, следуя ей можно избежать, например, ситуации, описанной выше или, например вот такой. Чуть выше мы начинали очерчивать свое будущее, мы его поделили на две фазы, и поняли, что мы можем рисовать в фазе тик, а точнее давать задания на отрисовку Node js. Но что будет если мы последовательно в коде напишем два раза подряд код см. выше. Правильно, с неким интервалам Task будет исполняться по два раза, но в какой последовательности? А вот этого нам никто точно не скажет, мы можем только надеется на определенный порядок, но он нам не гарантируется. Теперь вспоминаем, что мы ввели в наше воображение отношение порядка на множестве задач, и мы имеем полное право требовать его исполнения, и концепция promise вкупе с then дают нам такое право, с помощью then мы можем связывать наши задачи в цепь, и требовать исполнения их одной за другой. Но позвольте мне здесь не приводить пример с then, начнем немного с другого. А чем может быть обещание с точки зрения реализации в javascript. А как множество объектов (задач) представить в рамках javascript. Вы не заметили, как я неявно о множестве объектов «как о том, что мы обещаем» начал говорить как уже о множестве задач, а потом его превратил в множество обещаний. Какая-то двойственность у объектов этого множества наблюдается. Так вот мы постепенно переходим к конкретной реализации.
Для того чтобы нам реализовать отношение порядка на рассматриваемом множестве в рамках языка программирования, мы каждый элемент этого множества должны наделить двойной природой, это одновременно и обещание и задача. Т.е. здесь задача — это набор инструкций к выполнению, который не имеет никого представления о том, когда он будет выполняться, а обещание как раз то, что отвечает за отношение порядка непосредственно, именно в нем скрыты механизмы определения момента начала исполнения задачи. Обещание это что-то осязаемое, типа бумажного договора, где прописаны условия исполнения задач, а задача — это процесс, что-то неосязаемое, их двойственность можно сравнить с парой пространство(обещание)-время(задача). Пространству мы можем сопоставить — память, а времени — код, инструкции. Но javascript содержит в себе эту двойственность нативно, функции могут иметь свойства, равно как объект методы. Выбирай, что хочешь.
Разработчики библиотеки Q решили, что лучшем вариантом сопоставить задаче нативную javascript функцию, а обещанию объект. Каким образом простая функция, в которой мы кодируем задачу, может стать задачей в рамках какой-нибудь концепции, что мы должны с ней сделать, чтобы ее выделить из всех? Понятно, что какой-то классифицирующий признак должен быть у нее, чтобы технология обещаний была применима к ней, если этого признака не будет, тогда как Q может отделить обычные функции от задач. Вариантов классификации у javascript достаточно. Но разработчики решили использовать механизм обертки функции, что вполне естественно и красиво вписывается в природу функционального javascript. Т.е. они говорят пользователям библиотеки, ребята пишите функции, передавайте их в специальные оберточные функции библиотеки и в их контексте мы будем рассматривать их как задачи. Т.е. по сути мы вообще можем ничего специального не делать, для того чтобы начать работу с концепцией promise.
То есть, для того чтобы нам гарантировать отношение порядка для двух задач мы можем написать:
Q.fcall(function task1() {…}).then(function task2(){…});
Здесь fcall и then как раз функции обертки, попадая в них task1 и task2 становятся задачами, мы ничего для этого не делали с функциями, и никакими скрытыми свойствами в процессе исполнения кода выше они не обрастут. Что нам дает это код, а даст он нам гарантированное исполнение task2 за task1, но уже в следующих «тиках». Под исполнением task я имею в виду выполнение функции, т.е. когда в функции будет достигнуто выражение return или конец ее (оператор yield, здесь я упущу). Если же в функциях task1 и task2 будет вызов API с асинхронными callback – ами, то последовательность исполнения не гарантируется кодом см.выше, callback-и буду сами по себе если мы для этого не предпримем специальных действий. Тогда вы спросите, а зачем тогда такая конструкция нужна, можно просто написать:
task1();
task2();
Да, можно, но когда одна из них, например первая, будет иметь внутри себя обращение к асинхронному API, а вторая не будет, то задать порядок кодом task1(); task2(); не делая внутри них для этого ничего, не получится. Еще пример, у нас есть такая последовательность задач A(), B(), C() А и С – асинхронные, а B синхронная, тогда мы можем написать Q.fcall(A).then(B).then(C)
, при этом как мы потом увидим в функции B не нужно возвращать специальный объект обещание, библиотека Q это сделает за нас сама.
Перейдем теперь к обещаниям, а где они здесь фигурируют, где эти бумажки, как говорится без бумажки мы с братом не двойняшки. В недрах конструкции см.выше рождаются обещания, и результатом исполнения её будет обещание. Как это происходит внутри и как реализована библиотека Q оставим это на время, самое главное понимать, что цепочка вызовов, ее иногда называют монадой Q функций (fcall, then и т.д.), почти всегда порождает обещание, которое в свою очередь можно рассматривать как связующее звено между другими монадами. Объяснение, что в недрах чего там создается не всегда достаточно для полного контроля ситуации, и конечно же в библиотеке предусмотрен механизм явного создания обещания.
А можно ли обойтись без него и для чего он нужен? Если мы работаем с только синхронными задачами, то тогда явно создавать обещания нам нет особой необходимости. Если мы работаем с асинхронным API Node JS, то для него в библиотеке Q есть оберточные функции, которые неявно создают обещания, т.е. существует теоретическая возможность не использовать механизм явного создания обещаний и даже в случае работы с асинхронными задачами. Более того этот оберточный стиль распространяется на все асинхронные функции других библиотек, в которых фигурирует callback функции вида (error, result) в качестве последнего параметра асинхронной функции.
Вот пример из manual библиотеки Q:
Q.nfapply(FS.readFile, ["foo.txt", "utf-8"]).done(function task (text) {
…
});
Здесь видно как вызов стандартной функции readFile Node JS. оборачивается в вызов nfapply, которая принимает два аргумента: обертываемая функция и массив аргументов, который будет передан readFile в последствии при ее вызове в следующем тике. А куда делась callback функция readFile? Её место в такой конструкции автоматически занимает функция, в нашем примере её имя task, переданная в метод объекта promise done. Каким-то удивительным образом Q стал понимать, что как только асинхронная функция исполнит свое задание, она передаст управление в наш task. А как она это делает.
Вот псевдокод того, что делает приблизительно функция nfapply:
function nfapply(readFile, [args…]) {
var deffered = Q.defer();
readFile([args…], deffered .resolver());
return deferred.promise;
}
Здесь создается объект deffered его метод resolver возвращает функцию, назовем ее handler, которая подставляется в качестве callback функции readFile, упрощенный вид handler примерно такой:
function handler(err, result) {
if (err)
deffered.reject(err);
else
deffered.resolve(result);
}
Я сознательно не трогаю вопросы, связанные с обработкой исключений, чтобы не усложнять базовое понимание.
Теперь становится очевидным, почему обертки для асинхронных функций накладывают ограничение на вид функции callback, достаточно поменять аргументы err, result местами, как семантика оберточной функции кардинально изменится и тут нам уже никак не обойтись без явного создания объектов, чтобы контролировать процессы разрешения и отклонения обещаний полностью.
Создать обещание можно просто, вызвав метод Q.defer(), который вернет объект “deferred” содержащий в себе объект promise, мы это уже видели см.выше. И методы объекта “deferred” позволяют нам делать с обещанием все тоже самое, что мы с ними делаем в реальной жизни, отклонять -reject, выполнять — resolve, сигнализировать о ходе выполнения — notify. Вот вам на досуге вопрос, а почему мы не можем обещания откладывать в рамках библиотеки Q, т.е. почему метод suspend не реализован.
Вернемся к нашим цепочкам последовательно исполняемых задач – функций. Давайте ее вообразим как беговую дорожку, на которой проходят соревнования по эстафетному бегу. Задача — это бегун, который пробегая свою дистанцию, передает флажок другому бегуну, следующей задаче. Флажок по идеи должен достигнуть финиша. Мы вполне можем рассматривать флажок как обещание, которое передается от одной задачи к другой, до тех пор пока другая задача решит его не подменить, или пока он не достигнет финиша. Подмена происходит простым образом, просто во время бега бегун достает из кармана свой флажок и передает его следующему бегуну, у которого нет совсем времени для того чтобы его разглядывать, он просто получает его и с ним он бежит дальше. Подменить флаг может любой бегун. Для того чтобы нам осуществить подмену в коде, мы просто в функции – задаче возвращаем объект promise. Когда мы так делаем библиотека Q поймет это, и следующая задача начнет свое выполнение только после того как мы отклоним или сдержим свое обещание, вызвав соответственно resolve или reject объекта deferred. Вот простая иллюстрация, базовый шаблон:
Q.fcall(function task1() {
var deferred = Q.defer();
asyncOp(function callback(result) {
…
deferred .resolve(result);
};)
return deferred.promise;
}).then(function task2(result){
console.log(result);
});
Если мы понимаем как соединить два звена, то мы с легкость должны собирать многозвенные цепочки. Но тут есть одно но, не на всем рассматриваемом нами множестве задач-обещаний, в реальной жизни может действовать отношение порядка, т.е. для каких то задач мы можем определить порядок между ними, а для каких-то нет. И вот те, которые по отношению друг другу не определяют порядок можно объединять в группы независимых друг от друга задач, их можно выполнять параллельно. И для это случая в библиотеке Q есть конструкции, которые позволяют реализовывать такое поведение вещей. Функция Q.all([…]), принимает в качестве аргумента массив объектов promise, давая нам с вами обещание, что как только все они исполняться или хоть одно из них отклониться, она даст знать нам об этом. А делает она это просто, возвращая нам все тоже обещание (объект promise), которое мы можем продолжать соединять с другими, но уже украшениями. Параллельные задачи можно рассматривать как украшения на цепочке. Теперь мы уже можем делать типовые ювелирные изделия.
Рассмотрим процесс вывода на орбиту обещания подробнее, когда мы пишем Q.defer() мы получаем объект deferred, почему не сразу обещание promise, зачем на нужен какой-то вспомогательный объект, опять двойственность какая-то. Когда мы выводим на орбиту ракету, что она теряет в процессе взлета?, правильно, одну за другой ступени. Здесь примерно происходит тоже самое. Сделал deferred свое дело, теперь может гулять смело. Когда мы создаем сей объект, все что нам от него необходимо, так это, как правило, вызов одного из его следующих методов reject, resolve, которые переводят наше обещание в некоторое, но уже неизменное состояние. Вызов этих методов можно как раз сравнить с моментом отделения ступени от ракеты, ракета после отделения ступени уже будет находиться в другом состоянии, если ступень удачно отделилась, то в ускоренном, если нет, то в состоянии обломков. Тоже самое с нашими обещаниями, либо оно будет исполнено resolve, либо отклонено reject, и повторю эти состояния неизменны, хотя бы в силу того, что после отделения ступени мы ее к себе ну никак не можем присоединить обратно, в рамках библиотеки Q. Но разница все же есть, отделившись ступень не может ракете ничего передать. А мы можем передать значение следующей задаче, если вызовем deferred.resolve(value) или deferred.reject(value) передавая в них это value значение.
Бааа, мы научились создавать обещания и передавать значения, результат наших обещанных трудов далее по цепочке задач, теперь можно рвать на себе рубашку и задаваться вопросом, а собственно говоря зачем нам нужны, nfapply, fcall и другие вспомогательные функции, которые не рассматривались здесь, скрывающие в себе процесс создания обещаний. Ответ простой — для удобства, т.е. грубо говоря и без них можно начинать связывать цепочки.
Спасибо за внимание, надеюсь это информация может стать кому-нибудь полезной.
Автор: aquilawriter