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

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:
- Tái sử dụng mã nguồn: Không cần viết lại code cho các đặc điểm chung.
- 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).
- 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ênpublic
(name
), mộtprotected
(age
), và mộtprivate
(origin
). Nó cũng có các phương thức tương ứng. - Lớp
Dog
kế thừapublic
từAnimal
. Điều này có nghĩa là:name
(từAnimal
) vẫn làpublic
trongDog
. Bạn có thể truy cậpmyDog.name
từmain()
.eat()
(từAnimal
) vẫn làpublic
trongDog
. Bạn có thể gọimyDog.eat()
từmain()
.age
(từAnimal
) trở thànhprotected
trongDog
. Bạn có thể truy cậpage
bên trong các phương thức của lớpDog
(như trongdisplayDogInfo
, mặc dù đã comment), nhưng không thể truy cập trực tiếpmyDog.age
từmain()
.setAge()
(từAnimal
) trở thànhprotected
trongDog
. Bạn có thể gọisetAge()
bên trong các phương thức của lớpDog
, nhưng không thể gọimyDog.setAge()
từmain()
.origin
vàsetOrigin()
(từAnimal
) vẫn làprivate
trongAnimal
và không thể truy cập trực tiếp bởi các phương thức củaDog
hoặc từmain()
. Tuy nhiên, các phương thứcpublic
củaAnimal
nhưdisplayOrigin
có thể được gọi từDog
hoặcmain
(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ớpAnimal
đó.
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ànhprotected
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 public
và protected
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ừaprotected
từBase
. - Phương thức
publicBaseMethod()
củaBase
trở thànhprotected
trongProtectedDerived
. Do đó, nó có thể được gọi bên trong phương thứcaccessBaseMethods
củaProtectedDerived
, nhưng không thể gọi trực tiếp từmain()
qua đối tượngd
. - Phương thức
protectedBaseMethod()
củaBase
vẫn làprotected
trongProtectedDerived
và cũng chỉ có thể gọi bên trongProtectedDerived
. - Phương thức
privateBaseMethod()
củaBase
vẫn làprivate
và không thể truy cập ở bất kỳ đâu ngoàiBase
.
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ànhprivate
trong lớp dẫn xuất. - Các thành viên
protected
của lớp cơ sở trở thànhprivate
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 public
và protected
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ênpublic
vàprotected
. - Lớp
Machine
kế thừaprivate
từPowerSource
. - Các phương thức
turnOn()
vàturnOff()
(làpublic
trongPowerSource
) trở thànhprivate
trongMachine
. Chúng có thể được gọi bên trong các phương thức củaMachine
(startMachine
,stopMachine
), nhưng không thể gọi từmain()
qua đối tượngm
. - Thành viên
voltage
(làprotected
trongPowerSource
) trở thànhprivate
trongMachine
và chỉ có thể truy cập bên trongMachine
.
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ủaDerived
. Vì constructor mặc định củaDerived
không gọi constructor nào củaBase
trong danh sách khởi tạo, constructor mặc định củaBase
(Base()
) được gọi một cách ngầm định trước. - Khi
obj2
được tạo, constructor củaDerived
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ủaBase
với tham sốmsg
. Do đó,Base("Hello from Derived!")
được gọi trước, sau đó mới đến thân của constructorDerived
. - Khi chương trình kết thúc, các đối tượng cục bộ
obj2
vàobj1
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 public
và protected
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ớpDerived
, chúng ta có thể truy cập trực tiếpbaseProtectedData
(thành viênprotected
củaBase
) và gọibasePublicMethod()
(thành viênpublic
củaBase
). - Tuy nhiên, từ
main()
, chúng ta không thể truy cậpbaseProtectedData
vì nó vẫn làprotected
trongDerived
.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