Qual è la differenza tra una continuazione e una richiamata?


133

Ho navigato in tutto il Web alla ricerca dell'illuminazione sulle continuazioni, ed è sbalorditivo come la più semplice delle spiegazioni possa confondere così completamente un programmatore JavaScript come me. Ciò è particolarmente vero quando la maggior parte degli articoli spiega le continuazioni con il codice in Scheme o usa le monadi.

Ora che finalmente penso di aver compreso l'essenza delle continuazioni, volevo sapere se quello che so è in realtà la verità. Se ciò che penso sia vero non è in realtà vero, allora è ignoranza e non illuminazione.

Quindi, ecco quello che so:

In quasi tutte le lingue le funzioni restituiscono esplicitamente valori (e controllo) al chiamante. Per esempio:

var sum = add(2, 3);

console.log(sum);

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

Ora in un linguaggio con funzioni di prima classe possiamo passare il controllo e restituire il valore a un callback invece di tornare esplicitamente al chiamante:

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

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

Quindi invece di restituire un valore da una funzione stiamo continuando con un'altra funzione. Pertanto questa funzione è chiamata una continuazione della prima.

Quindi qual è la differenza tra una continuazione e una richiamata?


4
Una parte di me pensa che questa sia davvero una bella domanda e una parte di me pensa che sia troppo lunga e probabilmente si traduce solo in una risposta "sì / no". Tuttavia, a causa dello sforzo e della ricerca coinvolti, vado con il mio primo sentimento.
Andras Zoltan,

2
Qual'è la tua domanda? Sembra che tu lo capisca abbastanza bene.
Michael Aaron Safyan,

3
Sì, sono d'accordo - penso che probabilmente avrebbe dovuto essere un post sul blog più sulla falsariga di "Continuazioni JavaScript - quello che capisco che siano".
Andras Zoltan,

9
Bene, c'è una domanda essenziale: "Quindi qual è la differenza tra una continuazione e un callback?", Seguito da un "Credo ...". La risposta a questa domanda può essere interessante?
Confusione il

3
Sembra che potrebbe essere pubblicato in modo più appropriato su programmers.stackexchange.com.
Brian Reischl,

Risposte:


164

Credo che le continuazioni siano un caso speciale di richiamate. Una funzione può richiamare un numero qualsiasi di funzioni, un numero qualsiasi di volte. Per esempio:

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

Tuttavia, se una funzione richiama un'altra funzione come ultima cosa, la seconda funzione viene chiamata continuazione della prima. Per esempio:

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

Se una funzione chiama un'altra funzione come ultima cosa che fa, allora si chiama coda. Alcune lingue come Scheme eseguono l'ottimizzazione delle chiamate di coda. Ciò significa che la chiamata di coda non comporta l'intero overhead di una chiamata di funzione. Invece è implementato come un semplice goto (con il frame dello stack della funzione di chiamata sostituito dal frame dello stack della chiamata di coda).

Bonus : procedere allo stile di passaggio di continuazione. Considera il seguente programma:

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

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

Ora se ogni operazione (inclusa aggiunta, moltiplicazione, ecc.) Fosse scritta sotto forma di funzioni, avremmo:

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

Inoltre, se non ci fosse permesso di restituire alcun valore, dovremmo usare le continuazioni come segue:

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

Questo stile di programmazione in cui non è consentito restituire valori (e quindi è necessario ricorrere a continuazioni di passaggio) viene chiamato stile di passaggio di continuazione.

Vi sono tuttavia due problemi con lo stile del passaggio di continuazione:

  1. Il passaggio di continuazioni aumenta la dimensione dello stack di chiamate. A meno che tu non stia usando un linguaggio come Scheme che elimina le chiamate di coda, rischierai di rimanere senza spazio nello stack.
  2. Scrivere funzioni nidificate è una seccatura.

Il primo problema può essere facilmente risolto in JavaScript chiamando le continuazioni in modo asincrono. Chiamando la continuazione in modo asincrono, la funzione ritorna prima che venga chiamata la continuazione. Quindi la dimensione dello stack di chiamate non aumenta:

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

Il secondo problema è di solito risolto usando una funzione chiamata call-with-current-continuationche è spesso abbreviata come callcc. Purtroppo callccnon può essere completamente implementato in JavaScript, ma potremmo scrivere una funzione di sostituzione per la maggior parte dei suoi casi d'uso:

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

La callccfunzione accetta una funzione fe la applica a current-continuation(abbreviato come cc). La current-continuationè una funzione continuazione che avvolge il resto del corpo funzione dopo la chiamata a callcc.

Considera il corpo della funzione pythagoras:

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

Il current-continuationsecondo callccè:

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

Allo stesso modo il current-continuationprimo callccè:

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

Poiché il current-continuationprimo callcccontiene un altro callcc, deve essere convertito in stile di passaggio di continuazione:

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

