Trong C++, mỗi lời gọi hàm ảo phía sau là cả một cơ chế. Đối tượng đa hình mang một con trỏ ẩn vptr trỏ tới vtable — cái bảng liệt kê địa chỉ các hàm ảo của lớp. Gọi obj->f() thực ra là: đọc vptr từ đối tượng, đọc địa chỉ f từ vtable, rồi gọi gián tiếp tới đó. Nghe như hai lần chạm bộ nhớ cộng một cú nhảy — đắt, và đó là lý do bao nhiêu người né virtual ở đường nóng.

Hình dung như gọi điện qua tổng đài công ty. Bạn không quay số trực tiếp cho người cần gặp, mà bảo tổng đài "nối máy cho người này". Nếu cả buổi ai cũng nhờ nối vào đúng một phòng ban, cô tổng đài quen tay, gần như nối tức thì. Nhưng nếu mỗi cuộc lại nhờ nối một phòng ban khác nhau, khó đoán, thì lần nào cô cũng phải tra sổ, chậm hẳn. CPU dự đoán đích của cú gọi ảo y như vậy, bằng một thứ gọi là BTB. Nên câu hỏi thú vị không phải "virtual có đắt không", mà là khi nào nó đắt.

Cùng một dòng code, chênh gần 7 lần

Tôi dựng một lớp cơ sở có hàm ảo cùng bốn lớp con (thân hàm giống hệt), tạo đối tượng qua một factory che kiểu để buộc gọi ảo thật, rồi gọi hàng tỉ lần. Trên máy ARM:

Cách gọi obj->f() ns mỗi lần
Devirtualize (kiểu final, compiler inline) 0,68
Đa hình đơn — mọi đối tượng cùng một kiểu 0,69 — chỉ 1,01x gọi thường
Đa hình loạn — trộn bốn kiểu ngẫu nhiên 4,64 — chậm 6,72 lần

Cái làm tôi thấy dễ chịu là hàng giữa. Gọi ảo trên một tập đối tượng cùng một kiểu chỉ tốn 0,69 ns, gần bằng một gọi hàm thường. Đây là gọi ảo thật — vẫn đọc vptr, đọc vtable, gọi gián tiếp — nhưng vì mọi đối tượng cùng kiểu, vptr luôn trỏ tới cùng một vtable đã nằm sẵn trong cache, và cô tổng đài BTB đoán trúng đích mọi lần. Hai lần đọc vtable mà người ta hay sợ thì chồng lấp với độ trễ thân hàm, coi như miễn phí.

Cú sốc nằm ở hàng cuối. Khi tập đối tượng trộn nhiều kiểu ngẫu nhiên, đích của mỗi cú gọi đổi khó lường, BTB đoán trật liên tục, mỗi lần trật là một cú flush pipeline, phạt gần 4 ns. Kết quả: 4,64 ns, chậm 6,72 lần. Cùng đúng một dòng obj->f(), cùng thân hàm — chỉ khác ở chỗ các đối tượng bạn duyệt có cùng kiểu hay không.

Và còn một lối thoát nữa: nếu trình biên dịch biết chắc kiểu thật — vì lớp đánh dấu final, vì bạn gọi trên một đối tượng cụ thể — nó devirtualize luôn, đổi gọi ảo thành gọi trực tiếp rồi inline, xóa sạch cả vtable lẫn cú nhảy. Đó là hàng đầu, 0,68 ns, không còn dấu vết ảo nào.

Hai định kiến trái ngược

Tôi bước vào với định kiến bi quan: gọi ảo luôn tốn hai lần đọc bộ nhớ cộng một cú gọi gián tiếp, nên né OOP ở đường nóng. Đo xong mới thấy đơn hình gần như miễn phí, và compiler nhiều khi còn devirtualize hộ. Né mọi virtual một cách máy móc là tối ưu cho một cái giá thường không có thật.

Nhưng đừng tin điều ngược lại, rằng "gọi ảo là một cú tra bảng cố định, chi phí lúc nào cũng thế". Không. Đa hình thật — duyệt một tập đối tượng nhiều kiểu xen kẽ khó đoán — thì mỗi cú gọi trả giá flush pipeline, chậm gần 7 lần. Và việc compiler có devirtualize được hay không thay đổi chi phí hàng lần. Cùng dòng obj->f(), con số có thể là 0,68 hay 4,64 tùy cảnh.

Ngoài đời thì sao

Vậy nên: dùng hàm ảo thoải mái khi đơn hình hoặc devirtualize được, đừng sợ OOP theo phản xạ. Đánh dấu lớp/hàm final khi có thể để mở đường cho devirtualize. Chỉ khi đa hình thật nằm ở đường cực nóng mới cần lo — và cách chữa không phải bỏ virtual, mà là làm cho đích đoán được: gom đối tượng theo kiểu trước khi duyệt (xử lý hết loại A rồi tới loại B), biến một dòng trộn loạn thành từng khối đơn hình. Đây chính là gốc của "type-based batching" trong engine game và cơ sở dữ liệu, và cũng là một lý do sâu xa của cách bố trí dữ liệu theo kiểu SoA/ECS.

Chuyện này không riêng gì C++. Trong Java, mọi method mặc định là ảo, nhưng JIT có một mẹo rất hay gọi là inline cache: nếu một điểm gọi luôn thấy cùng một kiểu (monomorphic), nó nhớ và inline thẳng, nhanh như gọi thường; nếu điểm đó thấy quá nhiều kiểu khác nhau (megamorphic), nó rơi về tra vtable chậm — đúng y câu chuyện đơn hình với đa hình ở đây, chỉ là JVM tự làm lúc chạy. Go cũng vậy với lời gọi qua interface (itable). Nên bài học vượt khỏi C++: giữ cho các điểm gọi đa hình của bạn "nhìn thấy" ít kiểu, ổn định.

Muốn tự thấy thì tạo một lớp cơ sở với hàm ảo cùng vài lớp con, làm một mảng Base*, rồi gọi obj->f() trong vòng lặp hai lần: một lần mảng toàn một kiểu, một lần trộn kiểu ngẫu nhiên. Bản đơn hình gần bằng gọi thường, bản trộn chậm mấy lần. Thử đánh dấu một lớp final và gọi qua kiểu cụ thể của nó rồi mở assembly (-S) ra xem: cú gọi ảo biến mất hẳn. Cùng một virtual, nhanh hay chậm gần 7 lần chỉ tùy các đối tượng bạn duyệt cùng kiểu hay trộn loạn.