Hiểu hơn JavaScript: Object, Prototype và Class

Hiểu hơn JavaScript: Object, Prototype và Class
Photo by Sigmund from Unsplash

Lập trình hướng đối tượng là kiểu lập trình rất được ưa chuộng trong môi trường doanh nghiệp (dù tôi cũng không rõ lý do tại sao 😅). Đối tượng (object) cũng là khái niệm cơ bản của JavaScript (mọi thứ đều là đối tượng). Vì vậy, rất tự nhiên, việc hiểu cơ chế các đối tượng hoạt động sẽ đem lại lợi ích rất lớn cho lập trình viên.

Việc hiểu rõ cơ chế hoạt động có thể đem lại lợi ích cho bản thân và công việc (nâng cao trình độ, nâng cao năng suất). Ngược lại, hiểu không rõ hoặc tệ hơn là hiểu sai có dễ đến rất nhiều vấn đề. Nhẹ thì bản thân thất bại trong công việc, trình độ không tăng, năng suất lao động thấp, nặng hơn thì dự án thất bại, nặng hơn nữa thì công ty phá sản 🤣. Điều này đặc biệt đúng với ngôn ngữ khó hiểu như JavaScript.

Trong bài viết này, tôi sẽ trình bày những hiểu biết của mình về đối tượng và cơ chế hoạt động của nó trong JavaScript.

JavaScript và lập trình hướng đối tượng

Dù được thiết kế để làm việc với các đối tượng, JavaScript lại có những đặc điểm rất khác với những ngôn ngữ lập trình hướng đối tượng khác.

Hệ thống đối tượng của JavaScript hoạt động dựa trên prototype chứ không phải class 🤔. Có một thực tế phũ phàng rằng nhiều lập trình viên không thực sự hiểu rõ cơ chế này (vì không hiểu code vẫn chạy 😇 mà với nhiều người chạy đúng là đủ).

Nhiều người khác (dù có hiểu về prototype hay không) lại thích lập trình JavaScript giống như lập trình hướng đối tượng ở các ngôn ngữ khác (với cơ chế dựa trên class). Điều này gây ra sự chia rẽ lớn trong chính những lập trình viên của cùng một ngôn ngữ.

Sự phát triển mạnh mẽ của JavaScript kể từ sau ES6, và sau này là ngôn ngữ TypeScript hiện đại hơn khiến việc lập trình hướng đối tượng với JavaScript trở nên rất dễ dàng. Và rất nhiều người có suy nghĩ rằng:

Chẳng phải JavaScript đã có class kể từ ES6 rồi hay sao?

Sự thật nó lại không hoàn toàn như vậy. ES6 chỉ thêm cú pháp lập trình hướng đối tượng (giống với các ngôn ngữ khác) cho JavaScript mà thôi, chứ nó không thực sự mang đến cơ chế lập trình mới 😤.

Class-based vs. prototype-based

Class-based

Đây là mô hình lập trình hướng đối tượng mà tôi tin là 99% lập trình viên khi học đều được dạy mô hình này. Nó ăn sâu vào tâm chí của nhiều người, kể cả tôi, rằng lập trình hướng đối tượng là phải như vậy 🤦.

Phần lớn các ngôn ngữ lập trình hướng đối tượng (kinh điển nhất là Java) là ngôn ngữ dạng class-based. Trong những ngôn ngữ này, có 2 khái niệm cơ bản được phân biệt rõ ràng: class và instance.

Class (lớp đối tượng) sẽ định nghĩa những tính chất của một tập các đối tượng (thuộc tính và phương thức). Class là sự trừu tượng hóa từ các đối tượng cụ thể. Ví dụ một class Person là đại diện cho tất cả các đối tượng là con người. Một lớp lại có thể tái sử dụng thuộc tính của lớp khác bằng cách kế thừa.

Instance là biểu hiện của class, chính là các đối tượng cụ thể. Ví dụ, Alice hay Bob là những instance của class Person, là đối tượng cụ thể đại diện cho từng người trong bài toán. Một instance sẽ có tất cả tính chất mà class đã định nghĩa.

Trong mô hình này, tất cả các đối tượng đều được tạo ra từ các class, và do đó, mọi đối tượng đều là instance (biểu hiện) của một class nào đó.

Prototype-based

Đây là mô hình lập trình ít được dạy nên rất mơ hồ với nhiều người.

Trong ngôn ngữ prototype-based, điển hình là JavaScript (và cũng không có nhiều ngôn ngữ như thế 😆), không có sự phân biệt rõ ràng giữa hai khái niệm class và instance, hay nói đúng hơn là không tuân theo mô hình đó. Mà ngược lại, sẽ chỉ tồn tại khái niệm object (đối tượng) và mọi thứ đều là object.

Lưu ý rằng, JavaScript có một kiểu dữ liệu là Object. Tuy nhiên, object mà tôi muốn nói đến ở đây là các đối tượng chung chung, chứ không phải một biến với kiểu dữ liệu Object.

Trong mô hình prototype-based, việc tái sử dụng code (thay vì tạo đối tượng từ class và đối tượng sẽ có tất cả thuộc tính được định nghĩa trong class đó) sẽ được thực hiện thông qua việc tham chiếu đến các đối tượng có sẵn khác. Đối tượng được tham chiếu gọi là prototype (nguyên mẫu) của đối tượng hiện tại. Mọi đối tượng đều có thể được sử dụng để làm prototype cho đối tượng khác.

Một hệ quả tất yếu của mô hình này là dynamic typing. Các biến sẽ không thể có kiểu cố định và các đối tượng cũng không cố định. Đối tượng trong ngôn ngữ dạng prototype-based luôn luôn có những tính chất của riêng nó và có thể bị thay đổi liên tục (có thể từ lúc khởi tạo, có thể thay đổi trong quá trình thực thi).

