Sử dụng Đối tượng Trait Cho phép Các Giá trị của Nhiều Kiểu Khác nhau

Trong Chương 8, chúng ta đã đề cập rằng một hạn chế của vector là chúng chỉ có thể lưu trữ các phần tử của cùng một kiểu. Chúng ta đã tạo ra một giải pháp thay thế trong Listing 8-9, trong đó chúng ta định nghĩa một enum SpreadsheetCell có các biến thể để chứa số nguyên, số thực và văn bản. Điều này có nghĩa là chúng ta có thể lưu trữ các kiểu dữ liệu khác nhau trong mỗi ô và vẫn có một vector đại diện cho một hàng các ô. Đây là một giải pháp hoàn toàn phù hợp khi các mục có thể thay thế của chúng ta là một tập hợp các kiểu cố định mà chúng ta biết khi mã của chúng ta được biên dịch.

Tuy nhiên, đôi khi chúng ta muốn người dùng thư viện của mình có thể mở rộng tập hợp các kiểu hợp lệ trong một tình huống cụ thể. Để minh họa cách chúng ta có thể thực hiện điều này, chúng ta sẽ tạo một ví dụ về công cụ giao diện người dùng đồ họa (GUI) lặp qua danh sách các mục, gọi phương thức draw trên mỗi mục để vẽ nó lên màn hình—một kỹ thuật phổ biến cho các công cụ GUI. Chúng ta sẽ tạo một crate thư viện có tên gui chứa cấu trúc của thư viện GUI. Crate này có thể bao gồm một số kiểu cho mọi người sử dụng, chẳng hạn như Button hoặc TextField. Ngoài ra, người dùng gui sẽ muốn tạo các kiểu riêng của họ có thể được vẽ: ví dụ, một lập trình viên có thể thêm Image và một lập trình viên khác có thể thêm SelectBox.

Chúng ta sẽ không triển khai một thư viện GUI đầy đủ cho ví dụ này, nhưng sẽ cho thấy các phần sẽ kết hợp với nhau như thế nào. Tại thời điểm viết thư viện, chúng ta không thể biết và định nghĩa tất cả các kiểu mà các lập trình viên khác có thể muốn tạo. Nhưng chúng ta biết rằng gui cần theo dõi nhiều giá trị của các kiểu khác nhau và cần gọi phương thức draw trên mỗi giá trị có kiểu khác nhau này. Nó không cần biết chính xác điều gì sẽ xảy ra khi chúng ta gọi phương thức draw, chỉ cần biết rằng giá trị đó sẽ có phương thức sẵn sàng để gọi.

Để thực hiện điều này trong ngôn ngữ có tính kế thừa, chúng ta có thể định nghĩa một lớp có tên Component có phương thức tên là draw. Các lớp khác, chẳng hạn như Button, ImageSelectBox, sẽ kế thừa từ Component và do đó kế thừa phương thức draw. Mỗi lớp có thể ghi đè phương thức draw để định nghĩa hành vi tùy chỉnh của nó, nhưng framework có thể xử lý tất cả các kiểu như thể chúng là các thể hiện của Component và gọi draw trên chúng. Nhưng vì Rust không có tính kế thừa, chúng ta cần một cách khác để cấu trúc thư viện gui để cho phép người dùng mở rộng nó với các kiểu mới.

Định nghĩa một Trait cho Hành vi Chung

Để triển khai hành vi mà chúng ta muốn gui có, chúng ta sẽ định nghĩa một trait có tên Draw có một phương thức tên draw. Sau đó, chúng ta có thể định nghĩa một vector nhận một đối tượng trait. Một đối tượng trait trỏ đến cả một thể hiện của một kiểu triển khai trait được chỉ định của chúng ta và một bảng được sử dụng để tra cứu các phương thức trait trên kiểu đó tại thời điểm chạy. Chúng ta tạo một đối tượng trait bằng cách chỉ định một loại con trỏ, chẳng hạn như tham chiếu & hoặc con trỏ thông minh Box<T>, sau đó là từ khóa dyn, và sau đó chỉ định trait liên quan. (Chúng ta sẽ nói về lý do đối tượng trait phải sử dụng con trỏ trong "Các kiểu Kích thước Động và Trait Sized" trong Chương 20.) Chúng ta có thể sử dụng đối tượng trait thay thế cho kiểu generic hoặc kiểu cụ thể. Bất cứ khi nào chúng ta sử dụng đối tượng trait, hệ thống kiểu của Rust sẽ đảm bảo tại thời điểm biên dịch rằng bất kỳ giá trị nào được sử dụng trong ngữ cảnh đó sẽ triển khai trait của đối tượng trait. Do đó, chúng ta không cần phải biết tất cả các kiểu có thể có tại thời điểm biên dịch.

