В этом посте я расскажу о том, как создаются промисы в JavaScript, о некоторых путаницах, с которыми я столкнулся при работе с промисами JS, и о том, как Clojure обрабатывает промисы.

Потребность в обещании в Twirl

При написании конкретного теста для Twirl — веб-приложения, сокращающего URL-адрес, — было важно, чтобы запрос на сокращение конкретной ссылки был отправлен позже. В соответствии с масштабом проекта таблица аналитики на главной странице приложения должна отображать 50 самых популярных ссылок, сокращенных каждым пользователем. Эти ссылки должны быть упорядочены в соответствии с их количеством доступов, то есть, чем чаще используется ссылка, тем выше она должна быть в списке. Также для ссылок с одинаковым количеством обращений их следует расположить в порядке убывания даты создания. Чтобы проверить эту последнюю часть (т. е. упорядочены ли ссылки с одинаковым количеством доступов в соответствии с датой их создания), было важно, чтобы ссылка была укорочена в последующий момент времени (как часть тестов).

Простейшим способом добиться этого было использование библиотечной функции setTimeout. Хотя эта функция принимает функцию обратного вызова, которая будет выполнена через указанный период времени, она не возвращает отложенное обещание. Например, для простой реализации функции setTimeout, а именно setTimeout(() => console.log("time up"),10000), будет возвращено следующее значение:

Конечно, функция обратного вызова в конечном итоге будет выполнена. Однако, поскольку setTimeout не возвращает промис, мы не можем использовать для него await. Это означает, что движок JavaScript не будет приостанавливать выполнение до тех пор, пока не будет выполнена функция обратного вызова. В результате конкретный тест, описанный выше, не прошел, так как тест завершался до того, как мог быть выполнен будущий запрос на сокращение ссылки.

Чтобы убедиться, что тест работает, нам нужно было создать новое обещание, вызвав конструктор Promise. Конструктор Promise ожидает функцию обратного вызова в качестве параметра. Эта функция обратного вызова принимает две дополнительные функции обратного вызова, обычно называемые resolve и reject. Обещание будет считаться «ожидающим» до тех пор, пока не будет вызвана одна из этих двух функций обратного вызова, а именно resolve или reject. Если вызывается resolve, это будет означать, что обещание было успешно разрешено. Если вызывается reject, это указывает на то, что произошла какая-то ошибка, и обещание не может быть разрешено.

Пытаясь понять, как создаются промисы, у меня возникло несколько вопросов. Во-первых, кто вызывает функцию resolve? И что такое resolve?

Кто вызывает функцию разрешения?

Я (ошибочно) предположил, что функция resolve вызывается движком JavaScript при успешном выполнении асинхронного блока кода. Причиной этой путаницы было то, что я смотрел на обещания через узкую линзу setTimeOut. В этом случае есть определенный способ узнать, когда разрешен блок асинхронного кода: после того, как период времени, переданный в setTimeOut, истек, и функция обратного вызова, переданная в setTimeOut, была успешно вызвана. Я (ошибочно) ожидал, что движок JavaScript вызовет функцию resolve, как только это будет завершено.

Однако это не так. Функция обратного вызова, переданная обещанию, должна вызвать resolve, чтобы указать конструктору, что конкретное обещание успешно завершило свое выполнение. В случае setTimeOut функцию resolve следует вызывать из внутри функции обратного вызова, переданной setTimeOut.

Что такое разрешающая функция?

Теперь, если нам нужно вручную вызвать функцию разрешения, что это на самом деле? В любом другом случае, когда мы пишем функцию, мы используем переменные для идентификации ее параметров. Это вызывающий объект этой функции, который передает определенные аргументы, тем самым присваивая конкретные значения этим именованным параметрам. Например, в следующей функции sum параметры n и m не имеют определенных значений. Только когда мы вызываем sum(5, 6), переменные n и m имеют определенное значение.

Но в случае промисов мы не вызываем функцию обратного вызова, переданную в промис. Цитируя MDN, функция обратного вызова выполняется конструктором в процессе создания нового объекта Promise.

В приведенном выше примере мы никогда не вызываем функцию callbk с соответствующими значениями для res и rej. Если мы не вызываем эту функцию, приписывая определенные значения параметрам resolve и reject, какое значение имеют эти параметры?

Получается, что функция обратного вызова, переданная конструктору Promise, на самом деле вызывается самим конструктором (а не пользователем). Значения resolve и reject имеют особое значение для конструктора, и все, что он делает, это следит за тем, когда эти функции вызываются для каждого промиса. Цитата из MDN, «В то время, когда конструктор генерирует новый объект Promise, он также генерирует соответствующую пару функций для разрешенияFunc и rejectionFunc; они "привязаны" к объекту Promise.'

Поскольку эти параметры имеют определенные значения, приписываемые конструктором, нет необходимости вызывать эти функции из тела самой функции обратного вызова. Мы можем извлечь эти функции за пределы блока функции обратного вызова, и это все равно будет работать:

Создание простого конструктора (Com)Promise

Чтобы лучше представить, как промисы разрешаются под капотом, я подумал о создании игрушечной версии конструктора.

Для простоты приведенный выше пример имеет дело только с параметром resolve. Следовательно, это не полная копия конструктора JS Promise, что делает его скомпрометированным (простите за каламбур!)

