Tìm hiểu về Kế thừa trong C++: Khái niệm và Các Vấn đề Nâng cao

1. Khái niệm và Định nghĩa Kế thừa

1.1 Khái niệm Kế thừa

Kế thừa là một trong những cơ chế mạnh mẽ nhất của lập trình hướng đối tượng (OOP) trong C++, cho phép tái sử dụng mã nguồn hiệu quả. Cơ chế này cho phép một lớp mới (gọi là lớp con, lớp dẫn xuất, hay lớp kế thừa) mở rộng các thuộc tính và hành vi của một lớp đã có (gọi là lớp cha, lớp cơ sở). Lớp con không chỉ kế thừa mà còn có thể thêm các tính năng mới hoặc sửa đổi các tính năng hiện có của lớp cha. Kế thừa tạo ra một cấu trúc phân cấp, phản ánh mối quan hệ "là một loại" (is-a) giữa các lớp, giúp đơn giản hóa việc thiết kế các hệ thống phức tạp.

Trong khi tái sử dụng hàm giúp tiết kiệm công sức ở cấp độ hàm, kế thừa cung cấp khả năng tái sử dụng ở cấp độ thiết kế lớp, nơi các thuộc tính và phương thức có thể được chia sẻ giữa các lớp có liên quan.

#include <iostream>
#include <string> // Cần thiết cho std::string

class NhanVien
{
public:
    void HienThiThongTin() const
    {
        std::cout << "Ho ten: " << m_hoTen << std::endl;
        std::cout << "Tuoi: " << m_tuoi << std::endl;
    }

protected:
    std::string m_hoTen = "Tran Van A"; // Tên nhân viên
    int m_tuoi = 30;                     // Tuổi
};

class LapTrinhVien : public NhanVien
{
protected:
    int m_maNhanVien; // Mã lập trình viên
};

class QuanLy : public NhanVien
{
protected:
    int m_maPhongBan; // Mã phòng ban quản lý
};

int main()
{
    LapTrinhVien dev;
    QuanLy manager;

    // Các đối tượng LapTrinhVien và QuanLy tái sử dụng phương thức HienThiThongTin
    // và các thuộc tính m_hoTen, m_tuoi từ lớp NhanVien.
    dev.HienThiThongTin();
    manager.HienThiThongTin();

    return 0;
}

Sau khi kế thừa, tất cả các thành viên (biến thành viên và hàm thành viên) của lớp cha sẽ trở thành một phần của lớp con. Ví dụ trên cho thấy cách LapTrinhVien và QuanLy tái sử dụng các thành viên của NhanVien.

1.2 Định nghĩa Kế thừa

1.2.1 Cú pháp Định nghĩa

Trong ví dụ trên, NhanVien là lớp cơ sở (base class) hay lớp cha. LapTrinhVien và QuanLy là các lớp dẫn xuất (derived class) hay lớp con.

Cú pháp chung để định nghĩa một lớp dẫn xuất là:

class DerivedClassName : access_specifier BaseClassName
{
    // Các thành viên của lớp dẫn xuất
};

access_specifier (chỉ định quyền truy cập) có thể là public, protected, hoặc private, và nó định nghĩa cách các thành viên của lớp cơ sở sẽ được truy cập trong lớp dẫn xuất.

1.2.2 Mối quan hệ Kế thừa và Chỉ định Quyền truy cập

Quyền truy cập của các thành viên lớp cơ sở trong lớp dẫn xuất phụ thuộc vào hai yếu tố:

  1. Quyền truy cập gốc của thành viên đó trong lớp cơ sở (public, protected, private).
  2. Chỉ định quyền truy cập khi kế thừa (public, protected, private).

1.2.3 Sự thay đổi về Quyền truy cập thành viên lớp cơ sở

Bảng dưới đây minh họa cách quyền truy cập của các thành viên lớp cơ sở thay đổi trong lớp dẫn xuất:

Thành viên lớp cơ sở Kế thừa public Kế thừa protected Kế thừa private
Thành viên public Thành viên public của lớp dẫn xuất Thành viên protected của lớp dẫn xuất Thành viên private của lớp dẫn xuất
Thành viên protected Thành viên protected của lớp dẫn xuất Thành viên protected của lớp dẫn xuất Thành viên private của lớp dẫn xuất
Thành viên private Không thể truy cập trong lớp dẫn xuất

