Bỏ qua để đến nội dung

Trait nâng cao trong Rust

Ở trang Trait, bạn đã học cách định nghĩa và cài đặt một trait. Ở các trang Generic và Các trait thông dụng, bạn đã dùng những trait như Display, From và Add. Như vậy là đủ cho phần lớn code hằng ngày. Nhưng khi đọc thư viện chuẩn hay một crate nổi tiếng, bạn sẽ thấy những tính năng của trait trông hơi lạ lúc đầu: type Item, Add<Rhs = Self>, trait Pretty: Display, impl<T: Display> ... for T.

Trang này giải thích từng tính năng một. Không có gì là phép màu cả. Mỗi tính năng giải quyết một vấn đề nhỏ, có thật, và khi đã hiểu vấn đề thì cú pháp sẽ trở nên dễ hiểu. Cứ thong thả: đọc một phần, thử code, rồi quay lại sau cũng hoàn toàn ổn.

Kiểu liên kết: mỗi kiểu một câu trả lời

Phần tiêu đề “Kiểu liên kết: mỗi kiểu một câu trả lời”

Bạn đã gặp một kiểu liên kết rồi: Iterator có type Item. Kiểu liên kết (associated type) là một kiểu giữ chỗ bên trong trait. Mỗi phần cài đặt điền nó vào đúng một lần.

Đây là một trait Container nhỏ. Một playlist chứa các String, còn danh sách điểm chứa các u32, nên mỗi bên tự chọn Item của mình:

use std::fmt::Debug;
trait Container {
type Item;
fn get(&self, index: usize) -> Option<&Self::Item>;
fn size(&self) -> usize;
fn first(&self) -> Option<&Self::Item> {
self.get(0)
}
}
struct Playlist {
songs: Vec<String>,
}
struct Scores {
values: Vec<u32>,
}
impl Container for Playlist {
type Item = String;
fn get(&self, index: usize) -> Option<&String> {
self.songs.get(index)
}
fn size(&self) -> usize {
self.songs.len()
}
}
impl Container for Scores {
type Item = u32;
fn get(&self, index: usize) -> Option<&u32> {
self.values.get(index)
}
fn size(&self) -> usize {
self.values.len()
}
}
// Dùng được với mọi Container có phần tử in được bằng {:?}
fn describe<C: Container>(c: &C)
where
C::Item: Debug,
{
println!("{} phần tử, phần tử đầu là {:?}", c.size(), c.first());
}
fn main() {
let playlist = Playlist { songs: vec![String::from("Mở đầu"), String::from("Kết thúc")] };
let scores = Scores { values: vec![90, 72, 85] };
describe(&playlist);
describe(&scores);
}

Kết quả:

2 phần tử, phần tử đầu là Some("Mở đầu")
3 phần tử, phần tử đầu là Some(90)
▶ Tự chạy thử

Bên trong trait, Self::Item nghĩa là “kiểu mà phần cài đặt này đã chọn”. C::Item trong describe cũng mang nghĩa đó cho kiểu C. (Dòng where chỉ là chỗ gọn gàng hơn để viết ràng buộc; cuối trang sẽ nói thêm.)

Sao không dùng tham số generic? Bạn có thể viết trait Container<T>. Nhưng khi đó một kiểu có thể cài đặt nó nhiều lần, Container<String> và Container<u32>, và mọi nơi gọi đều phải nói rõ ý mình là cái nào. Với một playlist thì chẳng hợp lý chút nào: nó chứa bài hát, vậy thôi.

Đó chính là lý do Iterator dùng type Item. Iterator của một Vec<i32> cho ra i32 và không gì khác, nên for x in v.iter() không bao giờ cần chú thích kiểu. Một quy tắc nhanh:

  • Mỗi kiểu chỉ có một câu trả lời tự nhiên → kiểu liên kết (Iterator::Item, Add::Output).
  • Mỗi kiểu có nhiều câu trả lời hợp lệ → tham số generic (From<T>: một String có thể dựng từ &str, char, Box<str>, …).

Ở trang Các trait thông dụng, bạn đã làm cho + chạy với Point bằng cách cài đặt Add. Đây là cách Add thật sự được định nghĩa trong thư viện chuẩn:

trait Add<Rhs = Self> {
type Output;
fn add(self, rhs: Rhs) -> Self::Output;
}

