《Rust权威指南》学习笔记(五)
高级特性
1.在Rust中,unsafe是一种允许绕过Rust的安全性保证的机制,用于执行一些Rust默认情况下不允许的操作。unsafe存在的原因是:unsafe 允许执行某些可能被 Rust 的安全性检查阻止的操作,从而可以进行性能优化,如手动内存管理和优化特定的底层操作;在系统级编程中,通常需要直接操作硬件或底层资源,这些操作超出了 Rust 的安全模型的范围,unsafe 提供了一种方式来实现这些操作;在与不安全的代码库(例如 C 库)进行集成时,unsafe 使得可以调用这些库函数,虽然需要保证这些调用的安全性;允许开发者在受控的、已知的环境中使用不安全的操作,同时通过安全的 API 封装这些操作,确保整个程序的安全性。unsafe并没有关闭借用检查或停用其他安全检查,任何内存相关的错误必须留在unsafe块里,应尽量隔离unsafe代码,最好将其封装在安全的抽象里面,提供安全的API。unsafe块允许执行以下的操作:1解引用原始指针(裸指针):原始指针可以在其他地方创建但只能在unsafe块中使用,原始指针分为可变的*mut T和不可变的*const T(不可变意味着指针在解引用后不能直接对其赋值),此处*是类型名不是解引用,与引用不同,原始指针允许同时存在指向同个位置位置的可变指针来忽略借用规则,原始指针无法保证能指向合理的内存,且允许为null,原始指针不实现任何的自动清理。示例如下:
通过原始指针可以实现与C语言进行接口,构建借用检查器无法理解的安全抽象;2调用unsafe函数或方法:不安全的函数和方法调用是指在 Rust 中标记为 unsafe 的函数或方法,它们可能执行绕过 Rust 安全检查的操作,因此调用时必须在 unsafe 块中进行,以确保开发者意识到潜在风险并手动保证安全性。函数包含unsafe块代码并不意味着需要将整个函数标记为unsafe,将unsafe代码包裹在安全函数中是一个常见的抽象。如下图所示:
可以使用extern关键字调用其他语言的函数或从其他语言调用Rust函数,供其他语言调用的Rust函数需要在fn前面加上extern关键字,还要添加#[no_mangle]注解,避免Rust在编译时改变函数名称,如下图所示:
3访问或修改一个静态变量:在Rust中,静态变量(static变量)是具有'static生命周期的全局变量,这意味着它们在程序的整个运行期间都存在,静态变量可以在程序的任何地方访问,无论是主函数、子函数,还是多个线程之间。静态变量在程序的内存中占有固定位置,通常用于存储不变的数据或需要跨越多个函数或线程访问的数据。Rust中全局变量只能是静态变量,全局的数据还可以是常量const,他俩的区别是:const在编译时求值、不占用内存且总是不可变;static在运行时分配内存,具有'static生命周期,可用static mut使其可变。访问不可变静态变量是安全的,访问和修改可变静态变量是不安全的,不可变静态变量可以在程序的任何地方安全地访问,可变的静态变量 (static mut) 只能在unsafe块中访问。静态变量的名称通常使用全大写字母,如果静态变量的名称由多个单词组成,通常使用下划线_进行分隔。如下图所示:
4实现不安全的trait:当某个trait存在至少一个方法拥有编译器无法校验的不安全因素时,就称这个trait是不安全的,对于不安全的trait需在其前面加上unsafe声明,不安全的trait只能在unsafe块中实现,如下图所示:
2.Self和self的区别:在Rust中,Self代表当前类型,用于类型级别的操作,通常出现在实现构造函数、trait方法以及关联类型时,它表示与当前实现的类型相同的类型,可以在返回类型或关联类型中使用。例如,在impl块中,Self就等同于实现该impl块的具体类型。self则是方法的第一个参数,用于表示当前实例。它在实例方法中使用,用于访问和修改该实例的数据。根据需要,self可以通过值传递(self)、不可变引用(&self)、或可变引用(&mut 0self)的方式传递。
3.关联类型则是在trait中定义的一种占位符类型,具体类型由实现该trait的类型在实现过程中指定。如下图所示:
这种设计可以使trait的接口更简洁,因为使用者不需要在每次调用时都指定类型。关联类型尤其适用于涉及多个相关类型的trait,实现者可以为这些类型设置特定的关系,从而增强trait的表达力和易用性。泛型允许为不同类型多次实现同一个trait,如下图所示:
而关联类型在trait中占位且只能由具体实现者指定具体类型,因此在大多数情况下,一个关联类型只能为一个trait提供唯一的实现。
4.Rust中类型的三种变体(Variance):1协变(covariant):某类型只能用其“子类型”替代。例如,&'static T可以替代&'a T。这是因为'static生命周期比'a长,所以&'static T可以安全地用在需要&'a T的地方;2不变(invariant):类型必须严格提供指定的类型,不能有任何变动。例如,&mut T对于T来说是不变的,意味着必须严格匹配T的类型,不能使用其子类型或超类型;3逆变(contravariant):函数对参数的要求越低,参数可以发挥的作用越大。逆变指的是在某些上下文中,父类型可以替代子类型。逆变通常在函数参数中体现,如果一个函数能够接受一个更一般的类型作为参数,那么这个函数在子类中仍然有效。
5.可以在使用泛型参数时为泛型指定一个默认的具体类型,如标准库中Add定义,其中Rhs=Self表示默认的另一个操作数的类型与函数调用者类型相同:
这种技术通常用于运算符重载,可以在具体实现时不使用默认类型,而重新提供一个类型,如下图:
6.当结构体自身以及结构体实现的接口中都含有同名函数,默认调用的是结构体中的函数,想要调用接口中的函数,可使用下图中的方式:
其中&person指明了是哪个具体类型实现的接口中的函数,但如果这些函数中并没有传入&self,这样调用就会报错了,因为编译器无法知道该调用哪个类型对应的接口实现中的函数,此时可以使用完全限定语法来调用这些同名方法,格式为:<Type as Trait>::function(receiver_if_method,next_arg,…);,如下图所示:
7.可以使用supertrait来要求trait附带其它trait的功能,但需要被依赖的trait也被实现,否则会报错,那个被间接依赖的trait就是当前trait的supertrait,类似于其他语言中继承的概念,如下图所示:
8.可以使用type关键字创建类型别名,如下图所示:
有一个!的特殊类型,称为Never类型,他没有任何值,它在不返回值的函数中充当返回类,如无限循环、抛出错误、或其他导致函数退出的场景,不返回值的函数又称作发散函数。
9.孤儿规则(Orphan Rule)规定了一个trait可以被实现的前提是,trait或实现中的至少一个必须在当前crate中定义,以避免不同crate之间的冲突。但可以使用newtype模式在外部类型上实现外部trait,即利用元组结构体创建一个新的类型,如下图所示,fmt::Display和Vec<String>都是外部的:
Newtype模式通过封装现有类型创建新的类型,优点包括增加类型安全、封装数据、以及扩展功能。它通过定义一个包含现有类型的结构体来实现,这样可以控制对底层数据的访问,同时允许为新类型实现额外的功能或trait。动态大小(Dynamic Size)指的是在编译时无法确定大小的类型,这些类型的大小在程序运行时确定,而不是在编译时,如str类型。Sized trait是一个标记trait,用于指示某个类型的大小在编译时是已知的。默认情况下,Rust中的所有类型都实现了Sized trait,除非它们明确被声明为动态大小类型(DST)。T:?Sized是一个trait bound,用来表示T可以是定长的类型(Sized)或者不定长的类型(?Sized)。这通常用于泛型函数或结构体中,使得它们能够处理不定长类型。宽指针(FatPointer)是Rust中一种包含额外元数据的指针类型,用于引用不定长(?Sized)类型。宽指针不仅包含数据的内存地址,还包括额外的信息,如长度或虚表指针。常见的宽指针包括引用切片(&[T]或&str)和特征对象(&dynTrait)。对于切片,宽指针存储了指向数据的指针和长度;对于特征对象,宽指针存储了数据指针和指向虚表的指针,以便在运行时调用方法。
10.函数指针:可以将函数作为参数传递给其他函数,函数在传递过程中会被强制转换为fn类型,fn类型就是函数指针,如下图所示:
11.Rust中的宏主要分为两类:声明宏和过程宏。声明宏是最常见的一类宏,通过使用 macro_rules!进行定义。它们类似于函数,但操作的是代码而非数据,通常用于生成重复的代码模式。如下图:
过程宏允许通过编写函数来生成代码,它们比声明宏更为灵活和强大,能够进行复杂的代码生成和分析。过程宏可以分为三种类型:自定义派生宏允许为结构体或枚举自动生成代码,通常用于自动实现某些 trait,例如 Debug、Clone等。自定义派生宏通过 #[derive] 属性使用,例如#[derive(Debug)];属性宏允许将宏应用于函数、模块或其他 Rust 项目元素,以实现元编程。它们通过 #[attribute_name] 语法使用; 函数宏类似于声明宏,但它们的输入形式可以更复杂,函数宏可以接受括号内的任意输入,并生成对应的代码。
12.#[repr(transparent)] 是一个用于结构体的属性,它确保该结构体在内存中的布局与其单个字段的类型完全一致。这意味着在 FFI(Foreign Function Interface,外部函数接口)或与 C 语言交互时,Rust编译器不会对该结构体的布局进行任何调整,从而使其与字段的内存布局保持透明。repr(C)和repr(Rust)是Rust中用于控制数据结构内存布局的属性。repr(C)告诉编译器按C语言的内存布局规则排列结构体字段,使其在与C或其他语言的互操作中保持一致。这包括字段的顺序和对齐方式,确保Rust结构体在FFI中可以安全地传递和接收。而repr(Rust)是Rust的默认布局方式,编译器会根据优化的需要对字段进行排列,可能会重新排序字段以减少内存占用或提高访问速度,但这会导致布局不确定,因此不适合FFI。选择repr(C)提供了跨语言的兼容性,而repr(Rust)提供了在Rust内部更好的性能优化。repr(packed)用于紧凑排列结构体字段,忽略默认的内存对齐要求,减少结构体的内存占用,但可能导致未对齐的内存访问问题。repr(align(N)) 是 Rust 中的一个属性,用于将结构体的内存对齐设定为N字节,确保结构体在内存中的地址是 N 的倍数,有助于优化性能。下图示例展示了repr(C)和repr(Rust)的区别:
其他
1.Rust的编译过程始于源代码的解析和词法分析,编译器将源代码分解为标记(tokens),并构建抽象语法树(AST),这反映了代码的语法结构。接着,编译器进行语义分析,主要包括类型检查和借用检查,确保代码遵循Rust的严格类型系统和所有权规则。语义分析阶段还包括将AST转换为中间表示(HIR和MIR),以便进一步优化。在优化阶段,编译器对中间表示(MIR)执行各种优化,如常量折叠和循环展开,以提高代码效率。然后,编译器将优化后的MIR转换为LLVM的中间表示(LLVM IR),并利用LLVM的强大优化功能进一步改进代码,包括寄存器分配和指令合并等。最终,LLVM IR被编译为特定目标架构的机器码,这个阶段生成与硬件相关的二进制指令。编译过程的最后一步是链接,将不同的编译单元合并为最终的可执行文件或库文件。如果启用了增量编译,编译器会缓存中间结果以加快后续编译速度。整体上,Rust的编译过程通过严格的类型检查和多阶段优化,确保生成高效且安全的可执行代码。
2.不同于一些面向语句的编程语言,在Rust里,大多数控制结构(如if、match、loop等)都是表达式,这意味着它们可以返回一个值并直接用于赋值或作为其他表达式的一部分。分号表达式;返回值永远是自身的单元类型:(),分号表达式只有在块表达式的最后一行才会进行求值,其他时候只作为连接符存在。块表达式的值为块中最后一个表达式的值(块中最后一行不加;的表达式),块表达式只对其最后一行表达式进行求值。
3.在Rust中,编译期计算、常量函数和常量泛型是提高代码效率和灵活性的关键概念。它们允许在编译时执行计算,减少运行时开销,并使得代码在编译时就能确定许多值。这对性能至关重要,尤其是在系统编程和嵌入式编程中。常量函数(const fn)是可以在编译时执行的函数,与普通函数不同,const fn可以用于定义常量或在常量表达式中使用,编译器在编译过程中执行这些函数,并将其结果作为常量值嵌入到最终生成的代码中。例如:
const fn square(x: i32) -> i32 { x * x }
const SQUARE_OF_FIVE: i32 = square(5);
square就是一个常量函数,编译器在编译时就计算了SQUARE_OF_FIVE的值,并将其作为常量嵌入到程序中。常量函数还支持嵌套定义,如下图所示:
常量泛型是Rust提供的一种功能,允许在定义泛型类型或函数时使用常量值。通常情况下,泛型参数是类型参数,而常量泛型使得你可以使用整数或其他常量值作为泛型参数。例如:
struct Array<T, const N: usize> { elements: [T; N], }
fn main() {
let arr: Array<i32, 5> = Array { elements: [1, 2, 3, 4, 5] };
}
N是一个常量泛型参数,代表数组的长度。编译器在编译时会处理这个常量,并根据它生成相应的代码。这允许编译时确定数组的大小,而不需要运行时动态分配。
4.位置表达式是指那些能够表示数据的内存位置或地址的表达式,它们通常位于赋值操作的左侧,因为这些表达式可以被赋值,也就是能够“放置”一个值。位置表达式并不直接代表某个具体的值,而是代表某个可以存储值的内存位置。值表达式是指那些直接产生一个值的表达式,与位置表达式不同,值表达式表示的是某个具体的值,而不是一个可以存储值的内存位置。值表达式通常出现在右侧的表达式中,是表达式计算后的结果。常见的位置表达式包括:静态变量的初始化(如static mut LEVELS:u32=0;)、解引用表达式(如*rxpr)、数组索引表达式(形如expr[expr])、字段表达式(形如expr.field)、加上括号的位置表达式(形如(expr))。当位置表达式出现在值表达式的位置时就会发生copy或者所有权的转移。
5.[T]是一种抽象概念,用于描述元素的集合类型,而&[T]是实际使用中的借用类型,允许访问这组元素,切片在处理动态数组或需要获得部分数组的情况下非常有用。
6.NonNull<T>是Rust标准库中的一种指针类型,用于表示一个永远非空的原始指针。这种指针类型确保指针值永远不会是空指针,从而避免了空指针解引用带来的安全问题。但仍可能指向无效内存,因此通常需要在unsafe块中使用。
7.在Rust中,结构体分为具名结构体、元组结构体和单元结构体。具名结构体使用字段名称和类型进行定义,增强代码可读性,适合表示复杂数据结构。例如,struct Person { name: String, age: u32 }。元组结构体类似于元组定义,没有字段名,只有类型和顺序,用于简单封装或类型区分,适合无需复杂命名的简单结构,如struct Color(u8, u8, u8)。单元结构体不包含任何字段,不占用任何内存空间,类似于空元组(),通常用于类型标记或占位符,表示一种类型存在,例如struct Marker。这三种结构体形式满足不同需求,从复杂数据建模到简单类型标识,提供了灵活、多样的定义方式。
8.函数项类型和函数指针类型的主要区别在于语法表示、内存表示、自动转换、捕获环境和性能等方面。前者直接使用函数签名,是编译时确定的值,可以自动转换为后者,并能捕获闭包环境,通常更高效;后者使用fn关键字,是运行时确定的指针,不能自动转换为前者,也不能捕获环境,但可能有一些性能开销。因此,函数项类型更适合编译时确定的函数,函数指针类型更适合运行时确定的函数。
9.在Rust的结构体方法中,使用不同的参数类型会产生不同的效果。如果使用 self 作为参数,则意味着方法会获取结构体的所有权,并将其移动到方法内部,这意味着在方法调用之后,原始的结构体实例将不再可用。相反,如果使用 &self 作为参数,则方法只会借用结构体的引用,而不会获取其所有权,这样可以确保在方法调用之后,原始的结构体实例仍然可以使用。选择合适的参数类型取决于具体的使用场景,如果需要修改结构体的状态,应该使用 self,而如果只需要读取结构体的数据,则应该使用 &self。