Tính đa hình trong lập trình hướng đối tượng¶
Estimated time to read: 21 minutes
Mar 01, 2021 · ~5 minutes
Sơ lược về đa hình¶
Liên kết (blinding)¶
Liên kết là sự kết nối một lời gọi phương thức này tới thân phương thức khác.
Liên kết tĩnh (Static blinding)¶
Liên kết tĩnh là loại liên kết do compiler quyết định (hay còn gọi là quyết định tại thời điểm biên dịch).
Khi kiểu của đối tượng được quyết định tại compile time (bởi Compiler) thì đó là liên kết tĩnh.
Trong phương pháp lập trình hướng đối tượng, liên kết tĩnh không thể có ghi đè (overloading - tôi sẽ nói kĩ hơn ở phần dưới) kết quả.
Hãy xem xét đoạn chương trình sau
Vì đối tượng giá trị b thuộc lớp đối tượng B đã được hình thành tại thời điểm biên dịch, do đó đây là 1 loại liên kết tĩnh.
Liên kết động (Dynamic blinding)¶
Liên kết động là loại liên kết chỉ xuất hiện khi thực thi (runtime). Ví dụ
Với đoạn chương trình trên, sự kết nối giữa lời gọi lớp A đến lớp B chỉ xuất hiện lúc chương trình được thực thi tức là con trỏ đối tượng thuộc lớp đối tượng A chỉ được tham chiếu đến lớp đối tượng B khi thực thi chương trình. Lúc chưa thực thi chương trình, không có bất kì chuyện gì xảy ra cả. Thì đây là một ví dụ điển hình cho liên kết động.Định nghĩa về đa hình¶
Đa hình là hiện tượng các đối tượng thuộc các lớp đối tượng khác nhau có khả năng hiểu cùng một thông điệp theo các cách khác nhau. Ví dụ thông điệp di chuyển thì đối với người bình thường thì sẽ di chuyển bằng hai chân còn đối với con chó, con mèo sẽ di chuyển bằng bốn chân.
Hãy xét một bài toán cơ bản để hiểu rõ đa hình hơn nhé
Bài toán: Cho lớp đối tượng người gồm những thuộc tính: Họ và tên, tuổi và phương thức nhap
dùng để nhập thông tin, phương thức xuat
dùng để xuất thông tin của người đó. Cho lớp đối tượng sinh viên kế thừa lớp đối tượng người có thêm thuộc tính là mã số sinh viên và cũng có phương thức nhập để nhập thông tin sinh viên, phương thức xuất để xuất thông tin sinh viên.
Yêu cầu hãy viết đoạn chương trình nhập và xuất danh sách các sinh viên được nhập vào.
Dễ dàng ta có thể viết đoạn mã như sau:
enum
để giải quyết bài toán trên một cách tường minh (nhưng khá rườm rà) bằng cách tạo ra một phân loại enum loai{Ng, SV}
và đoạn mã chuyển đổi bên dưới người
và sinh viên
đều cùng có phương thức nhap
cũng như xuat
nhưng cách hiểu hai phương thức của hai đối tượng là khác nhau, thế tại sao không dùng cùng phương thức mà lại đặc thêm phương thức nhapThongTinSinhVien
và xuatThongTinSinhVien
gây thêm dài dòng (tôi không nói việc tạo ra nhiều phương thức là sai mà chỉ là gây ra phức tạp bài toán, dài dòng, khó sửa chữa). Vậy làm sao để thể hiện được việc "cả người
và sinh viên
đều cùng có phương thức nhap
cũng như xuat
nhưng cách hiểu hai phương thức của hai đối tượng là khác nhau" trên code?
Phương thức ảo sẽ giúp ta giải quyết được ván đề này.
Phương thức ảo¶
Phương thức ảo là một trong những cách thể hiện tính đa hình trong ngôn ngữ lập trình C++.
Các phương thức ở lớp cơ sở có tính đa hình phải được định nghĩa là phương thức ảo. Và để thể hiện 1 hàm thành phần là 1 phương thức ảo thì ta chỉ cần thêm từ khóa virtual
trước tên hàm đó là được.
Quan trọng hơn, nhờ phương thức ảo sẽ giúp cho các đối tượng thể hiện cách hiểu của nó đối với một thông điệp nào đó. Và đoạn chương trình trên ta có thể thay bằng.
SinhVien
sẽ kế thừa tất cả các thuộc tính và phương thức của lớp đối tượng Nguoi
và phương thức nhap
với xuat
trong lớp đối tượng Nguoi
đang ở phạm vi public tức lớp đối tượng SinhVien
sẽ sử dụng được 2 phương thức đó: - Đối với phương thức
nhap
, lớp đối tượngSinhVien
đã kế thừa được việc nhập thông tinhọ và tên
và thông tintuổi
tức ở phương thứcnhap
ở lớp đối tượngSinhVien
ta không cần thiết lập thêm việc nhập 2 thông tin đó nữa (tránh bị thừa). Hay nói các khác, trong hàmnhap
của lớp đối tượngSinhVien
, ta chỉ cần thêm định nghĩa cho việc nhập thông tin MSSV mà thôi. - Đối với phương thức
xuat
ta cũng lý giải tương tự và chỉ cần thêm định nghĩa cho việc xuất thông tin MSSV mà thôi.
Từ hai nhận định trên, ta đã có thể rút gọn đoạn mã cho 2 lớp đối tượng Nguoi
và SinhVien
đi rất nhiều. Và đây là mã sau khi rút gọn:
Hơn nữa với đoạn mã trên, không những dễ đọc mà ta còn dễ dàng sửa đổi cũng như thêm vào những định nghĩa mới.
Vấn đề
Tại sao 2 phương thức nhap
và xuat
trong lớp đối tượng Nguoi
là phương thức ảo?
Việc biến 2 phương thức nêu trên thành phương thức ảo nhằm sửa lỗi phân giải tĩnh (tôi đã đề cập ở phân sau).
Nhận xét
Tuy là phương thức nhap
cũng như phương thức xuat
ở lớp đối tượng SinhVien
không có từ khóa virtual
nhưng bù lại phương thức tương ứng của chúng ở lớp Nguoi
(tức lớp cơ sở) lại là phương thức ảo, do đó cả hai phương thức nhap
vàxuat
ở lớp đối tượng SinhVien
cũng đều là phương thức ảo.
Từ đây, ta có 2 cách để một phương thức trở thành phương thức ảo:
- Một là thêm từ khóa
virtual
vào trước tên phương thức. - Hai là phương thức tương ứng của nó ở lớp cơ sở là phương thức ảo.
Đặc biệt, việc thêm từ khóa virtual
ở lớp cơ sở nhằm giúp cho lớp dẫn xuất có thể tự hiểu theo cách mà nó sẽ được hiểu đối với một phương thức nào đó, chứ không mang ý nghĩa là loại bỏ phương thức đó đi. Giống như ở ví dụ trên, đối tượng thuộc lớp đối tượng SinhVien
khi trỏ đến phương thức nhap
(hoặc xuat
) thì nó sẽ hiểu phương thức nhap
(hoặc xuat
) đang trỏ đến nằm ở trong lớp đối tượng SinhVien
chứ không phải lớp đối tượng Nguoi
.
Phương thức hủy ảo (Virtual destructor)¶
Ta thêm từ khóa virtual
trước phương thức hủy bất kì, thì ngay lập tức phương thức hủy đó sẽ trở thành phương thức hủy ảo.
Quay trở lại một đoạn mã tương tự phần destructor
mà ta đã gặp ở phần kế thừa
A
tức là con trỏ a thuộc lớp đối tượng A đã bị hủy. Câu hỏi đặt ra là có chắc chắn rằng sau dòng delete a
được thực thi thì toàn bộ vùng nhớ trong chương trình đều đã được giải phóng không? Ở ví dụ trên vì khá đơn giản nên ta có thể khẳng định rằng toàn bộ vùng nhớ trong chương trình đều đã được giải phóng, nhưng giả sử chương trình trên có 1 phương thức khởi tạo trong lớp đối tượng C và trong phương thức đó có sinh ra các đối tượng con trỏ, thì lúc này toàn vùng nhớ có được giải phóng sau khi ta thực thi lệnh delete a
không? Câu trả lời là không vì muốn toàn bộ vùng nhớ được giải phóng, thì ta phải làm cách nào đó để thực thi tất cả các phương thức hủy của các đối tượng mà ta đã tạo (kể cả khi ta dùng liên kết động).
Lúc này đây, việc làm cho phương thức hủy ở lớp đối tượng A thành phương thức hủy ảo làm hoàn toàn cần thiết và nó giúp cho chúng ta dễ dàng giải quyết vấn đề trên. Đoạn mã hoàn chỉnh:
delete a
tức sẽ thực thi phương thức hủy của lớp đối tượng C (thông qua từ khóa virtual
). Và hiển nhiên, theo tính chất của tính kế thừa thì lần lượt phương thức hủy của lớp đối tượng B và của lớp đối tượng A sẽ được thực thi sau đó. Tóm lại, toàn bộ vùng nhớ sẽ được giải phóng hoàn toàn. Phương thức khởi tạo ảo (Virtual contructor)¶
Rất tiếc, không thể có phương thức khởi tạo ảo.
Phương thức thuần ảo¶
Về mặt lý thuyết, phương thức thuần ảo là phương thức ảo không có nội dung.
Về mặt ngôn ngữ, tùy trình biên dịch sau này mà nó có thể chấp nhận những phương thức ảo có nội dung (nhưng không thể thực thi các nội dung).
Ví dụ ta có một phương thức ảo: void virtual abc(){...}
. Ta biến nó thành phương thức thuần ảo bằng cách thêm =0
vào sau phương thức và không cần định nghĩa nội dung, tức là void virtual abc() = 0
.
Phương thức thuần ảo cũng là nền tảng để xây dựng 1 lớp đối tượng trừu tượng.
Lớp đối tượng trừu tượng¶
Lớp đối tượng trừu tượng là lớp có chứa tối thiểu 1 phương thức thuần ảo.
Một lớp cơ sở khi có chứa tối thiểu 1 phương thức thuần ảo, ta gọi nó là lớp cơ sở trừu tượng.
Ta không thể tạo ra đối tượng giá trị thuộc lớp cơ sở trừu tượng nhưng có thể tạo ra đối tượng con trỏ để thực hiện liên kết động từ lớp cơ sở trừu tượng đến các lớp dẫn xuất (trường hợp lớp dẫn xuất không là 1 lớp trừu tượng).
Các lớp dẫn xuất muốn được tạo đối tượng khi và chỉ khi lớp dẫn xuất đó đã định nghĩa lại toàn bộ các phương thức thuần ảo đã có trong lớp cơ sở trừu tượng. Nếu không, lớp dẫn xuất đó sẽ trở thành một lớp đối tượng trừu tượng. Ví dụ:
action()
của lớp A. Còn đoạn mã bên dưới, thì cả A và B đều không thể tạo đối tượng. Các vấn đề trong đa hình¶
Đa hình tại thời điểm biên dịch (Compiler time)¶
Nạp chồng hàm (Function overloading)¶
Câu hỏi đặt ra là ta đang có một hàm cong
để thực hiện việc cộng hai số, làm thế nào để dùng cùng 1 hàm cong
đó để ta có thể cộng hai số thực, hai số nguyên?
Lúc này, ta có thể định nghĩa hàm cong
với các đối số truyền vào khác nhau để thực hiện chức năng ta cần. Việc làm này được gọi chung là nạp chồng hàm. Xem đoạn mã dưới đây
Dù là cùng 1 hàm cong
, nhưng chỉ cần thay đổi đối số truyền vào và trả về khác kiểu, ta đã thực hiện được chức năng cộng hai số nguyên và cộng hai số thực một cách dễ dàng.
Hoặc hãy xem một ví dụ khác cụ thể hơn về hướng đối tượng
Ở ví dụ trên, ta có lớp đối tượng A
và có 1 phương thức output
thể hiện giá trị của 1 đối số truyền vào bất kì (khác kiểu trả về). Trong hàm main()
ta đã gọi phương thức output
ra với các kiểu đối số truyền vào khác nhau và hiển nhiên kiểu dữ liệu của giá trị đầu ra trong 3 lần gọi là khác nhau.
Quy tắc nạp chồng 1 hàm (phương thức)
- Nó phải có một danh sách đối số khác nhau.
- Nó có thể có một kiểu trả lại khác nhau.
- Nó có thể có các công cụ sửa đổi quyền truy cập khác nhau.
- Nó có thể ném ra các ngoại lệ khác nhau.
- Các phương thức từ một lớp cha có thể được nạp chồng trong một lớp con.
Nạp chồng toán tử (Operator overloading)¶
Câu hỏi đặt ra là tại sao trong lớp đối tượng SinhVien
lại có phương thức nhập và xuất? Mặc dù ta đã biết rằng trong ngôn ngữ lập trình C++ đã cung cấp cho chúng ta 2 loại toán tử >>
(toán tử nhập) và <<
(toán tử xuất). Thì liệu có cách nào dùng chính 2 loại toán tử đó để nhập cũng như xuất đối tượng SinhVien
hay không?
Lúc này, chúng ta cần định nghĩa lại 2 loại toán tử nhập và xuất cho đối tượng SinhVien
để có thể thực hiện được công việc trên. Việc làm này được gọi chung là nạp chồng toán tử.
Trong ngôn ngữ lập trình C++, có rất nhiều loại toán tử khác nhau mà những lập trình viên đã xây dựng từ rất lâu. Hiển nhiên, nếu kiến thức chúng ta đủ rộng và đủ tầm, hoàn toàn có thể tạo ra được các loại toán tử mới.
Trong ngôn ngữ lập trình C++, ta chỉ được quyền nạp chồng những toán tử mà bản thân những toán tử đó cho phép nạp chồng. Và đây cũng chứng tỏ được toán tử có khả năng đa năng hóa.
Hãy xem các ví dụ về nạp chồng toán tử tại Lab này
Đa hình tại thời điểm thực thi (Runtime)¶
Ghi đè hàm (Overriding)¶
Ghi đè phương thức được hiểu đơn giản là việc ta biến 1 thông điệp cho các đối tượng khác nhau hiểu theo những cách khác nhau. Ví dụ trong lớp đối tượng SinhVien
và lớp đối tượng Nguoi
đều có phương thức nhap()
và:
- Với đối tượng
Nguoi
: Hiểu phương thứcnhap()
chỉ ghi nhận 2 thông tin là học và tên và tuổi - Với đối tượng
SinhVien
: Ngoài việc ghi nhận 2 thông tin của đối tượngNguoi
, nó còn ghi nhận thêm thông tin mã số sinh viên
Lúc này, việc ghi đè phương thức là điều hoàn toàn cần thiết và cũng giúp tính đa hình thể hiện rõ hơn trong thời gian thực thi chương trình.
Quy tắc ghi đè 1 hàm (phương thức)
- Nó phải là một phương thức được xác định thông qua mối quan hệ IS-A (thông qua mở rộng hoặc thực hiện). Đây là lý do tại sao bạn có thể thấy nó được gọi là đa hình kiểu con.
- Nó phải có cùng danh sách đối số như định nghĩa phương thức ban đầu. Nó phải có cùng kiểu trả về hoặc kiểu trả về là lớp con của kiểu trả về của định nghĩa phương thức ban đầu.
- Nó không thể có công cụ sửa đổi quyền truy cập hạn chế hơn.
- Nó có thể có một công cụ sửa đổi quyền truy cập ít hạn chế hơn.
- Nó không được thông qua một ngoại lệ mới hoặc đã kiểm tra rộng hơn.
- Nó có thể thông qua 1 cách hẹp hơn, ít hơn hoặc không có ngoại lệ được kiểm tra, ví dụ: một phương thức khai báo IOException có thể bị ghi đè bởi một phương thức khai báo một FileNotFoundException (vì nó là một lớp con của IOException).
- Phương thức ghi đè có thể ném ra bất kỳ ngoại lệ nào chưa được kiểm tra, bất kể phương thức ghi đè có khai báo ngoại lệ hay không.
Lỗi phân giải tĩnh¶
Lỗi phân giải tĩnh1 là lỗi xảy ra khi thực thi chương trình bằng việc ta ghi đè 1 phương thức nhưng gây ra hiện tượng đối tượng hiểu sai thông điệp mà phương thức truyền tải. Ta xem xét đoạn mã sau:
A
. Ở đây, rõ ràng ta đang dùng 1 con trỏ thuộc lớp đối tượng A thực hiện việc liên kết động với lớp đối tượng B và điều mong muốn của ta tại thời điểm này chính là việc thực thi phương thức action()
hiện diện trong lớp đối tượng B, tức là đầu ra mong muốn phải là chữ B
. Với ví dụ trên, đây là một trường hợp điển hình thể hiện lỗi phân giải tĩnh, việc sửa lỗi này cũng khá là đơn giản, ta chỉ cần biến phương thức action()
trong lớp đối tượng A
thành phương thức ảo là được. Đoạn mã hoàn chỉnh sau đây:
Ta có bảng thể hiện sự khác nhau giữa Overriding và Overloading bên dưới:
Tiêu chí so sánh | Overriding | Overloading |
---|---|---|
Thời điểm xảy ra | Thời gian thực thi chương trình | Thời gian biên dịch chương trình |
Mối quan hệ | Dựa trên một phương thức từ mối quan hệ IS-A | Việc Overriding không nhất thiết phải xét quan hệ từ 1 phương thức. Overloading có thể xảy ra trong một lớp. |
Kiểu dữ liệu quan tâm | Các phương thức Overriding được chọn dựa trên kiểu đối tượng | Các phương thức Overloading được chọn dựa trên kiểu biến (tham chiếu). |
Tham khảo thêm¶
Xem thêm về overloading và overriding
-
Xem lỗi phân giải tĩnh tại https://stackoverflow.com/questions/41201266/how-does-resolution-of-class-member-identifiers-works-in-c ↩