Sự khác biệt giữa tiếp tục và gọi lại là gì?


133

Tôi đã duyệt trên tất cả các trang web để tìm kiếm sự giác ngộ về các phần tiếp theo và tôi cảm thấy khó hiểu khi cách giải thích đơn giản nhất có thể khiến một lập trình viên JavaScript như tôi hoàn toàn bối rối. Điều này đặc biệt đúng khi hầu hết các bài viết giải thích các phần tiếp theo với mã trong Lược đồ hoặc sử dụng các đơn nguyên.

Bây giờ tôi cuối cùng đã nghĩ rằng tôi đã hiểu bản chất của những sự tiếp nối mà tôi muốn biết liệu những gì tôi biết có thực sự là sự thật hay không. Nếu những gì tôi nghĩ là đúng không thực sự đúng, thì đó là vô minh và không giác ngộ.

Vì vậy, đây là những gì tôi biết:

Trong hầu hết tất cả các chức năng, các hàm đều trả về giá trị (và điều khiển) cho người gọi của chúng. Ví dụ:

var sum = add(2, 3);

console.log(sum);

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

Bây giờ trong một ngôn ngữ có các hàm hạng nhất, chúng ta có thể chuyển giá trị điều khiển và trả về cho một cuộc gọi lại thay vì trả lại rõ ràng cho người gọi:

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

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

Do đó, thay vì trả về một giá trị từ một hàm, chúng ta đang tiếp tục với một hàm khác. Do đó chức năng này được gọi là tiếp tục đầu tiên.

Vì vậy, sự khác biệt giữa tiếp tục và gọi lại là gì?


4
Một phần trong tôi nghĩ rằng đây là một câu hỏi thực sự hay và một phần trong tôi nghĩ rằng nó quá dài và có lẽ chỉ dẫn đến câu trả lời 'có / không'. Tuy nhiên vì những nỗ lực và nghiên cứu liên quan, tôi đi với cảm giác đầu tiên của mình.
Andras Zoltan

2
Câu hỏi của bạn là gì? Âm thanh như bạn hiểu điều này khá tốt.
Michael Aaron Safyan

3
Có, tôi đồng ý - Tôi nghĩ rằng nó có lẽ nên là một bài đăng trên blog dọc theo dòng 'Tiếp tục JavaScript - những gì tôi hiểu chúng là'.
Andras Zoltan

9
Chà, có một câu hỏi thiết yếu: "Vậy đâu là sự khác biệt giữa tiếp tục và gọi lại?", Tiếp theo là "Tôi tin ...". Câu trả lời cho câu hỏi đó có thú vị không?
Nhầm lẫn

3
Điều này có vẻ như có thể được đăng một cách thích hợp hơn trên các lập trình viên.stackexchange.com.
Brian Reischl

Câu trả lời:


164

Tôi tin rằng sự tiếp tục là một trường hợp đặc biệt của các cuộc gọi lại. Một chức năng có thể gọi lại bất kỳ số lượng chức năng, bất kỳ số lần. Ví dụ:

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

Tuy nhiên, nếu một chức năng gọi lại một chức năng khác như là điều cuối cùng nó thực hiện thì chức năng thứ hai được gọi là tiếp tục của chức năng đầu tiên. Ví dụ:

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

Nếu một chức năng gọi một chức năng khác là điều cuối cùng thì nó được gọi là một cuộc gọi đuôi. Một số ngôn ngữ như Scheme thực hiện tối ưu hóa cuộc gọi đuôi. Điều này có nghĩa là cuộc gọi đuôi không phát sinh toàn bộ chi phí của cuộc gọi chức năng. Thay vào đó, nó được triển khai như một goto đơn giản (với khung ngăn xếp của chức năng gọi được thay thế bằng khung ngăn xếp của cuộc gọi đuôi).

Tiền thưởng : Tiếp tục phong cách tiếp tục vượt qua. Hãy xem xét chương trình sau:

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

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

Bây giờ nếu mọi thao tác (bao gồm cả phép nhân, phép nhân, v.v.) được viết dưới dạng hàm thì chúng ta sẽ có:

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

Ngoài ra, nếu chúng tôi không được phép trả lại bất kỳ giá trị nào thì chúng tôi sẽ phải sử dụng các phần tiếp theo như sau:

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

Kiểu lập trình này trong đó bạn không được phép trả về các giá trị (và do đó bạn phải dùng đến các phần tiếp theo đi qua) được gọi là kiểu chuyển tiếp.

