​​Newtypes​​设计模式

请重点看两个[例程],[例程]写得真的很好,[例程]更精彩。

适用场景:

  • 克服【孤儿原则】,间接地将第三方​​crate​​声明的​​trait​​(e.g. ​​Display trait​​)实现于第三方​​crate​​定义的​​type​​(e.g. ​​Vec<T>​​)上。从而,低成本地增强一个已有类型,而不是“重新造轮子”。在​​js​​中,这类作法比比皆是。
  • 进一步语义化数据类型。举个例子,让​​rustc​​类型系统识别一个​​i32​​字面量​​5​​为​​5​​米,而不是​​5​​头猪。这样可以杜绝程序计算中出现“​​5​​米 + ​​5​​头猪”的逻辑错误。

场景一:克服【孤儿原则】

操作步骤 [例程1]:

  1. 首先,在本地​​crate​​给第三方​​type​​定义一个“薄”包装器类型​​Wrapper​​(一般为【元组结构体】)。
  2. 然后,将第三方​​type​​作为本地​​Wrapper​​私有字段的数据类型。
  3. 接着,给本地​​Wrapper​​实现第三方​​trait​​。
  4. 于是,形成了类型链条:“第三方​​trait​​ -- 被实现于 --> 本地​​Wrapper​​类型 -- 代理 --> 第三方​​type​​”。
  • 给本地​​Wrapper​​实现​​Deref / DerefMut trait​​,将其变形为【智能指针】。
  • 借助于​​Deref Coercion​​,本地​​Wrapper​​类型实例能够直接​​.​​出第三方​​type​​的成员方法与字段。从而,达成【代理】的目的。
  • 最后,【孤儿原则】破防。

场景二:语义化数据类型

最直观的作法是:

给每一个语义单位(比如,米、千米、斤、吨)分别创建一个独立的​​(tuple) struct​​​(比如,​​struct Miles(f64);​​)来

  • 包装标量值
  • 明确语义

从而避免在程序中出现“​​n​​​米 + ​​m​​​斤”的错误逻辑,因为​​rustc​​会警告类型不匹配。这个作法的弊端就是:

  • 当对语义化数据类型做【操作符-重载】时,操作符​​trait​​(比如,​​std::ops::Add​​)需要在每个语义化​​(tuple) struct​​上都被实现一遍。
  • 于是,相似的​​trait​​实现代码会被重复多次,因为,无论语义单位是“斤”还是“米”,其标量值的四则运算规则实际都是相同的。

更高级的作法是:

  • 将【语义单位】抽象成为共用【语义-包装类型】的【泛型类型参数】。而不是,给每一个语义单位分别创建一个独立的具体类型 --- 真有点傻乎乎的。
  • 借助于​​std::marker::PhantomData​​,将代表了语义单位的【泛型类型参数】作为【编译时】的类型标记,而不是【运行时】值。
  • 在静态类型检查之后,该类型标记便会被抛弃掉,而不会造成任何的运行时成本 --- 仅作为辅助【类型系统】静态代码检查的临时语法项。

所以,我理解​std::marker::PhantomData​​ + ​​newtypes​​设计模式 = 零(运行时)成本的语义化抽象

具体的作法 [例程2]:

  1. 声明一个​​(tuple) struct​​作为【语义-包装类型】。比如,​​struct SemVal<A, B>(A, PhantomData<B>);​​。
  • 前者为标量值数据类型;
  • 后者为编译时语义标记。
  • 前一个字段保存标量值;
  • 后一个字段为​​std::marker::PhantomData​​占位类型标记。
  • 有两个字段。
  • 有两个泛型类型形参。
  • ​(tuple) struct​​是通用【语义-包装器】。而,所有语义信息都存储在它的泛型类型参数里。
  • 给​​(tuple) struct​​做各种“赋能”
  • 【操作符-重载】,赋能标量值的“四则运算”能力。
  • 实现​​std::fmt::Display trait​​,赋能打印日志输出能力。即,输出有语义类型说明的标量值。
  • 派生​​PartialEq, PartialOrd trait​​,赋能【大小比较】能力。
  • 派生​​Clone, Copy trait​​,使其如标量值一样具有【复制-语义】,而不是【所有权-转移】。
  • 实现​​std::ops::Deref / std::ops::DerefMut trait​​,将其变形成【智能指针】和支持​​Deref Coercion​​。
  • 实现​​std::convert::From trait​​,赋能不同语义单位之间的标量值换算。比如,英寸​​<->​​厘米。
  • 给每个【语义单位】分别定义一个​​unit type​​。比如,用​​struct Centimeter;​​代表厘米。
  • 敲黑板,强调重点:虽然此​​unit type​​仅只作为【编译时】类型标记(并不会渗入【运行时】),但由于【​​auto trait​​扩散规则】,咱们也必须对其做​​Clone, Copy, PartialEq, PartialOrd trait​​的派生。否则,【语义-包装类型】将不具有【复制-语义】与【大小比较】能力。
  • 实例化一个有(业务逻辑)语义“加持”的标量值。例如,​​let cm1 = SemVal::<_, Centimeter>::new(5.0, PhantomData);​
  • 标量的具体类型由​​rustc​​推断
  • 泛型参数​​Centimeter​​标记(业务逻辑)语义类型,代表​​5.0​​是厘米
  • 由​​PhantomData​​实例占位。
  • 最后,​​rust​​类型系统就会确保
  • 不同(业务逻辑)单位之间,标量值不能四则运算与大小比较。
  • 但,它们可相互做单位换算。
  • 由于【复制语义】,它们不会所有权转移。
  • 能​​.​​出所有标量类型的成员方法。比如,求【对数】和【弧度换算】等。

结束语

关于​​Newtypes​​设计模式的分享大约就这些。后续有新的感悟与收获,我再补充。


例程1:https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=5f0e0536db246c407e7736551c5a78ee

例程2:https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=157195d69872478c9a77acad2107868c