39. Hãy làm hàm ảo thành không công khai (virtual → nonpublic) và hàm công khai thành không ảo (public → nonvirtual)

Estimated reading: 11 minutes 226 views

Tóm tắt

Trong các lớp cơ sở (base classes) có chi phí thay đổi cao (đặc biệt là các lớp trong thư viện và khung làm việc (frameworks)): Ưu tiên làm cho các hàm công khai (public functions) trở thành không ảo (nonvirtual). Ưu tiên làm cho các hàm ảo (virtual functions) trở thành riêng tư (private), hoặc được bảo vệ (protected) nếu các lớp dẫn xuất cần có khả năng gọi các phiên bản cơ sở. (Lưu ý rằng lời khuyên này không áp dụng cho các hàm hủy (destructors); Mục 50.)

Thảo luận

Hầu hết chúng ta đều đã học được qua kinh nghiệm cay đắng là phải để các thành viên lớp là private theo mặc định trừ khi chúng ta thực sự cần phơi bày chúng ra ngoài. Đó chính là tính đóng gói tốt (good encapsulation). Sự khôn ngoan này được áp dụng thường xuyên nhất cho các thành viên dữ liệu (Mục 41), nhưng nó áp dụng như nhau cho tất cả các thành viên, bao gồm cả các hàm ảo.

Đặc biệt là trong các phân cấp Hướng đối tượng (OO) đắt đỏ khi thay đổi, hãy ưu tiên sự trừu tượng hoàn toàn: Ưu tiên làm cho các hàm công khai thành không ảo, và ưu tiên làm cho các hàm ảo thành private (hoặc protected nếu các lớp dẫn xuất cần có khả năng gọi các phiên bản cơ sở). Đây là mẫu Giao diện Không ảo (Nonvirtual Interface – NVI). (NVI tương tự như các mẫu khác, đáng chú ý là Phương thức Mẫu (Template Method) [Gamma95], nhưng có động lực và mục đích khác biệt.)

Một hàm ảo công khai (public virtual function) vốn dĩ có hai trách nhiệm khác nhau và cạnh tranh nhau, nhắm đến hai đối tượng khán giả khác nhau và cạnh tranh nhau:

  • Nó chỉ định giao diện: Vì là public, nó trực tiếp là một phần của giao diện mà lớp trình bày cho phần còn lại của thế giới.

  • Nó chỉ định chi tiết thực thi: Vì là virtual, nó cung cấp một cái móc (hook) cho các lớp dẫn xuất để thay thế cài đặt cơ sở của hàm đó (nếu có); nó là một điểm tùy biến (customization point).

Bởi vì hai trách nhiệm này có động cơ và đối tượng khán giả khác nhau, chúng có thể (và thường xuyên) xung đột, và khi đó theo định nghĩa, một hàm không thể hoàn thành cả hai trách nhiệm một cách hoàn hảo. Việc một hàm ảo công khai vốn dĩ có hai công việc khác biệt đáng kể và hai đối tượng khán giả cạnh tranh nhau là một dấu hiệu cho thấy nó không tách biệt các mối quan tâm (separating concerns) tốt—bao gồm cả việc nó vốn dĩ vi phạm Mục 5 và 11—và rằng chúng ta nên cân nhắc một cách tiếp cận khác.

