“变长参数”函数与回调

前言

Rust 中,“变长参数” (variadic) 总是离不开宏这个话题:众所周知,Rust 的宏有三个主要功能

  1. 减少样板代码
  2. 自定义语法 (DSL)
  3. 变长参数接口

作为静态语言的 Rust,函数参数的个数在声明时已经被固定,也不可能传入不同个数的参数。

但我们真的无法针对函数设计出“变长参数”吗?

背景

首先,明确我们需要什么1。下面是一个略为复杂的 API 设计:

memoize(&mut ui, comp_, (...), |_| {});
// memoize : 提供给使用者的函数
// &mut ui : 函数的固定参数,或者设计者认为非常重要的参数(在这篇文章中不重要)
// comp_   : 使用者或者编写者提供的函数(或方法),是本文讨论的回调函数
// (...)   : 回调函数的参数,对 memoize 来说是“变长的”(是本文的重点)
// |_| {}  : 使用者提供的闭包,它可以提供上下文变量(在这篇文章中不重要)

使用者可以这样调用:

memoize(&mut ui, comp2, (2, 3), |_| {});
memoize(&mut ui, comp3, (1, 2, 3), |_| {});
memoize(&mut ui, comp4, (0, 1, 2, 3), |_| {});

这里,我们用元组这个看似可变长度的数据结构(而且它可以同时容纳不同的类型),来表达回调函数所需的(部分)参数。

而变长参数被设计成需满足 PartialEq + Clone + 'static,即:

pub fn memoize<Params, Content, Comp>(ui: &mut Ui, component: Comp, params: Params, content: Content)
    where Params: PartialEq + Clone + 'static,
          Content: FnOnce(&mut Ui),
          Comp: todo!() { todo!() }

fn comp2(ui: &mut Ui, a: u8, b: u32, f: impl FnOnce(&mut Ui)) { f(ui); }
fn comp3(ui: &mut Ui, a: u8, b: u32, c: u64, f: impl FnOnce(&mut Ui)) { f(ui); }
fn comp4(ui: &mut Ui, a: u8, b: u32, c: u64, d: usize, f: impl FnOnce(&mut Ui)) { f(ui); }

忽略 &mut Uif: impl FnOnce(&mut Ui) 这两个“固定参数”,它们不是本文的重点。

1

原例子由 @费超 提供

思路

在 Rust 的类型系统中,使用 trait 进行抽象是最根本的方式:

pub trait Component<Params, Content> {
    fn call(&self, ui: &mut Ui, params: Params, content: Content);
}

这里的关键在于,使用Params 泛型参数来抽象 comp* 函数中这些 a, b, c, ... 函数参数。

然后,给函数实现 Component (以简单的两参数为例):

impl<F, P1, P2, Content> Component<(P1, P2), Content> for F
where
    P1: PartialEq + Clone + 'static,
    P2: PartialEq + Clone + 'static,
    Content: FnOnce(&mut Ui),
    F: Fn(&mut Ui, P1, P2, Content),
{
    fn call(&self, ui: &mut Ui, params: (P1, P2), content: Content) {
        let (p1, p2) = params;
        self(ui, p1, p2, content)
    }
}

这个 where 语句表达了最核心的内容:

  1. 原本的泛型参数 Params 被“规定成”某种严格/具体一些的形式:(P1, P2)(P1, P2) 依然是一种泛型,表示使用者应传入两元素的元组。
  2. F: Fn(&mut Ui, P1, P2, Content):这把所考虑的回调函数的形式抽象出来了。
  3. P1P2Content 的 trait bounds 不是本文的重点,无需赘言。
// https://play.rust-lang.org/?version=nightly&mode=debug&edition=2021&gist=820a56b25ce4cfef20795e0b111f7cc5
#![allow(unused)]

pub struct Ui {}
impl Ui {
   pub fn update<T>(&mut self, a: String, b: Vec<T>, f: impl FnOnce(&mut Ui))
       where T: PartialEq + Clone + 'static {
   }
}

