Rust:类型 impl
Rust:类型 impl
高级类型特性
在现代的面向对象语言中,一个类中往往包含属性和方法。Rust吸收了面向对象的思想,允许用户以类似于面向对象的思路去编写程序。
impl 块
在Rust中,假设把一个struct看成一个类,属性一般放在结构体中,而方法就放在它的impl和trait中。本文主要聚焦于 impl,trait 是另一个强大且重要的特性,我们将在后续章节单独讨论。
impl 块用于为类型实现功能,在定义完一个结构体后,就可以通过impl 结构体名的形式来给这个类定义方法:
struct Person {
name: String,
age: u32,
}
impl Person {
// 创建一个新Person
fn new(name: String, age: u32) -> Self {
Person { name, age }
}
// 简单的介绍方法
fn say_hello(&self) {
println!("你好,我是{}", self.name);
}
// 检查是否成年
fn is_adult(&self) -> bool {
self.age >= 18
}
}
以上代码定义了一个Person结构体,内部含name和age两个字段。
new是非常常见的类方法,它一般用于传入参数,返回该类型的对象。这个函数的第一个参数不是self,调用方式为:
let p = Person::new("zhangsan", 18);
需要通过类名::方法的形式进行调用。这个方法的返回值是Self,在impl内部,Self特指类型本身,此处等于Person。
这种第一个参数不是self的方法,称为关联函数,在一些面向对象语言中也叫做静态方法。
对于后续两个方法say_hello和is_adult,它们的第一个参数是&self。这种写法相当于self: &Self,注意区分首字母大小写,首字母小写的是参数,大写的是类型。
对于这种方法,必须通过.func()的形式调用,例如:
p.say_hello();
p.is_adult();
通过.操作符,会把.前面的变量作为第一个参数self传进方法中,因此以上代码又相当于:
Person::say_hello(&p);
Person::is_adult(&p);
值得注意的是,这种第一个参数为self的方法,内部可以通过self.xxx来访问一个对象内部的属性,从而做出逻辑处理。例如此处通过self.age和self.name来分别判断是非成年,输出自己的名字。
这种带有self的叫做实例方法。
一个类型可以有多个impl块,这在组织代码时非常有用。只要方法名不冲突,你可以将相关的方法分组到不同的impl块中:
struct Person {
name: String,
age: u32,
}
// 第一个impl块:构造和基本信息方法
impl Person {
fn new(name: String, age: u32) -> Self {
Person { name, age }
}
fn say_hello(&self) {
println!("你好,我是{}", self.name);
}
}
// 第二个impl块:年龄相关方法
impl Person {
fn is_adult(&self) -> bool {
self.age >= 18
}
fn have_birthday(&mut self) {
self.age += 1;
println!("过生日!现在{}岁", self.age);
}
}
// 第三个impl块:工具方法
impl Person {
fn info(&self) -> String {
format!("姓名: {}, 年龄: {}", self.name, self.age)
}
}
只要确保每个方法的名字在当前类型中唯一,就可以自由地使用多个impl块来组织代码,甚至可以把impl放到不同文件中。
其他类型的 impl
impl可以用于struct、enum、union以及trait object,最后一个会在后面学习,现在只讨论前三种。
枚举的方法通常结合模式匹配来处理不同的变体:
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
}
impl Message {
// 关联函数创建不同变体
fn quit() -> Self {
Message::Quit
}
fn mov(x: i32, y: i32) -> Self {
Message::Move { x, y }
}
fn write(text: String) -> Self {
Message::Write(text)
}
// 实例方法处理消息
fn process(&self) {
match self {
Message::Quit => println!("退出程序"),
Message::Move { x, y } => println!("移动到位置: ({}, {})", x, y),
Message::Write(text) => println!("写入消息: {}", text),
}
}
// 检查变体类型
fn is_quit(&self) -> bool {
matches!(self, Message::Quit)
}
}
在process方法中,直接对self进行模式匹配,从而对不同变体做出不同的行为。
对于联合体,如果涉及到对某个具体的变体操作,需要把整个方法声明为unsafe:
#[repr(C)]
union IntOrFloat {
i: i32,
f: f32,
}
impl IntOrFloat {
// 创建整数变体
fn from_int(i: i32) -> Self {
IntOrFloat { i }
}
// 创建浮点数变体
fn from_float(f: f32) -> Self {
IntOrFloat { f }
}
unsafe fn as_int(&self) -> i32 {
self.i
}
unsafe fn as_float(&self) -> f32 {
self.f
}
}
以上代码中,as_int和as_float将变体中的数据提取出来,但是Rust本身不保证self存储的是该类型有效的值,所以要声明unsafe。
这一个小节其实并没有非常重要的知识点,只是在强调impl不是struct专属的特性,并展示了其它情况下一些常见的注意点。
self
前面提到,在impl的方法中,第一个参数可以为self,实际上self还有多种传参的形式,它们涉及到所有权的变化。
&self不可变引用
impl Person {
fn show_age(&self) {
println!("Age: {}", self.age);
}
}
当以&self 接收时,相当于self: &Self按照不可变引用形式接收参数。
此时内部的self会拿到一个借用,但是不占据所有权。这种情况下你不能在方法内部修改self的值,但是可以对它进行读取。
比如此处读取self.age并输出。
&mut self可变引用
impl Person {
fn have_birthday(&mut self) {
self.age += 1;
println!("过生日啦,现在{}岁!", self.age);
}
}
当以&mut self 接收时,相当于self: &mut Self按照可变引用形式接收参数。
此时内部的self会拿到一个可变借用,但是不占据所有权。这种情况下你可以在方法内部修改self的值,说明这个方法会对数据进行某些改动。
上述 have_birthday 方法,过完生日后,年龄就要加一岁,因此需要&mut self。
self / mut self获取所有权
self和mut self形式的传参,可以按照不可变或可变形式直接拿到外部Self实例的所有权。
impl Person {
fn into_name(self) -> String {
self.name
}
}
当以self 接收时,相当于self: Self,此时如果触发移动,则外部对象会失效,如果是拷贝,则会产生两份副本。这个细节会在后面所有权章节深入讲解,你可以提前认为,这里有可能产生两种行为模式,它和某些trait有关。
如果触发的是移动的话,这里有两种常见情况会使用这种传参模式:
- 链式调用
链式调用每个方法都返回self,这样就可以连续调用多个方法:
struct Text {
content: String,
is_bold: bool,
is_italic: bool,
}
impl Text {
fn new(content: String) -> Self {
Text {
content,
is_bold: false,
is_italic: false,
}
}
// 每个方法都消费self并返回新的self
fn bold(mut self) -> Self {
self.is_bold = true;
self
}
fn italic(mut self) -> Self {
self.is_italic = true;
self
}
fn show(&self) {
let bold = if self.is_bold { "粗体" } else { "正常" };
let italic = if self.is_italic { "斜体" } else { "正常" };
println!("内容: {} [{}, {}]", self.content, bold, italic);
}
}
以上代码中,定义了一个text结构体,表示一段文本。并可以通过bold和italic设置它的粗细和斜体。
如果按照一般的模式,你可能会这么写代码:
let mut t = Text::new("hello world!");
t.bold();
t.italic();
t.show();
你就需要反复使用t.这个前缀。
而这种返回Self的形式,可以直接链式调用:
Text::new("hello world!").bold().italic().show();
两段代码功能是一样的,但是后者很明显清爽了很多,而且不用维护中间变量t。
- 方法结束后销毁
当方法执行完后对象就不再需要时,使用self可以明确表示消费该对象:
struct Ticket {
singer: String,
user: String,
}
impl Ticket {
fn new(singer: String, user: String) -> Self {
Ticket {
singer,
user,
}
}
// 使用门票,消费后门票作废
fn use_ticket(self) {
println!("{} 已使用 {} 的门票", self.user, self.singer)
}
// 查看门票信息
fn read_singer(&self) {
println!("这是 {} 演唱会的门票", self.singer)
}
fn read_user(&self) {
println!("使用者为: {}", self.user)
}
}
以上是一个表示门票的类,它存储了歌手以及购票者的信息,可以通过read_xxx方法读取对应的信息。
let ticket = Ticket::new("邓紫棋".to_string(), "张三".to_string());
ticket.use_ticket(); // 消费ticket
例如以上代码表示张三买了一张邓紫棋演唱会的门票,当他检票的时候,调用use_ticket方法,那么此时外部的ticket就失去所有权,无法再使用了,可以理解为这张票在验票完毕后,就无法再使用了,use_ticket是最后一个调用的方法。
- 智能指针
self还允许传入指向Self类型的智能指针。
例如:
struct Data {
value: i32,
}
impl Data {
fn new(value: i32) -> Self {
Data { value }
}
fn process_in_heap(self: Box<Self>) -> i32 {
println!("处理堆上的数据: {}", self.value);
self.value * 2
}
fn boxed(self) -> Box<Self> {
Box::new(self)
}
}
这里就是简单的把i32进行了一个包装,不关注这个结构体实现了什么功能,重点在于process_in_heap的第一个参数,接受了一个 self: Box<Self>,这种行为是允许的。
但是使用智能指针不存在简化的写法,例如self: &Self可以简化为&self,self: Box<Self>没有任何简化形式。除了Box其它智能指针也是允许的。
总的来说self就允许四种情况:
&self不可变引用&mut self可变引用self / mut self获得所有权- 指向
Self的智能指针
其他的情况一律不允许,比如什么self: i32。
关联常量
关联常量是定义在impl块中的常量值,它们与类型本身相关联,而不是与具体的实例相关联。
关联常量使用const进行定义:
struct Circle {
radius: f64,
}
impl Circle {
const PI: f64 = 3.14;
// 在方法中使用关联常量
fn area(&self) -> f64 {
Self::PI * self.radius * self.radius
}
fn circumference(&self) -> f64 {
2.0 * Self::PI * self.radius
}
fn get_pi() -> f64 {
Self::PI
}
}
在以上代码中,PI就是一个关联常量,表示圆周率。
因为圆周率是一个固定的值,不会发生改变,所有的Circle实例去访问圆周率,都可以直接访问一个常量。同一类型的所有实例,访问到关联常量都是同一份数据,关联常量在内存中只会存储一份。
另外的,在关联函数中也可以访问关联常量。比如上述的get_pi,它的第一个参数不是self,但是方法内依然可以访问Self::PI。在impl的外部,也可以通过Self::PI直接拿到关联常量。
关联常量其实相当于其它面向对象语言中的静态成员。
多范式
如果仔细看会发现一些命名上的逻辑,面向对象中的静态方法和静态成员在Rust中分别叫做关联函数和关联常量。
此处的关联一词,一方面是有意避开了面向对象风格的命名,另一方面,它表示这个函数或者常量,只是和当前的类型有关联,比如PI这个常量与圆Circle强相关,涉及到面积体积之类的计算。
实际上,Rust是一个多范式语言,它同时吸收了面向对象,函数式编程等多种思想,并且把它们做合适的裁剪,比如Rust没有所谓的类型继承,甚至没有类这个概念,而是组合优先。
本博客前文多次提及面向对象,就是因为这些思想大多来自面向对象,但这里需要强调,Rust是一个多范式语言,而不是一个纯面向对象语言。
重要枚举
Rust 标准库内置了两个极其重要的枚举:Option<T> 和 Result<T, E>。它们是 Rust 类型系统中“错误处理”和“空值表示”的基础,几乎无处不在。
Option< T >
Option<T> 用来表达“一个值可能存在,也可能不存在”。定义如下:
enum Option<T> {
Some(T),
None,
}
此处的<T>表示一个泛型,即它可以承载任意类型的参数。
它的第一个变体为Some,第二个变体为None,前者表示值存在,后者表示值不存在。
假设有一个i32,你如何表示一个数字不存在?有人说使用0来表示这个数字不存在,但是0也算一种取值。
这就是Option的作用,可以表示不存在这一种状态。
首先,Option可以代替其他语言中的null。
let maybe_number: Option<i32> = Some(5);
let absent: Option<i32> = None;
在别的语言用 null 表示“没有值”,Rust 用 None 来表达。这消除了空指针的风险。
比如说你拿到一个 Option<i32> 后,可以使用match进行匹配:
let maybe_number: Option<i32> = Some(5);
match maybe_number {
Some(number) => println!("number: {}", number),
None => println!("number is disappear"),
}
由于match穷尽性的要求,Rust要求你必须处理None,从而减少不安全的行为。
常用方法
在前面,我们已经通过 match 模式匹配来使用 Option。不过,Option 自带一些非常实用的方法,可以让常见操作更简洁。
is_some/is_none
判断当前是否是 Some 或 None。
let x = Some(10);
assert!(x.is_some()); // x 确实是 Some
assert!(!x.is_none()); // 所以它不是 None
这两个方法常用于快速分支判断。
unwrap_or
在有值时取出内容,否则给一个默认值。
let a = Some(5);
let b: Option<i32> = None;
let v1 = a.unwrap_or(0); // 得到 5
let v2 = b.unwrap_or(0); // 没有值,用默认 0
println!("v1={}, v2={}", v1, v2);
map
对 Some 中的值做变换,如果是 None 就保持不变。
fn double(n: i32) -> i32 {
n * 2
}
let y = Some(7);
let doubled = y.map(double); // Some(14)
println!("doubled={:?}", doubled);
此处的y.map(double),意思是如果y里面是Some,那么把Some里面的T作为参数传给 double,如果是None就啥也不干。
and_then
常用于链式调用:
- 如果是
Some,就把里面的T交给一个返回Option的函数处理 - 如果是
None,就直接传递None
fn to_even(n: i32) -> Option<i32> {
if n % 2 == 0 {
Some(n)
} else {
None
}
}
let z = Some(10);
let res1 = z.and_then(to_even); // Some(10),因为10是偶数
println!("res1={:?}", res1);
let z2 = Some(11);
let res2 = z2.and_then(to_even); // None,因为11不是偶数
println!("res2={:?}", res2);
Result< T, E >
Result<T, E> 用来表达一次运算可能成功,也可能失败。定义如下:
enum Result<T, E> {
Ok(T),
Err(E),
}
它有两个变体:
Ok(T)表示操作成功,携带一个结果值T。Err(E)表示操作失败,携带一个错误信息E。
这种设计思想可以让错误处理显式、安全,而不是像某些语言那样抛异常(exception),读代码时一眼就能看出哪里会失败。
比如写一个除法函数,如果除数是 0,显然要报错:
fn divide(a: i32, b: i32) -> Result<i32, String> {
if b == 0 {
Err("除数不能为0".to_string())
} else {
Ok(a / b)
}
}
调用的时候可以用 match 进行匹配:
match divide(10, 2) {
Ok(v) => println!("结果是 {}", v),
Err(e) => println!("错误: {}", e),
}
这就是最基本的 Result 使用方式。
常用方法
和 Option 一样,Result 也内置了许多常见操作方法,让你不必总是写 match。
is_ok/is_err
快速判断结果是成功还是错误。
let r1: Result<i32, &str> = Ok(42);
let r2: Result<i32, &str> = Err("出错啦");
assert!(r1.is_ok());
assert!(r2.is_err());
unwrap_or
成功时取出值,失败时给一个默认值。
let r1: Result<i32, &str> = Ok(5);
let r2: Result<i32, &str> = Err("失败了");
let v1 = r1.unwrap_or(0); // 得到 5
let v2 = r2.unwrap_or(0); // 出错时取默认值 0
println!("v1={}, v2={}", v1, v2);
map
如果是成功,就对里面的 Ok(T) 做变换;如果是错误,则保持 Err(E) 不变。
fn double(n: i32) -> i32 {
n * 2
}
let r: Result<i32, &str> = Ok(7);
let doubled = r.map(double); // Ok(14)
println!("doubled={:?}", doubled);
let e: Result<i32, &str> = Err("错误");
let still_err = e.map(double); // Err("错误")
println!("still_err={:?}", still_err);
and_then
常用于链式调用:
- 如果是
Ok(T),就把T传递给一个 返回Result的函数 继续处理。 - 如果是
Err(E),就直接返回Err。
fn check_even(n: i32) -> Result<i32, String> {
if n % 2 == 0 {
Ok(n)
} else {
Err("不是偶数".to_string())
}
}
let r1: Result<i32, &str> = Ok(10);
let res1 = r1.and_then(|n| check_even(n));
println!("res1={:?}", res1); // Ok(10)
let r2: Result<i32, &str> = Ok(11);
let res2 = r2.and_then(|n| check_even(n));
println!("res2={:?}", res2); // Err("不是偶数")
unwrap_or_else
失败时通过一个函数来生成默认值,比 unwrap_or 更灵活。
fn default_value(err: &str) -> i32 {
println!("出错: {}", err);
-1
}
let r: Result<i32, &str> = Err("网络故障");
let v = r.unwrap_or_else(default_value); // 调用 default_value,得到 -1
println!("v={}", v);
空位优化
我之前在其它博客介绍过空位优化,
当枚举中存在无数据的变体,且另一个变体存在无效的值,那么枚举中会省略掉判别值,以减少内存
此处的Result 和Option都是有可能触发空位优化的。
由于Option本身是一个枚举,他需要额外带一个判别值:
println!("i32 大小: {} 字节", size_of::<i32>());
println!("Option<i32> 大小: {} 字节", size_of::<Option<i32>>());
代码输出i32为4 byte,而 Option<i32> 是8 byte,这就是判别值占据了内存,以及内存对齐导致的。
但是None这个变体本身不携带数据,只要T是一个存在无效值的变体,就可以触发空位优化,比如T是一个&i32指针。
println!("&i32 大小: {} 字节", size_of::<&i32>());
println!("Option<&i32> 大小: {} 字节", size_of::<Option<&i32>>());
在64位架构下,&i32和Option<&i32>大小都是8 byte,因为触发了空位优化。
对于Result来说,它有两个变体T和E,只要T是一个有无效值的类型,E是零大小类型,也可以触发空位优化。比如Result<&i32, ()>,这里就不再深入讲解了。
新一代开源开发者平台 GitCode,通过集成代码托管服务、代码仓库以及可信赖的开源组件库,让开发者可以在云端进行代码托管和开发。旨在为数千万中国开发者提供一个无缝且高效的云端环境,以支持学习、使用和贡献开源项目。
更多推荐


所有评论(0)