Какая разница между продолжением и обратным вызовом?

Я просматривал по всему Интернету в поисках просветления о продолжениях, и это пугало, как простейшие объяснения могут так сильно помешать программисту JavaScript, как я. Это особенно актуально, когда большинство статей объясняют продолжение с помощью кода на Схеме или использование монадов.

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

Итак, вот что я знаю:

В почти всех языках функции явно возвращают значения (и управление) их вызывающему. Например:

var sum = add(2, 3);

console.log(sum);

function add(x, y) {
    return x + y;
}

Ответ 1

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

var array = [1, 2, 3];

forEach(array, function (element, array, index) {
    array[index] = 2 * element;
});

console.log(array);

function forEach(array, callback) {
    var length = array.length;
    for (var i = 0; i < length; i++)
        callback(array[i], array, i);
}

Ответ 2

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

Также немного странно называть продолжения частным случаем обратных вызовов. Я вижу, как они легко группируются вместе, но продолжения не возникли из-за необходимости отличать от обратного вызова. Продолжение фактически представляет команды , оставшиеся для завершения вычисления, или оставшуюся часть вычисления с этого момента времени. Вы можете думать о продолжении, как о дыре, которую нужно заполнить. Если я смогу захватить текущее продолжение программы, я могу вернуться к тому, как именно программа была, когда я захватил продолжение. (Это гарантирует, что отладчики легче писать.)

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

Ответ 3

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

Рассмотрим функцию:

function add(x, y, c) {
    alert("before");
    c(x+y);
    alert("after");
}

(Я использую синтаксис Javascript, даже если Javascript фактически не поддерживает первоклассные продолжения, потому что это то, что вы дали в своих примерах, и это будет более понятным для людей, не знакомых с синтаксисом Lisp.)

Теперь, если мы передадим ему обратный вызов:

add(2, 3, function (sum) {
    alert(sum);
});

то мы увидим три предупреждения: "раньше", "5" и "после".

С другой стороны, если мы должны передать ему продолжение, которое делает то же самое, что и обратный вызов, вот так:

alert(callcc(function(cc) {
    add(2, 3, cc);
}));

тогда мы увидим только два предупреждения: "до" и "5". Вызов c() внутри add() завершает выполнение add() и возвращает callcc(); значение, возвращаемое callcc(), было значением, переданным как аргумент c (а именно, суммой).

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

Фактически, call/cc можно использовать для добавления операторов возврата к языкам, которые их не поддерживают. Например, если у JavaScript не было оператора return (вместо этого, как и многие языки Lips, просто вернув значение последнего выражения в тело функции), но имел call/cc, мы могли бы реализовать возврат следующим образом:

function find(myArray, target) {
    callcc(function(return) {
        var i;
        for (i = 0; i < myArray.length; i += 1) {
            if(myArray[i] === target) {
                return(i);
            }
        }
        return(undefined); // Not found.
    });
}

Вызов return(i) вызывает продолжение, которое завершает выполнение анонимной функции и вызывает callcc() для возврата индекса i, в котором target был найден в myArray.

(NB: есть некоторые способы, в которых аналогична "возвратная" аналогия немного упрощена.Например, если продолжение выходит из функции, в которой оно было создано, - будучи сохраненным в глобальном месте, скажем - возможно что функция, которая создала продолжение, может возвращаться несколько раз, даже если она была вызвана только один раз.)

Аналогично можно использовать вызов /cc для реализации обработки исключений (throw и try/catch), циклов и многих других структур contol.

Чтобы устранить некоторые возможные недоразумения:

  • Оптимизация звонков не требуется для поддержки первоклассных продолжений. Предположим, что даже язык C имеет (ограниченную) форму продолжений в виде setjmp(), который создает продолжение, и longjmp(), который вызывает один!

    • С другой стороны, если вы наивно пытаетесь написать свою программу в стиле продолжения передачи без оптимизации хвостового вызова, вы обречены на переполнение стека.
  • Нет никакой особой причины, чтобы продолжение требовало только одного аргумента. Именно этот аргумент для продолжения становится возвращаемым значением (-ами) вызова /cc, а вызов /cc обычно определяется как имеющий одно возвращаемое значение, поэтому, естественно, продолжение должно принимать ровно одно. В языках с поддержкой нескольких возвращаемых значений (например, Common Lisp, Go или действительно Scheme) вполне возможно иметь продолжения, которые принимают несколько значений.