Проще всего представлять себе «промис» как контейнер. Не какой-то конкретный, а просто любой контейнер. Подойдёт и просто обёртка для ссылки на объект: это — как бы контейнер, в котором может быть только один элемент.
Для этого контейнера должна быть определена операция, которая на самом деле — просто операция создания контейнера. По понятным причинам она должна быть: иначе их было бы невозможно использовать. Фактически эта операция — статичный метод, создающий этот контейнер — метод `Promise.resolve`.
Ещё должна быть определена операция отображения одного контейнера на другой. Более точней — операция «связывания» одного контейнера с другим. Чтобы понять, почему именно оно, а не что-то другое, рассмотрим сначала операцию, которая чуть проще — метод `map` у массива Array.
Пусть у нас имеется массив с элементами 1, 2 и 3. При помощи метода `map` этого массива мы можем над каждым элементом произвести какую-то операцию и получить новый массив, в котором находятся преобразованные данной операцией элементы. Например, мы можем умножить каждый элемент списка целых чисел на пять.
const a = [1, 2, 3];
const newA = a.map(x => 5 * x);
console.log(newA);
// > [5, 10, 15]
В скобках после `map` написана функция, при помощи которой делается преобразования каждого элемента.
Важно то, что на выходе получается контейнер того же класса: был Array — новый тоже будет Array-ем. При этом тип значения в новом контейнере может поменяться. То есть, мы могли бы не умножать на 5 элементы контейнера, а, например, превращать их в строки. Тогда у нас из Array<Number> получился бы Array<String>.
const a = [1, 2, 3];
const newA = a.map(x => `*${x}*`);
console.log(newA);
// > ["*1*", "*2*", "*3*"]
Но это не единственное место, где такие контейнеры полезны. На самом деле, таких мест — целая куча и вы с ними ежедневно сталкиваетесь, даже если не подозреваете, что подошёл бы специальный контейнер.
Рассмотрим пример: нужен поиск чего-то где-то. Например, первого элемента в массиве строк, который удовлетворяет заданным условиям. Сигнатура функции поиска в этом случае будет примерно такая.
function find(/* Array<String> */ array, /* String => Boolean */ criteria) {}
Первый аргумент функции — массив строк. Второй — функция, при помощи которой будет проверяться каждый аргумент на соответствие условию. Первый найденный элемент будет возвращён. Просто!
Ну а что возвращать, когда элемент, удовлетворяющий условию, не найден? null?
Ну, в этом примере оно, конечно, более-менее подходит, однако в общем случае null может считаться корректным значением и лежать в массиве. Как его отличить от «ничего не найдено», и, наконец, как потом обрабатывать результат? Ведь можно просто вызвать нужный метод у найденного объекта позабыв о том, что в качестве ответа может вернуться null.
И вот тут в тему оказываются контейнеры с одним элементом, которое может быть не указано, а именно так устроены «промисы»: надо просто результат поиска оборачивать в такой контейнер.
Рассмотрим один из них, назовём его Option. Option имеет два варианта: Some, то есть «какой-то», и None — «ничего». Если мы что-то нашли, то мы возвращаем Some(найденное), если нет — возвращаем None().
class Option extends Promise {}
function Some(v) { return new Option((r, _) => r(v)); }
function None( ) { return new Option((_, r) => r( )); }
Если оборачивать результат операций, подобных поиску или открытию файлов (т. е. те, где можно ничего не найти, неудачно открыть и т. п.) в такие контейнеры, то вышеописанные проблемы устраняются. Исключение при попытке вызова метода у null возникнуть уже не может. Есть, что возвращать даже для примитивных типов, безо всяких там «магических» констант вида «-1 означает, что ничего не найдено», и т. д.
Чтобы понять, в чём прикол такого контейнера, рассмотрим пример с серией операций.
Предположим, что у нас есть некоторый довольно примитивный набор методов для обработки файлов. Файл можно открыть, после этого можно прочитать его содержимое, которое можно «распарсить». И наконец, файл можно закрыть, чтобы высвободить ресурс. Для краткости вместо реализации этих методов я сделаю заглушки, ведь интересует только их использование.
class ParsedData {}
class File {
read() { return ""; }
close() {}
}
function openFile(fileName) { return new File }
function parseString(str) { return new ParsedData }
Примерно так выглядит способ работы с таким интерфейсом...
const file = openFile("my-file.txt");
const parsed = parseString(file.read());
file.close();
Всё cупер, кратко и лаконично. Однако есть много проблем. Каждый из этих методов может «не сработать». Файла с таким именем может не быть. Он может быть, но не читаться. Даже прочитавшись, он может содержать синтаксические ошибки и потому не «парситься». И закрытие файла тоже может завершиться с ошибкой.
Традиционный способ решения подразумевает использование try-catch. И вот во что превращаются эти простые три строки в данном случае.
let file, parsed;
try {
file = openFile("my-file.txt");
parsed = parseString(file.read());
} catch(error) {
console.error(error);
} finally {
try {
if(file) file.close();
} catch(error) {
console.error(error);
}
}
Это — ужасное, трудночитаемое и содержащее повторы нагромождение кода так разительно отличается от изначальных трёх строк...
Посмотрим, что нам даст вышерассмотренный контейнер с одним или нулём элементов — Option. Сначала переопределим методы для обработки файлов так, чтобы они не выкидывали исключений, а если возвращают результат, то возвращали бы его обёрнутым в Option.
class ParsedData {}
class File {
read() { return Some(""); }
close() { return Some() }
}
function openFile(fileName) { return Some(new File) }
function parseString(str) { return Some(new ParsedData) }
Воспользуемся тем, что Option — это контейнер. Причём он не просто контейнер, а контейнер с методом `then`, поскольку наследует от класса Promise. Это нам даёт следующее:
1. Если в контейнере нет элементов (None), то написанное в `then` не будет выполнено ни разу
2. Если в контейнере один элемент (Some), то написанное в `then` будет выполнено один раз — именно для того, что обёрнуто в этот Some
3. Нам вернётся результат выполнения, обёрнутый в Option: Some(результат), если всё прошло удачно, и None() — если нет.
Это позволяет нам написать вот так...
const file = openFile("my-file.txt");
const parsed = file.then(f => f.read()).then(parseString);
file.then(f => f.close());
Пусть, с небольшими добавками, но это всё-таки почти те же исходные три строки. Почти столь же хорошо читаемые.
Теперь, что делать, если мы хотим не просто проигнорировать все ошибки, а уведомить о них пользователя?
Идея та же: контейнер с одним элементом. С той лишь разницей, что вместо None должно использоваться что-то типа OtherSome(exception), которая будет, подобно None, «путешествовать» от одной строки к другой и, в конце концов, вернётся к нам, если что-то пойдёт не так.
function OtherSome(exception) { return Promise.reject(exception); }
Ещё «промис» может выполнять роль контейнера Try — это такая версия контейнера с одним элементом, которая внутри себя перехватывает возникшие исключения. Но не просто их игнорирует, а запоминает внутри обёртки, которая трактуется так же, как в Option трактуется None: никаких преобразований совершать не надо, надо просто вернуть то же самое обёрнутое исключение.
function Success(v) { return Promise.resolve(v); }
function Failure(v) { return Promise.reject (v); }
function Try(fn) {
return new Promise(r => r(fn()))
.then(Success)
.catch(Failure)
;
}
// пример с вводом целого числа
Try(() => {
const a = prompt("Insert number");
if(/^\d+$/.test(a.trim()))
return a;
else
throw "Not a Number";
}).then(console.log, alert);
В результате вышенаписанного в контейнере будет лежать либо Success(правильный ввод), либо Failure(то исключение, когда ввели не целое число).
Также «промис» можно представить контейнером — назовём его Future — над значением, которое может быть доступно в данный конкретный момент времени(resolved), или, возможно, будет доступно только в будущем(pending), или никогда не будет доступно(rejected)
function Future(fn) {
return new Promise(fn);
}
В принципе роль функций Some, None, Success, Failure, Try, Future — вспомогательная, они возвращают «промис», который можно было бы создать напрямую в коде. Однако согласитесь, что наделяют большим смыслом то, что просходит в коде, чем просто Promise.resolve или Promise.reject.
Об отображении
Функция `Promise.prototype.then` работает так...
из объекта типа Promise<A> // обозначим его как p1
и функции типа (A => Promise<B>) или (A => B) // обозначим её как f
делает Promise<B> // обозначим его как p2
т. е. p1.then(f) === p2
Для сравнения, функция `Array.prototype.map` работает так...
из объекта типа Array<A> // обозначим его как p1
и функции типа (A => B) // обозначим её как f
делает Array<B> // обозначим его как p2
т. е. p1.map(f) === p2
Об упаковке в контейнер
Функция `Promise.resolve` работает так...
из объекта типа A // обозначим его как a
делает Promise<A> // обозначим его как p
т. е. Promise.resolve(a) === p
Для сравнения, функция `Array.of` работает так...
из объекта типа A // обозначим его как a
делает Array<A> // обозначим его как p
т. е. Array.of(a) === p
Продолжение скоро...