Rust 难点攻关,你学会了吗?

开发 前端
裸指针用于低级别的操作,引用用于安全的借用,智能指针提供了更高级别的内存管理和所有权控制。在 Rust 中,推荐使用引用和智能指针来确保内存安全性和代码可维护性。​

当大家一路看到这里时,我敢说 90% 的人还是云里雾里的,例如你能说清楚:

  • 切片和切片引用的区别吗?
  • 各种字符串之间的区别吗?
  • 各种指针、引用的区别吗?
  • 所有权转移、拷贝、克隆的区别吗?

切片和切片引用

关于 str / &str,[u8] / &[u8] 区别,你能清晰的说出来嘛?如果答案是 No ,那就跟随我一起来看看切片和切片引用到底有何区别吧。

在继续之前,查看这里了解何为切片

切片允许我们引用集合中部分连续的元素序列,而不是引用整个集合。例如,字符串切片就是一个子字符串,数组切片就是一个子数组。

无法被直接使用的切片类型

Rust 语言特性内置的 str 和 [u8] 类型都是切片,前者是字符串切片,后者是数组切片,下面我们来尝试下使用 str :

let string: str = "banana";

上面代码创建一个 str 类型的字符串,看起来很正常,但是编译就会报错:

error[E0277]: the size for values of type `str` cannot be known at compilation time
 --> src/main.rs:4:9
  |
4 |     let string: str = "banana";
  |         ^^^^^^ doesn't have a size known at compile-time

编译器准确的告诉了我们原因:str 字符串切片它是 DST 动态大小类型,这意味着编译器无法在编译期知道 str 类型的大小,只有到了运行期才能动态获知,这对于强类型、强安全的 Rust 语言来说是不可接受的。

也就是说,我们无法直接使用 str,而对于 [u8] 也是类似的,大家可以自己动手试试。

总之,我们可以总结出一个结论:在 Rust 中,所有的切片都是动态大小类型,它们都无法直接被使用。

为何切片是动态大小类型

原因在于底层的切片长度是可以动态变化的,而编译器无法在编译期得知它的具体的长度,因此该类型无法被分配在栈上,只能分配在堆上。

为何切片只能通过引用来使用

既然切片只能分配到堆上,我们就无法直接使用它,大家可以想想,所有分配在堆上的数据,是不是都是通过一个在栈上的引用来访问的?切片也不例外。

为何切片引用可以存储在栈上

切片引用是一个宽指针,存储在栈上,指向了堆上的切片数据,该引用包含了切片的起始位置和长度,而且最重要的是,类似于指针,引用的大小是固定的(起始位置和长度都是整形),因此它才可以存储在栈上。

有没有可以存储在栈上的

有,使用固定长度的数组: let a: [i8;4] = [1,2,3,4];,注意看,数组的类型与切片是不同的,前者的类型带有长度:[i8;4],而后者仅仅是 [i8]。

切片引用

那么问题来了,该如何使用切片呢?

何以解忧,唯有引用。由于引用类型的大小在编译期是已知的,因此在 Rust 中,如果要使用切片,就必须要使用它的引用。

str 切片的引用类型是 &str,而 [i32] 的引用类型是 &[i32],相信聪明的读者已经看出来了,&str和 &[i32] 都是我们非常常用的类型,例如:

let s1: &str = "banana";
let s2: &str = &String::from("banana");

let arr = [1, 2, 3, 4, 5];

let s3: &[i32] = &arr[1..3];

这段代码就可以正常通过,原因在于这些切片引用的大小在编译器都是已知的。

总结

我们常常说使用切片,实际上我们在用的是切片的引用,我们也在频繁说使用字符串,实际上我们在使用的也是字符串切片的引用。

总之,切片在 Rust 中是动态大小类型 DST,是无法被我们直接使用的,而我们在使用的都是切片的引用。

切片

切片引用

str 字符串切片

&str 字符串切片的引用

[u8] 数组切片

&[u8] 数组切片的引用

但是出于方便,我们往往不会说使用切片引用,而是直接说使用字符串切片或数组切片,实际上,这时指代的都是切片的引用!

Eq 和 PartialEq