Tóm tắt:

  1. Thành viên private của lớp cơ sở không thể truy cập được trong lớp dẫn xuất, bất kể cách thức kế thừa. Tuy nhiên, chúng vẫn tồn tại trong đối tượng lớp dẫn xuất.
  2. Thành viên protected chỉ xuất hiện do kế thừa. Nó cho phép các thành viên được truy cập bởi lớp dẫn xuất nhưng không thể truy cập trực tiếp từ bên ngoài lớp.
  3. Một quy tắc đơn giản để nhớ: Quyền truy cập của thành viên lớp cơ sở trong lớp dẫn xuất sẽ là min(quyền_gốc_của_thành_viên, quyền_kế_thừa), với thứ tự ưu tiên: public > protected > private.
  4. Khi sử dụng từ khóa class để định nghĩa lớp, kiểu kế thừa mặc định là private. Khi sử dụng struct, kiểu kế thừa mặc định là public. Tuy nhiên, luôn khuyến khích chỉ định rõ ràng kiểu kế thừa.
  5. Trong thực tế, kiểu kế thừa public được sử dụng phổ biến nhất. Kế thừa protected hoặc private ít được sử dụng và không được khuyến khích vì nó giới hạn đáng kể khả năng mở rộng và bảo trì của lớp dẫn xuất.

2. Chuyển đổi giữa Đối tượng Lớp cơ sở và Lớp dẫn xuất

Một đối tượng của lớp dẫn xuất có thể được gán cho một đối tượng lớp cơ sở, một con trỏ lớp cơ sở, hoặc một tham chiếu lớp cơ sở. Hiện tượng này thường được gọi là "cắt lát" (slicing), vì chỉ phần của đối tượng lớp dẫn xuất tương ứng với lớp cơ sở được sao chép hoặc tham chiếu.

Ngược lại, một đối tượng lớp cơ sở không thể được gán cho một đối tượng lớp dẫn xuất một cách trực tiếp.

Con trỏ hoặc tham chiếu của lớp cơ sở có thể được chuyển đổi kiểu tường minh thành con trỏ hoặc tham chiếu của lớp dẫn xuất. Tuy nhiên, việc này chỉ an toàn khi con trỏ/tham chiếu lớp cơ sở thực sự trỏ đến một đối tượng lớp dẫn xuất. Đối với các lớp đa hình, có thể sử dụng dynamic_cast (thông qua RTTI - RunTime Type Information) để chuyển đổi an toàn và kiểm tra kiểu tại thời gian chạy.

#include <iostream>
#include <string>

class KhoaHoc
{
protected:
    std::string m_tenKhoa; // Tên khóa học
    int m_thoiLuong;       // Thời lượng (giờ)
};

class KhoaHocLapTrinh : public KhoaHoc
{
public:
    int m_maMonHoc; // Mã môn học
};

void KiemTraChuyenDoi()
{
    KhoaHocLapTrinh khoaLapTrinh;
    khoaLapTrinh.m_tenKhoa = "C++ Co Ban";
    khoaLapTrinh.m_thoiLuong = 60;
    khoaLapTrinh.m_maMonHoc = 101;

    // 1. Đối tượng lớp con có thể gán cho đối tượng/con trỏ/tham chiếu lớp cha
    KhoaHoc khoaChung = khoaLapTrinh; // Slicing: chỉ phần KhoaHoc của khoaLapTrinh được gán
    KhoaHoc* ptrKhoa = &khoaLapTrinh;
    KhoaHoc& refKhoa = khoaLapTrinh;

    std::cout << "Ten khoa chung: " << khoaChung.m_tenKhoa << std::endl;
    // std::cout << "Ma mon hoc (qua khoaChung): " << khoaChung.m_maMonHoc << std::endl; // Lỗi biên dịch, m_maMonHoc không có trong KhoaHoc

    // 2. Đối tượng lớp cha không thể gán cho đối tượng lớp con
    // khoaLapTrinh = khoaChung; // Lỗi biên dịch

    // 3. Con trỏ lớp cha có thể chuyển đổi tường minh thành con trỏ lớp con
    ptrKhoa = &khoaLapTrinh;
    KhoaHocLapTrinh* ptrLapTrinh1 = static_cast<KhoaHocLapTrinh*>(ptrKhoa); // An toàn
    std::cout << "Ma mon hoc (qua ptrLapTrinh1): " << ptrLapTrinh1->m_maMonHoc << std::endl;

    KhoaHoc khoaKhac;
    khoaKhac.m_tenKhoa = "Toan Hoc";
    ptrKhoa = &khoaKhac;
    KhoaHocLapTrinh* ptrLapTrinh2 = static_cast<KhoaHocLapTrinh*>(ptrKhoa); // Không an toàn, có thể gây truy cập ngoài vùng nhớ hợp lệ
    // ptrLapTrinh2->m_maMonHoc = 202; // Có thể gây lỗi runtime nếu không cẩn thận
}

