结构体之一

2024-05-06 15:50:38 浏览数 (2)

新四皇-千两道化-巴基

很久以前,当牧羊人想要了解两个羊群是否相似时,会挨个对它们进行比对。 ——John C. Baez,James Dolan,“Categorification”

Rust 中的结构体(struct/structure)类似于 C 和 C 中的 struct 类型、Python 中的类和 JavaScript 中的对象。结构体会将多个不同类型的值组合成一个单一的值,以便你能把它们作为一个单元来处理。给定一个结构体,你可以读取和修改它的各个组件。结构体也可以具有关联的方法,以对其组件进行操作。

笔记 结构体在实际开发使用中将非常高频

Rust 有 3 种结构体类型:具名字段型结构体元组型结构体单元型结构体。这 3 种结构体在引用组件的方式上有所不同:具名字段型结构体会为每个组件命名;元组型结构体会按组件出现的顺序标识它们;单元型结构体则根本没有组件。单元型结构体虽然不常见,但它们比你想象的更有用。

本文将详细解释每种类型并展示它们在内存中的样子;介绍如何向它们添加方法、如何定义适用于不同组件类型的泛型结构体类型,以及如何让 Rust 为你的结构体生成常见的便捷特型的实现。

9.1 具名字段型结构体

具名字段型结构体的定义如下所示:

代码语言:rust复制
    /// 由8位灰度像素组成的矩形
    struct GrayscaleMap {
        pixels: Vec<u8>,
        size: (usize, usize)
    }

它声明了一个 GrayscaleMap 类型,其中包含两个给定类型的字段,分别名为 pixels 和 size。Rust 中的约定是,所有类型(包括结构体)的名称都将每个单词的第一个字母大写(如 GrayscaleMap),这称为大驼峰格式(CamelCase 或 PascalCase)。字段和方法是小写的,单词之间用下划线分隔,这称为蛇形格式(snake_case)。

你可以使用结构体表达式构造出此类型的值,如下所示:

代码语言:rust复制
    let width = 1024;
    let height = 576;
    let image = GrayscaleMap {
        pixels: vec![0; width * height],
        size: (width, height)
    };

结构体表达式以类型名称(GrayscaleMap)开头,后跟一对花括号,其中列出了每个字段的名称和值。还有用来从与字段同名的局部变量或参数填充字段的简写形式:

代码语言:rust复制
    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 语法,而对同一结构体表达式中的其他字段使用简写语法。

要访问结构体的字段,请使用我们熟悉的 . 运算符:

代码语言:rust复制
    assert_eq!(image.size, (1024, 576));
    assert_eq!(image.pixels.len(), 1024 * 576);

与所有其他语法项一样,结构体默认情况下是私有的,仅在声明它们的模块及其子模块中可见。你可以通过在结构体的定义前加上 pub 来使结构体在其模块外部可见。结构体中的每个字段默认情况下也是私有的:

代码语言:rust复制
    /// 由8位灰度像素组成的矩形
    pub struct GrayscaleMap {
        pub pixels: Vec<u8>,
        pub size: (usize, usize)
    }

即使一个结构体声明为 pub,它的字段也可以是私有的:

代码语言:rust复制
    /// 由8位灰度像素组成的矩形
    pub struct GrayscaleMap {
        pixels: Vec<u8>,
        size: (usize, usize)
    }

其他模块可以使用此结构体及其任何公共的关联函数,但不能按名称访问私有字段或使用结构体表达式来创建新的 GrayscaleMap 值。也就是说,要创建结构体型的值,就需要结构体的所有字段都可见。这就是为什么你不能编写结构体表达式来创建新的 String 或 Vec。这些标准类型都是结构体,但它们的所有字段都是私有的。如果想创建一个值,就必须使用公共的类型关联函数,比如 Vec::new()

创建具名字段结构体的值时,可以使用另一个相同类型的结构体为省略的那些字段提供值。在结构体表达式中,如果具名字段后面跟着 .. EXPR,则任何未提及的字段都会从 EXPR(必须是相同结构体类型的另一个值)中获取它们的值。假设我们有一个代表游戏中怪物的结构体:

代码语言:rust复制
    // 在这个游戏中,怪物是一些扫帚。你会看到:
    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(《魔法师的学徒》):一个新手魔法师对一把扫帚施了魔法,让它为自己工作,但工作完成后不知道如何让它停下来。于是,他用斧头将扫帚砍成了两半,结果一把扫帚变成了两把,虽然每把扫帚的大小只有原始扫帚的一半,但仍然具有和原始扫帚一样的“工作热情”。

