avatar

Nihil

Nichts Hsu

  • 首页
  • 子域
  • 分类
  • 标签
  • 归档
  • 关于
首页 从另一个视角看 Rust HRTBs
文章

从另一个视角看 Rust HRTBs

发表于 2024/02/02 更新于 2024/04/18
作者 Nichts Hsu
12 分钟阅读

HRTBs,即高阶 Trait 约束(Higher-Rank Trait Bounds),在 Rust 中是令很多初学者感到莫名其妙的概念,一手 for<'a> S<'a> 的语法更是使得原本就复杂的生命周期更加吓人。

但是,如果从另一个角度对 HRTBs 进行解剖,或许我们能看到不一样的东西。

首先,让我们考虑一个泛型和闭包的应用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
use std::fmt::Display;

fn print<T, U>(x: T, y: U)
where
    T: Display,
    U: Display,
{
    println!("{x}, {y}")
}

fn curry<F, T, U, R>(f: F, a: T) -> impl Fn(U) -> R
where
    F: Fn(T, U) -> R,
    T: Copy,
{
    move |b| f(a, b)
}

fn main() {
    let print_one = curry(print, 1);
    print_one(1);
}

这段代码可能稍显复杂,简单来说,我们首先定义了一个泛型 print 函数,可以接受任意两个可打印的类型的值;然后我们定义了一个柯里化函数 curry,它可以将一个函数 f 和它所需的第一个参数进行打包,产生一个新的闭包,而这个闭包以函数 f 所需的第二个参数进行调用时,则会将两个参数同时传递给它进行调用。

在 main 函数中,我们将函数 print 和参数 1 进行打包,得到一个闭包 print_one,该 print_one 可以接收另一个参数,然后将之前打包的 1 和新的参数一起传递给 print 调用。因此,上述代码最终会在终端打印 1, 1。

但是,如果我们想在下面再继续调用 print_one(1.0) 或 print_one("hello") 或其他实现了 Display trait 的类型的值时,rust 则会严厉地拒绝我们:

1
2
3
4
5
6
7
error[E0308]: mismatched types
  --> src/main.rs:22:15
   |
22 |     print_one(1.0);
   |     --------- ^^^ expected integer, found floating-point number
   |     |
   |     arguments to this function are incorrect

这是因为 Rust 是静态类型语言,在我们调用 print_one(1) 时,就把 print_one 推导并固定为 impl Fn(i32) -> i32,从而不能再接收并不是 i32 类型的参数。因此,我们需要注释掉 print_one(1),才能以其他类型的值调用 print_one。

然而,在另一门同样是静态类型的语言 Haskell 中,一切又似乎是那么的不同:

1
2
3
4
5
6
7
8
ghci> myprint x y = (show x) ++ ", " ++ (show y) -- 定义 myprint 函数,haskell 默认此函数是泛型
ghci> myprint_one = myprint 1 -- 函数式语言原生支持柯里化,只需以数量不足的参数调用函数即可
ghci> myprint_one 1 -- 以整数类型调用 myprint_one
"1, 1"
ghci> myprint_one 1.0 -- 以小数类型调用 myprint_one
"1, 1.0"
ghci> myprint_one "hello" -- 以字符串类型调用 myprint_one
"1, \"hello\""

令人惊讶,同为静态类型语言,Haskell 却能做到这种神奇的事情。不难发现,在上述例子中,显然 myprint_one 是一个泛型函数,这意味着,myprint 1 这个表达式,返回的竟然是一个携带泛型类型的对象!

在 GHCI 中自动将 myprint_one 视为泛型函数,在源文件中则需要显式声明其为泛型:

1
2
myprint_one :: (Show a) => a -> [Char]
myprint_one = myprint 1

这是怎么做到的呢?

根本原因在于,Haskell 为所有泛型隐式地实现了全称量化(Universal Quantification)。

在数学中,我们有全称量词(Universal Quantifier)∀,例如,\(\forall n \in \mathbb{N} P(n)\) 意味着对于任意自然数 n 都满足谓词 P(n),因此 ∀ 在英语中也被叫做 forall。

