Trait là cách Rust nói "những kiểu khác nhau cùng làm được việc này" — tương đương interface ở ngôn ngữ khác. Nhưng Rust cho bạn hai cách gọi một phương thức trait, và chúng khác nhau tận gốc ở mã máy: điều phối tĩnh quyết định gọi hàm nào lúc biên dịch, điều phối động quyết định lúc chạy qua một bảng con trỏ. Cái đầu nhanh hơn nhưng làm phình mã; cái sau gọn mã nhưng trả giá mỗi lời gọi. Bài này đo cả hai trong container rust:1 (rustc 1.99.1) — bằng kích thước con trỏ, thời gian chạy, và assembly.
Hai cách viết cùng một việc
Một trait Hinh với hai kiểu cài đặt, rồi hai hàm cộng diện tích — một dùng generic, một dùng &dyn:
trait Hinh { fn dien_tich(&self) -> f64; }
struct Tron { r: f64 }
struct Vuong { c: f64 }
// TĨNH: generic — trình biên dịch sinh mã riêng cho từng kiểu T
fn tong_tinh<T: Hinh>(xs: &[T]) -> f64 {
let mut s = 0.0;
for x in xs { s += x.dien_tich(); }
s
}
// ĐỘNG: &dyn Hinh — gọi qua bảng ảo lúc chạy
fn tong_dong(xs: &[&dyn Hinh]) -> f64 {
let mut s = 0.0;
for x in xs { s += x.dien_tich(); }
s
}
Thân hai hàm trông giống hệt. Khác biệt nằm ở kiểu tham số: tong_tinh<T: Hinh> nhận một lát cắt các giá trị cùng một kiểu cụ thể, còn tong_dong nhận một lát cắt các con trỏ trait &dyn Hinh — có thể trộn Tron lẫn Vuong. Chính sự linh hoạt đó của dyn là thứ phải trả giá.
Con trỏ béo: &dyn chiếm 16 byte
Khác biệt đầu tiên lộ ra ở kích thước con trỏ:
&Tron (con trỏ mảnh) : 8 byte
&dyn Hinh (con trỏ béo) : 16 byte (con trỏ dữ liệu + con trỏ vtable)
Box<dyn Hinh> : 16 byte
&Tron là con trỏ thường, 8 byte, trỏ tới dữ liệu. &dyn Hinh là con trỏ béo (fat pointer) 16 byte: một nửa trỏ tới dữ liệu, nửa kia trỏ tới bảng ảo (vtable) — một bảng tĩnh chứa địa chỉ các phương thức của kiểu thật. Khi gọi x.dien_tich() trên một &dyn Hinh, chương trình phải: đọc con trỏ vtable, tra địa chỉ dien_tich trong bảng, rồi gọi gián tiếp tới địa chỉ đó. Ba bước này xảy ra mỗi lời gọi, lúc chạy — đó là cái giá của sự linh hoạt.
Assembly: nội tuyến thẳng vs gọi gián tiếp qua vtable
Xuất assembly hai hàm ở bản release và đếm, ta thấy đúng cơ chế:
tong_tinh (tĩnh) tong_dong (động)
lệnh gọi gián tiếp (blr) 0 1
lệnh nhân fmul (tính thẳng) 6 6
lõi vòng lặp động: ldr x8, [x8, #24] ← nạp ô vtable
blr x8 ← gọi gián tiếp
Bản tĩnh có 0 lệnh gọi gián tiếp: sau khi đơn hình hoá cho kiểu Tron, trình biên dịch biết chính xác dien_tich là gì, nội tuyến thẳng công thức 3.14 * r * r vào vòng lặp (6 lệnh fmul, thậm chí mở cuộn — như bài 4 về trừu tượng chi phí bằng không). Bản động thì có một lệnh blr (gọi gián tiếp): nó ldr x8, [x8, #24] — nạp ô thứ tư của vtable (offset 24 byte) chứa địa chỉ dien_tich — rồi blr x8 gọi tới đó. Vì địa chỉ chỉ biết lúc chạy, trình biên dịch không thể nội tuyến, và mỗi phần tử là một lời gọi hàm thật qua con trỏ.

Hình 1: Điều phối tĩnh đơn hình hoá và nội tuyến (0 lời gọi gián tiếp); điều phối động gọi qua vtable của con trỏ béo 16 byte (1 lệnh blr mỗi phần tử).
Thời gian: tĩnh 12,4 ms, động 15,5 ms
Cộng diện tích 20 triệu hình bằng cả hai cách, cùng dữ liệu:
20.000.000 hình, cộng diện tích:
điều phối TĨNH (generic, monomorphization) : 12,40 ms
điều phối ĐỘNG (&dyn Hinh, vtable) : 15,46 ms
động chậm hơn tĩnh 1,2 lần
Chênh 1,2 lần — không khổng lồ, nhưng thật và ổn định, với một phương thức cực nhỏ. Khoảng cách này giãn ra khi phương thức đủ nhỏ để đáng lẽ được nội tuyến (càng nhỏ, chi phí lời gọi gián tiếp càng chiếm tỉ trọng lớn) và khi điều phối động chặn luôn các tối ưu theo sau như mở cuộn hay vector hoá. Với phương thức lớn, chi phí một lời gọi gián tiếp lọt thỏm, và khoảng cách gần như biến mất. Nói cách khác: cái giá của dyn không cố định — nó lớn nhất đúng ở những vòng lặp nóng gọi hàm nhỏ hàng triệu lần.

Hình 2: Số đo thật — kích thước con trỏ mảnh vs béo, benchmark tĩnh vs động, và assembly cho thấy nội tuyến (0 blr) vs gọi gián tiếp qua vtable (blr).
Chọn cái nào
Không phải "tĩnh luôn tốt hơn". Mỗi bên có cái giá riêng. Điều phối tĩnh nhanh và nội tuyến được, nhưng đơn hình hoá sinh một bản mã cho mỗi kiểu — dùng tong_tinh với 10 kiểu hình là 10 bản sao trong nhị phân, làm phình kích thước tệp và áp lực lên cache lệnh. Điều phối động chỉ có một bản mã dùng chung cho mọi kiểu, nên gọn nhị phân, và là cách duy nhất để chứa các kiểu khác nhau trong cùng một tập hợp (Vec<Box<dyn Hinh>> trộn Tron lẫn Vuong — generic không làm được). Quy tắc thực dụng: mặc định dùng generic (tĩnh) cho đường nóng và thư viện; chuyển sang dyn khi cần chứa nhiều kiểu lẫn lộn, khi muốn cắt thời gian biên dịch và cỡ nhị phân, hoặc khi lời gọi không nằm trên đường nóng.
Gói lại
Hai cách gọi một trait, hai đánh đổi đo được. Tĩnh (generic, đơn hình hoá): con trỏ mảnh 8 byte, 0 lời gọi gián tiếp, nội tuyến thẳng, 12,4 ms — nhưng mỗi kiểu một bản mã. Động (&dyn, vtable): con trỏ béo 16 byte, 1 lời gọi gián tiếp qua vtable mỗi phần tử, 15,5 ms — nhưng một bản mã cho mọi kiểu và chứa được kiểu trộn lẫn. Biết cơ chế — blr x8 qua ô vtable offset 24 — là biết chính xác bạn trả giá gì và khi nào. Bài sau quay lại thứ borrow checker ở bài 2 đã hé: lifetime — cách Rust theo dõi một tham chiếu sống được bao lâu, lại đo bằng những lỗi biên dịch nó sinh ra.