Невысказанные недостатки JavaScript Promises

Несколько лет назад у JavaScript была проблема обратного вызова. Сообщество упорно работало, чтобы заменить обратные вызовы обещаниями. Теперь у JavaScript есть проблема с обещаниями.

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

В этой статье я хотел бы обратиться к каждому недостатку, обнаруженному мною в текущих реализациях Promises / A +, и показать, как все эти тонкие проблемы вместе взятые создают изменчивые API, которые могут не быть хорошей основой для написания асинхронных программ.

Рвение

В первую очередь обещания - это нетерпеливые: когда они построены, они сразу же начинают работу. Их не волнует, заинтересованы ли пользователи в результате операции.

Обещания жаждут, потому что они были смоделированы с самого начала не для контроля над вычислением, а просто для представления его результата. Ранние версии обещаний, часто называемые отложенными, даже не касались вычислений. Они были объектами-посредниками, несущими информацию о результате какого-то внешнего процесса.

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

Аннулирование

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

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

Чтобы проиллюстрировать эту важность, попробуйте запустить следующую команду в своем терминале, предполагая, что у вас установлен Node:

node -e '
  Promise.race([
    Promise.resolve("Done."),
    new Promise((res) => setTimeout(res, 20000, 1))
  ])
  .then(console.log)
'

Вы обнаружите, что результат появляется через несколько миллисекунд, но процесс остается активным еще 20 секунд только потому, что какая-то ненужная подпрограмма все еще занимает цикл обработки событий.

Специализированный API

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

На момент разработки спецификации Promises / A + уже существовало несколько существующих и популярных библиотек Promise, и было важно, чтобы различные реализации взаимодействовали друг с другом. Из-за этого было решено, что когда пользователи возвращают объект с then-функцией в свое обещание, он должен ассимилироваться, как если бы это было обещание. Эта автоматическая ассимиляция также выполняется многими реализациями Promise во время создания обещания, что фактически делает невозможным создание обещания или обещания. Однако автоматическая ассимиляция имеет некоторые недостатки:

Во-первых, тот факт, что простое существование then функции на объекте приведет к изменению поведения Promises, подвергает пользователей потенциальной опасности случайно предоставления чего-то, что слишком похоже на Promise и вызывает программа плохо себя ведет. Выполните следующее для примера некорректного поведения промисов:

node -p '
  Promise.resolve({ then: () => console.log("Hello!") })
'

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

Но особый подход к ценностям в целом имеет недостаток, который, вероятно, станет более очевидным в будущем.

Проблема в том, что обработка определенных значений специально нарушает свойство, которое теоретики типов называют параметрическим полиморфизмом. Отсутствие поддержки этого свойства означает, что рассматриваемый тип данных (обещания) не может использоваться в качестве одной из общих абстракций, которые мы будем видеть все чаще и чаще по мере того, как программисты догоняют математиков. Я говорю о функторах, монадах и других подобных инопланетных концепциях. Если я просто потеряю тебя там, не волнуйся! О них больше не будет упоминания. Если я вас заинтересовал, то советую вам взглянуть на Проект Fantasy Land.

Обратите внимание, что некоторые реализации Promise (например, Creed) позволяют пользователям обходить этот особый подход, предлагая альтернативы таким функциям, как resolve и then. Эти реализации в некоторой степени безопаснее в использовании, и их можно абстрагировать.

Обработка ошибок

Еще одна знакомая проблема заключается в том, что пользователей не заставляют обрабатывать отказы. Первоначально, когда Promise принимал отклоненное состояние и не было зарегистрировано никаких обработчиков отклонения, он оставался безмолвным. К тому времени, когда люди поняли, что это вредное поведение, Promises уже стали мейнстримом.

Это всех укусило: забыли позвонить catch в конце какой-то цепочки обещаний? Через несколько месяцев выясняется, что на некоторые запросы, похоже, так и не поступает ответ.

Что-то должно было быть сделано. Некоторые библиотеки добавляли функцию done(), которую пользователи должны были вызывать в конце цепочки, но у них были те же проблемы. Bluebird реализовал onPossiblyUnhandledRejection и onUnhandledRejectionHandled, которые теперь приняты Node.js. Это кажется хорошим решением, но за ним скрывается гораздо более тонкая проблема:

Когда отклонение достигает обработчика onPossiblyUnhandledRejection, процесс переходит в недопустимое состояние, из которого он может в какой-то момент восстановиться.

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

Но с необработанным отклонением, должны ли мы ждать восстановления процесса? Этого может никогда не случиться, а тем временем процесс может выйти из-под контроля. Как найти баланс между предоставлением отклоненному обещанию достаточно времени для обработки, но не так много времени, чтобы процесс в фактическом недопустимом состоянии стал проблемой?

