Box<T>智能指针·实践领悟

爱国的张浩予 发表于 2022-10-16 21:45

先概述,再逐一展开

Box<T>是【所有权·智能指针】

  1. Box<T>是【智能指针】,因为impl Deref for Boximpl DerefMut for Box。于是,当&Box<T>作为函数的实参时,就有了从&Box<T>&T的【自动解引用】语法糖。从效果上看,这就让以&T为形参的函数func(&T)能够接收&Box<T>的实参 — 形似OOP多态。但,这和GC类语言【多态性】的最大区别在于:
    1. 由【智能指针】【自动解引用】模仿的【多态】是编译时行为(【术语】单态化;它会延长编译时长)与【零(运行时)成本】 。所以,我的一贯观点是:Rust编译费时不是“瑕疵”,而是语言特征。它就是要在这个环节“变戏法”。倘若你真那么介意编译时间的话,没准【脚本语言】才更符合你的产品需求。
    2. GC类语言的【多态】是由强大的VM提供的运行时语言特性。即,将“变戏法”的时间点选择在了【运行时】。
  2. Box<T>是【所有权·变量】,因为它的生命周期与被引用【堆·数据】的生命周期绝对同步。具体地讲,
    1. Box::new(T)既将【栈】数据搬移至【堆】内存,同时也获取了原数据的【所有权】。
      1. 虽然Box<T>指针自身被保存在【栈】上,但由它所指向的数据却是在【堆】上。
      2. 其它变量只能通过&Box<T>(即,指针的引用)来间接地访问到【堆】上的原始数据。
    2. impl Drop for BoxBox<T>指针的析构时间点与【堆·数据】生命周期的终止时间点·严格地对齐。
      1. 于是,【堆·数据】何时被释放·就得看【栈】上的Box<T>实例会“活”到什么时候了。

不夸张地讲,Box<T>就是【堆·数据】在【栈】内存中的“全权·代理人”。具有同类特点的【智能指针】还包括StringCString等。

Box<T>FFIC ABI指针

Box<T>可直接作为“载体”,在RustC之间,穿越FFI边界,传输数据。

使用场景·介绍

适用场景

总结起来,Box<T>和被“糖”的完整语法形式(包括

)仅适用于由【场景一】+【场景二】构成的“闭环”使用场景:

  1. Rust
    1. 定义与导出FFI函数接口
    2. 定义与实例化FFI数据结构
  2. C端
    1. 调用Rust - FFI接口函数
    2. 获取Rust - FFI数据结构实例
    3. 使用该实例搞一系列操作
    4. 再调用Rust - FFI接口函数,将该实例给释放掉

题外话,你有没有对这个套路略感眼熟呀?再回忆回忆,它是不是FFI: Object-Based APIs设计模式。英雄所见略同!

不适用场景

另外,Box<T>和被“糖”的完整语法形式(包括

  1. 数据结构在C端定义
  2. 变量值也在C端被实例化

)。因为Box<T>对被接收的原始指针有如下(确定性)假设invariant

而这些假设,C端他保证不了!所以,我强烈推荐使用libc crate定义的各种数据类型与原始指针(比如,libc::c_char)来最贴切地“镜像”C数据类型到Rust端。我没有推荐其它的crate,因为我没用过,我不会!而不是因为libc crate真的有多好!

场景一·技术细节·展开

Rust FFI导出函数而言,函数返回值可直接使用Box<T: Sized>作为返回值类型,而不是原始指针*mut T 例程1。这样就绕开了Rust 2015版次要求的完整语法形式 例程2

RustC导出值的关键语句(伪码)let ptr: *mut T = Box::into_raw(Box::new::<T: Sized>(data: T));。它完成的任务可被拆解为:

上面看似繁复的处理流程,以Rust术语,一言概之:将·变量值·的【所有权】从FFIRust端转移至C调用端。或称,穿越FFI边界的变量【所有权】转移。

场景二·技术细节·展开

Rust FFI导出函数而言,函数·形参·类型可直接使用Option<Box<T: Sized>>,而不是原始指针*mut T 例程1。这样就绕开Rust 2015版次要求的完整语法形式 例程2。好处显而易见:

if ptr.is_null() { // 原始指针【判空】没有来自`Borrow Checker`监督。
  return;          // 若忘记了,那就等着运行时的内存段错误吧!
}

转变成由Borrow Checker监督落实的显示None值处理(再一次Hygienic

let value = if let Some(value) = input { // 开发忘记指针【判空】没有关系。编译失败会提醒你的。
  value
} else {
  return;
};

CString::as_ptr(&self) -> *const T返回值不可暴露给FFIC

要说清楚这其中的关窍,就得把CString::as_ptr(&self) -> *const TCString::into_raw(self) -> *mut T对照着来讲。

先介绍CString::into_raw(self) -> *mut T

Box::into_raw(Box<T: Sized>) -> *mut T关联函数很容易就联想到CString::into_raw(self) -> *mut T成员方法,因为它们的功能极为相似,且在FFI编程中也十分常见。那你是否曾经纠结过:为什么into_raw()在Box<T>上是关联函数,而在CString上却是成员方法呢?回答:

  1. into_raw()设计为Box<T>的关联函数是因为Box<T>是通用【智能指针】呀!所以,我们的设计·有必要最大限度地避免由【自动解引用】造成的【智能指针Box<T> as Deref】与【内部·实例<Box<T> as Deref>::Target】成员方法之间的“命名·冲突”。即,Box<T>的成员方法千万别遮蔽了<Box<T> as Deref>::Target的成员方法。
  2. into_raw()设计为CString的成员方法是因为CString仅只是CStr一个类型的【智能指针】,且已知CStr结构体没有into_raw()成员方法。于是,符合程序员直觉与看着顺眼就是首要关切。那还有什么比.操作符更减压的呢?— 若你非较真儿的话,我更偏向认为?操作符·语法糖才是最令人欲罢不能的!

再讲原因

一方面,CString::as_ptr(&self) -> *const T仅只返回了内部数据的内存地址“快照”(不携带任何生命周期信息与约束力)。所以,例程3

唯一令人欣慰的好消息是:rustc 2021已经能够linter警告”由【成员方法·链式调用】造成的dangling原始指针“了。该lint规则被称作temporary_cstring_as_ptr例程3

另一方面,CString::into_raw(self) -> *mut T在返回内部数据【原始指针】的同时

综上所述,由CString::as_ptr(&self) -> *const T返回的【原始指针】只可用来看(我现在也没有领会到:只能看,能有什么用?),而不能被【解引用】拿来用。

最后,结合FFI使用场景

  1. CString::into_raw(self) -> *mut T等效于将CString实例【所有权】转移给了FFIC端。但是,绝对不可使用C端的free()函数来回收CString实例占用的内存。相反,得模仿Box<T>的作法:
    1. 先,将该CString实例,经由FFI Rust ABI,传回给Rust端。
    2. 再,使用CString::from_raw(*mut T)恢复Rust对该CString实例的【所有权】管控
    3. 最后,由Drop Checker自动地在【作用域】(结束)边界处调用<CString as Drop>::drop()成员方法将此CString实例给回收掉。
  2. CString::as_ptr(&self) -> *const T是没有资格被使用于FFI场景的,因为一旦FFI - Rust导出函数被执行结束,那么
    1. *const T指向的CString实例内存就立即被Drop Checker给回收掉了。
    2. FFI - C端拿到的仅仅是一个【野指针】。

结束语

这次,我就分享这些心得体会。我对rust的实践机会少,所以不仅文章产出少,对技术知识点阐述的深度也有限。希望路过的神仙哥哥,仙女妹妹多评论指正,共同进步。