javascript - Aký je rozdiel medzi continuáciou a callbackom?
Prehľadával som celý internet v hľadaní osvetlenia o continuations, a je ohromujúce, ako najjednoduchšie vysvetlenia môžu tak úplne zmiasť JavaScript programátora ako som ja. Toto je obzvlášť pravda, keď väčšina článkov vysvetľuje continuations pomocou kódu v Scheme alebo používa monády.
Teraz, keď si konečne myslím, že som pochopil podstatu pokračovaní, chcel som vedieť, či to, čo viem, je naozaj pravda. Ak to, čo si myslím, že je pravda, nie je skutočne pravda, potom je to nevedomosť a nie osvetlenie.
Takže, tu je to, čo viem:
Vo väčšine jazykov funkcie explicitne vracajú hodnoty (a kontrolu) späť volajúcemu. Napríklad:
var sum = add(2, 3);
console.log(sum);
function add(x, y) {
return x + y;
}Teraz v jazyku s funkciami prvej triedy môžeme odovzdať ovládanie a návratovú hodnotu spätnému volaniu namiesto toho, aby sme explicitne vracali volajúcemu:
add(2, 3, function (sum) {
console.log(sum);
});
function add(x, y, cont) {
cont(x + y);
}Takže namiesto návratu hodnoty z funkcie pokračujeme s ďalšou funkciou. Preto sa táto funkcia nazýva pokračovaním prvej.
Tak aký je rozdiel medzi pokračovaním a spätným volaním?
Odpoveď č. 1
Verím, že pokračovania sú špeciálnym prípadom spätných volaní. Funkcia môže zavolať späť ľubovoľný počet funkcií, ľubovoľný počet ráz. Napríklad:
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);
}Avšak ak funkcia nakoniec zavolá inú funkciu ako poslednú vec, ktorú urobí, potom sa druhá funkcia nazýva pokračovaním prvej. Napríklad:
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);
}
}
}Ak funkcia volá inú funkciu ako poslednú vec, ktorú robí, nazýva sa to chvostové volanie. Niektoré jazyky, ako Scheme, vykonávajú optimalizácie chvostového volania. To znamená, že chvostové volanie nezahŕňa plnú režiu volania funkcie. Namiesto toho je implementované ako jednoduché goto (pričom zásobníkový rámec volajúcej funkcie je nahradený zásobníkovým rámcom chvostového volania).
Bonus: Pokračovanie k štýlu prechodu pokračovania. Zvážte nasledujúci program:
console.log(pythagoras(3, 4));
function pythagoras(x, y) {
return x * x + y * y;
}Teraz, keby bola každá operácia (vrátane sčítania, násobenia atď.) zapísaná vo forme funkcií, potom by sme mali:
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;
}Okrem toho, keby nám nebolo dovolené vracať žiadne hodnoty, museli by sme použiť pokračovania nasledovne:
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);
}Tento štýl programovania, v ktorom nie je dovolené vracať hodnoty (a preto musíte používať prenášanie kontinuácií), sa nazýva štýl prenášania kontinuácií.
Existujú však dva problémy so štýlom pokračovania.
- Prenášanie pokračovaní zvyšuje veľkosť zásobníka volaní. Pokiaľ nepoužívate jazyk ako Scheme, ktorý eliminuje chvostové volania, riskujete vyčerpanie zásobníka.
- Je to náročné písať vnorené funkcie.
Prvý problém je možné v JavaScripte ľahko vyriešiť volaním pokračovaní asynchrónne. Volaním pokračovania asynchrónne sa funkcia vráti skôr, než sa pokračovanie zavolá. Preto sa veľkosť zásobníka volaní nezvyšuje:
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);
}Druhý problém sa zvyčajne rieši pomocou funkcie s názvom call-with-current-continuation, ktorá sa často skracuje na callcc. Bohužiaľ, callcc sa nedá úplne implementovať v JavaScripte, ale môžeme napísať náhradnú funkciu pre väčšinu jej prípadov použitia:
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;
}Funkcia callcc berie funkciu f a aplikuje ju na current-continuation (skrátene cc). Current-continuation je pokračovacia funkcia, ktorá obklopuje zvyšok tela funkcie po volaní callcc.
Zvážte telo funkcie pythagoras:
var x_squared = callcc(square.bind(null, x));
var y_squared = callcc(square.bind(null, y));
add(x_squared, y_squared, cont);
Aktuálna kontinuuácia druhého callcc je:
function cc(y_squared) {
add(x_squared, y_squared, cont);
}
Podobne je current-continuation prvej callcc:
function cc(x_squared) {
var y_squared = callcc(square.bind(null, y));
add(x_squared, y_squared, cont);
}
Keďže current-continuation prvého callcc obsahuje ďalší callcc, musí byť prevedená na štýl odovzdávania continuit:
function cc(x_squared) {
square(y, function cc(y_squared) {
add(x_squared, y_squared, cont);
});
}
Takže v podstate callcc logicky prevádza celé telo funkcie späť na to, od čoho sme začali (a týmto anonymným funkciám dáva meno cc). Funkcia pythagoras používajúca túto implementáciu callcc potom vyzerá takto:
function pythagoras(x, y, cont) {
callcc(function(cc) {
square(x, function (x_squared) {
square(y, function (y_squared) {
add(x_squared, y_squared, cont);
});
});
});
}
Opäť nemôžete implementovať callcc v JavaScripte, ale môžete ho implementovať v štýle predávania pokračovania v JavaScripte nasledovne:
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);
}Funkcia callcc môže byť použitá na implementáciu zložitých riadiacich štruktúr ako sú bloky try-catch, korutiny, generátory, vlákna a podobne.
Odpoveď č. 2
Napriek skvelej recenzii si myslím, že trochu zamieňaš terminológiu. Napríklad, máš pravdu, že tzv. tail call nastane, keď je volanie poslednou vecou, ktorú funkcia potrebuje vykonať, ale vo vzťahu ku kontinuáciám tail call znamená, že funkcia nemodifikuje kontinuitu, s ktorou je volaná, iba aktualizuje hodnotu odovzdanú kontinuitu (ak chce). Preto je tak ľahké konvertovať tail rekurzívnu funkciu do CPS (jednoducho pridáš kontinuitu ako parameter a zavoláš kontinuitu na výsledok).
Je tiež trochu zvláštne nazývať pokračovania osobitným prípadom spätných volaní. Vidím, ako sa dajú ľahko zoskupiť dokopy, ale pokračovania nevznikli z potreby odlíšiť ich od spätného volania. Pokračovanie vlastne predstavuje pokyny zostávajúce na dokončenie výpočtu, alebo zvyšok výpočtu od tohto bodu v čase. Pokračovanie si môžete predstaviť ako dieru, ktorú je potrebné zaplniť. Ak dokážem zachytiť súčasné pokračovanie programu, potom sa môžem vrátiť presne do toho, ako program vyzeral v čase zachytenia pokračovania. (To určite uľahčuje písanie ladiacich programov.)
V tomto kontexte je odpoveď na vašu otázku taká, že callback je všeobecná vec, ktorá sa volá kedykoľvek podľa niektorého kontraktu poskytnutého volajúcim [callbacku]. Callback môže mať toľko argumentov, koľko chce, a môže byť štruktúrovaný akokoľvek chce. Pokračovanie je potom nevyhnutne procedúra s jedným argumentom, ktorá vyrieši hodnotu, ktorá jej bola odovzdaná. Pokračovanie musí byť aplikované na jednu hodnotu a aplikácia musí nastať na konci. Keď pokračovanie dokončí vykonávanie, výraz je kompletný a v závislosti od sémantiky jazyka mohli, alebo nemuseli byť vyvolané vedľajšie účinky.
Odpoveď č. 3
Krátka odpoveď je, že rozdiel medzi continuáciou a callbackom je v tom, že po vyvolaní callbacku (a jeho dokončení) sa vykonávanie obnoví na mieste, kde bol callback vyvolaný, zatiaľ čo vyvolanie continuácie spôsobí, že sa vykonávanie obnoví na mieste, kde bola continuácia vytvorená. Inými slovami: continuácia sa nikdy nevracia.
Zvážte funkciu:
function add(x, y, c) {
alert("before");
c(x+y);
alert("after");
}
(Používam syntax Javascriptu, aj keď Javascript vlastne nepodporuje pokračovania prvej triedy, pretože to bolo to, čo ste použili vo svojich príkladoch, a bude to zrozumiteľnejšie pre ľudí, ktorí nie sú oboznámení so syntaxou Lisp.)
Teraz, ak mu odovzdáme spätné volanie:
add(2, 3, function (sum) {
alert(sum);
});
Potom uvidíme tri upozornenia: "Predtým", "5" a "Po".
Na druhej strane, ak by sme mu odovzdali pokračovanie, ktoré robí to isté, čo robí spätné volanie, takto:
alert(callcc(function(cc) {
add(2, 3, cc);
}));
potom by sme videli iba dve upozornenia: „pred“ a „5“. Volanie c() vnútri add() ukončí vykonávanie add() a spôsobí, že callcc() sa vráti; hodnota vrátená callcc() bola hodnota odovzdaná ako argument c (konkrétne súčet).
V tomto zmysle, aj keď vyvolanie pokračovania vyzerá ako volanie funkcie, je v niektorých ohľadoch bližšie k príkazu return alebo vyvolaniu výnimky.
V skutočnosti je možné použiť call/cc na pridanie príkazov return do jazykov, ktoré ich nepodporujú. Napríklad, ak by JavaScript nemal príkaz return (namiesto toho, podobne ako mnoho jazykov Lisp, by len vrátil hodnotu posledného výrazu v tele funkcie), ale mal by call/cc, mohli by sme implementovať return takto:
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.
});
}
Volanie return(i) vyvolá kontinuačnú funkciu, ktorá ukončí vykonávanie anonymnej funkcie a spôsobí, že callcc() vráti index i, na ktorom bol target nájdený v myArray.
(Poznámka: existujú niektoré spôsoby, ktorými je analógia „návratu“ trochu zjednodušená. Napríklad, ak continuácia unikne z funkcie, v ktorej bola vytvorená - tým, že sa uloží niekde do globálnej premennej, povedzme - je možné, že funkcia, ktorá vytvorila continuáciu, môže sa vrátiť viackrát, aj keď bola zavolaná len raz.)
Call/cc sa môže podobne použiť na implementáciu spracovania výnimiek (throw a try/catch), slučiek a mnohých ďalších riadiacich štruktúr.
Aby sme objasnili niektoré možné nedorozumenia:
Optimalizácia volania chvosta nie je zďaleka potrebná na podporu kontinuácií prvej triedy. Uvedomme si, že aj jazyk C má (obmedzenú) formu kontinuácií vo forme
setjmp(), ktorá vytvára kontinuáciu, alongjmp(), ktorá jednu vyvolá!- Na druhej strane, ak sa naivne pokúsite napísať svoj program v štýle passing continuation bez optimalizácie koncových volaní, ste odsúdení na to, že nakoniec pretečie zásobník.
Neexistuje žiadny konkrétny dôvod, prečo by pokračovanie malo brať iba jeden argument. Je to len preto, že argument(y) pokračovania sa stanú návratovou hodnotou call/cc, a call/cc je zvyčajne definované tak, že má jednu návratovú hodnotu, takže prirodzene musí pokračovanie brať presne jeden. V jazykoch, ktoré podporujú viacnásobné návratové hodnoty (ako Common Lisp, Go alebo dokonca Scheme), by bolo úplne možné mať pokračovania, ktoré prijímajú viac hodnôt.
