讲动人的故事,写懂人的代码
几天前,Rust官网发布了1.81.0稳定版的发布报告(blog.rust-lang.org/2024/09/05/Rust-1.81.0.html)。
小吾很关注这次新发布的稳定版,能帮程序员避什么坑。
发布报告下面这段新特性的描述,吸引了小吾的注意。
新的排序实现 标准库中的稳定和不稳定排序实现都已更新为新算法,提高了它们的运行时性能和编译时间。 此外,这两种新的排序算法都试图检测
Ord
的不正确实现,这些实现会阻止它们产生有意义的排序结果,现在在这种情况下会引发panic,而不是返回实际上随机排列的数据。遇到这些panic的用户应该审核他们的排序实现,以确保它们满足PartialOrd
和Ord
文档中所记录的要求。
❓什么是稳定排序?什么是不稳定排序?
在稳定排序中,相等元素的相对顺序在排序前后保持不变。例如,如果有两个相等的元素 A 和 B,且 A 在排序前位于 B 之前,那么在排序后 A 仍然会位于 B 之前。通常需要额外的内存来保存原始顺序信息。适合多级排序,如先按年龄排序,再按姓名排序。结果更可预测,尤其是在处理复杂数据结构时。可能比不稳定排序慢。除了适合多级排序,还适合需要保持原始顺序的重要性时,如保持用户输入的顺序;也适合处理复杂数据结构,如排序包含多个字段的结构体。
在不稳定排序中,相等元素的相对顺序可能会改变。排序后,A 可能会出现在 B 之前或之后。通常可以原地排序,不需要额外内存。通常更快,内存使用更少。不适合需要保持原始顺序的场景,多级排序时可能产生不直观的结果。适合性能关键的场景,当排序速度是首要考虑因素时;内存受限的环境,当额外内存使用是个问题时;排序简单数据类型,如整数数组;单次排序,不需要考虑多级排序的情况。
这看起来很好呀。之前“Ord
的不正确实现”,会默默返回“实际上随机排列的数据”。在1.81.0之后,就能引发panic中止程序,提醒程序员修复bug,帮程序员避坑。
❓什么是panic
rust的panic是一种错误处理机制,用于处理程序遇到无法恢复的错误情况。它是程序遇到无法继续执行的情况时的一种反应。它会导致当前线程,通常是整个程序的突然终止。当 panic 发生时,程序会开始"展开"(unwind)调用栈。它会打印错误信息和调用栈跟踪。清理当前线程的资源(调用析构函数)。默认情况下,整个程序会在此终止。
那该如何验证一下这个新特性是否真的能帮程序员避坑?
我们可以做一个实验。先分别安装rust 1.80.1和1.81.0两个版本,以便比较运行差异。然后编写一段正常排序的代码。之后引入Ord
的不正确实现,并假设这个不正确实现能在1.81.0下引发panic。最后观察实验结果。
如果还没有在电脑上安装rust,可以参考rust官网的指导(www.rust-lang.org/tools/install)进行安装。安装好后,可以运行rustc --version
进行验证。如果看到类似“rustc 1.81.0 (eeb90cda1 2024-09-04)”的输出,就说明安装成功。
接下来,可以运行下面两行命令,分别安装两个版本的rust。
rustup toolchain install 1.81.0
rustup toolchain install 1.80.1
如何验证两个版本是否安装成功?可以运行下面的命令。
rustup toolchain list
如果看到类似下面的输出,且里面有那两个版本,就说明安装成功了。
stable-aarch64-apple-darwin
1.80.1-aarch64-apple-darwin
1.81.0-aarch64-apple-darwin (default)
下面我们以专业rust程序员最常使用的良好实践为标准,写一段正常排序的代码,如代码清单1所示。
代码清单1 专业rust程序员用最常使用的良好实践所编写的正常排序的代码
1 use std::cmp::Ordering;
2
3 #[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
4 struct GoodOrd(i32);
5
6 fn main() {
7 let mut vec = vec![GoodOrd(3), GoodOrd(2), GoodOrd(4), GoodOrd(1)];
8
9 println!("Before sorting: {:?}", vec);
10
11 vec.sort();
12
13 println!("After sorting: {:?}", vec);
14
15 // Demonstrating correct ordering
16 assert!(GoodOrd(1) < GoodOrd(2));
17 assert!(GoodOrd(2) > GoodOrd(1));
18 assert!(GoodOrd(2) == GoodOrd(2));
19
20 println!("All assertions passed!");
21 }
// Output:
// Before sorting: [GoodOrd(3), GoodOrd(2), GoodOrd(4), GoodOrd(1)]
// After sorting: [GoodOrd(1), GoodOrd(2), GoodOrd(3), GoodOrd(4)]
// All assertions passed!
要把代码清单1运行起来,并看到类似代码后边注释里的打印输出,有两种办法。
第一种办法是在mycompiler.io网页上运行。
打开www.mycompiler.io/new/rust网页,把代码清单1所对应的没有行号的代码(可以克隆github.com/wubin28/wuzhenbens_playground代码库,进入wuzhenbens_playground文件夹,再进入文件夹new_sort_implementations_in_1_81_0_stable_rust,将git切换到80ade30提交,找到main.rs源文件,就能看到代码清单1),复制粘贴到网页左侧。然后点击网页右上角的Run按钮即可运行。
第二种办法是在本地电脑上运行。
用上面第一种方法找到没有行号的代码,然后用任何喜爱的IDE(比如Cursor、vscode或rustrover),打开这个main.rs文件。
要想运行这个文件,可以在终端的new_sort_implementations_in_1_81_0_stable_rust文件夹下,运行命令cargo run
即可。要是你改动了代码,可以先运行cargo fmt
格式化代码,然后运行cargo build
进行编译构建,最后再运行cargo run
运行程序。
如果你想从零开始,构建这个项目,可以在一个新项目文件夹中,运行命令cargo new new_sort_implementations_in_1_81_0_stable_rust
,再进入文件夹new_sort_implementations_in_1_81_0_stable_rust
,你就能看到src
文件夹下,有一个main.rs文件。里面有一个hello world程序。此时你可以运行cargo run
运行一下。之后,就可以把代码清单1所对应的没有行号的代码,复制粘贴进去,然后运行cargo fmt
格式化代码,再运行cargo build
进行编译构建,最后再运行cargo run
运行程序。
代码运行起来后,如果能看到类似代码后边注释掉的打印输出,说明程序就能运行了。
代码清单1演示了自定义结构体的排序功能。
第1行引入了rust标准库的std::cmp::Ordering
模块,用于比较和排序。
第4行定义了一个名为GoodOrd
的结构体,它包含一个i32
类型的值。
❓什么是结构体?
结构体(
struct
)是Rust中用于创建自定义数据类型的一种方式。"struct
"是"Structure"的简写,可以理解为"结构"或"结构体"。结构体具有以下特点。自定义数据类型,允许开发者创建包含多个相关值的复合数据类型。命名字段,每个字段都有一个名称和类型。灵活性,可以包含不同类型的数据。内存布局,字段在内存中是连续存储的。可以实现结构体的方法和关联函数。
结构体具有以下优势。组织相关数据,将相关的数据组合在一起,提高代码的可读性和维护性。类型安全,编译器可以检查结构体字段的类型正确性。封装,可以通过
pub
关键字控制字段的可见性。方法实现,可以为结构体实现方法,增强面向对象编程能力。内存效率,结构体的内存布局是连续的,访问效率高。结构体也有以下劣势。内存对齐,可能导致一些内存浪费(虽然这通常不是大问题)。修改限制,一旦创建,结构体实例的字段默认是不可变的。复杂性,在某些简单场景下,使用结构体可能会增加不必要的复杂性。
结构体适用于以下场景。表示复杂的数据结构,如用户信息、配置选项等。实现自定义类型,当内置类型无法满足需求时。面向对象编程,结构体可以实现方法,类似于面向对象语言中的类。数据封装,将相关数据组织在一起,并控制访问权限。API设计,作为函数参数或返回值,提供清晰的接口。游戏开发,表示游戏中的实体、状态等。科学计算,表示复杂的数学模型或数据结构。
第3行为GoodOrd
结构体派生了Debug
、Eq
、PartialEq
、Ord
和PartialOrd
trait。这些trait使得结构体可以进行比较和排序操作。
❓什么是trait?
trait 是 Rust 中定义共享行为的方式。它类似于其他编程语言中的接口(interface)概念,但有一些独特的特性。Trait 定义了一组方法签名,可以被不同的类型实现。
trait具有以下特点。抽象行为,定义一组方法,而不提供具体实现。可以有默认实现,trait可以为方法提供默认实现。泛型约束,可以用作泛型约束,限制类型必须实现特定的trait。可以被动态分发,通过 trait 对象实现运行时多态。组合能力,可以通过组合多个 trait 来定义复杂的行为。关联类型,可以在 trait 中定义关联类型。
虽然Rust不支持传统意义上的类继承,但trait之间可以有类似继承的关系,即subtrait关系。另外,标记trait(marker trait)是没有任何方法的trait,用于标记类型具有某些属性。
trait具有以下优势。代码复用,允许多个类型共享相同的行为。抽象能力,可以编写不依赖具体类型的通用代码。灵活性,可以为现有类型实现新的trait,即使是在外部crate中定义的类型。组合高于继承,通过组合多个trait实现复杂行为,避免了继承的一些问题。静态分发,编译器可以进行单态化,提高运行时性能。动态分发,通过trait对象支持运行时多态。
trait也有以下劣势。复杂性,在某些情况下,trait的组合可能会导致代码变得复杂。编译时间,大量使用泛型和trait可能会增加编译时间。局限性,某些复杂的设计模式在Rust的trait系统中可能难以实现。
trait适用于以下场景。定义共享行为,当多个类型需要实现相同的功能时。泛型编程,编写可以操作多种类型的通用代码。抽象接口,定义模块或库的公共API。面向对象编程,实现类似于接口的功能。运行时多态,通过trait对象实现动态分发。扩展现有类型,为第三方类型添加新的功能。组合行为,通过组合多个trait来定义复杂的行为。类型约束,在泛型函数或结构体中限制类型必须实现特定的行为。
为何在GoodOrd(i32)结构体前面,派生那么多trait?
先看派生的这些trait,都能干啥。
Debug
trait,允许使用 {:?}
格式说明符打印结构体。这使得程序员可以轻松地打印GoodOrd
实例,对调试很有帮助。
PartialEq
、Eq
、PartialOrd
与Ord
这四个trait都与比较操作有关,但它们各自处理不同的比较方面。PartialEq
和 Eq
处理相等性比较,PartialOrd
和 Ord
处理顺序比较。
PartialEq
定义了部分相等关系,是最基本的相等性比较trait。所具有的主要方法有必须由实现者提供的eq()
和有默认实现的 ne()
。允许存在"部分相等"的概念,即可能有些值无法比较。实现了 PartialEq
的类型可以使用 ==
和 !=
运算符。用于大多数需要相等性比较的场景,适用于浮点数等可能存在特殊值(如NaN
)的类型。
Eq
是 PartialEq
的subtrait。加强了 PartialEq
的要求,表示全等关系(equivalence relation,是一种二元关系,满足以下三个性质。自反性,对于任何元素a,a ∼ a。这里的波浪线代表“等价”,比“等于”更宽泛。对称性,如果a ∼ b,则b ∼ a。传递性:如果a ∼ b且b ∼ c,则a ∼ c。)。没有额外的方法,是一个标记trait。保证自反性,即对任何值 x
,x == x
总是为 true
。用于需要确保完全相等性的场景,常用于哈希映射的键类型。
PartialOrd
是PartialEq
的subtrait。它在部分相等性的基础上,增加了部分顺序比较。要求类型也实现 PartialEq
。主要方法有必须由实现者提供的partial_cmp
,返回 Option<Ordering>
。它的lt
、le
、gt
和ge
方法都有默认实现。允许存在无法比较的情况。用于可能存在不可比较值的顺序比较,适用于浮点数等类型。
Ord
是 Eq
和PartialOrd
的subtrait。定义了全序关系(total order,是一种二元关系,满足以下四个性质。反对称性,如果a ≤ b且b ≤ a,则a = b。传递性,如果a ≤ b且b ≤ c,则a ≤ c。完全性或连通性,对于任意a和b,要么a ≤ b,要么b ≤ a。反自反性,如果a ≠ b,那么a < b或b < a必有一个成立)。要求类型也实现 Eq
、PartialOrd
和PartialEq
(间接subtrait)。主要方法有必须由实现者提供的cmp
,返回 Ordering
。它的max
、min
和clamp
方法都有默认实现。保证任意两个值都可以比较。用于需要完全排序的场景(如排序算法),可以作为某些集合类型(如 BTreeMap
)键的要求。
这4个trait的关系图如图1所示。
图1 PartialEq
、Eq
、PartialOrd
与Ord
这四个trait之间的subtrait和supertrait关系
那为什么需要这么多traits?因为下面一些原因。
PartialEq
, Eq
, PartialOrd
, 和 Ord
一起提供了完整的比较功能,允许相等性检查和排序。Ord
trait是vec.sort()
方法所必需的。没有它,向量就不能自动排序。Debug
trait使得在开发过程中可以轻松打印和检查GoodOrd
实例。GoodOrd
类型具有预期的行为,减少了运行时错误的可能性。图1中四个trait的subtrait和supertrait关系出现了菱形。这会不会导致C++臭名昭著的菱形继承问题?
❓多个trait的subtrait和supertrait关系如果出现了菱形,会不会导致菱形继承问题
多重继承在C++中可能会导致以下问题。
菱形继承(Diamond Problem)。这是最常见的问题。当一个类从两个不同的类继承,而这两个类又有一个共同的基类时,就会出现菱形继承,如图2所示。
图2 C++中的菱形继承问题
在图2中,D类会继承A类的两个副本,一个通过B,另一个通过C。这可能导致歧义和因继承导致的数据冗余。
名称冲突。如果两个基类有相同名称的成员或方法,派生类可能会面临歧义。
复杂性增加。多重继承可能使类的设计变得复杂,难以理解和维护。
这些问题的危害包括代码复杂性增加、潜在的运行时开销、可能的逻辑错误,以及可维护性降低。
在Rust中,不存在C++中那样的类,但有能起到相似作用且更加灵活的trait。trait的subtrait与supertrait机制与C++的类继承有很大不同。Rust使用trait作为接口,而不是类。可以回顾一下代码清单1中那四个trait。
PartialEq
定义部分等价关系。Eq
是PartialEq
的subtrait,定义完全等价关系。PartialOrd
是PartialEq
的subtrait,定义部分顺序关系。Ord
是Eq
和PartialOrd
的subtrait,定义完全顺序关系。在Rust中,这种继承关系由于以下原因,不会导致C++中多重继承的典型问题。
没有菱形继承问题。Rust的trait不包含数据,只定义行为,所以不会出现因继承导致的数据冗余。
不存在状态继承。trait只定义接口,不继承状态。
名称冲突解决。Rust有明确的解决方案,如完全限定语法。
实现清晰。Rust要求显式实现trait,减少了隐式行为。
在图中,
Ord
虽然是Eq
和PartialOrd
的subtrait,但这不会导致C++式的多重继承问题。Rust的trait系统设计避免了这些问题。
上面提到,PartialEq::eq
、PartialOrd::partial_cmp
和Ord::cmp
方法都是required,即必须由实现者提供,那么为何代码清单1中的代码,却没有实现它们?
❓trait的required方法为何没有实现
虽然
PartialEq::eq
、PartialOrd::partial_cmp
和Ord::cmp
方法必须由实现者提供,但在代码清单1中,这些方法是通过派生宏(derive macro)自动实现的。 在 Rust 中,#[derive(...)]
属性是一个强大的功能,它允许编译器自动为简单的结构体和枚举实现某些 trait。代码清单1中第3行告诉编译器,为GoodOrd
自动实现Debug
、Eq
、PartialEq
、Ord
和PartialOrd
trait。对于像
GoodOrd
这样的单字段结构体(称为元组结构体),派生宏会生成基于该字段类型(在这里是i32
)的默认实现。i32
已经实现了这些 trait,所以派生宏可以直接使用i32
的实现来为GoodOrd
生成相应的方法。生成的代码等效于下面的代码,如代码清单2所示。代码清单2 派生宏所生成的等效代码
1 impl PartialEq for GoodOrd {
2 fn eq(&self, other: &Self) -> bool {
3 self.0 == other.0
4 }
5 }
6
7 impl Eq for GoodOrd {}
8
9 impl PartialOrd for GoodOrd {
10 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
11 self.0.partial_cmp(&other.0)
12 }
13 }
14
15 impl Ord for GoodOrd {
16 fn cmp(&self, other: &Self) -> Ordering {
17 self.0.cmp(&other.0)
18 }
19 }
这种方法大大简化了代码,减少了样板代码。对于简单的数据结构,自动派生通常就足够了。它确保了实现的正确性,避免了手动实现可能引入的错误。
但这种方法也有一些限制。派生宏只能为相对简单的情况生成实现。对于需要自定义行为的复杂类型,仍然需要程序员手动实现这些 trait。
第6-21行main
函数创建了一个包含GoodOrd
实例的向量vec
。然后打印排序前的向量。接着使用sort()
方法对向量进行排序。之后打印排序后的向量。接下来使用断言来验证GoodOrd
实例之间的比较是否正确(检查小于、大于和相等关系)。最后,如果所有断言都通过,打印成功信息。
❓什么是向量
在Rust中,向量被称为"Vector",通常简写为"Vec"。它是一种可增长的数组类型,可以存储相同类型的多个值。
向量具有以下特点。动态大小,可以在运行时增加或减少元素。连续存储,元素在内存中连续存放。类型安全,只能存储相同类型的元素。索引访问,可以通过索引快速访问元素。所有权语义,遵循Rust的所有权规则。
可以使用下面两种方式,来分别创建空向量,以及用宏来创建并初始化向量。
let mut vec = Vec::new(); // 创建空向量
let vec = vec![1, 2, 3]; // 使用宏创建并初始化
可以像下面那样用栈的方式添加和删除向量元素。当然也可以用其他非栈的方式,但通常速度较慢。
vec.push(4); // 在末尾添加元素
vec.pop(); // 移除并返回最后一个元素
访问元素的方式可以有下面两种。
let third = vec[2]; // 通过索引访问
let third = vec.get(2); // 安全访问,返回Option<&T>
向量具有以下优势。灵活性,可以动态调整大小。效率,对于大多数操作,性能接近数组。安全性,提供边界检查,防止越界访问。功能丰富,标准库提供了多种有用的方法。
向量也有下面的劣势。内存开销,比固定大小的数组略高。性能,某些操作(如在中间插入)可能较慢。不能直接用于FFI(Foreign Function Interface,外部函数接口,是一种机制,允许一种编程语言编写的代码调用另一种编程语言编写的代码),与C语言交互时需要转换。
向量适用于以下场景。需要动态增长的数据集合。需要频繁添加或删除元素的情况。不确定最终元素数量的场景。需要按索引快速访问元素的情况。实现栈或队列等数据结构。
这个例子展示了如何为自定义类型实现排序功能,这在Rust中是一个常见且有用的模式。
代码清单1的第7行,创建了一个可变的向量vec
,其中的4个元素是 GoodOrd
结构体的实例。let mut vec
声明了一个名为 vec
的可变变量。mut
关键字表示这个变量是可以修改的,这是因为后面要进行向量本身的结构修改(即元素重新排序)。vec!
是 Rust 的宏,用于创建一个向量。[GoodOrd(3), GoodOrd(2), GoodOrd(4), GoodOrd(1)]
是向量的初始内容。这里创建了四个 GoodOrd
结构体的实例,每个实例都包含一个i32
类型的整数值。
第9行用于在控制台输出向量 vec
的内容。println!
是 Rust 的宏,用于向标准输出(通常是控制台)打印文本。双引号中的 "Before sorting: {:?}" 是要打印的格式化字符串。"Before sorting: " 是固定的文本,会原样输出。{:?}
是一个格式化占位符,用于打印复杂类型的内容。,vec
是传递给 println!
宏的参数,它会被插入到格式化字符串的 {:?}
占位符位置。
{:?}
中的 :?
是 Debug 格式说明符。它告诉 Rust 使用 Debug
trait 来格式化 vec
。这对于打印复杂类型(如结构体、枚举或容器)特别有用。
使用 Debug
格式打印 vec
是可能的,因为第3行 GoodOrd
结构体已经通过 #[derive(Debug)]
自动实现了 Debug
trait。
第11行 vec.sort();
是对向量 vec
进行排序的操作。.sort()
是 Rust 标准库中 Vec<T>
类型的一个方法,用于对向量进行原地排序(in-place sorting)。这个方法会直接修改原向量,不会创建新的向量。这就是为什么 vec
需要声明为可变(mut
)的原因。
sort()
方法默认使用元素类型实现的 Ord
trait 来进行比较和排序。在这个例子中,GoodOrd
结构体通过 #[derive(Ord, PartialOrd)]
自动实现了 Ord
trait。排序是按照升序进行的,也就是从小到大排列。对于 GoodOrd
结构体,排序会基于其内部的 i32
值进行。
从代码清单1后面注释里的运行结果能够看出,排序前的向量是:
[GoodOrd(3), GoodOrd(2), GoodOrd(4), GoodOrd(1)]
排序后,它会变成:
[GoodOrd(1), GoodOrd(2), GoodOrd(3), GoodOrd(4)]
这个排序操作展示了几个重要的 Rust 特性。
使用 sort()
方法是 Rust 中对向量进行排序的简单有效的方式,它利用了语言和标准库的特性来提供类型安全和高效的排序功能。
第16-18这三行代码使用了 Rust 的 assert!
宏来验证 GoodOrd
结构体的比较行为是否符合预期。第16行断言 GoodOrd(1)
小于 GoodOrd(2)
。它验证了 <
运算符对 GoodOrd
实例的正确实现。这个断言期望 GoodOrd
的比较基于其内部的整数值。第17行断言 GoodOrd(2)
大于 GoodOrd(1)
。它验证了 >
运算符对 GoodOrd
实例的正确实现。这个断言进一步确认了比较的一致性和正确性。第18行断言两个 GoodOrd(2)
实例是相等的。它验证了 ==
运算符对 GoodOrd
实例的正确实现。这个断言确保具有相同内部值的 GoodOrd
实例被视为相等。
这些断言有以下目的。验证 GoodOrd
结构体正确实现了比较操作。确保 <
、>
、和 ==
运算符的行为符合预期。通过测试不同的比较场景来增加代码的可靠性。
这些断言验证了 #[derive(Eq, PartialEq, Ord, PartialOrd)]
自动实现的trait的结果。这个派生宏为 GoodOrd
结构体生成了比较和相等性的实现,基于其内部的 i32
值。
如果任何一个断言失败,程序将会 panic,这有助于在开发过程中快速发现和定位问题。在这个例子中,所有的断言都应该通过,因为它们反映了整数的自然排序顺序。
这种做法体现了 Rust 编程中的一个好习惯,即使用断言来验证关键的程序行为,增强代码的正确性和可靠性。
什么是断言?与单元测试有什么区别和联系?
❓什么是断言?与单元测试有什么区别?
断言(assertion)是在程序中插入的一种检查,用于验证某个条件是否为真。
在 Rust 中,断言通常使用
assert!
宏。如果断言失败,程序通常会立即终止或抛出异常。断言是程序代码的一部分,在正常执行流程中运行。下面是一些断言的常用用法。
assert!(condition);
assert_eq!(value1, value2);
assert_ne!(value1, value2);
断言具有以下优势。快速捕获和定位错误。作为程序自我检查的机制。可以作为文档的一部分,说明代码的预期行为。
断言也有一些劣势。在生产环境中可能会影响性能。如果没有适当处理,可能导致程序意外终止。
断言适用于以下场景。验证函数的前置条件和后置条件。检查重要的不变量。在开发和调试阶段进行快速验证。
单元测试(unit test)是针对程序中最小可测试单元(通常是函数或方法)编写的独立测试。
单元测试通常存在于单独的测试模块或文件中。使用专门的测试框架和工具运行。不会影响正常的程序执行流程。
下面是Rust单元测试的常用用法。
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_some_function() {
assert_eq!(some_function(input), expected_output);
}
}
单元测试具有以下优势。可以全面测试各种情况,包括边界条件和异常情况。有助于重构和维护代码。可以作为回归测试的一部分。不影响生产代码的性能。
单元测试也有一些劣势。编写和维护相比断言需要更多的时间和努力。可能无法捕获集成或系统级别的问题。
单元测试适用于以下场景。验证单个函数或组件的正确性。测试各种输入和边界条件。在持续集成/持续部署(CI/CD)流程中自动化测试。
断言和单元测试之间到底有什么区别和联系?可以考虑下面几个方面。
运行时机。断言在程序运行时执行,而单元测试在开发和测试阶段单独运行。
范围。断言通常用于验证单个条件,而单元测试可以更全面地测试一个函数的行为。
影响。断言可能影响程序的正常运行,而单元测试不会影响生产代码的执行。
维护。单元测试需要单独维护,而断言是代码的一部分。
详细程度。单元测试通常更详细,可以测试多种情况,而断言往往更简单直接。
在实际开发中,这两种方法通常是互补的。断言用于捕获运行时的意外情况,而单元测试用于更系统地验证代码的正确性。结合使用这两种方法可以显著提高代码的质量和可靠性。
前面提到,断言”在生产环境中可能会影响性能”,而且“如果没有适当处理,可能导致程序意外终止”,那么在生产级别的代码中,是不是应该尽量减少断言?
❓在生产级别的代码中,是否应该尽量减少断言
在生产级别的代码中,该如何使用断言,涉及到软件开发中的一个常见权衡问题。需要考虑以下几个方面。
性能影响。断言会带来一定的性能开销,特别是在频繁执行的代码路径上。然而,这个开销通常是很小的,在大多数情况下可能微不足道。
安全性和正确性。断言可以帮助及早发现和诊断问题,防止错误状态进一步扩散,这对于维护系统的整体健康和正确性非常重要。
编译时优化。许多编程语言(包括 Rust)在发布模式(release mode)下会自动禁用或优化掉断言,从而消除生产环境中的性能影响。
关键检查。某些断言可能对于程序的正确性至关重要,即使在生产环境中也应该保留。
考虑到这些因素,以下是一些在生产代码中使用断言的避坑策略。
保留关键断言。对于保证程序正确性和安全性至关重要的检查,应该保留断言,即使在生产环境中也是如此。
使用条件编译。可以使用条件编译来控制哪些断言在生产环境中保留。例如在 Rust 中可以像下面那样写条件编译的断言。
#[cfg(debug_assertions)]
assert!(condition, "This assertion only runs in debug mode");
// 这个断言总是会执行
debug_assert!(important_condition, "This is a critical check");
分级断言。可以根据断言的重要性和性能影响进行分级,只在生产环境中保留最关键的断言。
使用日志替代。对于一些不太关键但仍然有用的检查,可以考虑将它们转换为日志语句,而不是使用断言。
性能关键路径。在性能特别敏感的代码路径上,可以考虑移除或优化断言,但要确保通过其他方式(如单元测试)充分验证这部分代码的正确性。
监控和错误报告。在生产环境中,可以将断言失败转化为错误日志或报告,而不是直接终止程序。
在生产级别的代码中,不应该完全避免使用断言,而是应该谨慎和策略性地使用它们。关键是要在性能、安全性和代码可维护性之间找到平衡。保留重要的断言可以帮助及早发现问题,提高系统的健壮性。同时,通过编译时优化和条件编译,可以最小化断言对性能的影响。
最后,记住断言是防御性编程的一部分,它们与良好的错误处理、日志记录和监控系统一起,构成了保障软件质量的综合策略。
Ord
的不正确实现能在1.81.0下引发panic还记得rust 1.81.0发布报告里那句话吧。
稳定和不稳定排序的新的排序算法,都试图检测
Ord
的不正确实现,这些实现会阻止它们产生有意义的排序结果,现在在这种情况下会引发panic,而不是返回实际上随机排列的数据。
代码清单1中第11行,就是一个稳定排序。
为了验证这个新特性是否真的能帮程序员避坑,可以做下面的假设。
假设在代码清单1中引入
Ord
的不正确的实现,那么当在rust 1.81.0中运行这样的代码时,会引发panic。
为了让Ord
的不正确的实现更加离谱,可以把PartialEq
和PartialOrd
的实现也一并搞得不正确。如代码清单3所示。
代码清单3
1 use std::cmp::Ordering;
2
3 #[derive(Debug)]
4 struct BadOrd(i32);
5
6 impl PartialEq for BadOrd {
7 fn eq(&self, other: &Self) -> bool {
8 // Intentionally inconsistent equality
9 self.0 % 2 == other.0 % 2
10 }
11 }
12
13 impl Eq for BadOrd {}
14
15 impl PartialOrd for BadOrd {
16 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
17 // Violates consistency, transitivity, and duality
18 if self.0 % 2 == 0 && other.0 % 2 != 0 {
19 Some(Ordering::Less)
20 } else if self.0 % 2 != 0 && other.0 % 2 == 0 {
21 Some(Ordering::Greater)
22 } else if self.0 == other.0 {
23 Some(Ordering::Equal)
24 } else {
25 None
26 }
27 }
28 }
29
30 impl Ord for BadOrd {
31 fn cmp(&self, other: &Self) -> Ordering {
32 // Inconsistent with PartialOrd and violates total ordering
33 if self.0 < other.0 {
34 Ordering::Greater
35 } else if self.0 > other.0 {
36 Ordering::Less
37 } else {
38 Ordering::Equal
39 }
40 }
41 }
42
43 fn main() {
44 let mut vec = vec![BadOrd(3), BadOrd(2), BadOrd(4), BadOrd(1)];
45
46 println!("Before sorting: {:?}", vec);
47
48 vec.sort(); // This will likely panic due to inconsistent ordering
49
50 println!("After sorting: {:?}", vec);
51
52 // These assertions will fail, demonstrating incorrect ordering
53 assert!(BadOrd(1) < BadOrd(2));
54 assert!(BadOrd(2) > BadOrd(1));
55 assert!(BadOrd(2) == BadOrd(2));
56
57 println!("All assertions passed!");
58 }
// Output:
// rustup run 1.81.0 cargo run
// Before sorting: [BadOrd(3), BadOrd(2), BadOrd(4), BadOrd(1)]
// After sorting: [BadOrd(2), BadOrd(4), BadOrd(3), BadOrd(1)]
// thread 'main' panicked at src/main.rs:53:5:
// assertion failed: BadOrd(1) < BadOrd(2)
// note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
要想找到没有行号的代码并运行代码清单3,可以克隆github.com/wubin28/wuzhenbens_playground代码库,进入wuzhenbens_playground文件夹,再进入文件夹new_sort_implementations_in_1_81_0_stable_rust,将git切换到1bcf0b97提交,找到main.rs源文件,就能看到代码清单3。
现在解释一下代码清单3中的代码。
代码清单3定义了一个名为 BadOrd
的结构体,并为其实现了 PartialEq
、Eq
、PartialOrd
和 Ord
trait。这些实现故意违反了这些 trait 的预期行为,以展示不正确的排序和比较可能导致的问题。
🔥代码清单3中第6-11行实现了PartialEq
trait的eq
方法。这个方法的签名是这样的。参数&self
是当前对象的引用,即要比较的第一个值。other: &Self
是要与之比较的另一个对象的引用。Self
表示实现这个 trait 的类型(在这个例子中是 BadOrd
)。返回类型是bool
,表示返回一个布尔值,true
表示两个值相等,false
表示不相等。
❓“&self
"与“&Self
"两者首字母大小写差异意味着什么
首字母小写的 self
是一个方法接收者,表示当前实例的引用。它是方法定义中的一个特殊参数,总是指向调用该方法的实例。&self
具体来说是对当前实例的不可变引用。也可能看到 &mut self
(可变引用)或 self
(所有权)。
首字母大写的 Self
是一个类型,表示实现当前 trait 或方法的类型。在 trait 定义或 impl 块中,Self
是一个占位符,代表将来实现该 trait 或方法的具体类型。&Self
是对该类型的引用。
&self
用在方法的参数列表中,作为第一个参数,表示方法接收者。&Self
可以用在返回类型、其他参数类型或方法体中,表示实现当前 trait 或方法的类型。
在具体实现中,self
的类型实际上就是 Self
。&self
等价于 self: &Self
。
第9行self.0
和 other.0
分别表示两个 BadOrd
实例中存储的 i32
值。% 2
是取模运算,它会返回除以 2 的余数。对于任何整数,这个结果要么是 0(偶数),要么是 1(奇数)。==
比较这两个余数是否相等。
这行代码实际上在检查两个 BadOrd
实例中的数字是否同为奇数或同为偶数。如果两个数都是奇数或都是偶数,那么它们被认为是"相等"的。
这种实现方式是刻意设计的,正如注释所说的 "Intentionally inconsistent equality"(故意不一致的相等性)。这种相等性定义违反了通常的相等性规则,因为它不考虑实际的数值,只考虑奇偶性。这会导致一些反直觉的结果,例如 BadOrd(1) == BadOrd(3)
会返回 true
,而 BadOrd(1) == BadOrd(2)
会返回 false
。
正确实现应该是代码清单2中第1-5行。注意,在Rust的函数或方法中,最后一个不带分号的表达式,就被视为这个函数或方法的返回值。
代码清单3中第13行Eq
trait的实现,与正确实现一致。
🔥代码清单3中第15-28行实现了PartialOrd
trait的partial_cmp
方法。方法签名中的参数前面已经介绍了。返回类型是 Option<Ordering>
。Option
是 Rust 的一个枚举类型,用于表示可能存在也可能不存在的值。Ordering
是另一个枚举,有三个可能的值:Less
、Equal
或 Greater
。返回 Option<Ordering>
意味着比较结果可能是这三种顺序之一,也可能是 None
(表示两个值不可比较)。
这段代码的逻辑是这样的。如果 self
是偶数且 other
是奇数,则返回 Less
。如果 self
是奇数且 other
是偶数,则返回 Greater
。如果 self
和 other
相等,则返回 Equal
。其他情况返回 None
。
这段代码违反了一致性(Consistency),因为这个实现与 PartialEq
的实现不一致。例如,PartialEq
认为所有偶数相等,但 PartialOrd
可能认为它们不可比较。
这段代码还违反传递性(Transitivity)。因为可能出现 A < B, B < C, 但 A 不小于 C 的情况。例如:BadOrd(2) < BadOrd(3)
, BadOrd(3) < BadOrd(4)
, 但 BadOrd(2)
和 BadOrd(4)
是不可比较的。
这段代码还违反对偶性(Duality)。正确的实现应该满足:如果 a < b,那么 b > a。但这个实现中,可能存在 a < b,但 b 与 a 不可比较的情况。
这段代码是不完全排序的。某些情况下返回 None
,表示这些值是不可比较的。这违反了全序关系(total ordering)的要求,全序关系要求任意两个元素都可比较。
这段代码与直觉不符。这个排序方法基于奇偶性而非数值大小,这与通常的数字排序直觉不符。比如,BadOrd(4)
被认为小于 BadOrd(3)
,尽管 4 > 3。BadOrd(1)
和 BadOrd(3)
被认为是不可比较的,返回 None
。BadOrd(4)
和 BadOrd(2)
相等(根据 PartialEq
),但在 PartialOrd
中它们是不可比较的。
正确实现应该是代码清单2中第9-13行。
🔥代码清单3中第30-41行实现了Ord
trait的cmp
方法。这段代码定义了 BadOrd
结构体的 Ord
trait 实现中的 cmp
方法。它返回一个 Ordering
枚举,可能的值是 Less
、Equal
或 Greater
。
这段代码的实现逻辑是这样的。如果 self.0 < other.0
,则返回 Ordering::Greater
。如果 self.0 > other.0
,则返回 Ordering::Less
。如果相等,则返回 Ordering::Equal
。
这与 PartialOrd
不一致。PartialOrd
基于奇偶性比较,而这个实现基于实际数值。另外顺序颠倒。实现颠倒了正常的排序顺序。通常,较小的值应该返回 Less
,较大的值返回 Greater
。这里却做了相反的事情,导致排序完全相反。
这段代码违反全序关系。Ord
trait 应该提供一个全序关系,即任意两个元素都应该可比较,且顺序应该是一致和传递的。虽然这个实现确实为所有情况都提供了一个顺序,但这个顺序是错误的。
这个实现与直觉不符。这种实现会导致排序结果与人们通常期望的完全相反。例如,在使用这种实现排序时,更大的数字会出现在更小的数字之前。
在实际编程中,正确的实现应该是代码清单2中第15-19行。
代码清单3第43-58行main
函数,与代码清单1相应的代码逻辑一致,就不赘述了。
因为代码清单3需要在rust 1.81.0中运行,所以就不适合在mycompiler.io网页上运行了。可以运行命令rustup run 1.81.0 cargo run
,使用 rustup
来临时切换到 Rust 1.81.0 版本。在这个特定版本的环境中,使用 cargo
来编译并运行当前项目。
结果真的看到了panic!
Before sorting: [BadOrd(3), BadOrd(2), BadOrd(4), BadOrd(1)]
After sorting: [BadOrd(2), BadOrd(4), BadOrd(3), BadOrd(1)]
thread 'main' panicked at src/main.rs:53:5:
assertion failed: BadOrd(1) < BadOrd(2)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
但别高兴太早。这个panic是代码清单3第53行那个断言引发的。那个断言要验证BadOrd(1) < BadOrd(2)
,但根据代码清单3第20行的代码,奇数总是大于偶数。所以这个断言引发了panic。
如果把代码清单3中第53-57行的所有断言和最后一行打印都注释掉,再在1.81.0里运行,还是不会出现panic。换成1.80.1,运行rustup run 1.80.1 cargo run
,也是不出panic。
这不支持之前的假设。为什么?不知道。或许是一个bug?已经上报给Rust了(github.com/rust-lang/rust/issues/130178)。
虽然这个实验结果无法支持rust 1.81.0能够帮助程序员避坑用于排序的Ord
的不正确实现的假设,但它提醒我们,断言真的能帮程序员避坑排序的问题。
如果喜欢这篇文章,别忘了给文章点个“在看”,好鼓励小吾继续写哦~😃
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。