Skip to main content

Rust 基础入门 - 第二章

理解 所有权 借用 ,是 Rust 学习的核心。

学习复合类型:元组、字符串、结构体

所有权

在一门编程语言的设计中,如何申请内存,如何释放内存,是重中之重,也是难点之一。在 CS 发展的过程中,出现了三种流派:

  • 垃圾回收机制 GC,如 Java、Python 等,在程序运行时不断寻找不使用的内存
  • 手动管理分配释放 ,如 C++,通过函数调用的方式来申请和释放内存
  • 所有权 ,如 Rust,通过编译器根据一系列规则检查

第三种,这种检查只会在编译时进行,不会影响程序的运行性能。

栈 (Stack) 和堆(Heap)

栈和堆是核心的数据结构,在之前学习 Python 时,并没有深入了解。但是对于 Rust 这种系统编程语言,值是位于栈上还是堆上,会影响程序的行为和性能。

栈和堆的核心目标是为程序运行时提供可供使用的内存空间。

栈:按照顺序存储并以相反顺序取出,先进后出,后进先出,好比一叠盘子,增加新的盘子,放在最上面,取出盘子,从最上面取出。

增加数据称为 入栈 ,取出数据称为 出栈

那么根据这种实现方式,栈中的数据需要占用固定大小的内存空间,因为如果数据大小不确定,无法确定数据的位置,也就无法取出数据。

堆:对于大小未知或者可能变化的数据,就可以存储在堆上。

向堆放入数据时,需要先申请一块内存空间,操作系统找到后,将数据放入内存空间,并返回一个表示这个位置地址的 指针 ,这个过程称为 分配

然后,返回的指针会被入 ,因为指针的大小是已知并且固定的,在后续使用中,由在栈上的指针来找到堆上的数据。

性能区别

写入

栈:入栈时,放入栈顶即可 堆:首先操作系统要找空间,并且记录已使用

读取

栈数据可以存在 CPU 的高速缓存中,读取速度快;而堆数据只能存储在内存中,访问速度慢,并且必须先访问栈,再通过指针访问内存。

所有权和栈堆

当代码中调用一个函数时,函数的参数和局部变量会被压入栈,在函数执行完毕后,会被出栈。

由于堆上的数据没有顺序,所以需要跟踪内存空间的分配和释放,否则会造成内存泄漏。

在 Rust 中,明白堆栈的原理,有助于我们理解所有权的工作原理

所有权原则

  1. 每个值都有一个被称为其所有者的变量。
  2. 值在任意时刻有且只有一个所有者。
  3. 当所有者(变量)离开作用域,这个值将被丢弃。

变量作用域

和其他语言类似,作用域是一个变量在程序中有效的范围。

String 类型

这里使用 String 类型来说明所有权的工作原理。

String 并不是字符串类型。在上一章介绍的字符串类型中,字符串字面值是不可变的,被硬编码到程序中,但是并不是每一个字符串的值都是已知的,所以需要一个可变的类型来存储这些值。例如:

let s = "hello";

为此,Rust 标准库提供了 String 类型,可以如下创建:

let mut s = String::from("hello");

变量绑定与数据交互

在上一章中,学习了变量的绑定。

转移所有权

在这段代码中:

let x = 5;
let y = x;

这里首先将 i32 整数类型的值 5 绑定到变量 x 上,然后拷贝 x 的值到 y 上。因为 i32 是 Rust 基本数据类型,大小是固定的,这两个值都是通过栈来存储的,所以拷贝的速度很快。

但是下面这个 String 类型的例子就不一样了:

let s1 = String::from("hello");
let s2 = s1;

刚才提到,i32 是基本数据类型,大小是固定的,但是 String 类型的值是在堆上分配的,大小是不固定的,因此不会使用自动拷贝来赋值给 s2

实际上,String 类型是一个复杂类型,包含了 存储在栈上的堆指针 字符串长度 字符串容量 ,其中堆指针指向堆上的字符串数据,容量是指堆上分配的内存大小,长度是指已使用的字符串长度。

在这个例子中的第一行,s1 是是一个指向堆上数据的指针,这个指针指向的数据是 hello 字符串

