Mục lục
Mục Tiêu Bài Học
Sau bài học, bạn sẽ:
- Hiểu khi nào một
Tlà không đủ và cần thêmU,V... — quy tắc: mỗi vị trí generic có thể mang kiểu khác nhau thì phải có type param riêng. - Khai báo function nhiều type param:
fn pair<T, U>(t: T, u: U) -> (T, U)— đọc đối số lài32vàStringtrong cùng một call, return tuple chứa cả hai. - Khai báo struct nhiều type param:
struct Pair<T, U> { first: T, second: U }— pattern request-response, key-value, before-after. - Khai báo enum nhiều type param:
enum Either<L, R> { Left(L), Right(R) }— hai branch mang hai kiểu khác hẳn (không có ý nghĩa lỗi nhưResult). - Đặt bound riêng cho từng param:
fn merge<T: Clone, U: Display>(t: T, u: U)— đọc tự nhiên hơnT: Clone + Display(intersection bắt cả hai trait trên cùng một type). - Biết khi nào nên chuyển sang
whereclause cho bound phức tạp — preview Bài 156. - Hiểu quy tắc turbofish:
pair::<i32, String>(1, "hi".into())— phải đầy đủ và đúng thứ tự khai báo, không skip giữa được. - Biết khi nào compiler infer được hết — đa số trường hợp không cần turbofish vì argument đủ thông tin để suy ra cả T và U.
Function Với Multi Param
Quy tắc bao trùm: mỗi tham số có thể mang kiểu khác nhau ở các call khác nhau thì phải có một type param riêng. Hàm pair nhận hai giá trị có thể khác kiểu, gói vào tuple — cần hai type param:
fn pair<T, U>(t: T, u: U) -> (T, U) {
(t, u)
}
fn main() {
let a = pair(1, "hi"); // (i32, &str)
let b = pair(3.14, true); // (f64, bool)
let c = pair("x".to_string(), 42u64); // (String, u64)
println!("{:?}, {:?}, {:?}", a, b, c);
}
Cú pháp đọc thẳng từ trái sang phải: fn pair tên hàm, <T, U> phần khai báo type param trong dấu ngoặc nhọn ngay sau tên — đây là nơi giới thiệu hai chữ T và U, biến chúng thành "biến kiểu" hợp lệ trong phần còn lại của signature; (t: T, u: U) dùng lại hai chữ đó để gán kiểu cho tham số; -> (T, U) nói return type là tuple hai phần tử kiểu lần lượt T và U. Nếu viết một T duy nhất fn pair<T>(t: T, u: T) -> (T, T), gọi pair(1, "hi") sẽ fail compile vì 1: i32 và "hi": &str không cùng kiểu — đây chính là lý do hai param phải tách rời. Quy ước tên: T cho "Type" thứ nhất, U cho thứ hai (chỉ vì U đứng sau T trong bảng chữ cái), không có nghĩa gì đặc biệt; nhiều người thích đặt tên gợi nhớ như fn map<In, Out>, fn merge<Req, Res> để code self-document.
Struct Với Multi Param
Struct chứa hai field có thể khác kiểu — pattern request-response, key-value, before-after, input-output đều rơi vào đây. Struct generic hai param viết tương tự fn:
struct Pair<T, U> {
first: T,
second: U,
}
fn main() {
// Cặp request-response
let rr: Pair<String, u16> = Pair {
first: "GET /users".to_string(), // request
second: 200, // response status
};
// Cặp key-value
let kv: Pair<&str, i32> = Pair { first: "count", second: 42 };
println!("{} -> {}", rr.first, rr.second);
println!("{} = {}", kv.first, kv.second);
}
struct Pair<T, U> khai báo hai type param ngay sau tên struct; bên trong body, first: T và second: U dùng lại. Khi khởi tạo, có thể chỉ định rõ Pair<String, u16> để tăng tính tự document, hoặc bỏ luôn để compiler tự infer từ giá trị field. Tổ hợp này là khuôn mẫu của nhiều thư viện thực tế: HashMap<K, V>, BTreeMap<K, V>, Result<T, E> — toàn struct/enum hai type param. Lý do quy ước K, V thay vì T, U: tên biểu cảm giúp người đọc nhận diện vai trò ngay lập tức (Key vs Value). Khi viết thư viện riêng, ưu tiên đặt tên có nghĩa nếu vai trò hai kiểu khác hẳn nhau.
Một chú ý nhỏ: số type param không phải càng nhiều càng tốt. Mỗi param thêm vào là một chỗ trống compiler phải lấp khi gọi. Nếu hai chỗ luôn luôn dùng chung một kiểu trong mọi use case thì gộp lại thành một T; nếu thực sự độc lập thì tách. Tách thừa làm signature dài và buộc người gọi phải nghĩ — gộp sai thì giới hạn khả năng tái sử dụng. Quy tắc kiểm tra: viết ra 2-3 call site khác nhau, nếu hai kiểu luôn trùng thì gộp.
Enum Với Multi Param
Enum nhiều type param điển hình là Either<L, R> — hai branch mang hai kiểu khác hẳn nhau, không có ý nghĩa "ok / lỗi" như Result. Thường gặp khi cần "hoặc cái này, hoặc cái kia, không phải lỗi":
enum Either<L, R> {
Left(L),
Right(R),
}
fn parse_or_keep(input: &str) -> Either<i32, String> {
match input.parse::<i32>() {
Ok(n) => Either::Left(n),
Err(_) => Either::Right(input.to_string()),
}
}
fn main() {
let a = parse_or_keep("42"); // Left(42)
let b = parse_or_keep("abc"); // Right("abc")
if let Either::Left(n) = a { println!("got number {n}"); }
if let Either::Right(s) = b { println!("kept string {s}"); }
}
So sánh với Result<T, E>: cả hai cùng có "hai branch hai kiểu", nhưng Result ngầm định Err là lỗi — tham gia vào toán tử ?, các adapter map_err, các trait Error. Either trung tính: hai branch ngang hàng, không cái nào "tốt" hơn cái nào. Khi nào nên dùng Either? Khi giá trị thực sự có hai dạng hợp lệ song song — ví dụ "id hoặc tên người dùng", "JSON đã parse hoặc string thô để fallback", "tier free hoặc tier paid với object khác nhau". Trong stdlib không có Either — phải khai báo tự, hoặc dùng crate either. Nhiều thư viện lớn (Tokio, Actix) dùng tên riêng phù hợp domain hơn là enum tổng quát.
Một biến thể khác: enum ba param enum Tagged<A, B, C> { First(A), Second(B), Third(C) } — hợp lệ nhưng hiếm; nếu thấy bốn năm type param trong khai báo thì thường thiết kế sai, nên gom thành struct hoặc tạo enum cụ thể không generic.
Bound Per Param
Khi mỗi type param cần một ràng buộc (trait bound) riêng, đặt bound trực tiếp vào chính param đó — gọi là inline bound:
use std::fmt::Display;
fn merge<T: Clone, U: Display>(t: T, u: U) -> String {
let _copy = t.clone(); // T: Clone → gọi được .clone()
format!("({:?}, {})", "<T>", u) // U: Display → format! qua {}
}
Phân biệt với intersection: fn f<T: Clone + Display>(t: T) bắt buộc cùng một T phải thoả cả Clone và Display. Còn fn merge<T: Clone, U: Display> thì tách rời: T chỉ cần Clone, U chỉ cần Display — hai param hoàn toàn không liên quan ràng buộc. Đặt nhầm chỗ dấu phẩy là lỗi rất hay gặp khi mới học, vì + nhìn giống , ở mặt cú pháp nhưng ngữ nghĩa khác hẳn. Mẹo nhớ: cùng param dùng +, khác param dùng ,.
Hệ quả thực tế: signature fn merge<T: Clone, U: Display> đọc tự nhiên hơn người ngoài đọc — mỗi tham số có vai trò riêng kèm trait riêng. Khi mỗi param chỉ có một-hai bound, viết inline là lựa chọn gọn nhất. Nhưng khi số bound nhiều, signature dài thành một hàng phình to không xuống dòng — đó là lúc chuyển sang where clause ở bước tiếp theo.
where Clause Khi Bound Phức Tạp
Khi mỗi param có 3-4 bound, signature inline dày đặc khó đọc. Rust cung cấp cú pháp where đặt sau danh sách tham số, gom bound vào một khối riêng:
use std::fmt::{Debug, Display};
fn foo<T, U>(t: T, u: U)
where
T: Display + Clone,
U: Debug + 'static,
{
let _copy = t.clone();
println!("t = {t}, u = {:?}", u);
}
Hai dạng tương đương về ngữ nghĩa: fn foo<T: Display + Clone, U: Debug + 'static>(...) và bản where ở trên đều compile ra cùng monomorphization. Khác biệt chỉ là vị trí bound trong source: inline khi gọn, where khi dài. Lợi thế của where: mỗi bound xuống một dòng, tên param không bị xa kiểu, code review nhanh nhìn ra ai phải thoả gì; còn cho phép bound trên kiểu phức tạp như Vec<T>: Display (không viết inline được vì Vec<T> đâu phải tên type param). Đó là chi tiết Bài 156 sẽ đào kỹ — ở bài này chỉ giới thiệu để biết sự tồn tại và đừng ngạc nhiên khi gặp.
Turbofish Phải Đầy Đủ Theo Thứ Tự
Khi cần chỉ định rõ kiểu (compiler không infer được hoặc muốn ép kiểu), dùng turbofish ::<...> đặt giữa tên hàm và dấu mở ngoặc. Với hàm nhiều type param, turbofish phải liệt kê tất cả param theo đúng thứ tự khai báo:
fn pair<T, U>(t: T, u: U) -> (T, U) { (t, u) }
fn main() {
// OK - đầy đủ và đúng thứ tự
let a = pair::<i32, String>(1, "hi".into());
println!("{:?}", a); // (1, "hi")
// Compile error - chỉ có một param trong turbofish
// let b = pair::<i32>(1, "hi".into());
// ^^^^^^^^^^^^ wrong number of type arguments: expected 2, found 1
// Sai thứ tự - hai kiểu hoán đổi
// let c = pair::<String, i32>(1, "hi".into());
// ^^^^^^^^^^^^^^^^^^^^^^ expected `String`, found `i32`
}
Khác với một số ngôn ngữ (C++ template, TypeScript) cho phép skip param sau khi compiler tự suy, Rust không cho skip giữa. Nếu turbofish có thì phải liệt kê đầy đủ; muốn để compiler suy ra một số param thì dùng underscore _: pair::<i32, _>(1, "hi".into()) — nói "ép T là i32, U thì compiler tự đoán". Underscore này rất tiện khi chỉ muốn ép một vài param mà vẫn giữ inference cho phần còn lại; thấy nhiều ở collect::<Vec<_>>() — ép outer là Vec nhưng để inner type compiler tự suy.
Type Inference Phổ Biến
Thực tế viết Rust hàng ngày: hiếm khi phải gõ turbofish cho hàm nhiều type param, vì compiler infer cả T và U từ argument:
fn pair<T, U>(t: T, u: U) -> (T, U) { (t, u) }
fn main() {
let a = pair(1, "hi"); // T=i32, U=&str — infer từ argument
let b = pair(3.14_f64, true); // T=f64, U=bool
let c: (String, u32) = pair("x".into(), 42); // infer từ return type annotation
}
Compiler dùng argument types để suy T và U đồng thời. Chỉ khi argument không đủ thông tin — ví dụ None::<i32> với Option<T>, hoặc "42".parse() không biết parse thành kiểu gì — mới cần turbofish hoặc annotation kiểu biến nhận. Một quy tắc kinh nghiệm: nếu phải gõ turbofish, đó là tín hiệu hoặc API thiếu thông tin (như collect, parse) hoặc ý đồ tác giả muốn ép kiểu cụ thể; bình thường code Rust trong wild không có turbofish khắp nơi.
Đối với struct và enum khi khởi tạo, type inference cũng tương tự: Pair { first: 1, second: "hi" } → compiler suy Pair<i32, &str>; Either::Left::<i32, String>(42) chỉ cần khi nhánh kia hoàn toàn không xuất hiện ở chỗ gọi và compiler không có gốc để suy R.
Tổng Kết
- Một
Tđủ khi mọi vị trí generic mang cùng kiểu; nhiều type param (T, U...) khi các vị trí có thể khác kiểu. fn pair<T, U>(t: T, u: U) -> (T, U)— function nhiều type param, return tuple chứa cả hai kiểu độc lập.struct Pair<T, U> { first: T, second: U }— pattern request-response, key-value;HashMap<K, V>,Result<T, E>đều là hai-type-param.enum Either<L, R> { Left(L), Right(R) }— hai branch hai kiểu khác nhau, trung tính (không có ý nghĩa lỗi nhưResult).- Bound per param:
fn merge<T: Clone, U: Display>— mỗi param ràng buộc riêng; đừng nhầm với intersectionT: Clone + Display(cùng mộtTthoả cả hai). - where clause: gom bound vào khối riêng khi inline dài; preview Bài 156 sẽ đào sâu.
- Turbofish:
pair::<i32, String>(1, "hi".into())phải đầy đủ và đúng thứ tự; underscore_cho param muốn để compiler tự suy. - Type inference ngày thường lo hết — gõ turbofish chỉ khi argument/return không đủ thông tin (collect, parse, None ép kiểu).
Bài Tập Củng Cố
Tự trả lời, đáp án ở cuối:
- Viết hàm
fn swap<T, U>(p: (T, U)) -> (U, T)nhận tuple và trả về tuple đã đảo vị trí. Tại sao không thể viết signature này với mộtTduy nhất? - Cho
struct Cache<K, V> { key: K, value: V, hits: u32 }. Khởi tạo mộtCache<String, Vec<u8>>chứa cặp("avatar.png", vec![0xFF, 0xD8])vàhits: 0. Có thể bỏ phần annotation kiểuCache<String, Vec<u8>>không? - Viết
enum Either<L, R>với methodfn is_left(&self) -> bool. Blockimpl<L, R> Either<L, R> { ... }cần khai báo gì ở vị tríimpl? - Signature nào sau đây sai và vì sao: (a)
fn f<T: Clone, U: Clone>(a: T, b: U); (b)fn f<T: Clone + U: Clone>(a: T, b: U); (c)fn f<T, U>(a: T, b: U) where T: Clone, U: Clone? - Gọi
pairở bước 7 sao choTbị ép thànhi64cònUđể compiler tự suy là&str. Viết turbofish phù hợp.
Đáp án
fn swap<T, U>(p: (T, U)) -> (U, T) { (p.1, p.0) }. Với mộtTduy nhất tuple input và output đều phải có hai phần tử cùng kiểu, hạn chế ngược lại ý đồ "đảo cặp kiểu khác nhau" — callswap((1, "hi"))sẽ fail.let c = Cache { key: "avatar.png".to_string(), value: vec![0xFF, 0xD8], hits: 0 };. Bỏ annotation được — compiler inferCache<String, Vec<u8>>từ giá trị field.impl<L, R> Either<L, R> { fn is_left(&self) -> bool { matches!(self, Either::Left(_)) } }. Phầnimpl<L, R>phải khai báo lại hai type param trước khi dùng trongEither<L, R>, giống như fn.- (b) sai — không có cú pháp
+nối giữa hai type param khác nhau, dấu cách giữa các param phải là,. (a) và (c) đúng và tương đương ngữ nghĩa, khác cú pháp (inline vs where). pair::<i64, _>(1, "hi")— épT = i64, để_cho compiler suyU = &str. Lưu ý1sẽ được coerce thànhi64(literal integer chưa fix kiểu).
Bài Tiếp Theo
Bài 155: Trait Bound Cơ Bản — fn foo<T: Display> — đi sâu vào trait bound: T: Display + Clone intersection (cùng một T thoả cả hai), method gọi bị giới hạn theo bound, ví dụ format value qua {}, và làm rõ phân biệt giữa bound per param (bài này) với bound intersection (bài tiếp).
