Một Vài Suy Ngẫm Về Lập Trình Hướng Đối Tượng
Phương pháp lập trình hướng đối tượng đã được bàn luận sôi nổi suốt hơn hai thập kỷ qua. Bản thân tôi tiếp xúc với nó tương đối muộn, mãi đến giữa những năm 90 mới bắt đầu học và áp dụng. Nếu nói đến việc đánh giá phương pháp này, thật ra có phần tự phụ. Tuy nhiên qua nhiều năm tháng, tôi vẫn thường xuyên suy nghĩ về các khía cạnh của lập trình hướng đối tượng. Dù đúng hay sai, tôi nghĩ đều đáng để tổng kết lại. Đây chỉ là quan điểm cá nhân, các bạn đọc qua có thể cân nhắc tiếp thu.
Điều đầu tiên cần làm rõ là sự khác biệt giữa “dựa trên đối tượng” (object-based) và “hướng đối tượng” (object-oriented).
Dựa trên đối tượng thường chỉ việc đóng gói dữ liệu và cung cấp một tập hợp phương thức thao tác trên dữ liệu đã được đóng gói. Ví dụ kinh điển là con trỏ FILE* trong thư viện I/O của C - một minh chứng rõ ràng cho mô hình dựa trên đối tượng.
Còn lập trình hướng đối tượng lại bổ sung thêm tính đa hình (polymorphism) vào mô hình dựa trên đối tượng. Đa hình ở đây có nghĩa là khả năng sử dụng cùng một phương thức để thao tác trên các đối tượng khác nhau. Tất nhiên, các đối tượng này phải có những đặc điểm chung nhất định, chỉ khi tồn tại những điểm chung này thì mới có thể áp dụng phương thức thống nhất. Nếu nhìn từ góc độ triển khai thông thường trong C++, khi hai lớp A và B đều kế thừa từ tổ tiên chung R, chúng ta có thể sử dụng cùng một phương thức điều khiển R để quản lý cả A và B.
Tại sao lại cần làm như vậy?
Hãy quay lại với định nghĩa cổ điển: Chương trình = Thuật toán + Cấu trúc dữ liệu
Trong thế giới máy tính, dữ liệu là tập hợp các bit, còn luồng thực thi mã là sự kết hợp của các cấu trúc tuần tự, rẽ nhánh và lặp. Để giải quyết vấn đề, chúng ta phải ánh xạ dữ liệu đầu vào khổng lồ (về mặt bit, mọi dữ liệu đầu vào đều có thể cực kỳ phức tạp) thành kết quả thông qua mã lệnh. Điều này đòi hỏi chúng ta phải tổ chức các bit dữ liệu thành các đơn vị nhỏ gọn hơn thông qua cấu trúc dữ liệu hợp lý.
Những đơn vị này chính là các đối tượng, bao gồm cả dữ liệu và phương thức thao tác trên dữ liệu đó. Như vậy, chúng ta đã thực hiện một phép đóng gói (encapsulation), biến đổi định nghĩa thành:
Chương trình = Thuật toán dựa trên thao tác đối tượng + Cấu trúc dữ liệu với đối tượng là đơn vị nhỏ nhất
Việc đóng gói luôn nhằm mục đích giảm độ phức tạp trong thao tác. Đóng gói dữ liệu giúp đơn giản hóa cấu trúc dữ liệu, từ đó giảm độ phức tạp của bài toán. Đóng gói mã lệnh lại tạo ra khả năng tái sử dụng, làm giảm kích thước mã nguồn và đơn giản hóa vấn đề.
Chuyển sang phần thuật toán dựa trên thao tác đối tượng. Thuật toán này phải xem các đối tượng thao tác như những thực thể đồng nhất. Ở cấp độ thấp hơn đối tượng, thuật toán thao tác trên các byte - những phần tử đồng loại. Nhưng khi lên đến cấp độ đối tượng, các phần tử này có thể khác biệt. Lúc này, thuật toán thao tác trên một tập hợp các khái niệm trừu tượng.
Trong thiết kế hướng đối tượng, không thể thiếu các cấu trúc lưu trữ (container) - nơi chứa các đối tượng có cùng khái niệm trừu tượng. Lưu ý rằng ở đây nói đến “các thứ có cùng khái niệm” chứ không phải “các đối tượng cụ thể”. Bởi vì trong tập hợp mà thuật toán thao tác, thường chứa các tham chiếu đến đối tượng thực thể chứ không phải bản thân đối tượng. Tham chiếu này thể hiện rằng thuật toán có thể thực hiện hành động nào đó trên đối tượng được tham chiếu, mà không quan tâm cụ thể đối tượng đó là gì.
Ví dụ cụ thể: Khi xây dựng hệ thống giao diện người dùng (GUI) hoặc thế giới 3D, cần triển khai chức năng xác định xem chuột có đang chọn trúng một vật thể nào đó hay không. Mỗi vật thể cung cấp một phương thức để kiểm tra vị trí chuột có nằm trong phạm vi của nó.
Phương pháp đơn giản nhất là: Đặt tất cả các vật thể có thể chọn vào một container, sau đó duyệt qua từng phần tử để kiểm tra phương thức xác định vị trí chuột.
Chúng ta không cần các vật thể này phải cùng loại, chỉ cần chúng cùng cung cấp một phương thức kiểm tra thống nhất. Những ai hiểu về COM chắc hẳn đã nhận ra điều tôi muốn nói. Đúng vậy, đây chính là mục đích của phương thức QueryInterface. Trong COM, QueryInterface cho phép lấy được một giao diện cụ thể từ đối tượng để thực hiện một tác vụ nhất định. Thường thì mã nguồn sau đó sẽ đặt giao diện này vào container để các phần khác của hệ thống dễ dàng sử dụng.
Bản chất của lập trình hướng đối tượng chính là tạo ra tính đa hình, nhóm các đối tượng khác nhau theo đặc tính chung và xử lý chúng một cách thống nhất. Các khái niệm như kế thừa, bảng ảo (virtual table)… chỉ là chi tiết triển khai kỹ thuật.
Nói thêm về COM: COM cho phép kế thừa giao diện nhưng không cho phép kế thừa đa giao diện. Quy định này xuất phát từ yêu cầu đảm bảo tính nhất quán nhị phân (binary compatibility).
Tại sao lại không đề cập đến kế thừa triển khai? Vì đây không phải yếu tố thiết yếu của lập trình hướng đối tượng. Trên thực tế, hiện nay người ta nhận thấy kế thừa triển khai có thể gây ảnh hưởng tiêu cực đến chất lượng phần mềm. Khi ghi đè phương thức ảo của lớp cơ sở, bạn có thể phá vỡ hành vi của lớp cơ sở (vì từ góc độ kế thừa, đối tượng lớp cơ sở là một phần của đối tượng lớp dẫn xuất). Thường thì lớp cơ sở được triển khai trước lớp dẫn xuất, không thể dự đoán được các thay đổi yêu cầu từ lớp dẫn xuất trong tương lai. Do đó, việc kế thừa triển khai một cách tùy tiện khi chưa hiểu rõ thiết kế lớp cơ sở sẽ tiềm ẩn rủi ro, gây khó khăn cho việc tách biệt mô-đun và tái sử dụng thành phần.
Vậy kế thừa giao diện có ý nghĩa gì? Theo quan điểm cá nhân, trong đa số trường hợp, kế thừa giao diện không mang lại nhiều giá trị thiết kế. Tuy nhiên trong thiết kế COM, việc yêu cầu mọi giao diện kế thừa từ IUnknown lại có ý nghĩa đặc biệt. Ý nghĩa này xuất phát từ sự thiếu hụt cơ sở hạ tầng - cụ thể là bộ thu gom rác (Garbage Collector). Trong môi trường không có GC, các phương thức AddRef và Release đóng vai trò quản lý tự động đếm tham chiếu (reference counting) cho từng đối tượng. Đối với mã nguồn gốc không chạy trên máy ảo, khi COM không phụ thuộc vào ngôn ngữ cụ thể, đây gần