Chúng ta đã đề cập rằng, trong Rust, chúng ta tránh gọi các struct và enum là "đối tượng" để phân biệt chúng với đối tượng của các ngôn ngữ khác. Trong một struct hoặc enum, dữ liệu trong các trường struct và hành vi trong các khối impl được tách biệt, trong khi ở các ngôn ngữ khác, dữ liệu và hành vi kết hợp thành một khái niệm thường được gọi là đối tượng. Tuy nhiên, đối tượng trait giống hơn với đối tượng trong các ngôn ngữ khác ở chỗ chúng kết hợp dữ liệu và hành vi. Nhưng đối tượng trait khác với đối tượng truyền thống ở chỗ chúng ta không thể thêm dữ liệu vào đối tượng trait. Đối tượng trait không hữu ích một cách tổng quát như đối tượng trong các ngôn ngữ khác: mục đích cụ thể của chúng là cho phép trừu tượng hóa qua hành vi chung.

Listing 18-3 cho thấy cách định nghĩa một trait có tên Draw với một phương thức có tên draw.

pub trait Draw {
    fn draw(&self);
}

Cú pháp này có thể quen thuộc từ các cuộc thảo luận của chúng ta về cách định nghĩa trait trong Chương 10. Tiếp theo là một cú pháp mới: Listing 18-4 định nghĩa một struct có tên Screen chứa một vector có tên components. Vector này có kiểu Box<dyn Draw>, là một đối tượng trait; nó là một đại diện cho bất kỳ kiểu nào bên trong một Box triển khai trait Draw.

pub trait Draw {
    fn draw(&self);
}

pub struct Screen {
    pub components: Vec<Box<dyn Draw>>,
}

Trên struct Screen, chúng ta sẽ định nghĩa một phương thức có tên run sẽ gọi phương thức draw trên mỗi components của nó, như được hiển thị trong Listing 18-5.

pub trait Draw {
    fn draw(&self);
}

pub struct Screen {
    pub components: Vec<Box<dyn Draw>>,
}

impl Screen {
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}

Điều này hoạt động khác với việc định nghĩa một struct sử dụng tham số kiểu generic với ràng buộc trait. Một tham số kiểu generic chỉ có thể được thay thế bằng một kiểu cụ thể tại một thời điểm, trong khi đối tượng trait cho phép nhiều kiểu cụ thể điền vào đối tượng trait tại thời điểm chạy. Ví dụ, chúng ta có thể đã định nghĩa struct Screen sử dụng kiểu generic và ràng buộc trait như trong Listing 18-6:

pub trait Draw {
    fn draw(&self);
}

pub struct Screen<T: Draw> {
    pub components: Vec<T>,
}

impl<T> Screen<T>
where
    T: Draw,
{
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}

Điều này giới hạn chúng ta vào một thể hiện Screen có danh sách các thành phần tất cả đều có kiểu Button hoặc tất cả đều có kiểu TextField. Nếu bạn chỉ muốn có các bộ sưu tập đồng nhất, sử dụng generic và ràng buộc trait là tốt hơn vì các định nghĩa sẽ được đơn hình hóa tại thời điểm biên dịch để sử dụng các kiểu cụ thể.

Mặt khác, với phương pháp sử dụng đối tượng trait, một thể hiện Screen có thể chứa một Vec<T> bao gồm cả Box<Button>Box<TextField>. Hãy xem cách điều này hoạt động, và sau đó chúng ta sẽ nói về các hệ quả hiệu suất thời gian chạy.