Tuy nhiên, có hai vấn đề với phong cách tiếp tục:

  1. Vượt qua các phần tiếp theo làm tăng kích thước của ngăn xếp cuộc gọi. Trừ khi bạn đang sử dụng một ngôn ngữ như Scheme để loại bỏ các cuộc gọi đuôi, bạn sẽ có nguy cơ hết dung lượng ngăn xếp.
  2. Thật đau đớn khi viết các hàm lồng nhau.

Vấn đề đầu tiên có thể được giải quyết dễ dàng bằng JavaScript bằng cách gọi liên tục không đồng bộ. Bằng cách gọi tiếp tục không đồng bộ, hàm trả về trước khi tiếp tục được gọi. Do đó, kích thước ngăn xếp cuộc gọi không tăng:

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

Vấn đề thứ hai thường được giải quyết bằng cách sử dụng một hàm gọi call-with-current-continuationlà thường được viết tắt là callcc. Thật không may, callcckhông thể thực hiện đầy đủ trong JavaScript, nhưng chúng tôi có thể viết một hàm thay thế cho hầu hết các trường hợp sử dụng của nó:

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

Các callccchức năng có một chức năng fvà áp dụng nó vào current-continuation(viết tắt là cc). Đây current-continuationlà một hàm tiếp tục kết thúc phần còn lại của thân hàm sau khi gọi đến callcc.

Hãy xem xét cơ thể của chức năng pythagoras:

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

Thứ current-continuationhai callcclà:

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

Tương tự current-continuationđầu tiên callcclà:

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

Vì cái current-continuationđầu tiên callccchứa cái khác, callccnó phải được chuyển đổi thành kiểu chuyển tiếp:

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

Vì vậy, về cơ bản callccchuyển đổi toàn bộ cơ thể chức năng trở lại những gì chúng ta đã bắt đầu (và đặt tên cho các chức năng ẩn danh đó cc). Hàm pythagoras sử dụng triển khai callcc này sẽ trở thành:

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

Một lần nữa, bạn không thể triển khai callccbằng JavaScript, nhưng bạn có thể triển khai nó theo kiểu chuyển tiếp trong JavaScript như sau:

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

Hàm này callcccó thể được sử dụng để thực hiện các cấu trúc dòng điều khiển phức tạp như khối bắt thử, coroutines, máy phát điện, sợi , v.v.


10
Tôi rất biết ơn những từ không thể mô tả. Cuối cùng tôi đã hiểu ở cấp độ trực giác tất cả các khái niệm liên quan đến tiếp tục trong một lần quét! Tôi mới một khi nó nhấp vào, nó sẽ đơn giản và tôi sẽ thấy tôi đã sử dụng mô hình nhiều lần trước khi vô tình, và nó chỉ như vậy. Cảm ơn rất nhiều cho lời giải thích tuyệt vời và rõ ràng.
ata

2
Trampolines là công cụ khá đơn giản nhưng mạnh mẽ. Vui lòng kiểm tra bài của Reginald Braithwaite về họ.
Marco Faustinelli

1
Cảm ơn câu trả lời. Tôi tự hỏi nếu bạn có thể có thể cung cấp nhiều hỗ trợ hơn cho tuyên bố rằng callcc không thể được triển khai trong JavaScript? Có thể là một lời giải thích về những gì JavaScript sẽ cần để thực hiện nó?
John Henry

1
@JohnHenry - tốt, thực sự có một triển khai cuộc gọi / cc trong JavaScript được thực hiện bởi Matt Might ( matt.might.net/articles/by-example-contininating-passing-style - đi đến đoạn cuối cùng), nhưng vui lòng không ' Tôi hỏi tôi cách thức hoạt động cũng như cách sử dụng nó :-)
Marco Faustinelli

1
@JohnHenry JS sẽ cần các phần tiếp theo của lớp đầu tiên (nghĩ về chúng như một cơ chế để nắm bắt các trạng thái nhất định của ngăn xếp cuộc gọi). Nhưng nó chỉ có các chức năng và đóng cửa hạng nhất, do đó CPS là cách duy nhất để bắt chước các phần tiếp theo. Trong các lược đồ Scheme là ẩn và một phần công việc của callcc là "thống nhất" các conts ngầm này, để hàm tiêu thụ có quyền truy cập vào chúng. Đó là lý do tại sao callcc trong Scheme mong đợi một hàm là đối số duy nhất. Phiên bản CPS của callcc trong JS khác nhau, vì cont được truyền dưới dạng đối số func rõ ràng. Vì vậy, callcc của Aadit là đủ cho rất nhiều ứng dụng.
kịch bản

27

