Rust:复合类型

Rust:复合类型


在编程中,我们经常需要将多个值组合在一起形成更有意义的数据单元。Rust 提供了一系列强大的复合类型工具,让我们能够以类型安全的方式构建复杂数据结构。


基础复合类型

元组 Tuple

元组是一种异构有限序列,异构是指元组内的元素可以是不同类型,有限是指元组的长度是固定的。

语法:

rust 复制代码
let t: (type1, type2, type3...) = (val1, val2, val3...);

将类型放在()内,每个类型之间用逗号隔开,这就是元组的类型。例如(i32, f64)就是一个类型,对应的该类型下的值,就是在()内每个位置放上对应的值,比如(15, 3.14)就是一个符合上述类型的元组。

通过下标可以访问一个元组内的元素:

rust 复制代码
let tuple: (i32, f64, bool) = (100, 3.14, true);

println!("第一个元素: {}", tuple.0); // 100
println!("第二个元素: {}", tuple.1); // 3.14
println!("第三个元素: {}", tuple.2); // true

通过tuple.0可以访问到第一个元素,以此类推。

元组中,最后一个元素末尾可以携带逗号,这是其它语言比较少见的设计。

以下四种写法都是正确的:

rust 复制代码
let tuple1: (i32, f64, bool) = (100, 3.14, true);
let tuple2: (i32, f64, bool,) = (100, 3.14, true);
let tuple3: (i32, f64, bool) = (100, 3.14, true,);
let tuple4: (i32, f64, bool,) = (100, 3.14, true,);

不论是元组的值,还是元组的类型,最后一个逗号都可以保留。在后续的结构体等地方,也有类似的设计。

git多次提交的时候,这种设计可以明确修改的行,比如:

以下是git的第一版本:

rust 复制代码
let tuple = (
			100, 
			3.14, 
			true,
);

如果后续某次对这个元组需要进行修改,添加一个新元素:

rust 复制代码
let tuple = (
			100, 
			3.14, 
			true,
			"hello", // diff
);

此时只会造成一行diff,很明确本次修改新增了一个元素。

如果是其他语言,那么要先在前一行添加逗号,下一行再写新元素,就会造成两行diff,但只有一行有实际意义的修改。

但是当元组只有一个元素的时候,必须携带末尾的逗号:

rust 复制代码
let tuple: (i32,) = (2025,);

类型和值末尾的逗号都不能省略,因为(2025)是一个表达式,小括号本身是用来调节计算顺序的,他不修改实际值。因此 (2025) == 2025,它们是全等的。

如果要实现单元素的元组,必须用一个末尾的逗号来表明这是一个元组,防止被解析为单个元素外套一层小括号。


模式匹配解构

元组支持一种特殊的语法解构赋值,它可以快速取出元组中的元素,赋值到变量上。

例如:

rust 复制代码
let point = (10, 20);
let (x, y) = point;  // 解构元组
println!("坐标: x={}, y={}", x, y); // 坐标: x=10, y=20

其中 let (x, y) = point 就是一个解构,它把point 这个元组的前两个元素,按位置赋值到变量xy上。

你也可以通过_来跳过某些元素:

rust 复制代码
let (first, _, third) = (1, "hello", true);
println!("first={}, third={}", first, third); // first=1, third=true

上例中,相当于忽略了元组中第二个元素,只提取第一个和第三个元素。


单元类型

当一个元组不含任何元素,也就是长度为0,称为空元组,也称为单元类型()

rust 复制代码
let unit: () = ();

单元类型 () 表示"没有有意义的值",常用于不需要返回值的函数或表达式中。它占用0字节,在内存中不实际存在。

比如当一个函数省略了返回值,或者一个表达式没有具体值,返回的都是(),所谓单元类型本质就是一个空元组。


数组 Array

数组是Rust内建的原始集合类型,它表示同类型、长度固定、有序的元素集合。

数组的类型为[T; N],其中T表示内部的类型,N表示数组的长度。

语法:

rust 复制代码
let arr: [T; N] = [val1, val2, val3...];
let arr: [T; N] = [val; N];  

数组有两种定义形式:

  • 第一种形式确定所有元素,每个val都必须是T类型的元素,而且必须有Nval
  • 第二种形式把所有元素都初始化为同一个值val,比如let arr = ["hello", 100]就是定义了一个一百个hello的数组。