int main() {
    KiemTraChuyenDoi();
    return 0;
}

3. Phạm vi trong Kế thừa

  1. Trong một hệ thống kế thừa, lớp cơ sở và lớp dẫn xuất đều có phạm vi riêng biệt của chúng.
  2. Nếu một lớp dẫn xuất định nghĩa một thành viên có cùng tên với một thành viên trong lớp cơ sở, thành viên trong lớp dẫn xuất sẽ "che giấu" (hide) hoặc "tái định nghĩa" (redefine) việc truy cập trực tiếp đến thành viên cùng tên của lớp cơ sở. Để truy cập thành viên bị che giấu của lớp cơ sở, cần sử dụng toán tử định nghĩa phạm vi (BaseClass::member).
  3. Đối với hàm thành viên bị che giấu, chỉ cần tên hàm giống nhau là đủ để tạo ra sự che giấu, không cần chữ ký hàm (signature) phải giống nhau như trong quá tải (overload). Điều này có nghĩa là một hàm thành viên trong lớp dẫn xuất sẽ che giấu tất cả các phiên bản hàm cùng tên trong lớp cơ sở, ngay cả khi chúng có các tham số khác nhau.
  4. Trong thực tế, tốt nhất là tránh định nghĩa các thành viên có cùng tên trong hệ thống kế thừa để tránh nhầm lẫn và lỗi khó phát hiện.
#include <iostream>
#include <string>

class TaiKhoan
{
protected:
    std::string m_tenChuTaiKhoan = "Nguyen Van B"; // Tên chủ tài khoản
    int m_soDu = 1000;                            // Số dư mặc định
};

class TaiKhoanTietKiem : public TaiKhoan
{
public:
    void InThongTin() const
    {
        std::cout << "Ten chu tai khoan: " << m_tenChuTaiKhoan << std::endl;
        std::cout << "So du tai khoan goc: " << TaiKhoan::m_soDu << std::endl; // Truy cập tường minh
        std::cout << "So du tiet kiem: " << m_soDu << std::endl;               // Truy cập thành viên lớp con
    }

protected:
    int m_soDu = 5000; // Số dư tiết kiệm (che giấu m_soDu của TaiKhoan)
};

int main()
{
    TaiKhoanTietKiem tk;
    tk.InThongTin();
    return 0;
}
#include <iostream>

class BaseOp
{
public:
    void ThucHien()
    {
        std::cout << "ThucHien() tu BaseOp" << std::endl;
    }
    void ThucHien(int val)
    {
        std::cout << "ThucHien(int " << val << ") tu BaseOp" << std::endl;
    }
};

class DerivedOp : public BaseOp
{
public:
    // Hàm ThucHien này che giấu TẤT CẢ các phiên bản ThucHien() của BaseOp
    void ThucHien(double val)
    {
        BaseOp::ThucHien(); // Có thể gọi tường minh phiên bản của lớp cha
        std::cout << "ThucHien(double " << val << ") tu DerivedOp" << std::endl;
    }
};

int main()
{
    DerivedOp obj;
    obj.ThucHien(12.34); // Gọi ThucHien(double) của DerivedOp
    // obj.ThucHien();    // Lỗi biên dịch: ThucHien() của BaseOp bị che giấu
    // obj.ThucHien(5);   // Lỗi biên dịch: ThucHien(int) của BaseOp bị che giấu
    obj.BaseOp::ThucHien(5); // Có thể gọi tường minh phiên bản của lớp cha

    return 0;
}

