我也谈Box<T>
智能指针·实践领悟
先概述,再逐一展开
Box<T>
既是【所有权·智能指针】— 即,它是【堆·数据】在【栈】内存上的“全权·代表”。Box<T>
也是FFI
按【引用】传值的C ABI
指针 — 即,它是Box::into_raw(Box<T: Sized>) -> *mut T
的语法糖。- 相较于
Box::into_raw(Box<T: Sized>) -> *mut T
,CString::as_ptr(&self) -> *const T
的【原始指针】返回值绝不能作为FFI
参数·传递。
Box<T>
是【所有权·智能指针】
Box<T>
是【智能指针】,因为impl Deref for Box
和impl DerefMut for Box
。于是,当&Box<T>
作为函数的实参时,就有了从&Box<T>
到&T
的【自动解引用】语法糖。从效果上看,这就让以&T
为形参的函数func(&T)
能够接收&Box<T>
的实参 — 形似OOP
多态。但,这和GC
类语言【多态性】的最大区别在于:- 由【智能指针】【自动解引用】模仿的【多态】是编译时行为(【术语】单态化;它会延长编译时长)与【零(运行时)成本】 。所以,我的一贯观点是:
Rust
编译费时不是“瑕疵”,而是语言特征。它就是要在这个环节“变戏法”。倘若你真那么介意编译时间的话,没准【脚本语言】才更符合你的产品需求。 GC
类语言的【多态】是由强大的VM
提供的运行时语言特性。即,将“变戏法”的时间点选择在了【运行时】。
- 由【智能指针】【自动解引用】模仿的【多态】是编译时行为(【术语】单态化;它会延长编译时长)与【零(运行时)成本】 。所以,我的一贯观点是:
Box<T>
是【所有权·变量】,因为它的生命周期与被引用【堆·数据】的生命周期绝对同步。具体地讲,Box::new(T)
既将【栈】数据搬移至【堆】内存,同时也获取了原数据的【所有权】。- 虽然
Box<T>
指针自身被保存在【栈】上,但由它所指向的数据却是在【堆】上。 - 其它变量只能通过
&Box<T>
(即,指针的引用)来间接地访问到【堆】上的原始数据。
- 虽然
impl Drop for Box
将Box<T>
指针的析构时间点与【堆·数据】生命周期的终止时间点·严格地对齐。- 于是,【堆·数据】何时被释放·就得看【栈】上的
Box<T>
实例会“活”到什么时候了。
- 于是,【堆·数据】何时被释放·就得看【栈】上的
不夸张地讲,Box<T>
就是【堆·数据】在【栈】内存中的“全权·代理人”。具有同类特点的【智能指针】还包括String
和CString
等。
Box<T>
是FFI
的C ABI
指针
Box<T>
可直接作为“载体”,在Rust
与C
之间,穿越FFI
边界,传输数据。
使用场景·介绍
- 场景一:将
Rust
内存上的一整段数据·扣出来(连同【所有权】一起)“移交”给FFI
的C
(调用)端。对FFI
的Rust
端,这意味着:被“移交出”的数据“已死”。即,Drop Checker
将其视为“已释放”,而不会再隐式地调用<Box<T> as Drop>::drop(self)
成员方法了。Borrow Checker
将其视为“已无效”。因为该变量的【所有权】被“消费”consumed
掉,所以禁止对该变量的任何后续·引用·与·移动·操作。
- 场景二:将在【场景一】由
FFI
接口“移交出”的内存数据·重新再给接收回来。进而,析构与释放掉(最初由Rust
端分配的)内存。即,自己分配的内存必须由自己回收。- 经验法则:由
Rust
端分配的内存数据最终还是要由Rust
端“出手”以相同的memory layout
析构与释放。而不是,由C
端的free()
函数就地释放,因为由Rust
端默认采用的std::alloc::Global
非常可能与C
端【分配器】不一样。这不完犊子了吗!
- 经验法则:由
适用场景
总结起来,Box<T>
和被“糖”的完整语法形式(包括
- 【
Rust -> C
导出】Box::into_raw(Box<T: Sized>) -> *mut T
- 【
C -> Rust
导入】unsafe Box::from_raw<T: Sized>(*mut T) -> Box<T>
)仅适用于由【场景一】+
【场景二】构成的“闭环”使用场景:
Rust
端- 定义与导出
FFI
函数接口 - 定义与实例化
FFI
数据结构
- 定义与导出
C
端- 调用
Rust - FFI
接口函数 - 获取
Rust - FFI
数据结构实例 - 使用该实例搞一系列操作
- 再调用
Rust - FFI
接口函数,将该实例给释放掉
- 调用
题外话,你有没有对这个套路略感眼熟呀?再回忆回忆,它是不是FFI: Object-Based APIs设计模式。英雄所见略同!
不适用场景
另外,Box<T>
和被“糖”的完整语法形式(包括
- 【
C -> Rust
导入】unsafe Box::from_raw<T: Sized>(*mut T) -> Box<T>
)不能用来接收C
端数据结构的变量值(即,
- 数据结构在
C
端定义 - 变量值也在
C
端被实例化
)。因为Box<T>
对被接收的原始指针有如下(确定性)假设invariant
:
- 内存对齐
- 非空
- 由
Global Allocator
内存分配
而这些假设,C
端他保证不了!所以,我强烈推荐使用libc crate
定义的各种数据类型与原始指针(比如,libc::c_char
)来最贴切地“镜像”C
数据类型到Rust
端。我没有推荐其它的crate
,因为我没用过,我不会!而不是因为libc crate
真的有多好!
场景一·技术细节·展开
就Rust FFI
导出函数而言,函数返回值可直接使用Box<T: Sized>
作为返回值类型,而不是原始指针*mut T
[例程1]。这样就绕开了Rust 2015
版次要求的完整语法形式 [例程2]。
从Rust
向C
导出值的关键语句(伪码)let ptr: *mut T = Box::into_raw(Box::new::<T: Sized>(data: T));
。它完成的任务可被拆解为:
- 将【栈·数据】搬移至【堆】内存上 — 只有【堆·数据】才能被传递给
C
端,因为- 【栈·数据】会随着函数执行结束而被【栈
pop
操作】给释放掉 - 【堆·数据】可以被假装释放和不再被追踪。
- 【栈·数据】会随着函数执行结束而被【栈
- “消费”掉·原数据实例·所有权 — 【借入·检查器】将进一步禁止对该·变量·的任何后续操作。
- 取出【堆·数据】的原始指针 — 该指针是要被传输给
C
端的。 - 将该数据从
Drop Checker
监控清单“除名”。这样,当函数结束时,Drop Checker
就不会调用<Box<T> as Drop>::drop(T)
成员方法和自动释放内容了。 - 返回【原始指针】作为函数返回值
上面看似繁复的处理流程,以Rust
术语,一言概之:将·变量值·的【所有权】从FFI
的Rust
端转移至C
调用端。或称,穿越FFI
边界的变量【所有权】转移。
场景二·技术细节·展开
就Rust FFI
导出函数而言,函数·形参·类型可直接使用Option<Box<T: Sized>>
,而不是原始指针*mut T
[例程1]。这样就绕开Rust 2015
版次要求的完整语法形式 [例程2]。好处显而易见:
-
避免明文地编写
unsafe code
(伪码:let data: Box<T> = unsafe {Box::from_raw::<T: Sized>(ptr: *mut T)};
),就能达成:- 将由【原始指针】引用的
C
端变量值·纳入到·Rust
端Drop Checker
的生命周期监控范围内。 Rust
端Borrow Checker
也会开始“抱怨”任何对C
端变量值有【内存泄漏风险】的操作语句。在Rust
词典中,对此有一个术语叫Hygienic
— 我打趣地将它翻译为“大保健”。
- 将由【原始指针】引用的
-
将对
C
端变量值的【判空】处理,从依赖开发者自觉性的随机行为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
返回值不可暴露给FFI
的C
端
要说清楚这其中的关窍,就得把CString::as_ptr(&self) -> *const T
与CString::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
上却是成员方法呢?回答:
- 将
into_raw()
设计为Box<T>
的关联函数是因为Box<T>
是通用【智能指针】呀!所以,我们的设计·有必要最大限度地避免由【自动解引用】造成的【智能指针Box<T> as Deref
】与【内部·实例<Box<T> as Deref>::Target
】成员方法之间的“命名·冲突”。即,Box<T>
的成员方法千万别遮蔽了<Box<T> as Deref>::Target
的成员方法。 - 将
into_raw()
设计为CString
的成员方法是因为CString
仅只是CStr
一个类型的【智能指针】,且已知CStr
结构体没有into_raw()
成员方法。于是,符合程序员直觉与看着顺眼就是首要关切。那还有什么比.
操作符更减压的呢?— 若你非较真儿的话,我更偏向认为?
操作符·语法糖才是最令人欲罢不能的!
再讲原因
一方面,CString::as_ptr(&self) -> *const T
仅只返回了内部数据的内存地址“快照”(不携带任何生命周期信息与约束力)。所以,[例程3]
-
*const T
指针的存在并不能暗示Drop Checker
给CString
实例“续命”。- 即,
Drop Checker
会无视*const T
指针的存在而在块作用域(或函数)结束时立即drop
掉CString
实例。
- 即,
-
Borrow Checker
也不会,因为*const T
指针正在借入已经被释放的CString
实例,而编译失败和抱怨:“正在借入一个已dropped
的变量”。- 一旦该【
dangling
原始指针】在某处被【解引用】取值,这馁馁地就是一个运行时内存段错误。错误原因你猜去吧!
唯一令人欣慰的好消息是:
rustc 2021
已经能够linter
警告”由【成员方法·链式调用】造成的dangling
原始指针“了。该lint
规则被称作temporary_cstring_as_ptr
。[例程3] - 一旦该【
另一方面,CString::into_raw(self) -> *mut T
在返回内部数据【原始指针】的同时
- 消费掉
CString
实例的所有权。于是,Borrow Checker
会监督该CString
实例不会再被借入或移动。 - 通知
Drop Checker
莫要真释放CString
实例内存,和将该CString
实例从Drop Checker
监管清单除名。于是,<CString as Drop>::drop(self)
成员方法就不会被Drop Checker
隐式自动执行。
综上所述,由CString::as_ptr(&self) -> *const T
返回的【原始指针】只可用来看(我现在也没有领会到:只能看,能有什么用?),而不能被【解引用】拿来用。
最后,结合FFI
使用场景
CString::into_raw(self) -> *mut T
等效于将CString
实例【所有权】转移给了FFI
的C
端。但是,绝对不可使用C
端的free()
函数来回收CString
实例占用的内存。相反,得模仿Box<T>
的作法:- 先,将该
CString
实例,经由FFI Rust ABI
,传回给Rust
端。 - 再,使用
CString::from_raw(*mut T)
恢复Rust
对该CString
实例的【所有权】管控 - 最后,由
Drop Checker
自动地在【作用域】(结束)边界处调用<CString as Drop>::drop()
成员方法将此CString
实例给回收掉。
- 先,将该
- 而
CString::as_ptr(&self) -> *const T
是没有资格被使用于FFI
场景的,因为一旦FFI - Rust
导出函数被执行结束,那么- 由
*const T
指向的CString
实例内存就立即被Drop Checker
给回收掉了。 FFI - C
端拿到的仅仅是一个【野指针】。
- 由
结束语
这次,我就分享这些心得体会。我对rust
的实践机会少,所以不仅文章产出少,对技术知识点阐述的深度也有限。希望路过的神仙哥哥,仙女妹妹多评论指正,共同进步。
评论区
写评论正好前几天看到了Cpp中的Handler的实现,其跟Box有相同的思想。这个文章正好从Rust层面解答了我的疑惑。尤其是FFI这块,是书里很难看到的,受益颇多,感谢作者的文章