Lập trình hướng đối tượng trong JavaScript

Trong phần này, chúng ta sẽ ôn lại một chút kiến thức về lập trình hướng đối tượng trong JavaScript trước khi tìm hiểu sâu hơn về cơ chế prototype.

Object trong Javascript

Object (đối tượng) là khái niệm cơ bản của JavaScript. Hầu như mọi thứ trong JavaScript đều là đối tượng. Một số dữ liệu nguyên thủy (string, number, boolean) dù không được coi là đối tượng, và không hoạt động như các đối tượng khác, nhưng chúng có cách khởi tạo rất giống các đối tượng. Có lẽ nó chỉ khác các object ở vùng nhớ lưu dữ liệu 🤔.

Về mặt cú pháp, object có thể coi là các cặp key-value, mô tả các thuộc tính và giá trị của chúng. Cách đơn giản nhất để tạo ra object là sử dụng dấu ngoặc nhọn {} với các thuộc tính và phương thức ban đầu. Thuộc tính và phương thức cũng có thể được thêm vào object với cú pháp sử dụng dấu chấm ..

const animal = {};
animal.name = 'Leo';
animal.energy = 10;

animal.eat = function (amount) {
    console.log(`${this.name} is eating.`);
    this.energy += amount;
};

animal.sleep = function (length) {
    console.log(`${this.name} is sleeping.`);
    this.energy += length;
};

animal.play = function (length) {
    console.log(`${this.name} is playing.`);
    this.energy -= length;
};

Một nhu cầu tất yếu của các ứng dụng là chúng ta cần tạo ra nhiều đối tượng tương tự nhau (tương tự animal trong ví dụ trên). Trong các ngôn ngữ lập trình hướng đối tượng khác, class (lớp đối tượng) chính là chìa khóa của việc này.

Với JavaScript thì có nhiều cách khác nhau để làm việc đó. Trong những phần tiếp theo, chúng ta sẽ tìm hiểu những cách như vậy (vì cách làm quá đa dạng, tôi không dám chắc là đã mô tả hết các cách 😫).

Factory function

Trong cách làm này, chúng ta sẽ đóng gói mọi thứ vào trong một hàm. Và mỗi khi cần tạo một object mới, chúng ta sẽ gọi hàm đó. Các hàm đó gọi là factory function (có nhiều cách gọi khác nhau nhưng tôi thấy cách này là hợp lý nhất 😁).

function Animal(name, energy) {
    const animal = {};
    animal.name = name;
    animal.energy = energy;

    animal.eat = function (amount) {
        console.log(`${this.name} is eating.`);
        this.energy += amount;
    };

    animal.sleep = function (length) {
        console.log(`${this.name} is sleeping.`);
        this.energy += length;
    };

    animal.play = function (length) {
        console.log(`${this.name} is playing.`);
        this.energy -= length;
    };

    return animal;
}

const leo = Animal('Leo', 7);
const snoop = Animal('Snoop', 10);

Mỗi khi muốn tạo một đối tượng mới chúng ta có thể gọi hàm Animal và truyền vào những giá trị khởi tạo. Cách làm này rất đơn giản và hiệu quả.

Chỉ có một vấn đề nhỏ là các phương thức eat, sleep, play là các phương thức tổng quát và nó có thể định nghĩa không phụ thuộc vào đối tượng cụ thể. Tuy nhiên, cách làm ở trên sẽ tiêu tốn bộ nhớ bởi với mỗi đối tượng được tạo mới, các phương thức này sẽ được định nghĩa lại cho từng đối tượng. Chúng ta có thể tái sử dụng những phần thuộc tính chung cho các đối tượng như sau:

const animalMethods = {
    eat(amount) {
        console.log(`${this.name} is eating.`);
        this.energy += amount;
    },
    sleep(length) {
        console.log(`${this.name} is sleeping.`);
        this.energy += length;
    },
    play(length) {
        console.log(`${this.name} is playing.`);
        this.energy -= length;
    },
};

function Animal(name, energy) {
    const animal = {};
    animal.name = name;
    animal.energy = energy;
    animal.eat = animalMethods.eat;
    animal.sleep = animalMethods.sleep;
    animal.play = animalMethods.play;

    return animal;
}

const leo = Animal('Leo', 7);
const snoop = Animal('Snoop', 10);

Chỉ đơn giản là chuyển những phần dùng chung ra ngoài và tham chiếu chúng, chúng ta đã giải quyết được vấn đề phình to dữ liệu của các object.

Object.create

Object.create là một phương thức cho phép tạo các object bằng cách ủy quyền cho một object khác. Quá trình ủy quyền diễn giải thì rất khó, nhưng hãy xem một ví dụ cụ thể sau:

const parent = {
    name: 'Stacey',
    age: 35,
    heritage: 'Irish',
};

const child = Object.create(parent);
child.name = 'Ryan';
child.age = 7;

console.log(child.name); // Ryan
console.log(child.age); // 7
console.log(child.heritage); // Irish

Trong ví dụ trên, child là đối tượng được tạo bằng Object.create(parent). Đối tượng này không phải là clone của parent và nó không có sẵn các thuộc tính của parent. Tuy nhiên nó đã ủy quyền cho parent, nên bất cứ khi nào một thuộc tính không tồn tại trong object child, nó sẽ được tìm kiếm trong parent.

Đây gọi là sự ủy quyền (delegation), khi mà một object này (child) ủy quyền cho object khác (parent) để truy vấn các thuộc tính. Sự ủy quyền chính là cơ sở của cơ chế prototype trong JavaScript. Chúng ta sẽ xem xét kỹ hơn ở phần sau.

Như ở dòng cuối cùng, khi gọi child.heritage, do thuộc tính heritage không phải là thuộc tính của child, nó ủy quyền cho parent để tìm kiếm và trả về giá trị của thuộc tính này.