pub trait Component<Params, Content> {
   fn call(&self, ui: &mut Ui, params: Params, content: Content);
}

impl<F, P1, P2, Content> Component<(P1, P2), Content> for F
   where P1: PartialEq + Clone + 'static,
         P2: PartialEq + Clone + 'static,
         Content: FnOnce(&mut Ui),
         F: Fn(&mut Ui, P1, P2, Content)
{
   fn call(&self, ui: &mut Ui, params: (P1, P2), content: Content) {
       let (p1, p2) = params;
       self(ui, p1, p2, content)
   }
}

pub fn memoize<Params: PartialEq + Clone + 'static,
               Content: FnOnce(&mut Ui),
               Comp: Component<Params, Content>>(
    ui: &mut Ui, component: Comp, params: Params, content: Content) {
    component.call(ui, params, content);
}

fn comp2(ui: &mut Ui, a: u8, b: u32, f: impl FnOnce(&mut Ui)) { f(ui); }

fn main() {
    let mut ui = Ui {};

    memoize(&mut ui, comp2, (2, 3), |_| {});

    let args = (String::new(), vec![(1usize, 1.0f64)]);
    memoize(&mut ui, Ui::update, args, |_| {});
}

至此,memoize 支持任何具有 F: Fn(&mut Ui, P1, P2, Content) 形式的函数(方法、甚至闭包),只要其参数满足各自的 trait bounds。

如何支持其他参数长度的函数 F: Fn(&mut Ui, ..., Content) 呢?

很简单,却也很无聊,只需继续实现 impl<F, ..., Content> Component<(...), Content> for F —— 整个过程只运用了最基础的 trait bounds 知识。

进阶

根据上一部分的内容,我们得到一份“样板代码”,你可以手写需要的部分,但支持宏编程的语言一定不会按这种原始的方式拓展样板代码。

考虑以下宏(忽略这里未考虑严格绝对路径):

// https://play.rust-lang.org/?version=nightly&mode=debug&edition=2021&gist=b35db5a10a259e9ac1cdd1899f781ab1
#![allow(unused)]

pub struct Ui {}

pub trait Component<Params, Content> {
   fn call(&self, ui: &mut Ui, params: Params, content: Content);
}