第二行 let s2 = s1,可以分成两种情况考虑:

  • 同时拷贝指针和数据到 s2
  • 只拷贝指针 s1 本身,因为这个很快。但是根据我们上文提到的 [所有权原则](# 所有权原则), 一个值只有一个所有者 ,而拷贝指针的行为使得这个字符串数据有了两个所有者,s1s2

这个 [所有权原则](# 所有权原则) 这么麻烦,先不管它,来看看会怎么样:

在这个例子中,s1s2 离开作用域后,都会释放相同的内存,这样就会出现 双重释放(double free) 的问题,导致潜在的内存安全问题。

因此,[所有权原则](# 所有权原则) 规定,当 s1 赋予 s2 的时候,s1 就不再有效了。如果只会再使用该变量,就直接报错咯:

fn main() {
let s1 = String::from("hello");
let s2 = s1;

println!("{}, world!", s1);
}
error[E0382]: borrow of moved value: `s1`
--> src/main.rs:5:28
|
2 | let s1 = String::from("hello");
| -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 | let s2 = s1;
| -- value moved here
4 |
5 | println!("{}, world!", s1);
| ^^ value borrowed here after move

其实这部分的概念,和 Python 或其他语言中的 [深浅拷贝](../../Python/python-basics/Memory-magic# 浅拷贝与深拷贝 -copy) 有点类似: 只拷贝指针,听起来很像浅拷贝,但是被浅拷贝的对象会失效,所以 Rust 中的这个行为叫做 move,用下面这张图可以说明:

rust-move

s1move 后就被释放了,也无法使用。

深拷贝

从上面两个分别拷贝 i32 类型和 String 类型的例子中,我们可以看到 Rust 不会自动进行深拷贝,但是如果我们需要拷贝堆上的数据,而不只是指针,可以使用 clone 方法:

fn main() {
let s1 = String::from("hello");
let s2 = s1.clone();

println!("s1 = {}, s2 = {}", s1, s2);
}

这样的话,这几行代码是可以正常编译运行并打印两个 hello 的。

caution

对于频繁使用的变量,需要谨慎使用 clone,可能会导致性能问题。

浅拷贝

对于整数类型,已经说过,在编译时大小已知,存储在栈上,拷贝的成本很低,所有没有深浅拷贝的区别。

在 Rust 中,有一个叫做 Copy 的特性,可以用于类似 i32 这样可以存在栈上的类型。即若一个类型拥有 Copy 特性,那么一个变量被赋值给另一个新变量后依然可以访问到。

函数传值与返回

将参数值传递给函数时,也会发生 move 所有权转移。如以下这个例子:

main.rs
fn main() {
let s = String::from("hello"); // s 进入作用域

takes_ownership(s); // s 的值移动到函数里 ...
// ... 所以到这里不再有效

let x = 5; // x 进入作用域

makes_copy(x); // x 移动函数里,
// 但 i32 是 Copy 的,所以在后面可继续使用 x

} // 这里, x 先移出了作用域,然后是 s。但因为 s 的值已被移走,
// 所以不会有特殊操作

fn takes_ownership(some_string: String) { // some_string 进入作用域
println!("{}", some_string);
} // 这里,some_string 移出作用域并调用 `drop` 方法。占用的内存被释放

fn makes_copy(some_integer: i32) { // some_integer 进入作用域
println!("{}", some_integer);
} // 这里,some_integer 移出作用域。不会有特殊操作

在这个例子中,若在第 5 行继续使用 s,则会报错,但在第 10 行继续使用 x,则相安无事。

虽然这样可以保证内存的安全性,但是总是把值击鼓传花很麻烦,所以 Rust 也提供了方法来获取某个变量的指针或引用。

引用

上文提到,Rust 中提供了获取变量指针引用的方法,一个称之为 借用 (borrowing) 的概念,即有借有还。

引用与解引用

常规引用是一个指针,指向对象存储的内存地址。指针可以被解引用,获得指向的值,如下例所示:

fn main() {
let x = 8; // 变量 x 绑定值 8
let y = &x; // 获得变量 x 的引用,并将其绑定到变量 y

assert_eq!(8, x); // 对
assert_eq!(8, y); // 不行,因为不能将引用与值进行比较
assert_eq!(8, *y); // 对,因为 *y 是 x 的值
}

其中第六行是不行的。

不可变引用

引用可以作为函数的参数传入函数,这样的好处就是可以不用转移变量的所有权,如下例:

fn main() {
let s1 = String::from("hello");

let len = calculate_length(&s1);

println!("The length of '{}' is {}.", s1, len);
}

fn calculate_length(s: &String) -> usize {
s.len()
}

其中第九行定义的函数 calculate_length 接受一个 &String 类型的参数,即一个指向 String 类型的引用。这样,函数内部就可以直接使用这个引用,而不用担心变量 s 的所有权会被转移。无需像上一个 [例子](# 函数传值与返回) 一样,先将所有权转移给函数,然后再传出所有权。

在第四行中,我们创建了一个指向 s1 的引用,但是并没有拥有这个值,所以离开作用域后,其指向的值不会被丢弃,依然可以在第六行中使用。

但是如果我们想对引用的值进行修改,是不行的:

fn main() {
let s = String::from("hello");

change(&s);
}

fn change(some_string: &String) {
some_string.push_str(", world"); // error
}

和变量一样,引用的值也是默认 不可变 的,但是我们有 mut 关键字!

可变引用

mut 整上:

fn main() {
let mut s = String::from("hello");

change(&mut s);
}

fn change(some_string: &mut String) {
some_string.push_str(", world");
}

我们分别在第二行,声明了 s 是可变类型;在第四行创建并传入了一个可变引用;在第七行,声明函数接受的参数为可变引用。

可变引同时用只能存在一个

可变引用看起来很爽,但是存在限制:

在同一作用域中,特定数据只能存在一个可变引用

fn main() {
let mut s = String::from("hello");

let r1 = &mut s;
let r2 = &mut s;

println!("{}, {}", r1, r2);
}

这段代码是会报错的,因为在 r1 消失之前,创建了第二个可变引用 r2,这是不被允许的。

这种限制可以使在编译期避免数据竞争,这可能会导致某些行为超出预期,尽管我现在还不知道怎么会导致的 😅。

可变引用和不可变引用不能同时存在

fn main() {
let mut s = String::from("hello");

let r1 = &s; // 创建第一个引用,不出错
let r2 = &s; // 创建第二个引用,允许,不出错
let r3 = &mut s; // 创建一个可变引用,不允许,出错

println!("{}, {}, and {}", r1, r2, r3);
}

这里也比较好理解,我都创建了两个引用 r1r2 了,然后又创建了一个可变引用 r3,那万一值变了,那 r1 r2 岂不是危险。 所有 Rust 编译是不允许这种情况的。

caution

需要注意的是,引用的作用域和变量的作用域 目前而言 是不同的,在早期版本的 Rust 中,这两者是一致的:

fn main() {
let mut s = String::from("hello");

let r1 = &s;
let r2 = &s;
println!("{} and {}", r1, r2);
// 新编译器中,r1,r2 作用域在这里结束,被使用后结束

let r3 = &mut s;
println!("{}", r3);
} // 老编译器中,r1、r2、r3 作用域在这里结束
// 新编译器中,r3 作用域在这里结束

在老版本的编译器中(Rust 1.31 前),将会报错,因为 r1r2 的作用域在第 11 行花括号 } 处结束,那么 r3 的可变引用就会触发 无法同时借用可变和不可变 的规则。

但是在新的编译器中,该代码将顺利通过,因为引用作用域的结束位置从花括号变成最后一次使用的位置(第 6 行),因此 r1 借用和 r2 借用在 println! 后,就结束了,此时 r3 可以顺利借用到可变引用。

对于这种编译器优化,Rust 称之为 Non-Lexical Lifetimes(NLL),专门用于找到某个引用在作用域 (}) 结束前就不再被使用的代码位置。

悬垂引用 (Dangling References)

当指向某个值的引用在其值被删除后,指针仍然存在,这就是悬垂引用。Rust 编译器保证了不会出现悬垂引用。试一下:

fn main() {
let reference_to_nothing = dangle(); // 报错咯,因为这里返回的引用,指向了一个不存在的值
}

fn dangle() -> &String {
let s = String::from("hello"); // 创建一个字符串 String

&s // 返回 s 的引用
} // s 离开作用域并被丢弃。其内存被释放。
// 引用指向的内存也被释放,这就是悬垂引用。

复合类型

已经学了 [基本类型](../rust-basics/rust-basic-2# 变量基本类型),复合类型是由基本类型组成的。

字符串

字符串在前面已经简单介绍过了,有如下操作和特性:

字符串切片

学 Python 的时候,就有过这个概念了,可以获取字符串中的部分连续元素。

在 Rust 中,切片是对 String 某一部分的引用:

fn main() {
let s = String::from("hello world");

let hello = &s[0..5];
let world = &s[6..11];
}

其中第 4、5 行,分别引用了 s 的部分内容,通过 [0..5][6..11] 来指定。这种语法用来创建 slice,方括号左边界闭区间,右边界开区间。

对于第 4 行来说,let world = &s[6..11]; 创建了一个指向 s 的第 7 个字节到第 11 个字节的切片(注意是 0-based),该切片的长度为 5 个字节:

slice

tip
  • 如果想从第一个元素开始切,左边界可以不写:
fn main() {
let s = String::from("hello");

let slice = &s[0..2];
let slice = &s[..2];
}
  • 如果想包含最后一个元素,右边界可以不写:
fn main() {
let s = String::from("hello");

let len = s.len();

let slice = &s[4..len];
let slice = &s[4..];
}
索引需落边界

需要格外注意的是,切片的索引一定要落在字符串的边界位置,也就是 UTF-8 编码的边界位置,比如中文汉字在 UTF-8 中占用 个字节,那么索引落在第 个位置上就会报错。

字符串类型

顾名思义,字符串是由 字符 组成的连续集合。

字符是 Rust 的基本类型,是 Unicode 编码类型,每个字符占用 4 个字节。但是字符串是 UTF-8 编码,字符串中的每个字符可能占用 1 ~ 4 个字节,是变化的,这样可以降低字符串所占用空间。

Rust 在语言级别,只有一种字符串类型:str,通常以引用方式出现:&str:

fn main() {
let s: &str = "Hello, world!";
}

其实就是一个字符串切片。

虽然语言级别上只有一个 str,但是标准库里还有很多其他类型,最常见的就是一直提到的 String,这两者的区别在于:

  • str 类型被编译成了固定大小的字符串,长度不可变,也无法被修改;
  • String 类型是可变的
为什么 String 可变,str 不可变?

就字符串字面值 str 来说,我们在编译时,该值就被直接硬编码进可执行文件中,快速且高效,这主要得益于字符串字面值的不可变性。

但是前提是值是不可变的,所以我们不可能把一个在编译时大小未知的文本都放进内存中(因为有的字符串是在程序运行得过程中动态生成的)。

对于 String 类型,为了支持一个可变、可增长的文本片段,需要在堆上分配一块在编译时未知大小的内存来存放内容,这些都是在程序运行时完成的:

  • 首先向操作系统请求内存来存放 String 对象
  • 在使用完成后,将内存释放,归还给操作系统 其中第一部分由 String::from 完成,它创建了一个全新的 String。

String 和 &str 的转换

&str 转 String

两种方式:

  1. to_string() 方法: let s = "hello".to_string();
  2. String::from() 方法: let s = String::from("hello");
String 转 &str

取引用就行:

fn main() {
let s = String::from("hello,world!");
say_hello(&s);
say_hello(&s[..]);
say_hello(s.as_str()); // as_str() 方法,就是 Extracts a string slice containing the entire String.

}

fn say_hello(s: &str) {
println!("{}",s);
}

字符串索引

在 Python 中,字符串的索引是一件再平常不过的事情了:

slice.py
a = 'Stranger Things'
a_slice = a[6:8]

若在 Rust 中,我们尝试对字符串进行索引:

fn main() {
let s1 = String::from("hello");
let h = s1[0];
}

这段代码编译是不会通过的,告诉我们 String 类型不支持索引,这么坑?为啥呢:

字符串内部表现

实际上,String 是一个 Vec<u8> 的封装,也就是说,String 是一个字节的集合,每个字节都是 u8 类型。

第一个例子:

let hello = String::from("Hola");

在这里,len 的值是 4 ,这意味着储存字符串 “Hola” 的 Vec 的长度是四个字节:这里每一个字母的 UTF-8 编码都占用一个字节。

但是世界上不止有英语,还有中文、俄文、藏话。这些语言的 UTF-8 编码所需要的字节数,是不一样的。

比如中文的“你好”,它的 UTF-8 编码是 E4 B8 AD E5 A5 BD,占用 6 个字节。那如果我对 String 索引,取第一个 " 你 " 的话,是不能这样写的:

let hello = String::from(" 你好 ");
let h = hello[0]; // 错错错

这样写,是无法返回我的预期的。所以为了避免返回意外的值并造成不能立刻发现的 bug,Rust 根本不会编译这些代码,并在开发过程中及早杜绝了误会的发生。这对我一个用惯了 Python 的人来说,真是小刀拉屁股,开了眼了。

切片

不过在之前说了,字符串 [切片](# 字符串切片) 是可以的,只不过要注意区间,一定要落在边界!

字符串操作

由于 String 是可变类型,所以可以有多种操作:

追加 (Push)

直接上代码就懂了:

fn main() {
let mut s = String::from("Hello "); // mut 关键字声明可变
s.push('r'); // push() 方法,追加单个字符
println!(" 追加字符 push() -> {}", s);

s.push_str("ust!"); // push_str() 方法,追加字符串
println!(" 追加字符串 push_str() -> {}", s);
}

那么,这两个追加的方法,都不会返回新的 String,而是直接在原来的 String 上进行修改。

插入 (Insert)

和追加唯一不同的地方就是需要第二个参数,来指定插入的位置。位置是 0-based,而且不能越界。

fn main() {
let mut s = String::from("Hello rust!");
s.insert(5, ',');
println!(" 插入字符 insert() -> {}", s);
s.insert_str(6, " I like");
println!(" 插入字符串 insert_str() -> {}", s);
}

替换 (Replace)

把字符串中的某个子串替换成另一个子串,有三种方法:

  1. replace()

适用于 String&str 类型。接受两个参数,第一个为被替换字符串,第二个为替换的字符串。

caution

该方法会替换所有匹配的字符串

返回一个 新的字符串 ,而不是操作原来的字符串。

fn main() {
let string_replace = String::from("I like rust. Learning rust is my favorite!");
let new_string_replace = string_replace.replace("rust", "RUST");
dbg!(new_string_replace); // "I like RUST. Learning RUST is my favorite!"
}
  1. replacen()

该方法多了一个字母 n,可以接受第三个参数,来指定替换的个数。同样返回一个新的字符串。

fn main() {
let string_replace = "I like rust. Learning rust is my favorite!";
let new_string_replacen = string_replace.replacen("rust", "RUST", 1);
dbg!(new_string_replacen); // I like RUST. Learning rust is my favorite!
}
  1. replace_range()

该方法仅适用于 String 类型,接受两个参数,第一个为被替换的范围,第二个为替换的字符串。

该方法直接操作原来字符串,不返回新字符串,所有被替换的字符串需要用 mut 关键字修饰。

fn main() {
let mut string_replace_range = String::from("I like rust!");
string_replace_range.replace_range(7..8, "R");
dbg!(string_replace_range); // I like Rust!
}

删除 (Delete)

与字符串删除相关有四个方法,都 仅适用 String 类型:

  1. pop()

直接操作 原来的字符串,删除最后一个字符,并返回被删除的字符。

尽管是直接操作原字符串,但是存在返回值,返回的是 Option 类型,如果字符串为空,则返回 None

fn main() {
let mut string_pop = String::from("rust pop 中文!");
let p1 = string_pop.pop();
let p2 = string_pop.pop();
dbg!(p1);
dbg!(p2);
dbg!(string_pop);
}

结果为:

p1 = Some(
'!',
)
p2 = Some(
' 文 ',
)
string_pop = "rust pop 中 "
  1. remove()

直接操作 原来的字符串,接受一个参数,指定删除位置,并返回被删除的字符。

caution

该方法是按照字节来处理字符串的,所以位置参数要落在合法的字符边界

fn main() {
let mut string_remove = String::from(" 测试 remove 方法 ");
println!(
"string_remove 占 {} 个字节 ",
std::mem::size_of_val(string_remove.as_str())
);
// 删除第一个汉字
string_remove.remove(0);
// 下面代码会发生错误
// string_remove.remove(1);
// 直接删除第二个汉字
// string_remove.remove(3);
dbg!(string_remove); // string_remove = " 试 remove 方法 "
}

这个例子中,这个字符串共占 18 个字节,4 个中文占 12 个,6 个字母占 6 个。所以第一个中文的合法边界是 0,第二个中文的合法边界是 3,如果落在 1 或者 2,就会报错。

  1. truncate()

直接操作 原来的字符串,接受一个参数,指定删除位置,删除从指定位置开始到字符串末尾的所有字符。

fn main() {
let mut string_truncate = String::from(" 测试 truncate");
string_truncate.truncate(3);
dbg!(string_truncate); // string_truncate = " 测 "
}
  1. clear()

直接操作 原来的字符串,删除所有字符。

fn main() {
let mut string_clear = String::from("string clear");
string_clear.clear();
dbg!(string_clear); // string_clear = ""
}

连接 (Concatenate)

  1. 使用 ++= 运算符

使用这两个操作符时,实际上调用了 String 类型的 add() 方法,那么这个方法的第二个参数,是一个引用类型,所以我们在 + 的右边,应该传递一个切片引用类型,而不是传递 String 类型。

++= 都返回一个新字符串

fn main() {
let string_append = String::from("hello ");
let string_rust = String::from("rust");
// &string_rust 会自动解引用为 &str
let result = string_append + &string_rust;
let mut result = result + "!";
result += "!!!"; // 等价于 result = result + "!!!";

println!(" 连接字符串 + -> {}", result);
}

关于这个方法,结合 [所有权转移](# 转移所有权) 的概念,如下例子:

fn main() {
let s1 = String::from("hello,");
let s2 = String::from("world!");
let s3 = s1 + &s2; // 调用了 String::add() 方法,s1 的所有权被转移走了,因此后面不能再使用 s1
assert_eq!(s3,"hello,world!");
// 下面的语句如果去掉注释,就会报错
// println!("{}",s1);
}
  1. 使用 format!() 方法

format!() 方法和 Python 中的 f-string 类似,可以将多个字符串拼接成一个字符串,但是和 + 不同的是,format!() 不会获取任何参数的所有权。

fn main() {
let s1 = "hello";
let s2 = String::from("rust");
let s = format!("{} {}!", s1, s2);
println!("{} + {} = {}", s1, s2, s); // 这里可以正常使用 s1 和 s2
}

操作 UTF-8 字符串

上面简单介绍了 [UTF-8 字节和字符的概念](# 字符串内部表现),那么该如何操作呢?

字符串

以 Unicdoe 字符的方式操作字符串,可以使用 chars() 方法,该方法返回一个迭代器,可以遍历字符串中的每一个字符。

fn main() {
let s = String::from(" 华中农业大学 ");
for c in s.chars() {
println!("{}", c);
}
}

字节

以字节的方式操作字符串,可以使用 bytes() 方法,该方法返回一个迭代器,可以遍历字符串中的每一个字节。

fn main() {
let s = String::from(" 农业 ");
for c in s.bytes() {
println!("{}", c);
}
}

输出:

  • 229
  • 134
  • 156
  • 228
  • 184
  • 154

也就是每个中文字符占用了 3 个字节。

子串

上文说到,要从 UTF-8 字符串中获取子串,由于要注意字符边界,所以相对复杂,所幸有一些第三方库可以帮助我们,比如 utf8_slice

元组 (Tuple)

元组 tuple 由多种类型组合到一起,其长度是固定的,其中元素的顺序也是固定的。

创建语法如下:

fn main() {
let tup: (i32, f64, u8) = (500, 6.4, 1);
}

将一个元组绑定给 tup 变量,该元组包含三个元素,类型分别为 i32f64u8

解构

上代码就懂了:

fn main() {
let tup = (500, 6.4, 1);

let (x, y, z) = tup;

println!("The value of y is: {}", y);
}

访问元组

可以使用点号 . 加上索引来访问元组中的元素,索引 0-baesd。

fn main() {
let x: (i32, f64, u8) = (500, 6.4, 1);

let five_hundred = x.0;

let six_point_four = x.1;

let one = x.2;
}

结构体 (Struct)

这位更是重量级,结构体是一种自定义的数据类型,可以将多个字段组合到一起,每个字段都有自己的类型。

定义结构体

通过关键字 struct 定义,需要一个结构体名称,其中需要 N 个字段,和 Python 的类的概念类似:

struct Student {  // 结构体名称为 Student,拥有如下四个字段
active: bool, // 是否在校,布尔值类型
name: String, // 名字, `String` 类型
age: u8, // 年龄,u8 类型
score: i32, // 分数,i32 类型
}

创建结构体实例

和 Python 类似,需要创建结构体的实例:

let wjwei = Student {
active: true,
name: String::from("wjwei"),
age: 18,
score: 100,
};

在初始化实例时, 每个字段 都需要初始化,但是字段的顺序 不需要 和定义结构体时的顺序一样。

访问结构体字段

通过 . 关键字可以访问到结构体的字段:

fn main() {
let wjwei = Student {
active: true,
name: String::from("wjwei"),
age: 18,
score: 100,
};
println!("wjwei'name: {:?}", wjwei.name); // wjwei'name: "wjwei"
}

修改结构体字段

声明结构体实例为可变的,就可以修改结构体的字段:

fn main() {
let mut wjwei = Student { // 声明结构体实例为可变的
active: true,
name: String::from("wjwei"),
age: 18,
score: 100,
};
println!("wjwei'name: {:?}", wjwei.name); // wjwei'name: "wjwei"
wjwei.name = String::from("wjwei2");
println!("wjwei'name: {:?}", wjwei.name); // wjwei'name: "wjwei2"
}

结构体更新

在部分场景中,需要根据已有的实例,创建新的实例,这时可以使用结构体更新语法:

let wjwei = Student {  // 创建第一个实例
active: true,
name: String::from("wjwei"),
age: 18,
score: 100,
};
let wjwei2 = Student { // 创建第二个实例,除了 name,其他字段都和 wjwei 相同
active: wjwei.active,
name: String::from("wjwei2"),
age: wjwei.age,
score: wjwei.score,
};
let wjwei3 = Student { // 创建第三个实例,可以使用结构体更新语法..wjwei
age: 20, // 这个实例的 age 改为 20,其余不变
..wjwei // 需要在尾部使用
};
danger

在上面这个例子中,wjweiname 字段所有权被转移到了 wjwei3 中,所以 wjweiname 字段和 wjwei 实例就不能再使用了:

fn main() {
let wjwei = Student {
active: true,
name: String::from("wjwei"),
age: 18,
score: 100,
};

let wjwei3 = Student {age: 20, ..wjwei};
println!("wjwei3-name: {}", wjwei3.name);
println!("wjwei-score: {}", wjwei.score) // 没报错
println!("wjwei-name: {}", wjwei.name); // error[E0382]: borrow of moved value: `wjwei.name`
println!("wjwei: {:?}", wjwei); // error[E0382]: borrow of moved value: `wjwei`
}

有意思的是,明明 activescore 字段也被赋值给了 wjwei3, 那为啥这两个字段还能用呢?

[所有权](# 浅拷贝) 的介绍中,我们说过可以实现 Copy 特征的类型,是可以在赋值时进行数据拷贝的,而这两个字段的类型就实现了 Copy 特征。

所以,wjwei3activescore 字段,是通过数据拷贝的方式赋值的,而 name 字段是通过所有权转移的方式赋值的。

元组结构体 (Tuple Struct)

定义一个结构体必须要用名称,但是其中的字段可以没有名称,这种结构体叫做元组结构体:

fn main() {
struct Color(i32, i32, i32);
struct Point(i32, i32, i32);

let black = Color(0, 0, 0);
let origin = Point(0, 0, 0);
println!("black-r = {:}", black.1);
}

在不关心字段名称时,这种元组结构体很适合。访问字段时,就用下标索引。

单元结构体 (Unit-like Struct)

之前提到过一个基本没啥用的 [单元类型](rust-basic-2# 单元类型),这里的单元结构体就是这个单元类型的结构体版本,没有字段和属性。那么它的作用就在于:不关心该类型的内容,只关心它的行为,比如:

fn main() {
struct A;
struct B;

impl A {
fn foo(&self) {
println!("A::foo");
}
}

impl B {
fn foo(&self) {
println!("B::foo");
}
}

let a = A;
let b = B;
a.foo();
b.foo();
}

结构体数据的所有权

在之前 [Student](# 创建结构体实例) 结构体的创建中,我们使用了 String 类型的字段,而不是它的引用类型 &str,这是因为我们想要结构体拥有它所有的数据,而不是从别的地方借用。

这里还不是特别懂为什么是这样的,先学下去吧。

打印结构体信息

#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}

fn main() {
let rect1 = Rectangle {
width: 30,
height: 50,
};

println!("rect1 is {:?}", rect1);
}

这里的第一行定义了一个 derive 宏,它可以帮助我们自动实现一些特征,比如 Debug 特征,这样我们就可以使用 {:?} 格式化输出结构体了。 后续还有一些进阶操作。

参考资料