Rust 基础入门 - 第二章
理解 所有权 和 借用 ,是 Rust 学习的核心。
学习复合类型:元组、字符串、结构体
所有权
在一门编程语言的设计中,如何申请内存,如何释放内存,是重中之重,也是难点之一。在 CS 发展的过程中,出现了三种流派:
- 垃圾回收机制
GC
,如 Java、Python 等,在程序运行时不断寻找不使用的内存 - 手动管理分配释放 ,如 C++,通过函数调用的方式来申请和释放内存
- 所有权 ,如 Rust,通过编译器根据一系列规则检查
第三种,这种检查只会在编译时进行,不会影响程序的运行性能。
栈 (Stack) 和堆(Heap)
栈和堆是核心的数据结构,在之前学习 Python 时,并没有深入了解。但是对于 Rust 这种系统编程语言,值是位于栈上还是堆上,会影响程序的行为和性能。
栈和堆的核心目标是为程序运行时提供可供使用的内存空间。
栈
栈:按照顺序存储并以相反顺序取出,先进后出,后进先出,好比一叠盘子,增加新的盘子,放在最上面,取出盘子,从最上面取出。
增加数据称为 入栈 ,取出数据称为 出栈 。
那么根据这种实现方式,栈中的数据需要占用固定大小的内存空间,因为如果数据大小不确定,无法确定数据的位置,也就无法取出数据。
堆
堆:对于大小未知或者可能变化的数据,就可以存储在堆上。
向堆放入数据时,需要先申请一块内存空间,操作系统找到后,将数据放入内存空间,并返回一个表示这个位置地址的 指针 ,这个过程称为 分配 。
然后,返回的指针会被入 栈 ,因为指针的大小是已知并且固定的,在后续使用中,由在栈上的指针来找到堆上的数据。
性能区别
写入
栈:入栈时,放入栈顶即可 堆:首先操作系统要找空间,并且记录已使用
读取
栈数据可以存在 CPU 的高速缓存中,读取速度快;而堆数据只能存储在内存中,访问速度慢,并且必须先访问栈,再通过指针访问内存。
所有权和栈堆
当代码中调用一个函数时,函数的参数和局部变量会被压入栈,在函数执行完毕后,会被出栈。
由于堆上的数据没有顺序,所以需要跟踪内存空间的分配和释放,否则会造成内存泄漏。
在 Rust 中,明白堆栈的原理,有助于我们理解所有权的工作原理
所有权原则
- 每个值都有一个被称为其所有者的变量。
- 值在任意时刻有且只有一个所有者。
- 当所有者(变量)离开作用域,这个值将被丢弃。
变量作用域
和其他语言类似,作用域是一个变量在程序中有效的范围。
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
本身,因为这个很快。但是根据我们上文提到的 [所有权原则](# 所有权原则), 一个值只有一个所有者 ,而拷贝指针的行为使得这个字符串数据有了两个所有者,s1
和s2
这个 [所有权原则](# 所有权原则) 这么麻烦,先不管它,来看看会怎么样:
在这个例子中,s1
和 s2
离开作用域后,都会释放相同的内存,这样就会出现 双重释放(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,用下面这张图可以说明:
s1
被 move 后就被释放了,也无法使用。
深拷贝
从上面两个分别拷贝 i32
类型和 String
类型的例子中,我们可以看到 Rust 不会自动进行深拷贝,但是如果我们需要拷贝堆上的数据,而不只是指针,可以使用 clone
方法:
fn main() {
let s1 = String::from("hello");
let s2 = s1.clone();
println!("s1 = {}, s2 = {}", s1, s2);
}
这样的话,这几行代码是可以正常编译运行并打印两个 hello 的。
对于频繁使用的变量,需要谨慎使用 clone
,可能会导致性能问题。
浅拷贝
对于整数类型,已经说过,在编译时大小已知,存储在栈上,拷贝的成本很低,所有没有深浅拷贝的区别。
在 Rust 中,有一个叫做 Copy
的特性,可以用于类似 i32
这样可以存在栈上的类型。即若一个类型拥有 Copy
特性,那么一个变量被赋值给另一个新变量后依然可以访问到。
函数传值与返回
将参数值传递给函数时,也会发生 move
所有权转移。如以下这个例子:
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);
}
这里也比较好理解,我都创建了两个引用 r1
和 r2
了,然后又创建了一个可变引用 r3
,那万一值变了,那 r1
r2
岂不是危险。
所有 Rust 编译是不允许这种情况的。
需要注意的是,引用的作用域和变量的作用域 目前而言 是不同的,在早期版本的 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 前),将会报错,因为 r1
和 r2
的作用域在第 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 个字节:
- 如果想从第一个元素开始切,左边界可以不写:
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
两种方式:
to_string()
方法:let s = "hello".to_string();
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 中,字符串的索引是一件再平常不过的事情了:
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)
把字符串中的某个子串替换成另一个子串,有三种方法:
replace()
适用于 String
和 &str
类型。接受两个参数,第一个为被替换字符串,第二个为替换的字符串。
该方法会替换所有匹配的字符串
返回一个 新的字符串 ,而不是操作原来的字符串。
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!"
}
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!
}
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
类型:
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 中 "
remove()
直接操作 原来的字符串,接受一个参数,指定删除位置,并返回被删除的字符。
该方法是按照字节来处理字符串的,所以位置参数要落在合法的字符边界
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,就会报错。
truncate()
直接操作 原来的字符串,接受一个参数,指定删除位置,删除从指定位置开始到字符串末尾的所有字符。
fn main() {
let mut string_truncate = String::from(" 测试 truncate");
string_truncate.truncate(3);
dbg!(string_truncate); // string_truncate = " 测 "
}
clear()
直接操作 原来的字符串,删除所有字符。
fn main() {
let mut string_clear = String::from("string clear");
string_clear.clear();
dbg!(string_clear); // string_clear = ""
}
连接 (Concatenate)
- 使用
+
或+=
运算符
使用这两个操作符时,实际上调用了 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);
}
- 使用
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
变量,该元组包含三个元素,类型分别为 i32
、f64
和 u8
。
解构
上代码就懂了:
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 // 需要在尾部使用
};
在上面这个例子中,wjwei
的 name
字段所有权被转移到了 wjwei3
中,所以 wjwei
的 name
字段和 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`
}
有意思的是,明明 active
和 score
字段也被赋值给了 wjwei3
, 那为啥这两个字段还能用呢?
在 [所有权](# 浅拷贝) 的介绍中,我们说过可以实现 Copy
特征的类型,是可以在赋值时进行数据拷贝的,而这两个字段的类型就实现了 Copy
特征。
所以,wjwei3
的 active
和 score
字段,是通过数据拷贝的方式赋值的,而 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
特征,这样我们就可以使用 {:?}
格式化输出结构体了。
后续还有一些进阶操作。