Rust的Deref特征:让智能指针“透明”的关键

除了上篇文章中介绍过的BorrowAsRef外,Rust中还有一个很常见的和引用相关的特征:Deref。不过,和BorrowAsRef两个特征不同,Deref其实是用于重载解引用运算符(也就是*)的特征;在为某个类实现了Deref特征后,对它使用*运算就会调用特征中重载的方法。

这篇文章不仅将介绍Deref特性,还将探讨Rust中一个极其重要的语法糖:Deref转换(Deref Coercion),这颗语法糖的设计是如此的自然,以至于很多写Rust的朋友都没有意识到这颗糖的存在。对于各领域的开发者来说,理解这一机制都大有裨益。

关于Deref转换算不算语法糖(Syntactic sugar),很多人都有自己的看法;之后有时间我也会写一篇文章来探究一下这个问题~

Deref:定义

Deref特征被用于解引用操作。实现了DerefDerefMut的类型被称为智能指针。通常,智能指针类型被用于改变所含值的所有权语义(如RcCow)或所含值的存储语义(如Box)。

Deref的定义很简单,只需要提供一个方法:

pub trait Deref {
    type Target: ?Sized;

    // Required method
    fn deref(&self) -> &Self::Target;
}

Deref 转换

如果Deref只有这点东西的话,看起来和AddNeg这样的纯运算符trait也没什么区别;但是,Deref特殊就特殊在Rust的一颗语法糖:Deref转换。具体来说,Rust编译器会在许多时候隐式地插入Deref的调用。看看下面的例子:

fn main() {
    let foo = Box::new(5i32);
    let bar: &i32 = &foo;
    println!("{}", bar);
}

请你花五秒钟阅读这段代码,然后告诉我它能不能正常编译?如果能的话,会输出什么?

答案是5,也就是被Box所包裹的值。这种情况看起来显然是不符合语法规则的:我们怎么能把一个类型为&Box<i32>的变量赋给&i32呢?其实,这就是因为Deref转换在悄悄发挥作用。回到我们的代码:

let bar: &i32 = &foo;

编译器注意到foo的类型Box<i32>i32不符合,但是Box<i32>实现了Deref<i32>;于是它尝试在foo上插入了Deref

let bar: &i32 = &(*(foo.deref()));

foo被执行了一次解引用后,类型由Box<i32>变为了i32,和bar的要求符合,于是编译没有失败,bar获得了foo中的值的引用。

回到Deref转换上来,根据Rust官方文档中的定义,如果一个类型T实现了Deref<Target = U>,那么对类型为T的变量v来说:

  • 在不可变的上下文中,*v相当于*Deref::deref(&v)
  • 类型为&T的值会被转换为&U
  • T隐式地实现了U中的所有方法(以&self为接收者)。

不管读者之前有没有意识到,其实我们已经无数次享受过Deref转换带来的便利了;例如split方法实现于str而不是String,但是我们仍然可以对String使用split

fn main() {
    let foo = String::from("Hello world");
    foo.split(" ").for_each(|s| println!("{s}"));
}

又或者first方法实现于[T]而不是Vec<T>,但我们仍然可以对Vec使用first

fn main() {
    let foo = vec![10, 20, 30];
    println!("{:?}", foo.first());
}

或者是最明显的:当我们在使用Mutex<T>的时候,调用lock方法之后返回的明明是一个类型为MutexGuard<T>的变量,我们却可以像使用T本身一样使用它:

use std::sync::{Mutex, MutexGuard};

fn main() {
    let foo = Mutex::new("hello world");
    let foo_guard: MutexGuard<&str> = foo.lock().unwrap();
    foo_guard.split(" ").for_each(|s| println!("{s}"));
}

除此以外,Deref转换也会连续进行,直到无法再继续Deref或匹配到正确的类型:

fn main() {
    let foo = Box::pin(String::from("Hello world"));  // foo: Pin<Box<String>>
    foo.split(" ").for_each(|s| println!("{s}"));
}

这段代码中,Rust编译器按照Pin<Box<String>> -> Box<String> -> String -> str的顺序连续地为foo插入deref()的调用,直到遇到了拥有split方法的str类型为止。


感谢Deref转换的存在,我们不需要写这样的代码:

fn main() {
    let foo = String::from("Hello world");
    &(*foo)[..].split(" ").for_each(|s| println!("{s}"));
}

个人认为,Deref转换的存在,使得Rust在保障安全性的同时,将语言的易用程度提高到了一个全新的高度,是Rust语言中我个人最喜欢的一颗语法糖。

实现Deref时需要注意的

Deref也不是万能妙具,不能也不该被随意滥用。Rust文档对实现Deref的行为提出了这样的警告:

警告:Deref 转换是一种强大的语言功能,对每个实现了 Deref 的类型都会造成深远的影响。编译器会默默插入对 Deref::deref 的调用。因此,在实现 Deref 时应小心谨慎,只有在需要 Deref 转换时才应该使用。请参阅下文,了解什么情况下需要或不需要使用 Deref。

文档也给出了这样的准则:何时可以实现Deref,何时不可以实现。

一般来说,如果出现以下情况,就应该实现Deref特性:

  1. 该类型的值与目标类型的值行为透明;
  2. 实现 deref 函数的成本较低;并且
  3. 该类型的用户不会对任何 deref 转换行为感到惊讶。

一般来说,如果出现以下情况,就不应该实现Deref特性:

  1. deref 实现可能意外失败;或
  2. 类型的某方法和目标类型的不一致;或
  3. 不希望将 deref 转换作为公共 API 的一部分。

AsRefBorrow的签名和Deref也很相似,在大多数情况下也需要同时实现它们中的一个或两个。

此外,Deref的实现在任何情况下都不应该失败,因为Deref转换的机制会使得这样的失败难以排查和定位。

最后,在介绍完Deref和Deref转换之后,也推荐大家看一下经典著作中对Deref的介绍;我对Rust了解不是很深入,对相关概念的介绍想必也肯定不如这些老师,因此大家可以延伸阅读一下:

  1. Deref 解引用 - Rust语言圣经(Rust Course)
  2. Treating Smart Pointers Like Regular References with the Deref Trait - The Rust Programming Language
posted @ 2024-02-27 23:44  Cinea  阅读(29)  评论(0编辑  收藏  举报