Đa kế thừa trong C++ (Multiple Inheritance) và kế thừa ảo

Đa kế thừa trong C++ (Multiple Inheritance) và kế thừa ảo - Khuê Nguyễn Creator

Trong bài này chúng ta sẽ học đa kế thừa trong C++ (multiple inheritance): một class kế thừa từ nhiều lớp cha cùng lúc. Bài sẽ đi qua cú pháp, thứ tự gọi hàm khởi tạo, cách xử lý khi 2 lớp cha có hàm trùng tên, và “vấn đề kim cương” nổi tiếng cùng cách giải quyết bằng kế thừa ảo virtual.

Bài 19 trong Serie Lập trình C++ từ A tới Z

Đa kế thừa là gì?

Ở bài tính kế thừa trong C++, mỗi lớp con chỉ có một lớp cha. Đa kế thừa là khi một lớp con kế thừa từ hai lớp cha trở lên, và có đủ thuộc tính, phương thức của tất cả các lớp cha đó.

Thư viện của chúng ta bắt đầu có sách điện tử. Một cuốn sách điện tử vừa là một cuốn sách (có tên, tác giả), vừa là một file số (có định dạng PDF hay EPUB, dung lượng). Đa kế thừa cho phép SachDienTu lấy cả hai phần đó.

Không phải ngôn ngữ nào cũng cho phép đa kế thừa class. Java và C# chỉ cho kế thừa một lớp cha, còn C++ thì cho phép, kèm theo một số rắc rối mà bài này sẽ chỉ ra.

Cú pháp đa kế thừa trong C++

Các lớp cha viết sau dấu :, ngăn cách bằng dấu phẩy, mỗi lớp có kiểu kế thừa riêng:

#include <iostream>
#include <string>

class Sach
{
public:
    Sach(const std::string& ten, const std::string& tacGia) : ten_(ten), tacGia_(tacGia)
    {
        std::cout << "  Khoi tao Sach\n";
    }
    void inSach() const { std::cout << ten_ << " - " << tacGia_ << '\n'; }

private:
    std::string ten_;
    std::string tacGia_;
};

class FileSo
{
public:
    FileSo(const std::string& dinhDang, double dungLuongMB) : dinhDang_(dinhDang), dungLuongMB_(dungLuongMB)
    {
        std::cout << "  Khoi tao FileSo\n";
    }
    void inFile() const { std::cout << "File " << dinhDang_ << ", " << dungLuongMB_ << " MB\n"; }

private:
    std::string dinhDang_;
    double dungLuongMB_;
};

class SachDienTu : public Sach, public FileSo
{
public:
    SachDienTu(const std::string& ten, const std::string& tacGia, const std::string& dinhDang, double mb)
        : Sach(ten, tacGia), FileSo(dinhDang, mb)
    {
        std::cout << "  Khoi tao SachDienTu\n";
    }
};

int main()
{
    std::cout << "Tao sach dien tu:\n";
    SachDienTu ebook("Lap trinh C++", "Bjarne Stroustrup", "PDF", 12.5);

    ebook.inSach();     // tu Sach
    ebook.inFile();     // tu FileSo
    return 0;
}

Đa kế thừa: SachDienTu kế thừa Sach và FileSo, khởi tạo Sach trước rồi FileSo

  • class SachDienTu : public Sach, public FileSo: kế thừa đồng thời 2 lớp cha.
  • Hàm khởi tạo gọi hàm khởi tạo của từng lớp cha trong danh sách khởi tạo: Sach(ten, tacGia), FileSo(dinhDang, mb).
  • Đối tượng ebook gọi được inSach() của Sach lẫn inFile() của FileSo.

Thứ tự gọi hàm khởi tạo trong đa kế thừa

Các hàm khởi tạo lớp cha được gọi theo thứ tự khai báo kế thừa (Sach rồi tới FileSo), sau cùng mới tới thân hàm khởi tạo của lớp con. Hàm hủy thì chạy theo thứ tự ngược lại.

Lưu ý: Thứ tự gọi do dòng class SachDienTu : public Sach, public FileSo quyết định, chứ không phải thứ tự các bạn viết trong danh sách khởi tạo. Viết : FileSo(...), Sach(...) thì Sach vẫn được khởi tạo trước, và g++ cảnh báo -Wreorder. Nếu lớp cha này phụ thuộc vào lớp cha kia, phải sắp đúng thứ tự ở dòng khai báo class.

