Rust入门系列:12、看到Rust中的结构体再也不害怕了

  • Louis
  • 更新于 2024-07-01 17:17
  • 阅读 328

结构体struct是复合数据结构,它是由其它数据类型组合而来。其它语言也有类似的数据结构,不过可能有不同的名称,例如object、record等。

基本概念

结构体 struct 是复合数据结构,它是由其它数据类型组合而来。 其它语言也有类似的数据结构,不过可能有不同的名称,例如 object、 record 等。

结构体可以为内部的每个字段起一个富有含义的名称。因此结构体灵活且强大,你无需依赖这些字段的顺序来访问和解析它们。

结构体语法

定义结构体

一个结构体由几部分组成:

  • 通过关键字 struct 定义
  • 一个清晰明确的结构体 名称
  • 几个有名字的结构体 字段

例如, 以下结构体定义了某网站的用户:

struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

该结构体名称是 User,拥有 4 个字段,且每个字段都有对应的字段名及类型声明,例如 username 代表了用户名,是一个可变的 String 类型。

创建结构体实例

为了使用上述结构体,我们需要创建 User 结构体的实例

let user1 = User {
    email: String::from("someone@example.com"),
    username: String::from("someusername123"),
    active: true,
    sign_in_count: 1,
};

有几点值得注意:

  1. 初始化实例时,每个字段都需要进行初始化
  2. 初始化时的字段顺序不需要和结构体定义时的顺序一致

访问结构体字段

通过 . 操作符即可访问结构体实例内部的字段值,也可以修改它们:

let mut user1 = User {
    email: String::from("someone@example.com"),
    username: String::from("someusername123"),
    active: true,
    sign_in_count: 1,
};

user1.email = String::from("anotheremail@example.com");

需要注意的是,必须要将结构体实例声明为可变的,才能修改其中的字段,Rust不支持将某个结构体某个字段标记为可变。

简化结构体创建

下面的函数类似一个构建函数,返回了 User 结构体的实例:

fn build_user(email: String, username: String) -> User {
    User {
        email: email,
        username: username,
        active: true,
        sign_in_count: 1,
    }
}

它接收两个字符串参数: email 和 username,然后使用它们来创建一个 User 结构体,并且返回。可以注意到这两行: email: email 和 username: username,非常的扎眼,因为实在有些啰嗦,如果你从 TypeScript 过来,肯定会鄙视 Rust 一番,不过好在,它也不是无可救药:

fn build_user(email: String, username: String) -> User {
    User {
        email,
        username,
        active: true,
        sign_in_count: 1,
    }
}

如上所示,当函数参数和结构体字段同名时,可以直接使用缩略的方式进行初始化,跟 TypeScript 中一模一样。

结构体更新语法

在实际场景中,有一种情况很常见:根据已有的结构体实例,创建新的结构体实例,例如根据已有的 user1 实例来构建 user2

let user2 = User {
    active: user1.active,
    username: user1.username,
    email: String::from("another@example.com"),
    sign_in_count: user1.sign_in_count,
};

老话重提,如果你从 TypeScript 过来,肯定觉得啰嗦爆了:竟然手动把 user1 的三个字段逐个赋值给 user2,好在 Rust 为我们提供了 结构体更新语法

let user2 = User {
    email: String::from("another@example.com"),
    ..user1
};

因为 user2 仅仅在 email 上与 user1 不同,因此我们只需要对 email 进行赋值,剩下的通过结构体更新语法 ..user1 即可完成。

.. 语法表明凡是我们没有显式声明的字段,全部从 user1 中自动获取。需要注意的是 ..user1 必须在结构体的尾部使用。

结构体更新语法跟赋值语句 = 非常相像,因此在上面代码中,user1 的部分字段所有权被转移到 user2 中:username 字段发生了所有权转移,作为结果,user1 无法再被使用。 聪明的读者肯定要发问了:明明有三个字段进行了自动赋值,为何只有 username 发生了所有权转移? 仔细回想一下所有权那一节的内容,我们提到了 Copy 特征:实现了 Copy 特征的类型无需所有权转移,可以直接在赋值时进行 数据拷贝,其中 bool 和 u64 类型就实现了 Copy 特征,因此 active 和 sign_in_count 字段在赋值给 user2 时,仅仅发生了拷贝,而不是所有权转移。 值得注意的是:username 所有权被转移给了 user2,导致了 user1 无法再被使用,但是并不代表 user1 内部的其它字段不能被继续使用,例如:

let user1 = User {
    email: String::from("someone@example.com"),
    username: String::from("someusername123"),
    active: true,
    sign_in_count: 1,
};
let user2 = User {
    active: user1.active,
    username: user1.username,
    email: String::from("another@example.com"),
    sign_in_count: user1.sign_in_count,
};
println!("{}", user1.active);
// 下面这行会报错
println!("{:?}", user1);

结构体的内存排列

先来看以下代码

#[derive(Debug)]
 struct File {
   name: String,
   data: Vec<u8>,
 }

 fn main() {
   let f1 = File {
     name: String::from("f1.txt"),
     data: Vec::new(),
   };

   let f1_name = &f1.name;
   let f1_length = &f1.data.len();

   println!("{:?}", f1);
   println!("{} is {} bytes long", f1_name, f1_length);
 }

上面定义的File结构体在内存中的排列如下图所示:

v2-8cc4ed8cd06d60f974d06ca2199b8df5_1440w.png

从图中可以清晰地看出 File 结构体两个字段 name 和 data 分别拥有底层两个 [u8] 数组的所有权(String 类型的底层也是 [u8] 数组),通过 ptr 指针指向底层数组的内存地址,这里你可以把 ptr 指针理解为 Rust 中的引用类型。

