Các bạn cảm thấy bài học về tính kế thừa thế nào ? Cá nhân tôi thì thấy nó vẫn còn khá khó dù tôi đã cố đơn giản hóa đi nhiều. Và thật buồn khi tôi phải thông báo với các bạn rằng bài học này cũng sẽ có mức độ khó tương tự. Đây không thể nghi ngờ là 1 trong những bài học khó nhất trong giáo trình này. Tuy nhiên những thứ bạn nhận được cũng sẽ rất xứng đáng.
Cùng với tính đóng gói và tính kế thừa, tính đa hình (polymorphism) là 1 trong 3 khái niệm nền tảng quan trọng nhất tạo nên sức mạnh của lập trình hướng đối tượng.
Đa hình có nghĩa là nhiều hình dạng. Tính chất này của lập trình hướng đối tượng cho phép chúng ta có thể làm việc với những đối tượng có khả năng thay đổi xử lý của bản thân tùy theo cách thức mà chúng được sử dụng.
! Tôi đề nghị đọc kỹ lại bài học về con trỏ trước khi bắt đầu bài học này.
Chúng ta hãy cùng bắt đầu với 1 sự kế thừa đơn giản. Trong bài học này, thay vì tiếp tục với trò chơi RPG, chúng ta sẽ quay về với những ví dụ cổ điển hơn như chương trình quản lý trạm bảo dưỡng xe với các đối tượng phương tiện xe cộ.
Ở đây chúng ta sẽ làm việc với các lớp PhuongTien, XeOto và XeMay.
class PhuongTien{ public: void hienThi() const; //Hien thi thong tin ve phuong tien protected: int m_giaTri; //Moi phuong tien co 1 gia tri nhat dinh }; class XeOto : public PhuongTien{ //Oto la 1 phuong tien public: void hienThi() const; private: int m_soCua; //So cua cua xe oto }; class XeMay : public PhuongTien{ //Xe may la 1 phuong tien public: void hienThi() const; private: double m_tocDo; //Toc do toi da cua xe may };
Trong ví dụ này tôi không nêu hết các thông tin cũng như phương thức của các lớp này. Đừng ngại thêm vào các thành phần mà bạn muốn để lớp trở nên hoàn thiện hơn. Còn để trình bày điều tôi muốn nói thì vậy là đủ rồi .
Mã xử lý của phương thức hienThi()
:
void PhuongTien::hienThi() const{ cout << "Day la 1 phuong tien giao thong." << endl; } void XeOto::hienThi() const{ cout << "Day la 1 chiec xe oto." << endl; } void XeMay::hienThi() const{ cout << "Day la 1 chiec xe may." << endl; }
Mỗi lớp sẽ có cách tự giới thiệu khác nhau. Nếu các bạn nắm rõ bài học trước thì sẽ nhận ra tôi đã sử dụng phép ghi đè để định nghĩa lại phương thức hienThi()
trong 2 lớp con.
Cùng thử chạy 1 đoạn mã ví dụ trong hàm main()
nhé.
int main(){ PhuongTien p; p.hienThi(); //"Day la 1 phuong tien giao thong." XeMay m; m.hienThi(); //" Day la 1 chiec xe may." return 0; }
Kết quả không có gì bất ngờ và khó đoán trước.
Nếu bây giờ chúng ta tạo ra thêm 1 hàm để gián tiếp sử dụng phương thức hienThi()
trong main()
.
void gioiThieu(PhuongTien p){ //Gioi thieu phuong tien trong tham so p.hienThi(); } int main(){ PhuongTien p; gioiThieu(p); XeMay m; gioiThieu(m); return 0; }
Về cơ bản thì đoạn mã này không khác nhiều so với đoạn mã trên. Thế nhưng các bạn sẽ thấy là kết quả trả về có những thay đổi khá thú vị
Thông điệp in ra bởi đối tượng XeMay có vấn đề. Dường như là khi truyền tham số cho hàm, đối tượng đã mất đi tính chuyên biệt của XeMay mà trở về thành 1 PhuongTien bất kỳ.
? Vì sao lại thế ?
Bởi vì chúng ta đã sử dụng quan hệ kế thừa nên chúng ta biết rằng 1 XeMay là 1 PhuongTien với 1 số thuộc tính thêm vào. Hàm gioiThieu()
thì có khả năng nhận vào tham số là 1 PhuongTien. Vậy nên nó cũng có thể nhận vào 1 XeOto hoặc 1 XeMay như chúng ta đã làm ở trên theo khái niệm về dẫn xuất kiểu dữ liệu.
Thế nhưng cần phải hiểu rằng, với trình biên dịch, ở bên trong hàm, chúng ta đang thao tác với 1 đối tượng PhuongTien mà không cần biết bản chất của nó vốn là XeMay hoặc XeOto. Thế nên nó sẽ sử dụng phương thức hienThi()
của lớp PhuongTien thay vì sử dụng phiên bản thích hợp hơn nằm trong lớp XeMay như chúng ta mong đợi.
Trong ví dụ tôi sử dụng ở bài trước, trình biên dịch đưa ra được lựa chọn chính xác bởi vì nó đang thao tác bên trong lớp. Trong ví dụ hiện thời thì hàm gioiThieu()
đang xét nằm bên ngoài các lớp nên nó không thể quyết định đúng được.
Thuật ngữ chuyên môn của hiện tượng này là phân giải liên kết tĩnh : hàm nhận vào tham số là 1 đối tượng PhuongTien thì sẽ luôn là các phương thức của lớp PhuongTien được sử dụng.
Phương thức nào được sử dụng được xác định bởi chính kiểu dữ liệu của tham số thay vì dựa trên bản chất thật của đối tượng.
Các bạn không nên lo lắng, chúng ta đã có sẵn cách để đối phó với việc này.
Cái chúng ta muốn là trình biên dịch sẽ gọi đúng phiên bản của phương thức gioiThieu()
mà chúng ta muốn, nghĩa là nó sẽ dựa trên bản chất của đối tượng được đưa vào làm tham số. Chúng ta gọi đây là phân giải liên kết động. Khi chạy, chương trình sẽ sử dụng đúng phương thức mà chúng ta muốn vì nó xác định được tham số là thực thể của lớp mẹ hay lớp con.
Để làm được việc này, chúng ta cần 2 thứ :
! Thiếu 1 trong 2 thứ trên thì chúng ta sẽ quay lại trường hợp mà máy tính không thể xác định xem nên sử dụng phương thức nào.
Để sử dụng các phương thức ảo, trước tiên chúng ta cần phải hiểu được chúng nghĩa là gì.
Việc này vô cùng đơn giản. Chúng ta chỉ cần thêm từ khóa virtual
vào trước nguyên mẫu của phương thức của lớp, trong tệp .h
.
class PhuongTien{ public: virtual void hienThi() const; //Hien thi thong tin ve phuong tien protected: int m_giaTri; //Moi phuong tien co 1 gia tri nhat dinh }; class XeOto : public PhuongTien{ //Oto la 1 phuong tien public: virtual void hienThi() const; private: int m_soCua; //So cua cua xe oto }; class XeMay : public PhuongTien{ //Xe may la 1 phuong tien public: virtual void hienThi() const; private: double m_tocDo; //Toc do toi da cua xe may };
! Việc thêm từ khóa virtual
vào nguyên mẫu của phương thức trong các lớp con thật ra là không cần thiết bởi vì sẽ được kế thừa từ phương thức của lớp mẹ. Tuy nhiên tôi vẫn thường thêm chúng vào để dễ dàng ghi nhớ các phương thức đặc biệt.
Tới lúc này thì vẫn chưa có gì quá khó khăn cả. Các bạn cần chú ý là không bắt buộc là toàn bộ các phương thức của 1 lớp phải là phương thức ảo. Chúng ta hoàn toàn có thể có 1 lớp vừa có các phương thức thông thường, vừa có các phương thức ảo.
! Chú ý là chỉ cần thêm từ khóa virtual vào nguyên mẫu của phương thức trong tệp .h
mà không cần thêm nó vào trong tệp .cpp
.
Tiếp theo, chúng ta sẽ cần sử dụng đến con trỏ hoặc tham chiếu. Chắc chắn là các bạn cũng giống như tôi, sẽ thích làm việc với tham chiếu hơn là thích thú với việc vật lộn thao tác con trỏ. Vậy nên chúng ta sẽ chọn giải pháp đơn giản hơn, đó là tham chiếu.
void gioiThieu(PhuongTien const& p){ p.hienThi(); } int main(){ PhuongTien p; gioiThieu(p); XeMay m; gioiThieu(m); return 0; }
! Tôi sử dụng thêm từ khóa const
để khai báo đây là tham chiếu hằng vì hàm này không thay đổi đối tượng.
Hàm gioiThieu()
đã hoạt động đúng như những gì chúng ta muốn khi chọn ra được phương thức nào của đối tượng cần được sử dụng. Đấy là nhờ sự kết hợp của phương thức ảo và sử dụng tham chiếu. Bên cạnh đó, chúng ta cũng có thể nhận được hiệu quả tương tự khi thay tham chiếu bằng con trỏ.
Cùng 1 đoạn mã nhưng lại đưa ra những xử lý khác nhau tùy thuộc vào kiểu dữ liệu của tham số, đó là tính đa hình. Chúng ta cũng gọi những phương thức như gioiThieu()
là các xử lý đa hình.
? Theo các bạn thì những phương thức nào của lớp sẽ không bao giờ được kế thừa ?
Câu trả lời rất đơn giản : các phương thức khởi tạo và phương thức hủy. Tất cả các phương thức khác đều có thể được kế thừa và mang theo những xử lý đa hình. Thế còn những phương thức đặc biệt này thì sao ?
Liệu có tồn tại các phương thức khởi tạo ảo không ?
Câu trả lời hiển nhiên là không, bởi vì khi muốn tạo ra 1 đối tượng thì tôi sẽ biết là tôi muốn tạo ra cái gì và khi biên dịch thì đối tượng nào sẽ được tạo ra. Chính vì thế sẽ không tồn tại sự phân giải liên kết động khi gọi phương thức khởi tạo cũng như không được phép tồn tại phương thức khởi tạo ảo ! Điều này cũng dẫn đến việc chúng ta sẽ không thể gọi các phương thức ảo khác trong phương thức khởi tạo. Vậy nên đừng mất công thử vì dù bạn có cố làm thế thì sự phân giải liên kết động cũng sẽ không xảy ra.
Đối với phương thức hủy thì mọi thứ có chút rắc rối hơn.
Chúng ta sẽ thử với 1 ví dụ xử lý đa hình sử dụng con trỏ.
int main(){ PhuongTien *p(0); p = new XeOto; //Tao ra 1 doi tuong xe oto va dua dia chi vao con tro kieu PhuongTien p->hienThi(); // "Day la 1 chiec xe oto." delete p; //Xoa doi tuong oto return 0; }
Chúng ta đã sử dụng phương thức ảo và con trỏ nên dong lệnh p->hienThi();
hiện ra đúng kết quả mà chúng ta muốn. Vấn đề nằm ở phép toán delete
. Chúng ta có con trỏ nhưng phương thức đang thao tác lại không phải là 1 phương thức ảo. Vậy nên phương thức được gọi sẽ là phương thức hủy của PhuongTien chứ không phải của XeOto.
Vấn đề không quá nghiêm trọng trong ví dụ này nhưng không thể coi nhẹ nó. Nếu các bạn thao tác với các phần mềm nhạy cảm như các ứng dụng nhúng trong động cơ máy bay và bạn gọi sai phương thức hủy, động cơ có thể không hoạt động và mọi thứ trở thành thảm họa trong nháy mắt.
Vậy nên chúng ta cần chắc chắn gọi đúng phương thức hủy của đối tượng. Không có quá nhiều lựa chọn, chúng ta phải tạo ra phương thức hủy là 1 phương thức ảo. Vậy nên chúng ta có thêm 1 quy tắc : 1 phương thức hủy phải luôn là phương thức ảo nếu chúng ta sử dụng các xử lý đa hình.
Chúng ta sẽ hoàn thiện đoạn mã ví dụ bằng cách thêm vào phương thức khởi tạo và phương thức hủy.
class PhuongTien{ public: PhuongTien(int giaTri); virtual void hienThi() const; //Hien thi thong tin ve phuong tien virtual ~PhuongTien(); protected: int m_giaTri; //Moi phuong tien co 1 gia tri nhat dinh }; class XeOto : public PhuongTien{ //Oto la 1 phuong tien public: XeOto(int giaTri, int soCua); virtual void hienThi() const; virtual ~XeOto(); private: int m_soCua; //So cua cua xe oto }; class XeMay : public PhuongTien{ //Xe may la 1 phuong tien public: XeMay(int giaTri, double tocDo); virtual void hienThi() const; virtual ~XeMay(); private: double m_tocDo; //Toc do toi da cua xe may };
PhuongTien::PhuongTien(int giaTri) : m_giaTri(giaTri){ } void PhuongTien::hienThi() const{ cout << "Day la 1 phuong tien giao thong co gia " << m_giaTri << " USD."<< endl; } PhuongTien::~PhuongTien(){} // Can them vao du khong co bat cu xu ly nao XeOto:: XeOto(int giaTri, int soCua) : PhuongTien(giaTri), m_soCua(soCua){ } void XeOto::hienThi() const{ cout << "Day la 1 chiec xe oto co "<< m_soCua << " cua va co gia " << m_giaTri << " USD."<< endl; } XeOto::~XeOto(){} XeMay::XeMay(int giaTri, double tocDoToiDa) : PhuongTien(giaTri), m_tocDo(tocDoToiDa){ } void XeMay::hienThi() const{ cout << "Day la 1 chiec xe may co toc do toi da la "<< m_tocDo << " km/h va co gia " << m_giaTri << " USD."<< endl; } XeMay::~XeMay(){}
Thế là chúng ta đã sẵn sàng để tiếp xúc với những ví dụ cụ thể về xử lý đa hình trong phần sau của bài học.
Ví dụ của chúng ta là phần mềm quản lý trạm bảo dưỡng phương tiện, vậy nên nó cần quản lý được danh sách các xe máy và ôtô ở trong trạm. Để quản lý các danh sách này, chúng ta sẽ cần dùng đến… mảng động.
vector<XeOto> danhSachOto; vector<XeMay> danhSachXeMay;
Tốt nhưng chưa phải là tối ưu. Nếu trong tương lai, trạm bảo dưỡng của chúng ta bảo dưỡng cả xe đạp, máy bay với cả xe tăng, vv… thì chúng ta sẽ phải tạo ra vô số vector
. Đoạn mã của chúng ta sẽ cần thay đổi rất nhiều mỗi khi có thêm 1 kiêu phương tiện mới.
Sẽ thật tuyệt nếu chúng ta có thể để tất cả chúng chung vào 1 mảng. Bởi vì dù là xe máy hay ôtô thì đều là phương tiện, ý tưởng là chúng ta sẽ tạo ra 1 mảng chứa các "phương tiện". Bằng cách đó, chúng ta có thể lưu trong mảng đó cả xe máy lẫn ôtô. Tuy nhiên, nếu chúng ta làm như thế, chúng ta sẽ mất đi thông tin về bản chất của đối tượng. Vậy nên, chúng ta sẽ cần sử dụng đến mảng chứa tham chiếu hoặc mảng chứa con trỏ để thực hiện các xử lý đa hình. Và bởi vì mảng chứa các tham chiếu thì không tồn tại, chúng ta không có quá nhiều sự lựa chọn ngoài việc sử dụng mảng con trỏ để thao tác.
Đây là 1 ứng dụng khác của con trỏ ngoài những tác dụng mà chúng ta đã từng nhắc đến trong bài học về con trỏ.
int main(){ vector<PhuongTien*> danhSachPhuongTien; return 0; }
Đây là 1 tập hợp đa hình vì về cơ bản, nó có thể chứa các cá thể thuộc nhiều kiểu dữ liệu khác nhau.
Chúng ta sẽ bắt đầu bằng việc điền các giá trị vào mảng. Bởi vì chúng ta chỉ cần con trỏ để truy cập tới các đối tượng PhuongTien của chúng ta nên không cần thiết phải tạo ra từng cái tên cho các đối tượng và có thể trực tiếp tạo ra chúng thông qua phân bổ động rồi lưu trong mảng.
int main(){ vector<PhuongTien*> danhSachPhuongTien; danhSachPhuongTien.push_back(new XeOto(30000, 4)); //Them vao danh sach 1 xe oto tri gia 15000 USD va co 4 cua danhSachPhuongTien.push_back(new XeOto(20000, 2)); danhSachPhuongTien.push_back(new XeMay(5000, 200)); //1 chiec xe may tri gia 5000 USD va co van toc toi da la 200 km/h return 0; }
Các đối tượng không thực sự nằm trong các ô nhớ của mảng mà ở đấy chỉ có các con trỏ trỏ tới ô nhớ chứa chúng.
Khi dùng phân bổ động với new
để tạo ra đối tượng, nhớ đừng quên sử dụng delete
để giải phóng bộ nhớ sau khi dùng xong. Chúng ta sẽ cần sử dụng đến vòng lặp.
int main(){ vector<PhuongTien*> danhSachPhuongTien; danhSachPhuongTien.push_back(new XeOto(30000, 4)); //Them vao danh sach 1 xe oto tri gia 15000 USD va co 4 cua danhSachPhuongTien.push_back(new XeOto(20000, 2)); danhSachPhuongTien.push_back(new XeMay(5000, 200)); //1 chiec xe may tri gia 5000 USD va co van toc toi da la 200 km/h for(int i(0); i < danhSachPhuongTien.size(); i++){ delete danhSachPhuongTien[i]; //Giai phong o nho thu i danhSachPhuongTien[i] = 0; //Dua con tro ve 0 } return 0; }
Giờ thì chỉ còn việc sử dụng những đối tượng mà chúng ta vừa tạo ra thôi.
int main(){ vector<PhuongTien*> danhSachPhuongTien; danhSachPhuongTien.push_back(new XeOto(30000, 4)); //Them vao danh sach 1 xe oto tri gia 15000 USD va co 4 cua danhSachPhuongTien.push_back(new XeOto(20000, 2)); danhSachPhuongTien.push_back(new XeMay(5000, 200)); //1 chiec xe may tri gia 5000 USD va co van toc toi da la 200 km/h danhSachPhuongTien [0]->hienThi(); //Hien thi thong tin cua xe oto dau tien danhSachPhuongTien [2]->hienThi(); for(int i(0); i < danhSachPhuongTien.size(); i++){ delete danhSachPhuongTien[i]; //Giai phong o nho thu i danhSachPhuongTien[i] = 0; //Dua con tro ve 0 } return 0; }
Trình biên dịch đã gọi đúng các phương thức xử lý bởi vì chúng ta đã thỏa mãn đủ các yêu cầu để phân giải liên kết động.
Dành cho 1 số bạn muốn rèn luyện thêm, sau đây là 1 số cải tiến chúng ta có thể thêm vào đoạn mã :
Và ngoài ra tất cả những thứ hấp dẫn các bạn có thể tưởng tượng ra được về trạm bảo dưỡng xe của chúng ta.
Các bạn đã thử ý tưởng về phương thức lấy thông tin về số bánh xe mà tôi nhắc đến ở trên chưa ? Nếu vẫn chưa thì bây giờ là lúc để thực hiện nó đấy. Các bạn sẽ nhận thấy những điều rất thú vị.
class PhuongTien{ public: PhuongTien(int giaTri); virtual void hienThi() const; //Hien thi thong tin ve phuong tien virtual int soBanhXe() const; //Tra ve so banh cua phuong tien virtual ~PhuongTien(); protected: int m_giaTri; //Moi phuong tien co 1 gia tri nhat dinh }; class XeOto : public PhuongTien{ //Oto la 1 phuong tien public: XeOto(int giaTri, int soCua); virtual void hienThi() const; virtual int soBanhXe() const; //Tra ve so banh cu axe oto virtual ~XeOto(); private: int m_soCua; //So cua cua xe oto };
Không có vấn đề gì với tệp .h
của chúng ta. Thế nhưng mã xử lý trong tệp .cpp
lại không đơn giản như vậy.
int PhuongTien::soBanhXe() const{ //Ket qua tra ve la bao nhieu ?????? } int XeOto::soBanhXe() const{ return 4; }
Chúng ta không biết phải xử lý phương thức của lớp PhuongTien như thế nào bởi vì số bánh xe của 1 phương tiện bất kỳ là không xác định, có thể là 2 bánh như xe máy hay 4 bánh như ôtô. Vấn đề là phương thức này không mang 1 ý nghĩa cụ thể nào nhưng chúng ta lại không thể xóa nó trong lớp mẹ bởi nếu không có nó, chúng ta không thể thao tác được từ trong tập hợp.
Vậy nên chúng ta cần giữ phương thức này lại nhưng đồng thời cũng không cho phép trình biên dịch gọi nó. Nói cách khác, chúng ta cần thông báo cho trình biên dịch là trong tất cả các lớp con của PhuongTien sẽ có 1 phương thức soBanhXe()
nhưng phương thức này lại không tồn tại trong PhuongTien.
Đây là cái mà chúng ta gọi là phương thức thuần ảo.
Để khai báo 1 phương thức là thuần ảo vô cùng đơn giản, các bạn chỉ cần thêm =0
vào cuối của nguyên mẫu của phương thức.
class PhuongTien{ public: PhuongTien(int giaTri); virtual void hienThi() const; //Hien thi thong tin ve phuong tien virtual int soBanhXe() const = 0; //Tra ve so banh cua phuong tien virtual ~PhuongTien(); protected: int m_giaTri; //Moi phuong tien co 1 gia tri nhat dinh };
Trong tệp PhuongTien.cpp
, chúng ta có thể không viết mã xử lý hoặc thậm chí xóa hẳn phương thức này vì nó không có ý nghĩa gì cả. Tất cả đã được thể hiện trong tệp .h
.
1 lớp mà có chứa ít nhất 1 phương thức thuần ảo thì lớp đó là 1 lớp trừu tượng (abstract class). Vậy nên lớp PhuongTien là 1 lớp trừu tượng.
Tại sao phải đặt 1 tên đặc biệt cho những lớp này ? Đó là bởi vì có 1 quy tắc quan trong cần ghi nhớ : không thể tạo ra 1 đối tượng từ 1 lớp trừu tượng
Đúng vậy, dòng lệnh sau sẽ không được biên dịch.
PhuongTien p(10000); //Tao ra 1 phuong tien gia 10000 USD.
Trong ngôn ngữ của lập trình viên thì chúng ta nói là không thể thực thể hóa 1 lớp trừu tượng. Lý do rất đơn giản : nếu có thể tạo ra 1 đối tượng PhuongTien thì sẽ có thể thông qua đó gọi phương thức soBanhXe()
trong khi phương thức này không có mã xử lý. Vậy nên việc này là không thể.
Thế nhưng đoạn mã sau là hoàn toàn hợp lệ.
int main(){ PhuongTien* ptr(0); //Con tro tro len 1 phuong tien XeOto lexus(250000,4); //Tao ra 1 doi tuong xe oto ptr = &lexus; //Huong con tro len doi tuong vua tao cout << ptr->soBanhXe() << endl; //Trong lop con, co ton tai phuong thuc soBanhXe() nen khong co loi bien dich return 0; }
Trong trường hợp này, phương thức soBanhXe()
là 1 xử lý đa hình nên phương thức được gọi và sử dụng là phương thức được định nghĩa trong lớp XeOto. Vậy nên dù phương thức trong lớp PhuongTien không tồn tại thì cũng không dẫn đến lỗi biên dịch.
Nếu các bạn muốn tạo ra thêm các lớp con khác của PhuongTien như XeTai, chúng ta sẽ bắt buộc phải định nghĩa lại phương thức soBanhXe()
cho nó nếu không phương thức này sẽ thuần ảo do kế thừa từ lớp mẹ và lớp XeTai của chúng ta cũng sẽ trở thành 1 lớp trừu tượng.
Nói tóm lại :
Trong thư viện Qt mà chúng ta tìm hiểu tới đây sẽ có rất rất nhiều các lớp trừu tượng, ví dụ như lớp QAbstractButton chứa rất nhiều điểm chung của các nút bấm. Thế nhưng để người dùng không thể trực tiếp thực thể hóa lớp này mà phải sử dụng các lớp thừa kế của nó, những người viết nên thư viện này đã khai báo đây là 1 lớp trừu tượng.