Devam etme ile geri arama arasındaki fark nedir?


133

Süreklilikler hakkında aydınlanma arayışı içinde tüm web'e göz atıyordum ve en basit açıklamaların benim gibi bir JavaScript programcısını tamamen şaşırtması akıllara durgunluk veriyor. Bu, özellikle çoğu makale Scheme'deki kodla devamları açıkladığında veya monadlar kullandığında doğrudur.

Şimdi nihayet devamların özünü anladığımı düşündüğüme göre, bildiğim şeyin aslında gerçek olup olmadığını bilmek istedim. Doğru olduğunu düşündüğüm şey aslında doğru değilse, o zaman cehalettir, aydınlanma değil.

İşte bildiğim şey:

Hemen hemen tüm dillerde işlevler, arayanlara değerleri (ve denetimi) açıkça döndürür. Örneğin:

var sum = add(2, 3);

console.log(sum);

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

Şimdi, birinci sınıf işlevlere sahip bir dilde, kontrolü ve dönüş değerini çağırana açıkça dönmek yerine bir geri aramaya iletebiliriz:

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

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

Böylece bir fonksiyondan bir değer döndürmek yerine başka bir fonksiyona devam ediyoruz. Bu nedenle bu işleve birincinin devamı denir.

Öyleyse devam ve geri arama arasındaki fark nedir?


4
Bir parçam bunun gerçekten iyi bir soru olduğunu düşünüyor ve bir parçam bunun çok uzun olduğunu ve muhtemelen sadece 'evet / hayır' cevabıyla sonuçlandığını düşünüyor. Ancak, harcanan çaba ve araştırma nedeniyle ilk hissimle gidiyorum.
Andras Zoltan

2
Sorunuz nedir? Görünüşe göre bunu çok iyi anlıyorsun.
Michael Aaron Safyan

3
Evet, katılıyorum - sanırım bu daha çok 'JavaScript Devamları - anladığım gibi' satırları boyunca bir blog yazısı olmalıydı.
Andras Zoltan

9
Pekala, temel bir soru var: "Öyleyse bir devam ve bir geri arama arasındaki fark nedir?" Ve ardından "inanıyorum ...". Bu sorunun cevabı ilginç olabilir mi?
Confusion

3
Bu, programmers.stackexchange.com'da daha uygun bir şekilde yayınlanmış gibi görünüyor.
Brian Reischl

Yanıtlar:


164

Devam etmenin özel bir geri arama durumu olduğuna inanıyorum. Bir işlev, herhangi bir sayıda işlevi herhangi bir sayıda geri çağırabilir. Örneğin:

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

Bununla birlikte, bir işlev başka bir işlevi yaptığı son şey olarak geri çağırırsa, o zaman ikinci işlev, birincinin devamı olarak adlandırılır. Örneğin:

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

Bir işlev, yaptığı son şey olarak başka bir işlevi çağırırsa, buna kuyruk çağrısı denir. Scheme gibi bazı diller, kuyruk arama optimizasyonları gerçekleştirir. Bu, kuyruk çağrısının bir işlev çağrısının tüm ek yüküne maruz kalmadığı anlamına gelir. Bunun yerine, basit bir goto olarak uygulanır (çağrı işlevinin yığın çerçevesi, kuyruk çağrısının yığın çerçevesi ile değiştirilir).

Bonus : Devam eden geçiş stiline geçme. Aşağıdaki programı düşünün:

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

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

Şimdi, her işlem (toplama, çarpma vb. Dahil) işlevler biçiminde yazılsaydı, o zaman elde ederiz:

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

Ek olarak, herhangi bir değer döndürmemize izin verilmezse, devam ettirmeleri aşağıdaki gibi kullanmak zorunda kalırdık:

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

Değerleri döndürmenize izin verilmeyen bu programlama tarzına (ve bu nedenle devam ettirme yoluna başvurmanız gerekir), devamlılık geçiş stili denir.

Bununla birlikte, devam eden geçiş stiliyle ilgili iki sorun vardır:

  1. Devamlılıkların etrafından dolaşmak çağrı yığınının boyutunu artırır. Kuyruk aramalarını ortadan kaldıran Scheme gibi bir dil kullanmadığınız sürece yığın alanının tükenme riskini alırsınız.
  2. İç içe geçmiş işlevler yazmak acı veriyor.

