Tóm lại polymorphism trong OOP là gì nhỉ?

(Bài này khá linh tinh và có thể chứa thông tin không chính xác. Nếu bạn mới học lập trình thì không nên đọc.)

Khi chưa tìm hiểu kĩ về polymorphism, tôi có cảm giác polymorphism là một cái gì đó khá mơ hồ. Nói chung thì các sách khi để cập đến vấn đề này đều dùng ví dụ minh họa hơn là định nghĩa chính xác. Với những tài liệu có định nghĩa, thì có vẻ như đánh đồng polymorphism và overriding. Tuy nhiên, trong khi với những ngôn ngữ như Java các method đều là virtual method thì cũng có những ngôn ngữ như C# trong đó virtual method và overriding phải được phát biểu một cách rõ ràng. Tuy nhiên, như ta đã biết, polymorphism cùng với encapsulation và inheritance thường được giới thiệu như là ba cột trụ (pillar) của OOP. Nếu polymorphism đồng nghĩa với overriding và virtual method thì tại sao những ngôn ngữ như C# lại có vẻ như hạn chế cột trụ này? (Nói ngoài lề là thực ra việc đòi hỏi phát biểu virtual method và overriding một cách rõ ràng trong C# có cái lí của nó, chứ không phải là để hạn chế.)

Thực tế là, khi đọc các tài liệu mang tính lí thuyết hơn, bạn sẽ thấy polymorphism không phải là một khái niệm của riêng OOP, càng không đồng nghĩa với overriding. Mặc dù không phải tất cả các tài liệu đều viết giống nhau, nhưng nhìn chung có thể định nghĩa polymorphism là tính chất cho phép các cấu trúc gồm hàm và kiểu dữ liệu có thể làm việc với nhiều kiểu dữ liệu khác nhau. Có ba dạng polymorphism:

  • Parametric polymorphism: Các type liên quan đến cấu trúc được cung cấp dưới dạng biểu thức trong đó type là các biến. Chẳng hạn, C++ template chính là một cơ chế hỗ trợ parametric polymorphism trong ngôn ngữ này. Tất nhiên, parametric polymorphism có thể có trong các ngôn ngữ không hỗ trợ OOP.
  • Ad hoc polymorphism: Thuật ngữ này dùng để chỉ overloading. Ví dụ phổ biến là toán tử + có thể là một function dạng int * int –> int, cũng có thể là một function dạng string * string –> string. Một số người không đồng ý xem overloading là một dang polymorphism.
  • Subtype polymorphism: Đây là dạng polymorphism mà thuật ngữ “polymorphism” trong OOP chỉ tới. Với subtype polymorphism, một method có thể áp dụng cho nhiều type khác nhau thông qua subtyping. Cụ thể, nếu một method áp dụng được cho type T, nó sẽ áp dụng được cho subtype của T. Quan hệ subtype thường được thiết lập thông qua inheritance. Chú ý là, định nghĩa này không đề cập đến overriding hay virtual method gì cả. Một ngôn ngữ OOP có thể không hỗ trợ overriding mà vẫn thỏa mãn subtype polymorphism.

Một điểm đáng chú ý là trừ Wikipedia, tôi chưa thấy có nguồn nào coi overriding là một dạng polymorphism cả.

Tóm lại nên định nghĩa polymorphism như thế nào? Về mặt lương tâm mà nói thì có lẽ cần trình bày dựa theo lí thuyết về ngôn ngữ lập trình. Về mặt cảm giác, tôi nghĩ định nghĩa polymorphism trong OOP bằng subtype polymorphism tuy trông thì phức tạp nhưng lại đơn giản, mạch lạc, ít mơ hồ hơn định nghĩa thông qua overriding (đương nhiên kéo theo người học dễ hiểu, dễ nhớ, dễ hệ thống hơn). Về thực tế thì vì định nghĩa về polymorphism trong OOP thông qua overriding (và kéo theo hiểu rằng polymorphism là đặc trưng của OOP) đã quá phổ biến, mà hiểu sao thì tóm lại vẫn là biết lập trình, nên có lẽ cũng không cần bận tâm đến polymorphism thực sự là cái gì nữa :D.

Các bạn có thể tham khảo thêm trong Concepts in programming languages và Programming languages pragmatics.

Môn Lập trình hướng đối tượng

Không biết vào thời điểm này thì đưa mấy bài kiểu này nên có an toàn không nhỉ :D.

Tháng trước HM có chat với một người bạn và nhận được một câu trong đề thi giữa kì môn Lập trình hướng đối tượng như sau:

Trong C++ câu lệnh ClassB CB = new ClassB() tạo ra đối tượng trong vùng nhớ: a. heap b. stack c. static d. tất cả đều sai

Và HM nhận ra đã hai năm kể từ khi học xong môn này, nó vẫn chả thay đổi gì cả. Câu hỏi trên thì dính dáng gì đến OOP? Khi xưa thi môn này HM cũng gặp những câu hỏi khiến người ta có cảm giác đây là môn “Lập trình OOP, Java, và C++ cóp nhặt”.

Theo ý kiến của HM (và HM không phải là giáo viên), một môn Lập trình hướng đối tượng nếu được dạy, cần phải thể hiện được:

  • OOP là một paradigm, các ngôn ngữ chỉ là implementation.
  • Các khái niệm về encapsulation, inheritance, polymorphism, overriding, v.v
  • Giới thiệu qua về các implementation khác nhau cho những khái niệm trên.
  • Các nguyên lí khi thiết kế hướng đối tượng, và một dự án lớn áp dụng được nhiều nguyên lí này thì rất tuyệt. Tuyệt hơn nữa nếu nó sử dụng lại bài tập lớn của môn học trước dành cho sinh viên mới, vì nó sẽ tạo ra sự đối chiếu. Môn Lập trình hướng đối tượng khi HM học không dạy cái này, và cũng chỉ đưa ra các bài thực hành dạng copy-paste, chạy có thể được có thể không, và hoàn toàn không minh họa gì cho các nguyên lí OOP.

Thực sự cảm thấy rất tiếc.

Paradigm và ngôn ngữ lập trình

Trước đây, đa phần lập trình viên bình thường chủ yếu làm việc với lập trình hướng đối tượng, thì sự khác biệt giữa paradigm và ngôn ngữ lập trình không thật quan trọng. Tuy nhiên, khi mà lập trình hàm (functional programming) đang ngày một thu hút nhiều sự chú ý, thì việc hiểu rõ sự khác biệt này là khá cần thiết.

Trước hết, bạn thử suy nghĩ, tại sao Java không giống C#, mà người ta lại dạy bạn là hai ngôn ngữ này đều là lập trình hướng đối tượng? Nếu bạn gặp thêm một ngôn ngữ X nào đó, làm sao bạn biết nó có hướng đối tượng? Hay nếu bạn vô tình đọc phải một cuốn như Object Oriented Programming With ANSI-C, bạn có dám nói C là một ngôn ngữ lập trình hướng đối tượng không?

Để trả lời những câu hỏi này, bạn phải hiểu lập trình hướng đối tượng là một paradigm. Paradigm định nghĩa các khái niệm mà chúng ta sẽ dùng để suy nghĩ về một vấn đề nào đó. Một số ngôn ngữ nào đó có thể cung cấp các tính năng cho phép chúng ta ứng dụng paradigm đó để hiện thực một cách dễ dàng, nhưng paradigm không bao giờ quy định cụ thể tính năng đó là gì, cú pháp ra sao, v.v. Chẳng hạn, paradigm cho lập trình hướng đối tượng nêu lên khái niệm encapsulation. Java dùng các modifier như public, private. Những người phát triển C# cho rằng property giúp tăng cường tính encapsulation, và họ đưa property vào những ngôn ngữ này. Những người phát triển Python lại lập luận rằng encapsulation không phải nhiệm vụ của trình dịch, nên trong Python không có cơ chế tạo private method, người ta ngầm quy ước method bắt đầu với “__” là private. Và cả Java, C#, Python đều được người phát triển chúng tuyên bố là có hỗ trợ lập trình hướng đối tượng. Nếu bạn hiểu encapsulation, bạn sẽ việc được code thỏa mãn tương đối tính chất này ở cả ba ngôn ngữ, nhưng nếu bạn chỉ học ngôn ngữ thôi, có thể code của bạn sẽ không có tính encapsulation. Hiểu một paradigm không chỉ giúp bạn khi sử dụng một ngôn ngữ được cho là hỗ trợ nó, mà bạn có thể áp dụng những khái niệm hay từ nó cho các ngôn ngữ khác, để làm cho code của bạn có chất lượng cao hơn (bạn thử nghĩ làm thể nào để tăng encapsulation trong C).