Rust的原生数组通常定义在栈上,基于TN在编译期就可以确定内存占用情况。比如说[i32; 10]类型的数组,每个i32大小为4 byte,那么整个数组大小就是40 byte。为了保证其编译期可以确定大小的特性,Rust要求N必须在编译期可以求值。

例如:

rust 复制代码
const fn const_func() -> usize {
    10
}

const LEN: usize = 10;

let arr = [2025; 10];
let arr = [2025; LEN];
let arr = [2025; const_func()];

以上三个定义都是合法的,这里展示了三种最常见的编译期可以确定值的表达式:字面量const 常量CFTE 函数

但是比如说使用变量就是非法的:

rust 复制代码
let len: usize = 10;
let arr = [202; len];

此处的len是一个变量,用它初始化arr编译无法通过,因为编译期无法确定数组长度。


下标访问

与元组一样,数组也通过下标访问,从0开始。

语法:

rust 复制代码
let val = arr[pos];

其中pos是要访问的下标,比如:

rust 复制代码
let mut arr = [2025; 10];
arr[1] = 2026
let val = arr[1];

可以把arr[pos]放在等号左边,对指定元素进行赋值,当然前提是mut。也可以用其它变量接收指定下标的值。

Rust 的数组访问有严格的边界检查,编译时能发现的越界会直接报错,运行时越界会导致 panic

Rust对安全要求极高,对数组也是一样。之前说过,数组长度N是在编译期就可以确定的。如果你在pos位置传入的表达式也是编译期可以确定的,那么Rust在编译期就可以进行越界检查,防止访问未开辟的内存。

比如:

rust 复制代码
let arr = [2025; 10];
let val = arr[10]; // 编译失败

由于arr的下标范围是0 ~ 9arr[10]在编译期直接失败,减少了运行时错误。

当然这也不是万能的,比如说pos位置传入了一个非编译期求值的表达式:

rust 复制代码
let arr = [2025; 10];

let num = 10;
let val = arr[num]; // 编译成功,运行 panic

虽然num已经越界了,但是编译期无法确定num的值,只能等到运行时触发panic


字符串 str & String

Rust的字符串分为两种,原生字符串str和集合字符串String。不论哪一种,内部存储的都是utf8编码序列,也就是说每个字符的大小是不定的。

不论哪一种字符串,都视为动态大小类型来处理,比如说:

rust 复制代码
let s: &str = "hello"; // 正确
let s: str = "world";  // 错误

字符串字面量往往存储在静态区,而非栈区,因此在函数中想要访问到字面量,必须通过指针。

以上代码中,第一行使用&str获取胖指针,它是正确的,这是动态大小类型的最常见处理方案。但是第二行直接用str接受字符串,这是非法的,因为编译器无法确认字符串的长度。

有人可能就问了,为什么str是动态大小类型?定义的时候不是已知每一个字符,自然就知道整个字符串所需要的空间了吗?没错,对于一个字面量确实是可以知道其所需的空间大小的,但是问题在于str这个类型本身大小不确定。

比如说数组,它的类型是[T; N],它的大小是体现在类型上的,不同长度的数组有自己单独的类型,就视为这个类型是一个静态大小类型。比如说[1, 2, 3][1, 2]是不同的类型,它们的长度分别是12 byte8 byte

但是对于一个字符串,不论是"hello"还是"rust",它们的类型都是str,但是同一个类型str却可能是4 byte或者5 byte。那么这个str从类型上来说就是一个动态大小类型,必须通过指针来访问。

除了str把字符串放在静态区,也可以使用String把字符串放在堆区。

rust 复制代码
let s = String::from("hello");

此时的s就是一个String类型的字符串,它的实体放在了堆区,当s变量销毁,触发RAII机制把堆区的字符串一起回收。

String中,存储三部分内容:

  1. ptr:指向堆区的指针
  2. len:字符串的长度
  3. capacity:堆区目前已分配的容量

前两个之前在胖指针中已经了解过了,capacity是什么?

在操作系统中,申请分配一块堆内存是需要额外成本的,因此如果每次增长字符串都要扩容的话,效率就会变得很低。所以程序往往会选择预先申请比自己所需内存更大的内存,这样就可以减少申请内存的此处。目前已申请的可用内存就是capacity

