结构是一种自定义的数据类型,它能够打包多个相关的数据并命名,使得它们组成一个有意义的组。如果你非常熟悉OO语言,Rust中的结构其实就像一个对象的数据属性。在本章中,我们会比较元组和结构,并示范如何使用结构,讨论如何为这些结构定义相关的方法和关联函数。结构和枚举(第六章)是在程序域中构建新数据类型的基石,它们在Rust编译时会进行类型检查,这也是Rust的优势之一。
结构和我们第三章中说过的元组很相似,和元组一样,结构的每一部分可以是不同类型的数据。但又有一点不同,你可以在结构中为每一个数据命名,数据的意义会相当明了。同时,由于数据都有自己的名字,结构使用起来会比元组更加灵活:你不须要依赖数据的排序去指定或访问一个实例的值。
在定义结构时,我们使用关键字struct
给整个结构命名,一个结构的名字理应能够体现出组成结构的数据的意义。然后在尖括号的代码块中,我们定义并命名了结构中的每一个数据和其对应的类型,这些我们称之为fields 字段。下面就是一个用于存储用户账号信息的结构例子:
struct User {
username: String,
email: String,
sign_in_count: u64,
active: bool,
}
想要使用我们定义的结构,我们必须为这个结构创建一个实例,指定结构中每一个字段的值。我们通过直接调用结构名来创建结构的实例,并在尖括号中使用一系列形似key: value
这样的键值对为结构中的每一个字段分配我们想让它们存储的值,且我们不需要严格按照结构定义中的顺序来为字段赋值。一句话,结构定义其实就像一个类型的普通模板,而实例就是往模板的每个字段中填入符合定义中类型要求的明确的数据。下面的例子就是创建一个具体用户实例:
let user1 = User {
email: String::from("[email protected]"),
username: String::from("someusername123"),
active: true,
sign_in_count: 1,
};
获得结构中一个具体的值,我们可以使用点表示法。如果我们只想获得用户的邮箱地址,我们可以使用user1.email
。更进一步,如果实例是可修改的,我们也可以通过点表示法,通过为字段指定一个新的值来修改它。下面就是一个修改结构实例中字段值的例子:
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不允许我们仅仅对结构中局部几个字段标记为可更改。就像其它表达式一样,我们可以将一个结构的实例放在函数体的最后,这样函数就能返回这个结构实例。
下面的例子中,build_function
函数返回了一个User
实例,它包含有我们传入的邮箱和用户名。结构中的active
字段,我们缺省给了值true
;sign_in_count
字段,缺省给了值1
:
fn build_user(email: String, username: String) -> User {
User {
email: email,
username: username,
active: true,
sign_in_count: 1,
}
}
函数的形参名与结构的字段名可以是一样的,但重复输入email
和username
有一丢丢烦,如果结构包含更多的字段,重复写每一个名字是很麻烦的。幸运的是,这里提供了一个舒服的速记法。
在上面的例子中,因为形参和结构中的字段名是一样的,我们可以使用字段速记初始化语法来重写build_user
函数,它的功能没有发生变化,但我们不必再在实例化结构时重复email
和username
了:
fn build_user(email: String, username: String) -> User {
User {
email,
username,
active: true,
sign_in_count: 1,
}
}
这样,我们就创建了一个新的User
结构实例。它的字段email
的值来自build_user
函数的email
形参,这里我们只要使用email
就行了,而不再需要email: email
。
更改一个旧实例中某些字段的值来创建一个新实例,通常是非常有用的。我们可以通过结构更新语法来实现这个功能。
在下面的例子中,我们没有通过结构更新语法创建了一个User
实例user2
。我们为email
和username
设置了新的值,但另一些字段,我们沿用了user1
实例中字段的值:
let user2 = User {
email: String::from("[email protected]"),
username: String::from("anotherusername567"),
active: user1.active,
sign_in_count: user1.sign_in_count,
};
下面的例子,通过结构更新语法,使用更少的代码完成了同样的效果。语句 ..
指定了结构中其余的字段应与另一个实例中的字段值保持一致:
let user2 = User {
email: String::from("[email protected]"),
username: String::from("anotherusername567"),
..user1
};
上面的例子中,我们同样创建了一个实例user2
,它的字段email
和username
有不同的值,但active
和sign_in_count
字段的值来自于user1
。
其实我们也能用类似元组的方式定义结构,这类结构被称作tuple structs元组结构。元组结构添加了有意义的结构名,但并不为它里面的字段命名,仅仅罗列了字段的数据类型。当你想让某个元组区别于其它元组时,使用元祖结构会非常有用,如果使用普通的结构,为每个字段是非常冗余的。
定义元祖结构,也是使用关键字struct
并紧跟结构名,结构名后就是包含具体数据类型的元组了。下面的例子,我们定义并实例化了两个元组结构Color
和Point
:
struct Color(i32, i32, i32);
struct Point(i32, i32, i32);
let black = Color(0, 0, 0);
let origin = Point(0, 0, 0);
留意下,black
和origin
的值是不同的数据类型,因为这两个实例是来自不同的元组结构,尽管这两个结构中的字段都有一样的数据类型。举例来说,一个函数的形参是Color
类型,那么它不能使用Point
作为实参,尽管这两个类型都是由三个i32
值组成。此外,元组结构的行为也很像元组,你能将它解构为多个独立的部分,你也能使用.
紧跟索引值来访问单独的某个值。
我们也可以定义不包含任何字段的结构,这类结构被称作unit-like structs 类单元结构,因为它们的行为模式和 ()
元数据类型很像。类单元结构在很多场景下都非常有用,譬如你须要为某个数据类型实现一个特性,但你又不想在这个类型中存储任何数据。关于特性的知识,我们会在第十章中讨论。
Ownership of Struct Data
在我们一开始定义的User
结构中,我们使用了String
类型而非&str
字符串切片。这是一个经过深思熟虑的选择,因为我们希望这个实例包含的数据的有效期能够和实例本身一致。结构也能够存储一些其它数据对象的引用,但这么做须要用到生命周期,这是Rust的一个特点,我们也会在第十章中再去讨论。生命周期可以确保被结构引用的数据在结构实例生效时也始终生效。你也可以照下面的代码尝试在结构中包含引用,但不指定声明周期,这段代码是无法工作的:
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
-->
|
2 | username: &str,
| ^ expected lifetime parameter
error[E0106]: missing lifetime specifier
-->
|
3 | email: &str,
| ^ expected lifetime parameter
在第十章中,我们会讨论如何修复这些错误来让你在结构中使用引用,但现在,请先通过使用
String
而非&str
来修复这个错误先。
为了更好地理解我们什么时候可能要用到结构,让我们来写个程序计算长方形的面积。我们会先通过单个变量做起,最后使用结构来重写程序。
我们先通过Cargo创建一个新的二进制项目叫做rectangles
,它会获得一个长方形的长高,并计算长方形的面积:
fn main() {
let width1 = 30;
let height1 = 50;
println!(
"The area of the rectangle is {} square pixels.",
area(width1, height1)
);
}
fn area(width: u32, height: u32) -> u32 {
width * height
}
现在,让我们用cargo run
命令来运行它:
The area of the rectangle is 1500 square pixels.
从结果上来看,我们的确通过area
函数正确的获得了每个长方形的面积,但是这个程序还是有很大的改进空间。首先一个长方形的长和高是一对现实中有关联的数据,但来看下area
函数的函数签名:
fn area(width: u32, height: u32) -> u32 {
尽管area
函数能够计算长方形的面积,但是却将长高拆分成了两个形参,这样我们就无法直观的在程序中表达出二者的关联。如果能将长高合理的组合起来,这样就能提高程序的可读性。在第三章中,我们讨论过了一种方法,即使用元组。
现在让我们使用元组来重构下我们的程序:
fn main() {
let rect1 = (30, 50);
println!(
"The area of the rectangle is {} square pixels.",
area(rect1)
);
}
fn area(dimensions: (u32, u32)) -> u32 {
dimensions.0 * dimensions.1
}
我们的程序看起来更好了,是不?元组使我们的程序结构更加合理,这次我们也只需要传递一个参数就行了。但这个新版本的程序也有一点问题,元组中各元素的意义并不清楚,因为元组无法对它的元素命名,在使用area
函数时,你会感到很困惑,因为你须要通过索引去访问参数里面的元素。
在结构中,我们可以通过为字段设置标签的方式来赋予它具体的含义,所以我们不妨将程序中的元组替换为结构:
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let rect1 = Rectangle { width: 30, height: 50 };
println!(
"The area of the rectangle is {} square pixels.",
area(&rect1)
);
}
fn area(rectangle: &Rectangle) -> u32 {
rectangle.width * rectangle.height
}
上面的程序中,我们定义了一个结构叫做Rectangle
,在尖括号中的结构体里,我们定义了两个字段width
和height
,两个字段都是u32
类型。在main
函数中,我们创建了一个具体的Rectangle
实例,它的长是30,高是50。
我们的area
函数现在就只需要定义一个形参rectangle
就够了,它的类型是Rectangle
实例的不可修改借用。回忆下第4章的知识,我们只想借用这个结构实例,而不须要获取它的所有权,这样main
将保留所有权,继续使用rect1
,这也是为什么我们在函数签名中使用了&
。
当我们在debug程序时,如果能将Rectangle
中各字段的值显示出来,那自然是极好的。所以在下面的例子中,我们使用了之前介绍过的println!
这个宏来尝试输出长方形的内容:
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`
宏println!
能够做许多格式化输出,参数中的尖括号对是用于告诉print
宏该在哪里来格式化输出,这种行为也被称为Display
:将输出结果以最终用户期望的格式展示。目前我们接触的很多基础数据类型都缺省实现了Display
特性,如果你想把1
或是其它基础数据类型展现给用户时,都只有很简单的一种样式,因为基础数据类型都是扁平的。但是对于结构,println!
就不清楚该如何去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);
去调用宏println!
,注意到了么,我们在尖括号中加入了:?
,这会告诉println!
我们想以Debug
特性要求的格式去输出。Debug
特性使得我们能够以开发者喜闻乐见的方式来显示结构,这样在debug我们的程序时,我们就可以看到结构中的字段值。
可是,当我们再度尝试编译。f**k!我们还是有报错:
error[E0277]: `Rectangle` doesn't implement `std::fmt::Debug`
编译器再一次提供了有用的注释信息:
= help: the trait `std::fmt::Debug` is not implemented for `Rectangle`
= note: add `#[derive(Debug)]` or manually implement `std::fmt::Debug`
Rust 并不 包含输出调试信息的功能,我们必须在我们的程序中明确的为我们的结构选择这个功能。我们须要在结构的定义前,标注上#[derive(Debug)]
:
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let rect1 = Rectangle { width: 30, height: 50 };
println!("rect1 is {:?}", rect1);
}
现在我们再运行程序,就不会有任何错误信息了,并且看到像下面这样的输出:
rect1 is Rectangle { width: 30, height: 50 }
奈斯!尽管这个输出并不漂亮,但它至少显示了结构实例中字段的值,这对于调试程序已有足够的帮助了。如果我们有一个更复杂的结构,这时可以通过使用{:#?}
来替代println!
参数中的{:?}
,这样显示的输出将更加方便阅读:
rect1 is Rectangle {
width: 30,
height: 50
}
Rust提供了一系列可以使用derive
标记来使用的特性,它们能为我们的自定义数据类型提供很多有用的行为。这些特性和它们的行为,都在官方教程的附录C中有罗列。我们将会在第十章中介绍如何用自定义的行为来实现这些特性,也会告诉你如何创建一个你自己的特性。
现在我们的area
函数的意义已经非常精确了:它是用来计算一个长方形的面积。如果能进一步将这个求面积的行为关联到我们的Rectangle
结构上,那无疑是更好的选择,因为它不能用于其它的结构类型。下面就来介绍下,如何继续重构我们的代码,使得area
函数变为我们Rectangle
定义中的一个 method方法 吧。
方法函数和普通函数很像:它们都通过fn
关键字加名字去定义;它们都可以有形参和返回值;它们都包含了一些代码,并且会在它们被调用时执行。方法函数与函数也有不同的地方,方法函数是在结构(枚举、特性对象,这两个概念会分别在第六章和第十七章中介绍)的上下文中被定义,并且它的第一个形参总是self
,self
代表了调用这个方法函数的结构实例。
让我们修改我们的代码,用area
方法函数来替换area
函数:
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
impl Rectangle {
fn area(&self) -> u32 {
self.width * self.height
}
}
fn main() {
let rect1 = Rectangle { width: 30, height: 50 };
println!(
"The area of the rectangle is {} square pixels.",
rect1.area()
);
}
我们通过impl
代码块,在Rectangle
的上下文中定义了方法函数实现,并且将area
方法函数塞了进去,同时area
方法函数签名中的第一个形参是self
。main
函数中,我们通过方法函数语法,让我们的Rectangle
实例调用了area
方法函数。在实例的方法函数语法中,我们在实例对象后添加了一个句点,之后是方法函数名和括号,当然你也可以跟上更多的参数。
area
方法函数中,我们使用&self
替代了rectangle: &Rectangle
,Rust知道self
的数据类型就是Rectangle
,因为这个方法函数是在impl Rectangle
上下文中。记住一点,我们仍然需要在self
前加上&
,就像我们之前使用&Rectangle
一样。方法函数能够获得self
的所有权,这里我们对self
做了不可修改引用,当然依据参数不同,你也能够对self
做可修改引用。
这里我们使用不可修改引用的原因和之前是一样的:我们不想获得所有权,我们只想读取结构的数据,而不涉及写入操作。如果我们想在方法函数中的某一部分中修改实例,可以使用&mut self
作为我们第一个形参。在方法函数中直接使用self
作为第一个形参来获得实例的所有权,这种情况是非常罕见的,一般这种情况是用于将self
转换为其它东西,同时你又不希望原始的调用者在转换后能继续使用这个实例对象。
用方法函数而非普通函数的一大好处是,在方法函数中,你不须要重复在签名里标注self
的数据类型。我们可以把实例相关的功能都写进一个impl
代码块中,我们功能的用户将来就不须要再像使用普通函数时那样,在代码库中拼命去找哪里用到了Rectangle
这个类型。
哪里有
->
箭头操作符?
在C和C++中,你可以使用两种操作符来调用方法:一种即是.
句点符,通过它你可以直接调用对象的方法;另一种是->
箭头符,这种情况是用于你想调用指针指向的对象上的方法,这时你须要先取消引用。一句话,如果object
是一个指针,object->something()
和(*object).something()
是一样的。Rust中没有与
->
相同的操作符,不过,Rust有一个功能叫做automatic referencing and dereferencing 自动引用与取消引用。调用方法函数是Rust种用到这个功能的地方之一。这里是它工作的原理:
当你使用object.something()
调用一个方法函数时,Rust会自动在object
前添加&
、&mut
或*
,使得object
匹配方法函数的签名。换句话说,像下面这两句代码是一样的:
p1.distance(&p2);
(&p1).distance(&p2);
第一行显然看起来更加清楚。方法函数能够使用自动引用,这是因为方法函数的消费者是一个明确的
self
实例。只要告诉实例调用的方法函数名字,Rust就能推测出这个方法函数是去读&self
、修改&mut self
还是直接消费self
。Rust在调用方法函数时隐藏对实例的借用,这是出于为了更符合代码编写习惯的考量。
让我们来尝试为Rectangle
结构添加第二个方法函数。这次我们想要比较两个Rectangle
实例,如果长方形A能够包含长方形B,就返回true
,反之则返回false
。这个方法函数我们叫它can_hold
,实现后我们就可以通过下面的例子去使用它:
fn main() {
let rect1 = Rectangle { width: 30, height: 50 };
let rect2 = Rectangle { width: 10, height: 40 };
let rect3 = Rectangle { width: 60, height: 45 };
println!("Can rect1 hold rect2? {}", rect1.can_hold(&rect2));
println!("Can rect1 hold rect3? {}", rect1.can_hold(&rect3));
}
因为rect2
比rect1
小,而rect3
比rect1
更宽,所以预期的结果应该如下:
Can rect1 hold rect2? true
Can rect1 hold rect3? false
我们知道,如果我们想要定义一个方法函数,就需要将它包含进impl
代码块中。这个方法函数名叫can_hold
,它有一个Rectangle
类型的不可更改引用形参。通过rect1.can_hold(&rect2)
调用方法函数时传入的&rect2
,我们能够找到对应的形参类型,它是一个Rectangle
实例rect2
的不可更改引用。这样做就足够了,因为我们只须要读取rect2
的值而非写入,我们不须要一个可修改引用。并且我们希望main
函数能保留rect2
的所有权,这样在can_hold
后我们还能继续使用它。can_hold
的返回值是一个波尔数值,它会分别去检查self
的长高是否都大于另一个Rectangle
的长高。让我们将can_hold
加入impl
代码块中:
impl Rectangle {
fn area(&self) -> u32 {
self.width * self.height
}
fn can_hold(&self, other: &Rectangle) -> bool {
self.width > other.width && self.height > other.height
}
}
完成后我们尝试运行main
函数中的代码,我们会获得期望的数据。方法函数可以包含多个形参,我们只需要将这些形参加到方法函数签名的self
参数后,在方法函数中使用这些形参和在普通函数中使用形参是一样的。
impl
代码块还有一个非常有用的功能,我们可以在impl
代码块中定义一个不带self
形参的方法函数。这种方法函数被称为关联函数,因为它们与结构紧密关联。它们仍然是函数,而不是方法函数,因为它们可以不需要一个结构的实例就能使用。你已经用过String::from
这个关联函数了。
关联函数常常被用做为构造函数,它们可以返回一个新的结构实例。举例来说,我们可以提供一个关联函数,它有一个尺寸参数,即被用于长,又被用于高,所以可以通过这个关联函数方便的创建一个正方形,而不须要指定一个相同的值两次:
impl Rectangle {
fn square(size: u32) -> Rectangle {
Rectangle { width: size, height: size }
}
}
调用关联函数,我们可以使用::
语法和结构名,譬如let sq = Rectangle::square(3);
。::
语法除了被用于关联函数外,在模块的命名空间中也会被用到。我们将在第七章中介绍模块相关的内容。
impl
Blocks 多个impl
代码块事实上,一个结构可以有多个impl
代码块:
impl Rectangle {
fn area(&self) -> u32 {
self.width * self.height
}
}
impl Rectangle {
fn can_hold(&self, other: &Rectangle) -> bool {
self.width > other.width && self.height > other.height
}
}
在上面的例子中,我们似乎并没有充足的理由来将方法函数拆分到impl
代码块中,但这是一个有价值的语法,在第十章中我们会介绍一般类型和特性,那时你会看到在某些场景下,多impl
代码块非常有用。
结构允许你创建一个在域中有明确意义的自定义类型;使用结构,我们可以使得有关联关系的数据紧密连接,并且使得代码结构更为清晰;方法函数能够让你为你的结构实例指定明确的行为;关联函数可以让你在没有实例的情况下,使用结构一些功能。
但是结构并不是你创建自定义类型的唯一方法:下一章我们将介绍Rust中的枚举功能,将另一件强大的工具加入你的豪华午餐。