4. Các Hàm thành viên Mặc định của Lớp dẫn xuất

C++ tự động tạo ra sáu hàm thành viên mặc định (constructor, copy constructor, assignment operator, destructor, address-of operator, const address-of operator) nếu chúng ta không định nghĩa chúng. Khi một lớp kế thừa từ một lớp khác, cách các hàm thành viên mặc định này được tạo ra sẽ có những điểm đặc biệt:

  1. Constructor: Constructor của lớp dẫn xuất phải gọi constructor của lớp cơ sở để khởi tạo phần lớp cơ sở của đối tượng. Nếu lớp cơ sở không có constructor mặc định (constructor không tham số hoặc constructor có tất cả các tham số đều có giá trị mặc định), lớp dẫn xuất phải gọi constructor của lớp cơ sở một cách tường minh trong danh sách khởi tạo (initializer list).
  2. Copy Constructor: Copy constructor của lớp dẫn xuất phải gọi copy constructor của lớp cơ sở để sao chép phần lớp cơ sở của đối tượng. Nếu không được gọi tường minh, trình biên dịch sẽ cố gắng gọi copy constructor mặc định của lớp cơ sở.
  3. Assignment Operator (operator=): Toán tử gán của lớp dẫn xuất phải gọi toán tử gán của lớp cơ sở để sao chép phần lớp cơ sở. Nếu không, phần lớp cơ sở sẽ không được gán đúng cách.
  4. Destructor: Destructor của lớp dẫn xuất sẽ tự động gọi destructor của lớp cơ sở sau khi hoàn thành việc giải phóng tài nguyên của riêng nó. Thứ tự này (lớp dẫn xuất trước, lớp cơ sở sau) đảm bảo rằng các tài nguyên cụ thể của lớp con được giải phóng trước khi các tài nguyên chung của lớp cha được xử lý.
  5. Thứ tự khởi tạo: Khi một đối tượng lớp dẫn xuất được tạo, constructor của lớp cơ sở được gọi trước, sau đó mới đến constructor của lớp dẫn xuất.
  6. Thứ tự hủy: Khi một đối tượng lớp dẫn xuất bị hủy, destructor của lớp dẫn xuất được gọi trước, sau đó mới đến destructor của lớp cơ sở.
  7. Nếu destructor của lớp cơ sở không được khai báo là virtual, destructor của lớp dẫn xuất và lớp cơ sở sẽ tạo thành mối quan hệ che giấu (name hiding), thay vì đa hình. Để đảm bảo hành vi đa hình đúng đắn khi hủy đối tượng qua con trỏ lớp cơ sở, destructor của lớp cơ sở cần được khai báo là virtual.
#include <iostream>
#include <string>

class DoiTuongChung
{
public:
    DoiTuongChung(const std::string& ten = "Khong ten")
        : m_ten(ten)
    {
        std::cout << "Constructor DoiTuongChung(" << m_ten << ")" << std::endl;
    }

    DoiTuongChung(const DoiTuongChung& other)
        : m_ten(other.m_ten)
    {
        std::cout << "Copy Constructor DoiTuongChung(" << m_ten << ")" << std::endl;
    }

    DoiTuongChung& operator=(const DoiTuongChung& other)
    {
        std::cout << "Assignment Operator DoiTuongChung(" << other.m_ten << ")" << std::endl;
        if (this != &other)
        {
            m_ten = other.m_ten;
        }
        return *this;
    }

    ~DoiTuongChung()
    {
        std::cout << "Destructor DoiTuongChung(" << m_ten << ")" << std::endl;
    }

protected:
    std::string m_ten;
};

class DoiTuongRieng : public DoiTuongChung
{
public:
    DoiTuongRieng(const std::string& ten, int id)
        : DoiTuongChung(ten) // Gọi tường minh constructor lớp cơ sở
        , m_id(id)
    {
        std::cout << "Constructor DoiTuongRieng(" << m_ten << ", " << m_id << ")" << std::endl;
    }

    DoiTuongRieng(const DoiTuongRieng& other)
        : DoiTuongChung(other) // Gọi tường minh copy constructor lớp cơ sở
        , m_id(other.m_id)
    {
        std::cout << "Copy Constructor DoiTuongRieng(" << m_ten << ", " << m_id << ")" << std::endl;
    }