İlk sorun, sürekliliği eşzamansız olarak çağırarak JavaScript'te kolayca çözülebilir. Sürekliliği eşzamansız olarak çağırarak işlev, devam çağrılmadan önce geri döner. Dolayısıyla çağrı yığını boyutu artmaz:

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

İkinci problem genellikle call-with-current-continuationolarak kısaltılan bir fonksiyon kullanılarak çözülür callcc. Maalesef callccJavaScript'te tam olarak uygulanamaz, ancak kullanım durumlarının çoğu için bir değiştirme işlevi yazabiliriz:

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

callccFonksiyon bir işlev alır fve uygular current-continuation(olarak kısaltılmıştır cc). current-continuationÇağrısının işlev gövdesinin geri kalanı kadar kaydırılan bir devamıdır fonksiyonudur callcc.

İşlevin gövdesini düşünün 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İkinci callccbir:

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

Benzer şekilde current-continuationilki callcc:

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

Yana current-continuationilk callccbaşka içeren callccbu devam geçen tarzı dönüştürülmesi gerekir:

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

Yani esasen callccmantıksal olarak tüm işlev gövdesini başladığımız şeye dönüştürür (ve bu anonim işlevlere adı verir cc). Callcc'nin bu uygulamasını kullanan pisagor işlevi şu hale gelir:

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

Yine callccJavaScript'te uygulayamazsınız , ancak bunu JavaScript'te devam geçiş stilini aşağıdaki gibi uygulayabilirsiniz:

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

İşlev callcc, dene-yakala blokları, eşduditler, üreteçler, lifler vb. Gibi karmaşık kontrol akış yapılarını uygulamak için kullanılabilir .


10
Çok minnettarım kelimeler tarif edemez. Sonunda, devamla ilgili tüm kavramları tek bir taramada sezgi düzeyinde anladım! Tıkladığında yeniydim, basit olacaktı ve daha önce bilmeden birçok kez deseni kullandığımı görecektim ve aynen öyle oldu. Harika ve net açıklama için çok teşekkürler.
ata

2
Trambolinler oldukça basit ama güçlü şeylerdir. Lütfen Reginald Braithwaite'in onlar hakkındaki gönderisine bakın .
Marco Faustinelli

1
Cevap için teşekkürler. Callcc'nin JavaScript'te uygulanamayacağı ifadesi için daha fazla destek sağlayıp sağlayamayacağınızı merak ediyorum. Muhtemelen JavaScript'i uygulamak için hangi JavaScript'in gerekeceğine dair bir açıklama?
John Henry

1
@JohnHenry - aslında JavaScript'te Matt Might tarafından yapılan bir çağrı / cc uygulaması var ( matt.might.net/articles/by-example-continuation-passing-style - en son paragrafa gidin), ama lütfen yapma ' Bana nasıl çalıştığını ve nasıl kullanılacağını sorma :-)
Marco Faustinelli

1
@JohnHenry JS'nin birinci sınıf devamlara ihtiyacı olacaktır (bunları çağrı yığınının belirli durumlarını yakalamak için bir mekanizma olarak düşünün). Ancak yalnızca Birinci Sınıf işlevlere ve kapanışlara sahiptir, bu nedenle CPS, sürekliliği taklit etmenin tek yoludur. Şemada bağlamlar örtüktür ve callcc'nin işinin bir parçası bu örtük kavramları "yeniden biçimlendirmektir", böylece tüketici işlev bunlara erişebilir. Bu nedenle, Scheme'deki callcc, tek argüman olarak bir işlev bekler. JS'deki callcc'nin CPS sürümü farklıdır, çünkü cont açık bir func bağımsız değişkeni olarak iletilir. Bu nedenle Aadit'in callcc'si birçok uygulama için yeterlidir.
scriptum

27

Harika yazıma rağmen, terminolojinizi biraz karıştırdığınızı düşünüyorum. Örneğin, bir işlevin yürütmesi gereken son şey çağrı olduğunda bir kuyruk çağrısının gerçekleştiğini haklıyorsunuz, ancak devamlarla ilgili olarak bir kuyruk çağrısı, işlevin çağrıldığı sürekliliği değiştirmediği, yalnızca devamına aktarılan değeri günceller (eğer isterse). Bu nedenle, bir kuyruk özyinelemeli işlevi CPS'ye dönüştürmek çok kolaydır (yalnızca devamı bir parametre olarak eklersiniz ve sonuçta devamı çağırırsınız).

