深入了解Rust 结构体的使用
古明地觉 人气:0楔子
结构体是一种自定义的数据类型,它允许我们将多个不同的类型组合成一个整体。下面我们就来学习如何定义和使用结构体,并对比元组与结构体之间的异同。后续我们还会讨论如何定义方法和关联函数,它们可以指定那些与结构体数据相关的行为。
定义并实例化结构体
结构体与我们之前讨论过的元组有些相似,和元组一样,结构体中的数据可以拥有不同的类型。而和元组不一样的是,结构体需要给每个数据赋予名字以便清楚地表明它们的意义。正是由于有了这些名字,结构体的使用要比元组更加灵活:你不再需要依赖顺序索引来指定或访问实例中的值。
关键字 struct 被用来定义并命名结构体,一个良好的结构体名称应当能够反映出自身数据组合的意义。除此之外,我们还需要在随后的花括号中声明所有数据的名字及类型,举个例子:
struct Girl { name: String, age: u8, email: String, }
为了使用定义好的结构体,我们需要为每个字段赋予具体的值来创建结构体实例,可以通过声明结构体名称,并使用一对大括号包含键值对的方式来创建实例。其中的键对应字段的名字,而值则对应我们想要在这些字段中存储的数据。
let g = Girl { name: String::from("古明地觉"), age: 16, email: String::from("satori@komeiji.com"), };
注意:字段的赋值顺序和在结构体中的声明顺序并不需要保持一致,换句话说,结构体的定义就像类型的通用模板一样,当我们将具体的数据填入模板时就创建出了新的实例。
在获得了结构体实例后,我们可以通过点号来访问实例中的特定字段,比如你想获得某个 Girl 的电子邮件地址,那么可以使用 g.email 来获取。另外,如果这个结构体的实例是可变的,那么我们还可以通过点号来修改字段中的值。
struct Girl { name: String, age: u8, email: String, } fn main() { let mut g = Girl { name: String::from("古明地觉"), age: 16, email: String::from("satori@komeiji.com"), }; println!("g.email = {}", g.email); // g.email = satori@komeiji.com g.email = String::from("satori@komeiji123.com"); println!("g.email = {}", g.email); // g.email = satori@komeiji123.com }
需要注意的是,一旦实例可变,那么实例中的所有字段也将是可变的。比如代码中的变量 g 声明为 mut,那么不仅它本身是可变的(可以赋值一个新的结构体实例给它),它内部的字段也是可变的(可以对内部的字段进行修改)。
这和我们之前介绍的数组和元组类似,对于任意一个复合类型的变量来说,不管是重新赋值,还是修改内部的某个元素,都要求变量必须是可变的。
当然结构体实例也如同其它表达式一样,我们可以在函数体的最后一个表达式中构造结构体实例,来隐式地将这个实例作为结果返回。
struct Girl { name: String, age: u8, email: String, } fn build_girl(name: String, age: u8, email: String) -> Girl { Girl { name: name, age: age, email: email, } } fn main() { let g = build_girl( String::from("古明地觉"), 16, String::from("satori@komeiji.com"), ); println!("{} {} {}", g.name, g.age, g.email); // 古明地觉 16 satori@komeiji.com }
在函数中使用与结构体字段名相同的参数名可以让代码更加易于阅读,但 name, age, email 同时作为字段名和变量名被书写了两次,则显得有些烦琐了,特别是当结构体拥有较多字段时,为此 Rust 提供了一个简便的写法。
简化版的实例化方式
由于上个例子中的参数与结构体字段拥有完全一致的名称,所以有些啰嗦。而如果你 IDE 比较智能的话,应该会给出提示:
所以我们可以使用名为字段初始化简写(field init shorthand)的语法来重构 build_girl 函数。这种语法不会改变函数的行为,但却能让我们免于在代码中重复书写。
fn build_girl(name: String, age: u8, email: String) -> Girl { Girl { age, name, email } }
build_girl 函数中使用了相同的参数名与字段名,并采用了字段初始化简写的语法进行编写。注意:这里顺序不要求一致,变量会自动赋给和自己名字相同的字段。如果变量名和结构体字段名不同,那么在赋值的时候必须指定字段名。
fn build_girl(name_xxx: String, age_xxx: u8, email_xxx: String) -> Girl { Girl { name: name_xxx, age: age_xxx, email: email_xxx, } }
这里我们故意在变量名的结尾后面加上了 _xxx,它们和结构体字段不相同,此时必须指定字段名。可能有人想到了 C 语言,那么下面这种赋值方式可不可以呢?
在 C 和 Go 里面是可以的,如果不指定字段名,那么会将传递的变量按照顺序分别赋给结构体的每一个字段。但在 Rust 里面是不可以的,IDE 也给出了提示,Rust 要求构造结构体实例的时候必须指定字段名,除非变量名和字段名一致。比如下面这个例子:
age 变量和结构体的 age 字段名称一致,那么 age 变量会赋值给 age 字段,而其它变量和结构体字段的名称不一致,因此赋值的时候必须指定字段名,并且赋值的时候不用考虑顺序。
基于已有结构体实例创建
在很多时候,新创建的结构实例中,除了需要修改的小部分字段,其余字段的值与某个旧结构体实例完全相同,于是我们可以使用结构体更新语法来快速实现此类新实例的创建。先来看看最直接的创建方法:
struct Girl { name: String, age: u8, email: String, } fn main() { let g1 = Girl { name: String::from("古明地觉"), age: 16, email: String::from("satori@komeiji.com"), }; let g2 = Girl { name: String::from("古明地觉"), age: 16, email: String::from("satori@komeiji123.com"), }; }
非常直接,在创建新结构体实例的时候直接初始化每一个字段即可,但问题是新创建的 g2 的 name, age 和已经存在的 g1 是一样的,我们没必要重新写一遍。所以此时可以使用结构体更新语法,来根据 g1 创建 g2,举个例子。
fn main() { let g1 = Girl { name: String::from("古明地觉"), age: 16, email: String::from("satori@komeiji.com"), }; let g2 = Girl { email: String::from("satori@komeiji123.com"), ..g1 }; }
我们只修改 email,因此 email 单独赋值,剩余的字段和 g1 保持一致。可以使用 ..g1 来表示剩下的那些还未被显式赋值的字段,都和给定的结构体实例 g1 一样拥有相同的值。
并且需要注意,当使用 ..g1 这种形式时,它一定要放在最后面。当然啦,如果你不习惯 Rust 提供的这种语法的话,也可以使用最传统的方式。
这种做法也是可以的,只不过此时必须要显式指定字段名。因为 Rust 规定只有传递和字段名相同的变量时,才可以省略字段名。而 g1.name, g1.age 显然和字段名不相同,所以此时字段名不可以省略。
元组结构体
除了上面的方式之外,还可以使用另外一种类似于元组的方式定义结构体,这种结构体也被称作元组结构体。元组结构体同样拥有用于表明自身含义的名称,但你无须在声明时对其字段进行命名,仅保留字段的类型即可。
一般来说,当你想要给元组赋予名字,并使其区别于其它拥有同样定义的元组时,就可以使用元组结构体。在这种情况下,像常规结构体那样为每个字段命名反而显得有些烦琐和形式化了。
struct Color(i32, i32, i32); struct Pointer(i32, i32, i32); fn main() { let black = Color(0, 0, 0); let origin = Pointer(0, 0, 0); }
定义元组结构体时依然使用 struct 关键字开头,并由结构体名称及元组中的类型组成,以上的代码中展示了两个分别叫作 Color 和 Point 的元组结构体定义。
然后基于这两个结构体,创建了两个变量 black 和 origin。但要注意它们是不同的类型,因为它们是不同的元组结构体的实例。我们所定义的每一个结构体都拥有自己的类型,即便结构体中的字段是完全相同的。
例如,一个以 Color 类型作为参数的函数不能合法地接收 Point 类型的变量,即使它们都是由 3 个 i32 组成的。除此之外,元组结构体实例的行为就像元组一样:你可以通过模式匹配将它们解构为单独的部分,也可以通过 . 模式用索引来访问特定字段。
没有字段的空结构体
也许会出乎你的意料,Rust 允许我们创建没有任何字段的结构体。因为这种结构体与空元组十分相似,所以它们也被称为空结构体。当你想要在某些类型上实现一个 trait,却不需要在该类型中存储任何数据时,空结构体就可以发挥相应的作用。
关于这里的 trait,后续会详细介绍。
// 元组结构体 // 里面只需要指定类型 struct Color(); // 普通的结构体 // 里面需要同时指定字段名和类型 struct Girl {} // 但以上两个结构体都是空结构体 fn main() { let color = Color(); let g = Girl {}; }
如果你有过 Go 的使用经验的话,你会发现当需要往 channel 里面发送数据,让其它 goroutine 解除阻塞的时候,一般也都会发一个空结构体实例进去。因为空结构体实例的大小是 0,在协调事件通信的时候省内存。
总之当我们需要用一个结构体去做一些事情,但又不需要它存储数据的时候,就可以使用空结构体。
结构体数据的所有权
上面的结构体定义中,我们使用了自持所有权的 String 类型而不是 &String 和 &str,这是一个有意为之的选择。因为默认情况下,结构体的内部不可以持有其它数据的引用。
这么做的原因也很简单,假设结构体实例存储了变量 a 的引用,但某个时刻变量 a 离开了作用域,那么相应的内存会被回收,而该结构体实例再通过引用访问的时候就会报错,因为可能会访问非法的内存。所以我们希望这个结构体实例拥有自身全部数据的所有权,而在这种情形下,只要结构体是有效的,那么它携带的数据也全部都是有效的。
struct Girl { name: &String, age: u8, email: &str, }
这段代码没办法通过检查,Rust 会在编译过程中报错,提示我们应该指定生命周期:
正如上面说的那样,如果结构体实例的内部持有某个变量的引用,那么当结构体实例存活时,变量也必须存活,否则该结构体就有可能访问非法的内存。
所以默认情况下,结构体内部不能持有引用,如果想持有,那么必须指定生命周期。通过生命周期来保证结构体实例中引用的数据的寿命不短于实例本身,从而让结构体实例在自己的有效期内都能合法访问引用的数据。
生命周期是 Rust 中的一个独有的概念,非常重要,我们后面说,目前就先使用 String 吧。
使用结构体的示例程序
为了能够了解结构体的使用时机,让我们来编写一个计算矩形面积的程序,并给出多个方案,看看哪种方案最好。
fn get_area1(width: u32, height: u32) -> u32 { width * height } fn get_area2(dimension: (u32, u32)) -> u32 { dimension.0 * dimension.1 } struct Rectangle { width: u32, height: u32, } fn get_area3(rectangle: &Rectangle) -> u32 { rectangle.width * rectangle.height }
以上三个函数都可以计算矩形的面积,那么哪种最好呢?
首先矩形的长和宽是互相关联的两个数据,但第一个函数却有着两个不同的参数,并且没有任何一点能够表明这两个参数存在关联。
第二个函数要求将长和宽组合成一个元组传过来,它的效果稍微要好一些,使得输入的参数结构化了。但与此同时程序也变得难以阅读了,因为元组并不会给出其中元素的名字,我们可能会对使用索引获取的值产生困惑和混淆。
在计算面积时,混淆宽度和高度的使用似乎没有什么问题,但当我们需要将这个矩形绘制到屏幕上时,这样的混淆就会出问题了。我们必须牢牢地记住,元素的索引 0 对应了宽度 width,而索引 1 则对应了高度 height。由于没有在代码里表明数据的意义,我们总是会因为忘记或弄混这些不同含义的值而导致各种程序错误。
于是便有了第三个函数,它接收一个结构体的引用。使用结构体无疑是最好的方式,我们会分别给结构体本身及它的每个字段赋予名字,而无须使用类似于元组索引的 0 或 1,这样就更加清晰了。
但要注意的是,get_area3 接收的是结构体的引用,而且是不可变引用。正如我们之前提到的,在函数签名和调用过程中使用 & 是因为我们希望借用结构体,而不是获取它的所有权,这样调用方在函数执行完毕后还可以继续使用它。
通过派生 trait 增加实用功能
需要说明的是,结构体实例默认是不可以打印的。
我们知道宏 println! 可以执行多种不同的文本格式化命令,而作为默认选项,格式化文本中的花括号会告知 println! 使用名为 Display 的格式化方法:这类输出可以直接被展示给终端用户。我们目前接触过的所有基础类型都默认实现了 Display,因为当你想要给用户展示类似 1、3.14 这种基础类型时没有太多可供选择的方式。
但对于结构体而言,println! 则无法确定应该使用什么样的格式化内容:在输出的时候需要逗号吗?需要打印花括号吗?所有的字段都要被展示吗?正是由于这种不确定性,Rust 没有为结构体提供默认的 Display 实现。
那如果像元组那样使用 {:?} 这种形式可以吗?我们来试一下。
我们看到也不行,但提示我们原因是 Rectangle 没有实现 Debug 这个 trait,那么如何实现呢?
#[derive(Debug)] struct Rectangle { width: u32, height: u32, } fn main () { let rect = Rectangle{ width: 30, height: 50 }; println!("{:?}", rect); println!("{:#?}", rect); /* area = Rectangle { width: 30, height: 50 } area = Rectangle { width: 30, height: 50, } */ }
以上就成功输出了,和元组一样只能使用 {:?} 和 {:#?} 来打印,但是需要添加注解来派生 Debug trait。实际上,Rust 提供了许多可以通过 derive 注解来派生的 trait,它们可以为自定义的类型增加许多有用的功能。
这里的 trait 到底是啥,后续会详细说,目前先知道有这么东西、以及怎么让结构体实例能够打印即可。
加载全部内容