《Effective Rust》第 8 条:熟悉引用和指针类型

  • King
  • 更新于 2024-06-28 15:52
  • 阅读 735

第8条:熟悉引用和指针类型在一般的编程中,引用(reference)是一种间接访问数据结构的方式,它与拥有该数据结构的变量是分开的。在实践中,引用通常由指针(pointer)来实现。指针是一个数字,它的值是数据结构的变量在内存中的地址。现代CPU通常会对指针施加一些限制:内存地

第 8 条:熟悉引用和指针类型

在一般的编程中,引用(reference) 是一种间接访问数据结构的方式,它与拥有该数据结构的变量是分开的。在实践中,引用 通常由 指针(pointer) 来实现。指针 是一个数字,它的值是数据结构的变量在内存中的地址。

现代 CPU 通常会对指针施加一些限制:内存地址应该处于有效的内存范围内(虚拟内存或物理内存),并且可能需要对齐(例如,一个4字节的整数值可能只有在其地址是4的倍数时才能访问)。

然而,高级编程语言通常会在其类型系统中编码更多关于指针的信息。在 C 衍生的语言(包括 Rust )中,指针中有一个类型,该类型表示期望在所指向的内存地址存储哪种类型数据结构。这允许通过代码解释在该地址以及随后内存中的内容。

这种基本的指针信息-假定的内存位置和预期的数据结构布局-在 Rust 中被表示为一个裸指针(raw point)。然而,安全的 Rust 代码不使用裸指针,因为 Rust 提供了更丰富的引用和指针类型,这些类型提供了额外的安全保证和约束。这些引用和指针类型是本节的主题;裸指针则留待[第16条]讨论(该节讨论 unsafe 代码)。

Rust引用

在 Rust 中,最常见的指针类型是 引用,用 &T 表示,其中 T 是任意类型。尽管在底层这是一个指针值,但编译器会确保在使用时遵循一些规则:

  • 始终指向有效且对齐正确的类型 T 的实例。
  • 被引用数据的生命周期(在[第14条]中介绍)必须比 引用 本身的生命周期更长。
  • 遵守借用检查规则(在[第15条]中解释)。

这些额外的约束总是隐含在 Rust 中的 引用 中,因此 裸指针 通常很少出现。

Rust 引用必须指向有效、正确对齐的项的约束,与 C++ 的引用类型相同。然而,C++ 没有生命周期的概念,因此允许使用悬空引用而导致错误[^1]:

// C++
const int& dangle() {
  int x = 32; // on the stack, overwritten later
  return x; // return reference to stack variable!
}

Rust 的借用和生命周期检查会让等价的代码甚至不能编译:

fn dangle() -> &'static i64 {
    let x: i64 = 32; // 在栈上
    &x
}
error[E0515]: cannot return reference to local variable `x`
   --> src/main.rs:477:5
    |
477 |     &x
    |     ^^ returns a reference to data owned by the current function

Rust 的引用 &T 允许只读访问底层元素(大致相当于 C++ 的const T&)。一个允许修改底层元素的可变引用写为 &mut T,同样也遵循第 15 项讨论的借用检查规则。这种命名方式反映了 Rust 和 C++ 之间略微不同的思维方式:

  • 在 Rust 中,默认情况下变量是只读的,可写类型需要特别标记(用 mut)。
  • 在 C++ 中,默认情况下引用是可写的,只读类型需要特别标记(用 const)。 编译器会将使用引用的 Rust 代码转换为使用简单指针的机器码,在 64 位平台上这些指针的长度为 8 个字节(本节假设一直如此)。

例如,一对局部变量以及对它们的引用:

pub struct Point {
    pub x: u32,
    pub y: u32,
}

let pt = Point { x: 1, y: 2 };
let x = 0u64;
let ref_x = &x;
let ref_pt = &pt;

可能最终在栈上布局如图1-2所示。

<img src="https://rustx-labs.github.io/effective-rust-cn/images/item8/stack.svg" alt="img"> 图1-2.带有指向局部变量的指针的栈布局

Rust 引用可以指向位于上的元素。Rust 默认情况下会在栈上分配内存,但是 Box&lt;T> 指针类型(大致相当于 C++ 的 std::unique_ptr&lt;T>) 会强制分配到堆上,这意味着分配的元素可以比当前代码块的作用域更长寿。本质上,Box&lt;T> 也是一个简单的8字节指针值(64 位平台):

注意

  • 栈是一种快速但有限制的内存区域,函数调用时分配,函数结束后释放。
  • 堆是一种更大但速度较慢的内存区域,程序可以显式分配和释放内存。
let box_pt = Box::new(Point { x: 10, y: 20 });

这在图1-3中被描述。 <img src="https://rustx-labs.github.io/effective-rust-cn/images/item8/heap.svg" alt="img"> 图1-3.栈上的 Box 指针指向堆上的 struct

