Closure là gì?

0
267
views
javascript-closure

Closure là gì?

Chắc hẳn bạn đã ít nhất một lần đọc được ở đâu đó, trong một bài viết nào đó, có nhắc đến thuật ngữ closure và thắc mắc xem nó là cái gì. Thực sự closure là một khái niệm khá trừu tượng, khó hiểu với các bạn mới bắt đầu học javascript nhưng nó lại là một khái niệm rất quan trọng, cốt lõi trong javascript. Sau khi đọc xong bài viết này sẽ có nhiều bạn nhận ra rằng mình đã dùng nó rồi, thậm chí là rất nhiều mà không biết tên nó. Hiểu một cách nôm na thì :

Closure là sự kết hợp đơn giản giữa một hàm và môi trường nơi hàm được khai báo và vào lúc hàm được chạy (lexical environment).

Để làm rõ khái niệm trừu tượng ở trên, trước hết ta tìm hiểu (nói lại) về function scope.

1. Function scope

Giống như hầu hết các ngôn ngữ lập trình phổ biến khác thì JS cũng có 3 loại scope:

  • Local scope: là các biến và hàm con (inner function) được khai báo trong thân của hàm. Các hàm và biến này chỉ có thể truy xuất được từ bên trong thân hàm.
  • Outer scope: là các biến và hàm được khai báo ngay bên ngoài hàm hay các hàm chị em của hàm. Các hàm ở outer scope không thể truy xuất các biến nằm trong local scope.
  • Global scope: là các hàm và biến toàn cục, có thể truy xuất mọi lúc mọi nơi.

Lấy một ví dụ cho dễ hình dung

var age = 10; // biến thuộc outer scope
function init() {
    var name = "Closure"; // name là biến thuộc local scope
    function hi() { // hi() là inner function thuộc local scope
        console.log('my name is ' + name); // hi() có thể truy xuất biến name
    }
    hi(); // hi chỉ có thể gọi được trong thân hàm init()   
}

init();
hi(); // không thể gọi hi() ở ngoài thân hàm init()
console.log('hi'); // console.log() là hàm thuộc global scope, có thể gọi được ở mọi nơi

2. Closure trông như thế nào?

Trên đây là cách viết rất phổ biến trong JS, giờ ta sẽ viết lại theo kiểu closure

function init() {
    var name = "Closure";
    function hi() {
        console.log('my name is ' + name);
    }
    return hi; // lưu ý không viết là return hi()
}

var f = init();
f();

Function trong JS cũng là một loại data type, nhưng là loại data type đặc biệt hơn so với các loại khác như String, Number … ở chỗ nó có thể chạy được (executable). Và vì function cũng là một loại data nên nó cũng có thể dùng làm giá trị trả về ở câu lệnh return. Tất cả các object trong JS có type là function thì đều có thể chạy được bằng cách thêm cặp () vào đằng sau như cách ta gọi f().

closure-trong-nhu-the-nao

Đến đây có thể sẽ rất nhiều người sẽ đặt câu hỏi là tại sao cách viết mới này vẫn có kết quả như cũ?, tại sao lại không gây ra lỗi ?

Nếu như trong Java hay C, C++, khi một hàm return thì rất các các biến local của nó sẽ bị thu hồi nhằm dành tài nguyên bộ nhớ các thao tác khác. Vậy tại sao khi gọi hàm f() vẫn in ra được name là Closure trong khi đáng lẽ ra biến name phải được thu hồi?

Điều đặc biệt ở đây chính là Closure, khi ta return hàm hi bên trong hàm init thì thực chất cả môi trường nơi hi được tạo ra (gọi là lexical environment trong bài viết này ta sẽ viết tắt là ENV) sẽ được gắn với hi dưới dạng một reference – một dạng tương tự pointer trong C++. Kết quả là khi ta gọi hàm f() thì name vẫn tồn tại vì nó được lấy ra từ ENV kèm theo đó. Nếu trên tab console của Chrome Developer Tools bạn tiếp tục gõ console.dir(f) thì bạn sẽ thấy rõ

closure-trong-nhu-the-nao

Kèm theo f là một mảng Scopes có 2 item ClosureGlobal. Có thể nói Closure đó chính là local scope nói ở trên và hiện có một giá trị name: "Closure"

Lí do ta phải gắn kèm reference của ENV theo closure là khả năng chạy bất đồng bộ của JS. Sau khi return hi hàm init đã hoàn tất, các biến local của nó sẽ bị thu hồi, trong đó có name. Để hi có thể chạy được thì cách đơn giản nhất là chụp ảnh lại ENV – trong đó có chứa name – và gửi kèm theo hi dưới dạng một reference. Reference là một cách giải quyết rất hiệu quả về mặt hiệu năng bộ nhớ, nếu bạn đã từng sử dụng Prototype thì sẽ cảm thấy rất quen thuộc.

3. Closure dùng trong thực tế

Trong phần này chúng ta sẽ xem xét một vài ví dụ để thấy được trong thực tế closure được dùng như thế nào. Có hai trường hợp phổ biến nhất là cùng function nhưng khác ENV và cùng ENV nhưng khác function.

3.1 Function factory

Lấy một ví dụ như sau:

function makeExponentiation(x) {
    var exponent = x;
    return function(y) {
        return Math.pow(y, exponent);
    }
}

var sqr = makeExponentiation(2);
var sqrt = makeExponentiation(0.5);
console.log('3 bình phương là ' + sqr(3));
console.log('căn bậc hai của 9 là ' + sqrt(9));

