Какая разница между продолжением и обратным вызовом?
Я просматривал по всему Интернету в поисках просветления о продолжениях, и это пугало, как простейшие объяснения могут так сильно помешать программисту 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) вполне возможно иметь продолжения, которые принимают несколько значений.