1 Star 0 Fork 11

coder_lw / wiki

forked from deepinwiki / wiki 
加入 Gitee
与超过 1200万 开发者一起发现、参与优秀开源项目,私有仓库也完全免费 :)
免费加入
克隆/下载
rust 特征.md 12.48 KB
一键复制 编辑 原始数据 按行查看 历史

rust 特征

前言

特征(trait)是 rust 类似接口的概念,但是它和其他语言还是有不少差异的。本文来总结一下这些差一点。

特征

概念:rust 里面的所有东西,都是有明确类型的,有一些类型可以写出来,有一些写不出来,但是编译器可以打印出来。作为一个强类型语言,rust 编程很大程度上就是要书写很多类型标注,还要去研究类型转换,类型继承扩展这些事情。这和动态语言不一样。

特征处于类型系统的一个顶端(root),我们可以使用它来扩展库里面的 api。

trait A{ 
    fn f1(&self);
    fn f2(&self){}
    fn f3(){}
}

特征,就是提供一组方法的抽象类

  1. 可以提供函数体实现,也可以省略。
  2. 函数实现将被继承。(被实现它的实体继承)
  3. 特征是没有大小的类型,所以它无法创建对象。
    1. 特征的对象就是实现它的实体
    2. 可以有特征对象:dyn A,其实体是引用或者智能指针的一部分
      1. dyn A 是所谓的动态分发(运行时确定类型)
      2. 相对的是静态分发,即编译期确定类型
  4. 当第一个参数是 self 、&self、&mut self 或能转换成以上三种的智能指针时
    1. 这是一个对象方法,即可通过 obj.f1() 这种形式可以调用的
    2. 对象方法支持多态,即根据 obj 真实的类型来确定调用哪个实现,而不是根据调用接口(如变量类型)来判断。
trait B{
    fn f1(&self);
    fn f2(&self){}
}
struct Oa; // 注意,和其他语言不同,实体在定义的时候不需要关联特征
struct Ob;

impl A for Oa{// 而可以在后续添加关联,即实现特征的定义
    fn f1(&self){}// 必须实现没有实现的函数
} 

实体和特征之间的关系并不是捆绑的

  1. 一个实体可以实现多个特征
  2. 一个特征可以被多个实体分别实现
  3. 实体的实现(函数实现)和数据定义是分开的
  4. 实体实现特征函数
    1. 特征未实现的函数需要实体实现
    2. 已实现的函数可以覆盖实现。即编译器会优先使用实体自己的实现。
      1. impl S{} 第一优先
      2. impl D for S{} 针对特定特征的实现,第二优先
      3. impl D{} 特征自己的默认实现,第三优先
      4. 注意:特征之间是没有优先级的,即使他们看上去具备层次关系,对实体来说都是同样的级别。
    3. 不支持函数重载,即参数不同的同名函数
      1. 在同一个作用域被视为冲突
      2. 在不同作用域,自身实现隐藏对特征的实现(即不能通过对象类型来调用,但它还存在)
impl B for Ob{// 一、实现 B 特征
    fn f1(&self){}
} 
impl Oa{
    fn f3(&self){}
}
impl Ob{ // 二、自身的实现
    fn f1(&self, _:i32){}
    fn f2(&self, _:i32){}
}

对于实体而言:

  1. 可以有自身的实现
  2. 可以拥有多个不同特征的实现
// impl B for Oa{} // 冲突
impl A for dyn B{}
impl<T:B> A for T{}

对特征而言,它可以选择不同对象来实现:

  1. 自身默认的实现
  2. 特定对象的实现
  3. 泛型 T 的实现
    1. 暂不支持特化(细化),如 for T 和 for i32 会冲突
    2. 可以对 T 所限制,缩小其冲突范围 T:B
      1. 当某 T,如 Oa 实现了 B,那么就出现冲突

T 的限制手段:

  1. T : 任意类型,注意它的定义是个嵌套操作,如 &T 包含 &&X, &[X]...
  2. T:A+B : 同时实现 A 和 B
  3. Oc<T>:B : 存在 Oc<T>:B 时,注意:左边是实体,右边是特征
  4. T:Fn() : 函数等
    1. std::ops::* 里面包含很多操作特征,可以用来限制 T
  5. for T,即目标位置上的 T
    1. &T : 只读借用 / 共享借用
    2. &mut T : 可写借用 / 独占借用
    3. * const T : 只读指针
    4. * mut T : 指针
    5. [T] : 切片
    6. fn(T) : 函数
    7. [T;32] : 数组
    8. (T,T,T) : 元组
    9. S<T> : 使用 T 做参数的类型