Trong ví dụ trên ta thấy hàm makeExponentiation giống như một function factory tạo ra các function khác tùy thuộc vào tham số truyền vào. sqrsqrt đều là 2 closure, có body giống nhau nhưng ENV khác nhau. Nếu ENV của sqr chứa {exponent: 2} thì của sqrt chứa {exponent: 0.5}. ENV của một closure chỉ chứa những biến hay hàm mà nó sử dụng, ở đây thì đó là biến exponent. Việc ENV chứa tất cả các biến local và outer là rất thừa thãi và không hiệu quả về mặt hiệu năng.

3.2 Mô phỏng phạm vi của biến trong OOP (variable visibility)

Trong JS ta không có khái niệm Class một cách đúng nghĩa như trong C++. Khái niệm Class trong ES6 chỉ là một cách khai báo, một cách hack. Giờ ta sẽ sử dụng closure để mô phỏng lại cách hack này

function Counter() {
    var counter = 0;

    function add(number) {
        counter += number;
    }

    return {
        increment: function() {
            add(1);
        },
        decrement: function() {
            add(-1);
        },
        value: function() {
            return counter;
        }
    };   
});

var counter = Counter();
console.log('giá trị ban đầu ' + counter.value());
counter.increment();
counter.increment();
console.log('sau khi tăng 2 lần ' + counter.value());
counter.decrement();
console.log('sau khi giảm 1 lần ' + counter.value());

các hàm increment, decrementvalue là các closure có body khác nhau nhưng chia sẻ chung một ENV, chính là các biến local của hàm Counter() hay “class” Counter. Việc chia sẻ chung ENV này chính là bí quyết để mô phỏng Class trong JS. Khi một closure update một biến thì sự thay đổi này cũng được ghi nhận trong các closure khác. Thực chất thì các closure này đều thao tác trên một tập biến giống như nhau. Bên ngoài class Counter ta không có cách nào truy xuất trực tiếp biến counter của nó được, ví dụ gán counter = 5 là không hợp lệ. Nhưng ta có thể thay đổi counter thông qua các hàm public increment, decrement. Behavior này mô phỏng một cách gần đúng một Class trong các ngôn ngữ lập trình khác như PHP hay Java.

4. Một số sai lầm thường gặp với closure

Closure và function scope là một trong những chủ đề rất phổ biến và được yêu thích trong các cuộc phỏng vấn vì nó thể hiện được sử hiểu biết sâu sắc của ứng viên về JS. Một ví dụ rất nổi tiếng đó là sai lầm với closure trong một vòng loop.

for (var i = 0; i < 3; i++) {
    setTimeout(function() {
        console.log(i);
    }, 1000)
}

Nhìn qua thì có vẻ trên console sẽ in ra lần lượt 0,1 và 2 nhưng không phải. Ta sẽ lần lượt giải thích tại sao ở console lại là 3, 3 và 3.

setTimeout là hàm hẹn giờ một hàm nào đó, có tham số đầu tiên là một hàm và tham số thứ 2 là khoảng thời gian tối thiếu mà sau đó hàm sẽ được chạy. Tham khảo thêm về Event Loop để hiểu tại sao tham số thứ 2 ở đây lại là thời gian tối thiểu.

Tham số đầu tiên chính là một closure với giá trị duy nhất là i. Vòng loop sẽ chạy 3 lần nên chúng ta sẽ có 3 closure dùng chung một ENV. Khoan đã, tại sao tôi lại nói là dùng chung ENV. Vì ta đã biết var là một toán tử có đặc tính functional scope tức là nó có giá trị trong phạm vi một function, ra ngoài function nó sẽ không có giá trị và sẽ bị tạo mới nếu cần. Vì thế biến i sẽ không được tạo mới qua mỗi một vòng loop, nó chỉ thay đổi về giá trị. Vì ENV của 3 closure kia chứa i nên sau 1 giây thì giá trị của i trong ENV này sẽ là 3. i là 3 mà không phải 2 bởi vì trước khi vòng loop thoát ra thì i++ đã kịp chạy thêm một lần nữa. Để ý là 3 closure không được chay ngay lập tức mà phải sau ít nhất 1s, đủ thời gian để i kịp tăng lên 3.

Vậy phải sửa đoạn code trên sao cho đúng như mong muốn?

cách thứ nhất : sử dụng let

for (let i = 0; i < 3; i++) {
    setTimeout(function() {
        console.log(i);
    }, 1000)
}

do let có đặc tính block scope, tức là biến sẽ có giá trị trong một block – ở giữa cặp {}, ra ngoài block nó sẽ bị hủy và tạo lại. Ở trên thì sau mỗi vòng for biến i sẽ nhận một giá trị mới, tức là i trong các ENV của các closure là khác nhau.

cách thứ 2: sử dụng thêm closure

for (var i = 0; i < 3; i++) {
    function log(x) {
        return function() {
            console.log(x);
        }
    }
    setTimeout(log(i), 1000)
}

Tham số truyền vào cho setTimeout vẫn là các closure, nhưng với ENV khác nhau. ENV ở đây không phải là i trong vòng for nữa mà là x của hàm log, mà x nhận các giá trị khác nhau ở mỗi vòng loop dẫn đến kết quả như mong muốn.

Hy vọng sau bài viết này các bạn sẽ có một cái nhìn khác về closure. Happy coding!

BÌNH LUẬN

Please enter your comment!
Please enter your name here