初始 Tauri 篇二之面向对象

前言

本章大部分内容与 Rust 相关,由于作者也是锵锵入门 Rust,基本都是编译器在教我编程,难免有所疏漏或存在错误,欢迎指正!

相关代码可以直接在线运行测试,访问 Rust Playground 即可。

面向对象的 Rust

在继续进行后面的说明前让我们首先来了解一下 rust 的面向对象。

JS 中的对象

以 JS 为例,我们可以很方便地像下面这样创建对象, 甚至你初始化的时候漏掉参数直接不写也不一定报错。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 在 js 里我们可以很方便构造一个对象
let person = {
name: "jack",
age: 22,
greet: function() {
console.log(`${this.name} greetings!`)
}
} // 字面量构建对象
person.greet(); // output -> "Jack greetings!"

// class 模式构建对象
class Person {
constructor(data) {
this.name = data.name;
this.age = data.age;
}

greet() {
console.log(`${this.name} greetings!`);
}
}

let Jack = new Person({ name: "Jack", age: 22 });
Jack.greet(); // output -> "Jack greetings!"

let John = new Person({ name: "John" });
John.greet(); // output -> "John greetings!"

Rust 构建对象

但是 rust 本身并没有实际地提供类似 Class 这样的类型来实现对象,而是通过结构体和枚举的方法实现了类似对象的功能,让我们将上面的 JS 对象转成 rust 对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// pub 意味着是公开的,可以被直接调用
pub struct Person{
// 和大部分后端语言类似,rust 要求结构体所有变量都必须明确类型
pub name: String,
pub age: u8
}

// 是的你没看错,并没有类将变量和方法包含起来
impl Person {
pub fn greet(&self) {
println!("{} greetings!", self.name);
}

pub fn rename(&mut self, new_name: String) {
self.name = new_name;
}
}


fn main() {
let mut jack = Person { name: "Jack".to_string(), age: 22 };
jack.greet(); // output -> "Jack greetings!"
jack.rename("Mike".to_string());
jack.greet(); // output -> "Mike greetings!"
}

相信看了上面的代码,完全不了解 rust 的小伙伴肯定一脸懵。

impl 在这里是怎么用,枚举不应该是 enum?

let 后面怎么还接了个 mut,mut 又是干什么的?

name 已经是字符串了,为什么要再 to_string() ?

那当然,与之对应的展开来就是 rust 的语言特性了,它们分别是:

方法 Method 与特征 Trait

所有权与借用

生命周期

数据类型及泛型

方法 Method 与特征 Trait

方法与函数的区别相信熟悉面向对象编程的小伙伴肯定了如指掌, impl 就相当于定义了一个函数块来单独存放对象的方法,当然这也意味着 impl 必然是和 struct 成对出现的。

方法有了,那特征又是什么呢?

别急,现在我们假设这样一个场景,有 Teacher 和 Student 两个对象,它们有着部分相同的属性和方法。

为了更进一步的抽象,我们很容易想到将公有的属性方法抽离出来,作 Person 类,然后 Teacher 和 Student 分别继承 Person 类,再在此基础上定义自身的属性与方法。

但是 Rust 并没有继承机制,而是使用了 trait 特征来实现类似功能,至于为什么这么做可以看看 继承,作为类型系统与代码共享

现在让我们回到刚刚举的例子,看看应该如何实现它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
pub struct Student {
pub name: String,
pub age: u8,
pub grade: u8,
}

pub struct Teacher {
pub name: String,
pub age: u8,
pub subject: String
}

pub trait Actions {
fn get_name(&self) -> String;

fn greet(&self) {
println!("{} greetings!", self.get_name());
}

fn bye(&self) {
println!("{} say bye~", self.get_name());
}
}

impl Actions for Student {
fn get_name(&self) -> String {
self.name.clone()
}
}

impl Actions for Teacher {
fn get_name(&self) -> String {
self.name.clone()
}

fn bye(&self) {
println!("Byebye from {}", self.get_name());
}
}

