Асинхронное программирование и стиль продолжений в JavaScript

В этой статье мы рассмотрим асинхронные функции обратного вызова в JavaScript как продолжения (continuation-passing style aka CPS). Разберемся, как они работают и дадим несколько рекомендаций о их использовании.

Асинхронное программирование и функции обратного вызова

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

function loadAvatarImage(id) {
    var profile = loadProfile(id);
    return loadImage(profile.avatarUrl);
}

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

function loadAvatarImage(id, callback) {
    loadProfile(id, function (profile) {
        loadImage(profile.avatarUrl, callback);
    });
}

Этот асинхронный стиль называется стиль продолжений. Синхронный стиль программирования называется прямым стилем. Имя продолжения указывает на тот факт, что вам необходимо всегда передавать функцию обратного вызова как последний аргумент в функции. Функция обратного вызова продолжает выполнение функции, после выполнения первого шага (получения профиля). Поэтому, она часто называется продолжением, особенно в функциональном программировании. Проблема продолжений в том, что они заразительны, их нужно использовать везде или нигде: loadAvatarImage внутри использует продолжения, и не может скрыть этот факт извне, и должна быть также написана в стиле продолжений. Это же справедливо для всех, кто использует loadAvatarImage.

Конвертирование в стиль продолжений

В этой секции рассмотрим несколько техник которые сделают более легким переход с прямого стиля на стиль продолжений.

Последовательность вызовов функций

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

function loadAvatarImage(id, callback) {
    loadProfile(id, loadProfileAvatarImage);  // (*)
    function loadProfileAvatarImage(profile) {
        loadImage(profile.avatarUrl, callback);
    }
}

В JavaScript функция loadProfileAvatar "всплывает" (hoisting - интерпретатор перемещает определение функции или переменной вверх к началу функции автоматически). Поэтому она может быть вызвана в позиции (*). Мы поместили loadProfileAvatarImage внутри loadAvatarImage потому что ей нужен доступ к функции обратного вызова callback. Вы увидите такой тип вложенности в местах, где необходимо использовать одно и то же состояние между вызовами функций. Альтернативой может быть использование самовызывающихся функций (Immediately-Invoked Function Expression - IIFE)

var loadAvatarImage = function () {
    var cb;
    function loadAvatarImage(id, callback) {
        cb = callback;
        loadProfile(id, loadProfileAvatarImage);
    }
    function loadProfileAvatarImage(profile) {
        loadImage(profile.avatarUrl, cb);
    }
    return loadAvatarImage;
}();

Итерация коллекций

Следующий код состоит из простого цикла for.

function logArray(arr) {
    for(var i=0; i < arr.length; i++) {
        console.log(arr[i]);
    }
    console.log("### Done");
}

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

function logArray(arr) {
    logArrayRec(0, arr);
    console.log("### Done");
}
function logArrayRec(index, arr) {
    if (index < arr.length) {
        console.log(arr[index]);
        logArrayRec(index + 1, arr);
    }
    // else: done
}

Теперь будет легче конвертировать в стиль продолжений. Для этого мы сделаем до вспомогательную функцию forEachCps.

function logArray(arr) {
    forEachCps(arr, function (elem, index, next) {  // (*)
        console.log(elem);
        next();
    }, function () {
        console.log("### Done");
    });
}
function forEachCps(arr, visitor, done) {  // (**)
    forEachCpsRec(0, arr, visitor, done)
}
function forEachCpsRec(index, arr, visitor, done) {
    if (index < arr.length) {
        visitor(arr[index], index, function () {
            forEachCpsRec(index + 1, arr, visitor, done);
        });
    } else {
        done();
    }
}

Здесь наиболее интересны два изменения: посетитель (*) получает свое продолжение next, которое продолжает следующий шаг "внутри" forEachCpsRec. Это позволяет делать вызовы продолжений в посетителе, например, для асинхронного запроса. Так же нам необходимо позаботиться (*) о том, что произойдет, когда закончиться обход цикла.

Отображение коллекции

Если мы немного перепишем forEachCps, то получим версию Array.prototype.map в стиле продолжений.