Более поздние версии Node ответили на этот вопрос: по умолчанию мы не ждем - мы немедленно завершаем работу программы. Но это не решение, а компромисс. Немедленный сбой программы означает, что пользователям придется немедленно присоединять обработчики исключений к каждому созданному обещанию, а это означает, что присоединение обработчиков отклонения не может происходить асинхронно.

Баланс становится еще труднее найти, когда мы признаем следующий и последний недостаток:

Смешивание исключений с ошибками

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

Я категорически не против ловить исключения. Однако проблемы возникают, когда исключения смешиваются с ожидаемыми сбоями. Есть разница между ошибкой типа the user is not in the database (ожидаемый сбой) и ошибкой типа cannot read property name of undefined (ошибка). Эта разница достаточно значительна, чтобы иметь разные способы их обработки, но обещания объединяют их в одну и ту же ветвь кода. Когда они существуют в одной ветке, больше не существует надежного способа провести различие.

Резюме

  1. Обещания нетерпеливы, что ставит их перед множеством проблем, а также делает их бесполезными для управления побочными эффектами.
  2. Обещания нельзя отменить, из-за чего они тратят ресурсы.
  3. Обещания смешивают исключения с ожидаемыми ошибками.
  4. Обещания позволяют пользователям забыть об обработке ошибок.
  5. Если обработчик ошибок не прикреплен (вовремя), ваш процесс переходит в недопустимое состояние, из которого он может или не может восстановиться.
  6. Многие реализации Promise кодируют строгую специальную обработку Promises, исключая их из числа типов данных, от которых мы можем абстрагироваться в общем виде.

Некоторые из этих проблем вместе создают очень тонкую, но очень устрашающую проблему:

Волатильность

Во введении я упомянул, что API многих реализаций Promise «непостоянен». Теперь, когда мы признали все недостатки, давайте посмотрим, что я имел в виду.

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

С каждым создаваемым вами обещанием вы рискуете вызвать сбой процесса из-за ожидаемого сбоя.

Ключевое слово здесь - риск. Каждая из рассмотренных нами проблем представляет собой некоторую форму риска. Используя обещания, вы постоянно снижаете этот риск: вы должны присоединять обработчики как можно быстрее. Не забудьте прикрепить обработчики ошибок. Вы должны поддерживать отказавшие процессы достаточно долго, чтобы можно было подключить обработчики, что само по себе является еще одним риском. Вы никогда не должны отказываться от значений, которые могут быть ошибочно определены как исключения. Вы никогда не должны прямо или косвенно создавать объект со свойством then, который оказывается функцией.

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

Решение

Было предпринято несколько попыток улучшить обещания, в частности, в отношении отмены, но ни одна из них не решила все проблемы, описанные здесь.

Одна абстракция, которая очень близко подходит к решению этих проблем, - это Observable, но она занимает другое место в этой матрице вычислительных абстракций:

               ╔═══════════╤═════════════╗
               ║    One    │    Many     ║ 
╔══════════════╬═══════════╪═════════════╣ 
║  Synchronous ║ Variables │ Arrays      ║ ╟──────────────╫───────────┼─────────────╢ 
║ Asynchronous ║ Promises  │ Observables ║ ╚══════════════╩═══════════╧═════════════╝

Нам нужна структура, представляющая одно асинхронное значение и обладающая следующими свойствами:

  1. Это «ленивый» (в отличие от нетерпеливых структур, он не запускается, пока вы ему об этом не скажете), что позволяет использовать его для управления побочными эффектами.
  2. Он имеет полную отмену восходящего потока, что позволяет высвобождать ресурсы на всем протяжении вычислительной цепочки.
  3. Он не смешивает исключения с ожидаемыми сбоями.
  4. Это заставляет пользователей предоставлять обработчик отклонения в конце.
  5. Хендлеры всегда подключаются вовремя, потому что раньше он не заработает.
  6. Он не обрабатывает какие-либо типы значений специально, что делает их кандидатом на общие абстракции.

Такие структуры уже существуют, и я буду называть их Futures. Это проверенная структура данных, которая давно существует в Haskell (тип ввода-вывода; ~ 20 лет). Я лично потратил много времени на создание Fluture: библиотеки, которая привносит Futures в JavaScript в зрелом и готовом к эксплуатации виде.

Fluture соответствует интерфейсу Fantasy Land, уже более трех лет тестируется в боевых условиях и зависит от растущего списка проектов. Проект также получает частичное финансирование от WEAREREASONABLEPEOPLE