在 Haskell 中,当我们启用语言扩展 RankNTypes 后,我们也可以使用全称量词 forall 显式地写出泛型类型的全称量化:

1
2
3
4
5
myprint :: forall a b. (Show a, Show b) => a -> b -> [Char]
myprint x y = (show x) ++ ", " ++ (show y)

myprint_one :: forall a. (Show a) => a -> [Char]
myprint_one = myprint 1

像 C++、Rust 这类静态分发泛型的语言,在编译时为泛型函数 fun<T>() 的每一种输入的泛型类型 T 都会实例化一个对应的专用函数,因此具有不同泛型类型的泛型函数是不同的对象。而在 Haskell 中,全称量化的存在使得泛型类型不与泛型函数的实例化绑定,一个泛型函数只有一个实例,每次被传递的都是这个实例,因此它可以在传递的过程中一直保持泛型。

话说回来,在 C++ 中,我们确实可以用宏和 lambda 模拟出类似的玩意出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>

#define FORALL auto&&
#define CURRY(func, ...)                                        \
    [](FORALL... args) {                                        \
        return func(__VA_ARGS__ __VA_OPT__(, )                  \
                        std::forward<decltype(args)>(args)...); \
    }

template <typename T, typename U>
void print(const T& a, const U& b) {
    std::cout << a << ", " << b << std::endl;
}

void test_template_f(FORALL f) {
    f(0.114514);
    f("hello");
}

int main() {
    test_template_f(CURRY(print, 12));
    return 0;
}

我们的 CURRY(print, 12) 实际是得到一个 lambda 对象,虽然它的 operator() 是泛型成员函数并且可以接受不同的泛型入参,但是该 lambda 对象一直只有一个,不会随着不同类型的入参而实例化成不同的 lambda 对象。想要在 Rust 里做出这种操作,首先 rust 得支持可变模板参数和泛型闭包才有可能。

如果,假设我们是 Rust 的设计者,我们想要在 Rust 中支持全称量化,我们应当怎么实现它?

首先,返回值类型 impl Fn(U) -> R 的泛型参数 U 和 R 都不能再依赖 curry 的泛型参数,否则 curry 被实例化时,这两个类型都会被固定,F 类型也是如此。那么,我们需要为这些与 curry 函数不相干的泛型参数定义一个新的语法来引入这些泛型参数。

在这里,我们假设我们从来都不知道什么 HRTBs,于是,我们选了一个看起来和 Haskell 的 forall a. 语法很像的 for<T> 语法,于是 curry 函数就变成了:

1
2
3
4
5
6
7
fn curry<F, T>(f: F, a: T) -> impl for<U, R> Fn(U) -> R
where
    F: for<U, R> Fn(T, U) -> R,
    T: Copy,
{
    move for<U, R> |b: U| -> R { f(a, b) }  // 当然我们还需要一个泛型闭包的语法
}

在这个新的 curry 函数中,泛型 U 和 R 不再和函数 curry 绑定,因此,实例化 curry 并不会固定 U 和 R 的类型。当然,Rust 到目前为止并没有支持 for<T> 这种语法,一切都是我们的一厢情愿罢了。

然而,尽管在 Rust 中并没有 for<T>,但确切地有一个类似的东西,他就是 HRTBs,它的语法是 for<'a>。到了这里,我相信不少看客内心多少都有了一些新的想法。

让我们来看一个生命周期的例子:

1
2
3
4
5
6
7
8
9
10
trait DoSth<T> {
    fn do_sth(&self, _t: T) {
        todo!()
    }
}

fn do_sth_for_i32(r: &dyn DoSth<&i32>) {
    let num = 1;
    r.do_sth(&num);
}

上述例子无法通过编译。Rust 向我们抱怨 num 的生命周期不够长:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
error[E0597]: `num` does not live long enough
  --> src/main.rs:9:14
   |
7  | fn do_sth_for_i32(r: &dyn DoSth<&i32>) {
   |                   - has type `&dyn DoSth<&'1 i32>`
8  |     let num = 1;
   |         --- binding `num` declared here
9  |     r.do_sth(&num);
   |     ---------^^^^-
   |     |        |
   |     |        borrowed value does not live long enough
   |     argument requires that `num` is borrowed for `'1`
10 | }
   | - `num` dropped here while still borrowed