function mapCps(arr, func, done) {
    mapCpsRec(0, [], arr, func, done)
}
function mapCpsRec(index, outArr, inArr, func, done) {
    if (index < inArr.length) {
        func(inArr[index], index, function (result) {
            mapCpsRec(index + 1, outArr.concat(result),
                      inArr, func, done);
        });
    } else {
        done(outArr);
    }
}

mapCps получает коллекция-образный объект и создает новую коллекцию применяя func к каждому элементу. Эта версия неразрушающая (иммутабельная) - создает новую коллекцию для каждого шага рекурсии. Ниже разрушающая (мутабельная) версия:

function mapCps(arrayLike, func, done) {
    var index = 0;
    var results = [];

    mapOne();

    function mapOne() {
        if (index < arrayLike.length) {
            func(arrayLike[index], index, function (result) {
                results.push(result);
                index++;
                mapOne();
            });
        } else {
            done(results);
        }
    }
}

Используется mapCps следующим образом:

function done(result) {
    console.log("RESULT: " + result);  // RESULT: ONE,TWO,THREE
}
mapCps(
    ["one", "two", "three"],
    function (elem, i, callback) {
        callback(elem.toUpperCase());
    },
    done
);

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

function parMapCps(arrayLike, func, done) {
    var resultCount = 0;
    var resultArray = new Array(arrayLike.length);
    for (var i=0; i < arrayLike.length; i++) {
        func(arrayLike[i], i, maybeDone.bind(null, i));  // (*)
    }
    function maybeDone(index, result) {
        resultArray[index] = result;
        resultCount++;
        if (resultCount === arrayLike.length) {
            done(resultArray);
        }
    }
}

В строке (*), нам необходимо сохранить значение переменной цикла i. Если мы этого не сделаем, мы всегда будем получать текущее значение i в продолжении. Например, arrayLike.length, если продолжение было вызвано после того, как закончился цикл. Сохранить копию так же можно используя IIFE или Array.prototype.forEach вместо цикла.

Итерация по дереву

Имеем следующую функцию в прямом стиле которая рекурсивно обходит дерево вложенных коллекций.

function visitTree(tree, visitor) {
    if (Array.isArray(tree)) {
        for(var i=0; i < tree.length; i++) {
            visitTree(tree[i], visitor);
        }
    } else {
        visitor(tree);
    }
}

Она используется следующим образом:

> visitTree([[1,2],[3,4], 5], function (x) { console.log(x) })
1
2
3
4
5

Если же вам необходимо, чтоб посетитель мог делать асинхронные запросы, то вам необходимо переписать visitTree в стиле продолжений:

function visitTree(tree, visitor, done) {
    if (Array.isArray(tree)) {
        visitNodes(tree, 0, visitor, done);
    } else {
        visitor(tree, done);
    }
}
function visitNodes(nodes, index, visitor, done) {
    if (index < nodes.length) {
        visitTree(nodes[index], visitor, function () {
            visitNodes(nodes, index+1, visitor, done);
        });
    } else {
        done();
    }
}

Или использовать forEachCps:

function visitTree(tree, visitor, done) {
    if (Array.isArray(tree)) {
        forEachCps(
            tree,
            function (subTree, index, next) {
                visitTree(subTree, visitor, next);
            },
            done);
    } else {
        visitor(tree, done);
    }
}

Ловушка: Исполнение продолжается после передачи разультата

В прямом стиле, возвращая значение, вы прерываете работу функции:

function abs(n) {
    if (n < 0) return -n;
    return n;  // (*)
}

