Lập trình dựa trên nguyên mẫu

Thừa kế (inheritance) là khái niệm chủ đạo trong lập trình hướng đối tượng (OOP). Đây cũng là thứ để lại nhiều câu chuyện thú vị nhất trong bộ môn "Javascript học".

Thoạt tiên, người ta cho rằng Javascript không phải là một ngôn ngữ hướng đối tượng, mặc dù trong lõi của Javascript có sẵn một đối tượng tên là Đối tượng (Object). Tại sao? Vì khi nhìn bằng quan điểm OOP truyền thống, người ta không thấy trong Javascript những đặc tính của một ngôn ngữ hướng đối tượng, như : đa hình (polymorphism), đóng gói (encapsulation ) và thừa kế (inheritance). Nhìn vào những phiên bản Javascript đầu tiên, có thể nói rằng Javascript không phải là một ngôn ngữ OOP "bẩm sinh".

May mắn thay, các nhà Javascript học tiên phong đã bước vào thế kỷ XXI với một loạt những khảo sát giá trị về ngôn ngữ này. Cho đến giữa thập kỷ trước, hàng loạt sản phẩm mới ra đời tận dụng một cách triệt để các thế mạnh của Javascript phía client như GMail, GMap... và bên cạnh đó là Firefox, mở đầu cho một thế hệ trình duyệt mới, mạnh mẽ và thông minh. Cộng đồng Mozilla, cũng như các nhà phát triển của AOL, Google, Yahoo!... từ lúc đó, đã nhìn thấy nơi Javascript bóng dáng một ngôn ngữ  lập trình của tương lai. Netscape vứt bỏ hẳn khái niệm applet/embed để tập trung vào nâng cấp Javascript engine. Hai tổ chức về chuẩn web và ngôn ngữ lập trình là  W3C và ECMA giữ vai trò cầm cân nẩy mực phía sau việc chuẩn hóa Javascript. Với sự hậu thuẫn từ rất nhiều thế lực Internet như vậy, Javascript đã ngày càng trở nên hoàn thiện.

Qua loạt bài dịch này, tôi sẽ kể lại câu chuyện về thừa kế trong Javascript, những phương pháp khắc phục hạn chế OOP mà những người yêu thích Javascript thời trước sáng tạo ra, và phương thức hỗ trợ thừa kế trong Javascript sẽ xuất hiện thời gian tới...

Nhưng trước hết, chúng ta hãy cùng tìm hiểu thế nào là lập trình dựa trên nguyên mẫu (prototype-based programming) qua một topic trên Wikipedia.


Lập trình dựa trên nguyên mẫu


Lập trình dựa trên nguyên mẫu (prototype) là phương pháp lập trình hướng đối tượng mà ở đó người ta không sử dụng các class (lớp). Thay vì thừa kế thuộc tính và phương thức của lớp cha như trong các ngôn ngữ dựa trên class, việc dùng lại phương thức và thuộc tính trong lập trình prototype được xử lý bằng cách sao chép một đối tượng có sẵn đóng vai trò nguyên mẫu. Mô hình này cũng được gọi bằng những cái tên khác như lập trình khử class, lập trình hướng nguyên mẫu, lập trình dựa trên hiện thể, hoặc lập trình thừa kế theo phương ngang (1). Ủy quyền (delegation) là điểm đặc trưng của các ngôn ngữ hỗ trợ lập trình prototype.