在 Rust 中,想要重载操作符,你就需要实现对应的特征。

例如 <、<=、> 和 >= 需要实现 PartialOrd 特征:

use std::fmt::Display;

struct Pair<T> {
    x: T,
    y: T,
}

impl<T> Pair<T> {
    fn new(x: T, y: T) -> Self {
        Self { x, y }
    }
}

impl<T: Display + PartialOrd> Pair<T> {
    fn cmp_display(&self) {
        if self.x >= self.y {
            println!("The largest member is x = {}", self.x);
        } else {
            println!("The largest member is y = {}", self.y);
        }
    }
}

再比如, + 号需要实现 std::ops::Add 特征,而本文的主角 Eq 和 PartialEq 正是 == 和 != 所需的特征,那么问题来了,这两个特征有何区别?

我相信很多同学都说不太清楚,包括一些老司机,而且就算是翻文档,可能也找不到特别明确的解释。如果大家看过标准库示例,可能会看过这个例子:

enum BookFormat { Paperback, Hardback, Ebook }
struct Book {
    isbn: i32,
    format: BookFormat,
}
impl PartialEq for Book {
    fn eq(&self, other: &Self) -> bool {
        self.isbn == other.isbn
    }
}
impl Eq for Book {}

这里只实现了 PartialEq,并没有实现 Eq,而是直接使用了默认实现 impl Eq for Book {},奇了怪了,别急,还有呢:

impl PartialEq<IpAddr> for Ipv4Addr {
    #[inline]
    fn eq(&self, other: &IpAddr) -> bool {
        match other {
            IpAddr::V4(v4) => self == v4,
            IpAddr::V6(_) => false,
        }
    }
}

impl Eq for Ipv4Addr {}

以上代码来自 Rust 标准库,可以看到,依然是这样使用,类似的情况数不胜数。既然如此,是否说明如果要为我们的类型增加相等性比较,只要实现 PartialEq 即可?

其实,关键点就在于 partial 上,如果我们的类型只在部分情况下具有相等性,那你就只能实现 PartialEq,否则可以实现 PartialEq 然后再默认实现 Eq。

好的,问题逐步清晰起来,现在我们只需要搞清楚何为部分相等。

部分相等性

首先我们需要找到一个类型,它实现了 PartialEq 但是没有实现 Eq(你可能会想有没有反过来的情况?当然没有啦,部分相等肯定是全部相等的子集!)

在 HashMap 章节提到过 HashMap 的 key 要求实现 Eq 特征,也就是要能完全相等,而浮点数由于没有实现 Eq ,因此不能用于 HashMap 的 key。

当时由于一些知识点还没有介绍,因此就没有进一步展开,那么让我们考虑浮点数既然没有实现 Eq 为何还能进行比较呢?

fn main() {
   let f1 = 3.14;
   let f2 = 3.14;

   if f1 == f2 {
       println!("hello, world!");
   }
}

以上代码是可以看到输出内容的,既然浮点数没有实现 Eq 那说明它实现了 PartialEq,一起写个简单代码验证下:

fn main() {
    let f1 = 3.14;
    is_eq(f1);
    is_partial_eq(f1)
}

fn is_eq<T: Eq>(f: T) {}
fn is_partial_eq<T: PartialEq>(f: T) {}

上面的代码通过特征约束的方式验证了我们的结论: 

3 |     is_eq(f1);
  |     ----- ^^ the trait `Eq` is not implemented for `{float}`

好的,既然我们成功找到了一个类型实现了 PartialEq 但没有实现 Eq,那就通过它来看看何为部分相等性。

其实答案很简单,浮点数有一个特殊的值 NaN,它是无法进行相等性比较的:

fn main() {
    let f1 = f32::NAN;
    let f2 = f32::NAN;

    if f1 == f2 {
        println!("NaN 竟然可以比较,这很不数学啊!")
    } else {
        println!("果然,虽然两个都是 NaN ,但是它们其实并不相等")
    }
}

大家猜猜哪一行会输出 :) 至于 NaN 为何不能比较,这个原因就比较复杂了( 有读者会说,其实就是你不知道,我只能义正严辞的说:咦?你怎么知道 :P )。

