Traits: Định Nghĩa Hành Vi Được Chia Sẻ

Một trait định nghĩa chức năng mà một kiểu cụ thể có và có thể chia sẻ với các kiểu khác. Chúng ta có thể sử dụng traits để định nghĩa hành vi được chia sẻ theo cách trừu tượng. Chúng ta có thể sử dụng trait bounds để chỉ định rằng một kiểu generic có thể là bất kỳ kiểu nào có hành vi nhất định.

Lưu ý: Traits tương tự với một tính năng thường được gọi là interfaces trong các ngôn ngữ khác, mặc dù có một số khác biệt.

Định Nghĩa một Trait

Hành vi của một kiểu bao gồm các phương thức mà chúng ta có thể gọi trên kiểu đó. Các kiểu khác nhau chia sẻ cùng một hành vi nếu chúng ta có thể gọi các phương thức giống nhau trên tất cả các kiểu đó. Định nghĩa trait là một cách để nhóm các chữ ký phương thức lại với nhau để định nghĩa một tập hợp các hành vi cần thiết để hoàn thành một mục đích nào đó.

Ví dụ, giả sử chúng ta có nhiều struct chứa các loại và số lượng văn bản khác nhau: một struct NewsArticle chứa một câu chuyện tin tức được lưu trữ ở một vị trí cụ thể và một SocialPost có thể có, nhiều nhất là 280 ký tự cùng với metadata cho biết liệu đó là một bài viết mới, một bài đăng lại, hoặc một trả lời cho bài viết khác.

Chúng ta muốn tạo một thư viện tổng hợp phương tiện có tên là aggregator có thể hiển thị tóm tắt dữ liệu có thể được lưu trữ trong một thể hiện NewsArticle hoặc SocialPost. Để làm điều này, chúng ta cần một bản tóm tắt từ mỗi loại, và chúng ta sẽ yêu cầu bản tóm tắt đó bằng cách gọi phương thức summarize trên một thể hiện. Listing 10-12 hiển thị định nghĩa của một trait Summary công khai thể hiện hành vi này.

pub trait Summary {
    fn summarize(&self) -> String;
}

Ở đây, chúng ta khai báo một trait bằng cách sử dụng từ khóa trait và sau đó là tên của trait, là Summary trong trường hợp này. Chúng ta cũng khai báo trait là pub để các crate phụ thuộc vào crate này cũng có thể sử dụng trait này, như chúng ta sẽ thấy trong một vài ví dụ. Bên trong dấu ngoặc nhọn, chúng ta khai báo các chữ ký phương thức mô tả các hành vi của các kiểu thực hiện trait này, trong trường hợp này là fn summarize(&self) -> String.

Sau chữ ký phương thức, thay vì cung cấp một triển khai trong dấu ngoặc nhọn, chúng ta sử dụng dấu chấm phẩy. Mỗi kiểu triển khai trait này phải cung cấp hành vi tùy chỉnh riêng của nó cho phần thân của phương thức. Trình biên dịch sẽ áp dụng rằng bất kỳ kiểu nào có trait Summary sẽ phải có phương thức summarize được định nghĩa với chữ ký này một cách chính xác.

Một trait có thể có nhiều phương thức trong thân: các chữ ký phương thức được liệt kê mỗi phương thức một dòng, và mỗi dòng kết thúc bằng dấu chấm phẩy.

Triển Khai một Trait trên một Kiểu

Bây giờ chúng ta đã định nghĩa các chữ ký mong muốn của các phương thức của trait Summary, chúng ta có thể triển khai nó trên các kiểu trong trình tổng hợp phương tiện của chúng ta. Listing 10-13 hiển thị một triển khai của trait Summary trên struct NewsArticle sử dụng tiêu đề, tác giả và vị trí để tạo giá trị trả về của summarize. Đối với struct SocialPost, chúng ta định nghĩa summarize là tên người dùng theo sau là toàn bộ văn bản của bài viết, giả định rằng nội dung bài viết đã giới hạn ở 280 ký tự.