    DoiTuongRieng& operator=(const DoiTuongRieng& other)
    {
        std::cout << "Assignment Operator DoiTuongRieng(" << other.m_ten << ", " << other.m_id << ")" << std::endl;
        if (this != &other)
        {
            DoiTuongChung::operator=(other); // Gọi tường minh assignment operator lớp cơ sở
            m_id = other.m_id;
        }
        return *this;
    }

    ~DoiTuongRieng()
    {
        std::cout << "Destructor DoiTuongRieng(" << m_ten << ", " << m_id << ")" << std::endl;
    }

protected:
    int m_id;
};

int main()
{
    std::cout << "--- Khoi tao s1 ---" << std::endl;
    DoiTuongRieng s1("DTR_A", 101);

    std::cout << "\n--- Khoi tao s2 (sao chep s1) ---" << std::endl;
    DoiTuongRieng s2(s1);

    std::cout << "\n--- Khoi tao s3 ---" << std::endl;
    DoiTuongRieng s3("DTR_B", 102);

    std::cout << "\n--- Gan s1 = s3 ---" << std::endl;
    s1 = s3;

    std::cout << "\n--- Ket thuc main ---" << std::endl;
    return 0;
}

5. Kế thừa và Friend

Quan hệ friend (bạn bè) trong C++ không được kế thừa. Điều này có nghĩa là nếu một lớp cha có một hàm hoặc lớp bạn, thì hàm hoặc lớp đó không tự động trở thành bạn của lớp dẫn xuất. Do đó, nó không thể truy cập các thành viên private hoặc protected của lớp dẫn xuất.

#include <iostream>
#include <string>

class SinhVien; // Khai báo trước để hàm HienThiThongTin có thể tham chiếu

class NguoiDung
{
public:
    friend void HienThiThongTinChung(const NguoiDung& user, const SinhVien& sv);
protected:
    std::string m_tenNguoiDung = "Hoang"; // Tên người dùng
};

class SinhVien : public NguoiDung
{
protected:
    int m_maSoSinhVien = 12345; // Mã số sinh viên
};

void HienThiThongTinChung(const NguoiDung& user, const SinhVien& sv)
{
    std::cout << "Ten nguoi dung (tu NguoiDung): " << user.m_tenNguoiDung << std::endl;
    // std::cout << "Ma so sinh vien (tu SinhVien): " << sv.m_maSoSinhVien << std::endl; // Lỗi biên dịch: friend cua NguoiDung, khong phai SinhVien
}

int main()
{
    NguoiDung n;
    SinhVien s;
    HienThiThongTinChung(n, s);
    return 0;
}

6. Kế thừa và Thành viên Tĩnh

Khi một lớp cơ sở định nghĩa một thành viên static, thì trong toàn bộ hệ thống kế thừa, chỉ có một thể hiện duy nhất của thành viên static đó. Bất kể có bao nhiêu lớp con được dẫn xuất, tất cả chúng đều chia sẻ cùng một thành viên static từ lớp cơ sở.

#include <iostream>
#include <string>

class ThietBi
{
public:
    ThietBi() { ++s_tongSoThietBi; } // Tăng biến tĩnh khi một đối tượng được tạo
protected:
    std::string m_tenModel;
public:
    static int s_tongSoThietBi; // Biến tĩnh để đếm tổng số thiết bị
};

int ThietBi::s_tongSoThietBi = 0; // Khởi tạo biến tĩnh bên ngoài lớp

class DienThoai : public ThietBi
{
protected:
    int m_soSim;
};

class MayTinhBang : public DienThoai
{
protected:
    std::string m_kichThuocManHinh;
};

int main()
{
    DienThoai dt1;
    DienThoai dt2;
    DienThoai dt3;
    MayTinhBang mtb1;

    // Tất cả các đối tượng đều chia sẻ cùng một biến tĩnh s_tongSoThietBi
    std::cout << "Tong so thiet bi hien tai: " << ThietBi::s_tongSoThietBi << std::endl;
    
    // Có thể truy cập qua bất kỳ lớp dẫn xuất nào
    DienThoai::s_tongSoThietBi = 10; 
    std::cout << "Tong so thiet bi sau khi thay doi: " << ThietBi::s_tongSoThietBi << std::endl;

    return 0;
}

