Bài 36.1: Khái niệm và sử dụng kế thừa trong C++

Chào mừng các bạn quay trở lại với series blog về C++ của FullhouseDev! Sau khi đã làm quen với các khái niệm cơ bản về lớp (class), đóng gói (encapsulation) và trừu tượng (abstraction), hôm nay chúng ta sẽ cùng khám phá một trụ cột tiếp theo và vô cùng mạnh mẽ của Lập trình hướng đối tượng (OOP): Kế thừa (Inheritance).

Hãy tưởng tượng bạn đang xây dựng một hệ thống quản lý các loại phương tiện giao thông. Bạn có thể có các lớp riêng cho ô tô, xe máy, xe tải... Tất cả chúng đều có những đặc điểm chung như tốc độ, số bánh xe, hãng sản xuất, và các hành động chung như tăng tốc, phanh. Thay vì viết lại những đặc điểm và hành động chung này cho từng loại phương tiện, sẽ thật tuyệt nếu chúng ta có thể định nghĩa chúng ở một nơi và cho phép các loại phương tiện cụ thể thừa hưởng lại. Đó chính xác là vai trò của kế thừa!

Kế thừa trong C++ cho phép chúng ta tạo ra một lớp mới (lớp dẫn xuất - derived class) dựa trên một lớp đã có (lớp cơ sở - base class). Về cơ bản, lớp dẫn xuất sẽ thừa hưởng các thành viên (thuộc tính và phương thức) của lớp cơ sở và có thể thêm các thành viên mới hoặc tùy chỉnh lại hành vi của các thành viên được thừa hưởng. Điều này giúp ích rất lớn trong việc:

  1. Tái sử dụng mã nguồn: Không cần viết lại code cho các đặc điểm chung.
  2. Tạo ra mối quan hệ: Mô hình hóa mối quan hệ 'là một loại' (is-a relationship) trong thế giới thực (Ví dụ: Ô tô là một loại Phương tiện, Chó là một loại Động vật).
  3. Mở rộng và bảo trì: Dễ dàng thêm các loại mới hoặc thay đổi hành vi chung bằng cách chỉ sửa đổi lớp cơ sở.

1. Khái niệm cơ bản: Lớp cơ sở và Lớp dẫn xuất

  • Lớp cơ sở (Base Class): Là lớp ban đầu mà từ đó các lớp khác sẽ thừa hưởng. Nó chứa các đặc điểm và hành vi chung.
  • Lớp dẫn xuất (Derived Class): Là lớp được tạo ra bằng cách thừa hưởng từ lớp cơ sở. Nó thừa hưởng các thành viên của lớp cơ sở và có thể có các thành viên riêng của mình.

Mối quan hệ giữa lớp dẫn xuất và lớp cơ sở thường được gọi là mối quan hệ "là một loại" (is-a). Ví dụ: class Dog : public Animal nghĩa là Dog là một loại Animal.

2. Cú pháp Kế thừa

Cú pháp cơ bản để khai báo một lớp dẫn xuất như sau:

class TenLopDanXuat : CheDoTruyCap TenLopCoSo {
    // Các thành viên riêng của lớp dẫn xuất
};

Trong đó:

  • TenLopDanXuat: Tên của lớp bạn đang tạo (lớp con).
  • CheDoTruyCap: Chỉ định cách các thành viên của lớp cơ sở được truy cập trong lớp dẫn xuất và từ bên ngoài lớp dẫn xuất. Có ba chế độ chính: public, protected, và private.
  • TenLopCoSo: Tên của lớp mà bạn muốn thừa hưởng (lớp cha).

Chúng ta sẽ đi sâu vào các chế độ truy cập ngay bây giờ.

3. Các Chế độ Truy cập (Access Specifiers) trong Kế thừa

Đây là phần quan trọng để hiểu cách các thành viên của lớp cơ sở hoạt động trong lớp dẫn xuất. Chế độ truy cập khi kế thừa (public, protected, private) xác định mức độ truy cập của các thành viên thừa hưởng trong lớp dẫn xuất và đối với các đối tượng của lớp dẫn xuất.