Hai tính năng gặp nhau ở đây. Output là một kiểu liên kết (thứ mà + tạo ra). Còn Rhs = Self là một tham số kiểu mặc định (default type parameter): “vế phải có cùng kiểu với vế trái, trừ khi bạn nói khác”. Khi viết impl Add for Point, bạn dùng giá trị mặc định. Khi muốn cộng với một kiểu khác, bạn viết impl Add<OtherType> for Point.

use std::ops::Add;
#[derive(Debug, Clone, Copy)]
struct Meters(f64);
#[derive(Debug, Clone, Copy)]
struct Centimeters(f64);
// Dùng mặc định Rhs = Self, nên đây là Meters + Meters
impl Add for Meters {
type Output = Meters;
fn add(self, other: Meters) -> Meters {
Meters(self.0 + other.0)
}
}
// Ghi đè mặc định: Meters + Centimeters
impl Add<Centimeters> for Meters {
type Output = Meters;
fn add(self, other: Centimeters) -> Meters {
Meters(self.0 + other.0 / 100.0)
}
}
fn main() {
let a = Meters(2.0);
let b = Meters(3.5);
let c = Centimeters(50.0);
println!("{:?}", a + b);
println!("{:?}", a + c);
}

Kết quả:

Meters(5.5)
Meters(2.5)
▶ Tự chạy thử

Hãy để ý Add dùng cả hai công cụ: một tham số generic cho vế phải (nhiều câu trả lời hợp lệ: bạn có thể cộng Meters hoặc Centimeters) và một kiểu liên kết cho kết quả (mỗi cặp một câu trả lời). Tham số kiểu mặc định hữu ích nhất khi bạn thiết kế một trait: chúng giữ trường hợp phổ biến ngắn gọn mà không chặn những trường hợp hiếm.

Supertrait: “bạn cũng phải cài đặt cái này”

Phần tiêu đề “Supertrait: “bạn cũng phải cài đặt cái này””

Đôi khi trait của bạn chỉ có ý nghĩa nếu kiểu đó đã làm được một việc khác. Một supertrait nói rằng “để cài đặt trait của tôi, bạn cũng phải cài đặt trait kia”. Bạn viết nó bằng dấu hai chấm, giống một ràng buộc:

use std::fmt;
// Thứ gì là Pretty thì cũng phải là Display
trait Pretty: fmt::Display {
fn pretty(&self) -> String {
format!("*** {} ***", self)
}
}
struct Temperature {
celsius: f64,
}
impl fmt::Display for Temperature {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{:.1}°C", self.celsius)
}
}
impl Pretty for Temperature {}
fn main() {
let t = Temperature { celsius: 21.456 };
println!("{}", t);
println!("{}", t.pretty());
}

Kết quả:

21.5°C
*** 21.5°C ***
▶ Tự chạy thử

Phương thức mặc định pretty dùng được {} trên self bởi vì supertrait hứa rằng Display có sẵn. Thiếu : fmt::Display, Rust sẽ từ chối lời gọi format! đó, vì nó không biết kiểu này in ra được. impl Pretty for Temperature {} để trống: nó chỉ nói “đồng ý, dùng phương thức mặc định”.

Ví dụ thật từ thư viện chuẩn: Copy: Clone (mọi kiểu Copy cũng phải là Clone) và Eq: PartialEq.

Blanket implementation: một impl cho nhiều kiểu

Phần tiêu đề “Blanket implementation: một impl cho nhiều kiểu”

Một blanket implementation (cài đặt phủ) cài đặt trait cho mọi kiểu thoả một điều kiện, chỉ trong một lần. Cú pháp là impl<T: SomeBound> MyTrait for T:

use std::fmt::Display;
trait Shout {
fn shout(&self) -> String;
}
// Mọi kiểu cài đặt Display đều có Shout miễn phí
impl<T: Display> Shout for T {
fn shout(&self) -> String {
format!("{}!", self.to_string().to_uppercase())
}
}
fn main() {
println!("{}", "hello".shout());
println!("{}", 42.shout());
println!("{}", 'a'.shout());
println!("{}", String::from("rust is fun").shout());
}

Kết quả:

HELLO!
42!
A!
RUST IS FUN!
▶ Tự chạy thử