Devamlılıkları özel bir geri arama durumu olarak adlandırmak da biraz tuhaf. Nasıl kolayca bir araya getirildiklerini görebiliyorum, ancak devamlılıklar bir geri aramadan ayırt etme ihtiyacından doğmadı. Bir devam, aslında bir hesaplamayı tamamlamak için kalan talimatları veya zamanın bu noktasından sonra hesaplamanın kalanını temsil eder . Devamlılığı doldurulması gereken bir boşluk olarak düşünebilirsiniz. Bir programın mevcut devamını yakalayabilirsem, devamını yakaladığımda programın tam olarak nasıl olduğuna geri dönebilirim. (Bu kesinlikle hata ayıklayıcıların yazılmasını kolaylaştırır.)

Bu bağlamda, sorunuzun cevabı, bir geri aramanın , arayan tarafından sağlanan [geri aramanın] bir sözleşmesinde belirtilen herhangi bir zamanda aranan genel bir şey olmasıdır. Bir geri arama, istediği kadar argümana sahip olabilir ve istediği şekilde yapılandırılabilir. Demek ki bir devam ettirme , kendisine aktarılan değeri çözen bir tek argüman prosedürüdür. Tek bir değere bir devamı uygulanmalı ve uygulama sonunda gerçekleşmelidir. Bir devam etme tamamlandığında, ifadenin yürütülmesi tamamlanmış olur ve dilin anlam bilgisine bağlı olarak, yan etkiler üretilmiş olabilir veya olmayabilir.


3
Açıklık getirdiğiniz için teşekkürler. Haklısın. Devam etme, aslında programın kontrol durumunun somutlaştırılmasıdır: programın belirli bir zamandaki durumunun anlık görüntüsü. Normal bir işlev gibi çağrılabilmesi konu dışıdır. Devamlılıklar aslında işlevler değildir. Öte yandan geri aramalar aslında işlevlerdir. Devam etme ve geri aramalar arasındaki gerçek fark budur. Yine de JS birinci sınıf devamlılıkları desteklemez. Yalnızca birinci sınıf işlevler. Bu nedenle, JS'de CPS'de yazılan devamlılıklar basit işlevlerdir. Girişiniz için teşekkürler. =)
Aadit M Shah

4
@AaditMShah evet, orada yanlış söyledim. Devam etmenin bir işlev (veya benim dediğim gibi prosedür) olması gerekmez. Tanımı gereği, henüz gelecek şeylerin soyut temsilidir. Bununla birlikte, Scheme'de bile, bir prosedür gibi bir devam ettirme çağrılır ve bir olarak devredilir. Hmm .. bu bir devamın neye benzediğine dair aynı derecede ilginç bir soruyu gündeme getiriyor, bunun bir fonksiyon / prosedür olmadığı.
dcow

@AaditMShah, tartışmaya burada devam ettiğim için yeterince ilginç: programmers.stackexchange.com/questions/212057/…
dcow

14

Kısa cevap, bir sürdürme ile geri arama arasındaki fark, bir geri arama çağrıldıktan sonra (ve tamamlandıktan sonra) yürütmenin çağrıldığı noktada devam etmesidir ve bir devam ettirmeyi çağırmak, devam ettirmenin oluşturulduğu noktada yürütmenin devam etmesine neden olur. Başka bir deyişle: bir devam asla geri dönmez .

İşlevi düşünün:

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

(Javascript aslında birinci sınıf devamları desteklemese de Javascript sözdizimini kullanıyorum çünkü örneklerinizi bu şekilde vermiştiniz ve Lisp sözdizimine aşina olmayan insanlar için daha anlaşılır olacak.)

Şimdi, bir geri aramayı iletirsek:

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

sonra üç uyarı göreceğiz: "önce", "5" ve "sonra".

Öte yandan, geri aramanın yaptığı gibi aynı şeyi yapan bir devam ettirirsek, şöyle:

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