Bằng cách tách biệt các hàm công khai khỏi các hàm ảo, chúng ta đạt được những lợi ích đáng kể sau:

  • Mỗi giao diện có thể có hình dạng tự nhiên của nó: Khi chúng ta tách biệt giao diện công khai khỏi giao diện tùy biến, mỗi giao diện có thể dễ dàng có hình dạng mà nó tự nhiên muốn có thay vì cố gắng tìm một sự thỏa hiệp buộc chúng phải trông giống hệt nhau. Thường thì, hai giao diện này muốn số lượng hàm khác nhau và/hoặc các tham số khác nhau; ví dụ, những người gọi bên ngoài (outside callers) có thể gọi một hàm Process công khai duy nhất thực hiện một đơn vị công việc logic, trong khi những người tùy biến (customizers) có thể thích chỉ ghi đè một số phần nhất định của quá trình xử lý, điều này được mô hình hóa tự nhiên bởi các hàm ảo có thể ghi đè độc lập (ví dụ: DoProcessPhase1, DoProcessPhase2) để các lớp dẫn xuất không bị buộc phải ghi đè mọi thứ. (Ví dụ sau này cụ thể là mẫu Template Method đạt được từ một con đường khác.)

  • Lớp cơ sở nắm quyền kiểm soát: Lớp cơ sở giờ đây hoàn toàn kiểm soát giao diện và chính sách của nó và có thể thực thi các điều kiện tiền đề (preconditions) và hậu điều kiện (postconditions) của giao diện (Mục 14 và 68), chèn các công cụ đo đạc (instrumentation), và thực hiện bất kỳ công việc tương tự nào tất cả tại một nơi tái sử dụng thuận tiện duy nhất—hàm giao diện không ảo. Việc “tiền tái cấu trúc” (prefactoring) để tách biệt này do đó thúc đẩy thiết kế lớp tốt.

  • Lớp cơ sở mạnh mẽ trước sự thay đổi: Chúng ta tự do thay đổi ý định sau này và thêm kiểm tra điều kiện tiền đề và hậu điều kiện, hoặc tách quá trình xử lý thành nhiều bước hơn, hoặc tái cấu trúc (refactor), hoặc thực thi sự tách biệt giao diện/cài đặt đầy đủ hơn bằng cách sử dụng thành ngữ Pimpl (Pimpl idiom) (Mục 43), hoặc thực hiện các sửa đổi khác đối với khả năng tùy biến của lớp cơ sở, mà không ảnh hưởng đến bất kỳ mã nào sử dụng hoặc kế thừa từ lớp đó. Lưu ý rằng bắt đầu với NVI dễ dàng hơn nhiều (ngay cả khi hàm công khai chỉ là một dòng chuyển tiếp (passthrough) đến hàm ảo) và sau đó thêm kiểm tra hoặc đo đạc, bởi vì điều đó sau này có thể được thực hiện mà không phá vỡ bất kỳ mã nào sử dụng hoặc kế thừa từ lớp. Khó khăn hơn nhiều khi bắt đầu với một hàm ảo công khai và sau đó phải tách nó ra, điều này nhất thiết sẽ phá vỡ hoặc là mã sử dụng lớp hoặc là mã kế thừa từ lớp, tùy thuộc vào việc chúng ta chọn giữ hàm ban đầu là hàm ảo hay là hàm công khai.

Xem thêm Mục 54.

Ngoại lệ

NVI không áp dụng cho các hàm hủy (destructors) do thứ tự thực thi đặc biệt của chúng (Mục 50).

NVI không trực tiếp hỗ trợ các kiểu trả về hiệp biến (covariant return types) cho người gọi. Nếu bạn cần sự hiệp biến hiển thị cho mã gọi mà không sử dụng ép kiểu xuống (downcasts) bằng dynamic_cast (xem thêm Mục 93), thì việc để hàm ảo là public sẽ dễ dàng hơn.

Tổng kết

1. Lời khuyên cốt lõi (Mẫu thiết kế NVI – Non-Virtual Interface)

  • Hàm Public: Nên là hàm không ảo (non-virtual). Đây là giao diện chính thức mà lớp cung cấp ra bên ngoài.

  • Hàm Virtual: Nên là hàm riêng tư (private) (hoặc protected nếu lớp con cần gọi lại bản gốc). Đây là nơi để lớp con tùy biến hành vi.

2. Tại sao nên làm như vậy?

  • Tách biệt trách nhiệm: Hàm public chịu trách nhiệm về giao diện (cam kết với người dùng), trong khi hàm virtual chịu trách nhiệm về chi tiết cài đặt (nơi tùy biến). Gộp chung hai thứ này vào một hàm public virtual sẽ gây xung đột về mục đích.

  • Quyền kiểm soát thuộc về lớp cơ sở (Base Class): Bằng cách buộc mọi lời gọi đi qua hàm public non-virtual (cổng chính), lớp cơ sở có thể chèn các đoạn mã kiểm tra điều kiện đầu vào/đầu ra, thiết lập môi trường, hoặc ghi log trước và sau khi gọi hàm ảo thực sự.

  • Dễ dàng thay đổi: Bạn có thể sửa đổi logic trong hàm public (ví dụ: thêm kiểm tra lỗi) mà không làm ảnh hưởng đến các lớp con hay mã nguồn đang sử dụng lớp đó.

3. Các ngoại lệ

  • Hàm hủy (Destructors): Không áp dụng quy tắc này. Hàm hủy thường phải là public virtual (để đa hình hoạt động đúng) hoặc protected non-virtual.

  • Kiểu trả về hiệp biến (Covariant return types): Nếu bạn muốn lớp con trả về con trỏ của chính nó thay vì con trỏ lớp cha để người dùng không phải ép kiểu, thì việc dùng public virtual sẽ thuận tiện hơn.

Leave a Comment

Chia sẻ:

39. Hãy làm hàm ảo thành không công khai (virtual → nonpublic) và hàm công khai thành không ảo (public → nonvirtual)

Or copy link

CONTENTS