GIẢI THÍCH CÁC KHÁI NIỆM TRONG OOP – TÍNH ĐA HÌNH – phần 2


Trước khi đọc bài này, xin hãy ngẩng đầu lên trời đọc câu sau 3 lần: “Khi tôi học gì thì phải hiểu đến tận gốc rễ!

Hãy đọc qua phần 1 nếu bạn chưa biết đa hình là gì.

Trong phần 2, ta sẽ tìm hiểu virtual❗️, là chìa khóa cho sức mạnh của đa hình, chúng ta cũng sẽ phải đọc một chút ngôn ngữ Assembly. Đây là cách học hại não🏴‍☠️, nhưng nó đáng giá từng phút bạn học, và đảm bảo nếu chịu khó bạn sẽ hiểu thêm được rất nhiều, vậy nên hãy cố lên nhé.

Đọc để biết thêm về Ngôn ngữ Assembly: https://daohainam.com/2023/03/07/the-nao-la-ngon-ngu-lap-trinh-bac-thap-bac-cao/

Trước tiên xin giới thiệu với bạn một công cụ cho phép ta dịch các chương trình sang assembly (hợp ngữ), ta sẽ dùng công cụ này để khảo sát những gì trình biên dịch tạo ra từ chương trình animal.

Bạn truy cập Godbolt tại https://godbolt.org/z/KiMvdD.

Trở lại với chương trình animal, với 2 lớp Dog và Fish. Ta sẽ dịch nó sang mã assembly và xem những gì thực sự xảy ra.

