Appearance
关于 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 的另一个例子。它表示字符串的一个切片(It represents a slice of strings)。但是,由于我们无法在编译时唯一地确定 [T] 的大小的原因,我们也无法确定所有字符串切片的大小。因为由str
类型代表的字符串切片可能是0
、1
、131
或100
万长。
DST 的概念并不是在编译时不知道它们的大小;它们是已知的,但它们可以变化,所以在编译时不能为它们指定唯一的大小。
Rust 编译器通常更倾向于在编译时知道类型的大小,原因有很多,比如更好的管理和优化。所以,我们在这里遇到了动态大小类型的问题,下面为创建一个值并指定类型为 DST 将无法编译。
rust
fn main() {
let dst_value: str = "hello world";
}
将导致编译失败,错误如下:
txt
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
让我们先看看复制和引用。我们可以使用 Javascript 进行说明。
Copy
这是在任何编程语言中进行的原子性操作之一。你在一个变量里有值,你就把它复制到另一个变量里。
ts
let a = 1
let b = a
基本上,一旦你有了 b = a,a 的值就会被复制到 b 中,因此当 b 被修改时,不会影响 a 中仍然包含的值。
Referencing
Referencing 引用是指多个变量没有自己唯一的值副本,而是指向同一个值。这意味着,如果其中一个变量改变了值,其他变量也会被改变。
ts
> 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 引入了所有权和价值移动的概念。简单地说,它意味着值不是分配给变量的,而是值被变量所拥有。比如说。
rust
let a = 1;
现在应该被看作是变量a
被授予了值1
的所有权。
也可以复制一个变量所拥有的值,从而创建另一个由其他变量所拥有的值。
ts
>> let a = 1;
>> let b = a;
>> a
1
>> b
1
这应该被解释为变量a
拥有数值 1,然后通过 let b = a,变量 a 拥有的数值的一个新副本被创建并移交给变量 b 拥有。
上述情况与我们看到的用 JavaScript 复制的行为差不多。
Rust 的附加功能是移动值(moving values)的概念: 这意味着,一个变量将一个值的所有权让给另一个变量,而不是复制。 因此,一旦数值被移动,所有权被转移,旧的变量就不能再被使用。
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 类型的值时,行为是复制。但是,当值是 String 类型的时候,值被移动了。
什么原因?
好吧,这又归结于类型的概念和它们的能力。一般在堆上分配的类型通常默认为移动值,而大多数放在堆上的基本类型则是复制。String 是一个在堆上管理内存的类型,因此它的默认能力不是复制。要创建一个独特的 String 的副本,就必须要克隆它。
Rust 进一步区分了复制和克隆:复制可以解释为简单地将比特从一个内存位置转移到另一个,而克隆除了移动比特之外,还需要执行额外的逻辑。
这种移动数值的整体概念是 Rust 特有的,新接触 Rust 的程序员需要意识到这一点。
借用和值引用
现在,为了重现我们在 Javascript 中的情景,即在一个变量中改变数组会导致另一个变量发生改变,我们有以下 Rust 代码:
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
所拥有的值可以被修改。
然后让let b = &mut a;
是神奇发生的地方。
这不是把值从a
移到 b,而是让b
借用这个值。
这种形式的借用是通过创建一个引用并将其交给b
来实现的。 语法&mut
是用来实现这个目的的,mut 关键字表示可以通过这个引用来改变值。
借用一个值是可能的,也就是说,创建一个值的引用,但不能改变这个值。算是一个只读的。 在这种情况下,你只能使用&
,例如:
rust
fn main() {
let mut a = vec![1,2,3,4];
let b = &a;
println!("{b:?}");
}
但是有一些事情你可以做,也不能做,这取决于一个值是拥有的、移动的、借用的、可变的还是不可变的。这些限制是为了确保内存安全,这就是借用检查器所执行的。
总之,借用检查器确保:只要没有变异引用,你可以对同一个内存位置有多个只读引用。如果对一个内存位置创建了一个易变的引用,那么只有这个易变的引用可以从该内存位置读取和写入。
关于指针和使用引用来处理 DST 的介绍
DST 已经作为在编译时没有唯一大小的类型引入,并且如上所述,Rust 更喜欢处理在编译时可以知道其大小的类型。那么,Rust 是如何处理 DST 的呢?为了理解这一点,我们需要谈谈指针。
Rust 中的指针和引用
指针是变量,就像其他类型的变量一样,但它们的值是其他变量的内存地址。它们是一个指示器,指向可以找到另一个值的内存位置。
在 Rust 中,指针也有类型,这很有意义,因为可以想象,特定的功能只对指针可用。指针也可以在 C 和 C++ 等语言中找到,但是由于可能会滥用对内存位置不受阻碍的访问,使用它们可能会很困难,而且不安全。
这就是为什么在 Rust 中,你几乎不直接处理指针的原因。取而代之的是引用,它是具有安全或活泼性保证的指针。
你可以把它们看作是一个保护层,使使用指针的工作更加安全。不是引用的指针,也就是在 Rust 中没有这种保护性保证的指针通常被称为原始指针。
引用的问题是,它们也有类型,而它们的好处是,它们有一个已知的大小。这是因为引用持有内存地址,因此可以有一个恒定的位长,它将永远被分配给引用,大到足以使它能够存储任何需要存储的内存地址。
有时,关于它们所指向的内存地址中的值的额外元信息也会被存储,当这种情况出现时,该引用通常被称为胖指针。
创建引用的语法是& T
。要创建对一个类型T
的引用,使用&T
。例如,在下面的代码中。
rust
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。由于引用是在编译时已知大小的类型,所以诀窍是只允许通过对它们的引用与 DST 进行交互。这就是为什么用str
注释类型会导致编译失败,而使用&str
(对 DST 的引用)却可以正常编译的原因。
总结:T
(Sized 和 DST)、&T
和&mut T
正如本文开头所提到的,类型是指对一个值所允许的编码能力。Rust 对此进行了扩展,包含了与内存管理和布局有关的能力。
所以类型T
可以以两种方式存在。 一种是它的内存大小总是已知为一个特定的位长:这些类型被称为 "固定大小的类型"; 另一种是其大小/位长不是唯一并且可以变化的:这些被称为 "动态大小的类型"。
然后是引用,它是指针(持有内存位置的变量),保证了对内存的安全访问。 这些保证是由借用检查器强制执行的。
这些引用可以是 &T
或 &mut T
类型,这取决于引用是否是可变的。如果类型 T
是一个 DST,则可以使用 &T
(或 &mut T
)类型的变量来引用该DST。