Danh sách bài viết

Bài 154: Multiple Generic Params

Bài 154 của series Rust Cơ Bản — các bài trước trong Nhóm 20 chỉ dùng một type parameter T: fn largest<T>, struct Point<T>, enum Option<T>. Một T đủ khi mọi vị trí generic trong khai báo mang cùng một kiểu — ví dụ Point<T> bắt cả x và y phải cùng kiểu. Nhưng có rất nhiều tình huống hai vị trí mang kiểu khác nhau: hàm nhận một i32 và một String rồi gói vào tuple, struct biểu diễn cặp request-response, enum hai nhánh mang hai kiểu khác hẳn nhau. Lúc đó cần nhiều type parameter — <T, U>, <L, R>, <K, V>. Bài này gói gọn cú pháp khai báo nhiều type param trong fn / struct / enum, cách đặt bound riêng cho từng param, preview where clause khi bound dài, và quy tắc turbofish phải đầy đủ và đúng thứ tự — không skip được tham số giữa.

1 lượt xem
1

Mục Tiêu Bài Học

Sau bài học, bạn sẽ:

  • Hiểu khi nào một Tkhông đủ và cần thêm U, 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à i32String trong 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ơn T: 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 where clause 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 đủđú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.
2

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ữ TU, 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 TU. 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"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.

3

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: Tsecond: 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.

4

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 Errlỗ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.

5

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ả CloneDisplay. 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.

6

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.

7

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.

8

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ả TU 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 TU đồ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.

9

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 intersection T: Clone + Display (cùng một T thoả 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).
10

Bài Tập Củng Cố

Tự trả lời, đáp án ở cuối:

  1. 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ột T duy nhất?
  2. Cho struct Cache<K, V> { key: K, value: V, hits: u32 }. Khởi tạo một Cache<String, Vec<u8>> chứa cặp ("avatar.png", vec![0xFF, 0xD8])hits: 0. Có thể bỏ phần annotation kiểu Cache<String, Vec<u8>> không?
  3. Viết enum Either<L, R> với method fn is_left(&self) -> bool. Block impl<L, R> Either<L, R> { ... } cần khai báo gì ở vị trí impl?
  4. 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?
  5. Gọi pair ở bước 7 sao cho T bị ép thành i64 còn U để compiler tự suy là &str. Viết turbofish phù hợp.
Đáp án
  1. fn swap<T, U>(p: (T, U)) -> (U, T) { (p.1, p.0) }. Với một T duy 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" — call swap((1, "hi")) sẽ fail.
  2. let c = Cache { key: "avatar.png".to_string(), value: vec![0xFF, 0xD8], hits: 0 };. Bỏ annotation được — compiler infer Cache<String, Vec<u8>> từ giá trị field.
  3. impl<L, R> Either<L, R> { fn is_left(&self) -> bool { matches!(self, Either::Left(_)) } }. Phần impl<L, R> phải khai báo lại hai type param trước khi dùng trong Either<L, R>, giống như fn.
  4. (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).
  5. pair::<i64, _>(1, "hi") — ép T = i64, để _ cho compiler suy U = &str. Lưu ý 1 sẽ được coerce thành i64 (literal integer chưa fix kiểu).
11

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).