《Rust权威指南》学习笔记(三)

泛型和trait

1.泛型可以提高代码的复用能力,泛型是具体类型或其他属性的抽象代替,可以看成是一种模版,一个占位符,编译器在编译时会将这些占位符替换成具体的类型,这个过程叫做"单态化",所以使用泛型的代码和使用具体类型的代码运行速度是一样的。在函数中定义泛型示例如下,在 Rust 中,当对一个切片(如 &[T])进行迭代时,默认情况下会得到切片中每个元素的引用,这是因为 Rust 的迭代器实现通常是以引用的方式遍历元素,而不是消耗它们或获取它们的所有权。

结构体中定义泛型示例如下,可以使用多个泛型的类型参数:

枚举中定义泛型如前文中提到的Option和Result,在方法中定义泛型示例如下,把T放在impl关键字后(impl<T> Point<T>),表示在类型T上实现方法,区别于针对具体类型实现方法(如下面的impl Point<i32>),struct中的泛型类型参数可以和方法的泛型类型参数不同:

2.Trait:用来告诉编译器某种类型具有哪些并且可以与其他类型共享的功能,可以抽象定义共享的行为。trait把函数签名放在一起,trait中只有方法签名而没有具体实现,可以有多个方法,每个方法占一行用;隔开,实现该trait的类型必须提供这些方法的具体实现。如下图所示(注意与为类型实现方法的语法不同,为类型实现trait有for关键字):

可以在某个类型上实现某个trait的前提条件是:这个类型或者这个trait是在本地crate中定义的,无法为外部类型来实现外部trait,这个限制确保其他人不能破坏我们的代码,反之亦然,如果没有这个规则,两个crate可以为同一类型实现同一个trait,Rust就不知道应该使用哪个实现了。trait中可以提供方法的默认实现,默认实现的方法可以调用trait中其他的方法,即使这些其他方法没有默认实现,可以在类型实现trait时重写有默认实现的方法,那么默认实现就会被这些重写覆盖,无法从方法的重写实现里面调用默认的实现。

3.trait可以作为函数参数,表明传入的参数类型需要实现指定的trait,具体写法有impl trait(上)和trait bound(下)两种语法,如下图所示,后者在复杂情况下表现更好:

可以使用+指定多个trait:

对于trait bound语法可以在方法签名后指定where子句增强代码可读性。

还可以将trait作为返回类型,但impl trait只能返回确定的同一种类型,即所有可能的返回类型必须是实现了指定的trait的同一种类型,否则会报错,如下图所示:

4.在使用泛型类型参数的impl块上使用trait bound,可以有条件的为实现了特定trait的类型来实现方法,这样的实现叫做覆盖实现,如下图,是为实现了fmt::Display的类型实现ToString。

生命周期

1.生命周期标注用于描述引用的有效范围,以确保引用不会超出其所引用数据的有效性。在Rust中每个引用都有自己的生命周期,生命周期可以看成是引用保持有效的作用域,大多数情况下生命周期是隐式的、可以被推断的,但当生命周期无法被推断时需要显示指出,否则会报错,如下图所示:

生命周期的标注描述了多个引用的生命周期间的关系但不会改变引用的生命周期长度,当指定了泛型生命周期参数,函数可以接收带有任何生命周期的引用。生命周期标注以'开头,通常后面跟全小写且非常短,生命周期标注在&号之后,使用空格将标注和引用类型分开。如:&'a i32、&'a mut i32。函数签名中的生命周期标注在函数名和参数列表之间的<>内,如上图所示。下图中展示了可能返回生命周期不够长的引用而报错:

从函数返回引用时,返回类型的生命周期参数需要与其中一个参数的生命周期匹配,如果返回的引用没有指向任何参数,那么它只能引用函数内创建的值,这就是悬垂引用,该值在函数结束时就走出了作用域(生命周期可以避免悬垂引用),如下图:

可以将其修改为,不用引用而用String作为返回值就不会报错了,这时直接发生所有权转移:

若在struct中包括了引用则需在每个引用上标注上生命周期,如下图所示:

  • 在struct的方法定义中使用生命周期,其语法和在struct的方法定义中使用泛型的语法是一样的,在impl后面声明,在struct后面使用,这些生命周期是struct类型的一部分,如下图所示:

下图展示了一个混合使用生命周期、泛型的情况:

静态生命周期:'static是一个特殊的生命周期,具有整个程序的持续时间,例如所有的字符串字面值都具有'static生命周期。

