高级类型特性

在现代的面向对象语言中,一个类中往往包含属性方法Rust吸收了面向对象的思想,允许用户以类似于面向对象的思路去编写程序。

impl 块

Rust中,假设把一个struct看成一个类,属性一般放在结构体中,而方法就放在它的impltrait中。本文主要聚焦于 impltrait 是另一个强大且重要的特性,我们将在后续章节单独讨论。

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结构体,内部含nameage两个字段。

new是非常常见的类方法,它一般用于传入参数,返回该类型的对象。这个函数的第一个参数不是self,调用方式为:

let p = Person::new("zhangsan", 18);

需要通过类名::方法的形式进行调用。这个方法的返回值是Self,在impl内部,Self特指类型本身,此处等于Person

这种第一个参数不是self的方法,称为关联函数,在一些面向对象语言中也叫做静态方法。

对于后续两个方法say_hellois_adult,它们的第一个参数是&self。这种写法相当于self: &Self,注意区分首字母大小写,首字母小写的是参数,大写的是类型。

对于这种方法,必须通过.func()的形式调用,例如:

p.say_hello();
p.is_adult();

通过.操作符,会把.前面的变量作为第一个参数self传进方法中,因此以上代码又相当于:

Person::say_hello(&p);
Person::is_adult(&p);

值得注意的是,这种第一个参数为self的方法,内部可以通过self.xxx来访问一个对象内部的属性,从而做出逻辑处理。例如此处通过self.ageself.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可以用于structenumunion以及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_intas_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 获取所有权

selfmut self形式的传参,可以按照不可变或可变形式直接拿到外部Self实例的所有权。

impl Person {
    fn into_name(self) -> String {
        self.name
    }
}

当以self 接收时,相当于self: Self,此时如果触发移动,则外部对象会失效,如果是拷贝,则会产生两份副本。这个细节会在后面所有权章节深入讲解,你可以提前认为,这里有可能产生两种行为模式,它和某些trait有关。

如果触发的是移动的话,这里有两种常见情况会使用这种传参模式:

  1. 链式调用

链式调用每个方法都返回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结构体,表示一段文本。并可以通过bolditalic设置它的粗细和斜体。

如果按照一般的模式,你可能会这么写代码:

let mut t = Text::new("hello world!");
t.bold();
t.italic();
t.show();

你就需要反复使用t.这个前缀。

而这种返回Self的形式,可以直接链式调用:

Text::new("hello world!").bold().italic().show();

两段代码功能是一样的,但是后者很明显清爽了很多,而且不用维护中间变量t

  1. 方法结束后销毁

当方法执行完后对象就不再需要时,使用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可以简化为&selfself: 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 表示“没有值”,RustNone 来表达。这消除了空指针的风险。

比如说你拿到一个 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

判断当前是否是 SomeNone

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

常用于链式调用:

  1. 如果是 Some,就把里面的T交给一个返回 Option 的函数处理
  2. 如果是 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

常用于链式调用:

  1. 如果是 Ok(T),就把 T 传递给一个 返回 Result 的函数 继续处理。
  2. 如果是 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);

空位优化

我之前在其它博客介绍过空位优化,

当枚举中存在无数据的变体,且另一个变体存在无效的值,那么枚举中会省略掉判别值,以减少内存

此处的ResultOption都是有可能触发空位优化的。

由于Option本身是一个枚举,他需要额外带一个判别值:

println!("i32 大小: {} 字节", size_of::<i32>());
println!("Option<i32> 大小: {} 字节", size_of::<Option<i32>>());

代码输出i324 byte,而 Option<i32>8 byte,这就是判别值占据了内存,以及内存对齐导致的。

但是None这个变体本身不携带数据,只要T是一个存在无效值的变体,就可以触发空位优化,比如T是一个&i32指针。

println!("&i32 大小: {} 字节", size_of::<&i32>());
println!("Option<&i32> 大小: {} 字节", size_of::<Option<&i32>>());

64位架构下,&i32Option<&i32>大小都是8 byte,因为触发了空位优化。

对于Result来说,它有两个变体TE,只要T是一个有无效值的类型,E是零大小类型,也可以触发空位优化。比如Result<&i32, ()>,这里就不再深入讲解了。


Logo

新一代开源开发者平台 GitCode,通过集成代码托管服务、代码仓库以及可信赖的开源组件库,让开发者可以在云端进行代码托管和开发。旨在为数千万中国开发者提供一个无缝且高效的云端环境,以支持学习、使用和贡献开源项目。

更多推荐