Quay trở lại với ví dụ ban đầu về Animal, chúng ta cũng có thể áp dụng Object.create để khởi tạo các đối tượng. Cũng tương tự như phần trước, chúng ta có thể định nghĩa riêng các thuộc tính dùng chung, và sẽ dùng Object.create để ủy quyền cho animalMethods để truy vấn.

const animalMethods = {
    eat(amount) {
        console.log(`${this.name} is eating.`);
        this.energy += amount;
    },
    sleep(length) {
        console.log(`${this.name} is sleeping.`);
        this.energy += length;
    },
    play(length) {
        console.log(`${this.name} is playing.`);
        this.energy -= length;
    },
};

function Animal(name, energy) {
    const animal = Object.create(animalMethods);
    animal.name = name;
    animal.energy = energy;

    return animal;
}

const leo = Animal('Leo', 7);
const snoop = Animal('Snoop', 10);

leo.eat(10);
snoop.play(5);

Khởi tạo bằng prototype

Như ví dụ này, chúng ta có thể tạm hiểu, rằng prototype là một thuộc tính mà mọi hàm trong JavaScript đều có. Và nó có thể được sử dụng để tham chiếu cho mọi đối tượng được tạo ra trong hàm đó. Về cơ bản, cách làm này cho ra kết quả giống như phần trước, nhưng thay vì tạo ra một đối tượng riêng để quản lý các thuộc tính chung, chúng ta cho nó vào luôn Animal.prototype.

function Animal(name, energy) {
    const animal = Object.create(Animal.prototype);
    animal.name = name;
    animal.energy = energy;

    return animal;
}

Animal.prototype.eat = function (amount) {
    console.log(`${this.name} is eating.`);
    this.energy += amount;
};

Animal.prototype.sleep = function (length) {
    console.log(`${this.name} is sleeping.`);
    this.energy += length;
};

Animal.prototype.play = function (length) {
    console.log(`${this.name} is playing.`);
    this.energy -= length;
};

const leo = Animal('Leo', 7);
const snoop = Animal('Snoop', 10);

leo.eat(10);
snoop.play(5);

Constructor function

Đây là cách mà mấy năm trước tôi vẫn hay dùng, dù chẳng hiểu cơ chế của nó lắm 😆. Nó vẫn chạy ổn nên lúc đó tôi cũng không có nhu cầu tìm hiểu kỹ làm gì.

Ở phần trước chúng ta đã sử dụng Object.create để khởi tạo một object. Những gì chúng ta cần là tạo một object, gán các thuộc tính và trả về object đó để sử dụng. Nếu không sử dụng Object.create thì không có sự ủy quyền và object được khởi tạo sẽ không thể gọi thuộc tính nào được.

Thế nhưng, với từ khóa new mọi thứ sẽ đơn giản hơn. Khi bạn gọi một hàm với từ khóa new, Object.create sẽ được gọi và việc đó hoàn toàn tự động mà không cần một dòng code nào. Đối tượng được trả về ở đây được gọi là this. Chúng ta có thể viết lại ví dụ trên như sau:

function Animal(name, energy) {
    // Với từ khóa `new` thì các dòng code bị comment dưới đây đều
    // được thực thi tự động

    // const this = Object.create(Animal.prototype);
    this.name = name;
    this.energy = energy;
    // return this;
}
...

const leo = new Animal('Leo', 7);
const snoop = new Animal('Snoop', 10);

Bỏ hết comment đi thì code của chúng ta sẽ rất gọn gàng:

function Animal(name, energy) {
    this.name = name;
    this.energy = energy;
}

Animal.prototype.eat = function (amount) {
    console.log(`${this.name} is eating.`);
    this.energy += amount;
};

Animal.prototype.sleep = function (length) {
    console.log(`${this.name} is sleeping.`);
    this.energy += length;
};

Animal.prototype.play = function (length) {
    console.log(`${this.name} is playing.`);
    this.energy -= length;
};

const leo = new Animal('Leo', 7);
const snoop = new Animal('Snoop', 10);

Cần nhắc lại rằng, hàm khởi tạo chỉ hoạt động khi kết hợp với từ khóa new mà thôi. Nếu bạn quên từ khóa này khi gọi hàm, điều vi diệu là sẽ không có lỗi nào cả (khi đó this sẽ là window, trừ khi bạn dùng strict mode) nhưng bạn sẽ chẳng nhận được object nào 🙈 mà sẽ nhận được các biến toàn cục nameenergy.

function Animal(name, energy) {
    this.name = name;
    this.energy = energy;
}

const leo = Animal('Leo', 7);
console.log(leo); // undefined

Cách làm này mô phỏng lại cách dùng class của các ngôn ngữ lập trình hướng đối tượng khác, nên có thể tạm gọi là giả class.

Class

Class (lớp đối tượng) là khái niệm cơ bản của các ngôn ngữ lập trình hướng đối tượng. Tuy nhiên với JavaScript thì mọi chuyện không dễ dàng như vậy.

Như trong phần trước, chúng ta đã thấy, lập trình viên phải sử dụng hàm để có thể tạo ra kết quả tương tự như class. Việc này cần một chút hiểu biết nhất định về JavaScript.

Rất may là JavaScript được cập nhật thường xuyên, và rất nhiều tính năng vẫn liên tục được thêm vào. Mặc dù ý tưởng ban đầu chỉ là một ngôn ngữ lập trình gọn nhẹ để chạy các script nho nhỏ, kể từ năm 2015 với ECMAScript 5 (ES6 hoặc ES2015), từ khóa class đã được thêm vào JavaScript.

Và ví dụ Animal của chúng ta có thể viết lại như sau:

class Animal {
    constructor(name, energy) {
        this.name = name;
        this.energy = energy;
    }
    eat(amount) {
        console.log(`${this.name} is eating.`);
        this.energy += amount;
    }
    sleep(length) {
        console.log(`${this.name} is sleeping.`);
        this.energy += length;
    }
    play(length) {
        console.log(`${this.name} is playing.`);
        this.energy -= length;
    }
}

const leo = new Animal('Leo', 7);
const snoop = new Animal('Snoop', 10);

Tuy nhiên, cần phải nhắc lại rằng, đây hoàn toàn là cú pháp mới của JavaScript mà thôi. Cơ chế hoạt động của các object trong JavaScript không có gì thay đổi.

Cơ chế prototype của JavaScript

Từ những ví dụ ở phần trước, chúng ta sẽ tìm hiểu kỹ hơn về các object trong JavaScript. Bằng cách sử dụng developer tools của các trình duyệt (tôi thích dùng Firefox) thì chúng ta có thể nhìn rõ hơn từng thuộc tính của các đối tượng.

Khi tạo đối tượng với Object.create

Trước hết, hãy xem xét lại những ví dụ của chúng ta, và các object được tạo ra như thế nào. Tạm thời bỏ qua những ví dụ đầu tiên, bởi vì chúng ta đã tạo ra các object và gán thuộc tính cho chúng. Prototype vẫn tồn tại nhưng không thể hiện rõ ràng bằng những trường hợp tiếp theo.

Hãy bắt đầu với việc tạo object bằng Object.create. Object leo của chúng ta khi được tạo bởi Object.create sẽ có các thuộc tính như sau:

object

Ngoài nameenergy là thuộc tính của đối tượng, đối tượng này có một thuộc tính đặc biệt là <prototype>: Object. Thuộc tính <prototype> cũng là một object và nó chứa các phương thức mà chúng ta đã định nghĩa. Prototype này chính là đối tượng animalMethods được truyền vào trong phương thức Object.create.

Ở đây, chúng ta có thể thấy rõ ràng rằng, Object.create không tạo ra một đối tượng mới với các thuộc tính của đối tượng cũ. Nó tạo ra một đối tượng mới với prototype là đối tượng cũ, và sẽ ủy quyền để truy vấn các thuộc tính này. Bản thân <prototype> cũng như các thuộc tính của nó lại có prototype của riêng mình (object hoặc function), vì vậy đây gọi là chuỗi prototype.

Lưu ý rằng, theo chuẩn ECMAScript thì thuộc tính này có tên là [[Prototype]] và phương thức để truy cập prototype là Object.getPrototypeOf. Nhưng tôi đang dùng Firefox nên sẽ gọi theo kiểu Firefox vẫn đang gọi (<prototype>)

Để truy cập thuộc tính này, các trình duyệt (riêng IE thì chỉ có IE 11) đều cài đặt thuộc tính __proto__ để làm việc đó. Phương thức này không phải là đối tượng <prototype> mà là getter/setter để truy cập đến đối tượng này.

Khi khởi tạo với prototype

Xem kỹ hơn một object khi được khởi tạo với Object.create và prototype, chúng ta thấy rằng về cơ bản cấu trúc của một đối tượng vẫn giống với phần trước. Một vài điểm khác biệt tôi sẽ nói ở phần tiếp theo.

prototype

Khi dùng hàm làm class

Dưới đây là một object khi sử dụng hàm (như một class) với từ khóa new. Có thể nhận ra, object này giống hệt với object ở phần trước. Điều đó củng cố thêm lý thuyết rằng, hàm kết hợp với new chính là hoạt động dựa trên Object.create.

function

Tuy nhiên, so với cách làm bằng Object.create thông thường, sử dụng prototype mang lại một số điểm khác biệt.

Trước hết, prototype (<prototype>) của object vẫn là một object với các thuộc tính mà chúng ta định nghĩa. Tuy nhiên, object này có thêm thuộc tính constructor trỏ vào hàm khởi tạo object.

Một điểm khác biệt nữa là các phương thức của object có thuộc tính hơi khác với các thuộc tính của phần trước. Bởi vì chúng là các hàm (function) chứ không phải là phương thức của một đối tượng khác. Vì vậy chúng có đầy đủ những thuộc tính của một hàm.

Để ý thấy có một thuộc tính tên là prototype nhưng nó khác với <prototype>. Nhưng tạm thời bỏ qua những sự khác biệt này đã. Chúng ta sẽ quay lại với nó sau.

Prototype trong JavaScript

Mọi đối tượng trong JavaScript sẽ đều tuân theo mô hình như trên. Mỗi đối tượng sẽ có một thuộc tính đặc biệt, gọi là prototype (nguyên mẫu) với tên chuẩn là [[Prototype]]. Bản thân prototype cũng là một đối tượng và nó sẽ có prototype của nó tạo thành một chuỗi prototype.

Chuỗi prototype sẽ kết thúc sau các đối tượng dựng sẵn của JavaScript (function, Object, v.v…) hoặc các đối tượng khác khi mà prototype của những đối tượng này là null.

Khi bạn truy cập một thuộc tính của đối tượng, việc truy xuất sẽ diễn ra thế này: nếu đối tượng không có sẵn thuộc tính đó, prototype của nó sẽ tìm các thuộc tính của mình. Nếu thuộc tính vẫn không tìm thấy, prototype của prototype sẽ tìm kiếm. Cứ như vậy, cho đến khi tìm thấy thuộc tính hoặc kết thúc chuỗi prototype thì dừng lại. Đây là sự ủy quyền (delegation).

Một điểm khác biệt giữa các thức hoạt động dựa trên prototype so với class truyền thống đó là việc tham chiếu các thuộc tính. Ví dụ chúng ta đã có 2 object của Animal, nếu xóa đi một phương thức của object leo, điều gì sẻ xảy ra?

Lúc này, không phải chỉ đối tượng leo mới bị mất phương thức, mà tất cả các đối tượng khác cũng không còn thuộc tính này:

delete Object.getPrototypeOf(leo).play;
leo.play; //undefined
snoop.play; // undefined

Nguyên nhân là bởi vì tất cả những đối tượng này cùng tham chiếu đến một prototype (tức là prototype là một object được dùng chung, không được clone mà chỉ tham chiếu).

Qua những ví dụ này thì cơ chế prototype của JavaScript cũng đã được hé lộ phần nào. Việc hoạt động dựa trên prototype không có gì là xấu, nó cho phép lập trình viên được tự do hơn trong lập trình, ít bị gò bó như những ngôn ngữ khắt khe như Java.

Hơn nữa cơ chế này đơn giản hơn nên hiệu suất cũng tốt hơn rất nhiều (có ý nghĩa rất lớn với những script chạy trên client side như JavaScript).

Như tôi đã nói (và ai cũng đã biết 👍), JavaScript đã hỗ trợ lập trình hướng đối tượng với từ khóa class. Tuy nhiên, nó chỉ mang đến cú pháp lập trình giống với các ngôn ngữ lập trình hướng đối tượng khác. Nó không mang đến cơ chế hoạt động khác cho JavaScript. Tại sao lại nói như vậy? Điều đó sẽ được làm rõ trong phần tiếp theo.

Dùng keyword class

Kể từ sau ES6, JavaScript đã phát triển nhanh hơn rất nhiều. Giờ đây chúng ta có hầu hết những keyword cần thiết cho lập trình hướng đối tượng như: class, constructor, super, extends.

Tuy nhiên, thực sự thì class vẫn là một function mà thôi. Tuy nó đã được nhận định là một class, nhưng prototype của nó vẫn là function và nó có đầy đủ thuộc tính của một hàm bình thường:

class

Trong chỉ có vậy:

typeof Animal;
// "function"

Xem xét kỹ hơn những gì bên trong đối tượng được tạo bởi một class thì nó cũng có những thuộc tính tương tự như ví dụ trước, có khác biệt khi constructor được gọi là class (nhưng về cơ bản nó vẫn là function):

Image

Bây giờ, để xem xét kỹ hơn về cơ chế lập trình hướng đối tượng của JavaScript, chúng ta thêm một class con nữa, dùng một số tính năng lập trình mới của JavaScript như sau:

class Dog extends Animal {
    constructor(name, energy) {
        super(name, energy);
        this.superPower = 'ROAR!';
    }

    bark() {
        console.log('BARK!');
    }
}

const hector = new Dog('Hector', 100);
hector.eat(50);
// Hector is eating.
hector.bark();
// BARK!

Vậy là chúng ta đã sử dụng những tính năng lập trình rất hiện đại của JavaScript. Thế nhưng, việc sử dụng extends để kế thừa thực tế chỉ là làm sâu thêm (thêm 1 cấp) của chuỗi prototype mà thôi.

extends

Đối tượng hector giờ đây phức tạp hơn một chút, nó có thuộc tính name, energy, superPower cũng như <prototype> với các hàm khởi tạo. Tuy nhiên, prototype của nó có prototype là object lấy từ class Animal, giống với leosnoop.

Như vậy, dù lập trình rất chuẩn theo kiểu hướng đối tượng như Java, cơ chế thực sự của JavaScript cũng không khác gì lúc trước. Và cũng như lúc trước, chúng ta có thể xóa 1 thuộc tính trong prototype và nó sẽ xóa thuộc tính đó với mọi đối tượng.

Thế nhưng, rất cảm ơn cú pháp mới, dù hoạt động với cơ chế cũ, thực sự nó đã mang lại những điểm cải tiến nhất định:

const dogWithoutNew = Dog('Dog Without New', 500);
// Uncaught TypeError: class constructors must be invoked with 'new'

Ngoài ra, cơ chế kế thừa (hay đúng hơn là tái sử dụng) đã dễ hơn rất nhiều. Nếu không có classextends thì lập trình viên sẽ phải làm việc khá vất vả như sau 😫.

À đây là với ai muốn lập trình cho giống với những ngôn ngữ class-based thôi nhé. Với JavaScript có nhiều cách hay hơn nhiều

function Dog(name, energy) {
    Animal.call(this, name, energy);
    this.superPower = 'ROAR!';
}

Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.bark = function () {
    console.log('BARK!');
};

So sánh với Python

Lúc đầu khi tìm hiểu cơ chế prototype của JavaScript thì tôi nghĩ ngay đến Python và thấy sự tương đồng quá lớn của 2 ngôn ngữ này. Theo những gì mô tả ở trên thì quá trình tham chiếu thuộc tính của đối tượng trong JavaScript không khác gì so với Python cả. Đặc biệt class của Python cũng là đối tượng.

Thế nhưng có phải Python là ngôn ngữ theo mô hình prototype-based? Câu trả lời là không.

Mặc dù chia sẻ nhiều ý tưởng giống nhau, cho phép lập trình viên tự do hơn trong lập trình, nhưng Python vẫn là class-based. Trong JavaScript, mọi thứ đều là object và object này có thể tham chiếu object khác làm prototype của mình (tức là không tồn tại khái niệm class, instance, mà mọi thứ đều ngang hàng).

Nhưng với Python thì khác. Trong Python vẫn tồn tại hai khái niệm instance và class.

  • Instance do một class tạo ra, nhưng nó không thể dùng một instance khác làm “class” được. Việc khởi tạo bắt buộc phải do một class thực hiện.
  • Các class (dù cũng là object) nhưng được tạo ra bởi một đối tượng đặc biệt (gọi là metaclass). Class có thể tái sử dụng code của một class khác bằng cách kế thừa, class kế thừa class chứ không thể kế thừa instance được.

Do đó, Python vẫn là ngôn ngữ class-based. Tuy nhiên nó là ngôn ngữ script, dynamic typing nên lập trình vẫn khá tự do trong việc sử dụng dữ liệu. Điều này khác hoàn toàn với những ngôn ngữ biên dịch như Java hay C++.

