Rust 进阶学习笔记(九):强类型特性
目录
类型转换与自动类型转换,类型别名,不定长类型,全局变量
本文属于我的 Rust 学习笔记 系列。
Rust 入门学习笔记以实际例子为主,讲解部分不是从零开始的,所以不建议纯萌新观看,读者最好拥有任意一种面向对象语言的基础,然后自己多多少少看过 Rust 的基本语法,刷过一点 rustlings。
Rust 进阶学习笔记以及实战的来源则五花八门,将会标注在下一行⬇️。
本节出处:圣经-4.3深入类型 圣经-4.7全局变量
没想到吧,都到高级九了,类型还能回归。因为这节难度和异步有一拼的,值得为它单开一篇。比如说之前遇到过很多自动解引用的情况,这其实就是一种类型转换。
除类型转换外,本节还有很多之前没接触过的类型特性。
类型转换
Rust 是强类型的语言,也是类型安全的语言,因此在 Rust 中进行类型转换并不容易。
as 转换
Rust 不允许两种不同的类型进行比较。最简单的解决办法就是用as
操作符来进行转换。
每个类型能表达的数据范围不同,如果把范围较大的类型转换成较小的类型,会造成数据失准(实际上就是粗暴地把二进制高位截断了只保留地位),因此只能把范围较小的类型转换成较大的类型。可以这样查看数据类型的最大值:
let a = i8MAX;
println!;
除了类型的转换,as
的另一个应用是内存地址和指针的转换:
let mut values: = ;
let p1: *mut i32 = values.as_mut_ptr;
let first_address = p1 as usize; // 将p1内存地址转换为一个整数
let second_address = first_address + 4; // 4 == std::mem::size_of::<i32>(),i32类型占用4个字节,因此将内存地址 + 4
let p2 = second_address as *mut i32; // 访问该地址指向的下一个整数p2
unsafe
assert_eq!;
TryInto 转换
TryInto
是一种内置转换的替代方案,显然这是一个特质。因此,它更灵活更自由,能够让开发者拥有对类型转换的完全控制。
// 要使用一个特质的方法的时候,也需要把特质引入到当前的作用域中
// 实际上,这个方法可以不 use,因为会被 std::prelude 自动引入
// 这是预引入列表 https://doc.rust-lang.org/std/prelude/index.html
use TryInto;
try_into
会尝试进行一次转换,并返回一个Result
,此时就可以对其进行相应的错误处理。try_into
转换最常见的应用就是捕获大类型向小类型转换时导致的溢出错误:
通用转换
as
和try_into
只能用于数值类型的转换。对于一些复杂类型,如结构体,当然可以循环转换其中的所有字段。当然,Rust 还有一些办法进行通用转换。
强制类型转换
本小节随便看看就好,不需要记住。
Rust 不提供原生类型的隐式转换,但部分类型可以进行一些隐式强制转换。这往往需要一些自动强转点(coercion sites),最典型的就是所需类型已给出或可以自动推导的(不是推断)的场景。例如:
- let 语句显式指定类型:
let _: &i8 = &mut 42;
- 静态和常量项的声明
- 函数的参数,实参可以被自动强制转换成形参
- 实例化结构体、枚举或联合体
- 函数 return 的结果
自动强转允许发生在下列类型之间:
- 反射性场景,如 T 到 U,如果 T 是 U 的一个子类型
- 传递性场景,如 T_1 到 T_3,当 T_1 可自动强转到 T_2、同时 T_2 又能自动强转到 T_3(注意这个还没有得到完全支持)
- &mut T 到 &T
- *mut T 到 *const T
- &T 到 *const T
- &mut T 到 *mut T
- &T 或 &mut T 到 &U(如果 T 实现了 Deref<Target = U>)
- &mut T 到 &mut U(如果 T 实现了 DerefMut<Target = U>)
- 类型构造器 TyCtor(T) 到 TyCtor(U)(TyCtor(T) 是
&T/&mut T/*const T/*mut T/Box<T>
) - ! 到任意 T
- 非捕获闭包到函数指针
还存在非固定 size 的自动转换:
- [T; n] 到 [T]
- T 到 dyn U,(当 T 实现 U + Sized, 并且 U 是对象安全的时)
- Foo<..., T, ...> 到 Foo<..., U, ...> 要求
- Foo 是一个结构体。
- T 实现了
Unsize<U>
。 - Foo 的最后一个字段是和 T 相关的类型。
- 如果这最后一个字段是类型
Bar<T>
,那么Bar<T>
实现了Unsized<Bar<U>>
。 - T 不是任何其他字段的类型的一部分。
- T 实现了
Unsize<U>
或CoerceUnsized<Foo<U>>
,且类型Foo<T>
可以实现CoerceUnsized<Foo<U>>
注意匹配特质时不会做除方法外的强制转换(T 到 U 不代表 impl T 到 impl U)。
在某些上下文中,编译器必须将多个类型强制放在一起,以尝试找到最通用的类型,这就是最小上界自动强转(Least Upper Bound, LUB)。下面是一个 LUB 的例子:
点操作符
方法调用会用到点操作符,这个过程中会发生大量类型转换,比如自动引用、自动解引用、强制类型转换等。
假设一个 value 拥有类型 T,然后有一个方法 foo,编译器会按如下顺序尝试进行类型转换:
- 值方法调用:编译器检查它是否可以直接调用 T::foo(value)
- 引用方法调用:如果方法类型错误或者特质没有针对 Self 进行实现(再次强调,特质不能强制转换)编译器会尝试增加自动引用,如
<&T>::foo(value)
和<&mut T>::foo(value)
- 解引用方法调用:编译器会使用
Deref
特质解引用 T ,然后再进行尝试。例如T: Deref<Target = U>
,编译器会用 U 尝试 - 如果 T 是定长类型,编译器会尝试将 T 转为不定长类型,如 [i32; 2] 到 [i32]
- 如果以上都不行,编译器会报错。
举个例子:
// 首先,array[0] 只是Index特质的语法糖:编译器会将 array[0] 转换为 array.index(0) 调用(当然在调用之前,编译器会先检查 array 是否实现了 Index 特质)
// 接着,编译器检查 Rc<Box<[T; 3]>> 是否有实现 Index 特质,结果是否,不仅如此,&Rc<Box<[T; 3]>> 与 &mut Rc<Box<[T; 3]>> 也没有实现。
// 上面的都不能工作,编译器开始对 Rc<Box<[T; 3]>> 进行解引用,把它转变成 Box<[T; 3]>
// 此时继续对 Box<[T; 3]> 进行上面的操作 :Box<[T; 3]>, &Box<[T; 3]>,和 &mut Box<[T; 3]> 都没有实现 Index 特质,所以编译器开始对 Box<[T; 3]> 进行解引用,然后我们得到了 [T; 3]
// [T; 3] 以及它的各种引用都没有实现 Index 索引(只有数组切片才可以),它也不能再进行解引用,因此编译器只能将定长转为不定长,因此 [T; 3] 被转换成 [T],也就是数组切片,它实现了 Index 特质,因此最终我们可以通过 index 方法访问到对应的元素。
let array: = ...;
let first_entry = array;
再看另一个例子:
// fn do_stuff<T: Clone>(value: &T) { // 值引用直接生效
再来看看同一个方法在参数 T 不同时候的行为:
// 一个复杂类型能否派生 Clone,需要它内部的所有子类型都能进行 Clone。
// 因此 Container<T>(Arc<T>) 是否实现 Clone 的关键在于 T 类型是否实现了 Clone 特质。
// 派生宏可能会生成如下代码:
// impl<T> Clone for Container<T> where T: Clone {
// fn clone(&self) -> Self {
// Self(Arc::clone(&self.0))
// }
// }
;
果然够难啊。。。好歹还看懂了,但是很难记住。后面还有根本看不懂的
逆天的“变形”
这里的“变形”是指 transmute,这可以说是 Rust 最不安全的操作了。mem::transmute<T, U>
将类型 T 直接转成类型 U,只要保证 T 和 U 在内存中的字节数大小相同即可。这很显然非常不安全:
- 转换后创建一个任意类型的实例会造成无法预测的混乱,例如把一个 i32 转换成 bool,他表示的内容根本没法知道是什么
- 变形后会有一个重载的返回类型,即使你没有指定返回类型,为了满足类型推导的需求,依然会产生千奇百怪的类型
- 将 & 变形为 &mut 是未定义的行为
- 变形为一个未指定生命周期的引用会导致无界生命周期1
- 在复合类型之间互相变换时,需要保证它们的排列布局是一模一样的否则字段会得到不可预期的值
mem::transmute_copy<T, U>
是一个更加离谱的操作:它从 T 类型中拷贝出 U 类型所需的字节数,然后转换成 U。如果 U 的尺寸比 T 大,会是一个未定义行为。可以发现,连内存大小检查都不要了。
为什么会有变形?来看看他们的应用:
// 裸指针转换为函数指针
let pointer = foo as *const ;
let function = unsafe ;
assert_eq!;
// 延长生命周期,或者缩短一个静态生命周期寿命
;
// 将 'b 生命周期延长至 'static 生命周期
unsafe
// 将 'static 生命周期缩短至 'c 生命周期
unsafe
这是非常先进的用法,但也很不安全。
自定义类型
新类型
所谓新类型(newtype)就是使用元组结构体的方式将已有的类型包裹起来,比如struct A(u32)
。自定义类型可以给出更有意义和可读性的类型名,并隐藏内部的类型细节。来看几个应用:
// 为外部类型实现外部特质
use fmt;
;
// 更好的可读性及类型异化
use Add;
use fmt;
// 为 u32 实现 Display 和 Add 特质
;
// 隐藏内部类型的细节
;
类型别名
类型别名(Type Alias)形如type A = u32
。和 newtype 不同,类型别名并不是一个独立的全新的类型,而是某一个类型的别名,只是起到增强可读性和减少模版代码使用的作用,并不会改变编译器的认知。
// 增强可读性
type Meters = u32;
let x: u32 = 5;
let y: Meters = 5;
// 能正常编译和输出
println!;
type Thunk = ;
// f 的类型太长,起个别名会更加优美
let f: Thunk = Box new;
// 简化 Result 枚举
// std::io 库定义了自己的 Error 类型:std::io::Error
// 可以把该错误对用户隐藏起来,只在内部使用
type Result<T> = Result;
// 这是类型别名在标准库中最常见的作用
// 由于它只是别名,因此我们可以用它来调用真实类型的所有方法,甚至包括 ? 符号
永不返回
!
用来说明一个函数永不返回任何值。当然也可以理解成所有类型的子类型。
不定长类型
Rust 的类型从编译器何时能获知类型大小的角度出发,可以分成两类:
- 定长(sized)类型:这些类型的大小在编译时是已知的
- 不定长(unsized)类型:大小只有到了程序运行时才能动态获知,又被称之为动态大小类型(DST,dynamically sized types)
动态大小类型
动态大小类型就是指编译器无法在编译期得知该类型值的大小,只有到了程序运行时,才能动态获知的类型。
集合并不是动态大小类型。集合只是把底层数据保存在堆上,在栈中还存有一个引用类型,该引用包含了集合的内存地址、元素数目、分配空间信息,栈上的引用类型是固定大小的,因此它们依然是固定大小的类型。可以把集合类型理解成一种智能指针。
Rust 中常见的 DST 类型有: str、[T]、dyn Trait,它们都无法单独被使用,必须要通过引用或者 Box 来间接使用 。
动态大小数组
n 在编译期无法得知,而数组类型的一个组成部分就是长度,长度变为动态的,自然类型就变成了 unsized。因此这段代码无法通过编译。
想要能够编译,只能使用 const:
str 及其他切片
切片也无法直接创建。因为底层的切片长度是可以动态变化的,而编译器无法在编译期得知它的具体的长度,因此该类型无法被分配在栈上,只能分配在堆上。
而切片引用是固定大小的。将动态数据固定化的秘诀就是使用引用指向这些动态数据,然后在引用中存储相关的内存位置、长度等信息。
特质对象
特质对象无法直接使用。
// OK
// OK
// ERROR!
定长特质
所有在编译时就能知道其大小的类型,都会自动实现Sized
特质。编译器也会给泛型类型 T 自动加上Sized
特质约束。
每一个特质都是一个可以通过名称来引用的动态大小类型。因此如果想把特质作为具体的类型来传递给函数,必须将其转换成一个特质对象(&dyn Trait
/ Box<dyn Trait>
/Rc<dyn Trait>
...)。
如果想在泛型函数中使用动态数据类型,需要使用?Sized
特质。?Sized
特质用于表明类型 T 既有可能是固定大小的类型,也可能是动态大小的类型。此时函数参数类型从 T 变成了 &T,因为 T 可能是动态大小的,因此需要用一个固定大小的引用来包裹它。
Box
如何把一个动态大小类型转换成固定大小的类型:使用引用指向这些动态数据,然后在引用中存储相关的内存位置、长度等信息。
整数到枚举的转换
从枚举到整数的转换很容易,反之则不然。但这个需求还是常见的。
假设有一个枚举类型,需要从外面传入一个整数,用于控制后续的流程走向,此时就需要用整数去匹配相应的枚举。这种使用方式在 C 语言中很常见,尤其是和 thrift 协议的交互时。但是 Rust 无法使用 C 语言的写法:
所以需要想一些其他办法。
三方库
[]
= "0.2.14"
= "0.3.3"
# 还可以用 num_enums 库
use FromPrimitive;
use FromPrimitive;
TryFrom
use TryFrom;
// TryFrom 特质可以来做转换
use TryInto;
// 使用宏来自动根据枚举的定义实现 TryFrom 特质
=> )
}
back_to_enum!
transmute
transmute 显然也可以用来做这个。从下面的例子可以看到,transmute 如果真用好了还是很有存在价值的。
// #[repr(..)] 控制底层类型的大小,防止类型对不齐
全局变量
全局变量也是一个常见的需求。全局变量的生命周期肯定是'static
,但是不代表它需要用 static 来声明。
编译期初始化
大多数使用的全局变量都只需要在编译期初始化即可。以下几种类型都是。
静态常量
全局常量可以在程序任何一部分使用,当然,如果它是定义在某个模块中,你需要引入对应的模块才能使用。常量,顾名思义它是不可变的,很适合用作静态配置:
const MAX_ID: usize = usizeMAX / 2;
常量具有如下特点:
- 关键字是 const 而不是 let
- 定义常量必须指明类型(如 i32),不能省略
- 定义常量时变量的命名规则一般是全部大写
- 常量可以在任意作用域进行定义,其生命周期贯穿整个程序的生命周期。编译时编译器会尽可能将其内联到代码中,所以在不同地方对同一常量的引用并不能保证引用到相同的内存地址
- 常量的赋值只能是常量表达式/数学表达式,也就是说必须是在编译期就能计算出的值,如果需要在运行时才能得出结果的值比如函数,则不能赋值给常量表达式
- 对于变量出现重复的定义(绑定)会发生变量遮盖,后面定义的变量会遮住前面定义的变量,常量则不允许出现重复的定义
静态变量
静态(static)变量允许声明一个全局的变量,常用于全局数据统计。必须使用unsafe
语句块才能访问和修改静态变量。只有在同一线程内或者不在乎数据的准确性时,才应该使用全局静态变量。
static mut REQUEST_RECV: usize = 0;
- 静态变量不会被内联,在整个程序中,静态变量只有一个实例,所有的引用都会指向同一个地址
- 存储在静态变量中的值必须要实现 Sync trait
原子类型
原子类型是线程安全的,作为全局变量可以在多个线程中使用。详见多线程一节。
运行期初始化
如果有一个全局的动态配置,它希望在程序开始后,才加载数据进行初始化,最终可以让各个线程直接访问使用。上小节的所有类型均无法像这样用函数进行静态初始化,于是就需要寻找其他办法。
懒静态
lazy_static 是社区第三方2提供的非常强大的宏,用于懒初始化静态变量,定义的静态变量都是不可变引用。
use Mutex;
use lazy_static;
lazy_static!
其内部实现用了一个底层的并发原语std::sync::Once
,在每次访问该变量时,程序都会执行一次原子指令用于确认静态变量的初始化是否完成,因此会有轻微性能损失。
lazy_static 可以用于实现全局缓存:
use lazy_static;
use HashMap;
lazy_static!
Box::leak
Box::leak
也可以用于全局变量。它可以将一个变量从内存中“泄漏”,然后将其变为'static
生命周期,最终该变量将和程序活得一样久,因此可以赋值给全局静态变量。
注意新版这招被默认被禁了,需要加上允许 static_mut_refs 的注解。
static mut CONFIG: = None;
如果需要在运行期从一个函数返回一个全局变量,同样可以用Box::leak
:
static mut CONFIG: = None;
标准库中的懒加载
标准库中也提供了一些懒加载的 API。
单线程 | 多线程 | |
---|---|---|
Once | OnceCell | OnceLock |
Lazy | LazyCell | LazyLock |
Lazy 会自动按需加载内容,让代码更简洁,更人性化,而 Once 则可以手动指定初始化的时机或使用不同的方法初始化,更强大。Cell 的实现更简单,效率也更高,但是他并不保证线程安全,而 Lock 通过内部同步机制实现了线程安全。
下面来看同一个例子的两种写法。
Once
use ;
;
static LOGGER: = new;
// // 输出
// Logger is being created...
// some message
// other message
// thread message
Lazy
use ;
;
// 使用 LazyLock::new 方法直接对全局变量 LOGGER 进行赋值,并传入一个初始化函数。
static LOGGER: = new;
📝 系列导航
- 上一篇: Rust 进阶学习笔记(八):异步编程
- 下一篇: Rust 进阶学习笔记(十):高级错误处理
- 合集列表