该图片也侧面印证了:把结构体中具有所有权的字段转移出去之后,将无法再访问该字段,但是可以正常访问其他的字段。

元组结构体(Tuple Struct)

结构体必须要有名称,但是结构体的字段可以没有名称,这种结构体长得很像元组,因此被称为元组结构体,例如:

struct Color(i32, i32, i32);
struct Point(i32, i32, i32);

let black = Color(0, 0, 0);
let origin = Point(0, 0, 0);

元组结构体在你希望有一个整体名称,但是又不关心里面字段的名称时将非常有用。例如上面的 Point 元组结构体,众所周知 3D 点是 (x, y, z) 形式的坐标点,因此我们无需再为内部的字段逐一命名为:xyz

结构体数据所有权

在之前的 User 结构体的定义中,有一处细节:我们使用了自身拥有所有权的 String 类型而不是基于引用的 &str 字符串切片类型。这是一个有意而为之的选择:因为我们想要这个结构体拥有它所有的数据,而不是从其它地方借用数据。

你也可以让 User 结构体从其它对象借用数据,不过这么做,就需要引入生命周期这个新概念(也是一个复杂的概念),简而言之,生命周期能确保结构体的作用范围要比它所借用的数据的作用范围要小。

总之,如果你想在结构体中使用一个引用,就必须加上生命周期,否则就会报错:

struct User {
    username: &str,
    email: &str,
    sign_in_count: u64,
    active: bool,
}

fn main() {
    let user1 = User {
        email: "someone@example.com",
        username: "someusername123",
        active: true,
        sign_in_count: 1,
    };
}

编译器会抱怨它需要生命周期标识符:

error[E0106]: missing lifetime specifier
 --> src/main.rs:2:15
  |
2 |     username: &str,
  |               ^ expected named lifetime parameter // 需要一个生命周期
  |
help: consider introducing a named lifetime parameter // 考虑像下面的代码这样引入一个生命周期
  |
1 ~ struct User<'a> {
2 ~     username: &'a str,
  |

error[E0106]: missing lifetime specifier
 --> src/main.rs:3:12
  |
3 |     email: &str,
  |            ^ expected named lifetime parameter
  |
help: consider introducing a named lifetime parameter
  |
1 ~ struct User<'a> {
2 |     username: &str,
3 ~     email: &'a str,
  |

未来在生命周期中会讲到如何修复这个问题以便在结构体中存储引用,不过在那之前,我们会避免在结构体中使用引用类型。

使用-derivedebug-来打印结构体的信息

在前面的代码中我们使用 #[derive(Debug)] 对结构体进行了标记,这样才能使用 println!("{:?}", s); 的方式对其进行打印输出,如果不加,看看会发生什么:

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`

提示我们结构体 Rectangle 没有实现 Display 特征,这是因为如果我们使用 {} 来格式化输出,那对应的类型就必须实现 Display 特征,基本类型的数据,都默认实现了该特征:

fn main() {
    let v = 1;
    let b = true;

    println!("{}, {}", v, b);
}

上面代码不会报错,那么结构体为什么不默认实现 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);

可是依然无情报错了:

error[E0277]: `Rectangle` doesn't implement `Debug`

好在,聪明的编译器又一次给出了提示:

= help: the trait `Debug` is not implemented for `Rectangle`
= note: add `#[derive(Debug)]` to `Rectangle` or manually `impl Debug for Rectangle`

首先,Rust 默认不会为我们实现 Debug,为了实现,有两种方式可以选择:

  • 手动实现
  • 使用 derive 派生实现

来看看该如何使用:

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    println!("rect1 is {:?}", rect1);
}

此时运行程序,就不再有错误,输出如下:

$ cargo run
rect1 is Rectangle { width: 30, height: 50 }

这个输出格式看上去也不赖嘛,虽然未必是最好的。这种格式是 Rust 自动为我们提供的实现,看上基本就跟结构体的定义形式一样。

当结构体较大时,我们可能希望能够有更好的输出表现,此时可以使用 {:#?} 来替代 {:?},输出如下:

rect1 is Rectangle {
    width: 30,
    height: 50,
}

此时结构体的输出跟我们创建时候的代码几乎一模一样了!

还有一个简单的输出 debug 信息的方法,那就是使用 dbg! 宏,它会拿走表达式的所有权,然后打印出相应的文件名、行号等 debug 信息,当然还有我们需要的表达式的求值结果。除此之外,它最终还会把表达式值的所有权返回!

dbg! 输出到标准错误输出 stderr,而 println! 输出到标准输出 stdout

下面的例子中清晰的展示了 dbg! 如何在打印出信息的同时,还把表达式的值赋给了 width:

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let scale = 2;
    let rect1 = Rectangle {
        width: dbg!(30 * scale),
        height: 50,
    };

    dbg!(&rect1);
}

最终的 debug 输出如下:

$ cargo run
[src/main.rs:10] 30 * scale = 60
[src/main.rs:14] &rect1 = Rectangle {
    width: 60,
    height: 50,
}

可以看到,我们想要的 debug 信息几乎都有了:代码所在的文件名、行号、表达式以及表达式的值,简直完美!

最后

写文章不易,如果文章对您有帮助,欢迎点个赞,您的支持是我写作的最大动力。

相关资料、源码已同步github: github.com/MagicalBridge 欢迎star

点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
Louis
Louis
0x2b75...1A7D
区块链开发工程师,技术交流或者有工作机会可加VX: magicalLouis