Trước tiên bạn hãy đọc đoạn code sau, nếu không hiểu thì nên bỏ qua bài này và học lại cơ bản C++, ngược lại sao chép và dán vào cửa sổ bên trái (hoặc nhấn vào link sau để mở đoạn code tôi đã viết sẵn: https://godbolt.org/z/KiMvdD):

#include <iostream>

class Animal {

public:

void move() { printf("Moving"); }

};

class Dog : public Animal {

public:

void move() { printf("Running"); }

};

class Fish : public Animal {

public:

void move() { printf("Swimming"); }

};

Dog dog;

Fish fish;

int main() {

Animal *a;

a = &dog;

a->move();

a = &fish;

a->move();

}

Bên phải bạn chọn trình dịch là “x86-64 gcc 9.3” (bạn có thể chọn trình dịch mới hơn, tuy nhiên có thể code sinh ra sẽ không giống trong bài này và bạn sẽ khó theo dõi).

Trên cửa sổ Execution (cửa sổ thứ 3), bạn có thể thấy kết quả chạy của chương trình: MovingMoving. Đây là kết quả của 2 lần gọi a->move().

Cửa sổ bên phải là mã assembly được dịch từ chương trình của bạn. Nếu chưa biết assembly (asm) là gì thì nó là một ngôn ngữ tương đương mã máy, mỗi lệnh asm sẽ đại diện cho chính xác 1 mã máy, vì mã máy chỉ là các con số nên rất khó nhớ và viết nên người ta tạo ra một ngôn ngữ thân-thiện-với-con-người hơn.

Tôi sẽ giải thích từng lệnh một để giúp bạn hiểu.

Các dòng đầu tiên khai báo các hằng chuỗi có trong chương trình của bạn, ở đây bạn sẽ thấy chuỗi “Moving”, hay nói cách khác, sẽ có một vùng nhớ được tạo ra với dữ liệu là “Moving” khi chương trình được nạp vào bộ nhớ.

Nếu nhìn vào dòng 3 đến 13, bạn sẽ thấy khai báo phương thức Animal::move(), nó được dịch ra từ void move() { printf(“Moving”); }. Nội dung của nó là lấy địa chỉ của chuỗi “Moving” đưa vào thanh ghi EDI, sau đó gọi hàm printf (printf được khai báo trong một thư viện khác nên ta sẽ không thấy xuất hiện trong mã assembly).

Bây giờ ta tìm phương thức Dog::move() và Fish::move(), bạn di chuyển xuống dòng 82 bên mã assembly.

Bạn đã tìm thấy 2 phương thức trên chưa? Chưa phải không? Nó có được dịch ra đâu mà thấy 😃.

❗️ Quá trình tối ưu mã của trình dịch gcc sẽ loại bỏ tất cả các thành phần không được dùng tới. Như vậy có nghĩa là 2 hàm Dog::move() và Fish::move() không bao giờ được gọi (nên mới bị loại bỏ).

Kiểm tra hàm main, ta sẽ thấy 2 lời gọi hàm đến Animal::move() ở dòng 25 và 29.

Vì biến a có kiểu Animal*, và Animal::move không phải là một hàm virtual nên TẤT CẢ các lời gọi a->move() sẽ LUÔN LUÔN gọi đến Animal::move(), kể cả khi a thực sự trỏ đến một thực thể Fish, đồng thời lớp Fish đã override lại move().

Hãy đảm bảo bạn đã hiểu toàn bộ cho đến đoạn này.

Bây giờ bạn hãy thêm từ khóa virtual vào chỗ khai báo Animal::move().

class Animal {
public:
virtual void move() { printf("Moving"); }
};

Nhìn sang bên phải bạn sẽ thấy nó thay đổi rất nhiều:

👉 Trên cửa sổ Excution, kết quả đã thay đổi thành RunningSwimming.

👉 Mã ASM của Animal::move() biến mất, thay thế vào đó là Dog::move() và Fish::move(), thậm chí cả chuỗi “Moving” cũng biến mất, và thay vào là “Running” và “Swimming”. Như trên đã nói điều đó đồng nghĩa với việc hàm Animal::move hoàn toàn không được gọi.

👉 Vào trong hàm main, bạn sẽ không thấy lời gọi TRỰC TIẾP đến Dog::move() hay Fish::move(), mà thay vào đó là một chuỗi lệnh rối rắm (dấu ; trong asm tương đương ký hiệu ghi chú // trong C++):

mov QWORD PTR [rbp-8], OFFSET FLAT:dog ; a = &dog, dog ở đây là 1 biến được khai báo là: .quad vtable for Dog+16, tức là 1 biến chứa địa chỉ của mảng “vtable for Dog” rồi + 16.

Nhìn xuống “vtable for Dog”, ta thấy nó được khai báo như sau:

vtable for Dog:

.quad 0 ; [0]

.quad typeinfo for Dog ; [1]

.quad Dog::move() ; [2]

Do “vtable for Dog” là một mảng 3 quad (1 quad là một giá trị 64bit – tức 8 byte), với 3 giá trị tương ứng là:

vtable for Dog[0] = 0

vtable for Dog[1] = địa chỉ của typeinfo for Dog

vtable for Dog[2] = địa chỉ của hàm Dog::move

“vtable for Dog” là một bảng chứa địa chỉ các hàm virtual của lớp dog, ta gọi nó nó là một virtual table.

“vtable for Dog+16” chính là vtable for Dog[2].

Tới đây QWORD PTR [rbp-8] – chính là biến a, đang chứa địa chỉ của biến dog, hay nói cách khác a là một con trỏ tới dog.

Bản thân dog cũng là 1 con trỏ đến “vtable for Dog[2]”.

Trong “vtable for Dog[2]” lại chứa địa chỉ của Dog::move.

Hay viết ngắn gọn ta có: a -> dog -> vtable for Dog[2] -> Dog::move (ký hiệu -> ta đọc là trỏ tới).

Giờ khảo sát tới các câu lệnh tiếp theo:

mov rax, QWORD PTR [rbp-8] ; RAX = a = &dog (xem thêm bài 32bit 64bit để biết RAX là gì)

mov rax, QWORD PTR [rax] ; RAX = *a = dog

mov rdx, QWORD PTR [rax] ; RDX = *dog = vtable for Dog[2], tới đây RDX đang chứa địa chỉ của Dog::move()

mov rax, QWORD PTR [rbp-8]

mov rdi, rax

call rdx ; gọi hàm có địa chỉ trong RDX, hay nói cách khác ta gọi đến Dog::move

Có thể bạn chưa hiểu rõ lắm, nhưng ta có thể tổng kết lại như sau:

👉 Nếu không có từ khóa virtual, lời gọi sẽ chỉ trực tiếp đến hàm cụ thể đó luôn. Nếu a có kiểu Animal*, vậy a->move() sẽ luôn là Animal::move(), dù cho a đang trỏ đến một đối tượng thuộc lớp Dog.

👉 Nếu có từ khóa virtual, lớp đó sẽ có thêm một bảng gọi là virtual table (“vtable for Dog” cho lớp Dog, hay “vtable for Fish” cho lớp Fish), đó là một bảng chứa địa chỉ của các hàm virtual có trong lớp đó. Khi gọi đến một hàm, ta sẽ lấy địa chỉ của hàm tương ứng từ trong bảng, như vậy địa chỉ đó chứa hàm nào thì hàm đó được gọi.

👉 Trong virtual table của Fish, địa chỉ của hàm move sẽ là địa chỉ của Fish::move(), trong virtual table của Dog, địa chỉ của hàm move sẽ là địa chỉ của Dog::move(), nhờ cách gọi gián tiếp này mà hàm tương ứng trong lớp con sẽ được gọi. Bây giờ nếu a = &dog thì a->move() sẽ là Dog::move().

👉 Sau này, thậm chí là khi chương trình của bạn đã được dịch và chạy, bạn hoàn toàn có thể tạo một lớp Bird mới thừa kế từ Animal, tải nó vào bộ nhớ rồi thực thi hàm Bird::move cũng bằng cách gọi a->move().

Link đến mã nguồn file animal.cpp và các file ASM: https://github.com/daohainam/oop-virtual-keyword-demo

Hãy cố gắng đọc bài này, nếu có thể hiểu toàn bộ, tôi tin là bạn đã bước lên một level mới. Nếu đọc một lần không hiểu, hãy đọc 5 lần, 10 lần (giống như tôi trước đây thôi). Thực sự ngay khi viết bài này chính tôi cũng phải kiểm tra đi kiểm tra lại vì sợ nhầm lẫn (con trỏ trỏ đến con trỏ trỏ đến con trỏ trỏ đến con trỏ 😐 ). Nhưng tin tôi đi, bạn sẽ biết thêm nhiều thứ hơn OOP nhiều!

☠️ Khi đi làm bạn sẽ phải cạnh tranh với hàng ngàn hàng vạn lập trình viên khác, bạn nghĩ bạn sẽ vượt lên họ bằng cách học giống như họ ư???

☠️ Bạn cũng đang nghĩ tôi sẽ có lương thật cao dù tôi cũng chỉ làm được thứ mà hàng vạn người khác cũng làm được sao?

2 thoughts on “GIẢI THÍCH CÁC KHÁI NIỆM TRONG OOP – TÍNH ĐA HÌNH – phần 2

  1. Viết thêm bài về tính đóng gói và tính kế thừa đi admin.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s