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 Animal {
public:
string name; // public member
Animal(const string& n) : name(n) {
cout << "Animal constructor: " << name << endl;
}
void eat() const { // public member function
cout << name << " is eating." << endl;
}
protected:
int age; // protected member (accessible in derived classes)
void setAge(int a) {
age = a;
}
private:
string origin; // private member (not accessible in derived or outside)
void setOrigin(const string& o) {
origin = o; // Only accessible within Animal methods
}
public:
// Public method to interact with private member indirectly
void displayOrigin(const string& o) {
setOrigin(o); // Can call private method within public method
cout << name << "'s origin set (via public method)." << endl;
}
// Public method to interact with protected member indirectly
void displayAge(int a) {
setAge(a); // Can call protected method within public method
cout << name << " is " << age << " years old." << endl; // Also can access protected member directly here
}
};
// Dog is a type of Animal -> Use public inheritance
class Dog : public Animal {
public:
string breed; // private member of Dog
Dog(const string& n, const string& b) : Animal(n), breed(b) { // Call base constructor
cout << "Dog constructor: " << name << ", breed: " << breed << endl;
}
void bark() const { // public member function of Dog
cout << name << " is barking!" << endl;
}
// Accessing base class members from derived class
void displayDogInfo() const {
cout << "--- Dog Info ---" << endl;
cout << "Name: " << name << endl; // OK (public inherited)
cout << "Breed: " << breed << endl; // OK (Dog's own member)
// cout << "Age: " << age << endl; // OK (protected inherited, accessible within Derived)
// cout << "Origin: " << origin << endl; // ERROR: origin is private in Animal
// eat(); // OK (public inherited method)
// setAge(5); // OK (protected inherited method)
// setOrigin("Zoo"); // ERROR: setOrigin is private in Animal
cout << "----------------" << endl;
}
};
int main() {
Dog myDog("Buddy", "Golden Retriever"); // Calls Animal constructor, then Dog constructor
// Accessing members via Derived object (myDog)
myDog.eat(); // OK: eat() is public in Animal and inherited as public in Dog
myDog.bark(); // OK: bark() is public in Dog
cout << "Name from main: " << myDog.name << endl; // OK: name is public in Animal and inherited as public
myDog.displayAge(3); // Using public method in Animal, inherited by Dog
myDog.displayOrigin("Home"); // Using public method in Animal, inherited by Dog
// cout << myDog.age << endl; // ERROR: age is protected in Animal and remains protected in Dog
// myDog.setAge(4); // ERROR: setAge is protected in Animal and remains protected in Dog
// cout << myDog.origin << endl; // ERROR: origin is private in Animal
// myDog.setOrigin("Street"); // ERROR: setOrigin is private in Animal
cout << "\nObject going out of scope." << endl;
// When myDog goes out of scope, Derived destructor runs first, then Base destructor (implicitly)
return 0;
}
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 Base {
public:
void publicBaseMethod() {
cout << "Base::publicBaseMethod()" << endl;
}
protected:
void protectedBaseMethod() {
cout << "Base::protectedBaseMethod()" << endl;
}
private:
void privateBaseMethod() {
cout << "Base::privateBaseMethod()" << endl;
}
};
// Inherit using protected
class ProtectedDerived : protected Base {
public:
void accessBaseMethods() {
publicBaseMethod(); // OK: becomes protected in ProtectedDerived, accessible internally
protectedBaseMethod(); // OK: remains protected in ProtectedDerived, accessible internally
// privateBaseMethod(); // ERROR: still private in Base
}
};
int main() {
ProtectedDerived d;
d.accessBaseMethods(); // OK: accessBaseMethods() is public in ProtectedDerived
// d.publicBaseMethod(); // ERROR: becomes protected in ProtectedDerived, not accessible from outside
// d.protectedBaseMethod(); // ERROR: protected, not accessible from outside
// d.privateBaseMethod(); // ERROR: private
return 0;
}
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 PowerSource {
public:
void turnOn() { cout << "PowerSource::turnOn()" << endl; }
void turnOff() { cout << "PowerSource::turnOff()" << endl; }
protected:
int voltage = 12;
};
// A machine might be implemented using a PowerSource, but the user doesn't
// interact with the PowerSource directly.
class Machine : private PowerSource { // Private inheritance
public:
void startMachine() {
cout << "Machine starting..." << endl;
turnOn(); // OK: turnOn() is public in PowerSource, becomes private in Machine, accessible internally
cout << "Voltage used: " << voltage << endl; // OK: voltage is protected in PowerSource, becomes private in Machine, accessible internally
}
void stopMachine() {
cout << "Machine stopping..." << endl;
turnOff(); // OK: turnOff() is public in PowerSource, becomes private in Machine, accessible internally
}
};
int main() {
Machine m;
m.startMachine(); // OK: public method of Machine
m.stopMachine(); // OK: public method of Machine
// m.turnOn(); // ERROR: turnOn() is private in Machine because of private inheritance
// m.turnOff(); // ERROR: turnOff() is private in Machine because of private inheritance
// cout << m.voltage << endl; // ERROR: voltage is private in Machine
return 0;
}
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 Base {
public:
Base() {
cout << "Base default constructor called." << endl;
}
Base(const string& msg) {
cout << "Base constructor called with message: " << msg << endl;
}
~Base() {
cout << "Base destructor called." << endl;
}
};
class Derived : public Base {
public:
// Derived default constructor (implicitly calls Base default constructor)
Derived() {
cout << "Derived default constructor called." << endl;
}
// Derived constructor calling a specific Base constructor
Derived(const string& msg) : Base(msg) { // Call Base(msg) constructor
cout << "Derived constructor called with message: " << msg << endl;
}
~Derived() {
cout << "Derived destructor called." << endl;
}
};
int main() {
cout << "--- Creating obj1 (using default constructor) ---" << endl;
Derived obj1; // Calls Base(), then Derived()
cout << "\n--- Creating obj2 (using specific constructor) ---" << endl;
Derived obj2("Hello from Derived!"); // Calls Base("Hello..."), then Derived()
cout << "\n--- obj1 and obj2 going out of scope ---" << endl;
// obj2 goes out of scope first, then obj1
// Order for each: Derived destructor, then Base destructor
return 0;
}
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 Base {
protected: // Accessible in derived classes
int baseProtectedData;
public:
Base() : baseProtectedData(100) {}
void basePublicMethod() const {
cout << "Base public method. Data: " << baseProtectedData << endl;
}
};
class Derived : public Base {
public:
void accessBaseMembers() const {
// Accessing protected member from Base (OK)
cout << "Accessing baseProtectedData from Derived: " << baseProtectedData << endl;
// Calling public method from Base (OK)
basePublicMethod();
}
};
int main() {
Derived d;
d.accessBaseMembers();
// d.baseProtectedData; // ERROR: baseProtectedData is protected in Derived
// d.basePublicMethod(); // OK: basePublicMethod is public in Base and remains public in Derived
return 0;
}
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