Các chuỗi được giữ tiếng Anh vì ví dụ đổi chữ thường thành chữ hoa (to_uppercase).

Ta chưa hề viết impl Shout for i32 hay impl Shout for char. Blanket impl đã bao trọn tất cả, kể cả những kiểu Display chưa tồn tại.

Bạn đã dùng blanket impl rất nhiều lần mà không biết. Thư viện chuẩn có (đại khái) impl<T: Display> ToString for T. Đó là lý do mọi kiểu có Display đều có phương thức .to_string(). Cũng mẹo đó cho bạn Into miễn phí khi bạn cài đặt From.

Một giới hạn: khi đã có một blanket impl, bạn không thể thêm một impl thứ hai, cụ thể hơn, cho kiểu mà nó đã bao (chẳng hạn một impl Shout for i32 đặc biệt). Rust sẽ có hai câu trả lời và từ chối bằng lỗi “conflicting implementations” (cài đặt xung đột).

Đây là một quy tắc khiến ai cũng bất ngờ ít nhất một lần. Thử cho Vec<String> một Display đẹp đẽ:

use std::fmt;
impl fmt::Display for Vec<String> {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "[{}]", self.join(", "))
}
}
error[E0117]: only traits defined in the current crate can be implemented for types defined outside of the crate
--> src/main.rs:3:1
|
3 | impl fmt::Display for Vec<String> {
| ^^^^^^^^^^^^^^^^^^^^^^-----------
| |
| `Vec` is not defined in the current crate
|
= note: define and implement a trait or new type instead

Rustc báo rằng chỉ những trait định nghĩa trong crate hiện tại mới cài đặt được cho kiểu định nghĩa bên ngoài crate, và Vec không thuộc crate của bạn. Đây là quy tắc orphan (orphan rule, “quy tắc mồ côi”): bạn chỉ cài đặt được một trait cho một kiểu nếu trait hoặc kiểu được định nghĩa trong crate của bạn. Display thuộc thư viện chuẩn, Vec cũng vậy, nên chẳng cái nào là của bạn.

Vì sao có quy tắc này? Hãy tưởng tượng hai crate bạn phụ thuộc cùng viết impl Display for Vec<String> của riêng chúng. Rust nên dùng cái nào? Không có câu trả lời tốt, nên Rust làm cho tình huống đó không thể xảy ra.

Cách lách là khuôn mẫu newtype: bọc kiểu bên ngoài trong một struct nhỏ của riêng bạn. Giờ kiểu đó là của bạn, và bạn cài đặt gì cho nó cũng được:

use std::fmt;
// Một lớp bọc cục bộ quanh một kiểu bên ngoài
struct Shopping(Vec<String>);
impl fmt::Display for Shopping {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "[{}]", self.0.join(", "))
}
}
fn main() {
let list = Shopping(vec![String::from("sữa"), String::from("trứng"), String::from("bánh mì")]);
println!("Bạn cần: {}", list);
println!("Tổng cộng {} món", list.0.len());
}

Kết quả:

Bạn cần: [sữa, trứng, bánh mì]
Tổng cộng 3 món
▶ Tự chạy thử

self.0 lấy ra Vec bên trong. Newtype không tốn gì lúc chạy: trong bộ nhớ, Shopping chính là một Vec<String>. Nhược điểm là các phương thức của Vec không nằm trực tiếp trên Shopping, nên bạn viết list.0.len() (hoặc tự thêm những phương thức cần thiết). Newtype còn hữu ích cho nhiều việc khác ngoài quy tắc orphan; xem Mẫu thiết kế.

Hai trait có thể có phương thức trùng tên, và chính kiểu đó cũng có thể có một phương thức như vậy. Vậy robot.greet() gọi cái nào?

trait English {
fn greet(&self) -> String;
fn language() -> String;
}
trait Spanish {
fn greet(&self) -> String;
fn language() -> String;
}
struct Robot;
impl Robot {
fn greet(&self) -> String {
String::from("BEEP BOOP")
}
}
impl English for Robot {
fn greet(&self) -> String { String::from("Hello!") }
fn language() -> String { String::from("English") }
}
impl Spanish for Robot {
fn greet(&self) -> String { String::from("¡Hola!") }
fn language() -> String { String::from("Spanish") }
}
fn main() {
let r = Robot;
println!("{}", r.greet()); // phương thức riêng của kiểu được ưu tiên
println!("{}", English::greet(&r)); // chọn trait theo tên
println!("{}", Spanish::greet(&r));
// Ở đây không có `self`, nên Rust cần cả kiểu LẪN trait:
println!("{}", <Robot as English>::language());
println!("{}", <Robot as Spanish>::language());
}