示例:

rust 复制代码
let mut s = String::new();
for _ in 0..100 {
    s.push('x');
    if s.len() == s.capacity() {
        println!("len: {}, cap: {}", s.len(), s.capacity());
    }
}

以上代码创建了一个String,通过循环往里面动态添加一百个x字符。每次添加完字符后检查lencapacity,如果相等就进行一次输出。

结果:

rust 复制代码
len: 8, cap: 8
len: 16, cap: 16
len: 32, cap: 32
len: 64, cap: 64

可以看到,一开始capacity8,后面每次扩容都会把当前的capacity * 2,一百个字符最后只需要四次扩容,减少了堆区内存的申请次数。


自定义复合类型

结构体 Struct

结构体是最常见的将相关的数据字段组合在一起的类型,它有三种类型:

具名结构体

具名结构体就是指带有名字的结构体,语法如下:

rust 复制代码
struct name {
	item1: type1,
	item2: type2,
	item3: type3,
	...
}

通过struct关键字定义一个结构体,name是该结构体的名称。一个结构体内可以有多个不同类型的元素,在{ }内使用逗号分隔,以item : type的形式定义。同样的,最后行的逗号可以保留也可以省略。

例如:

rust 复制代码
struct Person{
	name: String,
	age: i32,
}

这样就创建了一个Person类型,内部包含姓名和年龄字段。

使用该类型定义变量:

rust 复制代码
let p = Person {
    name: String::from("Alice"),
    age: 20,
};

通过结构体名称后面加一对{ },在大括号内部定义每一个元素的值,就可以创建一个结构体。

如果想要访问结构体内部的变量,通过var.item的形式:

rust 复制代码
println!("name: {}, age: {}", p.name, p.age);

在之前的结构体初始化中,要求每个元素都以item: value的形式列出,其实结构体还提供了几种更加简便的初始化方式。

  • 字段初始化简写

当某个作用域中存在与结构体字段名称相同的变量,可以直接使用变量名称来初始化。

例如:

rust 复制代码
struct Person {
	name: String,
	age: i32,
}

fn main {
	let age = 18;
	let p = Person {
		name: String::from("zhangsan"),
		age,
	};
}

以上代码汇总,初始化了一个Peron到变量p,设置age的初始值时,希望直接把age变量的值初始化到字段p.age中。就可以直接把age: age简化为age

  • 结构体更新语法

如果你已经有一个结构体了,希望基于现有结构体创建一个新的结构体,并且只修改部分字段,就可以使用结构体更新语法。

示例:

rust 复制代码
struct User {
	active: bool,
	username: String,
	email: String,
	sign_in_count: u64,
}

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

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

以上代码中,定义了一个user1变量。随后定义了一个新的user2,希望在user1的基础上只修改eamil字段。就可以把email单独拿出来进行赋值,随后在尾部使用..user1表示其余字段都和user1相同。这就是结构体更新语法。


元组结构体

元组结构体相当于给元组带上了具体的名称,语法如下:

rust 复制代码
struct name(type1, type2, type3...);

通过struct定义一个元组结构体,在结构体名称后使用()定义每一个元素的类型,多个类型之间用逗号,分隔,最后一个逗号可以保留。除此之外,元组结构体尾部必须要有;,而结构体不需要。

比如说某个程序需要一个结构来表示身高+年龄+体重,采用另一个结构来表示RGB颜色:

rust 复制代码
fn show_rgb(rgb: (i32, i32, i32)) {
    println!("rgb = ({}, {}, {})", rgb.0, rgb.1, rgb.2);
}

fn show_info(info: (i32, i32, i32)) {
    println!("height: {}, age: {}, weight: {}", info.0, info.1, info.2);
}

fn main() {
    let rgb: (i32, i32, i32) = (255, 255, 255); // RGB 色值
    let info: (i32, i32, i32) = (175, 18, 60);  // 身高 年龄 体重
}

现在两者都采用了元组(i32, i32, i32)类型,确实这个类型可以很好的描述两者。

但是思考这样一个问题,个人信息 与 RGB 颜色的类型相同,用户有没有可能向show_info里面传入一个 RGB 色值?这是完全可能的,而且Rust不会报错。