特征的限制手段:

  1. Self:B 即实现本特征的对象同时需要实现 B,这可以简写为 A:B ,也就是所谓特征的继承。
  2. Self:Sized 静态大小,即不能产生特征对象
  3. Self:Unsize<Self> 动态大小,不能产生实体?(还不是正式版)
  4. Self:?Sized 静态或动态都可能(默认,不用标注)

特征对象对特征的要求:

  1. 并不是什么特征都能使用特征对象,即 dyn A;
  2. 不能是:Self:Sized
  3. 不能有关联常量
  4. 函数明确标注不可分派,使用:Self:Sized ,如 fn f1(&self) where Self:Sized
  5. 当函数可动态分派时:
    1. 不能有泛型参数(允许生命周期)
    2. 有且只有第一个参数是 Self 相关的类型
  6. Self : T 中的 T (即超类特征)也必须满足上述要求
trait C{
    fn f1(&self){println!("C::F1")}
}
trait D
where Self : C
{}
trait E:C{}
trait F:Sized{}
trait G:Unsize<Self>{}
trait H/*:?Sized*/{}

struct Oc;
struct Od;
struct Oe;
struct Of;
struct Og;
struct Oh;

impl C for Oc{} // ok
impl D for Od{} // 因为 D:C 
impl C for Od{} // 所以 Od 必须也同时实现 C
impl E for Oe{} 
impl C for Oe{} // 同上
impl C for dyn B{} // B 特征满足 dyn B 的要求, dyn A 不行, dyn C 默认实现 C,所以不能重复定义。
impl F for Of{} // Sized 静态大小
// impl F for dyn B{} // 不允许,因为 dyn B 是动态大小
impl G for dyn B{} // 允许
// impl G for Og{} // Unsize 针对动态大小
impl H for Oh{} 

特征实现限制:

  1. 特征,或实现的对象,得有一个在本地范围内定义(Box<T>除外),这就是所谓的孤儿原则,这个限制比想象中要大,因为它很大层度限制了库的扩展
    1. 本地特征的定义和参数无关
    2. 本地类型的定义和类型别名无关
    3. 覆盖类型(Convered Type),即作为参数的类型 Vec<T>
    4. 未覆盖类型,即没有作为某类型参数的泛型 T
    5. impl Trait for T0
      1. Trait 或 T0 是本地
    6. impl<p1..pm> Trait<t1..,ti,..tn> for T0
      1. Trait 是本地,否则:
        1. ti 是本地,且 T0 是具体类型
        2. p1..pm 满足区间 (ti,tn],即不能落在 [t0..ti] 区间。
    7. Box<T> 例外
  2. for T,不能有重复定义,也就是大范围的T ,不能涵盖小范围的 T,得取消其中一个。
  3. dyn T + S ,特征对象可以添加其他约束,但 S 要是自动实现的。

调用方法:

fn test(){
    <Od as D>::f1(Od); // 用实际类型来调用特征接口,特征必须要和实际类型关联才能调用接口
    let a = Od;
    (&a as &dyn D).f1(); // 将引用转换成特征对象来动态调用接口
}

应用

Result<T,E> -> T

最近在网上看到一个网友说不想对 Result 做 match 判断,希望实现:

fn f()->T{
let a = OK("")?;
let b = Ok(a)?;
let c:T = Err(b)?
c
}

也就是它希望,不管Result里面是 T,还是E 都能返回 T.

trait MyInto<U>
{
  fn into2(self)->U;
}

impl<E,U> MyInto<U> for Result<U, E>
where U:From<E>,
{
  fn into2(self)->U{
      match self{
          Ok(t)=>t,
          Err(e)=>e.into(),
      }
  }
}

fn f(){
    let a:&str = Ok("").into2();
    let a:String = Err(a).into2(); // &str->String
    a
}

std 自带 Into 和 From 用来转换,但是因为孤儿原则,导致要扩展非常难。

经验总结:

  1. 对已有特征的修改(或覆盖),只能在 for T,这个 T 是你控制的情况下,也就是对原生的 String 之类的类型我们无能为力。
  2. 要么对目标类型做包装,要么使用新的特征
  3. 对特征进行扩展(对于其他语言来说是优先方案)存在很多问题
    1. 特征没有层次关系,只有约束关系,对实体来说他们是同级的,因此你不能扩展并复写他们的api,否则会出现 api 双重定义需要你明确指定是那个特征
    2. rust 是比较倡导静态分派的语言,很少使用特征对象。

特征对象