pub trait Summary {
    fn summarize(&self) -> String;
}

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {
    fn summarize(&self) -> String {
        format!("{}, by {} ({})", self.headline, self.author, self.location)
    }
}

pub struct SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

Triển khai một trait trên một kiểu tương tự như triển khai các phương thức thông thường. Sự khác biệt là sau impl, chúng ta đặt tên trait mà chúng ta muốn triển khai, sau đó sử dụng từ khóa for, và sau đó chỉ định tên của kiểu mà chúng ta muốn triển khai trait cho. Trong khối impl, chúng ta đặt các chữ ký phương thức mà định nghĩa trait đã định nghĩa. Thay vì thêm dấu chấm phẩy sau mỗi chữ ký, chúng ta sử dụng dấu ngoặc nhọn và điền vào phần thân phương thức với hành vi cụ thể mà chúng ta muốn các phương thức của trait có đối với kiểu cụ thể.

Bây giờ thư viện đã triển khai trait Summary trên NewsArticleSocialPost, người dùng của crate có thể gọi các phương thức trait trên các thể hiện của NewsArticleSocialPost giống như cách chúng ta gọi các phương thức thông thường. Sự khác biệt duy nhất là người dùng phải đưa trait vào phạm vi cũng như các kiểu. Đây là một ví dụ về cách một binary crate có thể sử dụng thư viện aggregator của chúng ta:

use aggregator::{SocialPost, Summary};

fn main() {
    let post = SocialPost {
        username: String::from("horse_ebooks"),
        content: String::from(
            "of course, as you probably already know, people",
        ),
        reply: false,
        repost: false,
    };

    println!("1 new social post: {}", post.summarize());
}

Mã này in ra 1 new post: horse_ebooks: of course, as you probably already know, people.

Các crate khác phụ thuộc vào crate aggregator cũng có thể đưa trait Summary vào phạm vi để triển khai Summary trên các kiểu riêng của họ. Một hạn chế cần lưu ý là chúng ta chỉ có thể triển khai một trait trên một kiểu nếu hoặc trait hoặc kiểu, hoặc cả hai, là local đối với crate của chúng ta. Ví dụ, chúng ta có thể triển khai các trait của thư viện tiêu chuẩn như Display trên một kiểu tùy chỉnh như SocialPost như một phần của chức năng crate aggregator của chúng ta vì kiểu SocialPost là local đối với crate aggregator của chúng ta. Chúng ta cũng có thể triển khai Summary trên Vec<T> trong crate aggregator của chúng ta vì trait Summary là local đối với crate aggregator của chúng ta.

Nhưng chúng ta không thể triển khai các trait bên ngoài trên các kiểu bên ngoài. Ví dụ, chúng ta không thể triển khai trait Display trên Vec<T> trong crate aggregator của chúng ta vì DisplayVec<T> đều được định nghĩa trong thư viện tiêu chuẩn và không local đối với crate aggregator của chúng ta. Hạn chế này là một phần của thuộc tính được gọi là coherence, và cụ thể hơn là quy tắc mồ côi, được đặt tên như vậy bởi vì kiểu cha không có mặt. Quy tắc này đảm bảo rằng mã của người khác không thể phá vỡ mã của bạn và ngược lại. Nếu không có quy tắc này, hai crate có thể triển khai cùng một trait cho cùng một kiểu, và Rust sẽ không biết triển khai nào để sử dụng.

Các Triển Khai Mặc Định

Đôi khi việc có hành vi mặc định cho một số hoặc tất cả các phương thức trong một trait thay vì yêu cầu triển khai cho tất cả các phương thức trên mọi kiểu là hữu ích. Sau đó, khi chúng ta triển khai trait trên một kiểu cụ thể, chúng ta có thể giữ nguyên hoặc ghi đè hành vi mặc định của từng phương thức.

