Rust:复合类型
Rust:复合类型
- 基础复合类型
- 元组 Tuple
- 模式匹配解构
- 单元类型
- 数组 Array
- 下标访问
- 字符串 str & String
- 自定义复合类型
- 结构体 Struct
- 具名结构体
- 元组结构体
- 单元结构体
- 枚举体 Enum
- 联合体 Union
在编程中,我们经常需要将多个值组合在一起形成更有意义的数据单元。Rust 提供了一系列强大的复合类型工具,让我们能够以类型安全的方式构建复杂数据结构。
基础复合类型
元组 Tuple
元组是一种异构有限序列,异构是指元组内的元素可以是不同类型,有限是指元组的长度是固定的。
语法:
let t: (type1, type2, type3...) = (val1, val2, val3...);
将类型放在()内,每个类型之间用逗号隔开,这就是元组的类型。例如(i32, f64)就是一个类型,对应的该类型下的值,就是在()内每个位置放上对应的值,比如(15, 3.14)就是一个符合上述类型的元组。
通过下标可以访问一个元组内的元素:
let tuple: (i32, f64, bool) = (100, 3.14, true);println!("第一个元素: {}", tuple.0); // 100
println!("第二个元素: {}", tuple.1); // 3.14
println!("第三个元素: {}", tuple.2); // true
通过tuple.0可以访问到第一个元素,以此类推。
元组中,最后一个元素末尾可以携带逗号,这是其它语言比较少见的设计。
以下四种写法都是正确的:
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的第一版本:
let tuple = (100, 3.14, true,
);
如果后续某次对这个元组需要进行修改,添加一个新元素:
let tuple = (100, 3.14, true,"hello", // diff
);
此时只会造成一行diff,很明确本次修改新增了一个元素。
如果是其他语言,那么要先在前一行添加逗号,下一行再写新元素,就会造成两行diff,但只有一行有实际意义的修改。
但是当元组只有一个元素的时候,必须携带末尾的逗号:
let tuple: (i32,) = (2025,);
类型和值末尾的逗号都不能省略,因为(2025)是一个表达式,小括号本身是用来调节计算顺序的,他不修改实际值。因此 (2025) == 2025,它们是全等的。
如果要实现单元素的元组,必须用一个末尾的逗号来表明这是一个元组,防止被解析为单个元素外套一层小括号。
模式匹配解构
元组支持一种特殊的语法解构赋值,它可以快速取出元组中的元素,赋值到变量上。
例如:
let point = (10, 20);
let (x, y) = point; // 解构元组
println!("坐标: x={}, y={}", x, y); // 坐标: x=10, y=20
其中 let (x, y) = point 就是一个解构,它把point 这个元组的前两个元素,按位置赋值到变量x和y上。
你也可以通过_来跳过某些元素:
let (first, _, third) = (1, "hello", true);
println!("first={}, third={}", first, third); // first=1, third=true
上例中,相当于忽略了元组中第二个元素,只提取第一个和第三个元素。
单元类型
当一个元组不含任何元素,也就是长度为0,称为空元组,也称为单元类型()。
let unit: () = ();
单元类型 () 表示"没有有意义的值",常用于不需要返回值的函数或表达式中。它占用0字节,在内存中不实际存在。
比如当一个函数省略了返回值,或者一个表达式没有具体值,返回的都是(),所谓单元类型本质就是一个空元组。
数组 Array
数组是Rust内建的原始集合类型,它表示同类型、长度固定、有序的元素集合。
数组的类型为[T; N],其中T表示内部的类型,N表示数组的长度。
语法:
let arr: [T; N] = [val1, val2, val3...];
let arr: [T; N] = [val; N];
数组有两种定义形式:
- 第一种形式确定所有元素,每个
val都必须是T类型的元素,而且必须有N个val。 - 第二种形式把所有元素都初始化为同一个值
val,比如let arr = ["hello", 100]就是定义了一个一百个hello的数组。
Rust的原生数组通常定义在栈上,基于T和N在编译期就可以确定内存占用情况。比如说[i32; 10]类型的数组,每个i32大小为4 byte,那么整个数组大小就是40 byte。为了保证其编译期可以确定大小的特性,Rust要求N必须在编译期可以求值。
例如:
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 函数。
但是比如说使用变量就是非法的:
let len: usize = 10;
let arr = [202; len];
此处的len是一个变量,用它初始化arr编译无法通过,因为编译期无法确定数组长度。
下标访问
与元组一样,数组也通过下标访问,从0开始。
语法:
let val = arr[pos];
其中pos是要访问的下标,比如:
let mut arr = [2025; 10];
arr[1] = 2026
let val = arr[1];
可以把arr[pos]放在等号左边,对指定元素进行赋值,当然前提是mut。也可以用其它变量接收指定下标的值。
Rust 的数组访问有严格的边界检查,编译时能发现的越界会直接报错,运行时越界会导致 panic。
Rust对安全要求极高,对数组也是一样。之前说过,数组长度N是在编译期就可以确定的。如果你在pos位置传入的表达式也是编译期可以确定的,那么Rust在编译期就可以进行越界检查,防止访问未开辟的内存。
比如:
let arr = [2025; 10];
let val = arr[10]; // 编译失败
由于arr的下标范围是0 ~ 9,arr[10]在编译期直接失败,减少了运行时错误。
当然这也不是万能的,比如说pos位置传入了一个非编译期求值的表达式:
let arr = [2025; 10];let num = 10;
let val = arr[num]; // 编译成功,运行 panic
虽然num已经越界了,但是编译期无法确定num的值,只能等到运行时触发panic。
字符串 str & String
Rust的字符串分为两种,原生字符串str和集合字符串String。不论哪一种,内部存储的都是utf8编码序列,也就是说每个字符的大小是不定的。
不论哪一种字符串,都视为动态大小类型来处理,比如说:
let s: &str = "hello"; // 正确
let s: str = "world"; // 错误
字符串字面量往往存储在静态区,而非栈区,因此在函数中想要访问到字面量,必须通过指针。
以上代码中,第一行使用&str获取胖指针,它是正确的,这是动态大小类型的最常见处理方案。但是第二行直接用str接受字符串,这是非法的,因为编译器无法确认字符串的长度。
有人可能就问了,为什么str是动态大小类型?定义的时候不是已知每一个字符,自然就知道整个字符串所需要的空间了吗?没错,对于一个字面量确实是可以知道其所需的空间大小的,但是问题在于str这个类型本身大小不确定。
比如说数组,它的类型是[T; N],它的大小是体现在类型上的,不同长度的数组有自己单独的类型,就视为这个类型是一个静态大小类型。比如说[1, 2, 3] 和[1, 2]是不同的类型,它们的长度分别是12 byte和8 byte。
但是对于一个字符串,不论是"hello"还是"rust",它们的类型都是str,但是同一个类型str却可能是4 byte或者5 byte。那么这个str从类型上来说就是一个动态大小类型,必须通过指针来访问。
除了str把字符串放在静态区,也可以使用String把字符串放在堆区。
let s = String::from("hello");
此时的s就是一个String类型的字符串,它的实体放在了堆区,当s变量销毁,触发RAII机制把堆区的字符串一起回收。
在String中,存储三部分内容:
ptr:指向堆区的指针len:字符串的长度capacity:堆区目前已分配的容量
前两个之前在胖指针中已经了解过了,capacity是什么?
在操作系统中,申请分配一块堆内存是需要额外成本的,因此如果每次增长字符串都要扩容的话,效率就会变得很低。所以程序往往会选择预先申请比自己所需内存更大的内存,这样就可以减少申请内存的此处。目前已申请的可用内存就是capacity。
示例:
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字符。每次添加完字符后检查len和capacity,如果相等就进行一次输出。
结果:
len: 8, cap: 8
len: 16, cap: 16
len: 32, cap: 32
len: 64, cap: 64
可以看到,一开始capacity为8,后面每次扩容都会把当前的capacity * 2,一百个字符最后只需要四次扩容,减少了堆区内存的申请次数。
自定义复合类型
结构体 Struct
结构体是最常见的将相关的数据字段组合在一起的类型,它有三种类型:
具名结构体
具名结构体就是指带有名字的结构体,语法如下:
struct name {item1: type1,item2: type2,item3: type3,...
}
通过struct关键字定义一个结构体,name是该结构体的名称。一个结构体内可以有多个不同类型的元素,在{ }内使用逗号分隔,以item : type的形式定义。同样的,最后行的逗号可以保留也可以省略。
例如:
struct Person{name: String,age: i32,
}
这样就创建了一个Person类型,内部包含姓名和年龄字段。
使用该类型定义变量:
let p = Person {name: String::from("Alice"),age: 20,
};
通过结构体名称后面加一对{ },在大括号内部定义每一个元素的值,就可以创建一个结构体。
如果想要访问结构体内部的变量,通过var.item的形式:
println!("name: {}, age: {}", p.name, p.age);
在之前的结构体初始化中,要求每个元素都以item: value的形式列出,其实结构体还提供了几种更加简便的初始化方式。
- 字段初始化简写
当某个作用域中存在与结构体字段名称相同的变量,可以直接使用变量名称来初始化。
例如:
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。
- 结构体更新语法
如果你已经有一个结构体了,希望基于现有结构体创建一个新的结构体,并且只修改部分字段,就可以使用结构体更新语法。
示例:
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相同。这就是结构体更新语法。
元组结构体
元组结构体相当于给元组带上了具体的名称,语法如下:
struct name(type1, type2, type3...);
通过struct定义一个元组结构体,在结构体名称后使用()定义每一个元素的类型,多个类型之间用逗号,分隔,最后一个逗号可以保留。除此之外,元组结构体尾部必须要有;,而结构体不需要。
比如说某个程序需要一个结构来表示身高+年龄+体重,采用另一个结构来表示RGB颜色:
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不会报错。
也就是说基础的元组只通过元组内元素的类型来区分,如果每个元素类型相同,就视为同一种类型,这就不够细致,进而导致逻辑错误。
采用元组结构体就可以很好的把他们区分开来:
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编译期的类型检查就会直接报错,这就是元组结构体相比于普通元组的优点。
单元结构体
当一个结构体内什么也没有,就称为单元结构体。
struct name;
直接在结构体名后面加一个分号;做结尾,就是一个单元结构体。单元结构体往往用于承载一些方法,内部不包含具体类型,在其它面向对象语言中也可以理解为接口类这样的存在。关于类型的方法,后续会进行讲解。
单元结构体有一个特性,就是在release版本下全局只保留一个实例。
示例:
struct People;fn main() {let p1 = People;let p2 = People;println!("p1 addr: {:p}", &p1);println!("p2 addr: {:p}", &p2);
}
以上代码定义了一个People单元结构体,随后用这个类型声明了两个实例p1和p2,最后分别输出两个变量的地址。
在debug模式下,p1和p2的地址是不同的,因为它们确实是不同的变量,逻辑上在内存中占据不同的空间。
release模式下,由于这个单元结构体本身其实没有任何内容,它的大小实际为0,它的地址也没有什么实际意义。因此Rust会把全局所有的People都指向同一个实例,从而提高效率。
这与C++有一些区别,在C++中如果一个结构体内部没有任何内容,那么C++会保证给它分配1 byte,那么以上的p1和p2就会拿到不同的地址。
枚举体 Enum
在有些场景下,对于一个数据,用户的可选择范围是有限的,比如性别分为男女,地球分为七个大洲,中国有56个民族等等。
对于这种带有固定数量选项的数据,就可以使用枚举来描述。
语法:
enum name {item1,item2,item3,...
}
使用enum 关键字定义一个枚举体,每个元素以逗号,分隔,最后一行的逗号可以保留。枚举体内的每个元素也称为变体。
例如某个函数接收一个枚举,根据用户所处的洲输出它所处的半球:
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还允许给枚举体的每个变体设置一个整形数值:
#[repr(u8)]
enum HttpStatus {Ok = 200,NotFound = 404,
}
此处定义了一个Http状态码的枚举,并且给不同状态设定了对于的数值。顶部的#[repr(u8)]用于指定变体的数据类型,此处指定为u8。
这种枚举本身在Rust中意义不大,因为Rust很少用到整形与枚举直接转换的性质。这个性质主要用于和C语言的接口进行交互,从而兼容C风格的枚举。
- 带参枚举体
枚举的变体中,还允许携带参数。
enum IpAddr {V4(u8, u8, u8, u8),V6(String),
}
以上枚举表示一个IP地址,但是我们不确定用户传入了一个IPv4还是IPv6,因此枚举中将他们分开处理。
对于IPv4要求把四个位置的数值传入,而IPv6则直接传入字符串。
这种枚举在模式匹配的时候,可以直接拿到变体中携带的参数:
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);}}
}
对应的,在创建这种枚举的时候,也要传入对应的参数:
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"));
处理像这种类似于元组的传参,还可以使用类似于结构体的传参:
enum Event {Click,KeyPress(char),Resize { width: u32, height: u32 },
}
在变体Resize中,可以携带两个命名参数,类似于结构体。
创建这样的变体时,也需要给每个字段指定值:
let e = Event::Resize { width: 800, height: 600 };
处理这个枚举时,可以通过{ }模式匹配到两个参数:
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 中很少使用。
语法:
union name{item1: type1,item2: type2,item3: type3,
}
使用union关键字声明一个联合体,每个元素之间使用逗号隔开,最后一行的逗号可保留。每个元素使用item: type格式指明类型。
联合体的特点在于,内部所有变体同时只能使用一个,并且它们共享内存。
比如现在创建一个联合体,让它可以在i32和f32之间自由切换:
union IntOrFloat {i: i32,f: f32,
}
此时访问 IntOrFloat.i就是以整形访问,IntOrFloat.f就是以浮点型访问。
必须把union放到unsafe { }内部处理:
let mut u = IntOrFloat { i: 42 };
unsafe {println!("作为整数: {}", u.i);u.f = 3.14;println!("作为浮点数: {}", u.f); // 未定义行为!
}
联合体在同一内存位置存储不同类型的值,每次只能安全访问当前活跃的字段。因为联合体没有手段去判断当前存储了哪一个变体,这就可能导致一些错误发生,所以要放到unsafe内部处理。
Rust本身其实并不常用这种类型,只是为了兼容C语言才推出的联合体,此处就不深入讲解了。
