特徵(trait)是用來定義特定型別與其他型別共享的功能,也指定了這些型別要滿足的功能要有哪些,如上一篇提到當需要限縮泛型的型別的時候就很重要。
而特徵界限(trait bounds)可以指定泛型型別要是擁有特定行為的任意型別,透過這個方式就可以在我們定義的範圍內又保留彈性。
官方說 Rust 的特徵和其他語言的 interface 類似,不過我自己沒怎麼接觸過,有興趣的人可以比較看看,這裡就不花篇幅比較。
Rust 用關鍵字 trait
定義特徵,和 struct
滿類似,一樣宣告一個英文大寫開頭的名稱,一樣用大括號在裡面定義函數,不過這邊的函數我們可以只定義簽名就好,也就是說,我們可以只定義這個函數的名稱還有輸入輸出的型別,要實作這個特徵的型別自己再去實作實際的邏輯就好。
這些函數定義結尾用 ;
分隔。
trait Shape {
fn new() -> Self;
fn area(&self) -> f64;
fn perimeter(&self) -> f64;
}
這邊的self
一樣是指實體本身,函數可以是關聯函數或是方法。
我們用 impl A for B
來為某個型別 B 實作 A 特徵,型別沒有限制是 struct
,其他型別也可以,只是實作特徵時有一個限制:該特徵或該型別位於我們的crate
時,才能對型別實作特徵。
這部分等到介紹到 crate
、套件、模組時再一起介紹,現階段只需要知道我們目前同一個檔案,如果是自訂型別實作特徵都不會被限制。
我們試著定義不同形狀並實作 Shape
特徵:
trait Shape {
fn new() -> Self;
fn area(&self) -> f64;
fn perimeter(&self) -> f64;
}
struct Rectangle {
width: f64,
height: f64,
}
impl Shape for Rectangle {
fn new() -> Self {
Self { width: 1.0, height: 1.0 }
}
fn area(&self) -> f64 {
self.width * self.height
}
fn perimeter(&self) -> f64 {
2.0 * (self.width + self.height)
}
}
struct Circle {
radius: f64,
}
impl Shape for Circle {
fn new() -> Self {
Self { radius: 1.0 }
}
fn area(&self) -> f64 {
std::f64::consts::PI * self.radius * self.radius
}
fn perimeter(&self) -> f64 {
2.0 * std::f64::consts::PI * self.radius
}
}
fn main() {
let rect = Rectangle::new();
let circle = Circle::new();
println!("矩形面積: {}, 周長: {}", rect.area(), rect.perimeter());
println!("圓形面積: {}, 周長: {}", circle.area(), circle.perimeter());
}
實作特徵就要實作所有該特徵內的函數,不然編譯器會報錯提醒,例如把 new
的部分移除。
error[E0046]: not all trait items implemented, missing: `new`
--> src/main.rs:14:1
|
2 | fn new() -> Self;
| ----------------- `new` from trait
...
14 | impl Shape for Rectangle {
| ^^^^^^^^^^^^^^^^^^^^^^^^ missing `new` in implementation
另一種方式是,特徵可以定義預設行為,在 trait
的區塊內寫完整的函數,那個函數就是預設行為了。
trait Shape {
fn new() -> Self;
fn area(&self) -> f64;
fn perimeter(&self) -> f64;
fn introduce(&self) {
println!("This is a shape"); // 預設行為
}
}
如果需要自行定義,在 impl
的區塊定義同名稱的函數就會把預設行為覆蓋過去。
impl Shape for Rectangle {
fn new() -> Self {
Self { width: 1.0, height: 1.0 }
}
fn area(&self) -> f64 {
self.width * self.height
}
fn perimeter(&self) -> f64 {
2.0 * (self.width + self.height)
}
fn introduce(&self) {
println!("This is a rectangle"); // 各個實作再自行定義的行為
}
}
這樣就可以不用把所有特徵包含的函數都多實作一次,減少重複代碼,也保留了彈性可以根據需求在實作的時候再自行定義。
上面的程式還有一個地方想討論,特徵應該定義什麼樣的函數?
以目前的例子來說,建立 Rectangle
需要兩個參數: width
和 height
,但是 Circle
只需要一個參數 radius
,如果我想要在建立實例的時候自訂大小,因為我把 new
放在特徵定義,有時候只要一個參數、有時候要兩個參數,實際上沒辦法改成可以自訂初始化實例大小又定義出共通參數,看來就是個不好的實踐。比較恰當的做法應該給每個型別自行定義。
trait Shape {
fn area(&self) -> f64;
fn perimeter(&self) -> f64;
}
struct Rectangle {
width: f64,
height: f64,
}
impl Rectangle {
fn new(width: f64, height: f64) -> Self {
Self {
width,
height
}
}
}
impl Shape for Rectangle {
fn area(&self) -> f64 {
self.width * self.height
}
fn perimeter(&self) -> f64 {
2.0 * (self.width + self.height)
}
}
struct Circle {
radius: f64,
}
impl Circle {
fn new(radius: f64) -> Self {
Self {radius}
}
}
impl Shape for Circle {
fn area(&self) -> f64 {
std::f64::consts::PI * self.radius * self.radius
}
fn perimeter(&self) -> f64 {
2.0 * std::f64::consts::PI * self.radius
}
}
fn main() {
let rect = Rectangle::new(1.5, 2.5);
let circle = Circle::new(1.5);
println!("矩形面積: {}, 周長: {}", rect.area(), rect.perimeter());
println!("圓形面積: {}, 周長: {}", circle.area(), circle.perimeter());
}
這樣看起來方法應該比較容易放在特徵裡,因為可以透過 &self
一個參數取得實例上的其他屬性或方法,而且關聯性高的方法再集中到特徵,其他就各型別自行定義、實作就好,才不會因為特徵的限制反而變不彈性了。
如同上一篇提到,在有泛型的情況下,有時候我們想要保有泛型的彈性,但又想限制泛型的範圍的時候,就需要用到特徵。
接下來討論函數的參數要如何使用特徵來限制型別,最基本的情境可以用 impl Trait
語法,代表的是有實作某個特徵的型別,在函數本體我們就可以調用這個特徵有定義的方法。
需要特別注意impl Trait
語法只能用在函數的參數和回傳值的型別詮釋。
以下編譯器都會報錯:
let shape: impl Shape = Circle {
name: "Circle".to_string(),
radius: 2.5,
};
// `impl Trait` is not allowed in the type of variable bindings
struct MyStruct {
shape: impl Shape
}
// `impl Trait` is not allowed in field types
正確用法:
fn print_shape_info(shape: &impl Shape) {
println!("面積: {}, 周長: {}", shape.area(), shape.perimeter());
}
impl Shape
就是代表有實作 Shape
的型別, &
是指引用,所以這個函數不會取得傳進來的 shape
的所有權。
不過這個寫法是一種語法糖,完整的寫法就是特徵界限,上面的例子完整寫是這樣:
fn print_shape_info<T: Shape>(shape: &T) {
println!("面積: {}, 周長: {}", shape.area(), shape.perimeter());
}
<T: Trait>
有限制一定要是特徵不能放其他型別,不然編譯器會報錯。
error[E0404]: expected trait, found struct `Circle`
--> src/main.rs:51:24
|
51 | fn print_shape_info<T: Circle>(shape: &T) {
| ^^^^^^ not a trait
我們在泛型已經看過類似的寫法,不過那時候的再更複雜一些,現在再回頭看一下:
fn sum<T: std::ops::Add<Output = T>>(n1: T, n2: T) -> T {
n1 + n2
}
std::ops::Add
是 Rust 標準庫中定義的一個特徵,和前面的 Shape
是一樣的,傳進來參數是同一種泛型型別 T
,比較特別的應該是<Output = T>
,所以我們看一下 std::ops::Add
的源碼:
pub trait Add<Rhs = Self> {
/// The resulting type after applying the `+` operator.
#[stable(feature = "rust1", since = "1.0.0")]
type Output;
/// Performs the `+` operation.
///
/// # Example
///
/// ```
/// assert_eq!(12 + 1, 13);
/// ```
#[must_use = "this returns the result of the operation, without modifying the original"]
#[rustc_diagnostic_item = "add"]
#[stable(feature = "rust1", since = "1.0.0")]
fn add(self, rhs: Rhs) -> Self::Output;
}
這是一個用到泛型的特徵,Rhs
(Right Hand Side)是泛型名稱,= Self
代表預設值是實作這個特徵的型別,這樣可以讓使用者在大多數情況下不必指定+
右邊的型別,不指定代表左右兩邊是同一個型別,也可以彈性在需要的時候指定右邊的型別。
特徵本體有一行 type Output;
,這種在特徵中定義的類型別名叫做關聯型別(Associated Type)。它允許我們在不使用泛型參數的情況下,為特徵引入額外的型別靈活性。
想像一下有一個特徵定義不同的方法,如果沒有關聯型別的話,要嘛我所有方法都輸出都要是一樣的,被泛型參數限制,要嘛用多個泛型參數,但那樣可讀性會變很差,泛型參數則針對這種情況提供了更精細型別選擇。
簡單來說,關聯型別是一種在特徵內部定義的、與特徵相關的類型,在實作的時候才自己定義。
再看 Add
特徵裡的方法 add
,第一個參數是 self
、第二個 rhs
要和泛型參數型別相同,分別代表 +
的左邊和右邊,如果不特別定義的話預設兩者是同一個型別,輸出則是 Self
命名空間底下的 Output
型別,會在實作特徵的時候才定下來。
我們試著幫非數字的型別實作 Add
特徵:
use std::ops::Add;
#[derive(Debug)]
struct Point {
x: i32,
y: i32,
}
impl Add<i32> for Point { // + 左邊是 Point 型別,右邊是 i32
type Output = Self; // Point 型別
fn add(self, rhs: i32) -> Self::Output {
Point {
x: self.x + rhs,
y: self.y + rhs,
}
}
}
fn main() {
let point = Point { x: 1, y: 2 };
println!("Original point: {:?}", point); // Original point: Point { x: 1, y: 2 }
let modified_point = point + 1;
println!("Modified point: {:?}", modified_point); // Modified point: Point { x: 2, y: 3 }
}
我定義了 Point
是一個座標型別,為了讓它使用 +
實作 Add
特徵,型別限定為 i32
和座標的 x
、 y
屬性相同方便計算,方法 add
定義+
右邊的型別是特徵的泛型參數型別 i32
,回傳另外一個座標位置是把實體上的 x
、y
數值分別加上+
右邊的數值。
這是 Output 和實體是同一種型別的情況。
也可以讓 Output 的型別和+
左右邊都不同,例如把座標的 x
、 y
數值加上加號右邊的數值而且回傳的型別是 f64
:
impl Add<i32> for Point {
type Output = f64;
fn add(self, rhs: i32) -> Self::Output {
(self.x + self.y + rhs) as f64
}
}
fn main() {
let point = Point { x: 1, y: 2 };
println!("Original point: {:?}", point);
let result = point + 1;
println!("Add result: {}", result); // Add result: 4.0
}
回到特徵界限,現在已經理解當初寫成 fn sum<T: std::ops::Add<Output = T>>(n1: T, n2: T) -> T
怎麼限制範圍在只有有實作特定特徵的型別。
另外也可以透過 +
來指定不只一個特徵界限,例如當初這段:
// 針對有正負號的數字型別的特殊方法
impl<T: std::ops::Neg<Output = T> + std::ops::Add<Output = T> + std::ops::Mul<Output = T> + Copy> Point<T> {
// 以 x 軸為對稱軸的對稱點
fn symmetric_x(&self) -> Self {
Point { x: self.x, y: -self.y }
}
// 以 y 軸為對稱軸的對稱點
fn symmetric_y(&self) -> Self {
Point { x: -self.x, y: self.y }
}
}
我們就限制了型別必須要同時符合實作了 Neg
、Add
、 Mul
、 Copy
這些特徵的型別,而且他們的 Output
型別都被限制成同一種 T
。
我們可以再多一個泛型參數 U
代表擁有不同特徵的型別:
use std::ops::Add;
fn add_numbers<T: Into<U>, U: Add<Output = U>>(a: T, b: U) -> U
{
a.into() + b
}
fn main() {
let result: f64 = add_numbers(1, 0.3);
println!("{}", result); // 1.3
}
不過條件越來越多可讀性會變差,所以 Rust 有提供另一個在函數簽名之後指定特徵界限的語法 where
子句。
改寫完之後結果如下:
fn add_numbers<T, U>(a: T, b: U) -> U
where
T: Into<U>,
U: Add<Output = U>,
{
a.into() + b
}
如果有多個泛型參數又都不只一個特徵界限,這種寫法就更好讀。
Rust 除了能在輸入的地方用impl Trait
語法代表具有某種特徵的型別,也可以在回傳值的地方用來代表回傳的型別具有某種特徵。
例如設計一個函數會回傳擁有 Shape
特徵的實體。
fn get_either_shape(switch: bool) -> impl Shape {
Rectangle {
name: String::from("rectangle"),
width: 1.0,
height: 1.0
}
}
這樣可以正常編譯沒問題,不過這樣沒有發揮 impl Shape
的優勢,所以想讓它可以回傳不同的型別:
fn get_either_shape(switch: bool) -> impl Shape {
if switch {
Rectangle {
name: String::from("rectangle"),
width: 1.0,
height: 1.0
}
} else {
Circle {
name: String::from("circle"),
radius: 1.0
}
}
}
不過改成這樣就有問題了:
error[E0308]: `if` and `else` have incompatible types
--> src/main.rs:109:9
|
102 | / if switch {
103 | | / Rectangle {
104 | | | name: String::from("rectangle"),
105 | | | width: 1.0,
106 | | | height: 1.0
107 | | | }
| | |_________- expected because of this
108 | | } else {
109 | | / Circle {
110 | | | name: String::from("circle"),
111 | | | radius: 1.0
112 | | | }
| | |_________^ expected `Rectangle`, found `Circle`
113 | | }
| |_______- `if` and `else` have incompatible types
|
help: you could change the return type to be a boxed trait object
|
101 | fn get_either_shape(switch: bool) -> Box<dyn Shape> {
| ~~~~~~~ +
help: if you change the return type to expect trait objects, box the returned expressions
|
103 ~ Box::new(Rectangle {
104 | name: String::from("rectangle"),
105 | width: 1.0,
106 | height: 1.0
107 ~ })
108 | } else {
109 ~ Box::new(Circle {
110 | name: String::from("circle"),
111 | radius: 1.0
112 ~ })
|
原因是當使用 impl Trait
作為回傳型別時,編譯器需要在編譯時確定具體回傳的型別,而即使Rectangle
和 Circle
有相同特徵,但它們還是不同的型別、有不同的結構,這種情況可以用 Box<T>
:一種智慧指標來處理,之後在智慧指標的介紹再來探討其中機制。
特徵與特徵界限和泛型的搭配可以保留泛型彈性的同時,又可以提早在編譯期發現型別錯誤,讓程式更穩定(降低在執行找不到對應方法報錯造成程式崩潰),提升程式執行效率(因為執行的時候不用再檢查一次有沒有這個方法),提供一個可以精確地指定型別需要滿足的條件的機制,既不會過於限制,也不會過於寬鬆,讓我們能保留彈性的同時寫出更安全、更高效的程式碼。