对于一般的编程来说,引用是一种间接访问某些数据结构的方法,与任何拥有该数据结构的变量分开。在实践中,这通常作为指针实现:一个数字,其值是数据结构内存中的地址。
现代CPU通常会对指针施加一些限制——内存地址应该在有效的内存范围内(无论是虚拟还是物理),并且可能需要对齐(例如,4字节的整数值只有在其地址是4的倍数时才能访问)。
然而,更高级别的编程语言通常在其类型系统中编码更多关于指针的信息。在包括Rust在内的C衍生语言中,指针有一种类型,指示指向内存地址预计存在哪种数据结构。这允许代码解释该地址和该地址后面的内存中的内存内容。
这种基本级别的指针信息——假定的内存位置和预期的数据结构布局——在Rust中表示为原始指针。然而,“正常”的Rust代码不使用原始指针,因为Rust提供了更丰富的参考和指针类型,提供了额外的安全保证和约束。这些参考和指针类型是本项目的主题;原始指针被降级到之后讨论。(讨论unsafe
代码)。
Rust References (rust引用)
Rust中最普遍的类似指针的类型是引用,其类型被写为某些类型T
的&T
。虽然这是封面下的指针值,但编译器确保遵守围绕其使用的各种规则:它必须始终指向相关类型T
的有效、正确对齐的实例,其生命周期超出了其使用范围,并满足借入检查规则。Rust中的“引用”一词总是暗示这些额外的约束,因此裸露的术语“指针”通常很少见。
Rust引用必须指向有效、正确对齐的项目的约束由C++的引用类型共享。然而,C++没有生命周期的概念,因此允许带有悬挂引用的footguns1:
// 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; // on the stack
&x
}
error[E0515]: cannot return reference to local variable `x`
--> references/src/main.rs:399:5
|
399 | &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字节(此项目自始至终假设):
struct Point {
x: u32,
y: u32,
}
let pt = Point { x: 1, y: 2 };
let x = 0u64;
let ref_x = &x;
let ref_pt = &pt;
Rust引用可以引用位于堆栈或堆栈上的项目。Rust默认在堆栈上分配项目,但Box<T>
指针类型(大致相当于C++'sstdstd::unique_ptr<T>
)强制分配发生在堆上,这反过来意味着分配的项目可以超过当前块的范围。在封面下,Box<T>
也是一个简单的8字节指针值。
let box_pt = Box::new(
Point { x: 10,
y: 20
}
);
指针特征
期望像&Point
这样的引用参数的方法也可以给&Box<Point>
:
fn show(pt: &Point) {
println!("({}, {})", pt.x, pt.y);
}
show(ref_pt);
show(&box_pt);
(1, 2)
(10, 20)
这是可能的,因为Box<T>用Target = T实现了Deref trait。对某些类型实现这个trait意味着该trait的Deref()方法可以用来创建对Target类型的引用。还有一个等效的DerefMut trait,它发出一个对Target类型的可变引用。
Deref / DerefMut特性有些特殊,因为Rust编译器在处理实现它们的类型时具有特定的行为。当编译器遇到解引用表达式(例如*x)时,它查找并使用这些特征之一的实现,这取决于解引用是否需要可变访问。这种Deref强制转换允许各种智能指针类型的行为像普通引用一样,并且是Rust中允许隐式类型转换的少数机制之一。
撇开技术不谈,值得理解为什么Deref
特征不能是目标类型的通用(Deref<Target>
)。如果是这样,那么某种类型ConfusedPtr
有可能同时实现Deref<TypeA>
和Deref<TypeB>
,这将使编译器无法为像*x
这样的表达式推断出单个唯一类型。因此,目标类型被编码为名为Target
的相关类型。
撇开这个技术问题提供了与其他两个标准指针特征,AsRef和AsMut特征的对比。这些特征不会在编译器中诱导特殊行为,但也允许通过显式调用其特征函数(分别为as_ref()和as_mut())转换为引用或可变引用。这些转换的目标类型被编码为类型参数(例如AsRef<Point>
),这意味着单个容器类型可以支持多个目的地。
例如,标准String类型用Target = str
实现Deref
特征,这意味着像&my_string
这样的表达式可以强制为类型&str
。但它也实现了:
AsRef<[u8]>
,允许转换为字节切片&[u8]
。AsRef<OsStr>
,允许转换为操作系统字符串。AsRef<Path>
,允许转换为文件系统路径。AsRef<str>
,允许转换为字符串切片&str
(如Deref
)。
上面我们看到,通过编译器执行的Deref强制转换,接受引用的函数可以自动接受实现Deref的任何类型。这样的函数可以变得更通用,通过在AsRef / AsMut特征之一上使其泛型,并将其更改为在输入上使用.as_ref()。这意味着它接受最广泛的类引用类型:
fn show_as_ref<T: AsRef<Point>>(pt: T) {
let pt: &Point = pt.as_ref();
println!("({}, {})", pt.x, pt.y);
}
宽指针
Rust有两种内置的 宽指针类型:充当指针的类型,但包含有关它们所指向的东西的额外信息。
第一个这样的类型是切片:对一些连续的值集合的子集的引用。它由一个(非自有的)简单指针与长度字段一起构建,是简单指针的两倍(64位平台上为16字节)。切片的类型写为&[T]
——对[T]
的引用,这是T
类型值的连续集合的名义类型。
名义类型[T]
无法实例化,但有两个常见的容器体现了它。第一个是数组:一个连续的值集合,其大小在编译时是已知的。因此,切片可以引用数组的子集:
let array = [0u64; 5];
let slice = &array[1..3];
连续值的另一个常见容器是Vec<T>
。这拥有一系列连续的值,其大小可以变化,其内容保存在堆上。因此,切片可以指向量的子集:
let mut vec = Vec::<u64>::with_capacity(8);
for i in 0..5 {
vec.push(i);
}
let slice = &vec[1..3];
表达式&vec[1..3]
的盖子下发生了很多事情,所以值得分解成其组件:
1..3
部分是一个范围表达式;编译器将其转换为theRangeRange<usize>类型的实例,它具有一个包含的下限和一个排他性的上界。Range
类型实现SliceIndex<T>特征,该特征描述了任意类型T
的切片的索引操作(因此Output
类型是[T]
)。vec[ ]
部分是一个索引表达式;编译器将其转换为vec
上Index特征的index方法的调用,以及取消引用(即*vec.index( )
)。- 可变表达式的等效特征是IndexMut。
vec[1..3]
因此,调用Vec<T>
对Index<I>
的实现,这要求I
是SliceIndex<[u64]>
的实例。这有效,因为Range<usize>
对任何T
(包括u64
实现SliceIndex<[T]>
。&vec[1..3]
取消引用,导致&[u64]
的最终表达式类型。
第二个内置的脂肪指针类型是特征对象:对实现特定特征的某些项目的引用。它从指向项目的简单指针以及指向类型vtable的内部指针构建,大小为16字节(在64位平台上)。类型实现特征的vtable包含每个方法实现的函数指针,允许在运行时动态调度。
所以一个简单的特征:
trait Calculate {
fn add(&self, l: u64, r: u64) -> u64;
fn mul(&self, l: u64, r: u64) -> u64;
}
用一个实现它struct
:
struct Modulo(pub u64);
impl Calculate for Modulo {
fn add(&self, l: u64, r: u64) -> u64 {
(l + r) % self.0
}
fn mul(&self, l: u64, r: u64) -> u64 {
(l * r) % self.0
}
}
let mod3 = Modulo(3);
可以转换为&dyn Trait
类型的trait对象(其中dyn关键字突出显示涉及动态调度的事实):
// Need an explicit type to force dynamic dispatch.
let tobj: &dyn Calculate = &mod3;
let result = tobj.add(2, 2);
assert_eq!(result, 1);
包含特征对象的代码可以通过vtable中的函数指针调用特征的方法,将项目指针作为&self
参数传递;有关更多信息和建议,请参阅之后的文章。
更多指针特征
前一节描述了处理容易转换为引用的类型时使用的两对trait (Deref / DerefMut, AsRef / AsMut)。在处理类似指针的类型时,还有一些标准特征也可以发挥作用,无论是来自标准库还是用户定义的。
其中最简单的是Pointer特征,它格式化了输出的指针值。这可能有助于低级调试,当编译器遇到{:p}
格式说明符时,它会自动达到此特征。
更有趣的是Borrow和BorrowMut特性,它们都有一个方法(分别是Borrow和borrow_mut)。这个方法与等价的AsRef / AsMut trait方法具有相同的签名。
这些特征之间意图的关键差异可以通过标准库提供的一揽子实现来看到。给定一个任意的Rust引用&T
,AsRef
和Borrow
都有一个全面实现;同样,对于可变引用&mut T
,有一个AsMut
和BorrowMut
的全面实现。
然而,Borrow
也有(非参考)类型的全面实现:
impl<T> Borrow<T> for T
这意味着接受Borrow
特征的方法可以平等地处理T
的实例以及对T
的引用:
fn add_four<T: std::borrow::Borrow<i32>>(v: T) -> i32 {
v.borrow() + 4
}
assert_eq!(add_four(&2), 6);
assert_eq!(add_four(2), 6);
标准库的容器类型对Borrow
有更现实的用途;例如,HashMap::get使用Borrow
来方便地检索条目,无论是按值还是引用键入。
ToOwned特征建立在Borrow
特征上,添加一个to_owned()方法,该方法生成基础类型的新拥有项目。这是对Clone
特征的概括:Clone
特别需要Rust引用&T
,ToOwned
反而会处理实现Borrow
的东西。
这意味着:
- 对某些类型的引用进行操作的函数可以接受
Borrow
,这样也可以使用移动的项目和引用来调用它。 - 在某种类型的自有项目上运行的函数可以接受
ToOwned
,这样也可以通过引用项目以及移动项目来调用它;传递给它的任何引用都将被复制到本地拥有的项目中。
虽然它不是指针类型,但目前值得一提的是Cow类型,因为它提供了处理相同情况的替代方法。Cow
是一个可以保存自有数据或引用借入数据的enum
。特殊名称代表“写时克隆”:Cow
输入可以作为借入数据保留,直到需要修改,但在数据需要更改时成为自有副本。
智能指针类型
Rust标准库包括各种类型,在某种程度上像指针一样,由上述标准库特征介导。这些智能指针类型都带有一些特定的语义和保证,其优点是它们的正确组合可以对指针的行为进行细粒度控制,但缺点是结果类型一开始可能看起来势不可挡(Rc<RefCell<Vec<T>>>
任何人?)。
第一个智能指针类型是Rc<T>,它是指向项目的引用计数指针(大致类似于C++的std::shared_ptr<T>)。它实现了所有与指针相关的特征,因此在许多方面都像Box<T>
一样。
这对于可以以不同方式访问同一项目的数据结构很有用,但它删除了Rust关于所有权的核心规则之一——每个项目只有一个所有者。放松此规则意味着现在可以泄露数据:如果A项有一个指向B项的Rc
指针,而B项有一个指向A的Rc
指针,那么该对将永远不会被丢弃。换句话说:您需要Rc
来支持循环数据结构,但缺点是现在数据结构中存在循环。
在某些情况下,相关的Weak<T>类型可以改善泄漏的风险,该类型持有对基础项目的非自有引用(大致类似于C++的std::weak_ptr<T>)。持有弱引用并不能阻止删除基础项目(当删除所有强引用时),因此使用Weak<T>
涉及升级到Rc<T>
——这可能会失败。
在引擎盖下,Rc
(目前)作为一对引用计数与引用项目一起实现,全部存储在堆上
use std::rc::Rc;
let rc1: Rc<u64> = Rc::new(42);
let rc2 = rc1.clone();
let wk = Rc::downgrade(&rc1);
Rc
本身使您能够以不同方式访问项目,但当您到达该项目时,您只能(通过get_mut)在没有其他方法到达该项目的情况下修改它——即没有其他现存的Rc
或对同一项目的Weak
引用。这很难安排,所以Rc
经常与另一种智能指针类型相结合......
下一个智能指针类型RefCell<T>放松了规则,即项目只能由其所有者或持有(仅)对该项目的可变引用的代码进行突变。这种内部可变性允许更大的灵活性——例如,允许特征实现在方法签名只允许&self
突变内部。然而,它也会产生成本:以及额外的存储开销(用于跟踪当前借款的额外isize
),正常的借贷支票从编译时移动到运行时。
use std::cell::RefCell;
let rc: RefCell<u64> = RefCell::new(42);
let b1 = rc.borrow();
let b2 = rc.borrow();
这些检查的运行时性质意味着RefCell
用户必须在两个选项之间进行选择,两者都不令人愉快:
- 接受借贷是一种可能失败的操作,并应对
Result
值try_borrow[_mut]
使用所谓的万无一失的借款方法borrow[_mut],并接受恐慌的风险!如果未遵守借用规则,则在运行时。
无论哪种情况,这种运行时检查都意味着RefCell
本身没有实现任何标准指针特征;相反,其访问操作返回一个Ref<T>或RefMut<T>智能指针类型,该类型确实实现了这些特征。
如果底层类型T
实现了Copy
特征(表明快速比特对比特复制产生有效项,请参阅项目5),则Cell<T>
类型允许内部突变,开销更少——get(&self)
方法复制当前值,set(&self, val)
方法复制为新值。Cell
类型由Rc
和RefCell
实现在内部使用,用于共享跟踪可以在没有&mut self
的情况下突变的计数器。
到目前为止描述的智能指针类型仅适用于单线程使用;它们的实现假设没有对其内部的并发访问。如果不是这样,那么需要不同的智能指针,其中包括额外的同步开销。
Rc<T>
的线程安全等价物是Arc<T>,它使用原子计数器来确保参考计数保持准确。与Rc
,Arc
实现了所有与指针相关的特征。
然而,Arc
本身不允许对底层项目进行任何类型的可变访问。Mutex类型涵盖了这一点,它确保只有一个线程可以访问底层项目,无论是可变的还是不可变的。与RefCell
一样,Mutex
本身不实现任何指针特征,但其lock()
操作返回一个类型的值:MutexGuard,它实现了Deref[Mut]
。
如果读者可能比作家多,RwLock类型更可取,因为它允许多个读者并行访问基础项目,前提是目前没有(单个)作家。
无论哪种情况,Rust的借阅和线程规则都迫使在多线程代码中使用其中一个同步容器(但这只能防止共享状态并发的一些问题;见项目17)。
同样的策略——看看编译器拒绝什么,以及它暗示了什么——有时可以应用于其他智能指针类型;然而,理解不同智能指针的行为意味着什么更快,也不那么令人沮丧。借用Rust书第一版的一个例子,
Rc<RefCell<Vec<T>>>
持有具有共享所有权(Rc
)的向量(Vec
),其中向量可以突变——但只能作为整个向量。Rc<Vec<RefCell<T>>>
还持有具有共享所有权的向量,但在这里,向量中的每个条目都可以独立于其他条目进行突变。
所涉及的类型准确地描述了这些行为。
1:尽管有来自现代编译器的警告。
2:这有点简化;完整的vtable还包括有关类型大小和对齐的信息,以及drop()
函数指针,以便可以安全地删除底层对象。