Triển khai Trait

Bây giờ chúng ta sẽ thêm một số kiểu triển khai trait Draw. Chúng ta sẽ cung cấp kiểu Button. Một lần nữa, việc thực sự triển khai một thư viện GUI nằm ngoài phạm vi của cuốn sách này, vì vậy phương thức draw sẽ không có bất kỳ triển khai hữu ích nào trong phần thân của nó. Để hình dung triển khai có thể trông như thế nào, một struct Button có thể có các trường cho width, heightlabel, như được hiển thị trong Listing 18-7:

pub trait Draw {
    fn draw(&self);
}

pub struct Screen {
    pub components: Vec<Box<dyn Draw>>,
}

impl Screen {
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}

pub struct Button {
    pub width: u32,
    pub height: u32,
    pub label: String,
}

impl Draw for Button {
    fn draw(&self) {
        // code to actually draw a button
    }
}

Các trường width, heightlabel trên Button sẽ khác với các trường trên các thành phần khác; ví dụ, một kiểu TextField có thể có những trường giống nhau cộng với trường placeholder. Mỗi kiểu mà chúng ta muốn vẽ trên màn hình sẽ triển khai trait Draw nhưng sẽ sử dụng mã khác nhau trong phương thức draw để định nghĩa cách vẽ kiểu cụ thể đó, như Button đã làm ở đây (không có mã GUI thực tế, như đã đề cập). Kiểu Button, ví dụ, có thể có một khối impl bổ sung chứa các phương thức liên quan đến những gì xảy ra khi người dùng nhấp vào nút. Những loại phương thức này sẽ không áp dụng cho các kiểu như TextField.

Nếu ai đó sử dụng thư viện của chúng ta quyết định triển khai một struct SelectBox có các trường width, heightoptions, họ cũng sẽ triển khai trait Draw trên kiểu SelectBox, như được hiển thị trong Listing 18-8.

use gui::Draw;

struct SelectBox {
    width: u32,
    height: u32,
    options: Vec<String>,
}

impl Draw for SelectBox {
    fn draw(&self) {
        // code to actually draw a select box
    }
}

fn main() {}

Người dùng thư viện của chúng ta bây giờ có thể viết hàm main của họ để tạo một thể hiện Screen. Với thể hiện Screen, họ có thể thêm một SelectBox và một Button bằng cách đặt mỗi cái vào một Box<T> để trở thành một đối tượng trait. Sau đó, họ có thể gọi phương thức run trên thể hiện Screen, sẽ gọi draw trên mỗi thành phần. Listing 18-9 cho thấy triển khai này:

use gui::Draw;

struct SelectBox {
    width: u32,
    height: u32,
    options: Vec<String>,
}

impl Draw for SelectBox {
    fn draw(&self) {
        // code to actually draw a select box
    }
}

use gui::{Button, Screen};

fn main() {
    let screen = Screen {
        components: vec![
            Box::new(SelectBox {
                width: 75,
                height: 10,
                options: vec![
                    String::from("Yes"),
                    String::from("Maybe"),
                    String::from("No"),
                ],
            }),
            Box::new(Button {
                width: 50,
                height: 10,
                label: String::from("OK"),
            }),
        ],
    };

    screen.run();
}

Khi chúng ta viết thư viện, chúng ta không biết rằng ai đó có thể thêm kiểu SelectBox, nhưng triển khai Screen của chúng ta có thể hoạt động với kiểu mới và vẽ nó vì SelectBox triển khai trait Draw, có nghĩa là nó triển khai phương thức draw.

Khái niệm này—chỉ quan tâm đến các thông điệp mà một giá trị phản hồi thay vì kiểu cụ thể của giá trị—tương tự như khái niệm duck typing trong các ngôn ngữ kiểu động: nếu nó đi như một con vịt và kêu quạc quạc như một con vịt, thì nó phải là một con vịt! Trong triển khai của run trên Screen trong Listing 18-5, run không cần biết kiểu cụ thể của mỗi thành phần là gì. Nó không kiểm tra liệu một thành phần có phải là một thể hiện của Button hay SelectBox, nó chỉ gọi phương thức draw trên thành phần. Bằng cách chỉ định Box<dyn Draw> là kiểu của các giá trị trong vector components, chúng ta đã định nghĩa Screen cần các giá trị mà chúng ta có thể gọi phương thức draw trên đó.

