Các Kiểu Dữ Liệu Generic

Chúng ta sử dụng generics để tạo các định nghĩa cho các thành phần như chữ ký hàm hoặc structs, mà sau đó chúng ta có thể sử dụng với nhiều kiểu dữ liệu cụ thể khác nhau. Hãy xem trước cách định nghĩa các hàm, structs, enums và phương thức sử dụng generics. Sau đó chúng ta sẽ thảo luận về cách generics ảnh hưởng đến hiệu năng mã.

Trong Định Nghĩa Hàm

Khi định nghĩa một hàm sử dụng generics, chúng ta đặt generics vào chữ ký của hàm nơi mà chúng ta thường xác định kiểu dữ liệu của các tham số và giá trị trả về. Làm như vậy giúp mã của chúng ta linh hoạt hơn và cung cấp nhiều chức năng hơn cho người gọi hàm của chúng ta đồng thời ngăn chặn trùng lặp mã.

Tiếp tục với hàm largest của chúng ta, Listing 10-4 hiển thị hai hàm mà cả hai đều tìm giá trị lớn nhất trong một slice. Sau đó chúng ta sẽ kết hợp chúng thành một hàm duy nhất sử dụng generics.

fn largest_i32(list: &[i32]) -> &i32 {
    let mut largest = &list[0];

    for item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn largest_char(list: &[char]) -> &char {
    let mut largest = &list[0];

    for item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let result = largest_i32(&number_list);
    println!("The largest number is {result}");
    assert_eq!(*result, 100);

    let char_list = vec!['y', 'm', 'a', 'q'];

    let result = largest_char(&char_list);
    println!("The largest char is {result}");
    assert_eq!(*result, 'y');
}

Hàm largest_i32 là hàm chúng ta đã trích xuất trong Listing 10-3 để tìm giá trị i32 lớn nhất trong một slice. Hàm largest_char tìm giá trị char lớn nhất trong một slice. Phần thân của hai hàm có cùng mã nguồn, vì vậy hãy loại bỏ sự trùng lặp bằng cách giới thiệu một tham số kiểu generic trong một hàm duy nhất.

Để tham số hóa các kiểu trong một hàm đơn lẻ mới, chúng ta cần đặt tên cho tham số kiểu, giống như chúng ta làm cho các tham số giá trị cho một hàm. Bạn có thể sử dụng bất kỳ định danh nào làm tên tham số kiểu. Nhưng chúng ta sẽ sử dụng T vì, theo quy ước, tên tham số kiểu trong Rust thường ngắn, thường chỉ một chữ cái, và quy ước đặt tên kiểu của Rust là CamelCase. Viết tắt của type, T là lựa chọn mặc định của hầu hết lập trình viên Rust.

Khi chúng ta sử dụng một tham số trong thân hàm, chúng ta phải khai báo tên tham số trong chữ ký để trình biên dịch biết tên đó có ý nghĩa gì. Tương tự, khi chúng ta sử dụng tên tham số kiểu trong chữ ký hàm, chúng ta phải khai báo tên tham số kiểu trước khi sử dụng nó. Để định nghĩa hàm generic largest, chúng ta đặt khai báo tên kiểu bên trong dấu ngoặc nhọn, <>, giữa tên của hàm và danh sách tham số, như sau:

fn largest<T>(list: &[T]) -> &T {

Chúng ta đọc định nghĩa này là: hàm largest là generic trên một số kiểu T. Hàm này có một tham số tên là list, là một slice của các giá trị của kiểu T. Hàm largest sẽ trả về một tham chiếu đến một giá trị cùng kiểu T.

Listing 10-5 hiển thị định nghĩa hàm largest kết hợp sử dụng kiểu dữ liệu generic trong chữ ký của nó. Listing này cũng cho thấy cách chúng ta có thể gọi hàm với một slice của các giá trị i32 hoặc giá trị char. Lưu ý rằng mã này sẽ không biên dịch được.

fn largest<T>(list: &[T]) -> &T {
    let mut largest = &list[0];

    for item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let result = largest(&number_list);
    println!("The largest number is {result}");

    let char_list = vec!['y', 'm', 'a', 'q'];

    let result = largest(&char_list);
    println!("The largest char is {result}");
}

Nếu chúng ta biên dịch mã này ngay bây giờ, chúng ta sẽ nhận được lỗi này:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0369]: binary operation `>` cannot be applied to type `&T`
 --> src/main.rs:5:17
  |
5 |         if item > largest {
  |            ---- ^ ------- &T
  |            |
  |            &T
  |
help: consider restricting type parameter `T` with trait `PartialOrd`
  |
1 | fn largest<T: std::cmp::PartialOrd>(list: &[T]) -> &T {
  |             ++++++++++++++++++++++

For more information about this error, try `rustc --explain E0369`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error

Văn bản trợ giúp đề cập đến std::cmp::PartialOrd, đó là một trait, và chúng ta sẽ nói về traits trong phần tiếp theo. Hiện tại, hãy biết rằng lỗi này cho biết rằng phần thân của largest sẽ không hoạt động cho tất cả các kiểu có thể của T. Bởi vì chúng ta muốn so sánh các giá trị của kiểu T trong phần thân, chúng ta chỉ có thể sử dụng các kiểu mà các giá trị của nó có thể được sắp xếp theo thứ tự. Để cho phép so sánh, thư viện tiêu chuẩn có trait std::cmp::PartialOrd mà bạn có thể thực hiện trên các kiểu (xem Phụ lục C để biết thêm về trait này). Để sửa mã ví dụ trên, chúng ta sẽ cần phải làm theo gợi ý của văn bản trợ giúp và giới hạn các kiểu hợp lệ cho T chỉ với những kiểu thực hiện PartialOrd. Ví dụ này sau đó sẽ biên dịch, vì thư viện tiêu chuẩn đã thực hiện PartialOrd cho cả i32char.

Trong Định Nghĩa Struct

Chúng ta cũng có thể định nghĩa structs để sử dụng tham số kiểu generic trong một hoặc nhiều trường bằng cách sử dụng cú pháp <>. Listing 10-6 định nghĩa một struct Point<T> để chứa các giá trị tọa độ xy của bất kỳ kiểu nào.

struct Point<T> {
    x: T,
    y: T,
}

fn main() {
    let integer = Point { x: 5, y: 10 };
    let float = Point { x: 1.0, y: 4.0 };
}

Cú pháp để sử dụng generics trong các định nghĩa struct tương tự với cú pháp được sử dụng trong các định nghĩa hàm. Đầu tiên chúng ta khai báo tên của tham số kiểu bên trong dấu ngoặc nhọn ngay sau tên của struct. Sau đó chúng ta sử dụng kiểu generic trong định nghĩa struct ở những vị trí mà chúng ta muốn chỉ định kiểu dữ liệu cụ thể.

Lưu ý rằng vì chúng ta chỉ sử dụng một kiểu generic để định nghĩa Point<T>, nên định nghĩa này nói rằng struct Point<T> là generic trên một số kiểu T, và các trường xy đều có cùng kiểu đó, bất kể kiểu đó là gì. Nếu chúng ta tạo một thể hiện của Point<T> có giá trị của các kiểu khác nhau, như trong Listing 10-7, mã của chúng ta sẽ không biên dịch.

struct Point<T> {
    x: T,
    y: T,
}

fn main() {
    let wont_work = Point { x: 5, y: 4.0 };
}

Trong ví dụ này, khi chúng ta gán giá trị số nguyên 5 cho x, chúng ta cho trình biên dịch biết rằng kiểu generic T sẽ là một số nguyên cho thể hiện này của Point<T>. Sau đó khi chúng ta chỉ định 4.0 cho y, mà chúng ta đã định nghĩa là có cùng kiểu với x, chúng ta sẽ nhận được lỗi không khớp kiểu như sau:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0308]: mismatched types
 --> src/main.rs:7:38
  |
7 |     let wont_work = Point { x: 5, y: 4.0 };
  |                                      ^^^ expected integer, found floating-point number

For more information about this error, try `rustc --explain E0308`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error

Để định nghĩa một struct Point trong đó xy đều là generics nhưng có thể có các kiểu khác nhau, chúng ta có thể sử dụng nhiều tham số kiểu generic. Ví dụ, trong Listing 10-8, chúng ta thay đổi định nghĩa của Point để generic trên các kiểu TU trong đó x có kiểu Ty có kiểu U.

struct Point<T, U> {
    x: T,
    y: U,
}

fn main() {
    let both_integer = Point { x: 5, y: 10 };
    let both_float = Point { x: 1.0, y: 4.0 };
    let integer_and_float = Point { x: 5, y: 4.0 };
}

Bây giờ tất cả các thể hiện của Point được hiển thị đều được cho phép! Bạn có thể sử dụng nhiều tham số kiểu generic trong một định nghĩa tùy thích, nhưng việc sử dụng quá nhiều sẽ làm cho mã của bạn khó đọc. Nếu bạn thấy mình cần nhiều kiểu generic trong mã của mình, điều đó có thể chỉ ra rằng mã của bạn cần được cấu trúc lại thành các phần nhỏ hơn.

Trong Định Nghĩa Enum

Như chúng ta đã làm với structs, chúng ta có thể định nghĩa enums để giữ các kiểu dữ liệu generic trong các biến thể của chúng. Hãy xem lại enum Option<T> mà thư viện tiêu chuẩn cung cấp, mà chúng ta đã sử dụng trong Chương 6:

#![allow(unused)]
fn main() {
enum Option<T> {
    Some(T),
    None,
}
}

Định nghĩa này bây giờ nên có ý nghĩa hơn với bạn. Như bạn có thể thấy, enum Option<T> là generic trên kiểu T và có hai biến thể: Some, mà giữ một giá trị của kiểu T, và một biến thể None không giữ bất kỳ giá trị nào. Bằng cách sử dụng enum Option<T>, chúng ta có thể biểu đạt khái niệm trừu tượng của một giá trị tùy chọn, và vì Option<T> là generic, chúng ta có thể sử dụng sự trừu tượng này bất kể kiểu của giá trị tùy chọn là gì.

Enums cũng có thể sử dụng nhiều kiểu generic. Định nghĩa của enum Result mà chúng ta đã sử dụng trong Chương 9 là một ví dụ:

#![allow(unused)]
fn main() {
enum Result<T, E> {
    Ok(T),
    Err(E),
}
}

Enum Result là generic trên hai kiểu, TE, và có hai biến thể: Ok, giữ một giá trị của kiểu T, và Err, giữ một giá trị của kiểu E. Định nghĩa này giúp thuận tiện để sử dụng enum Result ở bất kỳ nơi nào chúng ta có một hoạt động có thể thành công (trả về một giá trị của một số kiểu T) hoặc thất bại (trả về một lỗi của một số kiểu E). Trên thực tế, đây là những gì chúng ta đã sử dụng để mở một tệp trong Listing 9-3, trong đó T được điền với kiểu std::fs::File khi tệp được mở thành công và E được điền với kiểu std::io::Error khi có vấn đề khi mở tệp.

Khi bạn nhận ra các tình huống trong mã của mình với nhiều định nghĩa struct hoặc enum khác nhau chỉ ở kiểu của các giá trị mà chúng chứa, bạn có thể tránh trùng lặp bằng cách sử dụng các kiểu generic thay thế.

Trong Định Nghĩa Phương Thức

Chúng ta có thể triển khai các phương thức trên structs và enums (như chúng ta đã làm trong Chương 5) và sử dụng các kiểu generic trong các định nghĩa của chúng. Listing 10-9 hiển thị struct Point<T> mà chúng ta đã định nghĩa trong Listing 10-6 với một phương thức có tên x được triển khai trên nó.

struct Point<T> {
    x: T,
    y: T,
}

impl<T> Point<T> {
    fn x(&self) -> &T {
        &self.x
    }
}

fn main() {
    let p = Point { x: 5, y: 10 };

    println!("p.x = {}", p.x());
}

Ở đây, chúng ta đã định nghĩa một phương thức có tên x trên Point<T> trả về một tham chiếu đến dữ liệu trong trường x.

Lưu ý rằng chúng ta phải khai báo T ngay sau impl để chúng ta có thể sử dụng T để chỉ định rằng chúng ta đang triển khai các phương thức trên kiểu Point<T>. Bằng cách khai báo T như là một kiểu generic sau impl, Rust có thể xác định rằng kiểu trong dấu ngoặc nhọn trong Point là một kiểu generic chứ không phải là một kiểu cụ thể. Chúng ta có thể đã chọn một tên khác cho tham số generic này so với tham số generic đã khai báo trong định nghĩa struct, nhưng việc sử dụng cùng một tên là quy ước. Nếu bạn viết một phương thức trong một impl khai báo một kiểu generic, phương thức đó sẽ được định nghĩa trên bất kỳ thể hiện nào của kiểu, bất kể kiểu cụ thể nào cuối cùng thay thế cho kiểu generic.

Chúng ta cũng có thể chỉ định các ràng buộc trên các kiểu generic khi định nghĩa các phương thức trên kiểu. Chúng ta có thể, ví dụ, triển khai các phương thức chỉ trên các thể hiện Point<f32> thay vì trên các thể hiện Point<T> với bất kỳ kiểu generic nào. Trong Listing 10-10, chúng ta sử dụng kiểu cụ thể f32, nghĩa là chúng ta không khai báo bất kỳ kiểu nào sau impl.

struct Point<T> {
    x: T,
    y: T,
}

impl<T> Point<T> {
    fn x(&self) -> &T {
        &self.x
    }
}

impl Point<f32> {
    fn distance_from_origin(&self) -> f32 {
        (self.x.powi(2) + self.y.powi(2)).sqrt()
    }
}

fn main() {
    let p = Point { x: 5, y: 10 };

    println!("p.x = {}", p.x());
}

Mã này có nghĩa là kiểu Point<f32> sẽ có phương thức distance_from_origin; các thể hiện khác của Point<T>T không phải là kiểu f32 sẽ không có phương thức này được định nghĩa. Phương thức đo lường khoảng cách từ điểm của chúng ta đến điểm có tọa độ (0.0, 0.0) và sử dụng các phép toán toán học là chỉ có sẵn cho các kiểu số thực.

Các tham số kiểu generic trong định nghĩa struct không phải lúc nào cũng giống với các tham số bạn sử dụng trong chữ ký phương thức của cùng một struct đó. Listing 10-11 sử dụng các kiểu generic X1Y1 cho struct PointX2 Y2 cho chữ ký phương thức mixup để làm cho ví dụ rõ ràng hơn. Phương thức tạo một thể hiện Point mới với giá trị x từ Point self (kiểu X1) và giá trị y từ Point được truyền vào (kiểu Y2).

struct Point<X1, Y1> {
    x: X1,
    y: Y1,
}

impl<X1, Y1> Point<X1, Y1> {
    fn mixup<X2, Y2>(self, other: Point<X2, Y2>) -> Point<X1, Y2> {
        Point {
            x: self.x,
            y: other.y,
        }
    }
}

fn main() {
    let p1 = Point { x: 5, y: 10.4 };
    let p2 = Point { x: "Hello", y: 'c' };

    let p3 = p1.mixup(p2);

    println!("p3.x = {}, p3.y = {}", p3.x, p3.y);
}

Trong main, chúng ta đã định nghĩa một Point có một i32 cho x (với giá trị 5) và một f64 cho y (với giá trị 10.4). Biến p2 là một struct Point có một string slice cho x (với giá trị "Hello") và một char cho y (với giá trị c). Gọi mixup trên p1 với đối số p2 cho chúng ta p3, sẽ có một i32 cho xx đến từ p1. Biến p3 sẽ có một char cho yy đến từ p2. Lời gọi macro println! sẽ in ra p3.x = 5, p3.y = c.

Mục đích của ví dụ này là để chứng minh một tình huống trong đó một số tham số generic được khai báo với impl và một số được khai báo với định nghĩa phương thức. Ở đây, các tham số generic X1Y1 được khai báo sau impl vì chúng đi với định nghĩa struct. Các tham số generic X2Y2 được khai báo sau fn mixup vì chúng chỉ liên quan đến phương thức.

Hiệu Năng của Mã Sử Dụng Generics

Bạn có thể tự hỏi liệu có chi phí thời gian chạy khi sử dụng các tham số kiểu generic không. Tin tốt là việc sử dụng các kiểu generic sẽ không làm cho chương trình của bạn chạy chậm hơn so với sử dụng các kiểu cụ thể.

Rust thực hiện điều này bằng cách thực hiện monomorphization của mã sử dụng generics tại thời điểm biên dịch. Monomorphization là quá trình chuyển đổi mã generic thành mã cụ thể bằng cách điền các kiểu cụ thể được sử dụng khi biên dịch. Trong quá trình này, trình biên dịch làm ngược lại các bước chúng ta đã sử dụng để tạo hàm generic trong Listing 10-5: trình biên dịch xem xét tất cả các nơi mã generic được gọi và tạo mã cho các kiểu cụ thể mà mã generic được gọi với.

Hãy xem cách hoạt động của nó bằng cách sử dụng enum generic Option<T> của thư viện tiêu chuẩn:

#![allow(unused)]
fn main() {
let integer = Some(5);
let float = Some(5.0);
}

Khi Rust biên dịch mã này, nó thực hiện monomorphization. Trong quá trình đó, trình biên dịch đọc các giá trị đã được sử dụng trong các thể hiện Option<T> và xác định hai loại Option<T>: một là i32 và loại kia là f64. Như vậy, nó mở rộng định nghĩa generic của Option<T> thành hai định nghĩa chuyên biệt cho i32f64, từ đó thay thế định nghĩa generic bằng các định nghĩa cụ thể.

Phiên bản monomorphized của mã trông tương tự như sau (trình biên dịch sử dụng các tên khác với những gì chúng ta đang sử dụng ở đây để minh họa):

enum Option_i32 {
    Some(i32),
    None,
}

enum Option_f64 {
    Some(f64),
    None,
}

fn main() {
    let integer = Option_i32::Some(5);
    let float = Option_f64::Some(5.0);
}

Option<T> generic được thay thế bằng các định nghĩa cụ thể được tạo bởi trình biên dịch. Bởi vì Rust biên dịch mã generic thành mã chỉ định kiểu trong mỗi thể hiện, chúng ta không phải trả chi phí thời gian chạy cho việc sử dụng generics. Khi mã chạy, nó hoạt động giống như nếu chúng ta đã sao chép từng định nghĩa bằng tay. Quá trình monomorphization làm cho generics của Rust cực kỳ hiệu quả trong thời gian chạy.