o zaman yalnızca iki uyarı görürdük: "önce" ve "5". Çağırma c()içindeki add()uçların yürütme add()ve nedenleri callcc()dönüş; tarafından döndürülen değer callcc(), bağımsız değişken olarak iletilen değerdir c(yani, toplam).

Bu anlamda, bir devamı çağırmak bir işlev çağrısı gibi görünse de, bazı yönlerden bir dönüş ifadesine veya bir istisna atmaya daha benzerdir.

Aslında call / cc, desteklemeyen dillere dönüş ifadeleri eklemek için kullanılabilir. Örneğin, JavaScript'in return ifadesi yoksa (bunun yerine, birçok Lips dili gibi, yalnızca işlev gövdesindeki son ifadenin değerini döndürür), ancak call / cc'ye sahipse, return şu şekilde uygulayabiliriz:

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

Arama return(i)anonim fonksiyonunun uygulanmasını sona erer ve neden olan bir devamıdır başlatır callcc()dizin dönmek için ihangi targetbulundumyArray .

(NB: "dönüş" benzetmesinin biraz basit olduğu bazı yollar vardır. Örneğin, bir devamlılık oluşturulduğu işlevden kaçarsa - diyelim ki küresel bir yere kaydedilerek - işlevin sürekliliği oluşturan, yalnızca bir kez çağrılsa bile birden çok kez dönebilir .)

Call / cc, istisna işleme (fırlat ve dene / yakala), döngüler ve diğer birçok kontrol yapısını uygulamak için benzer şekilde kullanılabilir.

Bazı olası yanlış anlamaları gidermek için:

  • Birinci sınıf devamlılıkları desteklemek için kuyruk çağrısı optimizasyonu hiçbir şekilde gerekli değildir. C dilinin bile (sınırlı) şeklinde devam setjmp()eden, bir devamlılık yaratan ve longjmp()birini çağıran!

    • Öte yandan, programınızı kuyruk çağrısı optimizasyonu olmadan saf bir şekilde devam geçiş tarzında yazmaya çalışırsanız, sonunda yığından taşmaya mahkum olursunuz.
  • Bir devam etmenin yalnızca bir argüman gerektirmesinin özel bir nedeni yoktur. Sadece devam argüman (lar) ı call / cc'nin dönüş değeri (ler) i haline gelir ve call / cc tipik olarak tek bir dönüş değerine sahip olarak tanımlanır, bu nedenle doğal olarak devam tam olarak bir tane almalıdır. Çoklu dönüş değerlerini destekleyen dillerde (Common Lisp, Go veya aslında Scheme gibi) çoklu değerleri kabul eden devamlara sahip olmak tamamen mümkündür.


2
JavaScript örneklerinde herhangi bir hata yaptıysam özür dilerim. Bu cevabı yazmak, yazdığım toplam JavaScript miktarını kabaca ikiye katladı.
cpcallen

Bu cevapta sınırsız devamlardan bahsettiğinizi ve kabul edilen cevabın sınırlandırılmış devamlardan bahsettiğini doğru anlıyor muyum?
Jozef Mikušinec

1
"bir sürekliliğin çağrılması, sürekliliğin yaratıldığı noktada yürütmenin devam etmesine neden olur" - bence bir sürekliliği "yaratmak" ile mevcut devamı yakalamakla karıştırıyorsunuz .
Alexey

@Alexey: Bu benim onayladığım türden bir bilgiçlik. Ancak çoğu dil, mevcut devamlılığı yakalamaktan başka (somutlaştırılmış) bir devam yaratmanın herhangi bir yolunu sağlamaz.
cpcallen

1
@jozef: Kesinlikle sınırsız devamlardan bahsediyorum. Ben dcow notlar gibi kabul cevap kuyruk aramaları (yakından ilgili) den devamlılık ayrıştıramamaktadır olsa o yanı Aadit niyeti olduğunu düşünüyorum ve sınırlandırılmış devamı zaten fonksiyon konularında / prosedüre eşdeğer olduğuna dikkat: community.schemewiki.org/ ? composable-
continations
Sitemizi kullandığınızda şunları okuyup anladığınızı kabul etmiş olursunuz: Çerez Politikası ve Gizlilik Politikası.
Licensed under cc by-sa 3.0 with attribution required.