Lợi thế của việc sử dụng đối tượng trait và hệ thống kiểu của Rust để viết mã tương tự như mã sử dụng duck typing là chúng ta không bao giờ phải kiểm tra liệu một giá trị có triển khai một phương thức cụ thể nào đó tại thời điểm chạy hoặc lo lắng về việc gặp lỗi nếu một giá trị không triển khai một phương thức nhưng chúng ta vẫn gọi nó. Rust sẽ không biên dịch mã của chúng ta nếu các giá trị không triển khai các trait mà đối tượng trait cần.

Ví dụ, Listing 18-10 cho thấy điều gì xảy ra nếu chúng ta cố gắng tạo một Screen với một String làm thành phần.

use gui::Screen;

fn main() {
    let screen = Screen {
        components: vec![Box::new(String::from("Hi"))],
    };

    screen.run();
}

Chúng ta sẽ nhận được lỗi này vì String không triển khai trait Draw:

$ cargo run
   Compiling gui v0.1.0 (file:///projects/gui)
error[E0277]: the trait bound `String: Draw` is not satisfied
 --> src/main.rs:5:26
  |
5 |         components: vec![Box::new(String::from("Hi"))],
  |                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `Draw` is not implemented for `String`
  |
  = help: the trait `Draw` is implemented for `Button`
  = note: required for the cast from `Box<String>` to `Box<dyn Draw>`

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

Lỗi này cho chúng ta biết rằng hoặc là chúng ta đang truyền một thứ gì đó cho Screen mà chúng ta không có ý định truyền và do đó nên truyền một kiểu khác, hoặc chúng ta nên triển khai Draw trên String để Screen có thể gọi draw trên nó.

Đối tượng Trait Thực hiện Điều phối Động

Nhớ lại trong "Hiệu suất của Mã Sử dụng Generic" trong Chương 10, chúng ta đã thảo luận về quá trình đơn hình hóa được thực hiện trên generic bởi trình biên dịch: trình biên dịch tạo ra các triển khai không generic của các hàm và phương thức cho mỗi kiểu cụ thể mà chúng ta sử dụng thay thế cho tham số kiểu generic. Mã kết quả từ quá trình đơn hình hóa đang thực hiện điều phối tĩnh, đó là khi trình biên dịch biết phương thức nào bạn đang gọi tại thời điểm biên dịch. Điều này trái ngược với điều phối động, là khi trình biên dịch không thể biết tại thời điểm biên dịch phương thức nào bạn đang gọi. Trong các trường hợp điều phối động, trình biên dịch tạo ra mã mà tại thời điểm chạy sẽ tìm ra phương thức nào để gọi.

Khi chúng ta sử dụng đối tượng trait, Rust phải sử dụng điều phối động. Trình biên dịch không biết tất cả các kiểu có thể được sử dụng với mã đang sử dụng đối tượng trait, vì vậy nó không biết phương thức nào được triển khai trên kiểu nào để gọi. Thay vào đó, tại thời điểm chạy, Rust sử dụng các con trỏ bên trong đối tượng trait để biết phương thức nào cần gọi. Việc tra cứu này phát sinh chi phí thời gian chạy không xảy ra với điều phối tĩnh. Điều phối động cũng ngăn trình biên dịch chọn để nội tuyến mã của một phương thức, từ đó ngăn chặn một số tối ưu hóa, và Rust có một số quy tắc, được gọi là khả năng tương thích dyn, về nơi bạn có thể và không thể sử dụng điều phối động. Những quy tắc đó nằm ngoài phạm vi của cuộc thảo luận này, nhưng bạn có thể đọc thêm về chúng trong tài liệu tham khảo. Tuy nhiên, chúng ta đã có được sự linh hoạt bổ sung trong mã mà chúng ta đã viết trong Listing 18-5 và có thể hỗ trợ trong Listing 18-9, vì vậy đó là một sự đánh đổi cần cân nhắc.