既然浮点数有一个值不可以比较相等性,那它自然只能实现 PartialEq 而不能实现 Eq 了,以此类推,如果我们的类型也有这种特殊要求,那也应该这么作。

Ord 和 PartialOrd

事实上,还有一对与 Eq/PartialEq 非常类似的特征,它们可以用于 <、<=、> 和 >= 比较,至于哪个类型实现了 PartialOrd 却没有实现 Ord 就交给大家自己来思考了:)

疯狂字符串

字符串让人疯狂,这句话用在 Rust 中一点都不夸张,不信?那你能否清晰的说出 String、str、&str、&String、Box<str> 或 Box<&str> 的区别?

Rust 语言的类型可以大致分为两种:基本类型和标准库类型,前者是由语言特性直接提供的,而后者是在标准库中定义。即将登场的 str 类型就是唯一定义在语言特性中的字符串。

在继续之前,大家需要先了解字符串的基本知识,本文主要在于概念对比,而不是字符串讲解

str

如上所述,str 是唯一定义在 Rust 语言特性中的字符串,但是也是我们几乎不会用到的字符串类型,为何?

原因在于 str 字符串它是 DST 动态大小类型,这意味着编译器无法在编译期知道 str 类型的大小,只有到了运行期才能动态获知,这对于强类型、强安全的 Rust 语言来说是不可接受的。

let string: str = "banana";

上面代码创建一个 str 类型的字符串,看起来很正常,但是编译就会报错:

error[E0277]: the size for values of type `str` cannot be known at compilation time
 --> src/main.rs:4:9
  |
4 |     let string: str = "banana";
  |         ^^^^^^ doesn't have a size known at compile-time

如果追求更深层的原因,我们可以总结如下:所有的切片都是动态类型,它们都无法直接被使用,而 str就是字符串切片,[u8] 是数组切片。

同时还是 String 和 &str 的底层数据类型。由于 str 是动态

str 类型是硬编码进可执行文件,也无法被修改,但是 String 则是一个可增长、可改变且具有所有权的 UTF-8 编码字符串,当 Rust 用户提到字符串时,往往指的就是 String 类型和 &str 字符串切片类型,这两个类型都是 UTF-8 编码。

除了 String 类型的字符串,Rust 的标准库还提供了其他类型的字符串,例如 OsString, OsStr, CsString 和 CsStr 等,注意到这些名字都以 String 或者 Str 结尾了吗?它们分别对应的是具有所有权和被借用的变量。

在 Rust 中,作用域、生命周期和 NLL(Non-Lexical Lifetimes,非词法生命周期)是与内存管理和借用系统密切相关的概念。

1. 作用域(Scopes):

   在 Rust 中,每个变量都有自己的作用域,也就是变量的有效范围。作用域可以是一个代码块(使用花括号 `{}` 包围的代码段)或一个函数。当变量超出其作用域时,它将被销毁并释放其占用的内存。这种方式确保了资源的正确释放,避免了常见的内存泄漏和悬垂指针问题。

2. 生命周期(Lifetimes):

   生命周期是 Rust 中用于管理借用的机制。当一个变量借用另一个变量时,编译器需要确保借用的变量在使用期间保持有效。生命周期注解(通常表示为 `'a`、`'b` 等)用于指定变量之间的依赖关系,以确保借用的有效性。生命周期注解描述了变量的最小有效范围,编译器使用它来进行静态分析和验证。

3. NLL(Non-Lexical Lifetimes):

 NLL 是 Rust 编译器

在 Rust 中,`move`、`Copy` 和 `Clone` 是与变量所有权和复制相关的关键概念。

1. `move`:

   当将一个值赋值给另一个变量或将其作为函数参数传递时,Rust 会默认移动(move)该值的所有权。移动操作将转移变量的所有权,原始变量将无法再访问该值。这种方式避免了资源的重复释放和悬垂指针问题。移动操作常见于将所有权转移到函数中或从一个作用域转移到另一个作用域。