Quindi essenzialmente callccricalca logicamente l'intero corpo della funzione in ciò da cui siamo partiti (e dà a quelle funzioni anonime il nome cc). La funzione pitagora che utilizza questa implementazione di callcc diventa quindi:

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

Ancora una volta non è possibile implementare callccin JavaScript, ma è possibile implementare lo stile di passaggio di continuazione in JavaScript come segue:

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

La funzione callccpuò essere utilizzata per implementare complesse strutture di flusso di controllo come blocchi try-catch, coroutine, generatori, fibre , ecc.


10
Sono così grato che le parole non possano descriverlo. Finalmente ho capito a livello di intuizione tutti i concetti relativi alla continuazione in un colpo solo! Una volta che ho fatto clic nuovo, sarebbe stato semplice e vedrei che ho usato lo schema molte volte prima inconsapevolmente, ed era proprio così. Grazie mille per la spiegazione meravigliosa e chiara.
ata

2
I trampolini sono roba abbastanza semplice, ma potente. Si prega di controllare il post di Reginald Braithwaite su di loro.
Marco Faustinelli,

1
Grazie per la risposta. Mi chiedo se potresti fornire più supporto per l'affermazione che callcc non può essere implementato in JavaScript? Forse una spiegazione di quale JavaScript avrebbe bisogno per implementarlo?
John Henry,

1
@JohnHenry - beh, in realtà c'è un'implementazione call / cc in JavaScript fatta da Matt Might ( matt.might.net/articles/by-example-continuation-passing-style - vai all'ultimo paragrafo), ma per favore don ' non chiedermi come funziona né come usarlo :-)
Marco Faustinelli il

1
@JohnHenry JS avrebbe bisogno di continuazioni di prima classe (pensale come un meccanismo per catturare determinati stati dello stack di chiamate). Ma ha solo funzioni e chiusure di prima classe, quindi CPS è l'unico modo per imitare le continuazioni. Nello Schema i coni sono impliciti e parte del lavoro di callcc consiste nel "reimpostare" questi coni impliciti, in modo che la funzione di consumo abbia accesso ad essi. Ecco perché callcc in Scheme prevede una funzione come unico argomento. La versione CPS di callcc in JS differisce, perché il cont viene passato come argomento func esplicito. Quindi il callcc di Aadit è sufficiente per molte applicazioni.
scriptum,

27

Nonostante la meravigliosa scrittura, penso che tu stia confondendo un po 'la tua terminologia. Ad esempio, si ha ragione sul fatto che una chiamata di coda si verifica quando la chiamata è l'ultima cosa che una funzione deve eseguire, ma in relazione alle continuazioni, una chiamata di coda indica che la funzione non modifica la continuazione con cui viene chiamata, solo che aggiorna il valore passato alla continuazione (se lo desidera). Ecco perché convertire una funzione ricorsiva di coda in CPS è così semplice (basta aggiungere la continuazione come parametro e chiamare la continuazione sul risultato).

È anche un po 'strano chiamare le continuazioni un caso speciale di callback. Vedo come sono facilmente raggruppati insieme, ma le continuazioni non sono nate dalla necessità di distinguere da una richiamata. Una continuazione in realtà rappresenta le istruzioni rimanenti per completare un calcolo , o il resto del calcolo da questo punto nel tempo. Puoi pensare a una continuazione come a un buco che deve essere riempito. Se riesco a catturare l'attuale continuazione di un programma, posso tornare esattamente a come era il programma quando ho catturato la continuazione. (Ciò rende sicuramente più facile scrivere i debugger.)

In questo contesto, la risposta alla tua domanda è che un callback è una cosa generica che viene chiamata in qualsiasi momento specificato da un contratto fornito dal chiamante [del callback]. Un callback può avere tutti gli argomenti che vuole ed essere strutturato nel modo che desidera. Una continuazione , quindi, è necessariamente una procedura a argomento unico che risolve il valore passato in essa. Una continuazione deve essere applicata a un singolo valore e l'applicazione deve avvenire alla fine. Quando una continuazione termina l'esecuzione dell'espressione è completa e, a seconda della semantica del linguaggio, potrebbero essere stati generati o meno effetti collaterali.


3
Grazie per il tuo chiarimento. Hai ragione. Una continuazione è in realtà una reificazione dello stato di controllo del programma: un'istantanea dello stato del programma in un determinato momento. Il fatto che possa essere chiamato come una normale funzione è irrilevante. Le continuazioni non sono in realtà funzioni. I callback invece sono in realtà funzioni. Questa è la vera differenza tra continuazioni e callback. Tuttavia JS non supporta le continuazioni di prima classe. Solo funzioni di prima classe. Quindi le continuazioni scritte in CPS in JS sono semplicemente funzioni. Grazie per il tuo contributo. =)
Aadit M Shah,