Animal.prototype

Khái niệm

Trong JavaScript, hàm (function) cũng là đối tượng. Ở phần trước, chúng ta thấy rằng mỗi hàm của JavaScript có sẵn một thuộc tính là prototype (chỉ hàm mới có, các đối tượng khác, kể cả phương thức của một object, thì không).

Nhưng cần phải chú ý rằng, thuộc tính này tên là prototype rất dễ nhầm lẫn với khái niệm prototype mà chúng ta đã nói đến. Tuy nhiên đây chỉ là một thuộc tính bình thường (giống những thuộc tính khác) với một cái tên đặc biệt mà thôi. Nó không phải là <prototype> (hay theo chuẩn ECMAScript là [[Prototype]]).

Nguyên nhân của việc tồn tại một thuộc tính như vậy, là bởi vì JavaScript đã sử dụng cơ chế prototype từ những ngày đầu tiên. Đây là một trong số những tính năng quan trọng nhất của ngôn ngữ (và đến bây giờ vẫn không thay đổi).

Thế nhưng, những ngày xưa ấy, không có cách nào đề làm việc với prototype cả. Cách duy nhất để làm việc này là tin vào thuộc tính prototype của constructor function. Cho đến ngày nay, thuộc tính này vẫn tiếp tục được sử dụng.

Nếu prototype của hàm là một object, khi sử dụng hàm với từ khóa new, object đó sẽ được dùng để gán cho <prototype> của object mới được tạo ra.

const leo = new Animal('Leo', 7);

Thuộc tính này chỉ được sử dụng khi kết hợp với new mà thôi. Và giả sử, sau khi tạo ra đối tượng, chúng ta gán prototype thành một giá trị khác (Animal.prototype = <another object>), thì khi tạo mới các đối tượng bằng new Animal thì những đối tượng mới được tạo ra sau này sẽ có prototype mới, còn đối tượng đã tạo ra rồi vẫn giữ nguyên prototype hiện tại.

Thuộc tính prototype mặc định

Mọi hàm đều có prototype cho dù chúng ta có cần đến nó hay không. Mặc định, thuộc tính này là một object với một thuộc tính duy nhất (thực ra thì còn <prototype> nữa 🤣) là constructor trỏ vào chính bản thân hàm.

function Rabbit() {}

thì mặc định

Rabbit.prototype = { constructor: Rabbit };

Và đương nhiên, nếu không thay đổi gì, thuộc tính này sẽ được gán cho <prototype> của mọi đối tượng được tạo ra từ hàm đó.

const rabbit = new Rabbit();
console.log(rabbit.constructor == Rabbit); // true

Chúng ta có thể sử dụng constructor để tạo một object mới sử dụng chung hàm khởi tạo với object đang có. Cách này đặc biệt hữu dụng khi chúng ta có một object nhưng không biết nó được tạo ra như thế nào (ví dụ lấy từ thư viện bên ngoài) và chúng ta muốn tạo ra một object khác tương tự như thế.

function Rabbit(name) {
    this.name = name;
    console.log(name);
}

const rabbit = new Rabbit('White Rabbit');
const rabbit2 = new rabbit.constructor('Black Rabbit');

Tuy nhiên, cần phải chú ý rằng, prototype có một giá trị mặc định, nhưng JavaScript không có cơ chế nào để kiểm soát thuộc tính này. Về mặt kỹ thuật, chúng ta hoàn toàn có thể gán giá trị cho prototype thành một object khác mà không cần constructor.

function Rabbit() {}
Rabbit.prototype = {
    jumps: true,
};

const rabbit = new Rabbit();
console.log(rabbit.constructor === Rabbit); // false

Vì vậy, thông thường, cách dưới đây là cách mà các lập trình viên vẫn sử dụng (trước kia) để thêm và bớt các thuộc tính cho prototype chứ không phải gán nó cho một object khác.

function Rabbit() {}
Rabbit.prototype.jumps = true;

Tuy nhiên, hiện tại cú pháp class của ES6 có thể sử dụng để tạo một constructor function với prototype “chuẩn”.

Lưu ý

Thuộc tính prototype của hàm rất đơn giản và dễ hiểu nhưng có một vài điểm sau cần lưu ý:

  • Animal.prototype là thuộc tính (không phải <prototype>) được dùng làm <prototype> cho các object khi gọi new Animal.
  • Giá trị của thuộc tính prototype phải là null hoặc object. Các giá trị khác là không hợp lệ và sẽ không hoạt động.
  • Thuộc tính prototype chỉ được sử dụng khi gọi hàm với từ khóa new.

Với những object khác, chúng ta hoàn toàn có thể gán một thuộc tính prototype, tuy nhiên thuộc tính này cũng bình thường như mọi thuộc tích khác và không có ý nghĩa gì đặc biệt:

const user = {
    name: 'John',
    prototype: 'Bla-bla', // một thuộc tính bình thường
};

Tái sử dụng code trong JavaScript

Trong lập trình hướng đối tượng, kế thừa là tính chất rất quan trọng, là một cách phổ biến để tái sử dụng code. Thông thường, việc kế thừa đều dựa trên class (class kế thừa class).

JavaScript với mô hình prototype-based có cách hoạt động rất khác các ngôn ngữ khác. Việc tái sử dụng code trong JavaScript (cũng tạm gọi là kế thừa) dựa trên các object (object kế thừa object). Như tôi đã nói ở phần trước, cơ chế này không có gì là xấu. Và thậm chí, nếu khéo léo trong lập trình, nó có thể mang lại những lợi ích rất lớn.

Sự khác biệt của class và prototype trong kế thừa