Trong Listing 10-14, chúng ta chỉ định một chuỗi mặc định cho phương thức summarize của trait Summary thay vì chỉ định nghĩa chữ ký phương thức, như chúng ta đã làm trong Listing 10-12.

pub trait Summary {
    fn summarize(&self) -> String {
        String::from("(Read more...)")
    }
}

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {}

pub struct SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

Để sử dụng một triển khai mặc định để tóm tắt các thể hiện của NewsArticle, chúng ta chỉ định một khối impl trống với impl Summary for NewsArticle {}.

Mặc dù chúng ta không còn định nghĩa phương thức summarize trên NewsArticle trực tiếp, chúng ta đã cung cấp một triển khai mặc định và chỉ định rằng NewsArticle triển khai trait Summary. Kết quả là, chúng ta vẫn có thể gọi phương thức summarize trên một thể hiện của NewsArticle, như thế này:

use aggregator::{self, NewsArticle, Summary};

fn main() {
    let article = NewsArticle {
        headline: String::from("Penguins win the Stanley Cup Championship!"),
        location: String::from("Pittsburgh, PA, USA"),
        author: String::from("Iceburgh"),
        content: String::from(
            "The Pittsburgh Penguins once again are the best \
             hockey team in the NHL.",
        ),
    };

    println!("New article available! {}", article.summarize());
}

Mã này in ra New article available! (Read more...).

Việc tạo một triển khai mặc định không yêu cầu chúng ta thay đổi bất cứ điều gì về việc triển khai Summary trên SocialPost trong Listing 10-13. Lý do là cú pháp để ghi đè một triển khai mặc định giống với cú pháp để triển khai một phương thức trait không có triển khai mặc định.

Các triển khai mặc định có thể gọi các phương thức khác trong cùng trait, ngay cả khi những phương thức khác đó không có triển khai mặc định. Bằng cách này, một trait có thể cung cấp nhiều chức năng hữu ích và chỉ yêu cầu những người triển khai chỉ định một phần nhỏ của nó. Ví dụ, chúng ta có thể định nghĩa trait Summary có một phương thức summarize_author mà triển khai của nó là bắt buộc, và sau đó định nghĩa một phương thức summarize có triển khai mặc định gọi phương thức summarize_author:

pub trait Summary {
    fn summarize_author(&self) -> String;

    fn summarize(&self) -> String {
        format!("(Read more from {}...)", self.summarize_author())
    }
}

pub struct SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    fn summarize_author(&self) -> String {
        format!("@{}", self.username)
    }
}

Để sử dụng phiên bản này của Summary, chúng ta chỉ cần định nghĩa summarize_author khi triển khai trait trên một kiểu:

pub trait Summary {
    fn summarize_author(&self) -> String;

    fn summarize(&self) -> String {
        format!("(Read more from {}...)", self.summarize_author())
    }
}

pub struct SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    fn summarize_author(&self) -> String {
        format!("@{}", self.username)
    }
}

Sau khi chúng ta định nghĩa summarize_author, chúng ta có thể gọi summarize trên các thể hiện của struct SocialPost, và triển khai mặc định của summarize sẽ gọi định nghĩa của summarize_author mà chúng ta đã cung cấp. Bởi vì chúng ta đã triển khai summarize_author, trait Summary đã cung cấp cho chúng ta hành vi của phương thức summarize mà không yêu cầu chúng ta viết thêm bất kỳ mã nào. Đây là cách nó hoạt động:

use aggregator::{self, SocialPost, Summary};

fn main() {
    let post = SocialPost {
        username: String::from("horse_ebooks"),
        content: String::from(
            "of course, as you probably already know, people",
        ),
        reply: false,
        repost: false,
    };

    println!("1 new social post: {}", post.summarize());
}

Mã này in ra 1 new post: (Read more from @horse_ebooks...).

Lưu ý rằng không thể gọi triển khai mặc định từ một triển khai ghi đè của cùng một phương thức.