Kết quả:

BEEP BOOP
Hello!
¡Hola!
English
Spanish
▶ Tự chạy thử

Các lời chào được giữ nguyên tiếng Anh và tiếng Tây Ban Nha, vì tên hai trait chính là hai ngôn ngữ đó.

Ba mức, từ ngắn gọn đến tường minh hoàn toàn:

  • r.greet(): phương thức riêng (inherent) của kiểu được chọn trước.
  • English::greet(&r): “phiên bản English”. Rust suy ra kiểu từ &r.
  • <Robot as English>::language(): cú pháp đầy đủ (fully qualified syntax). Bạn cần nó cho những hàm không có self, vì không có giá trị nào để Rust nhìn vào. Chỉ viết English::language() sẽ thất bại với error[E0790]: cannot call associated function on trait without specifying the corresponding impl type (không gọi được hàm liên kết trên trait khi chưa chỉ rõ kiểu cài đặt).

Bạn sẽ không cần cái này thường xuyên. Khi cần, lỗi trình biên dịch thường gợi ý đúng cách sửa.

impl Trait ở tham số và giá trị trả về, cùng where

Phần tiêu đề “impl Trait ở tham số và giá trị trả về, cùng where”

Bạn có thể viết impl Trait ở hai chỗ, và ở mỗi chỗ nó mang nghĩa hơi khác:

  • Vị trí tham số: fn f(x: impl Trait): người gọi chọn kiểu. Đây là cách viết tắt của một hàm generic fn f<T: Trait>(x: T).
  • Vị trí trả về: fn f() -> impl Trait: chính hàm chọn một kiểu cụ thể và giấu nó đi. Người gọi chỉ biết “đó là thứ gì đó cài đặt Trait”.
// Người gọi chọn: iterator nào của u32 cũng được
fn print_all(items: impl Iterator<Item = u32>) {
let all: Vec<u32> = items.collect();
println!("{:?}", all);
}
// Hàm chọn: kiểu thật là một Filter<...> dài mà ta không bao giờ viết ra
fn evens_up_to(limit: u32) -> impl Iterator<Item = u32> {
(0..=limit).filter(|n| n % 2 == 0)
}
fn main() {
print_all(vec![3, 1, 4].into_iter());
print_all(evens_up_to(10));
}

Kết quả:

[3, 1, 4]
[0, 2, 4, 6, 8, 10]
▶ Tự chạy thử

impl Trait ở vị trí trả về đặc biệt tiện với closure và iterator. Kiểu thật của kết quả evens_up_to chứa cả kiểu của closure, mà closure thì không có tên nào để bạn gõ ra. impl Iterator cho phép bạn bỏ qua nó.

Khi một hàm có nhiều tham số generic, mỗi tham số lại có nhiều ràng buộc, cặp ngoặc nhọn sẽ chật chội. Mệnh đề where dời các ràng buộc ra sau chữ ký, để hình dạng của hàm dễ đọc hơn:

use std::fmt::{Debug, Display};
// Chật chội: fn report<T: Display, U: Debug + PartialOrd>(label: T, a: U, b: U) -> String
// Gọn hơn:
fn report<T, U>(label: T, a: U, b: U) -> String
where
T: Display,
U: Debug + PartialOrd,
{
let bigger = if a > b { a } else { b };
format!("{}: lớn hơn là {:?}", label, bigger)
}
fn main() {
println!("{}", report("điểm", 7, 12));
println!("{}", report(String::from("tên"), "ann", "bob"));
}

Kết quả:

điểm: lớn hơn là 12
tên: lớn hơn là "bob"
▶ Tự chạy thử

Với trình biên dịch, hai cách viết mang nghĩa y hệt nhau. where cũng là chỗ tự nhiên cho ràng buộc trên kiểu liên kết, như C::Item: Debug trong ví dụ đầu tiên của trang này.

