文章目录
2. Rust 基础入门
2.4. 复合类型
2.4.3. 结构体
上一节中提到需要一个更高级的数据结构来帮助我们更好的抽象问题,结构体 struct
恰恰就是这样的复合数据结构,它是由其它数据类型组合而来。 其它语言也有类似的数据结构,不过可能有不同的名称,例如 object
、 record
等。
结构体跟之前讲过的元组有些相像:都是由多种类型组合而成。但是与元组不同的是,结构体可以为内部的每个字段起一个富有含义的名称。因此结构体更加灵活更加强大,你无需依赖这些字段的顺序来访问和解析它们。
结构体语法
天下无敌的剑士往往也因为他有一柄无双之剑,既然结构体这么强大,那么我们就需要给它配套一套强大的语法,让用户能更好的驾驭。
1. 定义结构体
一个结构体由几部分组成:
- 通过关键字
struct
定义 - 一个清晰明确的结构体
名称
- 几个有名字的结构体
字段
例如, 以下结构体定义了某网站的用户:
struct User {
active: bool,
username: String,
email: String,
sign_in_count: u64,
}
该结构体名称是 User
,拥有 4 个字段,且每个字段都有对应的字段名及类型声明,例如 username
代表了用户名,是一个可变的 String
类型。
2. 创建结构体实例(1. 初始化实例时,每个字段都需要进行初始化;2. 初始化时的字段顺序不需要和结构体定义时的顺序一致)
为了使用上述结构体,我们需要创建 User
结构体的实例:
let user1 = User {
email: String::from("[email protected]"),
username: String::from("someusername123"),
active: true,
sign_in_count: 1,
};
3. 访问、修改结构体字段(通过.
访问)
通过 .
操作符即可访问结构体实例内部的字段值,也可以修改它们:
let mut user1 = User {
email: String::from("[email protected]"),
username: String::from("someusername123"),
active: true,
sign_in_count: 1,
};
user1.email = String::from("[email protected]");
需要注意的是,必须要将结构体实例声明为可变的,才能修改其中的字段,Rust 不支持将某个结构体某个字段标记为可变。
4. 简化结构体创建(当函数参数和结构体字段同名时,可以直接使用缩略的方式进行初始化,跟 TypeScript 中一模一样)
下面的函数类似一个构建函数,返回了 User
结构体的实例:
fn build_user(email: String, username: String) -> User {
User {
email: email,
username: username,
active: true,
sign_in_count: 1,
}
}
它接收两个字符串参数: email
和 username
,然后使用它们来创建一个 User
结构体,并且返回。可以注意到这两行: email: email
和 username: username
,非常的扎眼,因为实在有些啰嗦,如果你从 TypeScript 过来,肯定会鄙视 Rust 一番,不过好在,它也不是无可救药:
// 测试代码
struct User {
active: bool,
username: String,
email: String,
sign_in_count: u64,
}
fn build_user(email: String, username: String) -> User {
User {
email,
username,
active: true,
sign_in_count: 1,
}
}
fn main() {
let s1 = build_user(String::from("[email protected]"), String::from("Dontla"));
println!("s1.active is {}.", s1.active);
println!("s1.username is {}.", s1.username);
println!("s1.email is {}.", s1.email);
println!("s1.sign_in_count is {}.", s1.sign_in_count);
}
如上所示,当函数参数和结构体字段同名时,可以直接使用缩略的方式进行初始化,跟 TypeScript 中一模一样。
5. 结构体更新语法(..
)(注意部分类型所有权会转移)
在实际场景中,有一种情况很常见:根据已有的结构体实例,创建新的结构体实例,例如根据已有的 user1
实例来构建 user2
:
let user2 = User {
active: user1.active,
username: user1.username,
email: String::from("[email protected]"),
sign_in_count: user1.sign_in_count,
};
老话重提,如果你从 TypeScript 过来,肯定觉得啰嗦爆了:竟然手动把 user1
的三个字段逐个赋值给 user2
,好在 Rust 为我们提供了 结构体更新语法
:
let user2 = User {
email: String::from("[email protected]"),
..user1
};
因为 user2
仅仅在 email
上与 user1
不同,因此我们只需要对 email
进行赋值,剩下的通过结构体更新语法 ..user1
即可完成。
..
语法表明凡是我们没有显式声明的字段,全部从 user1
中自动获取。需要注意的是 ..user1
必须在结构体的尾部使用。
结构体更新语法跟赋值语句
=
非常相像,因此在上面代码中,user1
的部分字段所有权被转移到user2
中:username
字段发生了所有权转移,作为结果,user1
无法再被使用。聪明的读者肯定要发问了:明明有三个字段进行了自动赋值,为何只有
username
发生了所有权转移?仔细回想一下所有权那一节的内容,我们提到了
Copy
特征:实现了Copy
特征的类型无需所有权转移,可以直接在赋值时进行
数据拷贝,其中bool
和u64
类型就实现了Copy
特征,因此active
和sign_in_count
字段在赋值给user2
时,仅仅发生了拷贝,而不是所有权转移。值得注意的是:
username
所有权被转移给了user2
,导致了user1
无法再被使用,但是并不代表user1
内部的其它字段不能被继续使用,例如:
// 测试代码
#[derive(Debug)]
struct User {
active: bool,
username: String,
email: String,
sign_in_count: u64,
}
fn main() {
let user1 = User {
email: String::from("[email protected]"),
username: String::from("someusername123"),
active: true,
sign_in_count: 1,
};
let user2 = User {
active: user1.active,
username: user1.username,
email: String::from("[email protected]"),
sign_in_count: user1.sign_in_count,
};
println!("{}", user1.active);
// 下面这行会报错,因为部分字段值所有权被转移了
// println!("{:?}", user1);
println!("{:?}", user2);
}
结构体的内存排列
先来看以下代码:
// 测试代码
#[derive(Debug)]
struct File {
name: String,
data: Vec<u8>,
}
fn main() {
let f1 = File {
name: String::from("f1.txt"),
data: Vec::new(),
};
let f1_name = &f1.name;
let f1_length = &f1.data.len();
println!("{:?}", f1);
println!("{} is {} bytes long", f1_name, f1_length);
}
上面定义的 File
结构体在内存中的排列如下图所示:
从图中可以清晰地看出 File
结构体两个字段 name
和 data
分别拥有底层两个 [u8]
数组的所有权(String
类型的底层也是 [u8]
数组),通过 ptr
指针指向底层数组的内存地址,这里你可以把 ptr
指针理解为 Rust 中的引用类型。
该图片也侧面印证了:把结构体中具有所有权的字段转移出去后,将无法再访问该字段,但是可以正常访问其它的字段。
元组结构体(Tuple Struct)(字段没有名称的结构体)
结构体必须要有名称,但是结构体的字段可以没有名称,这种结构体长得很像元组,因此被称为元组结构体,例如:
struct Color(i32, i32, i32);
struct Point(i32, i32, i32);
let black = Color(0, 0, 0);
let origin = Point(0, 0, 0);
元组结构体在你希望有一个整体名称,但是又不关心里面字段的名称时将非常有用。例如上面的 Point
元组结构体,众所周知 3D 点是 (x, y, z)
形式的坐标点,因此我们无需再为内部的字段逐一命名为:x
, y
, z
。
单元结构体(Unit-like Struct)
还记得之前讲过的基本没啥用的单元类型吧?单元结构体就跟它很像,没有任何字段和属性,但是好在,它还挺有用。
如果你定义一个类型,但是不关心该类型的内容,只关心它的行为时,就可以使用 单元结构体
:
struct AlwaysEqual;
let subject = AlwaysEqual;
// 我们不关心 AlwaysEqual 的字段数据,只关心它的行为,因此将它声明为单元结构体,然后再为它实现某个特征
impl SomeTrait for AlwaysEqual {
}
补充说明
在Rust编程语言中,单元结构体(Unit Structs)是一种没有任何字段的结构体。它们主要用于几种情况:
-
类型标记:单元结构体可以用作类型标记,不携带数据但表示特定的类型意义。这在泛型编程中特别有用,例如,当你需要实现某个特质(trait)但不需要存储状态时。
-
静态方法:单元结构体经常用来组织静态方法。由于它们不包含数据,可以用它们来创建一个命名空间,类似于其他语言中的静态类。
-
实现特质:即使不存储数据,单元结构体也可以实现特质(traits)。这使得它们在某些设计模式中非常有用,例如状态模式,其中不同状态可以通过实现相同的特质但用不同的单元结构体来表示。
-
标记用途:它们可以用来标记某些处理流程的完成,例如,在并发编程中,使用单元结构体来发送信号表示任务完成。
这是一个简单的示例,展示如何定义和使用单元结构体:
// 测试代码
// 定义一个单元结构体
struct MyMarker;
impl MyMarker {
// 定义一个静态方法
pub fn do_something() {
println!("Doing something...");
}
}
fn main() {
// 调用单元结构体的静态方法
MyMarker::do_something();
}
在这个例子中,MyMarker
就是一个单元结构体,它通过提供一个静态方法来展示其用途,而不需要存储任何实际的数据。
结构体数据的所有权
在之前的 User
结构体的定义中,有一处细节:我们使用了自身拥有所有权的 String
类型而不是基于引用的 &str
字符串切片类型。这是一个有意而为之的选择:因为我们想要这个结构体拥有它所有的数据,而不是从其它地方借用数据。
你也可以让 User
结构体从其它对象借用数据,不过这么做,就需要引入生命周期(lifetimes)这个新概念(也是一个复杂的概念),简而言之,生命周期能确保结构体的作用范围要比它所借用的数据的作用范围要小。
总之,如果你想在结构体中使用一个引用,就必须加上生命周期,否则就会报错:
struct User {
username: &str,
email: &str,
sign_in_count: u64,
active: bool,
}
fn main() {
let user1 = User {
email: "[email protected]",
username: "someusername123",
active: true,
sign_in_count: 1,
};
}
编译器会抱怨它需要生命周期标识符:
error[E0106]: missing lifetime specifier
--> src/main.rs:2:15
|
2 | username: &str,
| ^ expected named lifetime parameter // 需要一个生命周期
|
help: consider introducing a named lifetime parameter // 考虑像下面的代码这样引入一个生命周期
|
1 ~ struct User<'a> {
2 ~ username: &'a str,
|
error[E0106]: missing lifetime specifier
--> src/main.rs:3:12
|
3 | email: &str,
| ^ expected named lifetime parameter
|
help: consider introducing a named lifetime parameter
|
1 ~ struct User<'a> {
2 | username: &str,
3 ~ email: &'a str,
|
解释:
这两个错误都是关于Rust中的生命周期(lifetime)管理。Rust使用生命周期来确保引用在它们被使用的整个期间都是有效的。当你在结构体中使用引用类型(如
&str
)时,Rust需要确保这些引用指向的数据在结构体实例存在的整个时间内都是有效的。为此,你需要在结构体定义中指定这些引用的生命周期。错误信息
error[E0106]: missing lifetime specifier
表明你的结构体定义中有引用(在这里是&str
类型的字段),但你没有为这些引用指定生命周期。编译器建议你引入一个命名的生命周期参数(如'a
),并使用它来标记结构体中的引用类型字段的生命周期。按照编译器的建议修改代码如下:
struct User<'a> { username: &'a str, email: &'a str, }
在这个修正中:
- 我们给结构体
User
添加了一个生命周期参数'a
。- 我们将结构体中的
username
和&str
改为&'a str
,表明这两个字段的数据将引用外部数据,其生命周期至少要和结构体实例'a
一样长。这样一来,当你创建
User
的实例时,Rust可以确保username
和User
实例存在的整个期间内都是有效的。这种机制帮助防止了悬挂引用和其他一些常见的内存安全问题。
未来在生命周期中会讲到如何修复这个问题以便在结构体中存储引用,不过在那之前,我们会避免在结构体中使用引用类型。
使用 #[derive(Debug)]
来打印结构体的信息
#[derive(Debug)]
和{:?}
在前面的代码中我们使用 #[derive(Debug)]
对结构体进行了标记,这样才能使用 println!("{:?}", s);
的方式对其进行打印输出,如果不加,看看会发生什么:
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let rect1 = Rectangle {
width: 30,
height: 50,
};
println!("rect1 is {}", rect1);
}
首先可以观察到,上面使用了 {}
而不是之前的 {:?}
,运行后报错:
error[E0277]: `Rectangle` doesn't implement `std::fmt::Display`
提示我们结构体 Rectangle
没有实现 Display
特征,这是因为如果我们使用 {}
来格式化输出,那对应的类型就必须实现 Display
特征,以前学习的基本类型,都默认实现了该特征:
fn main() {
let v = 1;
let b = true;
println!("{}, {}", v, b);
}
上面代码不会报错,那么结构体为什么不默认实现 Display
特征呢?原因在于结构体较为复杂,例如考虑以下问题:你想要逗号对字段进行分割吗?需要括号吗?加在什么地方?所有的字段都应该显示?类似的还有很多,由于这种复杂性,Rust 不希望猜测我们想要的是什么,而是把选择权交给我们自己来实现:如果要用 {}
的方式打印结构体,那就自己实现 Display
特征。
接下来继续阅读报错:
= help: the trait `std::fmt::Display` is not implemented for `Rectangle`
= note: in format strings you may be able to use `{
:?}` (or {
:#?} for pretty-print) instead
上面提示我们使用 {:?}
来试试,这个方式我们在本文的前面也见过,下面来试试:
println!("rect1 is {:?}", rect1);
可是依然无情报错了:
error[E0277]: `Rectangle` doesn't implement `Debug`
好在,聪明的编译器又一次给出了提示:
= help: the trait `Debug` is not implemented for `Rectangle`
= note: add `#[derive(Debug)]` to `Rectangle` or manually `impl Debug for Rectangle`
让我们实现 Debug
特征,Oh No,就是不想实现 Display
特征,才用的 {:?}
,怎么又要实现 Debug
,但是仔细看,提示中有一行: add #[derive(Debug)] to Rectangle
, 哦?这不就是我们前文一直在使用的吗?
首先,Rust 默认不会为我们实现 Debug
,为了实现,有两种方式可以选择:
- 手动实现
- 使用
derive
派生实现
后者简单的多,但是也有限制,具体见附录 D,这里我们就不再深入讲解,来看看该如何使用:
// 测试代码
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let rect1 = Rectangle {
width: 30,
height: 50,
};
println!("rect1 is {:?}", rect1);
}
此时运行程序,就不再有错误,输出如下:
$ cargo run
rect1 is Rectangle {
width: 30, height: 50 }
这个输出格式看上去也不赖嘛,虽然未必是最好的。这种格式是 Rust 自动为我们提供的实现,看上基本就跟结构体的定义形式一样。
{:#?}
当结构体较大时,我们可能希望能够有更好的输出表现,此时可以使用 {:#?}
来替代 {:?}
,输出如下:
rect1 is Rectangle {
width: 30,
height: 50,
}
此时结构体的输出跟我们创建时候的代码几乎一模一样了!当然,如果大家还是不满足,那最好还是自己实现 Display
特征,以向用户更美的展示你的私藏结构体。关于格式化输出的更多内容,我们强烈推荐看看这个章节。
dbg!
宏
还有一个简单的输出 debug 信息的方法,那就是使用 dbg!
宏,它会拿走表达式的所有权,然后打印出相应的文件名、行号等 debug 信息,当然还有我们需要的表达式的求值结果。除此之外,它最终还会把表达式值的所有权返回!
dbg!
输出到标准错误输出stderr
,而println!
输出到标准输出stdout
。
下面的例子中清晰的展示了 dbg!
如何在打印出信息的同时,还把表达式的值赋给了 width
:
// 测试代码
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let scale = 2;
let rect1 = Rectangle {
width: dbg!(30 * scale),
height: 50,
};
dbg!(&rect1);
}
最终的 debug 输出如下:
$ cargo run
[src/main.rs:10] 30 * scale = 60
[src/main.rs:14] &rect1 = Rectangle {
width: 60,
height: 50,
}
可以看到,我们想要的 debug 信息几乎都有了:代码所在的文件名、行号、表达式以及表达式的值,简直完美!