macro_rules! impl_component {
    ($($P:ident),*) => {
        impl<F, $($P,)* Content> $crate::Component<( $($P,)* ), Content> for F
            where F: Fn(&mut Ui, $($P,)* Content),
                  $( $P: ::std::cmp::PartialEq + ::std::clone::Clone + 'static, )*
                  Content: ::std::ops::FnOnce(&mut Ui)
        {

            fn call(&self, ui: &mut Ui, params: ( $($P,)* ), content: Content) {
                #[allow(non_snake_case)]
                let ($($P,)*) = params;
                self(ui, $($P,)* content)
            }
        }
    };
}

impl_component!();
impl_component!(P1);
impl_component!(P1, P2);
impl_component!(P1, P2, P3);
impl_component!(P1, P2, P3, P4);

pub fn memoize<Params, Content, Comp>(ui: &mut Ui, component: Comp, params: Params, content: Content)
   where Params: PartialEq + Clone + 'static,
         Content: FnOnce(&mut Ui),
         Comp: Component<Params, Content>
{
   component.call(ui, params, content);
}

fn comp1(ui: &mut Ui, a: u8, f: impl FnOnce(&mut Ui)) { f(ui); }
fn comp_(ui: &mut Ui, a: &str, f: impl FnOnce(&mut Ui)) { f(ui); }
fn comp2(ui: &mut Ui, a: u8, b: u32, f: impl FnOnce(&mut Ui)) { f(ui); }
fn comp3(ui: &mut Ui, a: u8, b: u32, c: u64, f: impl FnOnce(&mut Ui)) { f(ui); }
fn comp4(ui: &mut Ui, a: u8, b: u32, c: u64, d: usize, f: impl FnOnce(&mut Ui)) { f(ui); }

fn main() {
    let mut ui = Ui {};
    memoize(&mut ui, comp1, (1,), |_| {});
    memoize(&mut ui, comp_, ("",), |_| {});
    memoize(&mut ui, comp2, (2, 3), |_| {});
    memoize(&mut ui, comp3, (1, 2, 3), |_| {});
    memoize(&mut ui, comp4, (0, 1, 2, 3), |_| {});
}

这个声明宏十分简单,因为只匹配了一连串逗号分隔的标识符,然后利用反复技巧去展开。

如果你想精简重复的宏调用,或许希望继续写一个宏 all_tuples!,然后通过 all_tuples! {impl_component, 1, 16, P} 生成支持 1 ~ 16 个函数参数的实现。

这实际上是 bevy::SystemParamFunction 的基本思路。如果你已经理解以上内容,那么可以轻松阅读和理解 那部分源码

不过,bevy::all_tuples! 使用了过程宏来定义。你完全可以自己写一个类似过程宏,只需要运用简单的步骤,比如这样:

use proc_macro::TokenStream;
use quote::{format_ident, quote};
use syn::{
   parse::{Parse, ParseStream},
   parse_macro_input, Ident, LitInt, Result,
};

#[proc_macro]
pub fn all_tuples(input: TokenStream) -> TokenStream {
    let Input { name, start, end, ident } = parse_macro_input!(input);
    let id = |s: u8, e: u8| (s..e).map(|n| format_ident!("{ident}{n}"));
    let items = (start..=end).map(|n| {
                                 let ids = id(start, n + 1);
                                 quote!( #name!{#(#ids),*} )
                             });
    quote!(#(#items)*).into()
}

struct Input {
    name:  Ident,
    start: u8,
    end:   u8,
    ident: Ident,
}

impl Parse for Input {
    fn parse(input: ParseStream) -> Result<Self> {
        use syn::token::Comma;
        let name     = input.parse()?;
        let _: Comma = input.parse()?;
        let start    = input.parse::<LitInt>()?.base10_parse()?;
        let _: Comma = input.parse()?;
        let end      = input.parse::<LitInt>()?.base10_parse()?;
        let _: Comma = input.parse()?;
        let ident    = input.parse()?;
        Ok(Input { name, start, end, ident })
    }
}

宏充满了技巧,这需要观察和练习。如果你感兴趣的话,使用这段代码的展开结果见此处

当然,如果你的宏代码不够通用(不必复用),可以把这两个宏合并成一个宏:

  • 整合声明宏和过程宏的代码见此处
  • 只使用声明宏的代码见此处,此技巧受标准库这段代码的启发

然而,我想提醒你的是,本文的核心技巧是 trait 和泛型参数,宏只是锦上添花的内容。

细节

注意:这里的细节与最佳实践无关,意图在于简述事实。

元组

函数 memoizeparams: Params 参数实际是利用元组的以下特点做到“变长”的:

  • 元组可以容纳不同类型的元素,这与函数的各个参数可以为不同类型一致,所以使用泛型 (P1, ...) 来抽象“函数参数各自具有其类型”;
  • 这种“变长”的前提是“定长”:只有实现了固定长度的泛型元组,才能运用到相应的函数上。
    即因为 impl<F, P1, ..., Pn, Content> Component<(P1, ..., Pn), Content> for F,所以 memoize(&mut ui, comp_n, (p1, ..., pn), |_| {});

这种抽象并不是完美的,它建立在类型系统之上:泛型 (P1, ...) 虽然解决了参数的形式问题,但也意味着可能的、复杂的 trait bounds。

还是以两参数为例,这里的实现相当精简:

impl<F, P1, P2, Content> Component<(P1, P2), Content> for F
where
    P1: PartialEq + Clone + 'static,
    P2: PartialEq + Clone + 'static,
    Content: FnOnce(&mut Ui),
    F: Fn(&mut Ui, P1, P2, Content),

但你容易忽略一些基本事实和局限性:

  • 所有元素满足 trait bound,并不意味着元组满足这个 trait bound:如果要求 P1: SomeTrait ... Pn: SomeTrait,则必须 手动(P1, ..., Pn) 实现 SomeTrait。幸运的是,针对这种“传递”关系,标准库已经给元组,在最多 12 个元素的情况下实现了某些 traits。
    pub fn memoize<Params, Content, Comp>(ui: &mut Ui, component: Comp, params: Params, content: Content)
      where Params: PartialEq + Clone + 'static, // (P1, ..., Pn): PartialEq + Clone + 'static
            Content: FnOnce(&mut Ui),
            Comp: Component<Params, Content> { ... }
    
    超出标准库的元组实现,意味着你需要自己实现。参考 bevy 的 SystemParamSystemParamFetchSystemParamState
    例如,超出 12 个元素,且涉及标准库定义的 traits;或者无论多少元素,但凡涉及第三方库的 traits:你会遇到孤儿原则,需要使用其他技巧来手动实现。
  • 换言之,即便使用 all_tuples!(impl_component, 1, 16) 之类的技巧生成最多支持 16 个元素的元组,但以下代码无法编译通过:
    fn comp13(ui: &mut Ui, p1: u8, p2: u8, p3: u8, p4: u8, p5: u8, p6: u8, p7: u8, p8: u8, p9: u8,
              p10: u8, p11: u8, p12: u8, p13: u8, f: impl FnOnce(&mut Ui)) { f(ui); }
    
    memoize(&mut ui, comp13, (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0), |_| {});
    
    error[E0277]: can't compare `(u8, u8, u8, u8, u8, u8, u8, u8, u8, u8, u8, u8, u8)` with `(u8, u8, u8, u8, u8, u8, u8, u8, u8, u8, u8, u8, u8)`
     --> src/main.rs
      |
      | memoize(&mut ui, comp13, (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0), | _ | {});
      | ^^^^^^^ no implementation for `(u8, u8, u8, u8, u8, u8, u8, u8, u8, u8, u8, u8, u8) == (u8, u8, u8, u8, u8, u8, u8, u8, u8, u8, u8, u8, u8)`
    

总之,Rust 是静态语言,变长参数、可选参数这种极具动态语言的特性的事物对 Rust 来说并不是必须,但好在其类型系统不算薄弱。

'static

在参数的 trait bound 中,P: PartialEq + Clone + 'static 约束描述了函数参数必须是 不含引用的类型 或者 生命周期为 'static 的类型2,而且该类型实现了 PartialEqClone trait。

这意味着,memoize 虽然可以接受下面这个函数,但不接受非 'static 生命周期的参数

fn comp_(ui: &mut Ui, a: &str, b: Vec<&str>, f: impl FnOnce(&mut Ui)) { f(ui); }
// 实际上,由于 `P: 'static`,a 必须是 &'static str, b 必须是 Vec<'static str>
memoize(&mut ui, comp_, ("", vec![""]), |_| {}); // ok

let s = String::from(""); // 生命周期不是 'static
memoize(&mut ui, comp_, (&s, vec![&s]), |_| {}); // error[E0597]: `s` does not live long enough

当你去掉 'static 约束memoize 接受上面的代码。

2

如果你不明白 T: 'static 意味着什么,请仔细阅读 Common Rust Lifetime Misconceptions

Fn or fn

你可能会考虑到给什么样的函数实现 Component

impl<F: FnOnce(&mut Ui, ...), ...> Component<...> for F { } // 或者
impl<F: FnMut(&mut Ui, ...), ...> Component<...> for F { } // 或者
impl<F: Fn(&mut Ui, ...), ...> Component<...> for F { } // 或者
impl<...> Component<...> for fn(&mut Ui, ...) { } // 函数指针

Rust 可以从多种维度分类和抽象函数:

  1. 使用类型区分是否能捕获环境中的变量:
  2. 使用 trait 抽象“以何种方式捕获/或者如何处理捕获的环境变量”(注意,这与函数参数的所有权、可变性无关):
    • FnOnce:获取捕获变量的所有权
    • FnMut:修改捕获变量的值;且 FnMut: FnOnce
    • Fn:获取捕获变量的引用;且 Fn: FnMut

它们的具体说明参考各自的文档,这里只总结一下基本知识:

  • Function Item:只适用于函数条目,可以赋给变量,但无法写出其类型;可以转化成函数指针,而且实现了 FnOnceFnMutFn 以及其他 traits,零大小
  • Function Pointer:函数指针,是 primitive type,可以写出类型,通常从函数条目或非捕获闭包使用 as 转化过来,对最多 12 个参数的情况实现了 FnOnceFnMutFn 以及其他 traits,具有 usize 大小
  • Closure:闭包,按照一些规则实现 FnOnceFnMutFn 及其他 traits 中的部分或全部,可以赋给变量,但无法写出其类型;由捕获变量的类型大小和数量决定闭包大小

类型参数 or 关联类型

或许你想使用关联类型定义 Params

pub trait Component<Content> {
    type Params: PartialEq + Clone + 'static;
    fn call(&self, ui: &mut Ui, params: Self::Params, content: Content);
}

// 或者
pub trait Component {
    type Params: PartialEq + Clone + 'static;
    type Content: FnOnce(&mut Ui);
    fn call(&self, ui: &mut Ui, params: Self::Params, content: Content);
}

无论那种做法,在本文所需的场景下都不合适:因为它们实际上没有抽象出“不限定函数参数的类型”。

我总结了以下表格,也可以参考 某则帖子 对具体的 Iterator 进行类型参数还是关联类型抽象的讨论。

角度类型参数 TU关联类型 TU (不考虑 GAT)
形式trait Trait<T, U: Bounds>处于 trait 内的 type T; 或者 type U: Bounds;
Reference 链接genericsassociated-types
泛型 -> 具体类型由 trait impl 决定,但由使用者选择由 trait impl 决定和选择
抽象性满足 trait bound 的任意类型从声明的角度看,是满足 trait bound 的任意类型;
但从实现的角度看,无抽象,因为关联类型因具体实现而确定
使用语法该 trait 中:直接使用 TUSelf::TSelf::U<Implementator as Trait>::T
implementator3 vs T一对多4一对一5
类型推断通常要指明类型容易直接推断,因为一经实现,类型是固定的
方法由 trait bounds 提供泛型方法默认实现时由 trait bounds 提供泛型方法;覆盖实现时由具体类型提供方法
3

实现 trait 的类型:比如 impl AsRef<[u8]> for str 中,str 就叫做 implementator。

4

比如 impl AsRef<[u8]> for strimpl AsRef<str> for strimpl AsRef<OsStr> for str 等等。

5

比如 impl<'a> Iterator for Chars<'a> 只有 type Item = char

其他方式

完全使用宏(无须定义 Component trait),比如这样:

// https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=f44c02fd60a763d30fef5d25d29e7d45
#![allow(unused)]

pub struct Ui {}

macro_rules! memoize {
    ($ui:expr, $f:path, ($($p:expr),*), $content:expr) => {
        $f($ui, $($p,)* $content);
    };
}

fn comp0(ui: &mut Ui, f: impl FnOnce(&mut Ui)) { f(ui); }
fn comp_(ui: &mut Ui, a: &str, f: impl FnOnce(&mut Ui)) { f(ui); }
fn comp4(ui: &mut Ui, a: u8, b: u32, c: u64, d: usize, f: impl FnOnce(&mut Ui)) { f(ui); }
fn comp12(ui: &mut Ui, p1: u8, p2: u8, p3: u8, p4: u8, p5: u8, p6: u8, p7: u8, p8: u8, p9: u8,
          p10: u8, p11: u8, p12: u8, f: impl FnOnce(&mut Ui)) { f(ui); }

fn main() {
    let mut ui = Ui {};
    memoize!(&mut ui, comp0, (), |_| {});
    memoize!(&mut ui, comp_, (""), |_| {});
    memoize!(&mut ui, comp4, (0, 1, 2, 3), |_| {});
    memoize!(&mut ui, comp12, (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0), |_| {});
}

甚至可以用宏实现命名和默认参数

此外,还有 2013 年提出,但已经搁置的 RFC: variadic generics