7. Kế thừa Hình kim cương phức tạp và Kế thừa ảo

Kế thừa đơn: Một lớp con chỉ có một lớp cha trực tiếp.

Đa kế thừa: Một lớp con có hai hoặc nhiều lớp cha trực tiếp.

Kế thừa hình kim cương (Diamond Inheritance): Là một trường hợp đặc biệt của đa kế thừa, nơi một lớp dẫn xuất kế thừa từ hai lớp khác, và hai lớp này lại cùng kế thừa từ một lớp cơ sở chung.

Vấn đề của Kế thừa hình kim cương:

Khi sử dụng kế thừa hình kim cương thông thường (non-virtual inheritance), đối tượng của lớp cơ sở chung sẽ tồn tại hai lần trong đối tượng của lớp dẫn xuất cuối cùng. Điều này dẫn đến hai vấn đề chính:

  1. Dữ liệu dư thừa: Các thành viên của lớp cơ sở chung bị lặp lại, gây lãng phí bộ nhớ.
  2. Tính lưỡng nghĩa (Ambiguity): Khi truy cập một thành viên của lớp cơ sở chung thông qua đối tượng cuối cùng, trình biên dịch không biết phải truy cập phiên bản nào (từ đường dẫn kế thừa thứ nhất hay thứ hai), dẫn đến lỗi biên dịch.
#include <iostream>
#include <string>

class HinhDang
{
public:
    std::string m_tenHinhDang; // Tên hình dạng
};

class HinhTron : public HinhDang
{
protected:
    int m_banKinh; // Bán kính
};

class HinhVuong : public HinhDang
{
protected:
    int m_canh; // Cạnh
};

class HinhHocPhucHop : public HinhTron, public HinhVuong
{
protected:
    std::string m_moTa; // Mô tả phức hợp
};

int main()
{
    HinhHocPhucHop hh;
    // Lỗi biên dịch: Tính lưỡng nghĩa. Không biết truy cập m_tenHinhDang từ HinhTron hay HinhVuong
    // hh.m_tenHinhDang = "Hinh phuc hop";

    // Để giải quyết lưỡng nghĩa, cần chỉ định tường minh đường dẫn kế thừa:
    hh.HinhTron::m_tenHinhDang = "Hinh Tron phan cua HHPC";
    hh.HinhVuong::m_tenHinhDang = "Hinh Vuong phan cua HHPC";

    std::cout << hh.HinhTron::m_tenHinhDang << std::endl;
    std::cout << hh.HinhVuong::m_tenHinhDang << std::endl;

    // Tuy nhiên, vấn đề dữ liệu dư thừa vẫn còn (m_tenHinhDang tồn tại 2 lần)
    return 0;
}

Kế thừa ảo (Virtual Inheritance):

Kế thừa ảo là một cơ chế của C++ được thiết kế để giải quyết vấn đề dữ liệu dư thừa và tính lưỡng nghĩa trong kế thừa hình kim cương. Khi các lớp trung gian (như HinhTron và HinhVuong trong ví dụ trên) kế thừa từ lớp cơ sở chung (HinhDang) bằng cách sử dụng từ khóa virtual, C++ đảm bảo rằng chỉ có một thể hiện duy nhất của lớp cơ sở chung được nhúng vào đối tượng của lớp dẫn xuất cuối cùng.

Cần lưu ý rằng kế thừa ảo thường chỉ nên được sử dụng trong các tình huống kế thừa hình kim cương để tránh các phức tạp không cần thiết.

#include <iostream>
#include <string>

class HinhDangChung
{
public:
    std::string m_maDinhDanh; // Mã định danh chung cho hình dạng
};

// Sử dụng virtual public khi kế thừa từ HinhDangChung
class HinhTronVT : virtual public HinhDangChung
{
protected:
    int m_banKinhVT;
};

// Sử dụng virtual public khi kế thừa từ HinhDangChung
class HinhVuongVT : virtual public HinhDangChung
{
protected:
    int m_canhVT;
};

class HinhHocTongHop : public HinhTronVT, public HinhVuongVT
{
protected:
    std::string m_loaiTongHop;
};