Cần chú ý là trong khi một ngôn ngữ có thể tốt hơn một ngôn ngữ khác, như C# 3.0 so với C# 2.0 (mặc dù so sánh này chỉ là tương đối), bạn không thể so sánh paradigm này có tốt hơn paradigm khác không. Mỗi paradigm đều thích hợp trong một số tình huống, và không thích hợp trong những tình huống khác. Ví dụ, paradigm lập trình hàm cổ vũ cho việc sử dụng immutable object, nhưng không phải mọi bài toán đều giải quyết được chỉ dùng immutable object. Hiểu rõ các paradigm sẽ giúp bạn không áp dụng chúng một cách cứng nhắc, nhờ đó lời giải của bạn cho các vấn đề sẽ trở nên uyển chuyển, hiệu quả hơn (ví dụ này có vẻ hợp). Cũng bởi vì không có paradigm nào là tốt nhất, các ngôn ngữ lập trình hiện đại thường đều ở dạng multiparadigm, trong đó có thể có một paradigm là chủ đạo. Muốn tận dụng tốt nhất các tính năng mà những ngôn ngữ này cung cấp, bạn phải hiểu thế mạnh của các paradigm và nhìn ra những tình huống ứng dụng chúng.

Cuối cùng, cũng như nhiều khái niệm khác như khoa học máy tính và kĩ thuật máy tính, strong typing và weak typing, các paradigm nói chung cũng được định nghĩa một cách tương đối hoặc trừu tượng. Cho nên có thể đôi khi bạn sẽ băn khoăn liệu paradigm này có phải là hệ quả hay là một cách phát biểu khác của paradigm kia, v.v. Trong trường hợp này có lẽ tốt nhất là khỏi phải suy nghĩ nhiều làm gì cho mệt :D, thế giới định nghĩa sao ta chấp nhận thế. Điều quan trọng khi tìm hiểu một paradigm là phải ghi nhớ những khái niệm quan trọng nhất mà nó cổ vũ, và nếu có thể thì học một ngôn ngữ được cho là đại diện cho nó. Như thế, khi đã thành thạo, cho dù bạn không thể định nghĩa paradigm này, bạn vẫn có thể rút ra được những phương pháp và kinh nghiệm hay cho việc lập trình. Có thể xem như paradigm tác động tới thiết kế của các ngôn ngữ, và ngược lại các ngôn ngữ góp phần định nghĩa nên paradigm.

Singleton

Theo quyển Design Patterns (Gang of Four) thì Singleton là pattern cho phép class chỉ có duy nhất một instance. Đây cũng là một trong những pattern dễ hiểu, dễ hiện thực nhất. Khi nhắc đến Singleton thì mọi người cũng ngầm hiểu là Singleton với hiện thực như trong Gang of Four.

Tuy nhiên theo ý kiến chung là không nên dùng Singleton vì:

  • Khó thay đổi, mở rộng. Singleton dùng method static. Class con có thể hide method static, nhưng không thể override chúng.
  • Vi phạm nguyên tắc Explicit interface với các module dùng cùng một Singleton (tuy nhiên, đây không phải là luật).
  • Gây trở ngại cho unit test. Đây là hệ quả của lí do đầu tiên (xem nhanh).

Khi nào thì dùng Singleton? Bài viết này đề xuất:

  • Nếu tính chất “Singleton” không phải là bản chất của class, mà là do class dùng nó yêu cầu thế, thì tính duy nhất nên được hiện thực theo một cách độc lập, như Factory Method hay Dependency Injection.
  • Nếu tính chất “Singleton” là bản chất của class, xem xét việc dùng biến static để lưu trạng thái của instance (vẫn đem lại tính “duy nhất”), trước khi dùng đến phương pháp trong Gang of Four (vốn dùng cả method static).

Xem thêm Use your singletons wisely.

Bao đóng trong lập trình hướng đối tượng

Bao đóng là che đi phần hiện thực của module, và chỉ cung cấp cho bên ngoài các chức năng của module. Mục đích là làm cho việc thay đổi trong hiện thực của một module (supplier) không ảnh hưởng tới các module đã sử dụng nó (gọi là client).

Tuy nhiên, bao đóng thường bị hiểu sai thành che dấu thông tin một cách vật lý, ngăn cản client truy cập vào phần “bên trong” của supplier. Chính vì vậy mới có quan niệm cứ tạo private field, rồi sau đó tạo thêm các public getter/setter và thế là tính bao đóng vẫn được đảm bảo.

