blog.dopana

Back

Khi viết phần mềm, việc copy-paste cùng một đoạn mã cho các kiểu dữ liệu khác nhau là nguyên nhân hàng đầu dẫn đến các cơn ác mộng bảo trì. Nếu bạn viết một hàm tìm phần tử lớn nhất trong danh sách số nguyên, bạn chắc chắn không muốn phải viết lại hàm đó một lần nữa cho số thực hay chuỗi ký tự.

Trong Rust, Generics (Kiểu generic) và Traits (Đặc tính/Hành vi chung) là hai công cụ song hành cho phép chúng ta viết mã nguồn có tính tái sử dụng cao, hoàn toàn an toàn về mặt kiểu dữ liệu và đặc biệt là không hao tổn hiệu năng khi chạy (zero runtime overhead).

Trong bài viết này, chúng ta sẽ cùng tìm hiểu cách hoạt động của generics, traits, cách kết hợp chúng thông qua trait bounds (ràng buộc trait) và cơ chế biên dịch kỳ diệu đằng sau giúp mã nguồn Rust đạt tốc độ cực nhanh.

Generics: Kiểu Dữ Liệu Giữ Chỗ#

Generics cho phép chúng ta định nghĩa các struct, enum và hàm với các kiểu dữ liệu đóng vai trò giữ chỗ. Thay vì chỉ định cứng một kiểu cụ thể như i32 hoặc String, chúng ta sử dụng một tham số kiểu generic (thông thường được đặt tên là T).

Cấu Trúc Dữ Liệu Generic#

Một ví dụ kinh điển là cấu trúc Point (Tọa độ điểm). Nếu chúng ta muốn một điểm có thể chứa tọa độ của bất kỳ kiểu dữ liệu nào (số nguyên, số thực, v.v.), chúng ta khai báo nó với tham số kiểu generic:

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

fn main() {
    let integer_point = Point { x: 5, y: 10 };
    let float_point = Point { x: 1.0, y: 4.0 };
}
rust

Bằng cách đặt <T> ngay sau tên struct, chúng ta báo cho compiler biết rằng Point là một cấu trúc generic hoạt động trên kiểu dữ liệu T.

Trừu Tượng Hóa Không Hao Tổn: Đơn Hình Hóa (Monomorphization)#

Có thể bạn sẽ tự hỏi: Liệu việc sử dụng generics có làm chương trình chạy chậm hơn không?

Câu trả lời là không. Rust biên dịch mã nguồn generic thông qua một quá trình gọi là monomorphization (đơn hình hóa). Trong quá trình compile, trình biên dịch sẽ tìm tất cả các kiểu dữ liệu thực tế được sử dụng với generic của bạn, từ đó nhân bản và tạo ra mã máy chuyên biệt cho từng kiểu dữ liệu đó.

graph TD
    A["Point&lt;T&gt; (Mã Nguồn)"] --> B["Compiler (Monomorphization)"]
    B --> C["Point_i32 (Mã Máy Chuyên Biệt)"]
    B --> D["Point_f64 (Mã Máy Chuyên Biệt)"]

Nhờ cơ chế này, hoàn toàn không phát sinh chi phí hiệu năng nào khi chạy. Chương trình sẽ chạy nhanh tương đương với việc bạn viết thủ công các struct riêng biệt cho từng kiểu dữ liệu.

Traits: Hợp Đồng Hành Vi#

Mặc dù generics cho phép chúng ta làm việc với bất kỳ kiểu dữ liệu nào, chúng ta thường cần đảm bảo các kiểu dữ liệu đó sở hữu những hành vi nhất định. Ví dụ, nếu muốn in thông tin của một điểm ra màn hình hoặc sắp xếp một danh sách, các kiểu dữ liệu này phải hỗ trợ việc in ấn hoặc so sánh.

Một Trait định nghĩa một tập hợp các phương thức mà một kiểu dữ liệu phải cài đặt (implement) để đáp ứng một hành vi cụ thể.

Định Nghĩa và Cài Đặt Trait#

Hãy cùng định nghĩa một trait tên là Summary yêu cầu mọi kiểu dữ liệu muốn implement nó phải cung cấp phương thức summarize:

Bây giờ, chúng ta có thể gọi .summarize() trên các thực thể của cả NewsArticleTweet.

Cài Đặt Mặc Định (Default Implementations)#

Traits cũng có thể cung cấp các hành vi mặc định. Nếu một kiểu dữ liệu cài đặt trait đó, nó có thể tùy ý ghi đè (override) hoặc giữ nguyên phương thức mặc định:

pub trait Summary {
    fn summarize(&self) -> String {
        String::from("(Đọc thêm...)")
    }
}
rust

Trait Bounds: Kết Hợp Generics và Traits#

Sức mạnh thực sự xuất hiện khi chúng ta kết hợp cả hai lại. Trait Bounds (Ràng buộc trait) giới hạn các kiểu generic sao cho chúng chỉ được chấp nhận nếu chúng implement các trait cụ thể.

Ví dụ, hãy viết một hàm nhận vào một phần tử generic và in ra bản tóm tắt của nó:

// T phải implement trait Summary
pub fn notify<T: Summary>(item: &T) {
    println!("Tin nóng! {}", item.summarize());
}
rust

Mệnh Đề where#

Đối với các hàm phức tạp có nhiều tham số generic, việc viết ràng buộc trait ngay trong dòng khai báo có thể làm chữ ký hàm cực kỳ khó đọc. Rust cung cấp mệnh đề where giúp dọn dẹp mã nguồn trực quan hơn:

// Khó đọc:
fn some_function<T: Clone + Debug, U: Serialize + Clone>(t: &T, u: &U) {}

// Sạch sẽ và rõ ràng hơn với where:
fn some_function<T, U>(t: &T, u: &U) 
where
    T: Clone + Debug,
    U: Serialize + Clone,
{}
rust

Ví Dụ Thực Tế: Tìm Phần Tử Lớn Nhất#

Hãy xem xét một hàm thực tế tìm phần tử lớn nhất trong một slice. Để so sánh các phần tử, kiểu generic T bắt buộc phải implement trait so sánh PartialOrd từ thư viện chuẩn:

Nếu không có ràng buộc trait PartialOrd, trình biên dịch sẽ từ chối đoạn code trên vì nó không thể chắc chắn liệu mọi kiểu T truyền vào có thể so sánh được bằng toán tử > hay không.

Tổng Kết#

  • Generics là các kiểu giữ chỗ hỗ trợ tái sử dụng mã nguồn hiệu quả.
  • Monomorphization đảm bảo code generic chạy ở tốc độ tối đa không phát sinh chi phí runtime.
  • Traits đóng vai trò như các giao diện (interfaces) hoặc hợp đồng, định nghĩa những gì một kiểu dữ liệu có thể làm.
  • Trait Bounds cho phép các hàm generic gọi các phương thức trên tham số generic một cách an toàn bằng cách đảm bảo chúng implement các trait tương ứng.

Trong bài viết tiếp theo, chúng ta sẽ chinh phục Lifetimes, khái niệm kết hợp chặt chẽ với generics nhằm giữ cho việc sử dụng tham chiếu luôn an toàn.

Tài liệu tham khảo#