fn main() {
let jack = Student{ name: "Jack".to_string(), age: 18, grade: 13};
let ada = Teacher{ name: "Ada".to_string(), age: 32, subject: "math".to_string()};

jack.greet(); // output -> Jack greetings!
ada.greet(); // output -> Ada greetings!

jack.bye(); // output -> Jack say bye~
ada.bye(); // output -> Byebye from Ada
}

很好,程序能正常跑,说明我们的对象都有正确定义和初始化,现在我们就可以根据上述举例作以下简单的总结:

  1. 特征 Trait 可以应用于多个结构体,提高了灵活度。
  2. 特征 Trait 虽然有 self,但是 self 只能指向 trait 内函数,即 self.get_name() 是不能用 self.name 替换的。
  3. 特征 Trait 中的方法可以被覆盖修改,方便实现更个性化的操作。

当然 方法与特征 还有很多内容,本文并不会一一列举,详见:

Rust 语言圣经之方法 Method

Rust 语言圣经之特征 Trait

Rust 程序设计语言之面向对象特性

所有权与借用

现在让我们假设有两个学生,他们姓名一致,年龄不同,尝试初始化这样子的两个结构体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#[derive(Debug)]
struct Student {
pub name: String,
pub age: u8
}

impl Student {
fn print_info(&self) {
println!("name: {}, age: {}", self.name, self.age);
}
}

fn main() {
let normal_name = "Jake".to_string();

let student1: Student = Student { name: normal_name, age: 16};
let student2: Student = Student { name: normal_name, age: 18};

student1.print_info();
student2.print_info();
}

在 VSCode 环境下配合 rust-analyser 很容易发现 student2 会被标红且生成如下提示,即 normal_name 这个变量的所有权在 student1 初始化后是被 student1 所拥有的,student2 不可以使用它。

1
use of moved value: `normal_name` value used here after move

我们可以通过修改变量类型为引用类型使其正常运行。

1
2
3
4
5
6
7
let normal_name = "Jake"; // 此时 normal_name 是 &str 类型,即引用字符类型
let student1: Student = Student { name: normal_name.to_string(), age: 16};
let student2: Student = Student { name: normal_name.to_string(), age: 18};

// output
name: Jake, age: 16
name: Jake, age: 18

这看上去似乎很麻烦,尤其是结构体初始化过程中值很容易出现多处使用的情况,rust 又为什么非要用这么麻烦的机制来限制变量?

简单来说就是保证了内存安全。

熟悉 c/c++ ,尤其是写嵌入式代码的,对指针应该是又爱又恨:爱它的方便,直接访问内存地址甚至可以实现很多神奇操作,比如 bootloader;恨它太灵活,指针的灵活使用很容易造成悬空引用或内存泄漏等问题。

而 rust 则通过所有权机制在编译阶段就对内存安全进行检查,若发现无法保证内存安全就不能通过编译,大大降低了运行中可能发生的内存不安全相关问题。

当然我们这里也仅仅是对所有权与借用进行简单的描述,有关的更多详细介绍可以看看这篇文章 Rust’s Ownership and Borrowing Enforce Memory Safety

生命周期

上面的例子里结构体能用是是能用了,但是用起来实在不太美观,反正 “Jake” 是 &str 类型,何不直接定义 name 为 &str 类型?好想法!让我们来实践一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#[derive(Debug)]
struct Student {
pub name: &str,
pub age: u8
}

impl Student {
fn print_info(&self) {
println!("name: {}, age: {}", self.name, self.age);
}
}
fn main() {
let normal_name = "Jake";

let student1: Student = Student { name: normal_name, age: 16};
let student2: Student = Student { name: normal_name, age: 18};

student1.print_info();
student2.print_info();
}
1
2
3
4
5
6
7
8
9
10
11
12
// output
error[E0106]: missing lifetime specifier
--> src/main.rs:3:15
|
3 | pub name: &str,
| ^ expected named lifetime parameter
|
help: consider introducing a named lifetime parameter
|
2 ~ struct Student<'a> {
3 ~ pub name: &'a str,
|

