Virtual Tables (vtable) trong C++

Đã bao giờ bạn build source code và gặp lỗi linking kiểu như thế này chưa ?

Ngày xưa gặp lỗi này mình cứ ngồi chửi compiler báo ngũ vãi chả hiểu đếch gì. Sau hiểu vtable, vpointer là cái gì thì thấy hóa ra là mình ngu chứ không phải nó :D.

Trong bài viêt này này mình sẽ giải thích cho anh em hiểu bản chất của Virtual Tables (vtable) và vpointer trong C++, đây là các khái niệm quan trọng nhưng có rất nhiều người không biết.


Trước hết chúng ta cần hiểu khái niệm “static dispatch” trong C++ là gì

Trong câu chuyện chúng ta đang nói thì có thể hiểu “dispatch” là việc tìm đúng địa chỉ của hàm cần call. Trong trường hợp thông thường, khi không dùng hàm ảo (virtual method) thì khi chúng ta định nghĩa một hàm trong class thì lúc biên dịch compiler sẽ lưu địa chỉ hàm và jump đến địa chỉ này để thực thi mỗi khi có lời gọi đến hàm này.

Ví dụ

Trong ví dụ trên, compiler sẽ ghi nhớ địa chỉ của hàm các hàm method1(), method2() của class Foo. Địa chỉ của các hàm ngày sẽ chương trình jump đến và thực thi mỗi khi có lời gọi đến method1(), method2() trên đối tượng của class Foo. Ở đây compiler đã xác định được hàm nào cần phải call ngay khi biên dịch nên quá trình này gọi là “static dispatch”

Lưu ý rằng mỗi hàm method1, method2 của class Foo chỉ có 1 địa chỉ tương ứng duy nhất tồn tại và được sử dụng chung cho tất cả các đối tượng của class Foo.


Tiếp theo, giờ là lúc chém về “vtable”, “vpointer” và “dynamic dispatch”

Trong C++ sẽ có những trường hợp mà compiler không xác định được địa chỉ của hàm cần thực thi  ngay trong lúc biên dịch. Đó là trường hợp sử dụng virtual method trong class →

(anh em nào nếu không nhớ virtual method thì xem lại ở đây nhé)

Đặc điểm của virtual method  là nó có thể được/bị ghi đè (overriden) bởi subclasses (lớp con – lớp dẫn xuất). Ví dụ →

Hãy cùng phân tích lời gọi đến method1() trong đoạn code sau →

Trong trường hợp này nếu compiler áp dụng “static dispatch” thì hàm Base::method1() sẽ được thực thi bởi vì con trỏ baseObj  là con trỏ có kiểu là class Base. Nhưng rõ ràng là trong trường hợp này baseObj trỏ đến đối tượng của class Derived  nên hàm Derived ::method1() phải được thực thi mới hợp lý. Do đó trường hợp này compiler sẽ không áp dụng “static dispatch”. Với những trường hợp call đến virtual method thông qua con trỏ của class cha như ví dụ này thì chương trình phải tìm địa chỉ của hàm cần thực thi ở runtime, cái này gọi là “dynamic dispatch”.

Vậy “dynamic dispatch” được thực hiện như thế nào ?

Khi biên dịch chương trình, với mỗi class có chứa virtual method thì compiler sẽ tạo ra một cái gọi là virtual table (vtable). vtable sẽ lưu địa chỉ của các hàm virtual có thể call thông qua đối tượng của class đó. Các phần tử của vtable có thể trỏ đến địa chỉ của hàm được định nghĩa bởi chính class đó (class đó override lại virtual method của class cha, ví dụ: Derived::method1()) hoặc trỏ đến virtual method được kế thừa từ class cha (Base::method2()). Hãy xem kỹ hình bên dưới →

Trong ví dụ này vtable của class BaseDerived đều có 2 phần tử tương ứng với 2 virutal method. vtable của class Base có lẽ không cần giải thích nhiều, nói về vtable của class Derived một chút. Vì Derived override lại method1() nên method1 trong vtable của nó trỏ tới địa chỉ của Derived::method1(), class Derived không override method2() nên nó sẽ kế thừa từ class Base, method2 trong vtable trỏ tới địa chỉ của Base::method2().

Chú ý là chỉ có 1 vtable cho mỗi class và nó được share dùng chung cho tất cả các đối tượng của class.

Vpointer

Đến đây chúng ta đã biết vtable là gì. Nhưng vtable tham gia vào “dynamic dispatch” như thế nào ? Hãy cùng tìm hiểu.

vtable sẽ được sử dụng gián tiếp thông qua vpointer. Trong quá trình biên dịch, khi compiler tạo ra vtable thì đồng thời nó cũng sẽ tự động thêm một biến member gọi là vpointer vào cùng class đó. Điều này đồng nghĩa với việc mỗi object của class có vtable sẽ có thêm 1 biến member là vpointer và điều đó làm cho size của nó tăng lên một lượng là sizeof(vpointer) bytes.

Và đây là cách mà “dynamic dispatch” hoạt động: Trong lúc runtime, khi một lời gọi đến hàm ảo trên một đối tượng được thực hiện thì vpointer của đối tượng đó sẽ được sử dụng để tìm vtable tương ứng của class. Tiếp theo, tên hàm sẽ được sử dụng để tra cứu trong vtable để tìm ra đúng địa chỉ của hàm cần thực thi.


Hy vọng bài viết này sẽ giúp các CppDeveloper hiểu được bản chất của virtual method, vtable, vpointer qua đó code ngày càng pro hơn.

— Phạm Minh Tuấn (Shun) —