Bài 38.3: Bài tập thực hành đa hình trong C++

Bài 38.3: Bài tập thực hành đa hình trong C++
Chào mừng các bạn quay trở lại với chuỗi bài học C++ chuyên sâu của FullhouseDev!
Sau khi đã cùng nhau khám phá về đa hình (polymorphism) - một trong những trụ cột quan trọng nhất của lập trình hướng đối tượng (OOP) trong C++, hôm nay chúng ta sẽ đi sâu vào thực hành để củng cố kiến thức này. Lý thuyết là cần thiết, nhưng chỉ có thông qua các bài tập và ví dụ thực tế, chúng ta mới có thể nắm vững và áp dụng hiệu quả khái niệm đa hình vào các dự án của mình.
Đa hình, nói một cách đơn giản, là khả năng các đối tượng thuộc các lớp khác nhau nhưng có quan hệ kế thừa có thể được xử lý thông qua một giao diện chung, thường là con trỏ hoặc tham chiếu tới lớp cơ sở. Điều kỳ diệu xảy ra khi chúng ta gọi một phương thức virtual thông qua con trỏ/tham chiếu đó - C++ sẽ tự động xác định và thực thi phiên bản phương thức phù hợp với loại đối tượng thực tế, chứ không phải loại của con trỏ/tham chiếu. Đây chính là đa hình lúc chạy (runtime polymorphism).
Hãy cùng bắt tay vào các bài tập thực hành để thấy rõ sức mạnh và sự linh hoạt mà đa hình mang lại!
Bài tập 1: Hình học đơn giản với Đa hình
Một ví dụ kinh điển để bắt đầu. Chúng ta sẽ tạo một lớp cơ sở Shape
và các lớp dẫn xuất như Circle
và Square
. Mục tiêu là có thể vẽ (hoặc mô phỏng việc vẽ) các hình này một cách thống nhất.
Bước 1: Định nghĩa Lớp Cơ sở và các Lớp Dẫn xuất
Chúng ta cần một lớp cơ sở Shape
với một phương thức draw()
là virtual, và các lớp dẫn xuất Circle
, Square
ghi đè (override) phương thức này.
#include <iostream>
#include <vector> // Sẽ dùng vector để quản lý các hình
// Lớp cơ sở
class Shape {
public:
// Phương thức virtual - cho phép đa hình
virtual void draw() const {
cout << "Drawing a generic shape." << endl;
}
// Rất quan trọng: destructor phải là virtual khi dùng đa hình với con trỏ
virtual ~Shape() {
cout << "Shape destructor called." << endl;
}
};
// Lớp dẫn xuất
class Circle : public Shape {
public:
// Ghi đè phương thức draw
void draw() const override {
cout << "Drawing a Circle." << endl;
}
~Circle() override {
cout << "Circle destructor called." << endl;
}
};
// Lớp dẫn xuất khác
class Square : public Shape {
public:
// Ghi đè phương thức draw
void draw() const override {
cout << "Drawing a Square." << endl;
}
~Square() override {
cout << "Square destructor called." << endl;
}
};
Giải thích:
- Lớp
Shape
là lớp cơ sở. Phương thứcdraw()
được đánh dấu làvirtual
. Điều này báo hiệu cho trình biên dịch rằng các lớp dẫn xuất có thể cung cấp phiên bản riêng của phương thức này, và khi gọi thông qua con trỏ/tham chiếuShape
, hành vi sẽ được xác định lúc chạy. - Các lớp
Circle
vàSquare
kế thừa từShape
(public Shape
). - Chúng ghi đè phương thức
draw()
. Từ khóaoverride
(trong C++11 trở lên) là tùy chọn nhưng rất nên dùng vì nó giúp trình biên dịch kiểm tra xem bạn có thực sự ghi đè một phương thức virtual của lớp cơ sở hay không. - Điểm cực kỳ quan trọng: Destructor (
~Shape()
) cũng được đánh dấu làvirtual
. Chúng ta sẽ thảo luận chi tiết hơn về điều này sau, nhưng về cơ bản, nó đảm bảo rằng khi bạn xóa một đối tượng dẫn xuất thông qua con trỏ lớp cơ sở (delete ptr_to_base;
), destructor thực tế của lớp dẫn xuất sẽ được gọi, tránh rò rỉ bộ nhớ hoặc hành vi không mong muốn.
Bước 2: Thực hành Đa hình
Bây giờ, hãy xem làm thế nào chúng ta có thể sử dụng đa hình để xử lý các đối tượng Circle
và Square
một cách đồng nhất.
int main() {
// Sử dụng vector để lưu trữ con trỏ tới các đối tượng Shape
// Vector này có thể chứa cả Circle và Square
vector<Shape*> shapes;
// Tạo các đối tượng dẫn xuất và lưu trữ con trỏ của chúng
shapes.push_back(new Circle());
shapes.push_back(new Square());
shapes.push_back(new Circle()); // Thêm nhiều loại khác nhau
cout << "Drawing all shapes in the list:" << endl;
// Lặp qua vector và gọi phương thức draw()
// Mặc dù vector chứa con trỏ kiểu Shape*,
// nhờ đa hình, phương thức draw() đúng của đối tượng thực tế sẽ được gọi.
for (const auto& shape_ptr : shapes) {
shape_ptr->draw(); // Đây chính là điểm mấu chốt của đa hình
}
// Dọn dẹp bộ nhớ: Rất quan trọng khi sử dụng con trỏ 'new'
// Nhờ destructor virtual, destructor đúng của Circle và Square sẽ được gọi.
cout << "\nCleaning up memory:" << endl;
for (const auto& shape_ptr : shapes) {
delete shape_ptr; // Gọi destructor của đối tượng thực tế
}
return 0;
}
Giải thích:
- Chúng ta tạo một
vector<Shape*>
, tức là một vector chứa các con trỏ tới đối tượngShape
. - Chúng ta tạo các đối tượng
Circle
vàSquare
bằngnew
và lưu trữ con trỏ của chúng vào vectorshapes
. Điều này hoàn toàn hợp lệ vìCircle
vàSquare
là một loạiShape
(do kế thừa). - Khi lặp qua vector và gọi
shape_ptr->draw()
, C++ nhìn vào loại đối tượng thực tế màshape_ptr
đang trỏ tới lúc chạy. Nếu đó là đối tượngCircle
, nó gọiCircle::draw()
. Nếu làSquare
, nó gọiSquare::draw()
. Nếu làShape
(nếu chúng ta có tạo một đối tượngShape
trực tiếp), nó sẽ gọiShape::draw()
. Đây chính là đa hình lúc chạy. - Cuối cùng, chúng ta lặp lại một lần nữa để
delete
các con trỏ. Nhờ có destructor virtual trong lớpShape
, khidelete shape_ptr
được gọi, C++ biết được loại đối tượng thực tế là gì (Circle
haySquare
) và gọi destructor tương ứng của lớp dẫn xuất trước, sau đó mới gọi destructor của lớp cơ sở. Nếu destructor củaShape
không phải virtual, chỉ destructor củaShape
được gọi, dẫn đến rò rỉ bộ nhớ cho các thành viên của lớp dẫn xuất (nếu có).
Kết quả chạy chương trình sẽ là:
Drawing all shapes in the list:
Drawing a Circle.
Drawing a Square.
Drawing a Circle.
Cleaning up memory:
Circle destructor called.
Shape destructor called.
Square destructor called.
Shape destructor called.
Circle destructor called.
Shape destructor called.
Bạn có thể thấy rõ ràng destructor của lớp dẫn xuất được gọi trước khi destructor của lớp cơ sở được gọi cho mỗi đối tượng.
Bài tập 2: Đa hình với Tham chiếu và Hàm xử lý chung
Thay vì dùng con trỏ, chúng ta cũng có thể sử dụng tham chiếu để thể hiện đa hình. Kỹ thuật này thường được dùng khi bạn truyền đối tượng vào một hàm để xử lý.
Chúng ta sẽ sử dụng lại các lớp Shape
, Circle
, Square
từ ví dụ trước.
Bước 1: Tạo một hàm xử lý sử dụng tham chiếu lớp cơ sở
// Hàm này nhận một tham chiếu tới Shape
// Nhờ đa hình, nó có thể xử lý bất kỳ đối tượng dẫn xuất nào của Shape
void processShape(const Shape& shape_ref) {
cout << "Processing a shape via reference: ";
shape_ref.draw(); // Gọi phương thức draw() thông qua tham chiếu
}
Giải thích:
- Hàm
processShape
nhận đối số là một tham chiếu hằng (const Shape&
) tớiShape
. - Bên trong hàm, chúng ta gọi
shape_ref.draw()
. Vìshape_ref
thực sự đang tham chiếu đến một đối tượngCircle
hoặcSquare
, vàdraw()
là phương thức virtual, C++ sẽ gọi phiên bảndraw()
phù hợp với đối tượng thực tế.
Bước 2: Sử dụng hàm với các đối tượng khác nhau
int main() {
Circle myCircle;
Square mySquare;
cout << "Using processShape with different objects:" << endl;
// Truyền đối tượng Circle vào hàm
processShape(myCircle);
// Truyền đối tượng Square vào hàm
processShape(mySquare);
// Không cần delete ở đây vì chúng ta dùng đối tượng trên stack, không dùng 'new'
return 0;
}
Giải thích:
- Chúng ta tạo các đối tượng
myCircle
vàmySquare
trên stack. - Chúng ta gọi
processShape
và truyềnmyCircle
rồimySquare
vào. - Mặc dù hàm
processShape
mong đợiconst Shape&
, việc truyềnmyCircle
(kiểuCircle
) vàmySquare
(kiểuSquare
) là hoàn toàn hợp lệ do mối quan hệ kế thừa. Bên trong hàm, đa hình đảm bảo rằng hành vi (draw()
) là chính xác cho loại đối tượng được truyền vào. - Vì các đối tượng được tạo trên stack, chúng tự động được giải phóng khi ra khỏi phạm vi (
main
), nên không cần dùngdelete
.
Kết quả chạy chương trình sẽ là:
Using processShape with different objects:
Processing a shape via reference: Drawing a Circle.
Processing a shape via reference: Drawing a Square.
Đây là một cách thể hiện đa hình rất phổ biến, đặc biệt khi truyền đối tượng vào các hàm để xử lý chung.
Bài tập 3: Đa hình trong Hệ thống Nhân viên
Hãy xây dựng một hệ thống đơn giản quản lý các loại nhân viên khác nhau, ví dụ như nhân viên lương cứng và nhân viên lương theo giờ. Cả hai loại này đều có cách tính lương khác nhau, nhưng chúng ta muốn xử lý chúng một cách thống nhất để tính tổng lương cho toàn bộ công ty.
Bước 1: Định nghĩa Lớp Cơ sở và các Lớp Dẫn xuất
#include <iostream>
#include <vector>
// Lớp cơ sở
class Employee {
protected:
string name;
public:
Employee(const string& n) : name(n) {}
// Phương thức virtual để tính lương
virtual double calculateSalary() const = 0; // Pure virtual function
// Phương thức virtual thông thường
virtual string getName() const {
return name;
}
// Destructor virtual
virtual ~Employee() {
cout << "Employee destructor called for " << name << endl;
}
};
// Lớp dẫn xuất: Nhân viên lương cứng
class SalariedEmployee : public Employee {
private:
double annualSalary;
public:
SalariedEmployee(const string& n, double salary)
: Employee(n), annualSalary(salary) {}
// Ghi đè phương thức tính lương
double calculateSalary() const override {
// Ví dụ: Lương tháng = lương năm / 12
return annualSalary / 12.0;
}
~SalariedEmployee() override {
cout << "SalariedEmployee destructor called for " << name << endl;
}
};
// Lớp dẫn xuất: Nhân viên lương theo giờ
class HourlyEmployee : public Employee {
private:
double hourlyRate;
int hoursWorked;
public:
HourlyEmployee(const string& n, double rate, int hours)
: Employee(n), hourlyRate(rate), hoursWorked(hours) {}
// Ghi đè phương thức tính lương
double calculateSalary() const override {
// Lương = tỷ lệ giờ * số giờ làm
return hourlyRate * hoursWorked;
}
~HourlyEmployee() override {
cout << "HourlyEmployee destructor called for " << name << endl;
}
};
Giải thích:
- Lớp
Employee
là lớp cơ sở. Nó có một thành viênname
và constructor để khởi tạo tên. - Phương thức
calculateSalary()
được khai báo làvirtual double calculateSalary() const = 0;
. Dấu= 0
ở cuối biến nó thành một phương thức ảo thuần túy (pure virtual function). Một lớp có ít nhất một phương thức ảo thuần túy được gọi là lớp trừu tượng (abstract class). Bạn không thể tạo đối tượng trực tiếp từ một lớp trừu tượng. Mục đích của nó là định nghĩa một giao diện mà các lớp dẫn xuất phải thực hiện. Bất kỳ lớp dẫn xuất nào không ghi đè tất cả các phương thức ảo thuần túy của lớp cơ sở cũng sẽ là lớp trừu tượng. - Phương thức
getName()
là virtual thông thường, các lớp dẫn xuất có thể ghi đè hoặc sử dụng phiên bản mặc định của lớp cơ sở. - Destructor
~Employee()
là virtual (cũng như các destructor của lớp dẫn xuất) vì chúng ta sẽ dùng con trỏ vàdelete
. SalariedEmployee
vàHourlyEmployee
kế thừa từEmployee
. Chúng cung cấp các thành viên và constructor riêng để lưu trữ thông tin lương.- Cả hai lớp dẫn xuất bắt buộc phải ghi đè phương thức
calculateSalary()
để cung cấp logic tính lương cụ thể của từng loại.
Bước 2: Sử dụng Đa hình để Tính Tổng Lương
Bây giờ, chúng ta có thể tạo một danh sách các nhân viên thuộc các loại khác nhau và tính tổng lương của họ một cách dễ dàng nhờ đa hình.
int main() {
// Sử dụng vector để lưu trữ con trỏ tới các đối tượng Employee
vector<Employee*> employees;
// Thêm các loại nhân viên khác nhau vào danh sách
employees.push_back(new SalariedEmployee("Alice", 60000.0));
employees.push_back(new HourlyEmployee("Bob", 15.0, 160)); // 160 giờ làm/tháng
employees.push_back(new SalariedEmployee("Charlie", 72000.0));
employees.push_back(new HourlyEmployee("David", 18.0, 140));
double totalSalary = 0.0;
cout << "Calculating salaries for all employees:" << endl;
// Lặp qua danh sách và tính lương cho từng nhân viên
// Mặc dù là con trỏ Employee*, đa hình đảm bảo calculateSalary() đúng được gọi.
for (const auto& emp_ptr : employees) {
double monthlySalary = emp_ptr->calculateSalary(); // Đa hình hoạt động ở đây!
cout << emp_ptr->getName() << ": $" << monthlySalary << endl;
totalSalary += monthlySalary;
}
cout << "\nTotal monthly payroll: $" << totalSalary << endl;
// Dọn dẹp bộ nhớ
cout << "\nCleaning up employee objects:" << endl;
for (const auto& emp_ptr : employees) {
delete emp_ptr; // Đảm bảo destructor đúng được gọi nhờ 'virtual'
}
return 0;
}
Giải thích:
- Chúng ta tạo một
vector<Employee*>
. Vector này có thể chứa con trỏ tới bất kỳ đối tượng nào là dẫn xuất củaEmployee
. - Chúng ta tạo các đối tượng
SalariedEmployee
vàHourlyEmployee
bằngnew
và thêm con trỏ của chúng vào vector. - Khi lặp qua vector, chúng ta gọi
emp_ptr->calculateSalary()
. Tại đây, đa hình phát huy tác dụng:- Nếu
emp_ptr
trỏ tới mộtSalariedEmployee
, C++ gọiSalariedEmployee::calculateSalary()
. - Nếu
emp_ptr
trỏ tới mộtHourlyEmployee
, C++ gọiHourlyEmployee::calculateSalary()
.
- Nếu
- Chúng ta có thể xử lý tất cả các loại nhân viên khác nhau thông qua cùng một vòng lặp và cùng một lời gọi phương thức
calculateSalary()
, làm cho code linh hoạt và dễ mở rộng. Nếu sau này bạn có thêm loại nhân viên mới (ví dụ: nhân viên theo dự án), bạn chỉ cần tạo lớp mới kế thừa từEmployee
, ghi đècalculateSalary()
, và thêm đối tượng của lớp mới vào vector này mà không cần thay đổi logic tính tổng lương. - Cuối cùng, chúng ta dọn dẹp bộ nhớ bằng cách
delete
từng con trỏ. Destructor virtual đảm bảo quá trình giải phóng bộ nhớ diễn ra đúng đắn.
Kết quả chạy chương trình sẽ là (các số có thể khác tùy dữ liệu đầu vào):
Calculating salaries for all employees:
Alice: $5000
Bob: $2400
Charlie: $6000
David: $2520
Total monthly payroll: $15920
Cleaning up employee objects:
SalariedEmployee destructor called for Alice
Employee destructor called for Alice
HourlyEmployee destructor called for Bob
Employee destructor called for Bob
SalariedEmployee destructor called for Charlie
Employee destructor called for Charlie
HourlyEmployee destructor called for David
Employee destructor called for David
Các điểm quan trọng cần ghi nhớ khi thực hành Đa hình:
- Kế Thừa: Đa hình chỉ hoạt động giữa các lớp có quan hệ kế thừa.
- Phương thức
virtual
: Chỉ các phương thức được đánh dấu làvirtual
trong lớp cơ sở mới có thể được gọi đa hình thông qua con trỏ/tham chiếu lớp cơ sở. - Ghi đè (
override
): Các lớp dẫn xuất cung cấp phiên bản riêng của phương thức virtual bằng cách ghi đè nó. Sử dụng từ khóaoverride
giúp tránh lỗi chính tả hoặc sai chữ ký phương thức. - Con trỏ/Tham chiếu Lớp Cơ sở: Bạn cần sử dụng con trỏ hoặc tham chiếu tới lớp cơ sở để lưu trữ hoặc thao tác với các đối tượng dẫn xuất và gọi phương thức virtual một cách đa hình.
- Destructor
virtual
: Luôn luôn khai báo destructor của lớp cơ sở làvirtual
nếu lớp đó có bất kỳ phương thứcvirtual
nào và bạn dự định xóa các đối tượng dẫn xuất thông qua con trỏ/tham chiếu lớp cơ sở. Điều này là cực kỳ quan trọng để ngăn chặn rò rỉ bộ nhớ và đảm bảo giải phóng tài nguyên đúng cách. - Lớp Trừu tượng và Phương thức ảo thuần túy (
= 0
): Sử dụng phương thức ảo thuần túy để định nghĩa các hành vi mà bắt buộc các lớp dẫn xuất phải triển khai. Lớp chứa phương thức ảo thuần túy trở thành lớp trừu tượng và không thể tạo đối tượng trực tiếp.
Comments