Với các ngôn ngữ class-base, một class là một bản thiết kế, bản mô tả về đối tượng. Việc kế thừa sẽ được thực hiện phân cấp, một class kế thừa từ một class khác, tạo ra mối quan hệ cha-con và các class được phân cấp rõ ràng.

Với JavaScript, dù tồn tại từ khóa class nhưng về mặt kỹ thuật, các lớp đối tượng không tồn tại. Thay vào đó, class trong JavaScript dùng để định nghĩa một hàm khởi tạo.

Trong JavaScript, kế thừa được thực hiện trên cơ chế mở rộng chuỗi prototype với cơ chế đơn giản, đối tượng kế thừa trực tiếp từ đối tượng khác (prototype của prototype). Một đối tượng có thể kế thừa từ nhiều nguồn khác nhau, điều đó cho phép tạo ra một mối quan hệ phẳng trong kế thừa.

Hay nói đơn giản, sự khác biệt lớn nhất của kế thừa dựa theo prototype là sự phân cấp các lớp là không có sẵn (tất nhiên vẫn lập trình được theo kiểu như thế). Trong cơ chế này, đối tượng thường được tạo ra dựa trên hàm khởi tạo, hay với JavaScript là Object.create().

Vấn đề kế thừa trong lập trình hướng đối tượng

Kế thừa là một tính chất cơ bản trong lập trình hướng đối tượng. Một trong số những tác dụng của nó là để tái sử dụng code, một cơ chế đơn giản để các đối tượng có thể sử dụng chung một vài phần code nhất định.

Việc hiểu cơ chế rất quan trọng, bởi vì nếu hiểu sai, việc tái sử dụng code có thể không được như ý muốn và gây ra rất nhiều vấn đề.

Việc kế thừa dựa trên class mang đến một điều tất yếu là các class sẽ được phân cấp rõ ràng. Tuy nhiên, nó lại có tác dụng phụ là lập trình viên cần phải thiết kế class rất cẩn thận. Việc sử dụng các class base một cách rộng rãi có thể khiến code rất khó sửa chữa sau này (khi đã được gọi ở quá nhiều nơi).

Hiện tại, với các ngôn ngữ dạng class-based, vẫn đang tồn tại nhiều vấn đề liên quan đến thiết kế hướng đối tượng:

  • Các lớp gắn chặt với nhau
  • Class base rất khó thay đổi nếu có vấn đề
  • Phân cấp không linh hoạt (nếu thiết kế các class bị sai, nguy cơ phải đập đi làm lại là rất cao)
  • Do việc phân cấp rất khó thay đổi, việc code trùng lặp thường xảy ra (khó thay đổi code có sẵn, cách làm thường thấy là sao chép code đã có rồi mới sửa)
  • Vấn đề con khỉ và quả chuối (xin trích nguyên văn câu nói của Joe Armstrong, tác giả của Erlang: “You wanted a banana but what you got was a gorilla holding the banana and the entire jungle.”)

Để giải quyết những vấn đề trên, thì chỉ có một cách duy nhất là sử dụng object composition thay vì kế thừa class (theo bộ tứ, nhóm tác giả của quyển sách thần thánh Design Patterns: Elements of Reusable Object Oriented Software)

Favor object composition over class inheritance.

JavaScript có thể giải quyết vấn đề của class

Và với JavaScript thì cơ chế prototype cho phép sử dụng object composition đơn giản hơn nhiều. Dưới đây là một ví dụ điển hình về việc kế thừa bằng class truyền thống. Theo bộ tứ, sử dụng kế thừa này là không nên, dùng object composition mới là chân lý:

class GuitarAmp {
    constructor({ cabinet = 'spruce', distortion = '1', volume = '0' } = {}) {
        this.cabinet = cabinet;
        this.distortion = distortion;
        this.volume = volume;
    }
}

class BassAmp extends GuitarAmp {
    constructor(options = {}) {
        super(options);
        this.lowCut = options.lowCut;
    }
}

class ChannelStrip extends BassAmp {
    constructor(options = {}) {
        super(options);
        this.inputLevel = options.inputLevel;
    }
}

Đây là một ví dụ điển hình, khi mà người ta cố gắng lập trình JavaScript theo kiểu hướng đối tượng giống với các ngôn ngữ class-based.

Như trong ví dụ trên, BassAmp kế thừa từ GuitarAmp, và ChannelStrip kế thừa từ from BassAmp (do đó gián tiếp kế thừa từ GuitarAmp). Và dù JavaScript hoạt động dựa trên prototype thì kết quả cũng khá giống với các ngôn ngữ khác: một đối tượng BassAmp sẽ có đầy đủ thuộc tính của các đối tượng mà nó kế thừa.

Nhưng đó là lúc vấn đề xảy ra, một đối tượng ChannelStrip không cần thuộc tính cabinet nhưng nó vẫn có thuộc tính đó. Để giải quyết vấn đề này, thì giải pháp thông thường là tạo thêm một class base khác, mà cả BassAmpGuitarAmp cùng kế thừa từ đó.

Thế nhưng cách làm này cũng có những vấn đề nhất định. Ví dụ như hệ thống đã quá lớn, việc sửa lại thiết kế class base sẽ là một việc tốn thời gian và công sức. Đó là chưa kể, thiết kế class base mới sau này cũng sẽ không đáp ứng được yêu cầu và cần phải thiết kế lại.

Trong trường hợp này, sử dụng object composition, không cần kế thừa class và chỉ sử dụng những gì chúng ta cần là một phương pháp tốt hơn cả:

const distortion = { distortion: 1 };
const volume = { volume: 1 };
const cabinet = { cabinet: 'maple' };
const lowCut = { lowCut: 1 };
const inputLevel = { inputLevel: 1 };

const GuitarAmp = (options) => {
    return {
        ...distortion,
        ...volume,
        ...cabinet,
        ...options,
    };
};

