< 返回我的博客

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

Tags:Box,CString,CStr,raw-pointer,ffi,abi,as_ptr

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

先概述,再逐一展开

  • Box<T>既是【所有权·智能指针】— 即,它是【堆·数据】在【栈】内存上的“全权·代表”。
  • Box<T>也是FFI按【引用】传值的C ABI指针 — 即,它是Box::into_raw(Box<T: Sized>) -> *mut T的语法糖。
  • 相较于Box::into_raw(Box<T: Sized>) -> *mut TCString::as_ptr(&self) -> *const T的【原始指针】返回值绝不能作为FFI参数·传递。

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边界,传输数据。

使用场景·介绍

  • 场景一:将Rust内存上的一整段数据·扣出来(连同【所有权】一起)“移交”给FFIC(调用)端。对FFIRust端,这意味着:被“移交出”的数据“已死”。即,
    • 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>

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

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

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

不适用场景

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

  • C -> Rust导入】unsafe Box::from_raw<T: Sized>(*mut T) -> Box<T>

不能用来接收C端数据结构的变量值(即,

  1. 数据结构在C端定义
  2. 变量值也在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]

RustC导出值的关键语句(伪码)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术语,一言概之:将·变量值·的【所有权】从FFIRust端转移至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端变量值·纳入到·RustDrop Checker的生命周期监控范围内。
    • RustBorrow 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返回值不可暴露给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]

  • *const T指针的存在并不能暗示Drop CheckerCString实例“续命”。

    • 即,Drop Checker会无视*const T指针的存在而在块作用域(或函数)结束时立即dropCString实例。
  • 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使用场景

  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的实践机会少,所以不仅文章产出少,对技术知识点阐述的深度也有限。希望路过的神仙哥哥,仙女妹妹多评论指正,共同进步。

评论区

写评论
ManonLoki 2022-10-17 17:19

正好前几天看到了Cpp中的Handler的实现,其跟Box有相同的思想。这个文章正好从Rust层面解答了我的疑惑。尤其是FFI这块,是书里很难看到的,受益颇多,感谢作者的文章

1 共 1 条评论, 1 页