Search…

Virtual Table Và Hiện Thực Hóa Polymorphism

Nguyễn Hữu HiếuNguyễn Hữu Hiếu
06/08/20209 min read
Cơ chế vận hành của phương thức ảo, tính đa hình trong lập trình hướng đối tượng với C++.

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 BaseDerived:

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 
};
Virtual table của Base class trong C++.
Virtual Table của Base class
Virtual Table của Derived class

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ểu Base chỉ có thể truy xuất đến các thuộc tính và phương thức của lớp Base, lớp Base không thể truy xuất đến các thuộc tính và phương thức của Derived1, 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ức Base::function2().
  • Đối với Virtual Table của lớp Derived1, đối tượng có kiểu Derived1 sẽ có thể truy xuất đến thuộc tính và phương thức của lớp Base và lớp Derived1. Tuy nhiên Derived1 đã override phương thức function1() làm cho phương thức Derived1::function1() kế thừa “cao hơn” so với Base::function1(). Do đó, ô trống thứ nhất trên Virtual Table của lớp Derived1 sẽ trỏ đến phương thức Derived1::function1(), ô trống thứ hai sẽ trỏ đến Base::function2() vì lớp Derived1 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ớp Derived1, tuy nhiên lúc này ô trống thứ nhất trên Virtual Table của lớp Derived2 sẽ trỏ đến phương thức Base::function1(), còn ô trống thứ 2 sẽ trỏ đến phương thức Derived2::function2().
Ví dụ về Virtual Table.
Virtual Table của các class

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ủa Derived1.
  • Bước 3: tìm kiếm phương thức function1() trong Virtual Table của Derived1 để gọi đến. Lúc này, phương thức function1() được thiết lập là Derived1::function1(). Do đó, pointer_type_Base->function1() sẽ thực hiện phương thức function1() của lớp Derived1.

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.
IO Stream

IO Stream Co., Ltd

developer@iostream.co
383/1 Quang Trung, ward 10, Go Vap district, Ho Chi Minh city
Business license number: 0311563559 issued by the Department of Planning and Investment of Ho Chi Minh City on February 23, 2012

©IO Stream, 2013 - 2024