const BassAmp = (options) => {
    return {
        ...lowCut,
        ...volume,
        ...cabinet,
        ...options,
    };
};

const ChannelStrip = (options) => {
    return {
        ...inputLevel,
        ...lowCut,
        ...volume,
        ...options,
    };
};

Tái sử dụng code bằng prototype có nhiều kiểu

Trong ví dụ trên, chúng ta đã khai báo từng thuộc tính cho từng đối tượng, đúng những gì bài toán cần. Đó chính là object composition (hiểu đơn giản là cấu thành đối tượng từ nhiều thành phần đơn lẻ). Với những ngôn ngữ class-based, việc này gần như là không thể. Khi kế thừa một class, mọi thuộc tính sẽ đều được kế thừa.

Thế nhưng, có thể nhiều người sẽ thắc mắc, kế thừa ở đâu trong trường hợp này. Thực ra kế thừa bằng prototype cũng có nhiều kiểu, không phải cứ nối dài chuỗi mới là kế thừa (đó cũng là ưu điểm của prototype). Dưới đây là 3 kiểu kế thừa đó:

  • Kế thừa kết hợp: quá trình kế thừa là trực tiếp sao chép các thuộc tính từ đối tượng này sang đối tượng khác. Kể từ ES6, việc này được thực hiện dễ dàng nhờ vào cú pháp khai triển object (bằng ellipsis ...) hoặc Object.assign. Còn trước đó, lập trình viên thường phải sử dụng các thư viện như Lodash, Underscore hoặc jQuery.
  • Ủy quyền (delegation): Đây là kiểu kế thừa trong phần trước chúng ta đã xem xét. Một đối tượng sẽ liên kết đến prototype của nó để ủy quyền. Nếu một thuộc tính không có sẵn trong đối tượng, nó sẽ ủy quyền cho prototype của mình tìm kiếm thuộc tính đó. Prototype lại có thể ủy quyền cho prototype của nó cho đến khi tìm thấy hoặc hết chuỗi prototype thì thôi. Tuy nhiên, việc truy vấn có thể sẽ rất mất thời gian nếu chuỗi prototype quá dài.
  • Kế thừa hàm: Và với phương thức kế thừa này, lập trình viên sẽ xây dựng các factory function (không phải constructor function hay class). Trong hàm đó, một object sẽ được tạo ra, gán các thuộc tính trực tiếp và trả về object để sử dụng về sau.

Dưới đây là một ví dụ đơn giản về kế thừa hàm cho mọi người dễ hình dung:

// Base constructor function
function Animal(data) {
    var that = {};
    that.name = data.name;
    return that;
}

// Kế thừa từ Animal
function Cat(data) {
    var that = Animal(data);
    that.sayHello = function () {
        return "Hello, I'm " + that.name;
    };
    return that;
}

var myCat = Cat({ name: 'Rufi' });
console.log(myCat.sayHello());

Như chúng ta đã xem xét ở ví dụ trước, kế thừa kết hợp chính là bí quyết để xây dựng object composition trong JavaScript. Phối hợp kế thừa kết hợp với các kiểu kế thừa khác sẽ khiến JavaScript phát huy được hết sức mạnh của mình.

Khi nói về kế thừa và prototype trong JavaScript, đa số mọi người tập trong vào phương thức ủy quyền prototype. Thế nhưng, giờ đây chúng ta đã hiểu hơn về nhiều phương thức khác nhau của JavaScript (thực sự bỏ qua phương thức nào đó thật là một thiếu sót quá lớn 🤓).

Kết từ ES6, lập trình viên có thể sử dụng class và nhiều tính năng hiện đại để lập trình hướng đối tượng. Mặc dù hoạt động dựa trên mô hình prototype, nó sử dụng cơ chế ủy quyền, cố gắng mô phỏng lại việc kế thừa class. Do đó, cách này không thể phát huy hết sức mạnh của sự mềm dẻo mà mô hình prototype-based mang lại.

Thậm chí trong trường hợp xấu nhất, bạn lại tự đưa mình vào những vấn đề của lập trình hướng đối tượng mà mô hình class-based đang gặp phải. Có lẽ React cũng đã nhận ra lợi ích quá lớn của cơ chế prototype nên đã chuyển sang sử dụng functional component thay cho class từ lâu (tôi đoán thôi 😁).

Kết luận

JavaScript, như vốn dĩ, chưa bao giờ là ngôn ngữ dễ hiểu cả (dù thỉnh thoảng phỏng vấn vẫn có người bảo JavaScript là ngôn ngữ dễ hiểu, dễ học và dễ lập trình 🤣). Nhưng cũng nhờ nó, mà chúng ta có cơ hội được hiểu hơn rất nhiều điều trong thế giới lập trình.

Trong bài viết này, tôi không đi sâu vào so sánh class vs prototype. Bởi vì đây không phải là vấn đề lựa chọn cái nào tốt hơn. JavaScript không cho chúng ta lựa chọn. Điều quan trọng là chúng ta hiểu được vấn đề và tìm cách phát huy hết sức mạnh của ngôn ngữ.

Tôi xin lỗi nếu bài viết có bất kỳ typo nào. Nếu bạn nhận thấy điều gì bất thường, xin hãy cho tôi biết.

Nếu có bất điều gì muốn nói, bạn có thể liên hệ với tôi qua các mạng xã hội, tạo discussion hoặc report issue trên Github.

Welcome

manhhomienbienthuy

Đây là thế giới của manhhomienbienthuy (naa). Chào mừng đến với thế giới của tôi!

Bài viết liên quan

Bài viết mới

Chuyên mục

Lưu trữ theo năm

Thông tin liên hệ

Cảm ơn bạn đã quan tâm blog của tôi. Nếu có bất điều gì muốn nói, bạn có thể liên hệ với tôi qua các mạng xã hội, tạo discussion hoặc report issue trên Github.