Hai lớp cha có hàm trùng tên

Rắc rối đầu tiên của đa kế thừa: cả Sach và FileSo đều có hàm inThongTin(). Gọi trên SachDienTu thì gọi của ai?

#include <iostream>

class Sach
{
public:
    void inThongTin() const { std::cout << "Thong tin sach\n"; }
};

class FileSo
{
public:
    void inThongTin() const { std::cout << "Thong tin file\n"; }
};

class SachDienTu : public Sach, public FileSo {};

int main()
{
    SachDienTu ebook;
    ebook.inThongTin();          // loi: khong biet goi cua lop nao
    return 0;
}

g++ báo lỗi request for member inThongTin is ambiguous khi hai lớp cha có hàm trùng tên

g++ không đoán mà báo request for member 'inThongTin' is ambiguous, kèm danh sách 2 ứng viên. Có 2 cách giải quyết:

#include <iostream>

class Sach
{
public:
    void inThongTin() const { std::cout << "Thong tin sach\n"; }
};

class FileSo
{
public:
    void inThongTin() const { std::cout << "Thong tin file\n"; }
};

class SachDienTu : public Sach, public FileSo
{
public:
    void inThongTin() const      // tu dinh nghia lai, goi ro tung lop cha
    {
        Sach::inThongTin();
        FileSo::inThongTin();
    }
};

int main()
{
    SachDienTu ebook;
    ebook.inThongTin();
    ebook.Sach::inThongTin();     // hoac goi ro lop cha tu ben ngoai
    return 0;
}

Lớp con định nghĩa lại inThongTin và gọi rõ Sach::inThongTin, FileSo::inThongTin

Cách tốt nhất là lớp con tự định nghĩa lại inThongTin() và quyết định gọi phần nào của lớp cha nào. Cách viết ebook.Sach::inThongTin() từ bên ngoài cũng chạy, nhưng bắt người dùng class phải biết cấu trúc kế thừa bên trong, nên chỉ dùng khi thật cần.

Vấn đề kim cương (diamond problem)

Rắc rối lớn hơn xảy ra khi 2 lớp cha lại có chung một lớp ông. Giả sử cả Sach và FileSo đều kế thừa TaiLieu (có mã tài liệu):

#include <iostream>

class TaiLieu
{
public:
    TaiLieu() { std::cout << "  Khoi tao TaiLieu\n"; }
    int ma = 0;
};

class Sach : public TaiLieu {};
class FileSo : public TaiLieu {};
class SachDienTu : public Sach, public FileSo {};

int main()
{
    std::cout << "Tao SachDienTu:\n";
    SachDienTu ebook;
    ebook.Sach::ma = 1001;
    ebook.FileSo::ma = 2002;
    std::cout << "Sach::ma = " << ebook.Sach::ma << ", FileSo::ma = " << ebook.FileSo::ma << '\n';
    return 0;
}

Vấn đề kim cương: thuộc tính ma bị mơ hồ, TaiLieu được khởi tạo 2 lần với 2 mã khác nhau

Lệnh biên dịch đầu tiên trong ảnh là file ebook.cpp, giống hệt code trên nhưng main chỉ viết ebook.ma = 1001;: g++ báo request for member 'ma' is ambiguous. Bản thứ hai chỉ rõ từng đường kế thừa thì biên dịch được, và kết quả cho thấy vấn đề: TaiLieu được khởi tạo 2 lần, một cuốn sách điện tử có tới 2 mã khác nhau. Đó là vì mỗi lớp cha mang theo một bản TaiLieu riêng:

Sơ đồ vấn đề kim cương: kế thừa thường tạo 2 bản TaiLieu, kế thừa ảo virtual dùng chung 1 bản

Sơ đồ kế thừa đúng ý chúng ta muốn có hình thoi, giống viên kim cương, nên gọi là vấn đề kim cương (diamond problem). Nhưng kế thừa thường lại tạo ra 2 nhánh tách rời như hình bên trái.

Kế thừa ảo: giải quyết vấn đề kim cương

Thêm từ khóa virtual khi Sach và FileSo kế thừa TaiLieu, C++ sẽ chỉ tạo một bản TaiLieu dùng chung:

#include <iostream>

class TaiLieu
{
public:
    TaiLieu(int ma) : ma_(ma) { std::cout << "  Khoi tao TaiLieu, ma = " << ma << '\n'; }
    int ma() const { return ma_; }

private:
    int ma_;
};