Mặc dù bài viết tuyệt vời, tôi nghĩ rằng bạn đang nhầm lẫn thuật ngữ của bạn một chút. Ví dụ, bạn đúng rằng một cuộc gọi đuôi xảy ra khi cuộc gọi là điều cuối cùng mà một chức năng cần thực hiện, nhưng liên quan đến việc tiếp tục, một cuộc gọi đuôi có nghĩa là chức năng không sửa đổi sự tiếp tục mà nó được gọi, chỉ có nó cập nhật giá trị được chuyển sang phần tiếp theo (nếu nó mong muốn). Đây là lý do tại sao việc chuyển đổi một hàm đệ quy đuôi thành CPS rất dễ dàng (bạn chỉ cần thêm phần tiếp tục làm tham số và gọi phần tiếp theo trên kết quả).

Cũng hơi kỳ quặc khi gọi phần tiếp theo là trường hợp gọi lại đặc biệt. Tôi có thể thấy làm thế nào chúng dễ dàng được nhóm lại với nhau, nhưng sự tiếp tục không nảy sinh từ nhu cầu phân biệt với một cuộc gọi lại. Phần tiếp theo thực sự đại diện cho các hướng dẫn còn lại để hoàn thành một tính toán , hoặc phần còn lại của tính toán từ thời điểm này . Bạn có thể nghĩ về phần tiếp theo là một lỗ cần phải điền vào. Nếu tôi có thể nắm bắt phần tiếp theo hiện tại của chương trình, thì tôi có thể quay lại chính xác chương trình khi tôi chụp phần tiếp theo. (Điều đó chắc chắn làm cho trình gỡ lỗi dễ viết hơn.)

Trong ngữ cảnh này, câu trả lời cho câu hỏi của bạn là cuộc gọi lại là một điều chung chung được gọi tại bất kỳ thời điểm nào được chỉ định bởi một số hợp đồng được cung cấp bởi người gọi [của cuộc gọi lại]. Một cuộc gọi lại có thể có nhiều đối số như nó muốn và được cấu trúc theo bất kỳ cách nào nó muốn. Sau đó, một sự tiếp tục nhất thiết phải là một thủ tục một đối số để giải quyết giá trị được truyền vào nó. Việc tiếp tục phải được áp dụng cho một giá trị duy nhất và ứng dụng phải xảy ra ở cuối. Khi tiếp tục kết thúc việc thực hiện biểu thức đã hoàn tất và tùy thuộc vào ngữ nghĩa của ngôn ngữ, các tác dụng phụ có thể hoặc không thể được tạo ra.


3
Cảm ơn sự trình bày rõ ràng của bạn. Bạn hoàn toàn đúng. Tiếp tục thực sự là một sự thống nhất trạng thái điều khiển của chương trình: ảnh chụp nhanh trạng thái của chương trình tại một thời điểm nhất định. Việc nó có thể được gọi như một hàm bình thường là không liên quan. Tiếp tục không thực sự là chức năng. Cuộc gọi lại mặt khác thực sự là chức năng. Đó là sự khác biệt thực sự giữa tiếp tục và gọi lại. Tuy nhiên, JS không hỗ trợ các phần tiếp theo hạng nhất. Chỉ các chức năng hạng nhất. Do đó, các phần tiếp theo được viết bằng CPS trong JS chỉ đơn giản là các hàm. Cảm ơn về thông tin bạn vừa nhập. =)
Aadit M Shah

4
@AaditMShah vâng, tôi sai ở đó. Tiếp tục không cần phải là một chức năng (hoặc thủ tục như tôi đã gọi nó). Theo định nghĩa, nó chỉ đơn giản là đại diện trừu tượng của những điều sắp tới. Tuy nhiên, ngay cả trong Đề án, việc tiếp tục được gọi như một thủ tục và được thông qua như một. Hmm .. điều này đặt ra câu hỏi thú vị không kém về việc tiếp tục trông như thế nào không phải là một chức năng / thủ tục.
dcow

@AaditMShah đủ thú vị mà tôi đã tiếp tục các cuộc thảo luận ở đây: programmers.stackexchange.com/questions/212057/...
dcow

14

Câu trả lời ngắn gọn là sự khác biệt giữa tiếp tục và gọi lại là sau khi gọi lại được thực thi (và đã kết thúc), tại điểm nó được gọi, trong khi gọi tiếp tục sẽ khiến việc thực thi tiếp tục tại điểm tiếp tục được tạo. Nói cách khác: một sự tiếp tục không bao giờ trở lại .

Hãy xem xét chức năng:

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

(Tôi sử dụng cú pháp Javascript mặc dù Javascript không thực sự hỗ trợ các phần tiếp theo hạng nhất vì đây là những gì bạn đã đưa ra ví dụ của mình và nó sẽ dễ hiểu hơn đối với những người không quen với cú pháp Lisp.)

Bây giờ, nếu chúng ta vượt qua nó một cuộc gọi lại:

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

