很久以前,当牧羊人想要了解两个羊群是否相似时,会挨个对它们进行比对。 ——John C. Baez,James Dolan,“Categorification”
Rust 中的结构体(struct/structure)类似于 C 和 C++ 中的?struct
?类型、Python 中的类和 JavaScript 中的对象。结构体会将多个不同类型的值组合成一个单一的值,以便你能把它们作为一个单元来处理。给定一个结构体,你可以读取和修改它的各个组件。结构体也可以具有关联的方法,以对其组件进行操作。
笔记 结构体在实际开发使用中将非常高频
Rust 有 3 种结构体类型:具名字段型结构体、元组型结构体和单元型结构体。这 3 种结构体在引用组件的方式上有所不同:具名字段型结构体会为每个组件命名;元组型结构体会按组件出现的顺序标识它们;单元型结构体则根本没有组件。单元型结构体虽然不常见,但它们比你想象的更有用。
本文将详细解释每种类型并展示它们在内存中的样子;介绍如何向它们添加方法、如何定义适用于不同组件类型的泛型结构体类型,以及如何让 Rust 为你的结构体生成常见的便捷特型的实现。
具名字段型结构体的定义如下所示:
/// 由8位灰度像素组成的矩形
struct GrayscaleMap {
pixels: Vec<u8>,
size: (usize, usize)
}
它声明了一个?GrayscaleMap
?类型,其中包含两个给定类型的字段,分别名为?pixels
?和?size
。Rust 中的约定是,所有类型(包括结构体)的名称都将每个单词的第一个字母大写(如?GrayscaleMap
),这称为大驼峰格式(CamelCase 或 PascalCase)。字段和方法是小写的,单词之间用下划线分隔,这称为蛇形格式(snake_case)。
你可以使用结构体表达式构造出此类型的值,如下所示:
let width = 1024;
let height = 576;
let image = GrayscaleMap {
pixels: vec![0; width * height],
size: (width, height)
};
结构体表达式以类型名称(GrayscaleMap
)开头,后跟一对花括号,其中列出了每个字段的名称和值。还有用来从与字段同名的局部变量或参数填充字段的简写形式:
fn new_map(size: (usize, usize), pixels: Vec<u8>) -> GrayscaleMap {
assert_eq!(pixels.len(), size.0 * size.1);
GrayscaleMap { pixels, size }
}
结构体表达式?GrayscaleMap { pixels, size }
?是?GrayscaleMap { pixels: pixels, size: size }
?的简写形式。你可以对某些字段使用?key: value
?语法,而对同一结构体表达式中的其他字段使用简写语法。
要访问结构体的字段,请使用我们熟悉的?.
?运算符:
assert_eq!(image.size, (1024, 576));
assert_eq!(image.pixels.len(), 1024 * 576);
与所有其他语法项一样,结构体默认情况下是私有的,仅在声明它们的模块及其子模块中可见。你可以通过在结构体的定义前加上?pub
?来使结构体在其模块外部可见。结构体中的每个字段默认情况下也是私有的:
/// 由8位灰度像素组成的矩形
pub struct GrayscaleMap {
pub pixels: Vec<u8>,
pub size: (usize, usize)
}
即使一个结构体声明为?pub
,它的字段也可以是私有的:
/// 由8位灰度像素组成的矩形
pub struct GrayscaleMap {
pixels: Vec<u8>,
size: (usize, usize)
}
其他模块可以使用此结构体及其任何公共的关联函数,但不能按名称访问私有字段或使用结构体表达式来创建新的?GrayscaleMap
?值。也就是说,要创建结构体型的值,就需要结构体的所有字段都可见。这就是为什么你不能编写结构体表达式来创建新的?String
?或?Vec
。这些标准类型都是结构体,但它们的所有字段都是私有的。如果想创建一个值,就必须使用公共的类型关联函数,比如?Vec::new()
。
创建具名字段结构体的值时,可以使用另一个相同类型的结构体为省略的那些字段提供值。在结构体表达式中,如果具名字段后面跟着?.. EXPR
,则任何未提及的字段都会从?EXPR
(必须是相同结构体类型的另一个值)中获取它们的值。假设我们有一个代表游戏中怪物的结构体:
// 在这个游戏中,怪物是一些扫帚。你会看到:
struct Broom {
name: String,
height: u32,
health: u32,
position: (f32, f32, f32),
intent: BroomIntent
}
/// `Broom`可以支持的两种用途
#[derive(Copy, Clone)]
enum BroomIntent { FetchWater, DumpWater }
对程序员来说,最好的童话故事是?The Sorcerer's Apprentice(《魔法师的学徒》):一个新手魔法师对一把扫帚施了魔法,让它为自己工作,但工作完成后不知道如何让它停下来。于是,他用斧头将扫帚砍成了两半,结果一把扫帚变成了两把,虽然每把扫帚的大小只有原始扫帚的一半,但仍然具有和原始扫帚一样的“工作热情”。
// 按值接收输入的Broom(扫帚),并获得所有权
fn chop(b: Broom) -> (Broom, Broom) {
// 主要从`b`初始化`broom1`,只修改`height`。由于`String`
// 不是`Copy`类型,因此`broom1`获得了`b`中`name`的所有权
let mut broom1 = Broom { height: b.height / 2, .. b };
// 主要从`broom1`初始化`broom2`。由于`String`不是`Copy`类型,
// 因此我们显式克隆了`name`
let mut broom2 = Broom { name: broom1.name.clone(), .. broom1 };
// 为每一半扫帚分别起一个名字
broom1.name.push_str(" I");
broom2.name.push_str(" II");
(broom1, broom2)
}
有了这个定义,我们就可以制作一把扫帚,把它一分为二,然后看看会得到什么:
let hokey = Broom {
name: "Hokey".to_string(),
height: 60,
health: 100,
position: (100.0, 200.0, 0.0),
intent: BroomIntent::FetchWater
};
let (hokey1, hokey2) = chop(hokey);
assert_eq!(hokey1.name, "Hokey I");
assert_eq!(hokey1.height, 30);
assert_eq!(hokey1.health, 100);
assert_eq!(hokey2.name, "Hokey II");
assert_eq!(hokey2.height, 30);
assert_eq!(hokey2.health, 100);
新的扫帚?hokey1
?和?hokey2
?获得了修改后的名字,长度只有原来的一半,但生命值都跟原始扫帚一样。
第二种结构体类型称为元组型结构体,因为它类似于元组:
struct Bounds(usize, usize);
构造此类型的值与构造元组非常相似,只是必须包含结构体名称:
let image_bounds = Bounds(1024, 768);
元组型结构体保存的值称为元素,就像元组的值一样。你可以像访问元组一样访问它们:
assert_eq!(image_bounds.0 * image_bounds.1, 786432);
元组型结构体的单个元素可以是公共的,也可以不是:
pub struct Bounds(pub usize, pub usize);
表达式?Bounds(1024, 768)
?看起来像一个函数调用,实际上它确实是,即定义这种类型时也隐式定义了一个函数:
fn Bounds(elem0: usize, elem1: usize) -> Bounds { ... }
在最基本的层面上,具名字段型结构体和元组型结构体非常相似。选择使用哪一个需要考虑易读性、无歧义性和简洁性。如果你喜欢用?.
?运算符来获取值的各个组件,那么用名称来标识字段就能为读者提供更多信息,并且更容易防范拼写错误。如果你通常使用模式匹配来查找这些元素,那么元组型结构体会更好用。
元组型结构体适用于创造新类型(newtype),即建立一个只包含单组件的结构体,以获得更严格的类型检查。如果你正在使用纯 ASCII 文本,那么可以像下面这样定义一个新类型:
struct Ascii(Vec<u8>);
将此类型用于 ASCII 字符串比简单地传递?Vec<u8>
?缓冲区并在注释中解释它们的内容要好得多。在将其他类型的字节缓冲区传给需要 ASCII 文本的函数时,这种新类型能帮 Rust 捕获错误。我们会在第 22 章中给出一个使用新类型进行高效类型转换的例子。
第三种结构体有点儿晦涩难懂,因为它声明了一个根本没有元素的结构体类型:
struct Onesuch;
这种类型的值不占用内存,很像单元类型?()
。Rust 既不会在内存中实际存储单元型结构体的值,也不会生成代码来对它们进行操作,因为仅通过值的类型它就能知道关于值的所有信息。但从逻辑上讲,空结构体是一种可以像其他任何类型一样有值的类型。或者更准确地说,空结构体是一种只有一个值的类型:
let o = Onesuch;
在阅读 6.10 节中有关?..
?范围运算符的内容时,你已经遇到过单元型结构体。像?3..5
?这样的表达式是结构体值?Range { start: 3, end: 5 }
?的简写形式,而表达式?..
(一个省略两个端点的范围)是单元型结构体值?RangeFull
?的简写形式。
单元型结构体在处理特型时也很有用,第 11 章会对此进行描述。
笔记 目前来看,关于3种结构体的类型,使用频率最高的是
具名字段型结构体
,其次是元组型结构体
,最后单元型结构体
还不清楚它的具体使用场景在哪
在内存中,具名字段型结构体和元组型结构体是一样的:值(可能是混合类型)的集合以特定方式在内存中布局。例如,在本章前面我们定义了下面这个结构体:
struct GrayscaleMap {
pixels: Vec<u8>,
size: (usize, usize)
}
GrayscaleMap
?值在内存中的布局如图 9-1 所示。
图 9-1:内存中的?GrayscaleMap
?结构体
与 C 和 C++ 不同,Rust 没有具体承诺它将如何在内存中对结构体的字段或元素进行排序,图 9-1 仅展示了一种可能的安排。然而,Rust 确实承诺会将字段的值直接存储在结构体本身的内存块中。JavaScript、Python 和 Java 会将?pixels
?值和?size
?值分别放在它们自己的分配在堆上的块中,并让?GrayscaleMap
?的字段指向它们,而 Rust 会将?pixels
?值和?size
?值直接嵌入?GrayscaleMap
?值中。只有由?pixels
?向量拥有的在堆上分配的缓冲区才会留在它自己的块中。
你可以使用?#[repr(C)]
?属性要求 Rust 以兼容 C 和 C++ 的方式对结构体进行布局,第 23 章会对此进行详细介绍。
impl
?定义方法在本书中,我们一直在对各种值调用方法,比如使用?v.push(e)
?将元素推送到向量上、使用?v.len()
?获取向量的长度、使用?r.expect("msg")
?检查?Result
?值是否有错误,等等。你也可以在自己的结构体类型上定义方法。Rust 方法不会像 C++ 或 Java 中的方法那样出现在结构体定义中,而是会出现在单独的?impl
?块中。
impl
?块只是?fn
?定义的集合,每个定义都会成为块顶部命名的结构体类型上的一个方法。例如,这里我们定义了一个公共的?Queue
?结构体,然后为它定义了?push
?和?pop
?这两个公共方法:
/// 字符的先入先出队列
pub struct Queue {
older: Vec<char>, // 较旧的元素,最早进来的在后面
younger: Vec<char> // 较新的元素,最后进来的在后面
}
impl Queue {
/// 把字符推入队列的最后
pub fn push(&mut self, c: char) {
self.younger.push(c);
}
/// 从队列的前面弹出一个字符。如果确实有要弹出的字符,
/// 就返回`Some(c)`;如果队列为空,则返回`None`
pub fn pop(&mut self) -> Option<char> {
if self.older.is_empty() {
if self.younger.is_empty() {
return None;
}
// 将younger中的元素移到older中,并按照所承诺的顺序排列它们
use std::mem::swap;
swap(&mut self.older, &mut self.younger);
self.older.reverse();
}
// 现在older能保证有值了。Vec的pop方法已经
// 返回一个Option,所以可以放心使用了
self.older.pop()
}
}
在?impl
?块中定义的函数称为关联函数,因为它们是与特定类型相关联的。与关联函数相对的是自由函数,它是未定义在?impl
?块中的语法项。
Rust 会将调用关联函数的结构体值作为第一个参数传给方法,该参数必须具有特殊名称?self
。由于?self
?的类型显然就是在?impl
?块顶部命名的类型或对该类型的引用,因此 Rust 允许你省略类型,并以?self
、&self
?或?&mut self
?作为?self: Queue
、self: &Queue
?或?self: &mut Queue
?的简写形式。如果你愿意,也可以使用完整形式,但如前所述,几乎所有 Rust 代码都会使用简写形式。
在我们的示例中,push
?方法和?pop
?方法会通过?self.older
?和?self.younger
?来引用?Queue
?的字段。在 C++ 和 Java 中,"this"
?对象的成员可以在方法主体中直接可见,不用加?this.
?限定符,而 Rust 方法中则必须显式使用?self
?来引用调用此方法的结构体值,这类似于 Python 方法中使用?self
?以及 JavaScript 方法中使用?this
?的方式。
由于?push
?和?pop
?需要修改?Queue
,因此它们都接受?&mut self
?参数。然而,当调用一个方法时,你不需要自己借用可变引用,常规的方法调用语法就已经隐式处理了这一点。因此,有了这些定义,你就可以像下面这样使用?Queue
?了:
let mut q = Queue { older: Vec::new(), younger: Vec::new() };
q.push('0');
q.push('1');
assert_eq!(q.pop(), Some('0'));
q.push('∞');
assert_eq!(q.pop(), Some('1'));
assert_eq!(q.pop(), Some('∞'));
assert_eq!(q.pop(), None);
只需编写?q.push(...)
?就可以借入对?q
?的可变引用,就好像你写的是?(&mut q).push(...)
?一样,因为这是?push
?方法的?self
?参数所要求的。
如果一个方法不需要修改?self
,那么可以将其定义为接受共享引用:
impl Queue {
pub fn is_empty(&self) -> bool {
self.older.is_empty() && self.younger.is_empty()
}
}
同样,方法调用表达式知道要借用哪种引用:
assert!(q.is_empty());
q.push('⊙');
assert!(!q.is_empty());
或者,如果一个方法想要获取?self
?的所有权,就可以通过值来获取?self
:
impl Queue {
pub fn split(self) -> (Vec<char>, Vec<char>) {
(self.older, self.younger)
}
}
调用这个?split
?方法看上去和调用其他方法是一样的:
let mut q = Queue { older: Vec::new(), younger: Vec::new() };
q.push('P');
q.push('D');
assert_eq!(q.pop(), Some('P'));
q.push('X');
let (older, younger) = q.split();
// q现在是未初始化状态
assert_eq!(older, vec!['D']);
assert_eq!(younger, vec!['X']);
但请注意,由于?split
?通过值获取?self
,因此这会将?Queue
?从?q
?中移动出去,使?q
?变成未初始化状态。由于?split
?的?self
?现在拥有此队列,因此它能够将这些单独的向量移出队列并返回给调用者。
有时,像这样通过值或引用获取?self
?还是不够的,因此 Rust 还允许通过智能指针类型传递?self
。
Box
、Rc
?或?Arc
?形式传入?self
方法的?self
?参数也可以是?Box<Self>
?类型、Rc<Self>
?类型或?Arc<Self>
?类型。这种方法只能在给定的指针类型值上调用。调用该方法会将指针的所有权传给它。
你通常不需要这么做。如果一个方法期望通过引用接受?self
,那它在任何指针类型上调用时都可以正常工作:
let mut bq = Box::new(Queue::new());
// `Queue::push`需要一个`&mut Queue`,但`bq`是一个`Box<Queue>`
// 这没问题:Rust在调用期间从`Box`借入了`&mut Queue`
bq.push('■');
对于方法调用和字段访问,Rust 会自动从?Box
、Rc
、Arc
?等指针类型中借入引用,因此?&self
?和?&mut self
?几乎总是(偶尔也会用一下?self
)方法签名里的正确选择。
但是如果某些方法确实需要获取指向?Self
?的指针的所有权,并且其调用者手头恰好有这样一个指针,那么 Rust 也允许你将它作为方法的?self
?参数传入。为此,你必须明确写出?self
?的类型,就好像它是普通参数一样。
impl Node {
fn append_to(self: Rc<Self>, parent: &mut Node) {
parent.children.push(self);
}
}
给定类型的?impl
?块还可以定义根本不以?self
?为参数的函数。这些函数仍然是关联函数,因为它们在?impl
?块中,但它们不是方法,因为它们不接受?self
?参数。为了将它们与方法区分开来,我们称其为类型关联函数。
它们通常用于提供构造函数,如下所示:
impl Queue {
pub fn new() -> Queue {
Queue { older: Vec::new(), younger: Vec::new() }
}
}
要使用此函数,需要写成?Queue::new
,即类型名称 + 双冒号 + 函数名称。现在我们的示例代码简洁一点儿了:
let mut q = Queue::new();
q.push('*');
...
在 Rust 中,构造函数通常按惯例命名为?new
,我们已经见过?Vec::new
、Box::new
、HashMap::new
?等。但是?new
?这个名字并没有什么特别之处,它不是关键字。类型通常还有其他关联函数作为构造函数,比如?Vec::with_capacity
。
虽然对于一个类型可以有许多独立的?impl
?块,但它们必须都在定义该类型的同一个 crate 中。不过,Rust 确实允许你将自己的方法附加到其他类型中,第 11 章会解释具体做法。
如果你习惯了用 C++ 或 Java,那么将类型的方法与其定义分开可能看起来很不寻常,但这样做有几个优点。
impl
?块中可以让所有这 3 种结构体使用同一套语法。事实上,Rust 还使用相同的语法在根本不是结构体的类型(比如?enum
?类型和像?i32
?这样的原始类型)上定义方法。(任何类型都可以有方法,这是 Rust 很少使用对象这个术语的原因之一,它更喜欢将所有东西都称为值。)impl
?语法也可以巧妙地用于实现特型,后续章节会对此进行介绍。笔记 任何类型都可以有方法,这是 Rust 很少使用对象这个术语的原因之一,它更喜欢将所有东西都称为值。 在Rust中是不是可以称作 面向值 编程? 欢迎大家讨论交流 Rust,如果喜欢本文章或感觉文章有用,动动你那发财的小手点个赞再走呗?
^_^
? 微信公众号:草帽Lufei
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。