int main()
{
    HinhHocTongHop hht;
    // Không còn lưỡng nghĩa, chỉ có một phiên bản của HinhDangChung::m_maDinhDanh
    hht.m_maDinhDanh = "HT_HV_TongHop_001";
    hht.m_banKinhVT = 5;
    hht.m_canhVT = 10;

    std::cout << "Ma dinh danh: " << hht.m_maDinhDanh << std::endl;
    // Để chứng minh rằng đó là cùng một thành viên
    hht.HinhTronVT::m_maDinhDanh = "Cap nhat qua HinhTronVT";
    std::cout << "Ma dinh danh sau cap nhat: " << hht.HinhVuongVT::m_maDinhDanh << std::endl; // Giá trị đã thay đổi

    return 0;
}

Nguyên lý hoạt động của Kế thừa ảo:

Để hiểu cách kế thừa ảo giải quyết vấn đề dữ liệu dư thừa và lưỡng nghĩa, hãy xem xét cấu trúc bộ nhớ của đối tượng:

#include <iostream>

class CoSo
{
public:
    int m_giaTriA;
};

// class TrungGianB : public CoSo
class TrungGianB : virtual public CoSo
{
public:
    int m_giaTriB;
};

// class TrungGianC : public CoSo
class TrungGianC : virtual public CoSo
{
public:
    int m_giaTriC;
};

class DanXuatCuoiCung : public TrungGianB, public TrungGianC
{
public:
    int m_giaTriD;
};

int main()
{
    DanXuatCuoiCung objD;
    objD.TrungGianB::m_giaTriA = 10;
    objD.TrungGianC::m_giaTriA = 20; // Nếu không ảo, đây là hai biến khác nhau

    objD.m_giaTriB = 30;
    objD.m_giaTriC = 40;
    objD.m_giaTriD = 50;
    
    // Với kế thừa ảo:
    // objD.m_giaTriA = 100; // Truy cập trực tiếp mà không cần chỉ định, vì chỉ có một m_giaTriA

    std::cout << "Gia tri A qua B: " << objD.TrungGianB::m_giaTriA << std::endl;
    std::cout << "Gia tri A qua C: " << objD.TrungGianC::m_giaTriA << std::endl;
    std::cout << "Gia tri B: " << objD.m_giaTriB << std::endl;
    std::cout << "Gia tri C: " << objD.m_giaTriC << std::endl;
    std::cout << "Gia tri D: " << objD.m_giaTriD << std::endl;

    return 0;
}

Trong kế thừa hình kim cương thông thường, đối tượng DanXuatCuoiCung sẽ chứa hai bản sao của CoSo (một từ TrungGianB, một từ TrungGianC), dẫn đến hai biến m_giaTriA khác nhau.

Với kế thừa ảo, trình biên dịch C++ sẽ sắp xếp đối tượng DanXuatCuoiCung sao cho phần của lớp CoSo được đặt ở một vị trí chung (thường là ở cuối đối tượng). Để các lớp TrungGianB và TrungGianC có thể tìm thấy phần CoSo chung này, trình biên dịch sẽ thêm vào mỗi đối tượng TrungGianB và TrungGianC một con trỏ đặc biệt (thường gọi là virtual base pointer - vfptr) trỏ đến một bảng (virtual base table - vbptr). Bảng này chứa các offset (độ lệch) cho phép xác định vị trí của lớp cơ sở ảo CoSo trong đối tượng DanXuatCuoiCung. Bằng cách này, dù có nhiều đường dẫn kế thừa, tất cả chúng đều tham chiếu đến cùng một thể hiện của lớp cơ sở ảo, loại bỏ vấn đề dữ liệu dư thừa và lưỡng nghĩa.