Giả sử lớp cơ sở Base có các thành viên ở ba cấp độ truy cập: public, protected, và private.

Thành viên của Base Kế thừa public bởi Derived Kế thừa protected bởi Derived Kế thừa private bởi Derived
public member Trở thành public Trở thành protected Trở thành private
protected member Trở thành protected Trở thành protected Trở thành private
private member Không thể truy cập trực tiếp Không thể truy cập trực tiếp Không thể truy cập trực tiếp

Bây giờ, hãy xem xét ý nghĩa của từng chế độ kế thừa:

3.1. Kế thừa public

Đây là chế độ kế thừa phổ biến nhất, mô hình hóa mối quan hệ "is-a" một cách tự nhiên.

  • Các thành viên public của lớp cơ sở vẫn là public trong lớp dẫn xuất.
  • Các thành viên protected của lớp cơ sở vẫn là protected trong lớp dẫn xuất.
  • Các thành viên private của lớp cơ sở không thể được truy cập trực tiếp bởi các phương thức của lớp dẫn xuất hoặc từ bên ngoài đối tượng của lớp dẫn xuất.

Ví dụ minh họa Kế thừa public:

#include <iostream>
#include <string>

class DongVat {
public:
    string ten;

    DongVat(const string& t) : ten(t) {
        cout << "Tao DongVat: " << ten << "\n";
    }
    ~DongVat() {
        cout << "Huy DongVat: " << ten << "\n";
    }

    void an() const {
        cout << ten << " dang an.\n";
    }

protected:
    int tuoi;

    void datTuoi(int t) {
        tuoi = t;
    }

private:
    string nguonGoc;

    void datNguonGoc(const string& ng) {
        nguonGoc = ng;
    }

public:
    void hienNguonGoc(const string& ng) {
        datNguonGoc(ng);
        cout << "Nguon goc cua " << ten << " da duoc cai dat.\n";
    }

    void hienTuoi(int t) {
        datTuoi(t);
        cout << ten << " " << tuoi << " tuoi.\n";
    }
};

class Cho : public DongVat {
public:
    string giong;

    Cho(const string& t, const string& g) : DongVat(t), giong(g) {
        cout << "Tao Cho: " << ten << ", giong: " << giong << "\n";
    }
    ~Cho() {
        cout << "Huy Cho: " << ten << "\n";
    }

    void sua() const {
        cout << ten << " dang sua!\n";
    }

    void hienThongTinCho() const {
        cout << "--- Thong tin Cho ---\n";
        cout << "Ten: " << ten << "\n";
        cout << "Giong: " << giong << "\n";
        cout << "---------------------\n";
    }
};

int main() {
    Cho choToi("Buddy", "Golden Retriever");

    choToi.an();
    choToi.sua();
    cout << "Ten tu main: " << choToi.ten << "\n";

    choToi.hienTuoi(3);
    choToi.hienNguonGoc("Nha");

    cout << "\nDoi tuong sap ra khoi pham vi.\n";
    return 0;
}

Output:

Tao DongVat: Buddy
Tao Cho: Buddy, giong: Golden Retriever
Buddy dang an.
Buddy dang sua!
Ten tu main: Buddy
Buddy 3 tuoi.
Nguon goc cua Buddy da duoc cai dat.

Doi tuong sap ra khoi pham vi.
Huy Cho: Buddy
Huy DongVat: Buddy

Giải thích code:

  • Lớp Animal có một thành viên public (name), một protected (age), và một private (origin). Nó cũng có các phương thức tương ứng.
  • Lớp Dog kế thừa public từ Animal. Điều này có nghĩa là:
    • name (từ Animal) vẫn là public trong Dog. Bạn có thể truy cập myDog.name từ main().
    • eat() (từ Animal) vẫn là public trong Dog. Bạn có thể gọi myDog.eat() từ main().
    • age (từ Animal) trở thành protected trong Dog. Bạn có thể truy cập age bên trong các phương thức của lớp Dog (như trong displayDogInfo, mặc dù đã comment), nhưng không thể truy cập trực tiếp myDog.age từ main().
    • setAge() (từ Animal) trở thành protected trong Dog. Bạn có thể gọi setAge() bên trong các phương thức của lớp Dog, nhưng không thể gọi myDog.setAge() từ main().
    • originsetOrigin() (từ Animal) vẫn là private trong Animalkhông thể truy cập trực tiếp bởi các phương thức của Dog hoặc từ main(). Tuy nhiên, các phương thức public của Animal như displayOrigin có thể được gọi từ Dog hoặc main (nếu được kế thừa là public), và chúng có thể truy cập các thành viên private của chính lớp Animal đó.