Ngôn ngữ lập trình dựa trên prototype đầu tiên xuất hiện là Self, được hai tác giả David Ungar và Randall Smith phát triển vào khoảng giữa những năm 1980 khi  nghiên cứu các chủ đề về thiết kế ngôn ngữ lập trình hướng đối tượng. Đến cuối những năm 1990, các mô hình khử class đã ngày càng mở rộng ra đại chúng. Một vài ngôn ngữ lập trình hướng prototype ngày nay là JavaScript (cùng với những bổ sung khác của ECMAScript, JScript và Flash's ActionScript 1.0), Cecil, NewtonScript, Io, MOO, REBOL, và Lisaac.

So sánh với các mô hình dựa trên class

Trong các ngôn ngữ dựa trên class, cấu trúc của các đối tượng được chỉ định theo những khuôn dạng do lập trình viên định nghĩa, gọi là các lớp (class). Trong khi class định nghĩa kiểu dữ liệu và chức năng mà đối tượng sẽ có, các hiện thể là những đối tượng "đem dùng được" (2) dựa trên bộ khung phương thức/thuộc tính của một class riêng biệt. Trong mô hình này, class có tác dụng như một tập hợp các ứng xử (phương thức) và cấu trúc đồng nhất cho mọi hiện thể, còn các hiện thể đem theo dữ liệu của đối tượng. Sự phân biệt vai trò do đó chủ yếu căn cứ vào một bên là sự phân biệt giữa cấu trúc và ứng xử, còn một bên là trạng thái.

Những người bênh vực lập trình dựa trên prototype thường lập luận rằng các ngôn ngữ dựa trên class khuyến khích một mô hình phát triển tập trung trước hết vào sự phân loại các quan hệ giữa những class với nhau, trong khi trái lại, lập trình dựa trên prototype có vẻ như khuyến khích lập trình viên tập trung vào ứng xử của một đối tượng nào đó và chỉ tính đến chuyện phân nhóm chúng khi các đối tượng sau đó dùng lại  theo cách thức giống như khi dùng class.  

Tương tự thế, nhiều ngôn ngữ dựa trên prototype khuyến khích việc sửa đổi nguyên mẫu khi đang thực thi chương trình, điều mà hiếm hệ thống hướng đối tượng dựa trên class nào cho phép, ngoại trừ Common Lisp, Dylan, Smalltalk, Objective-C, Python, Perl, hay Ruby. Hầu hết các ngôn ngữ lập trình dựa trên prototype đều là thông dịch và định kiểu động. Mặc dù về khía cạnh kỹ thuật, các ngôn ngữ định kiểu tĩnh cũng tiện dụng không kém.

Ngôn ngữ Omera được nhắc đến trong bài này là một ví dụ. Xem qua website của Omega thì nó không phải là ngôn ngữ định kiểu tĩnh hoàn toàn, bởi lẽ "trình biên dịch có thể chọn sử dụng ràng buộc tĩnh khi cần thiết sao cho tối ưu hiệu suất chương trình."

Xem phần "Phê bình" để thấy những so sánh khác nữa.

Sự kiến tạo đối tượng

Trong các ngôn ngữ dựa trên class, một hiện thể mới được sinh ra nhờ một hàm kiến tạo (3) của class. Hàm kiến tạo là một hàm đặc biệt có nhiệm vụ cấp phát một block bộ nhớ cho các thành viên của đối tượng (thuộc tính/phương thức) và trả về tham chiếu tới block đó. Khi gọi hàm kiến tạo, có thể  truyền vào một tập  tham số tùy chọn và dùng các thuộc tính để lưu giữ chúng. Hiện thể sinh ra sẽ thừa kế tất cả các phương thức và thuộc tính được định nghĩa trong lớp và hành xử giống như tất cả những hiện thể khác sản sinh ra từ cùng một lớp đó.

Trong các ngôn ngữ dựa trên prototype không có các lớp rõ ràng. Các đối tượng thừa kế trực tiếp từ đối tượng khác với những gì mà chúng liên kết với nhau thông qua một thuộc tính, thường gọi là prototype như trong trường hợp Javascript. Có hai cách để tạo đối tượng mới: đó là tạo mới hoàn toàn (4)  hoặc sao chép từ một đối tượng có sẵn.

Có một số hình thái của đối tượng theo nghĩa đen giúp tạo ra đối tượng mới hoàn toàn, những khai báo nơi mà đối tượng được định nghĩa vào lúc chương trình đang chạy, nhờ các cú pháp đặc biệt như {...} gán thẳng vào một biến. Trong khi hầu hết các ngôn ngữ đều hỗ trợ một cách sao chép, không có gì đáng nói về việc tạo ra một đối tượng mới từ rỗng.

Các ngôn ngữ hỗ trợ tạo đối tượng rỗng cho phép những đối tượng mới được tạo ra mà không cần sao chép từ nguyên mẫu sẵn có. Chúng cung cấp một cú pháp đặc biệt để chi định những thuộc tính và hành xử của đối tượng mới mà không cần tham chiếu đến những đối tượng có sẵn.
Trong nhiều ngôn ngữ prototype có một đối tượng trực thuộc đối tượng gốc thường gọi là Object, Đối tượng này được cài đặt như nguyên mẫu mặc định cho mọi đối tượng sinh ra khi chương trình đang chạy và mang theo những phương thức phổ dụng cần thiết như toString để trả về chuỗi mô tả của đối tượng. Một khía cạnh hữu ích của việc tạo ra đối tượng rỗng là để đảm bảo rằng tên của các đối tượng mới tạo ra không bị đụng chạm với đối tượng Object ở tầng cao nhất. (Trong bản sửa đổi Javascript của Mozilla, người ta có thể làm điều này bằng cách thiết lập giá trị null cho thuộc tính  __proto__ của đối tượng mới tạo ra).

Bản sao của đối tượng tham chiếu tới tiến trình nơi mà qua đó nó được kiến tạo bằng cách sao chép các phương thức của một đối tượng sẵn có (nguyên mẫu của nó). Đối tượng mới mang theo mọi đặc tính của đối tượng gốc. Từ điểm này, đối tượng mới có thể được hiệu chỉnh. Trong một vài ngôn ngữ, đối tượng phái sinh duy trì một kết nối trực tiếp với nguyên mẫu của nó và những thay đổi trong nguyên mẫu sẽ kéo theo những thay đổi tại bản sao. Những ngôn ngữ khác, như Kevo, một ngôn ngữ lập trình Forth-like, không ánh xạ những thay đổi từ nguyên mẫu sang bản sao theo cách này mà hướng đến một mô hình liên thông tự nhiên hơn nơi mà những thay đổi trong đối tượng được sao chép không tự động ánh xạ tới các thế hệ bản sao kế tiếp của chúng.

// Example of true prototypal inheritance style
// in JavaScript.

// "ex nihilo" object creation using the literal
// object notation {}.
var foo = {name: "foo", one: 1, two: 2};
// Another "ex nihilo" object.
var bar = {two: "two", three: 3};

// Gecko and Webkit JavaScript engines can directly
// manipulate the internal prototype link.

// For the sake of simplicity, let us pretend
// that the following line works regardless of the

// engine used:
bar.__proto__ = foo; // foo is now the prototype of bar.

// If we try to access foo's properties from bar
// from now on, we'll succeed.
bar.one // Resolves to 1.

// The child object's properties are also accessible.
bar.three // Resolves to 3.

// Own properties shadow prototype properties
bar.two; // Resolves to "two"

foo.name; // unaffected, resolves to "foo"
bar.name; // Resolves to "foo"

Ví dụ sau viết với Javascript 1.8.5 + (xem thêm : http://kangax.github.io/es5-compat-table/)

var foo = {one: 1, two: 2};
// bar.[[ prototype ]] = foo
var bar = Object.create( foo );
bar.three = 3;
bar.one; // 1
bar.two; // 2
bar.three; // 3

Sự ủy quyền (Delegation)

Các ngôn ngữ dựa trên prototype luôn sử dụng cơ chế ủy quyền. Chương trình khi thực thi có thể gửi đi phương thức chính xác hóa hoặc tìm kiếm phần dữ liệu cần thiết theo một loạt các con trỏ ủy quyền (từ đối tượng tới nguyên mẫu của nó) cho tới khi tìm thấy so khớp. Điều này yêu cầu thiếp lập chia sẻ phương thức giữa các đối tượng với con trỏ ủy quyền. Không giống quan hệ giữa lớp với hiện thể trong các ngôn ngữ OOP dựa trên lớp, quan hệ giữa nguyên mẫu với mỗi nhánh của nó không yêu cầu đối tượng phải đồng nhất về cấu trúc hoặc vùng bộ nhớ, ngoại trừ liên kết ủy quyền nói trên. Như vậy, đối tượng phái sinh có thể tiếp tục được hiệu chỉnh và sửa đổi qua thời gian mà không cần sắp xếp lại cấu trúc nguyên mẫu liên đới như trong các ngôn ngữ dựa trên class. Cũng cần chỉ ra rằng không chỉ dữ liệu mà cả các phương thức cũng có thể thêm vào hoặc thay đổi được. Vì lý do này, một vài ngôn ngữ hướng prototype coi dữ liệu và phương thức như các "slots" hoặc "members".

Sự liên thông (Concatenation)

Nhìn vào mô hình lập trình dựa trên prototype thuần túy, cũng như các tham khảo về mô hình prototype liên thông và ví dụ đưa ra trong ngôn ngữ Kevo, không có con trỏ hoặc liên kết tới nguyên mẫu gốc từ một đối tượng được sao chép. Đối tượng nguyên mẫu (cha) thiên về một bản sao hơn là một liên kết tham chiếu. Kết quả là sự thay đổi trong nguyên mẫu sẽ không tác động gì đến các bản sao.

Khác biệt chính mang tính khái niệm dưới trật tự này là các thay đổi được thực hiện trên đối tượng nguyên mẫu không bị đem sang các bản sao. Có thể nhìn nhận điều này như ưu điểm hoặc hạn chế. (Dù sao Kevo cung cấp các bản gốc thêm vào để cho phép mang những thay đổi sang các tập đối tượng dựa vào sự giống nhau của chúng - do đó gọi là "family resemblances" (tương đồng quyến thuộc) - hơn là thông qua sự phân loại nguồn gốc, như đặc thù trong mô hình ủy nhiệm. Đôi khi người ta cũng cho rằng mô hình nguyên mẫu dựa trên ủy quyền có một bất lợi ở chỗ các thay đổi trên đối tượng con có thể ảnh hưởng đến sự hoạt động của đối tượng cha sau đó. Dù vấn đề này không phải là cố hữu đối với mô hình nguyên mẫu dựa trên ủy quyền và không tồn tại trong các ngôn ngữ dựa trên ủy quyền, như Javascript, có sự đảm bảo các thay đổi ở đối tượng con luôn được lưu lại trong chính nó và không bao giờ ảnh hưởng tới đối tượng cha (ví dụ giá trị của đối tượng con làm nhạt giá trị của đối tượng cha hơn là thay đổi nó).

Trong các bổ sung chú trọng tính đơn giản, mô hình prototype liên thông dò tìm thành viên nhanh hơn mô hình ủy nhiệm (vì không cần đi theo chain của đối tượng cha), nhưng ngược lại nó cũng tốn bộ nhớ hơn vì tất cả các thành viên đều được copy ra thay vị hiện diện như một đơn thể tham chiếu tới đối tượng cha. Các bản bổ sung phức tạp hơn có thể không bị những vấn đề này nhưng nhìn chung vẫn là một cuộc đổi chác giữa tốc độ và lượng bộ nhớ cần sử dụng. Ví dụ, các ngôn ngữ nguyên mẫu ràng buộc có thể sử dụng một bổ sung copy-on-write (5) để cho phép chia sẻ dữ liệu ở hậu cảnh - đây cũng là hướng tiếp cận của Kevo.  Ngược lại, các ngôn ngữ với nguyên mẫu dựa trên cơ chế ủy quyền có thể sử dụng cache để tăng tốc truy vấn dữ liệu.

Lời bình

Có thể thấy rằng những người ủng hộ mô hình class thường có xu hướng phê bình các ngôn ngữ prototype trên một số điểm tương tự như những gì mà phe chủ chương định kiểu tĩnh phê bình các ngôn ngữ định kiểu động. Thông thường là về tính chính xác, sự chắc chắn, khả năng tiên liệu, hiệu quả chương trình và mức độ thân thiện đối với lập trình viên.

Ở ba điểm đầu tiên, các class thường được xem như giống nhau về kiểu (chúng đáp ứng vai trò đó trong hầu hết các ngôn ngữ lập trình hướng đối tượng định kiểu tĩnh) và được đề xuất đề cung cấp một cách chính xác cho các hiện thể của chúng cũng như để người dùng các hiện thể này khu xử chúng theo một vài kiểu cách cho trước.

Liên quan đến tính hiệu quả, khai báo các lớp đơn giản hóa sự tối ưu  trình biên dịch mà cho phép phát triển khả năng tìm kiếm phương thức và thuộc tính của hiện thể hiệu quả. Như Self, phần lớn thời gian phát triển dùng vào các kỹ thuật phát triển, biên dịch và thông dịch để tăng cường hiệu suất cho các ngôn ngữ prototype trên cơ sở so sánh với các ngôn ngữ dựa trên class.

Một chỉ trích phổ biến nhằm vào các ngôn ngữ dựa trên prototype là thiếu tính thân thiện đối với cộng đồng phát triển phần mềm, bất chấp tính đại chúng và sức lan tỏa sâu rộng của Javascript.

Cấp độ hiểu biết về các ngôn ngữ prototype xem ra đang có chiều hướng thay đổi nhờ vào sự tăng trưởng của các framework Javascript và đang nâng cao hơn nữa theo sự ứng dụng Javascript phức tạp trong giai đoạn chín muồi của Web 2.0.


Nguồn : Prototype-based programming, Wikipedia.
___________________________________________


Chú thích :


1. Horizontal inheritance programming : khái niệm này không được nhắc đến trong bài gốc, người dịch bổ sung thêm cho đủ ý.
2. Nguyên văn : "usable" objects. Sở dĩ nói như vậy vì trong các ngôn ngữ dựa trên class, người ta thường phải gọi phương thức của class một cách gián tiếp qua hiện thể thay vì gọi trực tiếp đến class. Ngược lại, trong các ngôn ngữ lập trình dựa trên prototype, nguyên mẫu hay đối tượng gốc  cũng chỉ là đối tượng.
3. Nguyên văn : constructor function. Các tác giả Việt Nam thời trước thường dịch là "hàm dựng". Ngày nay nghe hai chữ "hàm dựng" có vẻ hơi tối nghĩa.
4. Nguyên văn : ex nihilo object creation. Ý là việc tạo ra đối tượng từ chỗ không có gì cả. Cụm từ Latin "ex nihilo" có nghĩa là từ trong cái trống rỗng, từ hư không. Xem chi tiết.
5. Copy-on-write : viết tắt COW, một biện pháp tối ưu hóa sử dụng trong lập trình. Xem chi tiết.