Яка різниця між продовженням і зворотним дзвінком?


133

Я переглядав всю мережу в Інтернеті, шукаючи просвітлення щодо продовження, і я маю на увазі хизуватися, як найпростіші пояснення можуть настільки заплутати програміста 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);
}

Таким чином, замість повернення значення з функції, ми продовжуємо з іншою функцією. Тому цю функцію називають продовженням першої.

Тож яка різниця між продовженням і зворотним дзвоном?


4
Частина мене думає, що це дійсно гарне запитання, а частина мене вважає, що це занадто довго і, ймовірно, лише призводить до відповіді "так / ні". Однак, завдяки зусиллям та дослідженням, я йду з першим почуттям.
Андрас Золтан

2
Яке ваше запитання? Схоже, ти це дуже добре розумієш.
Michael Aaron Safyan

3
Так, я погоджуюсь - я думаю, що це, мабуть, мало б бути повідомленням у блозі, так як "Продовження JavaScript - те, що я їх розумію".
Андрас Золтан

9
Ну, є важливе питання: "Тож яка різниця між продовженням і зворотним дзвінком?", А потім "Я вірю ...". Відповідь на це питання може бути цікавою?
Плутанина

3
Схоже, це може бути більш доцільно розміщено на programmers.stackexchange.com.
Брайан Рейшл

Відповіді:


164

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

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

Якщо функція викликає іншу функцію як останнє, що вона робить, то вона називається хвостовим викликом. Деякі мови, як-от Схема, виконують оптимізацію хвостових викликів. Це означає, що хвостовий виклик не здійснює повних накладних витрат функціонального виклику. Натомість він реалізований як простий 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. Проходження довкола продовження збільшує розмір стека викликів. Якщо ви не використовуєте мову на зразок Scheme, яка усуває хвостові дзвінки, ви ризикуєте втратити місце у стеку.
  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);

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може бути використана для реалізації складних структур управління потоком, таких як блоки спробу-захоплення, розробки, генератори, волокна тощо.


10
Я так вдячні слова не можу описати. Нарешті, я зрозумів на рівні інтуїції одним поняттям всі поняття, пов'язані із продовженням! Я новий, як тільки він натиснув, це буде просто, і я побачив, що я використовував шаблон багато разів, перш ніж несвідомо, і це було просто так. Велике спасибі за чудове і зрозуміле пояснення.
ата

2
Батути - це досить прості, але потужні речі. Перевірте публікацію Регінальда Брейтвайта про них.
Марко Фаустінеллі

1
Дякую за відповідь. Цікаво, чи могли ви надати більшу підтримку твердження про те, що callcc неможливо реалізувати в JavaScript? Можливо, пояснення того, що JavaScript знадобиться для його впровадження?
Джон Генрі

1
@JohnHenry - ну, насправді є функція call / cc в JavaScript, виконана Меттом Мейтом ( matt.might.net/articles/by-example-continuation-passing-style - перейдіть до останнього абзацу), але будь ласка, не робіть не запитайте мене, як це працює, ні як ним користуватися :-)
Марко Фаустінеллі

1
@JohnHenry JS знадобиться продовження першого класу (подумайте про них як про механізм захоплення певних станів стека викликів). Але він має лише функції першого класу та закриття, тому CPS - єдиний спосіб імітувати продовження. У схемі conts є неявними, і частина завдання callcc полягає в тому, щоб "переймати" ці неявні контури, щоб споживна функція мала доступ до них. Ось чому callcc в Scheme очікує функцію як єдиний аргумент. Версія CPS callcc у JS відрізняється, оскільки cont передається як явний аргумент func. Отже, callcc Aadit достатньо для багатьох застосувань.
scriptum

27

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

Також трохи дивно називати продовження дзвінків особливим випадком зворотного виклику. Я бачу, як їх легко згрупувати, але продовження не виникало з необхідності відрізняти від зворотного дзвінка. Продовження насправді представляє вказівки, що залишилися для завершення обчислення , або залишок обчислень з цього моменту. Ви можете думати про продовження як про дірку, яку потрібно заповнити. Якщо я можу зафіксувати поточне продовження програми, то я можу повернутися до того, як саме була програма, коли я захопила продовження. (Це впевнено полегшує запис налагоджувачів.)

