Advanced Traits
Traits đã được nhắc đến trong chương 10 “Traits: Defining Shared Behavior”, tuy nhiên đó chỉ là những kiến thức cơ bản nhất của traits mà thôi. Trong chương này, ta sẽ đi sâu hơn vào những tính năng nâng cao của traits.
Sử dụng Associated Types khi định nghĩa Trait
Associated types có thể được sử dụng khi định nghĩa Trait mà khi implement nó ta hoàn toàn biết trước được kiểu dữ liệu mà trait đó muốn sử dụng.
Các tính năng nâng cao khác ở chương này đa số đều ít khi được sử dụng, tuy nhiên associated types lại ở khoảng giữa: nó không được sử dụng quá nhiều như những tính năng khác được mô tả ở trong cuốn sách này nhưng lại được sử dụng phổ biến hơn các tính năng nâng cao khác.
Một ví dụ điển hình của việc sử dụng associated type trong trait là Iterator
của thư viện chuẩn trong Rust. Associated type có tên là Item
ở trong trường hợp này. Trong phần “The Iterator
Trait and the next
Method”, ta đã đề cập đến định nghĩa của Iterator
trait
pub trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
}
Kiểu Item
còn được gọi là placeholder type, next
method sẽ trả về một kiểu Option<Self::Item>
. Các struct implement Iterator
này đều sẽ có một kiểu dữ liệu duy nhất và cố định là Item
, next
method làm nhiệm vụ trả về Option
chứa Item đó.
Đến đây ta có thể thấy khá nhiều điểm tương đồng giữa associated type và generics type, vậy tại sao phải sử dụng associated types?
Ví dụ sau sẽ cho ta thấy sự khác biệt giữa 2 cách dùng. Ở chương 13, listing 13-21 sử dụng associated type Item
bằng u32
:
Filename: src/lib.rs
struct Counter {
count: u32,
}
impl Counter {
fn new() -> Counter {
Counter { count: 0 }
}
}
impl Iterator for Counter {
type Item = u32;
fn next(&mut self) -> Option<Self::Item> {
// --snip--
if self.count < 5 {
self.count += 1;
Some(self.count)
} else {
None
}
}
}
Và cú pháp dùng generics được mô tả trong listing 19-13?
pub trait Iterator<T> {
fn next(&mut self) -> Option<T>;
}
Sự khác biệt ở đây là khi dùng generics, ta phải chú thích kiểu dữ liệu cho mỗi lần implement; hoàn toàn có thể implement Iterator<String> for Counter
hoặc bất kì kiểu dữ liệu nào khác ngoài u32
, do đó ta có thể có rất nhiều các phiên bản khác nhau của Iterator
cho Counter
. Nói một cách khác, khi một trait sử dụng generics parameter, nó có thể implement rất nhiều lần, thay đổi kiểu dữ liệu cho mỗi lần đó. Khi sử dụng method next
, ta bắt buộc phải cung cấp kiểu dữ liệu để thể hiện Iterator
nào được sử dụng.
Với associated types, ta không cần phải chú thích kiểu dữ liệu như vậy bởi trait này không thể implement nhiều lần, Iterator
chỉ có Item
với kiểu dữ liệu duy nhất là u32
mà thôi.
Tham số Generic Type mặc định và nạp chồng toán tử (Operator Overloading)
Khi sử dụng generic type, ta có thể chỉ định tham số mặc định cho nó. Cú pháp ở đây là <PlaceholderType=ConcreteType>
.
Một ví dụ tuyệt vời nhất cho trường hợp này là khi dùng đến nạp chồng toán tử (operator overloading). Operator overloading dùng để biến tấu hành vi của một toán tử (như là +
) trong vài trường hợp cụ thể.
Rust không cho phép bạn tạo mới một toán tử hay nạp chồng một toán tử bất kì. Tuy nhiên, bạn có thể nạp chồng một toán tử nếu toán tử đó nằm trong thư viện std::ops
bằng cách implement toán tử nằm trong chính thư viện này. Ví dụ trong listing 19-4, ta sẽ nạp chồng toán tử +
để cộng 2 Point
với nhau. Để làm được điều này, ta phải implement Add
trait cho Point
struct:
Filename: src/main.rs
use std::ops::Add; #[derive(Debug, Copy, Clone, PartialEq)] struct Point { x: i32, y: i32, } impl Add for Point { type Output = Point; fn add(self, other: Point) -> Point { Point { x: self.x + other.x, y: self.y + other.y, } } } fn main() { assert_eq!( Point { x: 1, y: 0 } + Point { x: 2, y: 3 }, Point { x: 3, y: 3 } ); }
Method add
sẽ cộng hoành độ và tung độ tương ứng của 2 Point
. Trait Add
lúc này sẽ có một associated type là Output
.
Generic type mặc định được nằm trong phần định nghĩa Add
trait, như sau:
#![allow(unused)] fn main() { trait Add<Rhs=Self> { type Output; fn add(self, rhs: Rhs) -> Self::Output; } }
Ta có thể thấy phần khác biệt ở đây là Rhs=Self
: cú pháp này được gọi là default type parameters. Rhs
(viết tắt của "right hand side") sẽ định nghĩa kiểu dữ liệu cho biến rhs
được dùng trong method add
. Nếu ta không chỉ định kiểu dữ liệu cho Rhs
khi implement, Rhs
khi đó sẽ mặc định có kiểu Self
.
Khi implement Add
cho Point
, ta sẽ sử dụng kiểu mặc định cho Rhs
vì mục đích cuối cùng là cộng 2 Point
instances. Cùng xem ví dụ mà ở đây ta sẽ implement Add
trait và không sử dụng kiểu mặc định cho Rhs
nữa.
Ở đây có 2 structs, Millimeters
và Meters
, thể hiện giá trị ở các đơn vị đo khác nhau. Các struct này sẽ bao bên ngoài của kiểu dữ liệu đã tồn tại (u32
), cách làm này được gọi là newtype pattern (xem thêm trong phần “Using the Newtype Pattern to Implement External Traits on External Types”). Ở đây ta sẽ cộng giá trị ở đơn vị millimeters với giá trị ở đơn vị meters với việc bắt buộc phải chuyển đổi đơn vị đo.
Filename: src/lib.rs
use std::ops::Add;
struct Millimeters(u32);
struct Meters(u32);
impl Add<Meters> for Millimeters {
type Output = Millimeters;
fn add(self, other: Meters) -> Millimeters {
Millimeters(self.0 + (other.0 * 1000))
}
}
Để làm được điều này, ta sẽ chỉ định impl Add<Meters>
để set giá trị cho tham số Rhs
thay vì dùng tham số mặc định Self
.
Gọi các method có cùng tên
Rust không ngăn cản việc bạn tạo method có cùng tên với method của trait khác, cũng như cấm việc implement 2 trait có cùng một kiểu. Ta hoàn toàn có thể implement một method có cùng tên với các method của các traits khác.
Khi gọi các methods có cùng tên , bạn sẽ cần chỉ ra đâu là method mà bạn cần. Xem xét Listing 19-16 sau đây, có 2 trait là Pilot
và Wizard
đều định nghĩa method fly
. Sau đó implement cả 2 cho kiểu Human
cũng đã có sẵn method là fly
.
Filename: src/main.rs
trait Pilot { fn fly(&self); } trait Wizard { fn fly(&self); } struct Human; impl Pilot for Human { fn fly(&self) { println!("This is your captain speaking."); } } impl Wizard for Human { fn fly(&self) { println!("Up!"); } } impl Human { fn fly(&self) { println!("*waving arms furiously*"); } } fn main() {}
Khi gọi fly
ở Human
instance, compiler sẽ mặc định gọi method nào được implement trực tiếp, được thể hiện trong Listing 19-17.
Filename: src/main.rs
trait Pilot { fn fly(&self); } trait Wizard { fn fly(&self); } struct Human; impl Pilot for Human { fn fly(&self) { println!("This is your captain speaking."); } } impl Wizard for Human { fn fly(&self) { println!("Up!"); } } impl Human { fn fly(&self) { println!("*waving arms furiously*"); } } fn main() { let person = Human; person.fly(); }
Kết quả là dòng chữ *waving arms furiously*
được in ra, thể hiện rằng Rust đã gọi fly
trực tiếp từ Human
.
Nếu muốn gọi methods fly
từ Pilot
hoặc Wizard
, ta cần phải khai báo rõ ràng hơn bằng một cú pháp khác trong Listing 19-18.
Filename: src/main.rs
trait Pilot { fn fly(&self); } trait Wizard { fn fly(&self); } struct Human; impl Pilot for Human { fn fly(&self) { println!("This is your captain speaking."); } } impl Wizard for Human { fn fly(&self) { println!("Up!"); } } impl Human { fn fly(&self) { println!("*waving arms furiously*"); } } fn main() { let person = Human; Pilot::fly(&person); Wizard::fly(&person); person.fly(); }
Sau khi chạy, ta sẽ được kết quả:
$ cargo run
Compiling traits-example v0.1.0 (file:///projects/traits-example)
Finished dev [unoptimized + debuginfo] target(s) in 0.46s
Running `target/debug/traits-example`
This is your captain speaking.
Up!
*waving arms furiously*
Ở đây, method fly
có tham số self
, vì vậy ta có thể truyền person
vào và Rust có thể tìm ra trait nào cần sử dụng trong trường hợp này.
Vậy trong trường hợp sử dụng associated functions (không có tham số self
) thì sao? Rust sẽ không thể biết được bạn cần gọi method của trait nào nếu không sử dụng fully qualified syntax. Xét ví dụ dưới đây:
Filename: src/main.rs
trait Animal { fn baby_name() -> String; } struct Dog; impl Dog { fn baby_name() -> String { String::from("Spot") } } impl Animal for Dog { fn baby_name() -> String { String::from("puppy") } } fn main() { println!("A baby dog is called a {}", Dog::baby_name()); }
Trong hàm main
, hàm Dog::baby_name
được gọi, khi đó ta sẽ có kết quả:
$ cargo run
Compiling traits-example v0.1.0 (file:///projects/traits-example)
Finished dev [unoptimized + debuginfo] target(s) in 0.54s
Running `target/debug/traits-example`
A baby dog is called a Spot
Kết quả này không phải cái ta mong muốn. Hàm baby_name
phải in ra dòng chữ A baby dog is called a puppy
. Do vậy, kĩ thuật được sử dụng trong Listing 19-18 không áp dụng được trong trường hợp này; nếu thay đổi code như là Listing 19-20 thì sao:
Filename: src/main.rs
trait Animal {
fn baby_name() -> String;
}
struct Dog;
impl Dog {
fn baby_name() -> String {
String::from("Spot")
}
}
impl Animal for Dog {
fn baby_name() -> String {
String::from("puppy")
}
}
fn main() {
println!("A baby dog is called a {}", Animal::baby_name());
}
Vì Animal::baby_name
không có tham số self
, và có thể có các struct khác cũng sẽ implement Animal
trait, do đó Rust không thể biết được hàm Animal::baby_name
sẽ sử dụng implementation nào. Lỗi sẽ như sau:
$ cargo run
Compiling traits-example v0.1.0 (file:///projects/traits-example)
error[E0283]: type annotations needed
--> src/main.rs:20:43
|
20 | println!("A baby dog is called a {}", Animal::baby_name());
| ^^^^^^^^^^^^^^^^^ cannot infer type
|
= note: cannot satisfy `_: Animal`
For more information about this error, try `rustc --explain E0283`.
error: could not compile `traits-example` due to previous error
Vậy để pass qua được lỗi này, cùng xem Listing 19-21:
Filename: src/main.rs
trait Animal { fn baby_name() -> String; } struct Dog; impl Dog { fn baby_name() -> String { String::from("Spot") } } impl Animal for Dog { fn baby_name() -> String { String::from("puppy") } } fn main() { println!("A baby dog is called a {}", <Dog as Animal>::baby_name()); }
Ta sẽ cung cấp cho Rust một kiểu chú thích trong cặp ngoặc <>
, trong đó sẽ chỉ rõ rằng phương thức baby_name
của Animal
trait được implement từ Dog
. Khi đó kết quả sẽ như ta mong muốn:
$ cargo run
Compiling traits-example v0.1.0 (file:///projects/traits-example)
Finished dev [unoptimized + debuginfo] target(s) in 0.48s
Running `target/debug/traits-example`
A baby dog is called a puppy
Một cách tổng quát, fully qualified syntax được định nghĩa như sau:
<Type as Trait>::function(receiver_if_method, next_arg, ...);
Sẽ không có receiver
trong trường hợp đó là một associated functions chứ không phải methods. Bạn có thể sẽ phải sử dụng fully qualified syntax, tuy nhiên hoàn toàn có thể bỏ qua một phải phần nếu Rust có đủ thông tin để tự mình tìm ra được bạn sẽ muốn gọi hàm nào, giống như các ví dụ đã bàn ở trên.
Sử dụng Supertraits để gọi hàm của một trait từ trait khác.
Trong một vài trường hợp, ta cần sử dụng hàm của một trait từ trait khác. Khi đó, bạn cần phải dựa vào supertrait!
Ví dụ, ta có một trait là OutlinePrint
với method outline_print
sẽ in ra màn hình một giá trị với khung bao quanh. Nếu có một struct Point
implement Display
trait, khi gọi method outline_print
với đầu vào x bằng 1 và y bằng 3, ta sẽ có kết quả:
**********
* *
* (1, 3) *
* *
**********
Trong phần cài đặt hàm outline_print
, ta sẽ cần đến hàm ở bên trong Display
trait. Cú pháp ở đây là OutlinePrint::Display
. Xem thêm ở listing 19-22:
Filename: src/main.rs
use std::fmt; trait OutlinePrint: fmt::Display { fn outline_print(&self) { let output = self.to_string(); let len = output.len(); println!("{}", "*".repeat(len + 4)); println!("*{}*", " ".repeat(len + 2)); println!("* {} *", output); println!("*{}*", " ".repeat(len + 2)); println!("{}", "*".repeat(len + 4)); } } fn main() {}
Do OutlinePrint
yêu cầu sử dụng Display
, ta có thể sử dụng hàm to_string
của Display
trait. Nếu không sử dụng cú pháp :Display
như trên, ta sẽ gặp lỗi no method named to_string
was found for the types &Self
in current scope.
Tuy nhiên, hãy xem điều gì xảy ra nếu ta implement OutlinePrint
cho một struct không implement Display
, ví dụ như Point
:
Filename: src/main.rs
use std::fmt;
trait OutlinePrint: fmt::Display {
fn outline_print(&self) {
let output = self.to_string();
let len = output.len();
println!("{}", "*".repeat(len + 4));
println!("*{}*", " ".repeat(len + 2));
println!("* {} *", output);
println!("*{}*", " ".repeat(len + 2));
println!("{}", "*".repeat(len + 4));
}
}
struct Point {
x: i32,
y: i32,
}
impl OutlinePrint for Point {}
fn main() {
let p = Point { x: 1, y: 3 };
p.outline_print();
}
Lỗi ở đây do Display
là bắt buộc phải được implement, nhưng Point chưa làm điều đó:
$ cargo run
Compiling traits-example v0.1.0 (file:///projects/traits-example)
error[E0277]: `Point` doesn't implement `std::fmt::Display`
--> src/main.rs:20:6
|
20 | impl OutlinePrint for Point {}
| ^^^^^^^^^^^^ `Point` cannot be formatted with the default formatter
|
= help: the trait `std::fmt::Display` is not implemented for `Point`
= note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead
note: required by a bound in `OutlinePrint`
--> src/main.rs:3:21
|
3 | trait OutlinePrint: fmt::Display {
| ^^^^^^^^^^^^ required by this bound in `OutlinePrint`
For more information about this error, try `rustc --explain E0277`.
error: could not compile `traits-example` due to previous error
Vì vậy, hãy implement Display
cho struct Point
:
Filename: src/main.rs
trait OutlinePrint: fmt::Display { fn outline_print(&self) { let output = self.to_string(); let len = output.len(); println!("{}", "*".repeat(len + 4)); println!("*{}*", " ".repeat(len + 2)); println!("* {} *", output); println!("*{}*", " ".repeat(len + 2)); println!("{}", "*".repeat(len + 4)); } } struct Point { x: i32, y: i32, } impl OutlinePrint for Point {} use std::fmt; impl fmt::Display for Point { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "({}, {})", self.x, self.y) } } fn main() { let p = Point { x: 1, y: 3 }; p.outline_print(); }
Sử dụng Newtype Pattern để bỏ qua Orphan rule
Trong chương 10 phần “Implementing a Trait on a Type”, ta đã đề cập đến orphan rule, đó là một quy tắc cho phép implement trait cho một type miễn là trait hoặc type đó thuộc crate mà ta đang implement. Tuy nhiên ta hoàn toàn có thể lách luật bằng cách sử dụng newtype pattern, liên quan đến việc tạo một kiểu mới bằng tuple struct. (Đã đề cập đến trong phần “Using Tuple Structs without Named Fields to Create Different Types” của chương 5). Tuple struct này sẽ có 1 trường duy nhất và bọc bên ngoài kiểu mà ta muốn implement (wrapper type). Khi đó wrapper type này sẽ thuộc local của crate và ta hoàn toàn có thể implement trait cho wrapper type này.
Ví dụ, giả sử ta muốn implement Display
cho Vec<T>
, trait và type này đều mắc phải orphan rule (vì đều không nằm trong local của crate), vì vậy ta không thể implement Display
cho Vec<T>
một cách trực tiếp. Ta cần phải tạo một wapper type có tên Wrapper
bao bên ngoài của Vev<T>
; sau đó implement Display
cho Wrapper
, như listing 19-23.
Filename: src/main.rs
use std::fmt; struct Wrapper(Vec<String>); impl fmt::Display for Wrapper { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "[{}]", self.0.join(", ")) } } fn main() { let w = Wrapper(vec![String::from("hello"), String::from("world")]); println!("w = {}", w); }
Ta sẽ sử dụng self.0
để truy cập vào biến Vec<T>
như ví dụ trên.
Với việc sử dụng Wrapper
, ta có thể implement mọi method cho Vec<T>
một cách gián tiếp. Nếu bạn muốn Wapper
có mọi method mà Vec<T>
có, hãy implement Deref
trait (được nói đến ở chương 15 “Treating Smart Pointers Like Regular References with the Deref
Trait”).
Bây giờ bạn đã hiểu về newtype pattern trong Rust rồi đó, nó thực sự hữu dụng khi đừng bên cạnh trait. Và bây giờ, hãy chuyển qua các phần khác trong chương nhé.