在上一篇文章中,我们讲到了 Rust 中的模式匹配,今天,我们进入另一个话题——为你的结构体扩展行为。
方法
Rust 允许你将函数与类型相关联。你可以使用 impl 来实现这一点:
#[derive(Debug)]
struct CarRace {
name: String,
laps: Vec<i32>,
}
impl CarRace {
// 无接收者,即静态方法
fn new(name: &str) -> Self {
Self { name: String::from(name), laps: Vec::new() }
}
// 对 self 的独占借用读写权限
fn add_lap(&mut self, lap: i32) {
self.laps.push(lap);
}
// 对 self 的共享只读借用访问
fn print_laps(&self) {
println!("Recorded {} laps for {}:", self.laps.len(), self.name);
for (idx, lap) in self.laps.iter().enumerate() {
println!("Lap {idx}: {lap} sec");
}
}
// 对 self 的独占所有权(稍后介绍)
fn finish(self) {
let total: i32 = self.laps.iter().sum();
println!("Race {} is finished, total lap time: {}", self.name, total);
}
}
fn main() {
let mut race = CarRace::new("Monaco Grand Prix");
race.add_lap(70);
race.add_lap(68);// CarRace::add_lap(&mut race, 68) 效果一样
race.print_laps();
race.add_lap(71);
race.print_laps();
race.finish();
// race.add_lap(42); // 此处如果不注释,编译器会报错
}
self 参数指定了 “接收者”,即方法所作用的对象。方法常见的接收者有以下几种:
&self:通过共享且不可变的引用从调用者处借用对象。之后该对象仍可继续使用。&mut self:通过唯一且可变的引用从调用者处借用对象。之后该对象仍可继续使用 。self:获取对象的所有权,并将其从调用者处转移走。此时该方法成为对象的所有者。当方法返回时,对象将被释放(内存被回收),除非对象的所有权被显式转移。完全拥有对象的所有权并不自动意味着可以对其进行可变操作。mut self:与上述self类似,但该方法可以对对象进行可变操作。- 无接收者:此时该方法成为结构体的静态方法,在 Rust 中,这种方法有一个专用称呼——关联方法。通常用于创建按惯例命名为
new的构造函数。
那么,方法和函数,有什么区别?(如果你熟悉 Kotlin 或者 Java,你自然而然知道这些区别,当然作为新兴语言 Rust,有必要在这里说明一下)
- 方法是在某个类型(如结构体或枚举)的实例上调用的,其首个参数以
self来表示该实例。 - 开发人员选择使用方法,是为了利用方法接收者语法,并使代码结构更加清晰。通过使用方法,我们可以将所有实现代码集中放在一个可预测的位置。其实从代码设计角度,方法的作用就是扩展结构体的行为,上述代码中
add_lap就扩展了CarRace这个结构体的行为。 - 请注意,也可以像调用关联函数(看看
new是怎么调用的)那样,通过显式传递接收者来调用方法。例如:CarRace::add_lap(&mut race, 20)。 - 看看
new的实现,发现里面有一个大写字母开头的Self,Self就是impl块所作用类型的类型别名,并且可以在块内的其他位置使用,此处的Self就相当于CarRace。 - 注意
self的使用方式,可以使用点号表示法来访问各个字段。(Kotlin/Java/C++ 中的this)。
特征 Trait
你可以使用 trait 对类型进行抽象。它们类似于接口:
// 定义了一个 trait,表示 Pet 的通用行为
trait Pet {
/// 从这只宠物口中说出一句话
fn talk(&self) -> String;
/// 向控制台打个招呼
fn greet(&self);
}
trait 定义了一些方法,类型要实现该 trait 就必须具备这些方法:
struct Dog {
name: String,
age: i8,
}
impl Pet for Dog {
fn talk(&self) -> String {
format!("Woof, my name is {}!", self.name)
}
fn greet(&self) {
println!("I am a Dog naming {}.", self.name);
}
}
fn main() {
let fido = Dog { name: String::from("Fido"), age: 5 };
fido.greet();
}
// Output
// I am a Dog naming Fido.
要为类型实现 trait,你需要使用impl Trait for XXX { .. }代码块。
trait 可以为方法提供默认实现:
trait Pet {
fn talk(&self) -> String;
// 此处提供了默认实现。
fn greet(&self) {
println!("Greeting");
}
}
struct Dog {
name: String,
age: i8,
}
// Pet 不实现 greet 方法
impl Pet for Dog {
fn talk(&self) -> String {
format!("Woof, my name is {}!", self.name)
}
}
fn main() {
let fido = Dog { name: String::from("Fido"), age: 5 };
fido.greet();
}
// Output
// Greeting
对于给定的类型,允许存在多个 impl 代码块。这既包括固有 impl 代码块,也包括 trait 实现代码块。同样,一个给定的类型可以实现多个 trait(而且通常情况下,类型会实现很多 trait!)。impl 代码块甚至可以分布在多个模块/文件中。
trait Bird {
fn fly(&self);
}
trait Car {
fn wheel(&self) -> i32;
}
struct BirdCar {
wheel_count: i32,
}
impl Car for BirdCar {
fn wheel(&self) -> i32 {
self.wheel_count
}
}
impl Bird for BirdCar {
fn fly(&self) {
println!("fly with {} wheels", self.wheel()); // 注意这里我们调用的是 Car 中的方法 wheel
}
}
fn main() {
let xiao_peng = BirdCar { wheel_count: 6 };
xiao_peng.fly();
}
一个 trait 可以要求实现它的类型也实现其他 trait,这些被称为超 trait。下面的代码中,任何实现 Pet 的类型都必须实现 Animal:
trait Animal {
fn leg_count(&self) -> u32;
}
trait Pet: Animal {
fn name(&self) -> String;
}
struct Dog(String);
impl Animal for Dog {
fn leg_count(&self) -> u32 {
4
}
}
impl Pet for Dog {
fn name(&self) -> String {
self.0.clone()
}
}
fn main() {
let puppy = Dog(String::from("Rex"));
println!("{} has {} legs", puppy.name(), puppy.leg_count());
}
// Output
// Rex has 4 legs
这个行为被称为 “特征继承” ,但是它的行为与面向对象继承不是一个意思(Rust 没有传统的面向对象继承)。它只是为特征的实现指定了一个额外的要求——你必须实现另一个特征。
关联类型是由特征实现提供的占位类型:
#[derive(Debug)]
struct Meters(i32);
#[derive(Debug)]
struct MetersSquared(i32);
trait Multiply {
type Output; // 声明一个关联类型
fn multiply(&self, other: &Self) -> Self::Output;
}
impl Multiply for Meters {
type Output = MetersSquared;
fn multiply(&self, other: &Self) -> Self::Output {
MetersSquared(self.0 * other.0)
}
}
fn main() {
println!("{:?}", Meters(10).multiply(&Meters(20)));
}
// Output
// MetersSquared(200)
关联类型有时也被称为 “输出类型”。关键在于,选择这个类型的是实现方,而非调用方(如果现在还搞不懂这个,美观,后续随着学习的深入,慢慢会理解的)。
许多标准库特征都有关联类型,包括算术运算符和迭代器。
派生
对于自定义类型,可以自动实现
trait ,如下所示:
#[derive(Debug, Clone, Default)] // 自动实现了 Debug,Clone,Default 三个 trait
struct Player {
name: String,
strength: u8,
hit_points: u8,
}
fn main() {
let p1 = Player::default(); // Defaul 特征添加了 default 构造函数。
let mut p2 = p1.clone(); // Clone 特征添加了 clone 方法。
p2.name = String::from("EldurScrollz");
// Debug 特征增加了对使用 {:?} 进行打印的支持。
println!("{p1:?} vs. {p2:?}");
}
// Output
// Player { name: "", strength: 0, hit_points: 0 } vs. Player { name: "EldurScrollz", strength: 0, hit_points: 0 }
派生是通过宏实现的,许多包提供了有用的派生宏以添加有用的功能。例如,serde(一个第三方序列化库)可以使用 #[derive(Serialize)] 为结构体派生序列化支持。
派生通常用于那些具有通用的、样板式实现且在大多数情况下都正确的特征。例如,下面展示了手动实现 Clone 与派生该特征相比会有多繁琐:
impl Clone for Player {
fn clone(&self) -> Self {
Player {
name: self.name.clone(),
strength: self.strength.clone(),
hit_points: self.hit_points.clone(),
}
}
}
想想在没有 lombok 等这些插件的支持下,Java 的那些样板代码。Kotlin 在这方面,好了一点点。
如果你有多个结构体,这种重复的样板代码编写就很耗费时间,最关键的时候,一旦修改结构体,很容易忘记修改对应的实现。
练习
让我们设计一个简单的日志记录实用工具,使用一个带有 log 方法的 Logger trait。
在测试中,这可能会将消息写入测试日志文件,而在生产版本中,它会将消息发送到日志服务器。
然而,下面给出的 StderrLogger 会记录所有消息,无论详细程度如何。
你的任务是编写一个 VerbosityFilter 类型,它将忽略高于最大级别的消息。
trait Logger {
/// 以给定的级别记录一条消息。
fn log(&self, verbosity: u8, message: &str);
}
struct StderrLogger;
impl Logger for StderrLogger {
fn log(&self, verbosity: u8, message: &str) {
eprintln!("verbosity={verbosity}: {message}");
}
}
/// 只记录不超过给定级别的消息。
struct VerbosityFilter {
max_verbosity: u8,
inner: StderrLogger,
}
// TODO: 为 VerbosityFilter 实现 Logger 特征。
fn main() {
let logger = VerbosityFilter { max_verbosity: 3, inner: StderrLogger };
logger.log(5, "FYI"); // 正常情况下,该消息是不能被打印的!
logger.log(2, "Uhoh");
}
答案
别看了,答案肯定在下一篇文章啦!