У цьому контексті відповідь на ваше запитання полягає в тому, що зворотний виклик - це загальна річ, яка дзвонить у будь-який момент часу, визначений деяким договором, наданим абонентом [зворотного дзвінка]. Зворотний виклик може мати стільки аргументів, скільки він хоче, і бути структурованим будь-яким способом. Отже, продовження - це обов'язково процедура одного аргументу, яка вирішує передане в неї значення. Продовження має бути застосовано до одного значення, а додаток має відбутися в кінці. Коли завершення завершення виконання виразу завершено, і, залежно від семантики мови, побічні ефекти можуть бути, а можуть і не бути сформовані.


3
Дякую за роз’яснення. Ви маєте рацію. Продовження - це фактично зміна стану контролю програми: короткий знімок стану програми в певний момент часу. Те, що його можна назвати як нормальну функцію, не має значення. Продовження насправді не є функціями. З іншого боку, зворотні дзвінки - це фактично функції. Ось справжня різниця між продовженням та зворотним викликом. Проте JS не підтримує першокласне продовження. Тільки першокласні функції. Отже, продовження, записане в CPS в JS, є просто функціями. Дякую за ваш внесок =)
Аадіт М Шах

4
@AaditMShah так, я там помиляюсь. Продовженням не повинно бути функції (або процедури, як я її назвав). За визначенням це просто абстрактне представлення речей, які ще попереду. Однак навіть у схемі продовження викликається як процедура і передається як одне ціле. Хм .. це викликає не менш цікаве питання про те, як виглядає продовження, це не функція / процедура.
цві

@AaditMShah досить цікавий, що я продовжив дискусію тут: programmers.stackexchange.com/questions/212057/…
dcow

14

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

Розглянемо функцію:

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(а саме - сумою).

У цьому сенсі, навіть якщо виклик продовження виглядає як виклик функції, він певним чином схожий на оператор повернення або викидання винятку.

Насправді, 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.

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

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

Щоб усунути деякі можливі непорозуміння:

  • Оптимізація виклику хвоста не потрібна для підтримки першокласного продовження. Вважайте, що навіть мова С має (обмежену) форму продовження у формі setjmp(), яка створює продовження, і longjmp()яка викликає одну!

    • З іншого боку, якщо ви наївно намагаєтеся написати програму у стилі продовження без проходження оптимізації виклику хвоста, ви приречені з часом переповнювати стек.
  • Немає конкретних причин для продовження потрібно брати лише один аргумент. Просто цей аргумент для продовження стає значенням повернення call / cc, а call / cc, як правило, визначається як одне значення, що повертається, тому, природно, продовження повинно приймати рівно одне. У мовах із підтримкою декількох повернених значень (наприклад, загальні Lisp, Go, чи справді схема) цілком можливо мати продовження, які приймають кілька значень.


2
Вибачте, якщо я допустив помилки в прикладах JavaScript. Написання цієї відповіді приблизно збільшило вдвічі загальну кількість написаних вами JavaScript.
cpcallen

Чи правильно я розумію, що в цій відповіді ви говорите про необіцяне продовження, а прийнята відповідь говорить про обмежене продовження?
Юзеф Мікушинець

1
"виклик продовження призводить до того, що виконання буде відновлено в точці, коли було створено продовження" - я думаю, ви плутаєте "створення" продовження із захопленням поточного продовження .
Олексій

@ Алекс: це такий тип педантизму, який я схвалюю. Але більшість мов не передбачає жодного способу створення (рефікованого) продовження, окрім того, як фіксувати поточне продовження.
cpcallen

1
@jozef: Я, безумовно, говорю про небажане продовження. Я думаю, що це був і намір Аадіта, хоча, як зазначає dcow, прийнята відповідь не дозволяє відрізнити продовження від (тісно пов'язаних) хвостових викликів, і я зазначаю, що обмежене продовження все одно еквівалентно fuction / процедурі: community.schemewiki.org/ ? composable-продовження-підручник
cpcallen
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.