3.2. Kế thừa protected

Ít phổ biến hơn public inheritance. Khi kế thừa protected:

  • Các thành viên public của lớp cơ sở trở thành protected trong lớp dẫn xuất.
  • Các thành viên protected của lớp cơ sở vẫn là protected trong lớp dẫn xuất.
  • Các thành viên private của lớp cơ sở không thể truy cập trực tiếp.

Điều này có nghĩa là các thành viên publicprotected của lớp cơ sở sẽ chỉ có thể được truy cập bởi các phương thức của lớp dẫn xuất và các lớp kế thừa từ lớp dẫn xuất này (các lớp "cháu"), chứ không thể truy cập từ bên ngoài thông qua đối tượng của lớp dẫn xuất.

Ví dụ minh họa Kế thừa protected:

#include <iostream>

class CoSo {
public:
    void phuongThucCong() {
        cout << "CoSo::phuongThucCong()\n";
    }
protected:
    void phuongThucBaoVe() {
        cout << "CoSo::phuongThucBaoVe()\n";
    }
private:
    void phuongThucRieng() {
        cout << "CoSo::phuongThucRieng()\n";
    }
};

class DanXuatBaoVe : protected CoSo {
public:
    void truyCapPhuongThucCoSo() {
        phuongThucCong();
        phuongThucBaoVe();
    }
};

int main() {
    DanXuatBaoVe d;
    d.truyCapPhuongThucCoSo();
    return 0;
}

Output:

CoSo::phuongThucCong()
CoSo::phuongThucBaoVe()

Giải thích code:

  • Lớp Base có các phương thức ở ba cấp độ truy cập.
  • Lớp ProtectedDerived kế thừa protected từ Base.
  • Phương thức publicBaseMethod() của Base trở thành protected trong ProtectedDerived. Do đó, nó có thể được gọi bên trong phương thức accessBaseMethods của ProtectedDerived, nhưng không thể gọi trực tiếp từ main() qua đối tượng d.
  • Phương thức protectedBaseMethod() của Base vẫn là protected trong ProtectedDerived và cũng chỉ có thể gọi bên trong ProtectedDerived.
  • Phương thức privateBaseMethod() của Base vẫn là private và không thể truy cập ở bất kỳ đâu ngoài Base.
3.3. Kế thừa private

Đây là chế độ kế thừa hạn chế nhất, thường được dùng để mô hình hóa mối quan hệ "implemented-in-terms-of" hơn là "is-a".

  • Các thành viên public của lớp cơ sở trở thành private trong lớp dẫn xuất.
  • Các thành viên protected của lớp cơ sở trở thành private trong lớp dẫn xuất.
  • Các thành viên private của lớp cơ sở không thể truy cập trực tiếp.

Khi kế thừa private, lớp dẫn xuất có thể sử dụng các thành viên publicprotected của lớp cơ sở bên trong các phương thức của nó, nhưng từ bên ngoài, đối tượng của lớp dẫn xuất không thể truy cập bất kỳ thành viên nào của lớp cơ sở (vì tất cả đều đã trở thành private trong lớp dẫn xuất).

Ví dụ minh họa Kế thừa private:

#include <iostream>

class NguonDien {
public:
    void bat() { cout << "NguonDien::bat()\n"; }
    void tat() { cout << "NguonDien::tat()\n"; }
protected:
    int dienAp = 12;
};

class May : private NguonDien {
public:
    void khoiDongMay() {
        cout << "May dang khoi dong...\n";
        bat();
        cout << "Dien ap su dung: " << dienAp << "\n";
    }