Хотя наш игрушечный конструктор не имеет всех наворотов Promise, он отражает его основную суть:

  • Он принимает функцию обратного вызова
  • Эта функция обратного вызова принимает другую функцию обратного вызова, resolve
  • Когда вызывается этот resolve, (ком)промис считается выполненным. Как узнать, выполнено ли обещание? Метод hasEnded конструктора вернет true, когда обещание завершит свое выполнение. Кроме того, метод value конструктора вернет переданное функции resolve исходной функцией обратного вызова.

Чтобы проверить это, давайте напишем простую функцию обратного вызова fn, которая вызывает resolve из функции обратного вызова setTimeout:

Теперь, когда мы создаем обещание с помощью нашего конструктора, выполнение должно завершиться через 10 секунд, как указано в fn. Чтобы проверить, работает ли это, мы можем написать функцию setInterval, которая периодически проверяет состояние промиса, созданного нашим конструктором:

В приведенном выше примере будет получен следующий результат:

Promise pending...
Promise pending...
Promise pending...
Promise pending...
Promise pending...
Promise pending...
Promise pending...
Promise pending...
Promise pending...
Resolved! The promise resolved to : Done
Resolved! The promise resolved to : Done
Resolved! The promise resolved to : Done
Resolved! The promise resolved to : Done

Работа с асинхронностью в Clojure

Поняв, как обещания обрабатываются в JavaScript, было интересно увидеть, как Clojure, функциональный язык программирования, справляется с тем же.

Фундаментальное различие между JavaScript и Clojure, когда дело доходит до обработки асинхронных блоков кода, заключается в том, что выполнение JavaScript происходит асинхронно по умолчанию, в то время как выполнение Clojure выполняется синхронно, если явно не указано иное. Это означает, что обычно, когда компилятор Clojure натыкается на асинхронный код (т. е. поток, выполнение которого займет некоторое время), он будет ждать завершения выполнения этого кода, прежде чем продолжить. Напротив, в случае JavaScript ответом компилятора по умолчанию будет создание нового потока для асинхронного кода и продолжение основного потока.

Будущее: как сделать что-то асинхронное

Когда мы помещаем ключевое слово future в определенный блок, это указывает компилятору, что ему не нужно ждать завершения выполнения текущего блока. Вместо этого он может продолжить работу без ожидания после создания нового потока для выполнения текущего блока.

Возьмем пример. В Clojure Thread/sleep работает примерно так же, как функция setTimeout в JavaScript: она приостанавливает выполнение текущего потока на указанный период времени. Таким образом, когда мы просто используем Thread/sleep в блоке, он приостанавливает выполнение всего кода на определенный период времени:

В приведенном выше примере результат выражения (+ 1 2) будет выполнен через 4 секунды, и только после этого будет напечатана строка now. Если мы хотим избежать этого и хотим, чтобы now печаталось мгновенно, мы можем использовать future в первой части выражения:

Это немедленно напечатает now. Через 4 секунды REPL вернет значение выражения (+ 1 2)

Ключевое слово deref можно использовать для хранения значения, возвращаемого после выполнения асинхронного блока кода (на что указывает использование future). Когда мы deref получаем результат future, и если этот future еще не завершил свою компиляцию, он приостанавливает выполнение до тех пор, пока не будет сгенерирован результат (во многом подобно тому, как await приостанавливает выполнение функции async):

В приведенном выше примере, если мы введем второй оператор (deref x) до того, как будет выполнено будущее x (т. е. до истечения 3 секунд), выполнение всего кода будет приостановлено до тех пор, пока не будет выполнено будущее x.

Но обратите внимание, что, как и в случае с await, использование deref не приводит к повторному вызову будущего. Он просто возвращает значение, сгенерированное выполнением конкретного будущего.

Обещания в Clojure

Чтобы создать новое обещание, нам нужно использовать ключевое слово promise:

(def p (promise))

Когда мы создаем обещание способом, показанным выше, обещание считается ожидающим выполнения. На самом деле, мы можем использовать realized?, чтобы проверить, было ли выполнено обещание:

(realized? p)

Вышеприведенное вернет false

Теперь, когда мы хотим указать, что это обещание реализовано (аналогично вызову resolve из функции обратного вызова, переданной конструктору Promise в JavaScript), мы просто deliver обещание:

(deliver p (+1 2))

Это завершает обещание. Второй аргумент, переданный deliver, указывает значение, которое будет возвращено этим промисом после использования deref:

(deref p)

Это вернет 3. Теперь realized? вернет true:

(realized? p)

Обещания: Clojure против JavaScript

Должен признать, что для новичка изучение того, как обещания реализованы в Clojure, было намного проще и понятнее.

Чтобы создать обещание в JavaScript, вам нужно сначала написать функцию обратного вызова, а затем вызвать первый параметр этой функции обратного вызова (которая сама по себе является еще одной функцией обратного вызова), когда вы хотите, чтобы обещание было реализовано.

Напротив, создание обещаний в Clojure относительно проще:

Когнитивная нагрузка при написании промисов на JavaScript кажется выше, чем при создании и разрешении промисов первого уровня, предоставляемых Clojure. Возможно, есть веская причина, по которой путь к созданию и разрешению промисов в JavaScript такой сложный.

Но для новичка вроде меня понимание того, как обещания обрабатываются в Clojure, было гораздо более простым и очевидным.

Первоначально опубликовано на https://otee.dev 10 ноября 2021 г.