闭包自动克隆捕获变量.md
2022-06-03

闭包自动克隆捕获的变量

最近在改进 rtml api 时碰到这么一个设计问题.

rtml 中响应式数据是以 Reactive<T> 这么一个结构体保存, 这个结构体实现了 Clone trait, 用户可以订阅数据, 生成 html 元素标签内容, 属性和样式, 或者注册一个变更函数. 如下面创建计数器的例子

// 创建响应式数据
let count = 0usize.reactive();

let counter = div((
    // 使用 view 订阅数据, 生成标签内容
    p(count.view(|data| format!("count is {}", data.val()))),
    button("+1")
    // 使用 change 注册一个变更函数
    .on(Click, count.change(|data| {
        *data.val_mut() += 1;
        true
    }))
))

可以看到例子里无论是 view 还是 change 函数, 他们参数都是一个接受 Self 类型的闭包, 既然如此, 那么为什么不直接写成

button("+1").on(Click, move || {
    *data.val_mut() += 1;
    true
})

的形式不是更简洁嘛. 可惜不行, 因为 rtml 框架限制, rtml注册的闭包必须满足 ’static 约束, 因此必须加上 move 强制转移所有权, 为了保证闭包后面的代码还能使用这些数据, 我们必须提前克隆

let data_c = data.clone();
button("+1").on(Click, move || {
    *data_c.val_mut() += 1;
    true
})

但这样会导致代码里有大量 let xx_c = xx.clone() 模板代码, 如果闭包捕获不止一个数据, 一个闭包就需要写多个克隆并复制的语句, 非常麻烦.

所以我们需要一个办法让闭包自动 clone 捕获的变量.

首先查阅语言参考, 可惜目前 Rust 没有这样的特性, 连 RFC 也只是在讨论阶段. 所以我们只能想办法通过其他方式实现.

在之前的版本里, 为了避免这样的麻烦, rtml 使用 view, change 这种风格的函数来辅助生成一个自动 clone 的闭包, 具体实现这里不做说明, 感兴趣的朋友可以查看 rtml实现.

可惜这样做还是有些问题, 在写闭包函数时仍然需要写一个 data 参数, 如果是多个数据, 则需要写 (data1, data2, …) 这样的参数类型, 对变量命名困难用户来说这依然不是一件轻松的事.

或许可以使用过程宏, 可以很轻松的避开宏展开前的语法检查. 这的确可行, 我在 rtml 中实现过一版, 它支持如下用法

button("+1").on(Click, evt!(count => {
    *count.val_mut() += 1;
    true
}))

从代码看这确实挺简洁的, 但对我而言它有个致命的问题 IDE不好, 如果把鼠标放在宏内部, 各种提示完全无法使用, rtml 项目目标之一就包含了 IDE友好, 所以过程宏也不是一个好方案.

不过它依然给我们提示, 也许用类似 c++ 匿名函数显式声明捕获变量的风格加上示例宏实现. 实际上如果搜索 stack overflow, 也有类似思路的答案.

rtml 最初的实现如下

#[macro_export]
macro_rules! subs {
    ($($d:ident),+ $b:block) => {
        {
            $(let $d = $d.clone();)+
            Box::new(move || {
                $(let $d = $d.clone();)+
                Box::new(move |_: web_sys::Event| {
                    let should_update = $b;
                    if should_update {
                        $(
                            $d.update();
                        )+
                    }
                })
            })
        }
    }
}

为了符合 rtml 接口,宏实现有些复杂, 但其思路很简单, 利用 Rust 块作用域, 在块里声明同名变量遮蔽外层变量, 即 $(let $d = $d.clone();)+, 然后按 rtml 需求构建闭包即可.

使用也十分简单

button("+1").on(Click, subs!(count {
    *count.val_mut() += 1;
    true
}))

而且宏里面 IDE 依然可以正常工作, 完美!

但事情还没结束, 不管是 rtml 还是平常的 html, 处理点击等事件时, 经常需要 event 参数, 在 js 里这很简单, 因为 js 参数个数校验非常宽松. 但在 rtml 这里犯了难.

如果从展开后代码看, 最内层的闭包里已经将 event 引入, 好像能在传入的代码块里直接使用 event 变量

subs!(count {
    let target = event.target();
    ...
})

很可惜这样会导致编译失败, 因为在宏定义里我们捕获一个块表达式, $b:block, rustc 会在展开前检查, event 没在之前定义过, 对 rustc 而言这是一个未定义变量, 自然会报错.

因此需要一个办法在传入闭包体前声明 event 变量, 那直接传入一个闭包好了

($($d:ident),+ => $c:expr) => {
    {
        $(let $d = $d.clone();)+
        Box::new(move || {
            $(let $d = $d.clone();)+
            Box::new(move |event: web_sys::Event| {
                let should_update = $c(event);
                if should_update {
                    $(
                        $d.update();
                    )+
                }
            })
        })
    }
};

为了方便区分, rtml 加上了 => 分隔符. 使用示例如下

subs!(count => |evt: Event| {
    ...
})

现在除了必须写一个额外的闭包参数类型外, 只需要写一次捕获的变量就能将它们自动 clone 并转移到闭包中, 目标实现.