前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >有Bug? Rust 1.81.0新排序实现真能帮程序员避坑?

有Bug? Rust 1.81.0新排序实现真能帮程序员避坑?

原创
作者头像
程序员吾真本
修改2024-09-10 20:34:43
3770
修改2024-09-10 20:34:43
举报
文章被收录于专栏:Rust避坑式入门

讲动人的故事,写懂人的代码

几天前,Rust官网发布了1.81.0稳定版的发布报告(blog.rust-lang.org/2024/09/05/Rust-1.81.0.html)。

小吾很关注这次新发布的稳定版,能帮程序员避什么坑。

发布报告下面这段新特性的描述,吸引了小吾的注意。

新的排序实现 标准库中的稳定和不稳定排序实现都已更新为新算法,提高了它们的运行时性能和编译时间。 此外,这两种新的排序算法都试图检测Ord的不正确实现,这些实现会阻止它们产生有意义的排序结果,现在在这种情况下会引发panic,而不是返回实际上随机排列的数据。遇到这些panic的用户应该审核他们的排序实现,以确保它们满足PartialOrdOrd文档中所记录的要求。

❓什么是稳定排序?什么是不稳定排序?

在稳定排序中,相等元素的相对顺序在排序前后保持不变。例如,如果有两个相等的元素 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 1.80.1和1.81.0

如果还没有在电脑上安装rust,可以参考rust官网的指导(www.rust-lang.org/tools/install)进行安装。安装好后,可以运行rustc --version进行验证。如果看到类似“rustc 1.81.0 (eeb90cda1 2024-09-04)”的输出,就说明安装成功。

接下来,可以运行下面两行命令,分别安装两个版本的rust。

代码语言:javascript
复制
rustup toolchain install 1.81.0
rustup toolchain install 1.80.1

如何验证两个版本是否安装成功?可以运行下面的命令。

代码语言:javascript
复制
rustup toolchain list

如果看到类似下面的输出,且里面有那两个版本,就说明安装成功了。

代码语言:javascript
复制
stable-aarch64-apple-darwin
1.80.1-aarch64-apple-darwin
1.81.0-aarch64-apple-darwin (default)

正常的排序代码

下面我们以专业rust程序员最常使用的良好实践为标准,写一段正常排序的代码,如代码清单1所示。

代码清单1 专业rust程序员用最常使用的良好实践所编写的正常排序的代码

代码语言:javascript
复制
 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结构体派生了DebugEqPartialEqOrdPartialOrdtrait。这些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实例,对调试很有帮助。

PartialEqEqPartialOrdOrd这四个trait都与比较操作有关,但它们各自处理不同的比较方面。PartialEqEq 处理相等性比较,PartialOrdOrd 处理顺序比较。

PartialEq定义了部分相等关系,是最基本的相等性比较trait。所具有的主要方法有必须由实现者提供的eq() 和有默认实现的 ne()。允许存在"部分相等"的概念,即可能有些值无法比较。实现了 PartialEq 的类型可以使用 ==!= 运算符。用于大多数需要相等性比较的场景,适用于浮点数等可能存在特殊值(如NaN)的类型。

EqPartialEq 的subtrait。加强了 PartialEq 的要求,表示全等关系(equivalence relation,是一种二元关系,满足以下三个性质。自反性,对于任何元素a,a ∼ a。这里的波浪线代表“等价”,比“等于”更宽泛。对称性,如果a ∼ b,则b ∼ a。传递性:如果a ∼ b且b ∼ c,则a ∼ c。)。没有额外的方法,是一个标记trait。保证自反性,即对任何值 xx == x 总是为 true。用于需要确保完全相等性的场景,常用于哈希映射的键类型。

PartialOrdPartialEq的subtrait。它在部分相等性的基础上,增加了部分顺序比较。要求类型也实现 PartialEq。主要方法有必须由实现者提供的partial_cmp,返回 Option<Ordering>。它的ltlegtge方法都有默认实现。允许存在无法比较的情况。用于可能存在不可比较值的顺序比较,适用于浮点数等类型。

OrdEqPartialOrd 的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必有一个成立)。要求类型也实现 EqPartialOrdPartialEq(间接subtrait)。主要方法有必须由实现者提供的cmp,返回 Ordering。它的maxminclamp 方法都有默认实现。保证任意两个值都可以比较。用于需要完全排序的场景(如排序算法),可以作为某些集合类型(如 BTreeMap)键的要求。

这4个trait的关系图如图1所示。

图1 PartialEqEqPartialOrdOrd这四个trait之间的subtrait和supertrait关系

