AsRef、Deref、Borrow:Rust中与引用相关的各种特征

646 阅读7分钟

Rust标准库里提供了若干个与引用相关的trait,通过这些特征可以获取需要的引用类型,通常用于扩展方法签名的灵活性,可以接受更多的输入类型。

具体来说,常见的特征有6种:AsRefAsMutDerefDerefMutBorrowBorrowMut,它们按可变/不可变可以分为三组,下面只分析不可变版本的,也就是AsRefDerefBorrow,可变版本可依此类推。

让我们先来看一下这三个特征的定义(从标准库中拷出,去掉宏,只保留必要注释):

pub trait AsRef<T: ?Sized> {
    /// Converts this type into a shared reference of the (usually inferred) input type.
    fn as_ref(&self) -> &T;
}

pub trait Deref {
    /// The resulting type after dereferencing.
    type Target: ?Sized;

    /// Dereferences the value.
    fn deref(&self) -> &Self::Target;
}

pub trait Borrow<Borrowed: ?Sized> {
    fn borrow(&self) -> &Borrowed;
}

它们看起来很类似,都是将一个引用类型转化为另一个引用类型,那么它们有什么区别吗,或者它们分别是针对什么场景而设计的呢?

AsRefDeref

我们先来看AsRefDeref,二者的定义其实很接近,都是实现&self->&T的转换,可以将一个不可变引用转为另一个不可变引用,而且它们其实上也很常见。Deref就不用多说了,了解过Rust智能指针的都清楚这个特征,BoxArc都是基于Deref特征实现,实现了Deref特征的类,在遇到*操作符(也就是解引用)时会调用它的deref方法,并递归地处理,这样实现了Deref特征的类在一定程度上就可以当作一个通常的指针使用,也就是所谓的智能指针。AsRef其实也很常见,可能你没有注意到,它频繁出现在Rust的文件读写接口,与路径相关的接口基本都能看到它,以打开文件的接口为例,Rust是这么定义的:

    pub fn open<P: AsRef<Path>>(&self, path: P) -> io::Result<File> {
        self._open(path.as_ref())
    }

那么AsRefDeref有什么区别吗,或者说既然已经有了Deref特征,为什么还需要再设计一个AsRef特征?仔细分析它们的定义就能发现,AsRef的目标类型是通过泛型定义的,而Deref的目标类型则是通过关联类型定义。因此它们的区别实质上就是泛型与关联类型的区别。

通常情况下,关联类型与泛型非常接近,关联类型除了能提高泛型的可读性,它更重要的是增加一个限制:只能有一个实现版本,我们在实现这个特征时只能选择性地实现一个版本:

struct SmartPointer {
    s: String
}

/// 无法编译,这两个版本只能选择一个
impl std::ops::Deref for SmartPointer {
    type Target = String;

    fn deref(&self) -> &Self::Target {
        return &self.s;
    }
}

/// 无法编译,这两个版本只能选择一个
impl std::ops::Deref for SmartPointer {
    type Target = str;

    fn deref(&self) -> &Self::Target {
        return &self.s;
    }
}

与之相反,AsRef的实现可以有任意多的版本:

struct SmartPointer {
    s: String,
}

impl AsRef<String> for SmartPointer{
    fn as_ref(&self) -> &String {
        return &self.s;
    }
}

impl AsRef<str> for SmartPointer{
    fn as_ref(&self) -> &str {
        return &self.s;
    }
}

仔细一想很容易理解为什么Rust这么设计:Deref作为智能指针不能有二义性,否则遇到*p时Rust怎么知道应该采用哪个版本的实现,你可能会说编译器不是会自动推导类型吗:

fn take(s: &String){}

fn main() {
    let p = &SmartPointer{s: String::from("test")};
    take(p);
}

问题是解引用的过程可以是递归的,假设Rust支持实现多个版本的Deref,那它在遇到这种情况下应该怎么解引用:

image.png SmartPointer应该先解引用为PointerA再解引用为PointerC,还是先解引用为PointerB再解引用为PointerC?为了消除这个歧义,定义Deref特征时使用关联类型而不是泛型,这样保证了只会有一种实现,不会有歧义。而AsRef则相反,它作为Deref的补充,当用户需要实现多个版本的指针转换时,AsRef就派上用场了。与Deref不同,它需要通过as_ref()方法显式调用,而不是编译器自行处理,因此即使有歧义也可以通过显式的类型声明来消除歧义:

struct SmartPointer {
    a: PointerA,
    b: PointerB,
}

struct PointerA{
    c: PointerC,
}
struct PointerB{
    c: PointerC,
}
struct PointerC{}