指针特征

期望一个引用参数,如 &Point 的方法也可以接受一个 &Box&lt;Point>

fn show(pt: &Point) {
    println!("({}, {})", pt.x, pt.y);
}
show(ref_pt);
show(&box_pt);
(1, 2)
(10, 20)

这之所以可能,因为 Box&lt;T> 实现了 [Deref] 特征(Trait),Target = T。某个类型实现这个特征意味着该特征的 [deref()] 方法可以用于创建对 Target 类型的引用。还有一个等效的 [DerefMut] 特征,它会生成对 Target 类型的可变引用。

Deref/DerefMut 特征有点特别,因为Rust编译器在处理实现它们的类型时有特定的行为。当编译器遇到解引用表达式(例如,[*x]),它会根据解引用是否需要可变访问来查找并使用这些特征的实现。这种 Deref 转换允许各种智能指针类型像普通引用一样工作,它是 Rust 中少数允许隐式类型转换的机制之一(如[第5条]所述)。

作为一个技术细节,理解为什么 Deref 特征不能对目标类型是泛型的(Deref&lt;Target>)是很值得的。如果它们是,那么某些类型 ConfusedPtr 就可以同时实现 Deref&lt;TypeA>Deref&lt;TypeB>,这将使编译器无法为 *x 这样的表达式推导出唯一的类型。因此,目标类型被编码为一个名为 Target 的关联类型。 这种技术细节与另两个标准指针特征 [AsRef] 和 [AsMut] 形成对比。这些特征不会在编译器中引起特殊行为,但允许通过对其特征函数([as_ref()] 和 [as_mut()])的显式调用进行引用或可变引用的转换。转换的目标类型被编码为类型参数(例如,AsRef&lt;Point>),这意味着一个容器类型可以支持多个目标类型。

例如,标准 [String] 类型实现了 Deref 特征,Target = str,这意味着像 &my_string 这样的表达式可以强制转换为类型 &str。但它也实现了以下特征:

  • AsRef&lt;[u8]>,允许转换为字节切片 &[u8]
  • AsRef&lt;OsStr>,允许转换为OS字符串
  • AsRef&lt;Path>,允许转换为文件系统路径
  • AsRef&lt;str>,允许转换为字符串切片 &str(与 Deref 相同)

胖指针类型

Rust有两个内置的胖指针类型:切片(Slice)和特征(Trait)对象。这些类型的行为像指针,但它们持有关于指向对象的额外信息。

切片

第一种胖指针类型是切片:它引用某个连续值集合的子集。切片由一个(没有所有权的)简单指针和一个长度字段组成,因此大小是简单指针的两倍(在 64 位平台上为 16 字节)。切片的类型写为 &[T] - 它表示对 [T] 的引用,[T]是类型 T 的连续值集合的概念类型。

概念类型 [T] 不能被实例化,但是有两种常见的容器实现了它。第一种是数组:一个连续的值集合,其大小在编译时是已知的。

一个有5个值的数组将始终有5个值。因此,切片可以引用数组的一个子集(如图1-4所示):

let array: [u64; 5] = [0, 1, 2, 3, 4];
let slice = &array[1..3];

img 图1-4.指向栈数组的栈切片

连续值的另一种常见容器是 Vec&lt;T>。这像数组一样持有连续的值集合,但与数组不同,Vec中的值的数量可以增长(例如,用 [push(value)] )或缩小(例如,用 [pop()] )。 Vec 的内容保存在堆上(这允许其大小发生变化),并且总是连续的,因此切片可以引用 Vec 的子集,如图 1-5 所示:

let mut vector = Vec::&lt;u64>::with_capacity(8);
for i in 0..5 {
    vector.push(i);
}
let vslice = &vector[1..3];

img 图1-5.指向堆上的Vec内容的栈切片

表达式 &vector[1..3] 的底层有很多细节,所以值得将其拆解成d多个部分:

  • 1..3 部分是一个[范围表达式(range expression)];编译器会将其转换为 [Range&lt;usize>] 类型的实例,该类型包含下限(1)但不包含上限(3)。
  • Range 类型[实现了][SliceIndex&lt;T>]特征,该特征描述了对任意类型 T 的切片的索引操作(因此Output类型为[T])。
  • vector[]部分是一个[索引表达式(indexing expression)];编译器将其转换为在 vector 上调用 [Index] 特征的 [index] 方法,并附加一次解引用(即 *vector.index() )。[^2]
  • vector[1..3]会调用 Vec&lt;T>Index&lt;I> [实现],它要求 ISliceIndex&lt;[u64]> 的一个实例。这是因为 `Range<us...

剩余50%的内容订阅专栏后可查看

点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
King
King
0x56af...a0dd
擅长Rust/Solidity/FunC/Move开发