Traits như Tham Số

Bây giờ bạn đã biết cách định nghĩa và triển khai traits, chúng ta có thể khám phá cách sử dụng traits để định nghĩa các hàm chấp nhận nhiều kiểu khác nhau. Chúng ta sẽ sử dụng trait Summary mà chúng ta đã triển khai trên các kiểu NewsArticleSocialPost trong Listing 10-13 để định nghĩa một hàm notify gọi phương thức summarize trên tham số item của nó, là một kiểu nào đó triển khai trait Summary. Để làm điều này, chúng ta sử dụng cú pháp impl Trait, như thế này:

pub trait Summary {
    fn summarize(&self) -> String;
}

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {
    fn summarize(&self) -> String {
        format!("{}, by {} ({})", self.headline, self.author, self.location)
    }
}

pub struct SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

pub fn notify(item: &impl Summary) {
    println!("Breaking news! {}", item.summarize());
}

Thay vì một kiểu cụ thể cho tham số item, chúng ta chỉ định từ khóa impl và tên trait. Tham số này chấp nhận bất kỳ kiểu nào triển khai trait được chỉ định. Trong thân của notify, chúng ta có thể gọi bất kỳ phương thức nào trên item mà đến từ trait Summary, chẳng hạn như summarize. Chúng ta có thể gọi notify và truyền vào bất kỳ thể hiện nào của NewsArticle hoặc SocialPost. Mã gọi hàm với bất kỳ kiểu nào khác, chẳng hạn như String hoặc i32, sẽ không biên dịch vì các kiểu đó không triển khai Summary.

Cú Pháp Trait Bound

Cú pháp impl Trait hoạt động cho các trường hợp đơn giản nhưng thực sự là cú pháp đường tắt cho một hình thức dài hơn được gọi là trait bound; nó trông như thế này:

pub fn notify<T: Summary>(item: &T) {
    println!("Breaking news! {}", item.summarize());
}

Hình thức dài hơn này tương đương với ví dụ trong phần trước nhưng dài dòng hơn. Chúng ta đặt các trait bound với khai báo của tham số kiểu generic sau dấu hai chấm và bên trong dấu ngoặc nhọn.

Cú pháp impl Trait thuận tiện và tạo ra mã ngắn gọn hơn trong các trường hợp đơn giản, trong khi cú pháp trait bound đầy đủ hơn có thể biểu đạt phức tạp hơn trong các trường hợp khác. Ví dụ, chúng ta có thể có hai tham số triển khai Summary. Thực hiện điều này với cú pháp impl Trait trông như thế này:

pub fn notify(item1: &impl Summary, item2: &impl Summary) {

Sử dụng impl Trait là phù hợp nếu chúng ta muốn hàm này cho phép item1item2 có các kiểu khác nhau (miễn là cả hai kiểu đều triển khai Summary). Nếu chúng ta muốn buộc cả hai tham số phải có cùng kiểu, tuy nhiên, chúng ta phải sử dụng một trait bound, như thế này:

pub fn notify<T: Summary>(item1: &T, item2: &T) {

Kiểu generic T được chỉ định làm kiểu của các tham số item1item2 ràng buộc hàm sao cho kiểu cụ thể của giá trị được truyền dưới dạng đối số cho item1item2 phải giống nhau.

Chỉ Định Nhiều Trait Bounds với Cú Pháp +

Chúng ta cũng có thể chỉ định nhiều hơn một trait bound. Giả sử chúng ta muốn notify sử dụng định dạng hiển thị cũng như summarize trên item: chúng ta chỉ định trong định nghĩa notify rằng item phải triển khai cả DisplaySummary. Chúng ta có thể làm như vậy bằng cách sử dụng cú pháp +:

pub fn notify(item: &(impl Summary + Display)) {

Cú pháp + cũng hợp lệ với trait bounds trên các kiểu generic:

pub fn notify<T: Summary + Display>(item: &T) {

Với hai trait bounds đã được chỉ định, thân của notify có thể gọi summarize và sử dụng {} để định dạng item.

Trait Bounds Rõ Ràng Hơn với Mệnh Đề where

Việc sử dụng quá nhiều trait bounds có nhược điểm. Mỗi generic có trait bound riêng của nó, vì vậy các hàm với nhiều tham số kiểu generic có thể chứa rất nhiều thông tin trait bound giữa tên của hàm và danh sách tham số của nó, làm cho chữ ký hàm khó đọc. Vì lý do này, Rust có cú pháp thay thế để chỉ định trait bounds bên trong một mệnh đề where sau chữ ký hàm. Vì vậy, thay vì viết như thế này:

fn some_function<T: Display + Clone, U: Clone + Debug>(t: &T, u: &U) -> i32 {

chúng ta có thể sử dụng một mệnh đề where, như thế này:

fn some_function<T, U>(t: &T, u: &U) -> i32
where
    T: Display + Clone,
    U: Clone + Debug,
{
    unimplemented!()
}

Chữ ký của hàm này ít rối hơn: tên hàm, danh sách tham số, và kiểu trả về ở gần nhau, tương tự như một hàm không có nhiều trait bounds.

Trả Về các Kiểu Triển Khai Traits

Chúng ta cũng có thể sử dụng cú pháp impl Trait ở vị trí trả về để trả về một giá trị của một số kiểu triển khai một trait, như được hiển thị ở đây:

pub trait Summary {
    fn summarize(&self) -> String;
}

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {
    fn summarize(&self) -> String {
        format!("{}, by {} ({})", self.headline, self.author, self.location)
    }
}

pub struct SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

fn returns_summarizable() -> impl Summary {
    SocialPost {
        username: String::from("horse_ebooks"),
        content: String::from(
            "of course, as you probably already know, people",
        ),
        reply: false,
        repost: false,
    }
}

Bằng cách sử dụng impl Summary cho kiểu trả về, chúng ta chỉ định rằng hàm returns_summarizable trả về một số kiểu triển khai trait Summary mà không cần đặt tên kiểu cụ thể. Trong trường hợp này, returns_summarizable trả về một SocialPost, nhưng mã gọi hàm này không cần biết điều đó.

Khả năng chỉ định một kiểu trả về chỉ bằng trait mà nó triển khai đặc biệt hữu ích trong ngữ cảnh của closures và iterators, mà chúng ta sẽ đề cập trong Chương 13. Closures và iterators tạo ra các kiểu mà chỉ trình biên dịch biết hoặc các kiểu rất dài để chỉ định. Cú pháp impl Trait cho phép bạn ngắn gọn chỉ định rằng một hàm trả về một số kiểu triển khai trait Iterator mà không cần viết ra một kiểu rất dài.

Tuy nhiên, bạn chỉ có thể sử dụng impl Trait nếu bạn đang trả về một kiểu duy nhất. Ví dụ, mã này trả về một NewsArticle hoặc một SocialPost với kiểu trả về được chỉ định là impl Summary sẽ không hoạt động:

pub trait Summary {
    fn summarize(&self) -> String;
}

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {
    fn summarize(&self) -> String {
        format!("{}, by {} ({})", self.headline, self.author, self.location)
    }
}

pub struct SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

fn returns_summarizable(switch: bool) -> impl Summary {
    if switch {
        NewsArticle {
            headline: String::from(
                "Penguins win the Stanley Cup Championship!",
            ),
            location: String::from("Pittsburgh, PA, USA"),
            author: String::from("Iceburgh"),
            content: String::from(
                "The Pittsburgh Penguins once again are the best \
                 hockey team in the NHL.",
            ),
        }
    } else {
        SocialPost {
            username: String::from("horse_ebooks"),
            content: String::from(
                "of course, as you probably already know, people",
            ),
            reply: false,
            repost: false,
        }
    }
}

Việc trả về một NewsArticle hoặc một SocialPost không được phép do các hạn chế xung quanh cách cú pháp impl Trait được triển khai trong trình biên dịch. Chúng ta sẽ đề cập đến cách viết một hàm với hành vi này trong phần "Sử dụng Trait Objects Cho Phép các Giá Trị của Các Kiểu Khác Nhau" của Chương 18.

Sử Dụng Trait Bounds để Triển Khai Các Phương Thức Có Điều Kiện

Bằng cách sử dụng một trait bound với một khối impl sử dụng các tham số kiểu generic, chúng ta có thể triển khai các phương thức có điều kiện cho các kiểu triển khai các traits được chỉ định. Ví dụ, kiểu Pair<T> trong Listing 10-15 luôn triển khai hàm new để trả về một thể hiện mới của Pair<T> (nhớ lại từ phần "Định Nghĩa Phương Thức" của Chương 5 rằng Self là một bí danh kiểu cho kiểu của khối impl, trong trường hợp này là Pair<T>). Nhưng trong khối impl tiếp theo, Pair<T> chỉ triển khai phương thức cmp_display nếu kiểu bên trong T của nó triển khai trait PartialOrd cho phép so sánh trait Display cho phép in.

use std::fmt::Display;

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

impl<T> Pair<T> {
    fn new(x: T, y: T) -> Self {
        Self { x, y }
    }
}

impl<T: Display + PartialOrd> Pair<T> {
    fn cmp_display(&self) {
        if self.x >= self.y {
            println!("The largest member is x = {}", self.x);
        } else {
            println!("The largest member is y = {}", self.y);
        }
    }
}

Chúng ta cũng có thể triển khai có điều kiện một trait cho bất kỳ kiểu nào triển khai một trait khác. Việc triển khai một trait trên bất kỳ kiểu nào thỏa mãn các trait bounds được gọi là blanket implementations và được sử dụng rộng rãi trong thư viện tiêu chuẩn Rust. Ví dụ, thư viện tiêu chuẩn triển khai trait ToString trên bất kỳ kiểu nào triển khai trait Display. Khối impl trong thư viện tiêu chuẩn trông tương tự như mã này:

impl<T: Display> ToString for T {
    // --snip--
}

Bởi vì thư viện tiêu chuẩn có blanket implementation này, chúng ta có thể gọi phương thức to_string được định nghĩa bởi trait ToString trên bất kỳ kiểu nào triển khai trait Display. Ví dụ, chúng ta có thể chuyển các số nguyên thành các giá trị String tương ứng của chúng như thế này vì số nguyên triển khai Display:

#![allow(unused)]
fn main() {
let s = 3.to_string();
}

Các blanket implementation xuất hiện trong tài liệu của trait trong phần "Implementors".

Traits và trait bounds cho phép chúng ta viết mã sử dụng các tham số kiểu generic để giảm sự trùng lặp nhưng cũng chỉ định cho trình biên dịch rằng chúng ta muốn kiểu generic có hành vi cụ thể. Trình biên dịch sau đó có thể sử dụng thông tin trait bound để kiểm tra rằng tất cả các kiểu cụ thể được sử dụng với mã của chúng ta cung cấp hành vi chính xác. Trong các ngôn ngữ kiểu động, chúng ta sẽ gặp lỗi tại thời điểm chạy nếu chúng ta gọi một phương thức trên một kiểu không định nghĩa phương thức đó. Nhưng Rust chuyển các lỗi này sang thời điểm biên dịch để chúng ta buộc phải sửa các vấn đề trước khi mã của chúng ta thậm chí có thể chạy. Ngoài ra, chúng ta không phải viết mã kiểm tra hành vi tại thời điểm chạy vì chúng ta đã kiểm tra tại thời điểm biên dịch. Làm như vậy cải thiện hiệu suất mà không phải từ bỏ tính linh hoạt của generics.