Cài đặt một trait nhưng quên supertrait của nó. Nếu bạn xoá khối impl fmt::Display for Temperature khỏi ví dụ supertrait, bạn sẽ nhận được:

error[E0277]: `Temperature` doesn't implement `std::fmt::Display`
--> src/main.rs:13:17
|
13 | impl Pretty for Temperature {}
| ^^^^^^^^^^^ unsatisfied trait bound
|
note: required by a bound in `Pretty`
--> src/main.rs:3:15
|
3 | trait Pretty: fmt::Display {
| ^^^^^^^^^^^^ required by this bound in `Pretty`

Rustc báo Temperature chưa cài đặt Display, mà Pretty lại đòi hỏi nó. Cách sửa là cài đặt luôn supertrait (ở đây là Display). Dòng note chỉ thẳng vào : fmt::Display đang đòi hỏi điều đó.

Trả về hai kiểu khác nhau từ -> impl Trait. impl Trait giấu một kiểu. Nó không thể “lúc thì cái này, lúc thì cái kia”:

fn numbers(evens: bool) -> impl Iterator<Item = u32> {
if evens {
(0..10).filter(|n| n % 2 == 0)
} else {
vec![1, 3, 5].into_iter()
}
}
error[E0308]: `if` and `else` have incompatible types
--> src/main.rs:5:9
|
2 | / if evens {
3 | | (0..10).filter(|n| n % 2 == 0)
| | ------------------------------
| | | |
| | | the expected closure
| | expected because of this
4 | | } else {
5 | | vec![1, 3, 5].into_iter()
| | ^^^^^^^^^^^^^^^^^^^^^^^^^ expected `Filter<Range<{integer}>, {closure@...}>`, found `IntoIter<{integer}>`
6 | | }
| |_____- `if` and `else` have incompatible types
|
= note: expected struct `Filter<std::ops::Range<{integer}>, {closure@src/main.rs:3:24: 3:27}>`
found struct `std::vec::IntoIter<{integer}>`
help: you could change the return type to be a boxed trait object
|
1 - fn numbers(evens: bool) -> impl Iterator<Item = u32> {
1 + fn numbers(evens: bool) -> Box<dyn Iterator<Item = u32>> {
|
help: if you change the return type to expect trait objects, box the returned expressions
|
3 ~ Box::new((0..10).filter(|n| n % 2 == 0))
4 | } else {
5 ~ Box::new(vec![1, 3, 5].into_iter())
|

Rustc báo hai nhánh if và else có kiểu không tương thích, và gợi ý đổi kiểu trả về thành một trait object đóng hộp. Khi kiểu thật sự thay đổi lúc chạy, hãy trả về Box<dyn Trait> như trình biên dịch gợi ý. Xem Trait object.

  • Một kiểu liên kết (type Item) cho mỗi phần cài đặt đúng một câu trả lời. Dùng tham số generic khi một kiểu có thể cài đặt trait theo nhiều cách.
  • Tham số kiểu mặc định như Add<Rhs = Self> giữ trường hợp phổ biến ngắn gọn; impl Add<Other> for T ghi đè nó.
  • Một supertrait (trait Pretty: Display) đòi hỏi một trait khác, và cho phép phương thức mặc định dùng nó.
  • Một blanket impl (impl<T: Display> MyTrait for T) bao trọn mọi kiểu phù hợp cùng lúc: đó là cách .to_string() hoạt động.
  • Quy tắc orphan ngăn bạn cài đặt một trait bên ngoài cho một kiểu bên ngoài. Bọc kiểu đó trong một newtype để lách qua.
  • Dùng cú pháp đầy đủ <Type as Trait>::function() khi tên bị trùng; impl Trait ở vị trí trả về giấu một kiểu cụ thể; where làm ràng buộc dài dễ đọc hơn.

Kiểm tra nhanh

1. Vì sao Iterator dùng type Item thay vì Iterator<T>?

2. Rhs = Self trong trait Add<Rhs = Self> có nghĩa là gì?

3. Bạn muốn impl Display for Vec<String>. Rust từ chối. Cách sửa thường dùng là gì?

4. Dòng nào gọi phiên bản English của một hàm không có tham số self?

5. Trong fn evens() -> impl Iterator<Item = u32>, ai quyết định kiểu iterator cụ thể?

Điểm: 0 / 5