sau đó chúng ta sẽ thấy ba cảnh báo: "trước", "5" và "sau".

Mặt khác, nếu chúng ta vượt qua nó, việc tiếp tục thực hiện tương tự như cuộc gọi lại, như thế này:

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

sau đó chúng ta sẽ chỉ thấy hai cảnh báo: "trước" và "5". Gọi c()bên trong add()kết thúc việc thực hiện add()và nguyên nhân callcc()để trở lại; giá trị được trả về callcc()là giá trị được truyền dưới dạng đối số c(cụ thể là tổng).

Theo nghĩa này, mặc dù việc gọi tiếp tục trông giống như một lệnh gọi hàm, nhưng về mặt nào đó, nó gần giống với một câu lệnh return hoặc ném một ngoại lệ.

Trong thực tế, cuộc gọi / cc có thể được sử dụng để thêm câu lệnh trả về cho các ngôn ngữ không hỗ trợ chúng. Ví dụ: nếu JavaScript không có câu lệnh return (thay vào đó, giống như nhiều ngôn ngữ Môi, chỉ trả về giá trị của biểu thức cuối cùng trong thân hàm) nhưng đã có lệnh gọi / cc, chúng ta có thể thực hiện trả về như thế này:

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

Gọi điện thoại return(i) gọi một sự tiếp nối mà chấm dứt việc thực hiện các chức năng ẩn danh và gây callcc()trở về chỉ số imà tại đó targetđã được tìm thấy trong myArray.

. đã tạo ra sự tiếp tục có thể trả về nhiều lần mặc dù nó chỉ được gọi một lần .)

Call / cc tương tự có thể được sử dụng để thực hiện xử lý ngoại lệ (ném và thử / bắt), các vòng lặp và nhiều cấu trúc contol khác.

Để làm sáng tỏ một số hiểu lầm có thể:

  • Tối ưu hóa cuộc gọi đuôi không phải bằng bất kỳ phương tiện nào được yêu cầu để hỗ trợ tiếp tục hạng nhất. Hãy xem xét rằng ngay cả ngôn ngữ C cũng có dạng tiếp tục (bị hạn chế) ở dạng tiếp theo setjmp(), tạo ra sự tiếp nối vàlongjmp() , gọi một ngôn ngữ!

    • Mặt khác, nếu bạn ngây thơ cố gắng viết chương trình của mình theo kiểu chuyển tiếp liên tục mà không tối ưu hóa cuộc gọi đuôi, bạn sẽ phải chịu số phận cuối cùng.
  • Không có lý do cụ thể một nhu cầu tiếp tục chỉ mất một đối số. Đó chỉ là (các) đối số tiếp tục trở thành (các) giá trị trả về của cuộc gọi / cc và cuộc gọi / cc thường được định nghĩa là có một giá trị trả về duy nhất, do đó, việc tiếp tục phải thực hiện chính xác một. Trong các ngôn ngữ có hỗ trợ cho nhiều giá trị trả về (như Common Lisp, Go hoặc Scheme), hoàn toàn có thể có các phần tiếp theo chấp nhận nhiều giá trị.


2
Xin lỗi nếu tôi đã thực hiện bất kỳ lỗi nào trong các ví dụ JavaScript. Viết câu trả lời này đã tăng gần gấp đôi tổng số JavaScript tôi đã viết.
cpcallen

Tôi có hiểu chính xác rằng bạn đang nói về các phần tiếp theo không bị giới hạn trong câu trả lời này không, và câu trả lời được chấp nhận nói về phần tiếp theo được phân tách?
Jozef Mikušinec

1
"Gọi tiếp tục khiến cho việc thực thi tiếp tục tại thời điểm tiếp tục được tạo ra" - tôi nghĩ bạn đang nhầm lẫn "tạo ra" một sự tiếp nối với việc nắm bắt sự tiếp tục hiện tại .
Alexey

@Alexey: đây là loại hình nhà sư phạm tôi chấp nhận. Nhưng hầu hết các ngôn ngữ không cung cấp bất kỳ cách nào để tạo ra sự tiếp nối (thống nhất) ngoài việc nắm bắt sự tiếp tục hiện tại.
cpcallen

1
@jozef: Tôi chắc chắn đang nói về sự tiếp nối không giới hạn. Tôi nghĩ đó cũng là ý định của Aadit, mặc dù như dcow lưu ý rằng câu trả lời được chấp nhận không phân biệt được các cuộc gọi đuôi (liên quan chặt chẽ) và tôi lưu ý rằng việc tiếp tục được phân định là tương đương với một cuộc đấu giá / thủ tục nào: Community.schemewiki.org/ ? composable-continuations-
guide
Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.