4
@AaditMShah sì, ho sbagliato a parlare lì. Una continuazione non deve necessariamente essere una funzione (o procedura come l'ho chiamata). Per definizione è semplicemente la rappresentazione astratta delle cose che devono ancora venire. Tuttavia, anche nello Schema una continuazione viene invocata come una procedura e passata come una. Hmm .. questo solleva la domanda altrettanto interessante di come appare una continuazione che non è una funzione / procedura.
dc

@AaditMShah abbastanza interessante da aver continuato la discussione qui: programmers.stackexchange.com/questions/212057/…
dcow,

14

La risposta breve è che la differenza tra una continuazione e una richiamata è che dopo che una richiamata è invocata (e ha terminato) l'esecuzione riprende nel punto in cui è stata invocata, mentre invocare una continuazione fa riprendere l'esecuzione nel punto in cui è stata creata la continuazione. In altre parole: una continuazione non ritorna mai .

Considera la funzione:

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

(Uso la sintassi Javascript anche se in realtà Javascript non supporta le continuazioni di prima classe perché questo è ciò in cui hai fornito i tuoi esempi e sarà più comprensibile per le persone che non hanno familiarità con la sintassi di Lisp.)

Ora, se gli passiamo un callback:

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

quindi vedremo tre avvisi: "prima", "5" e "dopo".

D'altra parte, se dovessimo passare una continuazione che fa la stessa cosa del callback, in questo modo:

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

allora vedremmo solo due avvisi: "prima" e "5". Invocare c()dentro add()termina l'esecuzione add()e fa callcc()tornare; il valore restituito da callcc()era il valore passato come argomento a c(vale a dire la somma).

In questo senso, anche se invocare una continuazione sembra una chiamata di funzione, è in qualche modo più simile a una dichiarazione di ritorno o lanciare un'eccezione.

In effetti, call / cc può essere usato per aggiungere dichiarazioni di ritorno a lingue che non le supportano. Ad esempio, se JavaScript non avesse un'istruzione return (invece, come molti linguaggi Lips, restituendo semplicemente il valore dell'ultima espressione nel corpo della funzione) ma avesse call / cc, potremmo implementare return in questo modo:

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

La chiamata return(i)richiama una continuazione che termina l'esecuzione della funzione anonima e causa la callcc()restituzione dell'indice iin cui è targetstata trovata myArray.

(NB: ci sono alcuni modi in cui l'analogia del "ritorno" è un po 'semplicistica. Ad esempio, se una continuazione fuoriesce dalla funzione in cui è stata creata - essendo salvata in un luogo globale, diciamo - è possibile che la funzione che ha creato la continuazione può tornare più volte anche se è stata invocata una sola volta .)

Call / cc può essere usato allo stesso modo per implementare la gestione delle eccezioni (lancio e prova / cattura), loop e molte altre strutture di controllo.

Per chiarire alcune possibili incomprensioni:

  • L'ottimizzazione delle chiamate di coda non è assolutamente necessaria per supportare continuazioni di prima classe. Considera che anche il linguaggio C ha una forma (limitata) di continuazioni nella forma di setjmp(), che crea una continuazione e longjmp()che invoca una!

    • D'altra parte, se provi ingenuamente a scrivere il tuo programma in continuazione passando lo stile senza l'ottimizzazione delle chiamate di coda, sei destinato a traboccare lo stack.
  • Non vi è alcun motivo particolare per cui una continuazione debba prendere solo un argomento. È solo che l'argomento / i alla continuazione diventano il / i valore / i di ritorno di call / cc, e call / cc è generalmente definito come avente un singolo valore di ritorno, quindi naturalmente la continuazione deve prendere esattamente uno. Nelle lingue con supporto per più valori di ritorno (come Common Lisp, Go o effettivamente Scheme) sarebbe del tutto possibile avere continuazioni che accettano più valori.


2
Mi scuso se ho commesso degli errori negli esempi JavaScript. Scrivere questa risposta ha quasi raddoppiato la quantità totale di JavaScript che ho scritto.
cpcallen,

Comprendo correttamente che stai parlando di continuazioni non delimitate in questa risposta e che la risposta accettata parla di continuazioni delimitate?
Jozef Mikušinec

1
"invocare una continuazione fa riprendere l'esecuzione nel punto in cui è stata creata la continuazione" - penso che tu stia confondendo "la creazione" di una continuazione con l'acquisizione della continuazione corrente .
Alexey,

@Alexey: questo è il tipo di pedanteria che approvo. Ma la maggior parte delle lingue non fornisce alcun modo per creare una continuazione (reificata) se non quella di catturare la continuazione corrente.
cpcallen,

1
@jozef: sto sicuramente parlando di continuazioni non delimitate. Penso che sia stata anche l'intenzione di Aadit, sebbene, come nota dcow, la risposta accettata non riesca a distinguere le continuazioni dalle chiamate di coda (strettamente correlate) e noto che una continuazione delimitata equivale comunque a una fuction / procedura: community.schemewiki.org/ ? composable-continuations-tutorial
cpcallen
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.