也就是说基础的元组只通过元组内元素的类型来区分,如果每个元素类型相同,就视为同一种类型,这就不够细致,进而导致逻辑错误。

采用元组结构体就可以很好的把他们区分开来:

rust 复制代码
struct RBG(i32, i32, i32);
struct Info(i32, i32, i32);

fn show_rgb(rgb: RBG) {
    println!("rgb = ({}, {}, {})", rgb.0, rgb.1, rgb.2);
}

fn show_info(info: Info) {
    println!("height: {}, age: {}, weight: {}", info.0, info.1, info.2);
}

fn main() {
    let rgb: RBG = RBG(255, 255, 255);  // RGB 色值
    let info: Info = Info(175, 18, 60); // 身高 年龄 体重

    show_rgb(rgb);
    show_info(info);
}

现在用户就不能往show_info里面传入RBG了,如果误传,Rust编译期的类型检查就会直接报错,这就是元组结构体相比于普通元组的优点。


单元结构体

当一个结构体内什么也没有,就称为单元结构体。

rust 复制代码
struct name;

直接在结构体名后面加一个分号;做结尾,就是一个单元结构体。单元结构体往往用于承载一些方法,内部不包含具体类型,在其它面向对象语言中也可以理解为接口类这样的存在。关于类型的方法,后续会进行讲解。

单元结构体有一个特性,就是在release版本下全局只保留一个实例。

示例:

rust 复制代码
struct People;

fn main() {
    let p1 = People;
    let p2 = People;
    println!("p1 addr: {:p}", &p1);
    println!("p2 addr: {:p}", &p2);
}

以上代码定义了一个People单元结构体,随后用这个类型声明了两个实例p1p2,最后分别输出两个变量的地址。

debug模式下,p1p2的地址是不同的,因为它们确实是不同的变量,逻辑上在内存中占据不同的空间。

release模式下,由于这个单元结构体本身其实没有任何内容,它的大小实际为0,它的地址也没有什么实际意义。因此Rust会把全局所有的People都指向同一个实例,从而提高效率。

这与C++有一些区别,在C++中如果一个结构体内部没有任何内容,那么C++会保证给它分配1 byte,那么以上的p1p2就会拿到不同的地址。


枚举体 Enum

在有些场景下,对于一个数据,用户的可选择范围是有限的,比如性别分为男女,地球分为七个大洲,中国有56个民族等等。

对于这种带有固定数量选项的数据,就可以使用枚举来描述。

语法:

rust 复制代码
enum name {
	item1,
	item2,
	item3,
	...
}

使用enum 关键字定义一个枚举体,每个元素以逗号,分隔,最后一行的逗号可以保留。枚举体内的每个元素也称为变体

例如某个函数接收一个枚举,根据用户所处的洲输出它所处的半球:

rust 复制代码
enum Continents {
    Asia,
    Africa,
    NorthAmerica,
    SouthAmerica,
    Antarctica,
    Europe,
    Oceania,
}

fn get_hemisphere(continent: Continents) -> String {
    match continent {
        Continents::Asia => "主要位于东半球和北半球".to_string(),
        Continents::Africa => "地跨南北半球,主要位于东半球".to_string(),
        Continents::NorthAmerica => "主要位于西半球和北半球".to_string(),
        Continents::SouthAmerica => "主要位于西半球和南半球".to_string(),
        Continents::Antarctica => "位于南半球,跨所有经度".to_string(),
        Continents::Europe => "主要位于东半球和北半球".to_string(),
        Continents::Oceania => "主要位于南半球和东半球".to_string(),
    }
}

枚举常和match出现,基于模式匹配,可以很好的处理各种枚举值的情况。而枚举本身又限制了用户可输入的数据范围,不存在要求用户传入一个洲,却传入了一个国家的情况。

  • 类 C 枚举体

Rust还允许给枚举体的每个变体设置一个整形数值:

rust 复制代码
#[repr(u8)]
enum HttpStatus {
    Ok = 200,
    NotFound = 404,
}

此处定义了一个Http状态码的枚举,并且给不同状态设定了对于的数值。顶部的#[repr(u8)]用于指定变体的数据类型,此处指定为u8

这种枚举本身在Rust中意义不大,因为Rust很少用到整形与枚举直接转换的性质。这个性质主要用于和C语言的接口进行交互,从而兼容C风格的枚举。

  • 带参枚举体

