38. Ghi đè (overriding) một cách an toàn

Estimated reading: 9 minutes 216 views

Tóm tắt

Ghi đè một cách có trách nhiệm: Khi ghi đè một hàm ảo (virtual function), hãy bảo toàn khả năng thay thế (substitutability); cụ thể là, tuân thủ các điều kiện tiền đề (pre-conditions) và hậu điều kiện (post-conditions) của hàm trong lớp cơ sở. Đừng thay đổi các đối số mặc định (default arguments) của các hàm ảo. Nên khai báo lại một cách tường minh các hàm ghi đè là virtual. Hãy cảnh giác với việc che khuất các nạp chồng (overloads) trong lớp cơ sở.

Thảo luận

Mặc dù các lớp dẫn xuất thường thêm nhiều trạng thái (tức là, các thành viên dữ liệu) hơn, chúng mô hình hóa các tập con (subsets), không phải tập cha (supersets), của các lớp cơ sở của chúng. Trong kế thừa đúng đắn, một lớp dẫn xuất mô hình hóa một trường hợp đặc biệt của một khái niệm cơ sở tổng quát hơn (Mục 37).

Điều này có hệ quả trực tiếp đến việc ghi đè đúng đắn: Tôn trọng mối quan hệ bao hàm (inclusion relationship) ngụ ý khả năng thay thế—các thao tác áp dụng cho toàn bộ tập hợp cũng phải áp dụng cho bất kỳ tập con nào của chúng. Sau khi lớp cơ sở đảm bảo các điều kiện tiền đề và hậu điều kiện của một thao tác, bất kỳ lớp dẫn xuất nào cũng phải tôn trọng các đảm bảo đó. Một hàm ghi đè có thể yêu cầu ít hơn và cung cấp nhiều hơn, nhưng nó không bao giờ được yêu cầu nhiều hơn hoặc hứa hẹn ít hơn vì điều đó sẽ phá vỡ hợp đồng đã hứa với mã gọi (calling code).

Việc định nghĩa một hàm ghi đè dẫn xuất có thể thất bại (ví dụ: ném ra một ngoại lệ; Mục 70) chỉ đúng nếu lớp cơ sở không quảng bá rằng thao tác đó luôn thành công. Ví dụ, giả sử Employee cung cấp một hàm thành viên ảo GetBuilding nhằm trả về mã hóa của tòa nhà nơi Employee làm việc. Điều gì sẽ xảy ra nếu chúng ta muốn viết một lớp dẫn xuất RemoteContractor ghi đè GetBuilding để đôi khi ném ngoại lệ hoặc trả về mã hóa tòa nhà null? Điều đó chỉ hợp lệ nếu tài liệu của Employee chỉ định rằng GetBuilding có thể thất bại và RemoteContractor báo cáo lỗi theo cách đã được Employee tài liệu hóa.