Следовательно, (*) не будет выполнено в случае если `n меньше нуля. В отличии от возвращения значения в стиле продлолжений, где функция не прерывается:

// Ошибка!
function abs(n, success) {
    if (n < 0) success(-n);  // (**)
    success(n);
}

Следовательно, если n < 0 то обе, и success(-n) и success(n), будут вызваны. Исправить это легко - использовать полную форму оператора if.

function abs(n, success) {
    if (n < 0) {
        success(-n);
    } else {
        success(n);
    }
}

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

Продолжения и поток управления

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

Прямой стиль. Вы вызываете функцию и она должна вернуть результат, она не может избежать вложенностей, которые бывают при вызове функций. Следующий код имеет два таких вызова: f вызывает g который вызывает h.

function f() {
    console.log(g());
}
function g() {
    return h();
}
function h() {
    return 123;
}

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

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

function f() {
    g(function (result) {
        console.log(result);
    });
}
function g(success) {
    h(success);
}
function h(success) {
    success(123);
}

Теперь поток управление совсем отличается. f вызывает g, он вызывает h, который потом вызывает продолжение g', которое далее вызывает f'. Схема управления потоком:

управление потоком стиль продолжений

Return

Первая иллюстрация - как много возможности имеет функция над потоком управления, посмотрите на следующий код. Потом мы немного перепишем и добавим вспомогательную функцию которая будет аналогом return для вызывающего(!).

function searchArray(arr, searchFor, success, failure) {
    forEachCps(arr, function (elem, index, next) {
        if (compare(elem, searchFor)) {
            success(elem);  // (*)
        } else {
            next();
        }
    }, failure);
}
function compare(elem, searchFor) {
    return (elem.localeCompare(searchFor) === 0);
}

Стиль продолжений позволяет немедленно покинуть тело цикла на (*). В Array.prototype.forEach вы не сможете этого сделать, и нужно ждать, пока цикл не дойдет до конца. Если переписать сравнение на стиль продолжений, то он автоматически вернется нам из цикла.

function searchArray(arr, searchFor, success, failure) {
    forEachCps(arr, function (elem, index, next) {
        compareCps(elem, searchFor, success, next);
    }, failure);
}
function compareCps(elem, searchFor, success, failure) {
    if (elem.localeCompare(searchFor) === 0) {
        success(elem);
    } else {
        failure();
    }
}

Это весьма поражает. Для того чтоб достич подобного эффекта в прямом стиле нужно использовать исключения.

try-catch

С использованием стиля продолжений, вы можете реализовать обработку ошибок в языке - нет необходимости в специальных языковых конструкциях. В следующем примере, мы реализуем функцию printDiv в стиле продолжений. Она будет вызывать функцию div, которая может генерировать исключения. Поэтому, ее необходимо оборачивать в tryIt, нашу реализацию try-catch

function printDiv(a, b, success, failure) {
    tryIt(
        function (succ, fail) {  // try
            div(a, b, function (result) {  // might throw
                console.log(result);
                succ();
            }, fail);
        },
        function (errorMsg, succ, fail) {  // catch
            handleError(succ, fail);  // might throw again
        },
        success,
        failure
    );
}

Для того, чтоб обработка ошибок работала, каждая функция требует два продолжения: один для успешного завершения и один на случай, если случиться ошибка. Функция стремиться реализовать конструкцию try-catch. Ее первый аргумент - это блок try, который имеет свою локальную версию success и failure продолжений. Второй аргумент - это блок catch, который в свою очередь тоже имеет локальные продолжения. Последние два аргумента - продолжения, которые применяются для целой функции. Деление в стиле продолжений генерирует исключение, если делитель равен нулю.

function div(dividend, divisor, success, failure) {
    if (divisor === 0) {
        throwIt("Division by zero", success, failure);
    } else {
        success(dividend / divisor);
    }
}

А теперь реализуем обработку исключений.

function tryIt(tryBlock, catchBlock, success, failure) {
    tryBlock(
        success,
        function (errorMsg) {
            catchBlock(errorMsg, success, failure);
        });
}
function throwIt(errorMsg, success, failure) {
    failure(errorMsg);
}

Обратите внимание, что продолжения в блоке catch определены статически, они не переданы в нее, когда вызывается ошибка. Они практически такие же как и целая функция tryIt

Генераторы

Генераторы - это особенность ECMAScript.next, которую можно уже испробовать в текущей версии Firefox (прим. пер. на момент перевода практическое большинство браузеров их поддерживают). Генератор - это объект, который оборачивает функцию. Каждый раз когда вызывается метод next() на объекте, продолжается выполнение функции. Каждый раз функция генерирует производное значение, и выполнение останавливается до следующего вызова next, в то же время next() возвращает значение. Следующий генератор производит бесконечную последовательность чисел 0, 1, 2, ...

function* countUp() {
    for(let i=0;; i++) {
        yield i;
    }
}

Обратите внимание, что бесконечный цикл в функции обернут в объект генератора. Он продолжает исполняться каждый раз когда вызывается next() и останавливается каждый раз когда встречается yield. Пример взаимодействия:

> let g = countUp();
> g.next()
0
> g.next()
1

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

function countUpCps() {
    var i=0;
    function nextStep(yieldIt) {
        yieldIt(i++, nextStep);
    }
    return new Generator(nextStep);
}

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

var g = countUpCps();
g.next(function (result) {
    console.log(result);
    g.next(function (result) {
        console.log(result);
        // etc.
    });
});

Конструктор генератора может быть реализован следующим образом.

function Generator(genFunc) {
    this._genFunc = genFunc;
}
Generator.prototype.next = function (success) {
    this._genFunc(function (result, nextGenFunc) {
        this._genFunc = nextGenFunc;
        success(result);
    });
};

Обратите внимание, как мы сохраняем текущее продолжение генератора, как функцию внутри объекта. Мы не передаем.

Продолжения и стек

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

function f(n) {
    var i=0;
    for(; i < n; i++) {
        if (isFinished(i)) {
            break;
        }
    }
    console.log("Stopped at "+i);
}

Та же самая программа, реализованная через оператор goto:

function f(n) {
    var i=0;
L0: if (i >= n) goto L1;
    if (isFinished(i)) goto L1;
    i++;
    goto L0;
L1: console.log("Stopped at "+i);
}

Версия в стиле продолжений (игнорируем isFinished) не очень то сильно и отличается:

function f(n) {
    var i=0;
    L0();
    function L0() {
        if (i >= n) {
            L1();
        } else if (isFinished(i)) {
            L1();
        } else {
            i++;
            L0();
        }
    }
    function L1() {
        console.log("Stopped at "+i);
    }
}

Хвостовая оптимизация

Давайте еще раз рассмотрим следующую функцию в прямом стиле, которая использует рекурсию для прохода по коллекции:

function logArrayRec(index, arr) {
    if (index < arr.length) {
        console.log(arr[index]);
        logArrayRec(index+1, arr);  // (*)
    }
    // else: done
}

В текущей версии JavaScript, стек будет расти для каждого элемента коллекции. Однако, если присмотреться, то вы поймете, что не обязательно сохранять в стек при рекурсивном вызове (*). В последнем вызове функции нечего возвращать. Вместо этого можно удалить данные текущей функции из стека стека перед вызовом функции: он не будет расти. Если вызов функции последний в функции, то он называется хвостовым вызовом. Большинство функциональных языков делают вышеупомянутую оптимизацию. Вот почему обход цикла рекурсивно наиболее эффективен в этих языках в сравнении с итеративной версией (например, циклом for). Все "правильные" продолжения являются хвостовыми и могут быть оптимизированы. На этот факт мы намекали, когда говорили, что они похожи на оператор goto.

Трамилины

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

function f(n) {
    var i=0;
    return [L0];
    function L0() {
        if (i >= n) {
            return [L1];
        } else if (isFinished(i)) {
            return [L1];
        } else {
            i++;
            return [L0];
        }
    }
    function L1() {
        console.log("Stopped at "+i);
    }
}

В стиле продолжений каждая функция всегда хвостовая. Мы каждый вызов трансформируем

func(arg1, arg2, arg3);

в оператор return

return [func, [arg1, arg2, arg3]];

Трамплин получает возвращенную коллекцию и вызывает соответствующую функцию.

function trampoline(result) {
    while(Array.isArray(result)) {
        var func = result[0];
        var args = (result.length >= 2 ? result[1] : []);
        result = func.apply(null, args);
    }
}

Теперь мы вызовем f следующим образом:

 trampoline(f(14));

Очередь сообщений и трамплин

В браузерах и Node.js трамплины осуществляются через очередь сообщений. Если у вас много вызовов продолжений подряд (без ожидания асинхронного результата через очередь сообщений), вы можете добавить это продолжение в очередь и избежать переполнения стека. Таким образом вместо

continuation(result);

можете написать

setTimeout(function () { continuation(result) }, 0);

Более того, Node.js имеет для этого специально предназначеную функцию process.nextTick():

process.nextTick(function () { continuation(result) });

Заключение

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


Источник

Добавить комментарий