那为什么需要这么多traits?因为下面一些原因。

  • 完整的比较功能。PartialEq, Eq, PartialOrd, 和 Ord 一起提供了完整的比较功能,允许相等性检查和排序。
  • 排序能力。Ord trait是vec.sort()方法所必需的。没有它,向量就不能自动排序。
  • 调试友好。Debug trait使得在开发过程中可以轻松打印和检查GoodOrd实例。
  • 类型安全。通过明确派生这些traits,确保了GoodOrd类型具有预期的行为,减少了运行时错误的可能性。
  • 代码简洁。通过派生这些traits,避免了手动实现它们的复杂性,使代码更加简洁和易于维护。

图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定义部分等价关系。EqPartialEq的subtrait,定义完全等价关系。PartialOrdPartialEq的subtrait,定义部分顺序关系。OrdEqPartialOrd的subtrait,定义完全顺序关系。

在Rust中,这种继承关系由于以下原因,不会导致C++中多重继承的典型问题。

没有菱形继承问题。Rust的trait不包含数据,只定义行为,所以不会出现因继承导致的数据冗余。

不存在状态继承。trait只定义接口,不继承状态。

名称冲突解决。Rust有明确的解决方案,如完全限定语法。

实现清晰。Rust要求显式实现trait,减少了隐式行为。

在图中,Ord虽然是EqPartialOrd的subtrait,但这不会导致C++式的多重继承问题。Rust的trait系统设计避免了这些问题。

上面提到,PartialEq::eqPartialOrd::partial_cmpOrd::cmp方法都是required,即必须由实现者提供,那么为何代码清单1中的代码,却没有实现它们?

❓trait的required方法为何没有实现

虽然 PartialEq::eqPartialOrd::partial_cmpOrd::cmp 方法必须由实现者提供,但在代码清单1中,这些方法是通过派生宏(derive macro)自动实现的。 在 Rust 中,#[derive(...)] 属性是一个强大的功能,它允许编译器自动为简单的结构体和枚举实现某些 trait。代码清单1中第3行告诉编译器,为 GoodOrd 自动实现 DebugEqPartialEqOrdPartialOrd trait。

对于像 GoodOrd 这样的单字段结构体(称为元组结构体),派生宏会生成基于该字段类型(在这里是 i32)的默认实现。i32 已经实现了这些 trait,所以派生宏可以直接使用 i32 的实现来为 GoodOrd 生成相应的方法。生成的代码等效于下面的代码,如代码清单2所示。

代码清单2 派生宏所生成的等效代码

代码语言:javascript
复制
 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的所有权规则。

可以使用下面两种方式,来分别创建空向量,以及用宏来创建并初始化向量。

代码语言:javascript
复制
let mut vec = Vec::new();  // 创建空向量
let vec = vec![1, 2, 3];   // 使用宏创建并初始化

可以像下面那样用栈的方式添加和删除向量元素。当然也可以用其他非栈的方式,但通常速度较慢。

代码语言:javascript
复制
vec.push(4);       // 在末尾添加元素
vec.pop();         // 移除并返回最后一个元素

访问元素的方式可以有下面两种。

代码语言:javascript
复制
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后面注释里的运行结果能够看出,排序前的向量是:

代码语言:javascript
复制
[GoodOrd(3), GoodOrd(2), GoodOrd(4), GoodOrd(1)]

排序后,它会变成:

代码语言:javascript
复制
[GoodOrd(1), GoodOrd(2), GoodOrd(3), GoodOrd(4)]

这个排序操作展示了几个重要的 Rust 特性。

  • 结构体可以通过派生宏自动实现比较和排序的能力。
  • 标准库提供了高效的排序算法。
  • Rust 的类型系统和 trait 系统允许对自定义类型进行灵活的操作。

使用 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! 宏。如果断言失败,程序通常会立即终止或抛出异常。断言是程序代码的一部分,在正常执行流程中运行。

下面是一些断言的常用用法。

代码语言:javascript
复制
assert!(condition);
assert_eq!(value1, value2);
assert_ne!(value1, value2);

断言具有以下优势。快速捕获和定位错误。作为程序自我检查的机制。可以作为文档的一部分,说明代码的预期行为。

断言也有一些劣势。在生产环境中可能会影响性能。如果没有适当处理,可能导致程序意外终止。

断言适用于以下场景。验证函数的前置条件和后置条件。检查重要的不变量。在开发和调试阶段进行快速验证。

单元测试(unit test)是针对程序中最小可测试单元(通常是函数或方法)编写的独立测试。

单元测试通常存在于单独的测试模块或文件中。使用专门的测试框架和工具运行。不会影响正常的程序执行流程。