class Sach : virtual public TaiLieu
{
public:
    Sach(int ma) : TaiLieu(ma) {}
};

class FileSo : virtual public TaiLieu
{
public:
    FileSo(int ma) : TaiLieu(ma) {}
};

class SachDienTu : public Sach, public FileSo
{
public:
    SachDienTu(int ma) : TaiLieu(ma), Sach(ma), FileSo(ma) {}
};

int main()
{
    std::cout << "Tao SachDienTu:\n";
    SachDienTu ebook(1001);
    std::cout << "ma = " << ebook.ma() << '\n';
    return 0;
}

Kế thừa ảo virtual: TaiLieu chỉ khởi tạo 1 lần, ma bằng 1001

TaiLieu giờ chỉ khởi tạo 1 lần, và ebook.ma() không còn mơ hồ. Điểm đặc biệt: với lớp cơ sở ảo, lớp con cuối cùng (SachDienTu) phải tự gọi hàm khởi tạo TaiLieu(ma). Lời gọi TaiLieu(ma) trong Sach và FileSo bị bỏ qua khi tạo SachDienTu, vì nếu không thì không biết lấy mã của nhánh nào.

Lưu ý: Chữ virtual phải đặt ở các lớp cha trung gian (Sach, FileSo), không phải ở lớp con cuối cùng. Và phải thêm ngay từ khi thiết kế Sach, FileSo: đợi tới lúc viết SachDienTu mới nhận ra thì phải quay lại sửa các class đã có.

Khi nào nên dùng đa kế thừa?

Đa kế thừa mạnh nhưng làm thiết kế phức tạp hơn nhiều. Kinh nghiệm chung trong C++:

  • Nên dùng khi các lớp cha là interface: class chỉ gồm các hàm thuần ảo, không có dữ liệu. Ví dụ SachDienTu kế thừa CoTheTimKiem và CoTheTaiXuong. Không có dữ liệu thì không có vấn đề kim cương. Phần interface có trong bài tính trừu tượng trong C++.
  • Cân nhắc thay bằng thành phần (composition) khi lớp cha có nhiều dữ liệu: SachDienTu kế thừa Sach và chứa một đối tượng FileSo làm thuộc tính. Sách điện tử có một file, cách nói này cũng hợp lý.
  • Tránh cây kế thừa hình kim cương nếu có thể. Thư viện chuẩn C++ chỉ dùng nó ở vài chỗ như std::iostream kế thừa cả std::istream lẫn std::ostream.

Bài tập

  1. Viết lớp ThietBiDien (công suất) và ThietBiMang (địa chỉ IP), rồi lớp CameraIP kế thừa cả 2. In thứ tự gọi hàm khởi tạo và hàm hủy.
  2. Thêm hàm inThongTin() vào cả 2 lớp cha ở bài 1, rồi viết inThongTin() cho CameraIP gọi cả 2.
  3. Cho ThietBiDien và ThietBiMang cùng kế thừa ThietBi (có số serial). In sizeof(CameraIP) khi kế thừa thường và khi kế thừa ảo, giải thích sự khác nhau.
  4. Viết lại SachDienTu theo kiểu thành phần: kế thừa Sach và có thuộc tính kiểu FileSo. So sánh với cách đa kế thừa.

Lời giải sẽ có trong bài tổng hợp bài tập OOP C++ nhé.

Kết

Đa kế thừa trong C++ cho phép một class có đặc điểm của nhiều lớp cha, nhưng đi kèm 2 rắc rối: hàm trùng tên giữa các lớp cha và vấn đề kim cương khi các lớp cha có chung lớp ông. Trùng tên thì tự định nghĩa lại hàm ở lớp con, còn kim cương thì dùng kế thừa ảo virtual ở các lớp trung gian. Quy tắc an toàn nhất: đa kế thừa từ các interface, còn dữ liệu thì ưu tiên thành phần.

Bài tiếp theo chúng ta sẽ học tính chất quan trọng nhất của OOP: tính đa hình trong C++ với hàm ảo. Nếu thấy bài viết có ích, hãy chia sẻ và tham gia hội Nghiện lập trình để cùng thảo luận nhé.

Rate this post

Để lại một bình luận

Email của bạn sẽ không được hiển thị công khai. Các trường bắt buộc được đánh dấu *