发现并不可以,提示我们结构体中的引用类型需要的是被生命周期修饰的参数,根据编译器提示进行修改,最终结构体长这样了,此时即可顺利通过编译,正常运行输出。

1
2
3
4
5
6
7
8
9
10
11
#[derive(Debug)]
struct Student <'a>{
pub name: &'a str,
pub age: u8
}

impl<'a> Student <'a>{
fn print_info(&self) {
println!("name: {}, age: {}", self.name, self.age);
}
}

可是这个 ‘a 是个什么东西?这样的结构体和之前的功能上有什么区别,哪里又体现了生命周期?

简单来说 ‘a 就是个注解,告诉编译器这里是个带生命周期的参数,整个结构体使用相同的 ‘a 也意味着整个结构体的生命周期都是 ‘a 代表的生命周期。

而这样的结构体和之前的功能上的区别是:没什么区别,也没怎么体现生命周期,但当你需要一个不需要进行数据所有权转移和拷贝的结构体,使得结构体中的原始数据可以被方便地在外界被访问,带生命周期的结构体就是个不二选择。

现在让我们来看看一个更通用的例子,两数比较找最大。

1
2
3
4
5
6
7
8
9
10
11
fn max_num(x: &u8, y: &u8) -> &u8 {
let res = if x > y {x} else {y}; // 是的,这就是 rust 的三元,不是我不会三元!
res
}

fn main() {
let x = 1;
let y = 2;
let max = max_num(&x, &y);
println!("max number: {}", max);
}

看上去似乎没什么问题,但编译器告诉我们,你错了,怎么能没有生命周期注解呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
error[E0106]: missing lifetime specifier
--> src/main.rs:1:31
|
1 | fn max_num(x: &u8, y: &u8) -> &u8 {
| --- --- ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `x` or `y`
help: consider introducing a named lifetime parameter
|
1 | fn max_num<'a>(x: &'a u8, y: &'a u8) -> &'a u8 {
| ++++ ++ ++ ++

For more information about this error, try `rustc --explain E0106`.

看到这里想必多多少少有点懵,我就引用下,借点东西,怎么还非要上生命周期了。

其实换个说法粗浅点讲可能比较容易理解:指针是可能在被使用前被提前释放的,c++ 信任你,不做任何限制,但rust 不这样,必须得先解决了可能存在的安全隐患才能允许编译。

当然粗浅理解就是粗浅理解,想深入理解的话可以看看这篇文章 细说 rust 生命周期参数

根据经验来说,一般作者是很不希望碰生命周期的,因为它带来的麻烦将远大于可能带来的便利。

数据类型及泛型

这部分就属于孰能生巧了,在此不多赘述,仅介绍大致、常用的一些类型,更多相关知识可见 数据类型泛型数据类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
name: String // 可变字符串类型 
family_name: str // 不可变字符串类型
gender: char // 字符类型

age: u8 // 无符号整型 有符号为 i8
cash: f32 // 浮点类型
is_single: bool // 布尔类型

location: (str, u8, bool) // 元组类型,长度在声明后不可修改
location2: [] //数组类型,长度同样固定,但每个元素类型必须相同
location3: Vec<bool> // Vector 类型,长度不固定,但每个元素类型必须相同

// 简单定义泛型结构体
struct Point<T> {
x: T,
y: T
}

结语

当我们了解了上述内容,我们就可以开始接触 Tauri 相关的知识了,在下一章我们将要学习到的是有关前后端通信的内容,其中就会涉及到本章所提及的许多内容,希望到时候大家都能觉得本章打下的基础都十分有用。


初始 Tauri 篇二之面向对象
http://example.com/2022/09/07/初始-Tauri-篇二之面向对象/
作者
Steins Gu
发布于
2022年9月7日
许可协议