开始使用Rust:结构
在几乎所有的编程语言中,你都可以创建数据结构--比如类或记录--将一组东西打包在一起。Rust也不例外,它的结构也是如此。
本文将告诉你如何在Rust中使用结构体。
在本文结束时,你会知道:
- 如何定义和实例化一个结构。
- 如何为结构派生出特性。
- 什么是结构方法,以及如何使用它们。
这篇文章的所有代码都可以在这里找到。
什么是Rust中的结构?
在Rust中,结构是一种自定义的数据类型,可以保存多个相关值。
让我们直接进入我们的第一个例子,定义Point --两个坐标的封装器:
// [1] [2]
struct Point {
// [3] [4]
x: i32,
y: i32,
}
// We
// [1]: tell Rust that we want to define a new struct
// [2]: named "Point"
// [3]: that contains two fields with names "x" and "y",
// [4]: both of type "i32".
元组结构
你可以使用元组结构将多个值组合在一起,而不需要对它们进行命名。
元组结构是这样定义的:
// [1] [2] [3]
struct Point(i32, i32);
// [1]: Struct name.
// [2]: First component / field of type i32.
// [3]: Second component of type i32.
如何创建一个结构的实例?
以下是我们之前定义的结构:
// Ordinary struct
struct Point {
x: i32,
y: i32,
}
// Tuple struct
struct PointTuple(i32, i32);
现在我们可以创建这些结构的实例--类型为Point 和PointTuple 的对象:
// [1]
let p_named = Point {
// [2] [3]
x: 13,
y: 37,
};
// [1]: Struct name.
// [2]: Field name.
// [3]: Field value.
// [1] [2] [3]
let p_unnamed = PointTuple(13, 37);
// [1]: Struct name.
// [2], [3]: Field values.
正如你所看到的,初始化语法是非常直接的。这就像定义一个JSON对象,其中键被字段名所取代。
有两件事要记住:
- 所有字段都必须有值。Rust中没有默认参数。
- 字段-值对的顺序并不重要。如果你愿意,你可以写
let p_strange_order = Point { y: 37, x: 13 };。
如何访问结构字段?
获取一个值
你可以通过点符号来查询字段的值:
let x = p_named.x;
let y = p_named.y;
有人可能会问:获取元组结构中某个特定字段的值的语法是什么?幸运的是,Rust选择了最简单的方法,即从0开始对元组组件进行索引:
let x = p_unnamed.0;
let y = p_unnamed.1;
设置一个值
使用点符号可以改变一个字段的值,但是结构变量必须定义为可变:
let mut p = PointTuple(1, 2);
// ^^^
p.0 = 1000;
assert_eq!(p.0, 1000);
与其他一些语言不同,Rust不允许将结构的特定字段标记为可变或不可变的:
struct Struct {
mutable: mut i32,
// ^^^
// Expected type, found keyword `mut`.
immutable: bool,
}
如果你对一个结构有一个不可变的绑定,你就不能改变它的任何字段。如果你有一个可变的绑定,你可以设置你想要的任何字段。
减少模板
在初始化结构字段时,有两个特点可以减少模板代码:
- 结构更新语法
- 字段初始化简写
为了说明这一点,我们首先需要定义一个新的结构,并使用常规语法创建一个实例:
struct Bicycle {
brand: String,
kind: String,
size: u16,
suspension: bool,
}
let b1 = Bicycle {
brand: String::from("Brand A"),
kind: String::from("Mtb"),
size: 56,
suspension: true,
};
结构更新语法
经常发生的情况是,您想复制一个结构的实例并修改其部分(但不是全部)值。想象一下,一个不同的自行车品牌生产了一个具有相同参数的模型,我们需要创建一个该模型的实例。
我们可以手动移动所有的值:
let b2 = Bicycle {
brand: String::from("Other brand"),
kind: b1.kind,
size: b1.size,
suspension: b1.suspension,
};
但这种方法涉及太多的代码。结构更新语法可以提供帮助:
let b2 = Bicycle {
brand: String::from("Other brand"),
..b1
// ^^ struct update syntax
};
..b1 告诉Rust,我们要把剩余的 '的字段从 。b2 b1
字段初始化简写
如果值是一个变量(或函数参数),其名称与字段相匹配,你可以在field: value 初始化语法中省略字段名称:
fn new_bicycle(brand: String, kind: String) -> Bicycle {
Bicycle {
brand,
// ^^^ instead of "brand: brand"
kind,
// ^^^ instead of "kind: kind"
size: 54,
suspension: false,
}
}
特质
好吧,让我们试着对我们的结构做一些事情。例如,打印它:
let p = Point { x: 0, y: 1 };
println!("{}", p);
// ^^^
// Compile error
突然,编译器说"Point" doesn't implement "std::fmt::Display" ,并建议我们使用{:?} ,而不是{} 。
好吧,为什么不呢?
let p = Point { x: 0, y: 1 };
println!("{:?}", p);
// ^^^
// Compile error
不幸的是,这并没有帮助。我们仍然得到错误信息。"Point" doesn't implement "Debug".
但我们也得到了一个有用的说明:
note: add `#[derive(Debug)]` to `Point` or manually `impl Debug for Point`
出现这个错误的原因是,Debug 和std::fmt::Display 都是特性。我们需要实现它们,以使Point 打印出该结构。
什么是trait?
特质类似于Java中的接口或Haskell中的类型分类。它定义了我们可能期望从一个给定类型中获得的某些功能。
通常情况下,trait声明了一个方法列表,这些方法可以在实现该trait的类型上调用。
这里有一个来自标准库的常用特质的列表:
在我们的例子中,Point 需要实现Debug trait,因为println!() 宏需要它。
有两种方法来实现一个特性:
- 派生实现:编译器能够通过
#[derive]宏为固定的特质列表提供基本的实现。 - 手动编写实现。
让我们从第一个选项开始。我们需要在Point 的定义前添加#[derive] ,并列出我们想要派生的特性:
#[derive(Debug, PartialEq, Eq)]
struct Point {
x: i32,
y: i32,
}
let p = Point { x: 0, y: 1 };
println!("{:?}", p);
现在编译器没有抱怨,所以让我们运行我们的程序:
> cargo run
Point { x: 0, y: 1 }
我们没有自己写任何与格式化Point ,但我们已经有了一个看起来不错的输出。很好!"。
Eq 和PartialEq
你可能已经注意到,我们还派生了Eq 和PartialEq 特质。
正因为如此,我们可以比较两个Points是否相等:
let a = Point { x: 25, y: 50 };
let b = Point { x: 100, y: 100 };
let c = Point { x: 25, y: 50 };
assert!(a == c);
assert!(a != b);
assert!(b != c);
手动实现特质
性状派生有其缺点:
这就是为什么我们可以自己实现一个特质。
实现Display
当我们试图使用"{}" 打印一个Point 实例时,编译器说Point 没有实现std::fmt::Display 。这个特质与std::fmt::Debug 相似,但它有一些不同之处:
- 它必须被手动实现。
- 它应该以更漂亮的方式格式化数值,不包含任何不必要的信息。
你可以把Debug 和Display 分别看作是程序员和用户的格式化器。
让我们添加一个手动实现的Display 。在括号里,我们必须定义特质中声明的每个函数。在我们的例子中,只有一个--fmt:
impl std::fmt::Display for Point {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "({}, {})", self.x, self.y)
}
}
现在我们可以为开发者和用户打印我们的Point 结构。
let p = Point::new(0, 1);
assert_eq!(format!("{:?}", p), "Point { x: 0, y: 0 }");
assert_eq!(format!("{}", p), "(0, 0)");
结构方法
就像在Java或C++中一样,你可以定义与某个特定类型相关的方法。这是在impl 块中完成的:
impl Point {
// Method that can't modify the struct instance
fn has_same_x(&self, other: &Self) -> bool {
self.x == other.x
}
// Method that can modify the struct instance
fn shift_right(&mut self, dx: i32) {
self.x += dx;
}
}
您可以通过点符号调用这些方法:
// Mutable binding
let mut point = Point { x: 25, y: 25 };
assert!(!point.has_same_x(&Point { x: 30, y: 25 }));
// Calling a method that changes the object state
point.shift_right(3);
assert_eq!(point.x, 28);
方法可以突变它们所关联的结构实例,但这需要&mut self 作为第一个参数。这样一来,它们就只能通过可变的绑定来调用。
自我
你可能已经注意到,方法的第一个参数是self 。它代表了方法被调用的结构的实例,类似于Python中的做法。这里涉及到一些语法糖。让我们分两步来分解它。
| 1 | fn has_same_x(&self, other: &Self) | fn shift_right(&mut self, dx: i32) |
| 2 | fn has_same_x(self: &Self, other: &Self) | fn shift_right(self: &mut Self, dx: i32) |
| 3 | fn has_same_x(self: &Point, other: &Point) | fn shift_right(self: &mut Point, dx: i32) |
不要把self 和Self 混淆。后者是impl 块的类型的别名。在我们的例子中,它是Point 。
关联函数
你也可以使用impl 块来定义不占用Self 实例的函数,比如Java中的静态函数。它们通常用于构造类型的新实例。
impl Point {
// Associated function that is not a method
fn new(x: i32, y: i32) -> Point {
Point { x, y }
}
}
你可以通过使用双分号来调用这些函数 (::)。
let point = Point::new(1, 2);
为什么使用方法而不是普通函数?
方法对于使你的代码变得更好是相当有用的。让我们来看看一些例子。
漂亮的点符号
为了调用一个类型的方法,我们使用点符号。
除了漂亮的语法,点符号还提供了一个额外的属性--自动引用,这意味着你可以写point.shift_right(3); ,而不是(&mut point).shift_right(3); 。
而函数则没有这个优势,你总是需要引用。
shift_right(&mut point, 3);
代码组织
所有的方法都放在一个或多个impl 块内。这意味着两件事。
首先,你不需要导入方法:
use my_module::MyStruct;
...
let s = MyStruct::new();
s.do_stuff();
s.do_other_stuff();
如果没有方法,就会像这样:
use my_module::{MyStruct, do_stuff, do_other_stuff}; // ugly
...
let s = MyStruct::new();
do_stuff(&s);
do_other_stuff(&s);
第二,方法是有名字的:你不会有跨类型的名字冲突:
my_rectangle.area()
my_circle.area()
如果没有方法,你就需要写成这样:
rectangle_area(my_rectangle)
circle_area(my_circle)
方法与特质
方法和特质有些相似,所以初学者可以把它们混为一谈。
对于初学者来说,何时使用哪种方法的一般规则很简单:
- 如果你正在实现一个存在特质的普通功能(转换为字符串、比较等),请尝试实现该特质。
- 如果你正在做一些你的应用程序的特殊功能,可能在常规的
impl块中使用方法。
一个方法只能在它所定义的类型上被调用。而特质则克服了这一限制,因为它们通常是为了被多种不同的类型所实现。
所以特质允许某些函数被泛化。这些函数并不是只接受一种类型,而是接受由特质约束的一组类型。
我们已经看到了这样的例子:
println!("{:?}", ...)不关心我们要打印什么样的对象,只要它实现了 特质。Debugassert_eq!(...)允许比较任何类型的对象,只要它们实现了 。PartialEq
总结
在这篇文章中,我们介绍了Rust中结构体的基础知识。我们探讨了定义、初始化以及为结构和元组结构添加实现块的方法。我们还研究了特征以及如何派生或实现它们。
结构不是创建自定义类型的唯一方法。Rust也有枚举。虽然我们在这篇博文中没有涉及它们,但方法、特征和派生等概念也可以以类似的方式应用于它们。
如果你想阅读更多关于Rust的初学者文章,请务必在Twitter上关注我们,或通过下面的表格订阅我们的通讯。