关于Rust类型如何使用的简短说明


在编程时处理内存是一个主要对使用垃圾收集器语言的开发人员隐藏的领域。本节简要概述内存管理的一些关键方面,因为迁移到 Rust 需要更深入地了解幕后发生的事情。

栈和堆
程序中的值会占用内存。计算机中有各种内存区域,可以在其中放置值,但最常见的两个区域是栈和堆。

您可以将栈视为执行函数所需值的临时存储位置。这包括函数参数和本地定义的变量。由于这更像是一个暂存区域,因此访问速度很快,并且一旦函数执行完毕,就可以丢弃/覆盖这些值。因此,堆栈上值的典型生命周期与需要它执行的函数相关联。

另一方面,堆是一个更持久的内存位置,可以放置与执行函数的生命周期无关的值。由于显而易见的原因,处理堆更加复杂。堆不像栈那么简单,找到放置新值的空间可能需要额外的低级内存管理操作才能使这些空间可用。栈没有这种复杂性。

这个想法是尽可能地限制堆区域的使用,因为它不如栈内存区域快。

Sized Type和动态Sized Type
变量的类型决定了此类变量可以包含的值的类型。这样做的结果是类型还决定了这样一个值所需的内存大小。

一个u32变量类型可以包含 0 到 4,294,967,295 范围内的数值,而另一方面, u8变量具有较小的大小,可以包含 0 到 255 之间的数值。这意味着变量u32将需要 32 位长,而u8内存 需要 8 位长的内存。

大多数类型都有特定的大小,因此需要内存长度,这在编译时是可知的。u8和u32属于这一类类型。这些类型称为Sized Types。
它们被保证保持唯一的不变性。
一个类型u32总是需要 32 位长度的内存,不管它包含 0 还是 4,294,967,295。这同样适用于所有其他大小的类型。

另一方面,还有其他类型的大小在编译时无法确定知道:

一个例子是用[T]表示的数组。这种类型依次代表一定数量的T,但我们不知道有多少个,可能是0、1、132或100万个T。因此,在编译时不可能给这些类型赋予一个唯一的大小。这些类型被称为动态大小的类型(DST)。

str类型是另一个DST的例子。它表示字符串的一个片断。但是,由于我们无法在编译时唯一地确定[T]的大小的同样原因,我们也无法确定所有字符串片断的大小。因为由str类型代表的字符串片可能是0、1、131或100万长。

DST的想法并不是说在编译时不知道其大小;它们是知道的,但它们可以变化,因此在编译时不能给它们赋予唯一的大小。

Rust编译器通常更倾向于在编译时知道类型的大小,原因有很多,比如更好的管理和优化。因此,我们在动态大小的类型方面有一个问题,因为创建一个值并给它注上DST是不会被编译的。

fn main() {
   let dst_value: str = "hello world";
}

将导致编译失败,错误如下:

error[E0277]: the size for values of type `str` cannot be known at compilation time
  --> src/main.rs:70:9
   |
70 |     let dst_value: str = "hello world";
   |         ^^^^^^^^^ doesn't have a size known at compile-time
   |
   = help: the trait `Sized` is not implemented for `str`
   = note: all local variables must have a statically known size
   = help: unsized locals are gated as an unstable feature

Rust编译器很好地告诉我们为什么它拒绝编译这段代码。它给出的一个非常有启发性的理由是:"所有局部变量必须有一个静态已知的大小"。

那么,我们该如何处理这个问题呢?

为了充分理解,我们首先看一下Rust中的所有权和借贷检查器的概念。

Ownership, Copying, Referencing, Moving 和Borrowing

Copy
这是在任何编程语言中进行的原子性操作之一。你在一个变量里有值,你就把它复制到另一个变量里。在Javascript中。

> let a = 1
undefined
> let b = a
undefined
> b
1
> b = 2
2
> b
2
> a
1

基本上,一旦你有了b = a,a的值就会被复制到b中,因此当b被修改时,不会影响a中仍然包含的值。

Referencing
Referencing引用是指多个变量没有自己唯一的值副本,而是指向同一个值。这意味着,如果其中一个变量改变了值,其他变量也会被改变。

> let a = [1,2,3]
undefined
> let b = a
undefined
> a.push(4)
4
> a
[ 1, 2, 3, 4 ]
> b
[ 1, 2, 3, 4 ]

为了防止创建引用,将不得不创建一个克隆。

复制和引用的概念也存在于Rust中,但Rust对它们的处理方式不同。在讨论Rust中的复制之前,让我们先看看所有权和数值移动的概念,另一方面,这也是Rust独有的。

Ownership和moving of values
Rust引入了所有权和价值移动的概念。简单地说,它意味着值不是分配给变量的,而是值被变量所拥有。比如说。

>> let a = 1;

现在应该被看作是变量a被授予了值1的所有权。

也可以复制一个变量所拥有的值,从而创建另一个由其他变量所拥有的值。

>> let a = 1;
>> let b = a;
>> a
1
>> b
1

这应该被解释为变量a拥有数值1,然后通过let b = a,变量a拥有的数值的一个新副本被创建并移交给变量b拥有。

上述情况与我们看到的用JavaScript复制的行为差不多。

Rust的附加功能是移动值的概念:
这意味着,一个变量将一个值的所有权让给另一个变量,而不是复制。
因此,一旦数值被移动,所有权被转移,旧的变量就不能再被使用。

fn main() {
   let a = String::from("Hello world");
   let b = a;
// value has moved from a to b
   println!(
"{b}");
   println!(
"{a}"); // this line won't make it compile
}