枚举的变体中,还允许携带参数。

rust 复制代码
enum IpAddr {
	V4(u8, u8, u8, u8),
	V6(String),
}

以上枚举表示一个IP地址,但是我们不确定用户传入了一个IPv4还是IPv6,因此枚举中将他们分开处理。

对于IPv4要求把四个位置的数值传入,而IPv6则直接传入字符串。

这种枚举在模式匹配的时候,可以直接拿到变体中携带的参数:

rust 复制代码
fn print_ip_address(ip: IpAddr) {
    match ip {
        IpAddr::V4(a, b, c, d) => {
            println!("IPv4 地址: {}.{}.{}.{}", a, b, c, d);
        }
        IpAddr::V6(s) => {
            println!("IPv6 地址: {}", s);
        }
    }
}

对应的,在创建这种枚举的时候,也要传入对应的参数:

rust 复制代码
let home = IpAddr::V4(127, 0, 0, 1);
let loopback = IpAddr::V4(192, 168, 1, 1);

let loopback_v6 = IpAddr::V6(String::from("::1"));
let global_v6 = IpAddr::V6(String::from("2001:0db8:85a3:0000:0000:8a2e:0370:7334"));

处理像这种类似于元组的传参,还可以使用类似于结构体的传参:

rust 复制代码
enum Event {
    Click,
    KeyPress(char),
    Resize { width: u32, height: u32 },
}

在变体Resize中,可以携带两个命名参数,类似于结构体。

创建这样的变体时,也需要给每个字段指定值:

rust 复制代码
let e = Event::Resize { width: 800, height: 600 };

处理这个枚举时,可以通过{ }模式匹配到两个参数:

rust 复制代码
match e {
    Event::Click => println!("Clicked!"),
    Event::KeyPress(c) => println!("Key pressed: {}", c),
    Event::Resize { width, height } => {
        println!("Resized to {}x{}", width, height);
    }
}

联合体 Union

联合体主要用于与 C 语言交互和极端性能优化,在安全 Rust 中很少使用。

语法:

rust 复制代码
union name{
    item1: type1,
    item2: type2,
    item3: type3,
}

使用union关键字声明一个联合体,每个元素之间使用逗号隔开,最后一行的逗号可保留。每个元素使用item: type格式指明类型。

联合体的特点在于,内部所有变体同时只能使用一个,并且它们共享内存。

比如现在创建一个联合体,让它可以在i32f32之间自由切换:

rust 复制代码
union IntOrFloat {
    i: i32,
    f: f32,
}

此时访问 IntOrFloat.i就是以整形访问,IntOrFloat.f就是以浮点型访问。

必须把union放到unsafe { }内部处理:

rust 复制代码
let mut u = IntOrFloat { i: 42 };
unsafe {
    println!("作为整数: {}", u.i);
    u.f = 3.14;
    println!("作为浮点数: {}", u.f); // 未定义行为!
}

联合体在同一内存位置存储不同类型的值,每次只能安全访问当前活跃的字段。因为联合体没有手段去判断当前存储了哪一个变体,这就可能导致一些错误发生,所以要放到unsafe内部处理。

Rust本身其实并不常用这种类型,只是为了兼容C语言才推出的联合体,此处就不深入讲解了。


相关推荐
R.lin3 小时前
红包实现方案
java·开发语言·网络·后端·架构
莫听穿林打叶声儿3 小时前
关于Qt开发UI框架Qt Advanced Docking System测试
开发语言·qt·ui
minji...4 小时前
C++ 模板进阶
开发语言·c++
lsx2024064 小时前
HTML 音频/视频
开发语言
我叫汪枫4 小时前
【刷机分享】解决K20Pro刷入PixelOS后“网络连接”受限问题(附详细ADB命令)
开发语言·adb·php
老程序员刘飞4 小时前
hardhat 搭建智能合约
开发语言·php·智能合约
Autism1145 小时前
javase-day22-stream
java·开发语言·windows·笔记
江塘5 小时前
机器学习-KNN算法实战及模型评估可视化(C++/Python实现)
开发语言·c++·人工智能·python·算法·机器学习
钟离墨笺5 小时前
Go语言-->Goroutine 详细解释
开发语言·后端·golang