2. `Copy`:

   `Copy` 是一个 trait(特质),用于标记可以通过简单的位拷贝来复制的类型。当一个类型实现了 `Copy`,它的值可以在赋值或传递给函数时进行隐式的复制,而不会转移所有权。`Copy` 类型的特点是在赋值或传递时不会发生所有权转移,因此原始变量仍然可以访问该值。常见的 `Copy` 类型包括整数、布尔值、浮点数以及一些固定大小的结构体和枚举。

3. `Clone`:

   `Clone` 也是一个 trait,用于标记可以通过显式克隆来复制的类型。与 `Copy` 不同,`Clone` 的复制是显式的,需要调用 `clone()` 方法来创建一个新的拷贝。`Clone` 适用于需要深度复制的类型,它可以在需要时创建一个值的独立拷贝,而不是共享相同的底层数据。需要注意的是,并非所有类型都实现了 `Clone`,因为深度复制可能涉及复杂的操作。

在 Rust 中,`move`、`Copy` 和 `Clone` 的使用取决于变量的所有权和复制需求。通过合理地使用这些概念,可以确保代码的所有权转移和复制操作是正确且高效的。

在 Rust 中,裸指针、引用和智能指针是用于处理内存和所有权的不同工具。

1. 裸指针(Raw Pointers):

   裸指针是直接操作内存地址的指针,没有 Rust 的安全保证。在 Rust 中,裸指针分为不可变裸指针(`*const T`)和可变裸指针(`*mut T`)。裸指针可以用于以下情况:

   - 与外部代码(如 C 代码)进行交互。

   - 访问未初始化的内存区域。

   - 实现某些不安全的数据结构和算法。

   使用裸指针需要谨慎,因为它们绕过了 Rust 的所有权和借用系统,容易导致内存安全问题。

2. 引用(References):

   引用是 Rust 中的安全指针,用于借用值而不获取其所有权。引用分为不可变引用(`&T`)和可变引用(`&mut T`)。引用具有以下特点:

   - 引用是非空且始终有效的。

   - 引用遵循 Rust 的借用规则,保证了内存安全性。

   - 引用在编译时检查,不会导致运行时开销。

   引用是 Rust 中常用的机制,用于实现借用检查和避免数据竞争。

3. 智能指针(Smart Pointers):

   智能指针是包装了堆上数据的结构,提供了额外的功能和语义。在 Rust 中,常见的智能指针有 `Box<T>`、`Rc<T>` 和 `Arc<T>`:

   - `Box<T>` 是在堆上分配内存并拥有唯一所有权的指针。

   - `Rc<T>` 是引用计数智能指针,可以在多个位置共享所有权。

   - `Arc<T>` 是原子引用计数智能指针,适用于并发环境。

   智能指针提供了内存管理、所有权传递、生命周期扩展和特定行为的能力,可以用于解决特定的问题和场景。

总结:裸指针用于低级别的操作,引用用于安全的借用,智能指针提供了更高级别的内存管理和所有权控制。在 Rust 中,推荐使用引用和智能指针来确保内存安全性和代码可维护性。


责任编辑:武晓燕 来源: 开源测试联盟
相关推荐

2024-02-04 00:00:00

Effect数据组件

2024-01-19 08:25:38

死锁Java通信

2023-07-26 13:11:21

ChatGPT平台工具

2023-01-10 08:43:15

定义DDD架构

2024-04-09 13:16:21

Rust命名规范

2024-02-27 08:39:19

RustJSON字符串

2023-10-31 14:04:17

Rust类型编译器

2023-01-30 09:01:54

图表指南图形化

2023-12-12 08:02:10

2022-07-08 09:27:48

CSSIFC模型

2023-08-01 12:51:18

WebGPT机器学习模型

2024-01-02 12:05:26

Java并发编程

2022-03-08 08:39:22

gRPC协议云原生

2023-01-28 09:50:17

java多线程代码

2024-02-02 09:00:14

内存泄漏对象

2022-11-16 08:33:29

监听Spring监听器

2022-12-26 07:48:04

敏捷项目

2024-01-11 08:21:33

Jetpack数据技术

2023-10-30 07:05:31

2023-08-28 07:02:10

点赞
收藏

51CTO技术栈公众号