上述代码不会被编译。这是因为对于String类型的值,let b = a一行意味着变量a的值被移到了变量b上。也就是说,变量a已经把所有权转移到了变量b上。因此,在代码的后面试图使用变量a,试图把它打印出来,是不会被编译的。

令人困惑的是,在前面的例子中,我们处理u8类型的值时,行为是复制,这与我们在JavaScript中的行为相似。但是,当值是String类型的时候,值被移动了。

什么原因?

好吧,这又归结于类型的概念和它们的能力。一般的想法是,在堆上分配的类型通常默认为移动值,而大多数放在堆上的原始类型则是复制。String是一个在堆上管理内存的类型,因此它的默认能力不是复制。要创建一个独特的String的副本,就必须要克隆它。

可以说明的是,Rust进一步区分了复制和克隆:复制可以解释为简单地将比特从一个内存位置转移到另一个,而克隆除了移动比特之外,还需要执行额外的逻辑。

这种移动数值的整体概念是Rust特有的,新来的程序员需要注意这一点。

借用和值引用
现在,为了重现我们在Javascript中的情景,即在一个变量中突变数组会导致另一个变量的突变,我们有以下Rust代码。

fn main() {
   let mut a = vec![1,2,3,4];
   let b = &mut a;
   b.push(5);
   println!("{a:?}");
}

变量a持有一个向量,但它通过变量b发生了突变,增加了5,这在变量a中得到了反映。本质上,与我们在JavaScript数组中的引用情况相同。

然而,唯一不同的是,Rust将引用显性化。

let mut a = vec![1,2,3,4]; 将向量的所有权授予变量a。大多数数据结构在Rust中默认是不可变的,所以我们使用mut关键字来表示a所拥有的值可以被修改。

然后让b =&mut a;是神奇发生的地方。

这不是把值从a移到b,而是让b借用这个值。

这种形式的借用是通过创建一个引用并将其交给b来实现的。
语法&mut是用来实现这个目的的,mut关键字表示可以通过这个引用来改变值。

借用一个值是可能的,也就是说,创建一个值的引用,但不能改变这个值。算是一个只读的。
在这种情况下,你只能使用&,例如:

fn main() {
   let mut a = vec![1,2,3,4];
   let b = &a;
   println!("{b:?}");
}

但是有一些事情你可以做,也不能做,这取决于一个值是拥有的、移动的、借用的、可变的还是不可变的。这些整类的限制是为了确保内存安全,这就是借贷检查器所执行的。

总之,借贷检查器确保:只要没有变异引用,你可以对同一个内存位置有多个只读引用。如果对一个内存位置创建了一个易变的引用,那么只有这个易变的引用可以从该内存位置读取和写入。

关于指针和使用引用来处理DST的介绍
DSTs已经被介绍为在编译时没有唯一大小的类型,如上所述,Rust更倾向于处理它在编译时可以知道其大小的类型。那么,Rust是如何处理DST的呢?为了理解这一点,我们需要谈谈指针。

Rust中的指针和引用
指针是变量,就像其他类型的变量一样,但它们的值是其他变量的内存地址。它们是一个指示器,指向可以找到另一个值的内存位置。

在Rust中,指针也有类型,这很有意义,因为可以想象,特定的能力只对指针可用。指针也可以在C和C++等语言中找到,但由于有可能滥用对内存位置的无障碍访问,因此它们在工作中可能很困难,也不安全。

这就是为什么在Rust中,你几乎不直接处理指针的原因。取而代之的是引用,它是具有安全或活泼性保证的指针。

你可以把它们看作是一个保护层,使使用指针的工作更加安全。不是引用的指针,也就是在Rust中没有这种保护性保证的指针通常被称为原始指针。

引用的问题是,它们也有类型,而它们的好处是,它们有一个已知的大小。这是因为引用持有内存地址,因此可以有一个恒定的位长,它将永远被分配给引用,大到足以使它能够存储任何需要存储的内存地址。

有时,关于它们所指向的内存地址中的值的额外元信息也会被存储,当这种情况出现时,引用通常被称为胖指针。

创建引用的语法是&T。要创建对一个类型T的引用,使用&T。例如,在下面的代码中。

use std::collections::HashMap;
fn main() {
   let phone_code: HashMap<String, u8> = HashMap::from([("NL".to_string(), 31), ("USA".to_string(), 1)]);
   let ref_to_phone_code: &HashMap<String, u8> = &phone_code;
   dbg!(ref_to_phone_code);
}

phone_code的类型是HashMap<String, u8>,但是Ref_to_phone_code,一个引用,类型是&HashMap<String, u8>-注意类型中的安培号。

另外,&HashMap<String, u8>类型的值是通过在它要引用的变量上加上&来创建的,也就是上面代码中的&phone_code。

Rust使用引用来处理DST。由于引用是在编译时已知大小的类型,所以诀窍是只允许通过对它们的引用与DSTs进行交互。这就是为什么用str注释一个类型,一个DST,会导致编译失败,但&str,一个DST的引用,却可以正常编译。

总结:T(Sized和DST)、&T和&mut T
正如本文开头所提到的,类型是指对一个值所允许的编码能力。Rust对此进行了扩展,包含了与内存管理和布局有关的能力。

所以一个类型T可以以两种方式存在。一种是它的内存大小总是已知为一个特定的位长;这些类型被称为 "有大小的类型",另一种是其大小/位长不是唯一的,可以变化;这些被称为 "动态有大小的类型"。

然后你有引用,它是指针(持有内存位置的变量),具有确保安全内存访问的保证。

这些保证是由借贷检查器强制执行的。

这些引用可以是&T或&mut T类型,这取决于引用是否是可变的。如果类型T是一个DST,那么类型&T(或&mut T)的变量可以用来引用DST。