std::any::* 下面实现了动态类型识别的基础框架。

  1. Any 特征
    1. type_id(&self)->TypeId : 获取类型 id
    2. dyn Any : 使用特征对象时
      1. is<T>(&self)->bool : 判断是否为 T 类型
      2. downcast_ref<T>(&self)->Option<&T> : 转换到 &T
      3. downcast_mut<T>(&self)->Option<&mut T> : 转换到 &mut T
  2. TypeId 结构
    1. of<T>()->TypeId: 获取 T 的类型 id

因为实现了 impl<T> Any for T,所以所有类型都可以使用 Any 的接口,不需要特地转换成 Any。

所有类型的 id 都是不一样的,哪怕多一个 & 也是不同的类型。

rust 的特征对象是一个胖指针,包含两个指针,一个指向数据,一个指向虚表(函数的表格),也就是指向虚表的指针是静态编译的。每一个特征,和具体的类型,都会生成一个虚表,所以编译器可以很容易上具体的类型转换成特征对象,但是特征对象转换到基类的特征对象,是做不到的。因为特征对象并不知道具体的对象是什么,所以它无法确定要找那个具体对象的虚表(记住,虚表是根据具体对象和特征这两个因素来生成的)。一个特征,有多个实现它的具体对象,因此它不可能在编译期就知道应该选择那个具体对象。

这就是为什么 rust 特征对象不像其他语言那么轻而易举的转换成基类的特征对象。有没有办法实现这种转换,这就需要手动提供这种转换信息,将静态编译的转换为动态提供。

这是一个例子:

trait A{fn f1(&self){println!("A::f1()")}}
trait B:A{fn f2(&self){println!("B::f2()")}}
struct C;

let mut a:&dyn A;
let b:&dyn B = &C;
a = b; // 不能转换,B (在类型角度)没有转换到 A 的必要信息。

// 解决方案
trait B:A
where for<'a> &'a dyn A:From<&'a dyn B>
{
   fn getA(&self)->&dyn A; // 通过多态动态获取正确的虚表
}

impl<'a> From<&'a dyn B> for &'a dyn A{
   fn from(item: &'a dyn B)->Self{
      item.getA()
   }
}

// impl C
impl A for C{}
impl B for C{
   fn getA(&self)->&dyn A{
      self // 因为在 C 实现的接口,自然能取得正确的虚表
   }
}

a = b.into();

特征与多态

不要滥用特征。任何支持多态的语言,都会有一个毛病,就是让对象去实现它本来不应该去实现的 api。

特征的设计来源:

  1. 先有对象关系(以对象类型进行编码),归纳出共有的api,得到特征
  2. 先有客户代码(伪代码),得到特征,然后让对象实现 api。

两种方式都很依赖经验,方式一容易陷入细节、条理不清、高度耦合,方式二容易想当然、难以实现或效率低下。总之经验不足,api 必然是要修修补补,这很正常。但是应该避免:

  1. 没有对象关系,没有客户代码,想当然的设计 api。

因为这样必然会导致滥用,迫使对象去实现它不应该具备的接口,也迫使对象之间形成它不应该具备的对象网络拓扑。

对面向对象最大的批评,就是子类要完全实现父类,这是很强的自我限制。

如何判断是否过度设计了?首先就要看对象是否编写了很煞笔的 api 实现。这些实现完全是为了实现接口而去实现的,并没有什么意义。我们应该尽量的精简接口,而不是反过来去实现多余的接口,这是设计的首要原则,不要暴露无意义的公开接口,哪怕你代码质量存在问题,那也是内部的事情,通过自我迭代,代码质量可以得到提高。

但一旦成为外部接口,就不可避免的连累到客户。所以这种封闭性是应该首先倡导的。

因此,我们完全没必要去追求高度抽象,根据具体的对象来编程,往往能得到最精炼的api,最简要的设计,这样也便于我们在实际项目中快速更新迭代。

特征更适合成熟的产品,如 rust 的官方库。我们实现 rust 库中的特征,或者在自己的 api 中添加这些特征,这是没有毛病的。他们很成熟,相当于免费提供了优秀的方案给我们,让我们的代码变得规范而优秀,同时也利于和客户代码整合。

当然不是说逃避抽象,只是说抽象应该是自然而然的。在重复代码出现到一定的层度,那么就算别人不要求,你也会厌倦,而使用抽象来隔离具体的对象。关键是掌握这个技术,然后在必要的时候使用,审慎的检查对象是否契合这个接口。

参考

  1. Trait 实现的“禁忌”: https://rustcc.cn/article?id=70a4d45a-043e-497d-9ef3-07164659dd6d
1
https://gitee.com/coder_lw/wiki.git
git@gitee.com:coder_lw/wiki.git
coder_lw
wiki
wiki
master

搜索帮助