impl AsRef<PointerA> for SmartPointer{
    fn as_ref(&self) -> &PointerA {
        return &self.a;
    }
}

impl AsRef<PointerB> for SmartPointer{
    fn as_ref(&self) -> &PointerB {
        return &self.b;
    }
}

impl AsRef<PointerC> for PointerA{
    fn as_ref(&self) -> &PointerC {
        return &self.c;
    }
}

impl AsRef<PointerC> for PointerB{
    fn as_ref(&self) -> &PointerC {
        return &self.c;
    }
}

fn take(c: &PointerC){}

fn main() {
    let a = SmartPointer {
        a: PointerA { c: PointerC {} },
        b: PointerB { c: PointerC {} },
    };
    let b: &PointerA = a.as_ref();
    take(<SmartPointer as AsRef<PointerA>>::as_ref(&a).as_ref());
    take(<SmartPointer as AsRef<PointerB>>::as_ref(&a).as_ref());
}

AsRefBorrow

接下来我们再看AsRefBorrow,我们上面已经分析了AsRefDeref最大的区别就是泛型和关联类型的区别,而BorrowAsRef一样,均是使用泛型定义,事实上,你仔细看会发现它们的定义其实完全一致(Borrow特征中的Borrowed只是为了增强可读性,将它改成T就与AsRef的定义完全一样),那么它们的区别又是什么呢? 首先来看Borrow的应用场景,它被广泛应用在标准库的数据结构中,尤其是HashMapBTreeMap,如果你研究过HashMap的api,你会发现它的get方法是这么定义的:

impl<K, V, S> HashMap<K, V, S>
where
    K: Eq + Hash,
    S: BuildHasher,
{
    pub fn get<Q: ?Sized>(&self, k: &Q) -> Option<&V>
    where
        K: Borrow<Q>,
        Q: Hash + Eq,
    {
        self.base.get(k)
    }
}

哈希表的键值类型分别是KV,但get方法的参数k的类型很奇怪,既不是K,也不是&K,而是&Q,而Q则通过Borrow特征与K关联起来,用rust的专业说法称为:K可被借用为Q。 让我们简单回顾一下哈希表的实现原理,查询时首先会计算key的哈希值,通过哈希值定位哈希桶,假设使用链表法解决哈希冲突,在这个桶下面的链表中逐一比对key的内容是否一致即可。由于K可被借用为Q,因此对比的时候只需要将链表中各元素的键“借用”为&Q,这样就可与参数k直接对比了(注意Q需要实现Eq)。

听起来用AsRef也可以实现这一效果,为什么需要再增加一个Borrow特征呢?如果HashMap中的get方法是用AsRef定义的会怎么样呢?

    pub fn get<Q: ?Sized>(&self, k: &Q) -> Option<&V>
    where
        K: AsRef<Q>,
        Q: Hash + Eq,
    {
    }

假设K和V类型都是String,那调用的时候就不能指定:

    let m: HashMap<String, String>;
    m.get(&String("hello")); // 传递的参数类型是&String,那么Q是String,不成立,因为String没有实现AsRef<String>

只能指定:

    let m: HashMap<String, String>;
    m.get("hello"); // 传递的参数类型是&str,那么Q是str,成立,因为String实现了AsRef<str>

设计标准库的时候自然希望API越灵活越好,这样用户可以根据他们的场景随意传递参数,不需要自己再做适配转换。对于HashMap<String, String>,我们希望它的get方法既能接受:&String也能接受&str,而Borrow刚好能满足这一要求,因此rust会为所有T&T都默认实现Borrow<T>

impl<T: ?Sized> Borrow<T> for T {
    fn borrow(&self) -> &T {
        self
    }
}

impl<T: ?Sized> Borrow<T> for &T {
    fn borrow(&self) -> &T {
        &**self
    }
}

同时类似AsRef,rust为String实现了Borrow<str>,这样通过Borrow特征,rust扩大了标准库中API的应用范围,简化了用户的调用过程。

这里也能窥见rust设计的简洁之美,其它语言可能会通过重载函数来实现,例如java;rust并不支持函数重载,当需要接受多种类型的参数时,rust倾向于使用特征来抽象,这样既能减少API的维护成本,也能降低使用方的学习成本。

总结

  • AsRefDeref的区别在于泛型和关联类型,泛型可以实现任意数量版本,而关联类型只能实现一种版本,由于解引用是编译器自动调用的,为了消除歧义,Deref通过关联类型定义;
  • AsRefBorrow都是通过泛型定义的,区别在于rust会自动为T&T实现Borrow<T>,通过Borrow特征可以进一步扩大API的应用范围,例如HashMap<String, String>get方法可以同时接受&String&str