为了能够了解结构体的使用时机,让我们来共同编写一个计算长方形面积的程序。
我们会从使用变量开始,并逐渐将它重构为使用结构体的版本。使用 Cargo 创建一个叫作 rectangles 的二进制项目。
这个程序会接收以像素为单位的宽度和高度作为输入,并计算出对应的长方形面积。示例5-8展示了文件 src/main.rs 中实现的一段简单程序。
// 示例5-8:分别指定宽度和高度变量来计算长方形的面积
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.
尽管 示例5-8 中的程序成功地计算出了长方形的面积,但它还有可以改进的空间。这里的宽度和高度是相互关联的两个数据,它们两个组合在一起才能定义一个长方形。
示例5-8中的问题可以在 area 的签名中看到:
fn area(width: u32, height: u32) -> u32 {
area 函数被编写出来计算长方形的面积,但它却有两个不同的参数。这两个参数是相互关联的,但程序中却没有任何地方可以表现出这一点。
将宽度和高度放到一起能够使我们的代码更加易懂,也更加易于维护。我们曾经在前面文章中讨论过一种可行的组织方式:元组。
示例5-9 中展示了使用元组重构后的代码版本:
// 示例5-9:通过元组来指定长方形的宽度和高度
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了。
但从另一方面来讲,这个版本的程序变得难以阅读了。元组并不会给出其中元素的名字,我们可能会对使用索引获取的元组值产生困惑和混淆❷。
在计算面积时,混淆宽度和高度的使用似乎没有什么问题,但是当我们需要将这个长方形绘制到屏幕上时,这样的混淆就会出问题了!
我们必须牢牢地记住,元素的索引 0 对应了宽度 width,而索引1则对应了高度 height。如果有其他人想要接手这部分代码,那么他也不得不搞清楚并牢记这些规则。
在实际工作中,由于没有在代码里表明数据的意义,我们总是会因为忘记或弄混这些不同含义的值而导致各种程序错误。
我们可以使用结构体来为这些数据增加有意义的标签。在重构元组为结构体的过程中,我们会分别给结构体本身及它们的每个字段赋予名字,如 示例5-10 所示。
// 示例5-10:定义Rectangle结构体
❶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❶。随后,在花括号中依次定义了 u32 类型的字段 width 和 height❷。
接着,在 main 函数中创建了一个宽度为 30 和高度为 50 的 Rectangle 实例❸。现在,用于计算面积的 area 函数在被定义时只需要接收一个 rectangle 参数,它是结构体 Rectangle 实例的不可变借用❹。
正如我们之前提到过的,在函数签名和调用过程中使用&进行引用是因为我们希望借用结构体,而不是获取它的所有权,这样 main 函数就可以保留 rect1 的所有权并继续使用它。
area 函数会在执行时访问 Rectangle 实例的 width 和 height 字段❺。此时,area 的函数签名终于准确无误地明白了我们的意图:使用 width 和 height 这两个字段计算出 Rectangle 的面积。
Rectangle 结构体表明了宽度和高度是相互关联的两个值,并为这些值提供了描述性的名字,而无须使用类似于元组索引的 0 或 1。如此,我们的代码看起来就更加清晰了。
如果我们可以打印出 Rectangle 实例及其每个字段的值,那么调试代码的过程就会变得简单许多。你也许会试着使用之前接触过的 println! 宏来达到这个目的,如 示例5-11 所示,但它暂时还无法通过编译。
// 示例5-11:尝试打印出Rectangle实例
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! 宏可以执行多种不同的文本格式化命令,而作为默认选项,格式化文本中的花括号会告知 println! 使用名为 Display 的格式化方法:这类输出可以被展示给直接的终端用户。
我们目前接触过的所有基础类型都默认地实现了 Display,因为当你想要给用户展示 1 或其他基础类型时没有太多可供选择的方式。
但对于结构体而言,println! 则无法确定应该使用什么样的格式化内容:在输出的时候是否需要逗号?需要打印花括号吗?所有的字段都应当被展示吗?正是由于这种不确定性,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! 宏调用会类似于 println!("rect1 is {:?}, rect1); 。
我们把标识符号 :? 放入了花括号中,它会告知 println! 当前的结构体需要使用名为 Debug 的格式化输出。
Debug 是另外一种格式化 trait,它可以让我们在调试代码时以一种对开发者友好的形式打印出结构体。
修改完代码后再次尝试运行程序。让人沮丧的是,我们还是触发了一个错误:
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)] 注解,如 示例5-12 所示。
/* 示例5-12:添加注解来派生Debug trait,
并使用调试格式打印出Rectangle实例 */
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let rect1 = Rectangle { width: 30, height: 50 };
println!("rect1 is {:?}", rect1);
}
注意:这个 #[derive(Debug)] 注解是写在结构的上面,不是整篇代码的顶部,也不是其它位置。否则会报错。
现在,让我们再次运行程序。这下应该不会有任何错误了,我们将在程序成功运行后观察到如下所示的输出内容:
rect1 is Rectangle { width: 30, height: 50 }
真棒!这也许还不是最漂亮的输出,但它展示了实例中所有字段的值,这毫无疑问会对调试有帮助。
而对于某些更为复杂的结构体,你可能会希望调试的输出更加易读一些,为此我们可以将println! 字符串中的 {:?} 替换为 {:#?}。修改后的输出会变成下面的样子:
rect1 is Rectangle {
width: 30,
height: 50
}
实际上,Rust提供了许多可以通过 derive 注解来派生的 trait,它们可以为自定义的类型增加许多有用的功能。
我们会在后面学习如何通过自定义行为来实现这些 trait,以及创建新的 trait。这里的 area 函数其实是非常有针对性的:它只会输出长方形的面积。
既然它不能被用于其他类型,那么将其行为与 Rectangle 结构体本身结合得更加紧密一些可以帮助我们理解它的含义。