    void dungMay() {
        cout << "May dang dung...\n";
        tat();
    }
};

int main() {
    May m;
    m.khoiDongMay();
    m.dungMay();
    return 0;
}

Output:

May dang khoi dong...
NguonDien::bat()
Dien ap su dung: 12
May dang dung...
NguonDien::tat()

Giải thích code:

  • Lớp PowerSource có các thành viên publicprotected.
  • Lớp Machine kế thừa private từ PowerSource.
  • Các phương thức turnOn()turnOff() (là public trong PowerSource) trở thành private trong Machine. Chúng có thể được gọi bên trong các phương thức của Machine (startMachine, stopMachine), nhưng không thể gọi từ main() qua đối tượng m.
  • Thành viên voltage (là protected trong PowerSource) trở thành private trong Machine và chỉ có thể truy cập bên trong Machine.

Tóm lại về Chế độ Truy cập:

  • Sử dụng public inheritance khi lớp dẫn xuất là một loại của lớp cơ sở (mối quan hệ "is-a").
  • Sử dụng private inheritance khi lớp dẫn xuất được triển khai dựa trên lớp cơ sở, nhưng mối quan hệ này không cần được phơi bày ra bên ngoài (mối quan hệ "implemented-in-terms-of" hoặc "uses-a").
  • protected inheritance ít phổ biến hơn và thường được sử dụng trong các cấu trúc kế thừa sâu, nơi bạn muốn cho phép các lớp "cháu" truy cập các thành viên của lớp "ông cha", nhưng không cho phép truy cập từ bên ngoài hệ thống kế thừa đó.

4. Constructor và Destructor trong Kế thừa

Constructor và destructor của lớp cơ sở không được thừa hưởng bởi lớp dẫn xuất. Tuy nhiên, chúng được gọi theo một trình tự cụ thể khi đối tượng được tạo và hủy.

  • Thứ tự gọi Constructor: Khi tạo một đối tượng của lớp dẫn xuất, constructor của lớp cơ sở luôn được gọi trước constructor của lớp dẫn xuất. Điều này là hợp lý vì lớp cơ sở cần được khởi tạo hoàn chỉnh trước khi lớp dẫn xuất có thể sử dụng các thành viên của nó.
  • Thứ tự gọi Destructor: Khi hủy một đối tượng của lớp dẫn xuất, destructor của lớp dẫn xuất luôn được gọi trước destructor của lớp cơ sở.

Bạn có thể gọi constructor cụ thể của lớp cơ sở từ danh sách khởi tạo (initializer list) của constructor lớp dẫn xuất. Nếu bạn không gọi constructor nào của lớp cơ sở, constructor mặc định (constructor không tham số) của lớp cơ sở sẽ được gọi một cách ngầm định.

Ví dụ minh họa Constructor và Destructor:

#include <iostream>
#include <string>

class LopCoSo {
public:
    LopCoSo() {
        cout << "Ham tao mac dinh LopCoSo duoc goi.\n";
    }
    LopCoSo(const string& tn) {
        cout << "Ham tao LopCoSo voi thong bao: " << tn << "\n";
    }
    ~LopCoSo() {
        cout << "Ham huy LopCoSo duoc goi.\n";
    }
};

class LopDanXuat : public LopCoSo {
public:
    LopDanXuat() {
        cout << "Ham tao mac dinh LopDanXuat duoc goi.\n";
    }

    LopDanXuat(const string& tn) : LopCoSo(tn) {
        cout << "Ham tao LopDanXuat voi thong bao: " << tn << "\n";
    }

    ~LopDanXuat() {
        cout << "Ham huy LopDanXuat duoc goi.\n";
    }
};

int main() {
    cout << "--- Tao doiTuong1 (ham tao mac dinh) ---\n";
    LopDanXuat doiTuong1;

    cout << "\n--- Tao doiTuong2 (ham tao cu the) ---\n";
    LopDanXuat doiTuong2("Xin chao tu LopDanXuat!");

    cout << "\n--- doiTuong1 va doiTuong2 sap ra khoi pham vi ---\n";
    return 0;
}

Output:

--- Tao doiTuong1 (ham tao mac dinh) ---
Ham tao mac dinh LopCoSo duoc goi.
Ham tao mac dinh LopDanXuat duoc goi.

--- Tao doiTuong2 (ham tao cu the) ---
Ham tao LopCoSo voi thong bao: Xin chao tu LopDanXuat!
Ham tao LopDanXuat voi thong bao: Xin chao tu LopDanXuat!

--- doiTuong1 va doiTuong2 sap ra khoi pham vi ---
Ham huy LopDanXuat duoc goi.
Ham huy LopCoSo duoc goi.
Ham huy LopDanXuat duoc goi.
Ham huy LopCoSo duoc goi.

Giải thích code:

  • Khi obj1 được tạo, nó gọi constructor mặc định của Derived. Vì constructor mặc định của Derived không gọi constructor nào của Base trong danh sách khởi tạo, constructor mặc định của Base (Base()) được gọi một cách ngầm định trước.
  • Khi obj2 được tạo, constructor của Derived với tham số string được gọi. Trong danh sách khởi tạo : Base(msg), chúng ta gọi rõ ràng constructor của Base với tham số msg. Do đó, Base("Hello from Derived!") được gọi trước, sau đó mới đến thân của constructor Derived.
  • Khi chương trình kết thúc, các đối tượng cục bộ obj2obj1 lần lượt bị hủy. Đối với mỗi đối tượng, destructor của lớp dẫn xuất (~Derived()) được gọi trước, sau đó là destructor của lớp cơ sở (~Base()).

5. Truy cập Thành viên Lớp Cơ sở từ Lớp Dẫn xuất

Như đã thấy trong các ví dụ về chế độ truy cập, lớp dẫn xuất có thể truy cập các thành viên publicprotected của lớp cơ sở trực tiếp bằng tên của chúng (như thể chúng là thành viên của chính lớp dẫn xuất).

#include <iostream>

class Goc {
protected:
    int duLieuBaoVe;
public:
    Goc() : duLieuBaoVe(100) {}
    void phuongThucCongGoc() const {
        cout << "Phuong thuc cong Goc. Du lieu: " << duLieuBaoVe << "\n";
    }
};

class PhaiSinh : public Goc {
public:
    void truyCapThanhVienGoc() const {
        cout << "Truy cap duLieuBaoVe tu PhaiSinh: " << duLieuBaoVe << "\n";
        phuongThucCongGoc();
    }
};

int main() {
    PhaiSinh p;
    p.truyCapThanhVienGoc();
    return 0;
}

Output:

Truy cap duLieuBaoVe tu PhaiSinh: 100
Phuong thuc cong Goc. Du lieu: 100

Giải thích code:

  • Trong phương thức accessBaseMembers của lớp Derived, chúng ta có thể truy cập trực tiếp baseProtectedData (thành viên protected của Base) và gọi basePublicMethod() (thành viên public của Base).
  • Tuy nhiên, từ main(), chúng ta không thể truy cập baseProtectedData vì nó vẫn là protected trong Derived. basePublicMethod() thì có thể gọi vì nó được kế thừa là public.

6. Đa Kế thừa (Multiple Inheritance)

C++ cũng hỗ trợ Đa Kế thừa (Multiple Inheritance), nơi một lớp dẫn xuất có thể kế thừa từ nhiều lớp cơ sở. Cú pháp chỉ đơn giản là liệt kê các lớp cơ sở được phân tách bằng dấu phẩy:

class Derived : CheDoTruyCap1 Base1, CheDoTruyCap2 Base2 {
    // ...
};

Đa kế thừa mạnh mẽ nhưng cũng có thể dẫn đến các vấn đề phức tạp như "diamond problem" (vấn đề kim cương) khi một lớp kế thừa từ hai lớp cùng kế thừa từ một lớp chung. Chúng ta sẽ tìm hiểu sâu hơn về đa kế thừa và cách xử lý các vấn đề tiềm ẩn của nó trong các bài viết sau. Hiện tại, hãy tập trung vào kế thừa đơn giản (kế thừa từ một lớp cơ sở).

Comments

There are no comments at the moment.