8. Tóm tắt và Suy nghĩ về Kế thừa

  1. Kế thừa, đặc biệt là đa kế thừa, thường được coi là một trong những khía cạnh phức tạp của C++. Đa kế thừa có thể dẫn đến kế thừa hình kim cương, làm tăng đáng kể sự phức tạp trong triển khai cấp thấp và có thể ảnh hưởng đến hiệu suất. Vì vậy, việc thiết kế sử dụng đa kế thừa hoặc kế thừa hình kim cương thường không được khuyến khích.
  2. Nhiều ngôn ngữ hướng đối tượng ra đời sau C++ (như Java) đã loại bỏ đa kế thừa hoàn toàn để tránh những phức tạp này.
  3. Kế thừa và Kết hợp (Composition):
    • Kế thừa public thể hiện mối quan hệ "là một loại" (is-a). Ví dụ, một SinhVien là một loại NguoiDung.
    • Kết hợp thể hiện mối quan hệ "có một" (has-a). Ví dụ, một XeHoi có một DongCo.

    Ưu tiên sử dụng kết hợp đối tượng hơn là kế thừa lớp.

    Kế thừa cho phép bạn định nghĩa triển khai của lớp dẫn xuất dựa trên triển khai của lớp cơ sở. Kiểu tái sử dụng này thường được gọi là tái sử dụng "hộp trắng" (white-box reuse). Thuật ngữ "hộp trắng" ám chỉ tính nhìn thấy: các chi tiết nội bộ của lớp cơ sở có thể nhìn thấy được đối với lớp con. Kế thừa ở một mức độ nào đó phá vỡ tính đóng gói của lớp cơ sở; sự thay đổi trong lớp cơ sở có thể ảnh hưởng lớn đến lớp dẫn xuất. Mối quan hệ phụ thuộc giữa lớp dẫn xuất và lớp cơ sở rất mạnh, dẫn đến khớp nối cao.

    Kết hợp đối tượng là một lựa chọn tái sử dụng khác ngoài kế thừa. Các chức năng mới, phức tạp hơn có thể được tạo ra bằng cách lắp ghép hoặc kết hợp các đối tượng. Kết hợp yêu cầu các đối tượng được kết hợp phải có giao diện được định nghĩa rõ ràng. Phong cách tái sử dụng này được gọi là tái sử dụng "hộp đen" (black-box reuse), bởi vì các chi tiết nội bộ của đối tượng không thể nhìn thấy được. Đối tượng chỉ xuất hiện dưới dạng "hộp đen". Các lớp được kết hợp có mối quan hệ phụ thuộc yếu hơn, khớp nối thấp. Ưu tiên sử dụng kết hợp đối tượng giúp giữ cho mỗi lớp được đóng gói tốt.

    Trong thực tế, nên ưu tiên sử dụng kết hợp khi có thể. Kết hợp mang lại khớp nối thấp và dễ bảo trì hơn. Tuy nhiên, kế thừa vẫn có vai trò quan trọng; một số mối quan hệ tự nhiên phù hợp với kế thừa, và để đạt được đa hình trong C++, kế thừa là bắt buộc. Khi một mối quan hệ có thể được mô hình hóa bằng cả kế thừa và kết hợp, hãy chọn kết hợp.

#include <iostream>
#include <string>

// Ví dụ về Kế thừa (is-a relationship)
class PhuongTien
{
protected:
    std::string m_mauSac = "Trang";  // Màu sắc chung
    std::string m_bienSo = "51A-123.45"; // Biển số chung
};

class XeMay : public PhuongTien
{
public:
    void LaiXe() { std::cout << "Di chuyen nhanh chong va linh hoat." << std::endl; }
};

class OTo : public PhuongTien
{
public:
    void LaiXe() { std::cout << "Di chuyen thoai mai va an toan." << std::endl; }
};

// Ví dụ về Kết hợp (has-a relationship)
class BanhXe
{
protected:
    std::string m_hangSX = "Michelin";  // Hãng sản xuất
    size_t m_kichThuoc = 17;          // Kích thước inch
};

class OToHienDai
{
protected:
    std::string m_mauSac = "Den";
    std::string m_bienSo = "30F-567.89";
    BanhXe m_bonBanh[4]; // Một chiếc ô tô có 4 bánh xe (kết hợp)
};

int main()
{
    XeMay yamaha;
    yamaha.LaiXe();
    OTo mercedes;
    mercedes.LaiXe();

    OToHienDai myCar;
    // myCar.m_bonBanh[0].m_hangSX = "Pirelli"; // không thể truy cập trực tiếp do protected

    return 0;
}

Thẻ: C++ Kế thừa Đa hình Lập trình hướng đối tượng Name Hiding

Đăng vào ngày 14 tháng 11 lúc 04:05