2.生命周期的省略:首先我们将函数或方法的参数的生命周期称为输入生命周期,而将其返回值的生命周期称为输出生命周期,编译器使用以下三个规则来隐式地确定生命周期:1每个引用类型的参数都有自己的生命周期;2如果只有1个输入生命周期参数,那么该生命周期被赋给所有的输出生命周期参数;3如果有多个输入生命周期参数,但其中一个是&self或是&mut self(只有方法才会有self),那么self的生命周期会被赋给所有的输出生命周期参数。如果应用上述三个规则后,引用的生命周期仍然模糊不清,则会报错,此时需要手动添加上生命周期。如下图所示,上一个first_word中的生命周期可以被隐式推断,而下一个longest中的生命周期无法被编译器隐式判断:

3.在 Rust 中,使用未初始化的变量是被禁止的,这可以避免一类常见的内存错误,比如使用未初始化内存区域的数据,这可能导致未定义行为或程序崩溃。

4.借用检查器是 Rust 编译器的一部分,用于确保在程序中引用的安全性。它通过静态分析验证所有引用(借用)都遵循 Rust 的所有权规则,通过比较作用域来判断所有的借用是否合法,防止数据竞争和悬垂引用。借用检查器确保引用在使用期间不会被修改或释放,确保程序的内存安全和线程安全。通过这种方式,Rust 提供了强大的内存安全保障而无需运行时开销。Rust中的引用始终引用的是有效数据,引用与usize的倍数对齐,引用可以为动态大小的类型提供上述保障。

5.下图是一个例子,当z=&x被注释掉时会报错,z引用的内容会因为x的重新赋值而失效或变得不再可靠(虽然&x的值没有变,但x本身所指向的堆内存发生改变,导致z失效,借用检查器会防止这种悬垂引用的出现)。

测试

1.测试体函数通常执行三个操作:准备数据/状态、运行被测试的代码、断言(Assert)结果。测试函数需要使用test属性(attribute)进行标注,Attribute就是一段Rust代码的元数据,在函数前加上#[test]可把函数变成测试函数。可以使用cargo test命令运行所有测试函数,Rust会创建一个Test Runner可执行文件,它会运行标注了test的函数,并报告运行是否成功。通常情况下,每个测试运行一个新线程,测试函数引起panic就视为失败,当主线程看见某个测试线程挂掉了,那个测试就被标记为失败了。可以用assert!宏来检查测试结果,测试通过这个宏返回true,测试失败则会调用panic,如下图所示:

还可以用assert_eq!和assert_ne!两个宏来判断两个参数相等或不等,断言失败时这两个宏会自动打印出所传入的两个参数的值,如下图所示:

对于assert!、assert_eq!和assert_ne!这几个宏可以添加自定义错误信息(assert!的第二个参数,assert_eq!和assert_ne!的第三个参数),自定义消息会被传递给format!宏,可以使用{}占位符,如下图所示:

2.测试除了验证代码的返回值是否正确,还需要验证代码是否如预期的处理了发生错误的情况,可验证代码在特定情况下是否发生了panic,可以通过should_panic属性来实现(函数发生了panic则测试通过,反之不通过),还可以给should_panic属性添加一个可选的expected参数(将检查失败消息中是否包含指定的文字),如下图所示:

3.可以不针对panic,而在测试中使用Result<T,E>,返回Ok表示测试通过,返回Err表示测试失败(不要在使用Result<T,E>编写的测试中标注#[should_panic])。如下图所示:

4.控制测试的运行:在用cargo test命令运行测试时,默认是并行运行所有测试,并且对于成功通过的测试,测试函数中的println!等语句的输出会被捕获,不会被输出到屏幕上,只会输出测试失败的函数中的输出语句和失败信息,可以使用cargo test -- --show-output命令(注意是两个--)来确保不管测试成功还是失败所有println!都被输出。可以使用cargo test -- --test-threads=n命令来设置同时运行的测试线程数,n=1时表示串行运行。可以按名称来选择运行某个或一部分测试函数,要运行单个测试函数,直接使用cargo test 测试函数名 命令就可以了,想要运行多个测试可以使用模块名,或者这几个测试函数名的公共前缀。

5.对于某些想要忽略的测试,可以加上ignore属性,这样在使用cargo test命令进行测试时这些测试函数会被忽略,可以使用cargo test-- --ignored命令来运行这些具有ignore属性的测试函数。

6.Rust测试可分为单元测试和集成测试,单元测试专注于对单个模块进行隔离的测试,可以对private接口进行测试,集成测试则类似于外部代码的测试,只能测public接口,通常在测试中使用多个模块进行全面的验证。单元测试需要标注#[cfg(test)],cfg代表 "configuration"即配置。test是配置的条件,表示这部分代码块仅在cargo test或rustc --test(编译带有测试的代码)时有效。集成测试通常放在tests目录下(和src目录在同一级),如下图所示:

tests目录下的每个测试文件都是一个单独的crate,在这些测试函数中无需标注#[cfg(test)],tests目录会被Rust特别对待,只有在运行cargo test命令时才会编译这个目录下的文件。要运行一个特定的集成测试,可以使用cargo test 函数名 命令,运行某个测试文件内的所有测试可使用cargo test --test 文件名 命令。

迭代器和闭包

1.在Rust中,普通函数不能直接捕获和使用定义时的外部变量,函数与其环境是隔离的,所有的输入都必须通过参数传递。因此函数只能使用显式传递给它的参数,而不能直接访问外部作用域中的变量。闭包(closure)是一种可以捕获其环境中的变量的匿名函数,闭包和函数类似,可以保存为变量、作为参数传递给其他函数,或者从函数中返回。与普通函数不同的是,闭包可以捕获并使用其定义所在作用域中的变量,而不需要显式地传递这些变量。如下图中,注释部分的闭包可以使用x,但是函数使用x会报错:

闭包语法为:let closure_name = |参数列表(参数之间用,分隔,可以在每个参数后用:标注类型)| -> 返回类型 { // 闭包体 };,闭包一般不要求标注参数类型或返回类型,因为闭包通常很短小,只在狭小的上下文中工作,编译器通常能推断出类型。下图是一个使用闭包的示例:

下图展示了闭包和函数语法形式的区别,第一个为函数,后面三个为闭包:

闭包的定义最终只会为参数/返回值推断出唯一具体的类型,如下图所示情况就会报错:

可以将闭包作为结构体的成员,如下图所示,其中where T: Fn(u32) -> u32指定了泛型参数T的约束条件,它表示T必须是一个闭包,该闭包接收一个u32参数并返回一个u32类型的值。换句话说,T必须实现了Fn(u32) -> u32这个特征。成员是闭包时使用Fn、FnMut或FnOnce特征表示,能够捕获并使用定义时的环境变量,适用于需要环境上下文的场景。结构体成员还可以是函数,函数则用fn关键字定义类型,例如fn(i32) -> i32,可以看成是一个函数指针,函数不能捕获环境变量,仅用于简单函数调用。

2.在Rust中,闭包根据其捕获变量的方式分为三种类型:Fn、FnMut和FnOnce,它们之间的区别在于如何处理捕获的变量。Fn闭包通过不可变引用(&T)捕获变量,可以多次调用且不会修改捕获的变量;FnMut闭包通过可变引用(&mutT)捕获变量,可以多次调用并修改捕获的变量;FnOnce闭包通过按值(T)捕获变量,可能会消耗捕获的变量(但对于i32等实现copy trait的类型仍然是复制操作,也就是说原来的变量仍然能使用),因此只能调用一次。所有闭包至少实现了FnOnce,因为闭包在某些情况下可能需要移动或消耗捕获的变量,即使没有实际移动。Rust会根据闭包如何捕获和使用变量来自动推断其具体类型,确保闭包在使用时的安全性和灵活性。通过这种自动推断机制,Rust确保了闭包在处理不同的上下文时具有适当的行为约束,从而避免了潜在的并发和所有权问题。

3.在参数列表前使用move关键字,可以强制闭包取得它所使用的环境值的所有权(对闭包中所有捕获的变量都有效),如下图所示, 没有使用move的情况下,闭包可能以借用的方式(不可变或可变)捕获 Vec,这取决于闭包的使用方式。

4.在Rust中,迭代器(Iterator)是一个抽象的概念,用于遍历集合中的每一个元素,而不需要手动管理索引或其他迭代状态。迭代器提供了一种统一的接口来处理不同类型的集合数据结构,比如数组、向量、链表等。Rust中的迭代器是通过实现Iterator trait来定义的。Iterator trait包含了一个主要的方法next,它返回Option<Self::Item>,其中Self::Item是迭代器的元素类型(pub trait Iterator { type Item; fn next(&mut self) -> Option<Self::Item>; },这个Iterator trait是在标准库中定义的,可以通过实现这个trait来自定义迭代器)。每次调用next时,迭代器会返回集合中的下一个元素,返回结果包裹在Some里,如果所有元素都已经遍历完,则返回None(即返回的是Option类型)。一些方法会"消耗"迭代器,即它们会调用 next 方法以获取每一个元素并进行某种操作。这些方法包括 collect、sum、count 等。例如collect方法可以将迭代器的元素收集到一个集合类型中。迭代器还提供了一些不会立即消耗迭代器的方法,称为"惰性适配器"。这些方法返回一个新的迭代器,通常是通过对原始迭代器的元素进行某种转换或过滤。常见的惰性适配器包括 map、filter、take 等。由于迭代器是惰性计算的,你可以将多个惰性适配器链接在一起形成复杂的操作链,然后通过调用一个消耗型适配器来最终执行这些操作。如下图所示:

5.在Rust中,iter、into_iter和iter_mut是三种用于迭代的方法,它们的主要区别在于对集合元素的处理方式以及所有权的转移。iter方法用于借用集合,返回一个不可变引用的迭代器。这意味着可以遍历集合中的每个元素,但不能修改它们。这个方法适用于只需要读取数据而不改变集合内容的场景。iter_mut则类似,但它返回一个可变引用的迭代器,允许在遍历过程中修改集合中的元素。iter_mut适用于需要在遍历时对集合内容进行更改的情况。相比之下,into_iter将集合的所有权转移到迭代器,这意味着集合在遍历过程中会被消耗,之后不能再使用。这种方式适合需要在遍历过程中处理或转移集合元素所有权的场景。例如当想要将集合中的元素移动到另一个数据结构时into_iter是理想选择。使用iter和iter_mut时,集合在迭代后仍然保持不变,适合只读或原地修改的情况,而into_iter则适合需要所有权转移的操作。

6.filter方法是迭代器的一种惰性适配器,它用于筛选迭代器中的元素。filter会对迭代器中的每个元素应用一个闭包或函数,并保留那些返回true的元素,形成一个新的迭代器。它不会立即执行过滤操作,直到调用一个消耗型方法(如collect)时,操作才会被实际执行;map方法是一种常用的迭代器适配器,它可以对迭代器的每个元素应用一个闭包或函数,并返回一个新的迭代器,这个新的迭代器中的元素是应用闭包后的结果; zip方法是一种迭代器适配器,用于将两个迭代器"压缩"在一起,形成一个新的迭代器。这个新的迭代器的每个元素都是一个元组,元组的元素分别来自原始两个迭代器的对应位置;collect()方法是一个非常强大的迭代器消耗型适配器,它用于将迭代器转换为集合类型,例如Vec、HashMap、String等,collect()方法会消耗迭代器中的所有元素,并将它们收集到指定的集合类型中。filter、zip、map这些方法都不会直接修改或消耗原始迭代器,它们只是返回一个新的惰性迭代器,而collect则是一个消耗型方法,它会耗尽迭代器的所有元素。

7.Rust 中的零开销抽象通过编译时优化和静态分发等技术实现,使得高级抽象特性(如泛型、特性、闭包等)不会影响程序的运行效率。编译器在生成最终机器代码时,会消除不必要的开销,从而确保抽象层的使用不会引入性能损失。

相关推荐
我爱挣钱我也要早睡!1 分钟前
Java 复习笔记
java·开发语言·笔记
知识分享小能手3 小时前
React学习教程,从入门到精通, React 属性(Props)语法知识点与案例详解(14)
前端·javascript·vue.js·学习·react.js·vue·react
汇能感知5 小时前
摄像头模块在运动相机中的特殊应用
经验分享·笔记·科技
阿巴Jun5 小时前
【数学】线性代数知识点总结
笔记·线性代数·矩阵
茯苓gao5 小时前
STM32G4 速度环开环,电流环闭环 IF模式建模
笔记·stm32·单片机·嵌入式硬件·学习
是誰萆微了承諾5 小时前
【golang学习笔记 gin 】1.2 redis 的使用
笔记·学习·golang
DKPT6 小时前
Java内存区域与内存溢出
java·开发语言·jvm·笔记·学习
aaaweiaaaaaa6 小时前
HTML和CSS学习
前端·css·学习·html
ST.J6 小时前
前端笔记2025
前端·javascript·css·vue.js·笔记
Suckerbin7 小时前
LAMPSecurity: CTF5靶场渗透
笔记·安全·web安全·网络安全