代码语言:rust复制
    // 按值接收输入的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)
    }

有了这个定义,我们就可以制作一把扫帚,把它一分为二,然后看看会得到什么:

代码语言:rust复制
    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 获得了修改后的名字,长度只有原来的一半,但生命值都跟原始扫帚一样。

9.2 元组型结构体

第二种结构体类型称为元组型结构体,因为它类似于元组:

代码语言:rust复制
    struct Bounds(usize, usize);

构造此类型的值与构造元组非常相似,只是必须包含结构体名称:

代码语言:rust复制
    let image_bounds = Bounds(1024, 768);

元组型结构体保存的值称为元素,就像元组的值一样。你可以像访问元组一样访问它们:

代码语言:rust复制
    assert_eq!(image_bounds.0 * image_bounds.1, 786432);

元组型结构体的单个元素可以是公共的,也可以不是:

代码语言:rust复制
    pub struct Bounds(pub usize, pub usize);

表达式 Bounds(1024, 768) 看起来像一个函数调用,实际上它确实是,即定义这种类型时也隐式定义了一个函数:

代码语言:rust复制
    fn Bounds(elem0: usize, elem1: usize) -> Bounds { ... }

在最基本的层面上,具名字段型结构体和元组型结构体非常相似。选择使用哪一个需要考虑易读性、无歧义性和简洁性。如果你喜欢用 . 运算符来获取值的各个组件,那么用名称来标识字段就能为读者提供更多信息,并且更容易防范拼写错误。如果你通常使用模式匹配来查找这些元素,那么元组型结构体会更好用。

元组型结构体适用于创造新类型(newtype),即建立一个只包含单组件的结构体,以获得更严格的类型检查。如果你正在使用纯 ASCII 文本,那么可以像下面这样定义一个新类型:

代码语言:rust复制
    struct Ascii(Vec<u8>);

将此类型用于 ASCII 字符串比简单地传递 Vec<u8> 缓冲区并在注释中解释它们的内容要好得多。在将其他类型的字节缓冲区传给需要 ASCII 文本的函数时,这种新类型能帮 Rust 捕获错误。我们会在第 22 章中给出一个使用新类型进行高效类型转换的例子。

9.3 单元型结构体

第三种结构体有点儿晦涩难懂,因为它声明了一个根本没有元素的结构体类型:

代码语言:rust复制
    struct Onesuch;

这种类型的值不占用内存,很像单元类型 ()。Rust 既不会在内存中实际存储单元型结构体的值,也不会生成代码来对它们进行操作,因为仅通过值的类型它就能知道关于值的所有信息。但从逻辑上讲,空结构体是一种可以像其他任何类型一样有值的类型。或者更准确地说,空结构体是一种只有一个值的类型:

代码语言:rust复制
    let o = Onesuch;

在阅读 6.10 节中有关 .. 范围运算符的内容时,你已经遇到过单元型结构体。像 3..5 这样的表达式是结构体值 Range { start: 3, end: 5 } 的简写形式,而表达式 ..(一个省略两个端点的范围)是单元型结构体值 RangeFull 的简写形式。

单元型结构体在处理特型时也很有用,第 11 章会对此进行描述。

笔记 目前来看,关于3种结构体的类型,使用频率最高的是具名字段型结构体,其次是元组型结构体,最后单元型结构体 还不清楚它的具体使用场景在哪

9.4 结构体布局

在内存中,具名字段型结构体和元组型结构体是一样的:值(可能是混合类型)的集合以特定方式在内存中布局。例如,在本章前面我们定义了下面这个结构体:

代码语言:rust复制
    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 章会对此进行详细介绍。

9.5 用 impl 定义方法

在本书中,我们一直在对各种值调用方法,比如使用 v.push(e) 将元素推送到向量上、使用 v.len() 获取向量的长度、使用 r.expect("msg") 检查 Result 值是否有错误,等等。你也可以在自己的结构体类型上定义方法。Rust 方法不会像 C 或 Java 中的方法那样出现在结构体定义中,而是会出现在单独的 impl 块中。

impl 块只是 fn 定义的集合,每个定义都会成为块顶部命名的结构体类型上的一个方法。例如,这里我们定义了一个公共的 Queue 结构体,然后为它定义了 push 和 pop 这两个公共方法:

代码语言:rust复制
    /// 字符的先入先出队列
    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: Queueself: &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 了:

代码语言:rust复制
    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,那么可以将其定义为接受共享引用:

代码语言:rust复制
    impl Queue {
        pub fn is_empty(&self) -> bool {
            self.older.is_empty() && self.younger.is_empty()
        }
    }

同样,方法调用表达式知道要借用哪种引用:

代码语言:rust复制
    assert!(q.is_empty());
    q.push('⊙');
    assert!(!q.is_empty());

或者,如果一个方法想要获取 self 的所有权,就可以通过值来获取 self

代码语言:rust复制
    impl Queue {
        pub fn split(self) -> (Vec<char>, Vec<char>) {
            (self.older, self.younger)
        }
    }

调用这个 split 方法看上去和调用其他方法是一样的:

代码语言:rust复制
    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

9.5.1 以 BoxRc 或 Arc 形式传入 self

方法的 self 参数也可以是 Box<Self> 类型、Rc<Self> 类型或 Arc<Self> 类型。这种方法只能在给定的指针类型值上调用。调用该方法会将指针的所有权传给它。

你通常不需要这么做。如果一个方法期望通过引用接受 self,那它在任何指针类型上调用时都可以正常工作:

代码语言:rust复制
    let mut bq = Box::new(Queue::new());

    // `Queue::push`需要一个`&mut Queue`,但`bq`是一个`Box<Queue>`
    // 这没问题:Rust在调用期间从`Box`借入了`&mut Queue`
    bq.push('■');

对于方法调用和字段访问,Rust 会自动从 BoxRcArc 等指针类型中借入引用,因此 &self 和 &mut self 几乎总是(偶尔也会用一下 self)方法签名里的正确选择。

但是如果某些方法确实需要获取指向 Self 的指针的所有权,并且其调用者手头恰好有这样一个指针,那么 Rust 也允许你将它作为方法的 self 参数传入。为此,你必须明确写出 self 的类型,就好像它是普通参数一样。

代码语言:rust复制
    impl Node {
        fn append_to(self: Rc<Self>, parent: &mut Node) {
            parent.children.push(self);
        }
    }

9.5.2 类型关联函数

给定类型的 impl 块还可以定义根本不以 self 为参数的函数。这些函数仍然是关联函数,因为它们在 impl 块中,但它们不是方法,因为它们不接受 self 参数。为了将它们与方法区分开来,我们称其为类型关联函数

它们通常用于提供构造函数,如下所示:

代码语言:rust复制
    impl Queue {
        pub fn new() -> Queue {
            Queue { older: Vec::new(), younger: Vec::new() }
        }
    }

要使用此函数,需要写成 Queue::new,即类型名称 双冒号 函数名称。现在我们的示例代码简洁一点儿了:

代码语言:rust复制
    let mut q = Queue::new();

    q.push('*');
    ...

在 Rust 中,构造函数通常按惯例命名为 new,我们已经见过 Vec::newBox::newHashMap::new 等。但是 new 这个名字并没有什么特别之处,它不是关键字。类型通常还有其他关联函数作为构造函数,比如 Vec::with_capacity

虽然对于一个类型可以有许多独立的 impl 块,但它们必须都在定义该类型的同一个 crate 中。不过,Rust 确实允许你将自己的方法附加到其他类型中,第 11 章会解释具体做法。

如果你习惯了用 C 或 Java,那么将类型的方法与其定义分开可能看起来很不寻常,但这样做有几个优点。

  • 找出一个类型的数据成员总是很容易。在大型 C 类定义中,你可能需要浏览数百行成员函数的定义才能确保没有遗漏该类的任何数据成员,而在 Rust 中,它们都在同一个地方。
  • 尽管可以把方法放到具名字段型结构体中,但对元组型结构体和单元型结构体来说这看上去不那么简洁。将方法提取到一个 impl 块中可以让所有这 3 种结构体使用同一套语法。事实上,Rust 还使用相同的语法在根本不是结构体的类型(比如 enum 类型和像 i32 这样的原始类型)上定义方法。(任何类型都可以有方法,这是 Rust 很少使用对象这个术语的原因之一,它更喜欢将所有东西都称为。)
  • 同样的 impl 语法也可以巧妙地用于实现特型,后续章节会对此进行介绍。

笔记 任何类型都可以有方法,这是 Rust 很少使用对象这个术语的原因之一,它更喜欢将所有东西都称为。 在Rust中是不是可以称作 面向值 编程? 欢迎大家讨论交流 Rust,如果喜欢本文章或感觉文章有用,动动你那发财的小手点个赞再走呗 ^_^  微信公众号:草帽Lufei

我正在参与2024腾讯技术创作特训营最新征文,快来和我瓜分大奖!

0 人点赞