Mục lục
Mục Tiêu Bài Học
Sau bài học, bạn sẽ:
- Hiểu monomorphization là quá trình compile-time mà rustc tạo phiên bản code cụ thể cho mỗi concrete type dùng với generic.
- Giải thích được vì sao generic Rust là zero-cost abstraction — chạy nhanh ngang code tay viết, không runtime dispatch overhead.
- So sánh được monomorphization (Rust, C++ template) với type erasure (Java, Kotlin, TypeScript) — cách tiếp cận, hiệu năng, binary size, error message.
- Nhận thức được trade-off binary size: code gen nhiều hơn để đổi tốc độ; biết khi nào trade-off này đáng và khi nào không.
- Biết về alternative
Box<dyn Trait>(trait object) — runtime dispatch qua vtable, không monomorphize, sẽ học chi tiết ở bài 168. - Dùng
cargo expandđể inspect code sau monomorphization khi cần debug hoặc học sâu hơn.
Monomorphization Là Gì
Tên gọi monomorphization ghép từ mono (một) + morph (hình dạng) — chuyển một thứ "đa hình" (generic) thành nhiều thứ "đơn hình" (concrete). Định nghĩa: với mỗi concrete type dùng để gọi một generic function/struct, compiler tự động sinh ra một bản sao chuyên biệt với type parameter đã thay thế bằng concrete type. Tất cả diễn ra ở compile time.
Ví dụ với hàm tìm phần tử lớn nhất:
// Programmer viết — generic version:
fn largest<T: PartialOrd>(list: &[T]) -> &T {
let mut max = &list[0];
for v in list { if v > max { max = v; } }
max
}
fn main() {
let nums = vec![10, 25, 3, 88, 42];
let words = vec!["pear", "apple", "kiwi"];
println!("{}", largest(&nums)); // T = i32
println!("{}", largest(&words)); // T = &str
}
Sau khi rustc monomorphize, binary thực chất chứa hai hàm riêng biệt, có thể hình dung như:
// Compiler sinh ra (pseudo, sau monomorph):
fn largest_i32(list: &[i32]) -> &i32 {
let mut max = &list[0];
for v in list { if v > max { max = v; } }
max
}
fn largest_str(list: &[&str]) -> &&str {
let mut max = &list[0];
for v in list { if v > max { max = v; } }
max
}
Tên thật trong binary là dạng mangled như _ZN4demo7largest17h...E, nhưng ý tưởng là vậy: generic biến mất khỏi binary, thay vào là tập các hàm concrete. Lúc runtime, largest(&nums) được link tới largest_i32 trực tiếp — không lookup, không cast, không boxing. Áp dụng cho cả struct, enum, method, closure — bất kỳ chỗ nào có generic.
Zero-Cost Abstraction
"Zero-cost abstraction" là phương châm trung tâm của Rust (kế thừa từ C++): abstraction càng cao càng tốt, nhưng không được đánh đổi bằng runtime cost so với code tay viết cho concrete type. Monomorphization là cơ chế đảm bảo điều này cho generics.
Cụ thể, khi bạn viết fn sum<T: Add<Output = T> + Copy>(list: &[T]) -> T và gọi với &[i32], binary sau compile giống hệt như tự tay viết fn sum(list: &[i32]) -> i32. Compiler inline, vectorize, optimize bằng SIMD — mọi tối ưu của LLVM đều áp dụng vì code đã concrete. Không dispatch table; không nhảy gián tiếp qua vtable; không heap allocation cho boxing.
Hệ quả: đừng ngại dùng generic vì sợ chậm. Vec<T>, HashMap<K, V>, iterator chain (.map().filter().collect()) đều monomorphize, sau compile chỉ còn lại loop tay viết tối ưu — đây là lý do iterator combinator Rust thường nhanh hơn for loop tay viết.
So Sánh Java Generic Erasure
Java đi hướng ngược lại — type erasure. Generic chỉ tồn tại ở source code và quá trình type check ở compile time; sau khi javac biên dịch, type parameter biến mất hoàn toàn, thay bằng Object (hoặc bound nếu có). Cùng một hàm Java tương đương:
// Programmer viết — Java:
public static <T extends Comparable<T>> T largest(List<T> list) {
T max = list.get(0);
for (T v : list) if (v.compareTo(max) > 0) max = v;
return max;
}
// Sau erasure (bytecode, conceptual):
public static Object largest(List list) {
Object max = list.get(0);
for (Object v : list)
if (((Comparable) v).compareTo(max) > 0) max = v;
return max;
}
Hệ quả của erasure:
- Binary nhỏ: một bản code cho mọi T — không phình theo concrete type.
- Chậm hơn: phần tử là
Objectreference, primitiveintphải boxing thànhInteger, method qua dynamic dispatch — không inline, GC phải dọn box. - Mất type info runtime: không
new T(), khônginstanceof List<String>— nguồn của hàng loạt limitation Java.
Rust chọn ngược: thà code gen nhiều, binary lớn — miễn runtime nhanh và type info đầy đủ. Quyết định này gắn với mục tiêu Rust thay thế C/C++ ở systems programming.
So Sánh C++ Template
C++ template gần với Rust monomorphization hơn nhiều so với Java erasure. Cả hai đều sinh code cụ thể cho mỗi concrete type, đều zero-cost runtime. Khác biệt nằm ở khi nào và thế nào compiler check bound:
// C++ template — duck typing, lazy instantiation:
template <typename T>
T largest(const std::vector<T>& list) {
T max = list[0];
for (auto& v : list) if (v > max) max = v;
return max;
}
// Compile được — chưa check operator> tồn tại.
// Chỉ khi gọi largest(some_vec) thì C++ mới expand template
// và báo lỗi nếu T không có operator>.
C++ template lazy: định nghĩa không bị check ngay — compiler chỉ instantiate khi gặp lời gọi. Hệ quả là cryptic error message: lỗi nổ tại site instantiation, ngập trang error nhắc tới template nội bộ STL, khó đọc.
Rust ngược lại: strict, check tại site định nghĩa. Nếu fn largest<T> thiếu bound T: PartialOrd, compiler báo lỗi ngay tại định nghĩa, không đợi lời gọi. E0277 ngắn gọn, chỉ rõ trait thiếu, gợi ý fix. Đây là lý do người chuyển từ C++ sang Rust thường khen "error message dễ chịu hơn" — cùng cơ chế monomorph nhưng UX khác hẳn.
Trade-off Binary Size
Cái giá của monomorphization là binary size. Mỗi specialized version chiếm chỗ trong executable; project có N generic function instantiate với M concrete type về lý thuyết sinh N × M bản code. Project lớn dùng serde, tokio, diesel (hàng nghìn generic) — binary có thể phình hàng chục MB so với Java/Go.
Hệ quả phụ: compile time chậm. Compiler phải sinh và optimize từng bản — đây là lý do cargo build có tiếng chậm so với Go.
Đối phó thực tế:
- Strip + LTO: bật
strip = truevàlto = "fat"trong release profile — gỡ debug info và inline aggressively. - Wrapper mỏng: tách lõi không generic ra hàm riêng, monomorph chỉ là wrapper mỏng — monomorphization-friendly design.
- Trait object thay generic: dùng
Box<dyn Trait>(mục 7) cho phần không hot path.
Dynamic Dispatch Với dyn Trait
Rust cho phép programmer chủ động chọn không monomorphize — bằng trait object, viết là dyn Trait (thường gói trong Box, &, hoặc Rc):
use std::fmt::Display;
// Generic — monomorphize, static dispatch:
fn print_static<T: Display>(item: T) {
println!("{item}");
}
// Trait object — KHÔNG monomorphize, dynamic dispatch:
fn print_dynamic(item: Box<dyn Display>) {
println!("{item}");
}
fn main() {
print_static(42); // static call
print_dynamic(Box::new(42)); // vtable lookup runtime
print_dynamic(Box::new("hello")); // vtable lookup runtime
// Heterogeneous collection — chỉ làm được với dyn:
let items: Vec<Box<dyn Display>> = vec![
Box::new(42),
Box::new("hello"),
Box::new(3.14),
];
for it in &items { println!("{it}"); }
}
Cơ chế: trait object là fat pointer gồm hai con trỏ — một trỏ tới data, một trỏ tới vtable chứa địa chỉ method cho concrete type. Mỗi lời gọi là một indirection: load địa chỉ method từ vtable rồi nhảy tới.
Trade-off đảo ngược so với generic:
- Binary nhỏ: một bản
print_dynamicdùng chung cho mọi type implDisplay. - Runtime overhead nhẹ: thêm load + indirection mỗi method call; không inline; khó vectorize.
- Heterogeneous collection:
Vec<Box<dyn Trait>>chứa nhiều type khác nhau — generic không làm được vì T là một type cố định.
Chi tiết dyn Trait, object safety, vtable layout sẽ ở bài 168.
Khi Nào Chọn Generic, Khi Nào Chọn dyn
Rule of thumb cộng đồng Rust:
- Chọn generic khi: hot path / inner loop, performance critical; tập concrete type nhỏ (1-3); muốn inline + SIMD; cần preserve type-specific behavior.
- Chọn
dyn Traitkhi: cần heterogeneous collection (Vec<Box<dyn Widget>>); plugin / dynamic loading type biết runtime; ưu tiên binary size; muốn ẩn concrete type khỏi caller; không nằm trong hot path. - Default beginner: bắt đầu bằng generic, đổi sang
dynkhi profile chỉ ra vấn đề hoặc cần heterogeneous.
Nhiều library kết hợp cả hai: public API nhận impl Trait (generic, fast), nội bộ giữ Box<dyn Trait> trong store cho heterogeneous — pattern phổ biến trong axum, actix.
Inspect Với cargo expand
cargo expand là subcommand cộng đồng cho thấy code Rust sau khi expand macro. Cài đặt:
cargo install cargo-expand
# nightly toolchain cần thiết cho expand
rustup toolchain install nightly
Sử dụng cơ bản:
# Expand toàn bộ crate, in ra stdout:
cargo expand
# Chỉ expand một module:
cargo expand path::to::module
# Chỉ một test:
cargo expand --test my_test
Để xem code sau monomorphization thực sự (đã thay T thành concrete type), cách tiêu chuẩn là đọc LLVM IR hoặc assembly:
# Sinh LLVM IR cho file:
cargo rustc --release -- --emit=llvm-ir
# Output ở target/release/deps/*.ll — grep tên hàm để thấy các bản monomorph
# Hoặc xem assembly đẹp hơn với cargo-asm:
cargo install cargo-show-asm
cargo asm my_crate::largest
Hai lệnh trên cho thấy rõ một hàm generic sinh ra nhiều symbol mangled khác nhau — bằng chứng monomorphization đã xảy ra. Công cụ tuyệt vời để debug performance và verify abstraction thật sự zero-cost.
Tổng Kết
- Monomorphization là compile-time process: rustc sinh bản code cụ thể cho mỗi concrete type dùng với generic — sau compile, generic biến mất khỏi binary.
- Generic Rust là zero-cost abstraction: chạy ngang code tay viết, không vtable lookup, không boxing, inline-able, vectorize-able.
- So sánh ba ngôn ngữ: Rust monomorph + strict check tại định nghĩa; C++ template + lazy check + error cryptic; Java erasure runtime + Object + boxing → chậm nhưng binary nhỏ.
- Trade-off chính: binary size phình theo số tổ hợp generic × concrete type; compile time cũng chậm hơn.
- Alternative
Box<dyn Trait>: runtime dispatch qua vtable — smaller binary nhưng overhead 1 indirection; bắt buộc cho heterogeneous collection. - Rule: hot path / perf critical → generic; plugin / heterogeneous / binary size critical →
dyn Trait; default beginner → generic, switch khi profile chỉ ra vấn đề. - Inspect:
cargo expandcho macro,--emit=llvm-irhoặccargo asmđể thấy code sau monomorph thực sự. - Nhóm 20 đã đi qua: generic function, generic struct, generic enum, generic method, multiple param, trait bound,
whereclause, và bây giờ là cơ chế dưới capô — monomorphization.
Bài Tập Củng Cố
Tự trả lời, đáp án ở cuối:
- Viết
fn double<T: std::ops::Add<Output = T> + Copy>(x: T) -> T { x + x }. Gọi vớii32,f64,u8. Compile rồi chạycargo rustc --release -- --emit=llvm-ir, grep têndoubletrong file.ll— bạn thấy bao nhiêu symbol? - Viết hai phiên bản hàm in:
fn print_static<T: Display>(x: T)vàfn print_dyn(x: Box<dyn Display>). Tạo benchmark đơn giản gọi mỗi cái 1 triệu lần vớii32, đo thời gian — bản nào nhanh hơn, vì sao? - Tạo
Vec<Box<dyn Display>>chứa hỗn hợpi32,String,f64. Thử thayBox<dyn Display>bằng genericT: Display— vì sao Vec generic không làm được điều này? - Project có 5 generic function, mỗi cái dùng với 10 concrete type. Ước lượng có bao nhiêu bản code sinh ra sau monomorph? Bạn sẽ chọn strategy nào nếu binary size quan trọng?
- Cài
cargo-expandrồi expand crate có#[derive(Debug)]trên struct — bạn thấy macro generate ra code gì cho impl Debug?
Đáp án
- Ba symbol mangled khác nhau:
double::<i32>,double::<f64>,double::<u8>— mỗi cái có suffix hash riêng. Đây là bằng chứng monomorphization sinh ba bản code thực sự. - Bản
print_staticnhanh hơn (thường gấp 2-5x trong micro-benchmark) vì compiler inline lời gọiprintln!và optimize toàn bộ. Bảnprint_dynmỗi call có vtable lookup + indirection, ngăn inline. Ngoài raBox::newcòn alloc heap mỗi lần. - Generic
Vec<T>sau monomorph chỉ chứa một type T cố định. Hỗn hợp i32 + String là ba type khác nhau, không có một T duy nhất nào thoả. Phải dùng trait object để "ẩn" concrete type sau cùng một interface. - Tối đa 50 bản code (5 × 10). Strategies giảm: (a) chuyển sang
Box<dyn Trait>cho hàm không hot path; (b) tách core không-generic ra hàm riêng để bản generic chỉ là wrapper mỏng; (c) bậtlto = "fat"+codegen-units = 1+strip = truetrong release profile. - Macro
#[derive(Debug)]expand thànhimpl std::fmt::Debug for Foo { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { ... } }— viết hết các field bằngf.debug_struct(...).field(...).finish().cargo expandcho thấy chính xác code này.
Bài Tiếp Theo
Bài 158: Trait Là Gì — Rust Interface — mở đầu Nhóm 21 (Traits cơ bản). Trait là cơ chế Rust thay thế interface của Java/C#: định nghĩa tập method mà type phải có để được coi như "thuộc" trait đó; khác kế thừa (no inheritance, dùng composition); hỗ trợ default method; là nền tảng của mọi abstraction trong Rust — từ Display, Debug, Iterator tới Future, Error. Bài 158 đi qua khái niệm trait, cú pháp impl Trait for Type, dispatch method, và mối quan hệ giữa trait với generic + bound đã học ở Nhóm 20.