下面是Rust单元测试的常用用法。

代码语言:javascript
复制
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_some_function() {
        assert_eq!(some_function(input), expected_output);
    }
}

单元测试具有以下优势。可以全面测试各种情况,包括边界条件和异常情况。有助于重构和维护代码。可以作为回归测试的一部分。不影响生产代码的性能。

单元测试也有一些劣势。编写和维护相比断言需要更多的时间和努力。可能无法捕获集成或系统级别的问题。

单元测试适用于以下场景。验证单个函数或组件的正确性。测试各种输入和边界条件。在持续集成/持续部署(CI/CD)流程中自动化测试。

断言和单元测试之间到底有什么区别和联系?可以考虑下面几个方面。

运行时机。断言在程序运行时执行,而单元测试在开发和测试阶段单独运行。

范围。断言通常用于验证单个条件,而单元测试可以更全面地测试一个函数的行为。

影响。断言可能影响程序的正常运行,而单元测试不会影响生产代码的执行。

维护。单元测试需要单独维护,而断言是代码的一部分。

详细程度。单元测试通常更详细,可以测试多种情况,而断言往往更简单直接。

在实际开发中,这两种方法通常是互补的。断言用于捕获运行时的意外情况,而单元测试用于更系统地验证代码的正确性。结合使用这两种方法可以显著提高代码的质量和可靠性。

前面提到,断言”在生产环境中可能会影响性能”,而且“如果没有适当处理,可能导致程序意外终止”,那么在生产级别的代码中,是不是应该尽量减少断言?

❓在生产级别的代码中,是否应该尽量减少断言

在生产级别的代码中,该如何使用断言,涉及到软件开发中的一个常见权衡问题。需要考虑以下几个方面。

性能影响。断言会带来一定的性能开销,特别是在频繁执行的代码路径上。然而,这个开销通常是很小的,在大多数情况下可能微不足道。

安全性和正确性。断言可以帮助及早发现和诊断问题,防止错误状态进一步扩散,这对于维护系统的整体健康和正确性非常重要。

编译时优化。许多编程语言(包括 Rust)在发布模式(release mode)下会自动禁用或优化掉断言,从而消除生产环境中的性能影响。

关键检查。某些断言可能对于程序的正确性至关重要,即使在生产环境中也应该保留。

考虑到这些因素,以下是一些在生产代码中使用断言的避坑策略。

保留关键断言。对于保证程序正确性和安全性至关重要的检查,应该保留断言,即使在生产环境中也是如此。

使用条件编译。可以使用条件编译来控制哪些断言在生产环境中保留。例如在 Rust 中可以像下面那样写条件编译的断言。

代码语言:txt
复制

#[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的不正确的实现更加离谱,可以把PartialEqPartialOrd的实现也一并搞得不正确。如代码清单3所示。

代码清单3

代码语言:javascript
复制
 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 的结构体,并为其实现了 PartialEqEqPartialOrdOrd 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.0other.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 是另一个枚举,有三个可能的值:LessEqualGreater。返回 Option<Ordering> 意味着比较结果可能是这三种顺序之一,也可能是 None(表示两个值不可比较)。

这段代码的逻辑是这样的。如果 self 是偶数且 other 是奇数,则返回 Less。如果 self 是奇数且 other 是偶数,则返回 Greater。如果 selfother 相等,则返回 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) 被认为是不可比较的,返回 NoneBadOrd(4)BadOrd(2) 相等(根据 PartialEq),但在 PartialOrd 中它们是不可比较的。

正确实现应该是代码清单2中第9-13行。

🔥代码清单3中第30-41行实现了Ord trait的cmp 方法。这段代码定义了 BadOrd 结构体的 Ord trait 实现中的 cmp 方法。它返回一个 Ordering 枚举,可能的值是 LessEqualGreater

这段代码的实现逻辑是这样的。如果 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!

代码语言:javascript
复制
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 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 安装rust 1.80.1和1.81.0
  • 正常的排序代码
    • 如何运行代码
      • 正常排序代码的解释
      • 假设引入Ord的不正确实现能在1.81.0下引发panic
      • 实验结果
      相关产品与服务
      持续集成
      CODING 持续集成(CODING Continuous Integration,CODING-CI)全面兼容 Jenkins 的持续集成服务,支持 Java、Python、NodeJS 等所有主流语言,并且支持 Docker 镜像的构建。图形化编排,高配集群多 Job 并行构建全面提速您的构建任务。支持主流的 Git 代码仓库,包括 CODING 代码托管、GitHub、GitLab 等。
      领券
      问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档