В чем разница между продолжением и обратным вызовом?


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

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

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

var sum = add(2, 3);

console.log(sum);

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

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

add(2, 3, function (sum) {
    console.log(sum);
});

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

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

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

3 123

3 ответа:

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

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);
}

однако если функция вызывает другую функцию как последнее, что она делает, то вторая функция называется продолжением первой. Например:

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;

    // This is the last thing forEach does
    // cont is a continuation of forEach
    cont(0);

    function cont(index) {
        if (index < length) {
            callback(array[index], array, index);
            // This is the last thing cont does
            // cont is a continuation of itself
            cont(++index);
        }
    }
}

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

бонус: переходим к продолжению прохождения стиля. Рассмотрим следующую программу:

console.log(pythagoras(3, 4));

function pythagoras(x, y) {
    return x * x + y * y;
}

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

console.log(pythagoras(3, 4));

function pythagoras(x, y) {
    return add(square(x), square(y));
}

function square(x) {
    return multiply(x, x);
}

function multiply(x, y) {
    return x * y;
}

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

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

pythagoras(3, 4, console.log);

function pythagoras(x, y, cont) {
    square(x, function (x_squared) {
        square(y, function (y_squared) {
            add(x_squared, y_squared, cont);
        });
    });
}

function square(x, cont) {
    multiply(x, x, cont);
}

function multiply(x, y, cont) {
    cont(x * y);
}

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

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

однако есть две проблемы с продолжением прохождения стиля:

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

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

Function.prototype.async = async;

pythagoras.async(3, 4, console.log);

function pythagoras(x, y, cont) {
    square.async(x, function (x_squared) {
        square.async(y, function (y_squared) {
            add.async(x_squared, y_squared, cont);
        });
    });
}

function square(x, cont) {
    multiply.async(x, x, cont);
}

function multiply(x, y, cont) {
    cont.async(x * y);
}

function add(x, y, cont) {
    cont.async(x + y);
}

function async() {
    setTimeout.bind(null, this, 0).apply(null, arguments);
}

вторая проблема обычно решается с помощью функции под названием call-with-current-continuation который часто сокращается как callcc. К сожалению callcc не может быть полностью реализована на JavaScript, но мы могли бы написать функцию замены для большинства случаев использования:

pythagoras(3, 4, console.log);

function pythagoras(x, y, cont) {
    var x_squared = callcc(square.bind(null, x));
    var y_squared = callcc(square.bind(null, y));
    add(x_squared, y_squared, cont);
}

function square(x, cont) {
    multiply(x, x, cont);
}

function multiply(x, y, cont) {
    cont(x * y);
}

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

function callcc(f) {
    var cc = function (x) {
        cc = x;
    };

    f(cc);

    return cc;
}

в callcc функция принимает функцию f и относится к current-continuation (сокращенно cc). Элемент current-continuation - это функция продолжения, которая обертывает остальную часть тела функции после вызова callcc.

рассмотрим тело функции pythagoras:

var x_squared = callcc(square.bind(null, x));
var y_squared = callcc(square.bind(null, y));
add(x_squared, y_squared, cont);

The current-continuation второй callcc - это:

function cc(y_squared) {
    add(x_squared, y_squared, cont);
}

аналогично current-continuation первого callcc - это:

function cc(x_squared) {
    var y_squared = callcc(square.bind(null, y));
    add(x_squared, y_squared, cont);
}

С current-continuation из первых callcc содержит еще один callcc он должен быть преобразован в стиль продолжения передачи:

function cc(x_squared) {
    square(y, function cc(y_squared) {
        add(x_squared, y_squared, cont);
    });
}

таким образом callcc логически преобразует все тело функции обратно к тому, с чего мы начали (и дает этим анонимным функциям имя cc). Функция Пифагора, использующая эту реализацию callcc, становится тогда:

function pythagoras(x, y, cont) {
    callcc(function(cc) {
        square(x, function (x_squared) {
            square(y, function (y_squared) {
                add(x_squared, y_squared, cont);
            });
        });
    });
}

опять вы не можете реализовать callcc в JavaScript, но вы можете реализовать его стиль продолжения передачи в В JavaScript следующим образом:

Function.prototype.async = async;

pythagoras.async(3, 4, console.log);

function pythagoras(x, y, cont) {
    callcc.async(square.bind(null, x), function cc(x_squared) {
        callcc.async(square.bind(null, y), function cc(y_squared) {
            add.async(x_squared, y_squared, cont);
        });
    });
}

function square(x, cont) {
    multiply.async(x, x, cont);
}

function multiply(x, y, cont) {
    cont.async(x * y);
}

function add(x, y, cont) {
    cont.async(x + y);
}

function async() {
    setTimeout.bind(null, this, 0).apply(null, arguments);
}

function callcc(f, cc) {
    f.async(cc);
}

функции callcc может использоваться для реализации сложных структур потока управления, таких как блоки try-catch, сопрограммы, генераторы,волокнами и т. д.

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

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

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

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

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

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, мы могли бы реализовать return следующим образом:

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.

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

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

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

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

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