Khi ghi đè, không bao giờ thay đổi các đối số mặc định. Chúng không phải là một phần của chữ ký hàm (function's signature), và mã khách (client code) sẽ vô tình truyền các đối số khác nhau cho hàm, tùy thuộc vào việc họ truy cập thông qua nút nào của hệ thống phân cấp. Hãy xem xét:

				
					#include <iostream>
using namespace std;

class Base {
public:
    virtual void Foo(int x = 0) {
        cout << "Base::Foo chay voi x = " << x << endl;
    }
};

class Derived : public Base {
public:
    // Ghi đè hàm, nhưng thay đổi tham số mặc định thành 1
    void Foo(int x = 1) override {
        cout << "Derived::Foo chay voi x = " << x << endl;
    }
};

int main() {
    Derived* pD = new Derived;
    Base* pB = pD;

    // TH1: Gọi qua con trỏ Derived
    // Compiler thấy Derived*, lấy mặc định x=1. Runtime gọi Derived::Foo.
    pD->Foo(); // Output: Derived::Foo chay voi x = 1  (Đúng như mong đợi)

    // TH2: Gọi qua con trỏ Base (Cái bẫy!)
    // Compiler thấy Base*, lấy mặc định x=0. Runtime gọi Derived::Foo.
    pB->Foo(); // Output: Derived::Foo chay voi x = 0  (KỲ QUẶC!)

    return 0;
}				
			

Có thể gây ngạc nhiên cho người gọi rằng hàm thành viên của cùng một đối tượng lại lặng lẽ nhận các đối số khác nhau tùy thuộc vào kiểu tĩnh (static type) mà họ tình cờ dùng để truy cập nó. → Giải thích chi tiết.

Ưu tiên thêm từ khóa virtual dư thừa khi ghi đè một hàm. Nó làm cho ý định rõ ràng hơn đối với người đọc.

Hãy cảnh giác với việc vô tình che khuất các nạp chồng (overloads) trong lớp cơ sở. Ví dụ:

				
					class Base { //...
    virtual void Foo( int); 
    virtual void Foo( int, int);
    void Foo( int, int, int ); 
};

class Derived : public Base { //...
    virtual void Foo( int); // ghi đè Base::Foo(int), nhưng che khuất các cái khác
};

Derived d;
d.Foo(1);       // ok
d.Foo(1, 2);    // lỗi (oops?)
d.Foo(1, 2, 3); // lỗi (oops?)				
			

Nếu các nạp chồng của lớp cơ sở cần được nhìn thấy, hãy viết một khai báo using để khai báo lại chúng trong lớp dẫn xuất:

				
					class Derived : public Base { //...
    virtual void Foo( int); // ghi đè Base::Foo(int)
    using Base::Foo;        // đưa các nạp chồng Base::Foo khác vào phạm vi
};				
			

Ví dụ

Ví dụ: Đà điểu (Ostrich). Nếu lớp Bird định nghĩa hàm ảo Fly và bạn dẫn xuất một lớp mới Ostrich (một loài chim nổi tiếng không biết bay) từ Bird, bạn cài đặt Ostrich::Fly như thế nào? Câu trả lời là, “Còn tùy.” Nếu Bird::Fly đảm bảo thành công (tức là, cung cấp đảm bảo không thất bại (no-fail guarantee); (Mục 71) vì bay là một phần thiết yếu của mô hình Bird, thì Ostrich không phải là một cài đặt thích hợp của mô hình đó.

Tổng kết

  • Tuân thủ “Hợp đồng” (Contract) của lớp cha:

    • Ghi đè phải đảm bảo tính thay thế (Substitutability): Bất cứ nơi nào dùng lớp cha đều có thể thay thế bằng lớp con mà không gây lỗi.

    • Nguyên tắc vàng: Hàm ghi đè ở lớp con không được đòi hỏi nhiều hơn (về điều kiện đầu vào – Preconditions) và không được hứa hẹn ít hơn (về kết quả đầu ra – Postconditions) so với lớp cha.

    • Nếu hàm lớp cha cam kết “luôn thành công” (không bao giờ lỗi), thì hàm lớp con tuyệt đối không được ném ngoại lệ (Exception).

  • Tuyệt đối KHÔNG thay đổi tham số mặc định (Default Arguments):

    • Tham số mặc định được xử lý tại thời điểm biên dịch (Static binding) dựa trên kiểu của con trỏ (thường là Base*).

    • Hàm ảo được xử lý tại thời điểm chạy (Dynamic binding) dựa trên đối tượng thực (Derived).

    • Hậu quả: Nếu thay đổi, bạn sẽ rơi vào tình huống “Hàm của con, nhưng tham số của cha”, gây ra lỗi logic cực kỳ khó phát hiện.

  • Cẩn trọng với việc che khuất hàm (Hiding Overloads):

    • Nếu lớp cha có nhiều hàm nạp chồng (cùng tên, khác tham số), việc bạn ghi đè một hàm trong số đó ở lớp con sẽ che khuất (hide) toàn bộ các hàm còn lại của lớp cha.

    • Giải pháp: Sử dụng using Base::TênHàm; trong lớp con để lộ ra các hàm bị che khuất.

  • Viết code rõ ràng:

    • Nên viết lại từ khóa virtual (hoặc tốt hơn là dùng override trong C++11 trở lên) khi ghi đè để người đọc code hiểu rõ ý định.

  • Ví dụ kinh điển – Hình vuông & Hình chữ nhật / Chim & Đà điểu:

    • Đừng áp dụng các so sánh đời thực (như “Đà điểu là Chim”) nếu hành vi không tương thích (Chim biết bay, Đà điểu không biết bay). Nếu lớp Bird có hàm Fly() cam kết luôn bay được, thì Ostrich không thể kế thừa từ Bird một cách đúng đắn.

Tham khảo

[DewhurstO3] §73-74, §78-79 • [SutterOO] §21 • [Keffer95] p. 18

Leave a Comment

Chia sẻ:

38. Ghi đè (overriding) một cách an toàn

Or copy link

CONTENTS