Оригинал: https://staltz.com/promises-are-not-neutral-enough.html
Первоначальный автор Staltz является основным разработчиком cyclejs и callbag. Хэ Шицзюнь сделал исчерпывающее опровержение этой статьи,Опровержение здесь.
Проблемы с промисами влияют на всю экосистему JS! В этой статье будут рассмотрены некоторые из этих вопросов.
Вышеприведенная фраза может натолкнуть вас на мысль, что меня замучил Промис в очень плохом настроении, ругая компьютер, поэтому я решил высказаться в Интернете. Не совсем, я только что заварил себе кофе сегодня утром, когда кто-то спросил меня в Твиттере, что я думаю об обещаниях, и я написал этот пост. Я пил кофе и думал снова и снова, а потом ответил ему на несколько твитов. Некоторые люди ответили, что лучше вести блог, отсюда и эта статья.
Основная цель Promise — представить значение, которое в конечном итоге будет получено (далее — конечное значение). Это значение может быть доступно в следующем цикле событий или через несколько минут. Есть много других примитивов, которые служат той же цели, например обратные вызовы, задачи в C#, Futures в Scala, Observables в RxJS и т. д. Обещания в JS — лишь один из таких примитивов.
Хотя эти примитивы могут достичь этой цели, обещание JS является слишком самоуверенным (Аннотация: самоуверенное означает субъективное, здесь означает неуместное, навязанное мнение) решение, которое вызвало много странных проблем. Эти проблемы, в свою очередь, вызывают другие проблемы в синтаксисе и экосистеме JS. Я думаю, что Promise недостаточно нейтрален, и его самоуверенность проявляется в следующих четырех местах:
- Немедленное выполнение вместо отложенного выполнения
- непрерывный
- не может выполняться синхронно
- then() на самом деле является смесью map() и flatMap().
Выполняется немедленно, а не отложенное выполнение
Когда вы создаете экземпляр Promise, задача уже начала выполняться, например следующий код:
console.log('before');
const promise = new Promise(function fn(resolve, reject) {
console.log('hello');
// ...
});
console.log('after');
Вы увидите до, привет и после в консоли. Это связано с тем, что функция fn, которую вы передаете промису, выполняется немедленно. Я выкрутил fn отдельно, и вы могли бы увидеть это немного яснее:
function fn(resolve, reject) {
console.log('hello');
// ...
}
console.log('before');
const promise = new Promise(fn); // fn 是立即执行的!
console.log('after');
Таким образом, Promise немедленно выполнит свою задачу. Обратите внимание, что в приведенном выше коде мы еще даже не использовали экземпляр Promise, т. е. не использовали promise.then() или какой-либо другой API-интерфейс обещания. Простое создание экземпляра Promise немедленно выполняет задачи в Promise.
Это важно понимать, потому что
- Иногда вы не хотите, чтобы задача в обещании запускалась немедленно.
- Иногда вы хотите иметь повторно используемую асинхронную задачу, но промисы выполняют задачу только один раз, поэтому после создания экземпляра промиса вы не можете использовать его повторно.
Обычное решение этой проблемы состоит в том, чтобы написать процесс создания экземпляра промиса в функции:
function fn(resolve, reject) {
console.log('hello');
// ...
}
console.log('before');
const promiseGetter = () => new Promise(fn); // fn 没有立即执行
console.log('after');
Поскольку функция может быть вызвана позже, «функция, возвращающая экземпляр промиса» (далее именуемая «промис-геттер») решает нашу проблему. Но возникает другая проблема: мы не можем просто использовать .then() для подключения этих получателей промисов. Чтобы решить эту проблему, любой подход состоит в том, чтобы написать метод, аналогичный .then() для Promise Getter, но это должно решить проблему повторного использования и цепного вызова Promise. Например следующий код:
// getUserAge 是一个 Promise Getter
function getUserAge() {
// fetch 也是一个 Promise Getter
return fetch('https://my.api.lol/user/295712')
.then(res => res.json())
.then(user => user.age);
}
Таким образом, Promise Getter на самом деле более удобен для композиции и повторного использования. Это связано с тем, что промис-геттеры могут задерживать выполнение. Если бы промисы с самого начала были задуманы как ленивые, нам бы не пришлось беспокоиться:
const getUserAge = betterFetch('https://my.api.lol/user/295712')
.then(res => res.json())
.then(user => user.age);
(Примечание переводчика: после выполнения приведенного выше кода задача выборки еще не запущена)
Мы можем вызвать getUserAge.run(cb), чтобы выполнить задачу. Если вы вызовете getUserAge.run несколько раз, будет выполнено несколько задач, и вы получите несколько окончательных значений. хорошо! Таким образом, мы можем не только повторно использовать промисы, но и делать связанные вызовы. (Аннотация: это для Promise Getter, потому что Promise Getter можно использовать повторно, но не в цепочке)
Отложенное выполнение является более общим, чем немедленное выполнение, поскольку немедленное выполнение нельзя вызывать повторно, в то время как отложенное выполнение можно вызывать несколько раз. Отложенное выполнение не накладывает никаких ограничений на количество вызовов.
Поэтому я думаю, что немедленное исполнение более самоуверенно, чем отсроченное. Task в C# очень похож на Promise, за исключением того, что Task в C# выполняется отложенно, а Task имеет метод .start(), а Promise — нет.
Позвольте привести аналогию. Обещание — это и рецепт, и приготовленное блюдо. Когда вы едите блюдо, вы должны есть и рецепт. Это ненаучно.
непрерывный
Как только вы создадите экземпляр Promise, задачи в Promise будут выполняться немедленно, и, к сожалению, вы не можете предотвратить выполнение. Итак, вы все еще хотите создать экземпляр Promise сейчас? Это дорога невозврата.
Я думаю, что «непрерывность» промисов тесно связана с их функцией «немедленного выполнения». использовать здесьхороший примерпроиллюстрировать:
var promiseA = someAsyncFn();
var promiseB = promiseA.then(/* ... */);
Предполагая, что мы можем использовать promiseB.cancel() для прерывания задачи, следует ли прервать задачу promiseA? Возможно, вы думаете, что его можно прервать, тогда посмотрите на следующий пример:
var promiseA = someAsyncFn();
var promiseB = promiseA.then(/* ... */);
var promiseC = promiseA.then(/* ... */);
В настоящее время, если мы можем использовать promiseB.cancel() для прерывания задачи, задача promiseA не должна быть прервана, потому что promiseC зависит от promiseA.
Именно из-за «немедленного выполнения» восходящий механизм прерывания задачи Promise усложняется. Возможным решением является подсчет ссылок, но это решение имеет много пограничных случаев и даже ошибок.
Если промисы выполняются лениво и предоставляют метод .run, все становится проще:
var execution = promise.run();
// 一段时间后
execution.cancel();
Выполнение, возвращаемое promise.run(), представляет собой цепочку задач с возвратом, и каждая задача в цепочке создает свое собственное выполнение. Если мы вызовем выполнение C.cancel(), то выполнение A.cancel() будет вызвано автоматически, а выполнение B имеет свое собственное выполнение A, которое не имеет ничего общего с выполнением выполнения C выполнения A. Таким образом, одновременно может выполняться несколько задач A, что не вызовет никаких проблем.
Если вы хотите избежать выполнения нескольких задач A, вы можете добавить общий метод к задачам A, что означает, что мы можем «необязательно использовать» счетчик ссылок вместо «принудительного» счетчика ссылок. Обратите внимание на разницу между «факультативным использованием» и «обязательным использованием», если действие «факультативно используется», то оно нейтрально; если действие является «обязательным использованием», то оно самоуверенно.
Возвращаясь к примеру со странным рецептом, допустим, вы заказали блюдо в ресторане, но через минуту вы не хотите его есть, это делает Обещание: хотите вы его есть или нет, оно заставит блюдо в горло. Потому что Promise думает, что вы должны есть, когда заказываете (непрерывно).
не может выполняться синхронно
In the design strategy of Promise, the earliest allowable resolution time is before entering the next event loop stage (Annotation: Please refer to process.nextTick), so as to facilitate the solution of the race condition generated when multiple Promise instances are created at the в то же время.
console.log('before');
Promise.resolve(42).then(x => console.log(x));
console.log('after');
Приведенный выше код будет печатать «до», «после» и 42 последовательно. Независимо от того, как вы создадите этот экземпляр Promise, вы не сможете сделать функцию, а затем напечатать 42 до «после».
Конечным результатом является то, что вы можете писать синхронный код как промисы, но нет способа превратить промисы в синхронный код. Это искусственное ограничение.Вы можете видеть, что у обратного вызова нет этого ограничения.Мы можем написать синхронный код как обратный вызов, или изменить обратный вызов на синхронный код. Возьмем forEach в качестве примера:
console.log('before');
[42].forEach(x => console.log(x));
console.log('after');
Этот код печатает «до» 42 и «после» одновременно.
Поскольку мы не можем переписать промис в синхронный код, как только мы используем промис в нашем коде, код вокруг него становится кодом, основанным на промисах), даже если это не имеет смысла.
Я могу понять, что асинхронный код делает окружающий код асинхронным, но обещания заставляют код, окружающий синхронный код, быть асинхронным. Это еще один субъективный аспект Promises. Нейтральное решение не должно заставлять данные доставляться синхронно или асинхронно.
Я думаю, что промисы — это своего рода «абстракция с потерями», похожая на «сжатие с потерями», когда вы помещаете что-то в промис и вынимаете что-то из промиса, это не то же самое, что раньше.
Представьте, вы заказываете гамбургер в сети быстрого питания, официант тут же вытаскивает готовый гамбургер и протягивает его вам, но протягивает руку, чтобы взять его, но обнаруживает, что официант хватает бургер и не дает вам, он просто смотрит на тебя, затем начался 3-секундный обратный отсчет, прежде чем он отпустил. Вы берете свой бургер и выходите из ресторана быстрого питания, пытаясь сбежать из этого жуткого места. Так или иначе, они просто хотят, чтобы вы немного подождали, прежде чем поесть, на всякий случай.
then() на самом деле является смесью map() и flatMap().
При передаче обратного вызова в then ваша функция обратного вызова может возвращать обычное значение или экземпляр Promise. Интересно, что оба способа написания имеют одинаковый эффект.
Promise.resolve(42).then(x => x / 10);
// 效果跟下面这句话一致
Promise.resolve(42).then(x => Promise.resolve(x / 10));
Чтобы промисы не вкладывались в промисы, когда возвращаемое значение является обычным значением, оно преобразуется в экземпляр промиса (Примечание: это карта, см. объяснение карты hax)Promise<T>.then(T => U): Promise<U>
) и использовать его непосредственно при встрече с экземпляром Promise (аннотация: это flatMap,Promise<T>.then(T => Promise<U>): Promise<U>
).
В некотором смысле это поможет вам, потому что, если вы не очень хорошо знаете детали, программа автоматически сделает это за вас. Предполагая, что промисы действительно могут предоставлять методы map, flatten и flatMap, мы можем использовать только метод then для получения всех требований. Видите ли вы ограничения промисов? Тогда я ограничен упрощенным API, который выполняет некоторые автоматические преобразования, и у меня больше нет контроля.
Давным-давно, когда промисы впервые были представлены сообществу JS, некоторые люди думали о добавлении в промисы методов map и flatMap.это обсуждениесм. в. Но люди, участвовавшие в разработке грамматики, возражали им на основании теории категорий и функционального программирования.
Я не хочу слишком много говорить о функциональном программировании в этом посте, скажу только одно: практически невозможно создать нейтральный программный примитив, не следуя математике. Математика не является предметом, который не связан с реальным программированием.Концепции в математике практичны, поэтому, если вы не хотите, чтобы ваше творение было противоречивым, возможно, вам следует больше узнать о математике.
Основное внимание в этом обсуждении уделяется тому, почему промисы не могут иметь такие методы, как map, flatMap и concat. Многие другие примитивы имеют эти методы, например массивы, и если вы использовали ImmutableJS, вы обнаружите, что у него тоже есть эти методы. map, flatMap и concat работают очень хорошо.
Представьте, что когда мы пишем код, мы можем просто вызывать map, flatMap и concat, независимо от того, какой это примитив. Пока источник ввода имеет эти методы. Это упрощает тестирование, потому что я могу напрямую использовать массив в качестве фиктивных данных. Если ваш код использует ImmutableJS или асинхронные API в продакшене, то достаточно просто имитировать массивы в тестовой среде. В функциональном программировании «дженерики», «программирование классов типов» и монады имеют схожие значения, что означает, что мы можем дать разным примитивам пакет с одним и тем же именем метода. Раздражает, если имя метода одного примитива concat, а имя метода другого примитива concatenate, но по сути они делают почти одно и то же.
Так почему бы не понимать Promise как концепцию, аналогичную массиву, с такими методами, как concat и map. Промисы в основном могут быть сопоставлены, поэтому просто добавьте метод map в промисы; промисы в основном могут быть связаны цепочкой, поэтому добавьте метод flatMap в промисы.
К сожалению, это не тот случай, Promise втиснул туда map и flatMap и добавил некоторую логику автоматического преобразования. Это было сделано только потому, что map иflapMap выглядели одинаково, и они посчитали, что писать два метода будет лишним.
Суммировать
Ну, промисы тоже работают, вы можете делать свои дела с промисами, и все работает нормально. Нет необходимости паниковать. Обещания просто выглядят немного странно, и, к сожалению, они все еще самоуверенны. Они накладывают правила на промисы, которые в какой-то момент теряют смысл. Это не проблема, потому что мы можем легко обойти эти правила.
Промисы трудно использовать повторно, не важно, что мы можем сделать это с помощью дополнительных функций; Промисы не могут быть прерваны, не имеет значения, что мы можем позволить тем задачам, которые должны быть прерваны, продолжать выполняться, разве это не пустая трата некоторых ресурсов.
Это раздражает, нам всегда приходится возиться с промисами; Это раздражает, все новые API основаны на промисах, мы даже придумали синтаксический сахар для промисов: async/await.
Так что нам придется жить со странностями промисов долгие годы. Если бы мы изначально отложили выполнение в промисы, возможно, с промисами была бы другая история.
Как бы это выглядело, если бы Promises изначально разрабатывался с учетом математического мышления? Здесь я привожу два примера:fun-taskа такжеavenir, обе эти библиотеки выполняются лениво, поэтому общего много, различия в основном отражаются в именовании и доступности методов. Обе библиотеки менее самоуверенны, чем Promises, потому что они:
- отложенное исполнение
- разрешить синхронизацию
- включить прерывание
Обещания были изобретены, а не обнаружены. Открываются лучшие примитивы, и поскольку эти примитивы нейтральны, мы не можем их опровергнуть. Например, круг — такое математическое понятие, которое невозможно опровергнуть, поэтому говорят, что люди открыли круг, а не изобрели его. Поскольку круги нейтральны и не накладывают никаких субъективных ограничений, вы никак не можете опровергнуть существование круга. И круги везде.