Binding là gì?
Một chương trình C++ được thực thi một cách liên tục nhưng khi gặp phải một lời gọi phương thức, quá trình thực thi "nhảy đến" điểm bắt đầu của phương thức được gọi.
Điều đó đã được thực hiện như thế nào? Khi một chương trình được biên dịch, trình biên dịch sẽ chuyển đổi từng dòng lệnh C++ thành các đoạn mã máy. Mỗi đoạn mã máy này đều có một địa chỉ duy nhất và xác định, phương thức cũng không ngoại lệ. Khi trình biên dịch dịch đến một phương thức, nó sẽ chuyển đổi phương thức này sang mã máy và cho nó một địa chỉ xác định.
Binding là một cơ chế trong lập trình được sử dụng để chuyển đổi các "ký hiệu nhận dạng" (ứng với phương thức và biến) trở thành ngôn ngữ máy. Mặc dù static binding được sử dụng cho cả phương thức và biến nhưng trong bài viết này tập trung vào static binding của phương thức.
- Early binding (static binding): khái niệm chỉ sự liên kết trực tiếp giữa "ký hiệu nhận dạng" với địa chỉ bằng mã máy của nó. Mọi phương thức đều có một địa chỉ bằng mã máy duy nhất. Vì thế khi trình biên dịch gặp một lệnh gọi phương thức sẽ thay thế bằng một câu lệnh bằng mã máy ra lệnh "nhảy đến" địa chỉ của phương thức đó.
- Late binding (dynamic binding): trong chương trình thực tế, không phải lúc nào trình biên dịch cũng có thể xác định được phương thức nào sẽ được gọi cho đến khi chương trình thực thi, tùy vào ngữ cảnh tại thời điểm gọi mới xác định địa chỉ phương thức cần gọi.
Early binding
#include <iostream>
int Add(int x, int y) { return x + y; }
int main() { std::cout << Add(1, 2); return 0; }
Trong ví dụ trên, khi biên dịch đến đoạn lệnh có lời gọi đến phương thức Add(int x, int y)
, trình biên dịch sẽ thay bằng đoạn mã máy ra lệnh nhảy đến địa chỉ của phương thức Add(int x, int y)
.
Late binding
#include <iostream> int Add(int x, int y) { return x + y; } int Subtract(int x, int y) { return x - y; } int main() { int x = 0; int (*ptfOperator)(int, int);
std::cout << "Enter an operator (1 = add, 2 = subtract): "; std::cin >> x;
if (x == 1) { ptfOperator = Add; } else { ptf_Operator = Subtract; }
std::cout << ptfOperator(1,2);
return 0; }
Như ví dụ trên khi gọi phương thức thông qua con trỏ hàm thì trình biên dịch sẽ không thể sử dụng Early binding để giải quyết lời gọi hàm vì nó không biết được con trỏ hàm *ptfOperator
sẽ trỏ đến phương thức nào trong quá trình biên dịch vì lúc này phương thức mà *ptfOperator
trỏ đến phụ thuộc vào kết quả mà người dùng nhập vào, điều mà chỉ xảy ra trong quá trình thực thi.
So sánh giữa hai khái niệm binding với nhau có thể thấy được late binding cơ động hơn early binding do nó có thể quyết định được phương thức nào sẽ được gọi trong quá trình thực thi chương trình. Nó còn được sử dụng để hiện thực hóa các phương thức ảo (Virtual Function).
Virtual Table là gì?
Để hiện thực hóa các phương thức ảo (virtual function) từ đó cho ra đời Polymorphism (tính đa hình, đa xạ), C++ sử dụng một hình thức đặc biệt của late binding là Virtual Table.
Virtual Table là một bảng tra cứu các phương thức được sử dụng để giải quyết các lệnh gọi hàm trong quá trình late binding. Virtual Table đôi khi được gọi là vtable, virtual function table, virtual method table, …
- Tất cả những lớp có sử dụng phương thức ảo (hoặc kế thừa từ một lớp có sử dụng phương thức ảo) đều sở hữu Virtual Table.
- Bảng này thực chất là một mảng tĩnh được trình biên dịch thiết lập trong quá trình biên dịch (compile time).
- Mỗi một Virtual Table đều có ô chứa cho từng phương thức ảo có thể được truy xuất từ đối tượng của lớp.
- Mỗi ô chứa trong Virtual Table thực chất là một con trỏ hàm trỏ đến phương thức được kế thừa gần nhất và có thể truy cập từ lớp này.
Giả sử ta có 2 lớp Base
và Derived
:
class Base { public: virtual ~Base(); virtual void f(); virtual void g(); virtual void h(); virtual void r(); void s(); // phương thức bình thường void t(); // phương thức bình thường }; class Derived : public Base { public: virtual ~Derived(); virtual void f(); // phương thức được override virtual void g(); // phương thức được override void x(); // phương thức mới, bình thường void y(); // phương thức mới, bình thường };
Trình biên dịch thêm vào lớp có phương thức ảo một con trỏ, thường được gọi là *__vptr
, được thiết lập một cách tự động khi thể hiện của lớp được tạo ra để nó trỏ vào Virtual Table. Khác với con trỏ this
, là một tham số của hàm được sử dụng để tham chiếu đến chính nó, *__vptr
là một con trỏ thực. Do đó nó làm cho thể hiện của lớp có kích thước lớn hơn kích thước của một con trỏ. Nó cũng đồng nghĩa với việc là con trỏ *__vptr
sẽ được kế thừa bởi lớp con.
Quá trình thiết lập Virtual Table
class Base { public: virtual void function1() {}; virtual void function2() {}; }; class Derived1: public Base { public: virtual void function1() {}; }; class Derived2: public Base { public: virtual void function2() {}; };
Do có 3 lớp được định nghĩa ở đây, nên sẽ có 3 Virtual Table được thiết lập:
- Một cho lớp
Base
. - Một cho lớp
Derived1
. - Một cho lớp
Derived2
.
Trình biên dịch sẽ tự động thêm vào một con trỏ *__vptr
trỏ đến lớp cơ sở thấp nhất (most base class) sử dụng phương thức ảo. Mặc dù điều này được trình biên dịch thực hiện tự động và “ẩn giấu” con trỏ *__vptr
, code mẫu bên dưới sẽ thêm *__vptr
vào để ví dụ để trực quan hơn.
class Base { FunctionPointer *__vptr; public: virtual void function1() {}; virtual void function2() {}; }; class Derived1: public Base { public: virtual void function1() {}; }; class Derived2: public Base { public: virtual void function2() {}; };
Khi thể hiện của một lớp được tạo ra, *__vptr
sẽ trỏ đến Virtual Table tương ứng của lớp đó, ví dụ như khi đối tượng có kiểu Base
được tạo ra, *__vptr
sẽ trỏ đến Virtual Table của lớp Base
, khi đối tượng có kiểu Derived1
hay Derived2
được tạo ra, *__vptr
sẽ trỏ đến Virtual Table của lớp Derived1
hay Derived2
tương ứng.
Bởi vì lớp cha có 2 phương thức ảo, cho nên mỗi Virtual Table sẽ có 2 ô chứa:
- Một cho phương thức
function1()
. - Một cho phương thức
function2()
.
Các ô trống của Virtual Table được lấp đầy bởi các con trỏ hàm trỏ đến phương thức được kế thừa cao nhất (most-derived function) mà lớp đó có thể truy xuất đến được.
- Đối với Virtual Table của lớp
Base
, đối tượng có kiểuBase
chỉ có thể truy xuất đến các thuộc tính và phương thức của lớpBase
, lớpBase
không thể truy xuất đến các thuộc tính và phương thức củaDerived1
,Derived2
. Do đó, ô trống thứ nhất trên Virtual Table sẽ trỏ đến phương thức Base::function1()
và ô trống thứ hai sẽ trỏ đến phương thứcBase::function2()
. - Đối với Virtual Table của lớp
Derived1
, đối tượng có kiểuDerived1
sẽ có thể truy xuất đến thuộc tính và phương thức của lớpBase
và lớpDerived1
. Tuy nhiênDerived1
đã override phương thứcfunction1()
làm cho phương thứcDerived1::function1()
kế thừa “cao hơn” so vớiBase::function1()
. Do đó, ô trống thứ nhất trên Virtual Table của lớpDerived1
sẽ trỏ đến phương thứcDerived1::function1()
, ô trống thứ hai sẽ trỏ đếnBase::function2()
vì lớpDerived1
không override phương thức này. - Đối với Virtual Table của lớp
Derived2
, cũng khá tương tự như lớpDerived1
, tuy nhiên lúc này ô trống thứ nhất trên Virtual Table của lớpDerived2
sẽ trỏ đến phương thứcBase::function1()
, còn ô trống thứ 2 sẽ trỏ đến phương thứcDerived2::function2()
.
Cách thức hoạt động của Virtual Table
Sau quá trình lý giải về Virtual Table, xem xét 1 ví dụ minh họa như sau:
int main() { Derived1 object_type_Derived1; return 0; }
Do đối tượng object_type_Derived1
có kiểu là Derived1
nên *__vptr
của đối tượng này sẽ trỏ đến Virtual Table của Derived1
. Tiến hành thêm vào 1 con trỏ có kiểu là Base
.
int main() { Derived1 object_type_Derived1; Base* pointer_type_Base = &object_type_Derived1; return 0; }
Lưu ý pointer_type_Base
là một con trỏ kiểu Base
, nên nó chỉ có thể truy xuất đến các thành phần của lớp Base
bên trong đối tượng object_type_Derived1
. Tuy nhiên, con trỏ *__vptr
lại là thành phần của lớp Base
cho nên con trỏ pointer_type_Base
có thể truy xuất đến nó. Mà con trỏ *__vptr
lại trỏ đến Virtual Table của Derived1
. Dẫn đến mặc dù pointer_type_Base
là một con trỏ kiểu Base
nhưng nó vẫn có khả năng truy xuất đến Virtual Table của Derived1
. Vậy chuyện gì sẽ xảy ra nếu như chúng ta gọi pointer_type_Base->function1()
?
int main() { Derived1 object_type_Derived1; Base* pointer_type_Base = &object_type_Derived1; pointer_type_Base->function1(); return 0; }
- Bước 1: chương trình sẽ phát hiện ra rằng
function1()
là một phương thức ảo. - Bước 2:
pointer_type_Base->*__vptr
sẽ truy cập đến Virtual Table củaDerived1
. - Bước 3: tìm kiếm phương thức
function1()
trong Virtual Table củaDerived1
để gọi đến. Lúc này, phương thứcfunction1()
được thiết lập làDerived1::function1()
. Do đó,pointer_type_Base->function1()
sẽ thực hiện phương thứcfunction1()
của lớpDerived1
.
Bằng việc sử dụng Virtual Table, trình biên dịch và chương trình có thể đảm bảo việc phương thức ảo luôn được gọi một cách chính xác, kể cả khi nó được gọi bởi con trỏ của lớp cha.
Phương thức ảo được gọi lâu hơn phương thức bình thường bởi các lý do sau:
- Máy tính phải sử dụng
*__vptr
để truy cập đến Virtual Table tương ứng. - Máy tính phải tìm kiếm trong Virtual Table để ra được phương thức ảo tương ứng.