Bertrand Meyer trong Object-oriented software construction đã đưa ra một định nghĩa khác, nhằm tránh sự hiểu lầm trên. Bao đóng nghĩa là tính đúng đắn của các client chỉ có thể chứng minh dựa trên tính đúng đắn của các thuộc tính public của supplier. Nói cách khác, không thể viết được client chỉ có thể vận hành đúng đắn dựa vào các thuộc tính private của supplier.

Ví dụ, xét trường hợp một supplier cung cấp một cơ chế tìm kiếm trong bảng. Client của nó, chẳng hạn như một module trong chương trình bảng tính, sử dụng supplier trên để tìm kiếm các phần tử trong bảng. Bao đóng ở đây có nghĩa rằng, ngay cả khi người xây dựng client biết cụ thể supplier dùng tìm kiếm nhị phân hay bảng băm, họ cũng không thể viết ra một client chỉ chạy đúng với một trong hai phương pháp tìm kiếm, và sai với phương pháp còn lại.

Martin Fowler còn đưa thêm thuật ngữ published để tránh sự nhập nhằng này. Các thuộc tính public là các thuộc tính mà ngôn ngữ lập trình cho phép truy cập từ bên ngoài. Các thuộc tính published là các thuộc tính public mà người phát triển sẽ công bố cho cộng đồng. Chính các thuộc tính published sẽ cho thấy module có thỏa tính bao đóng hay không. Và Eclipse là một ví dụ đáng kể cho chính sách này.

Tóm lại, các cơ chế public, private của các ngôn ngữ lập trình chỉ là công cụ để việc bao đóng được thực hiện một cách gọn gàng và an toàn. Còn việc một module có thỏa mãn tính chất này không còn phụ thuộc vào thiết kế của nó.

Để khẳng định điều này, xin được minh họa một tính năng của Java, cho phép lấy giá trị của một private field của một object bên ngoài.

import java.lang.reflect.Field;

class Foo { 
    private int bar = 100; 
}

public class EncapsulationExample { 
    public static void main(String[] args) throws Exception { 
        Foo foo = new Foo(); 
        Class c = foo.getClass(); 
        Field f = c.getDeclaredField("bar"); 
        f.setAccessible(true); 
        Object o = f.get(foo); 
        System.out.println(o.toString()); // 100 
    } 
}

Getter/setter

Ngày xưa khi học C++ hay được dạy phải để field của object là private, sau đó tạo hai method public là get…() và set…(…) để truy cập và thay đổi các field này. Nhưng không ai dặn mình phải dùng nó một cách cẩn thận. Có quá nhiều lí do để get/set là dấu hiệu của một thiết kế yếu kém.

Thường thì chúng ta get một field để lấy thông tin của đối tượng, và thao tác trên thông tin đó. Vấn đề là tại sao phải trực tiếp làm việc trên thông tin này. Xét cho cùng, đối tượng là một thực thể mà tất cả những gì bên ngoài (nên) được biết về nó là các “dịch vụ” mà nó cung cấp. Cho nên, thay vì lấy thông tin từ đối tượng để thực hiện một công việc, hãy bảo đối tượng này làm nó cho chúng ta. Nếu điều này là không thể, thì có vẻ như thiết kế của chương trình có vấn đề, các đối tượng đã không được xây dựng để làm các công việc của nó một cách tách bạch, rõ ràng. Và khi sự coupling giữa các đối tượng càng cao, việc bảo trì và mở rộng trong tương lai sẽ càng khó khăn, phức tạp.

Mặt khác, khi get hay set một field nào đó, thường chúng ta đã có giả định về kiểu và ý nghĩa của nó. Nói cách khác, chúng ta đã có hiểu biết nhất định về hiện thực bên trong của đối tượng. Có điều, hiện thực của một đối tượng không phải là một bất biến. Khó có thể đảm bảo trong tương lai, khi đối tượng được xây dựng lại, các private field đó sẽ giữ nguyên kiểu và ý nghĩa như hôm nay, thậm chí nó có thể không tồn tại nữa. Hệ quả là, những đoạn mã có sử dụng get/set cũng trở nên vô dụng. Điều đó đồng nghĩa với việc không đạt được tính dễ bảo trì và mở rộng—mục tiêu mà lập trình hướng đối tượng hướng tới.

Tất nhiên, nói như thế không có nghĩa là chúng ta phải tránh xa get/set. Get/set sẽ tồn tại nếu chúng thực sự là những “dịch vụ” cần thiết, và không phụ thuộc và thay đổi của bất kì cách hiện thực nào của đối tượng. Nhưng lúc đó thì get/set lại không hẳn liên quan đến cái get/set người ta hay dạy cho học sinh nữa.