这是为什么呢?事实上,原因和之前类似,我们知道,&i32 隐含了一个生命周期参数 &'a i32,Rust 在实例化 dyn DoSth<&i32> 这一泛型时,已经将 &'a i32 的生命周期 'a 实例化为了一个固定的生命周期。Rust 无从知晓我们究竟会以什么样的方式使用该生命周期,因此,Rust 采用了最保险的办法,它认为该生命周期至少要大于 dyn DoSth<&i32> 的生命周期,自然,我们无法将 num 的引用传入。

而想要解决这个问题,我们所采取的手段也是类似的:我们需要引入一个新的语法,用以声明该生命周期与 dyn DoSth<&i32> 的实例化无关,该类型的对象仍然携带一个泛型的生命周期。这个语法就是 HRTBs,即 for<'a>,我们将 dyn DoSth<&i32> 改成 dyn for<'a> DoSth<&'a i32>,这个例子就可以通过编译:

1
2
3
4
5
6
7
8
9
10
trait DoSth<T> {
    fn do_sth(&self, _t: T) {
        todo!()
    }
}

fn do_sth_for_i32(r: &dyn for<'a> DoSth<&'a i32>) {
    let num = 1;
    r.do_sth(&num);
}

最后,仍然是惯例提醒一下,在使用形如 Fn(&T) -> &U 的语法时,Rust 隐式地为其添加了 HRTBs,也就是说它实际是 for<'a> Fn(&'a T) -> &'a U,不需要我们显式地使用 HRTBs 语法;而当使用形如 Fn(&T, &U) -> &W 的语法时,Rust 不知道返回值的生命周期受到哪个参数的约束,因此,仍然需要我们添加 HRTBs 才能通过编译。

杂记, Rust
rust 编程语言
本文由作者按照 CC BY 4.0 进行授权
分享

最近更新

  • 从另一个视角看 Rust HRTBs
  • C++ Coroutine VS Rust Async
  • Rust 不透明类型上的生命周期
  • USB 2.0 与 USB 3.2
  • Rust 中的闭包递归与 Y 组合子
外部链接
  • 996.icu
  •  此博客的 Github 仓库
  •  Olimi 的个人博客

相关文章

2022/12/30

Rust 2022 年稳定的语法

概览 在整个 2022 年,Rust 一共发布了 1.58.0 ~ 1.66.0 共 9 个版本,让我们感谢 Rust 团队一整年的付出。 通常来说,大部分人都不是喜欢追着 Release Note 啃的类型,因此对于大部分人而言,Rust 的语法就只有书上写出来的那一些。这也是我撰写这篇文章的目的:总结和记录 Rust 整个 2022 年稳定的语法,让更多人意识到 “原来 Rust 还...

2023/03/28

[Rust] 当实例被移动时,究竟发生了什么?

前言 本文并不是移动语义的教程,并且本文假设你已经看过 the Book,已经了解了 Rust 中所有权的概念。 本文包含汇编和 MIR,但是并非需要了解汇编和 MIR 才能看懂。只要跟随本文的思路,即使以前不懂汇编和 MIR,也可以理解本文所表达的意图。 从汇编看移动 先简简单单 wrapper 一个 i32: use std::fmt::Debug; #[derive(Deb...

2023/05/11

C++ Coroutine VS Rust Async

在 C++20 中,我们有 Coroutine,在 Rust 中,我们有 Async。严格来说,二者之间没有完全等效的概念,但是我们可以找到一些相似之处,进而了解 C++ Coroutine 与 Rust Async 设计上的异同点。 由于我的上一篇文章介绍了 C++ 的 Coroutine,因此我们本文主要以 C++ Coroutine 的视角来看 Rust 的 Async。 为了更好...

于 Rust 1.76 稳定的 trait upcasting coercion

Rust 不透明类型上的生命周期

© 2025 Nichts Hsu. 保留部分权利。

本站采用 Jekyll 主题 Chirpy

热门标签

编程语言 教程 rust c++ android c++20 usb 翻译 linux qt

发现新版本的内容。