クロージャとコールバック:JSとRust間の連携
クロージャとは?
クロージャとは、周囲のスコープから変数をキャプチャできる無名関数です。Rustでは、クロージャは最も強力な機能の一つであり、JavaScriptがコールバックに大きく依存しているため、Wasmインターオペレーションには不可欠です。
// クロージャの3つの等価な書き方:
let add = |a, b| a + b; // 型推論
let add = |a: i32, b: i32| a + b; // 明示的な型
let add = |a: i32, b: i32| -> i32 { a + b }; // 完全な構文3つのクロージャトレイト
Rustは、キャプチャした変数の使い方に基づいてクロージャを3つのトレイトに分類します:
+----------+-------------------+---------------------------+
| トレイト | キャプチャ方法 | 呼び出し回数 |
+----------+-------------------+---------------------------+
| Fn | &T(不変借用) | 何度でも |
| FnMut | &mut T(可変借用)| 何度でも(&mutが必要) |
| FnOnce | T(値渡し) | 1回のみ |
+----------+-------------------+---------------------------+コンパイラは、キャプチャした変数に対する操作に基づいて、クロージャがどのトレイトを実装するかを自動的に判断します:
let x = 5;
let reads_x = || println!("{}", x); // Fn(xを借用)
let mut y = 5;
let mutates_y = || { y += 1; }; // FnMut(yを可変借用)
let z = String::from("hello");
let consumes_z = || { drop(z); }; // FnOnce(zを移動)重要: すべてのFnOnceがFnMutとは限らず、すべてのFnMutがFnとは限りません。しかし Fn ⊂ FnMut ⊂ FnOnce です(Fnを実装するクロージャはFnMutとFnOnceも実装します)。
moveキーワード
デフォルトでは、クロージャは必要最小限の借用で変数をキャプチャします。moveキーワードはクロージャにキャプチャしたすべての変数の所有権を強制的に取得させます:
let name = String::from("Alice");
let greet = move || println!("Hello, {}", name);
// nameはクロージャに移動したため、ここではアクセス不可
greet();これはWasmにとって重要です。JavaScriptに渡されるクロージャは'staticでなければならず、Rustスタックからの借用はできません。JSがクロージャを呼び出す時点でスタックフレームが消えている可能性があるためです。
wasm-bindgenにおけるクロージャ
Wasmの世界では、wasm_bindgen::closure::ClosureがRustのクロージャをラップして、JavaScriptから呼び出せるようにします。主に2つのコンストラクタがあります:
Closure::wrap
// Closure::wrapは長寿命のJS呼び出し可能クロージャを作成する
let cb = Closure::wrap(Box::new(|event: web_sys::MouseEvent| {
// クリック処理
}) as Box<dyn FnMut(web_sys::MouseEvent)>);
element.add_event_listener_with_callback("click", cb.as_ref().unchecked_ref())?;Closure::once
// Closure::onceはワンショットクロージャ(FnOnce)を作成する
let cb = Closure::once(move || {
// これは一度だけ実行され、その後クロージャはクリーンアップされる
web_sys::console::log_1(&"Loaded!".into());
});'staticライフタイム要件
RustからJavaScriptにクロージャを渡す場合、'staticライフタイム境界を満たす必要があります。これは以下を意味します:
┌─────────────────────────────────────────────────────┐
│ Rustスタックフレーム │
│ │
│ let local_string = String::from("hello"); │
│ │
│ // コンパイル不可 — クロージャがlocal_stringを借用 │
│ let bad = Closure::wrap(Box::new(|| { │
│ console::log_1(&local_string.into()); │
│ }) as Box<dyn Fn()>); │
│ │
│ // 動作する — moveがクロージャに所有権を与える │
│ let good = Closure::wrap(Box::new(move || { │
│ console::log_1(&local_string.into()); │
│ }) as Box<dyn FnOnce()>); │
│ │
└─────────────────────────────────────────────────────┘理由:Rust関数が返ると、そのスタックは消えます。後でJSがクロージャを呼び出すと、借用された参照はダングリングになります。'static境界はクロージャが必要なものをすべて所有していることを保証します。
メモリ管理:forget() vs into_js_value()
これはWasmクロージャの最も難しい部分の一つです。RustでClosureがdropされると、JSコールバックは無効になります。しかし、コールバックが作成元のRust関数より長く生きる必要があることがよくあります。
closure.forget()
let cb = Closure::wrap(Box::new(|| { /* ... */ }) as Box<dyn Fn()>);
element.set_onclick(Some(cb.as_ref().unchecked_ref()));
cb.forget(); // メモリリーク — クロージャは永久に生き続ける利点: シンプルで、常に動作する。 欠点: メモリリーク。イベントリスナーを頻繁に追加/削除すると、メモリが際限なく増加する。
closure.into_js_value()
let cb = Closure::wrap(Box::new(|| { /* ... */ }) as Box<dyn Fn()>);
let js_func: JsValue = cb.into_js_value();
// js_funcがクロージャを所有 — JSのGCが回収するとき解放される利点: メモリリークなし — JSのGCがライフタイムを管理する。
欠点: RustのClosureハンドルを失う(後でリスナーを簡単に削除できない)。
比較表
| メソッド | メモリリーク? | JS GCが管理? | ユースケース |
|---|---|---|---|
forget() |
あり | いいえ | 長寿命イベントリスナー |
into_js_value() |
なし | はい | 渡して忘れるコールバック |
| 構造体に保存 | なし | いいえ | 制御されたライフタイム(最善) |
Closure::once |
なし | 自動クリーンアップ | ワンショットコールバック |
ベストプラクティス:構造体にクロージャを保存する
最もクリーンなアプローチは、コールバックが必要な間生き続けるRust構造体にClosureを保存することです:
#[wasm_bindgen]
pub struct App {
click_handler: Option<Closure<dyn FnMut(MouseEvent)>>,
}
#[wasm_bindgen]
impl App {
pub fn new() -> App {
App { click_handler: None }
}
pub fn setup_click(&mut self, element: &HtmlElement) {
let cb = Closure::wrap(Box::new(|e: MouseEvent| {
// クリック処理
}) as Box<dyn FnMut(MouseEvent)>);
element.set_onclick(Some(cb.as_ref().unchecked_ref()));
self.click_handler = Some(cb); // 保存 — リークしない
}
}
// Appがdropされると、click_handlerもdropされ、コールバックは無効になるJavaScriptからRustへの関数受け渡し
JS関数を引数として受け取ることもできます:
#[wasm_bindgen]
pub fn call_js_function(f: &js_sys::Function) {
let this = JsValue::NULL;
let arg = JsValue::from(42);
f.call1(&this, &arg).unwrap();
}JavaScript側:
import { call_js_function } from './pkg/my_module.js';
call_js_function((x) => console.log("Got from Rust:", x));
// 出力: "Got from Rust: 42"よくあるパターン
デバウンスコールバック
use std::cell::RefCell;
use std::rc::Rc;
let timeout_id = Rc::new(RefCell::new(None));
let tid = timeout_id.clone();
let debounced = Closure::wrap(Box::new(move || {
if let Some(id) = tid.borrow_mut().take() {
window.clear_timeout_with_handle(id);
}
let new_id = window.set_timeout_with_callback_and_timeout_and_arguments_0(
actual_handler.as_ref().unchecked_ref(), 300
).unwrap();
*tid.borrow_mut() = Some(new_id);
}) as Box<dyn FnMut()>);requestAnimationFrameループ
fn request_animation_frame(f: &Closure<dyn FnMut()>) {
window().unwrap()
.request_animation_frame(f.as_ref().unchecked_ref())
.expect("should register `requestAnimationFrame`");
}
let f = Rc::new(RefCell::new(None));
let g = f.clone();
*g.borrow_mut() = Some(Closure::wrap(Box::new(move || {
// ここでフレームをレンダリング
request_animation_frame(f.borrow().as_ref().unwrap());
}) as Box<dyn FnMut()>));
request_animation_frame(g.borrow().as_ref().unwrap());まとめ
- Rustクロージャは、変数のキャプチャ方法に応じて
Fn、FnMut、FnOnceを実装する moveクロージャは'static要件のため、Wasmではほぼ常に必要- **
Closure::wrapは複数回使用可能なJSコールバックを作成し、Closure::once**は単発のコールバックを作成する - **
forget()はメモリリークするがシンプル、into_js_value()**はJS GCにライフタイム管理を委ねる - ベストプラクティス:
Closureの値を構造体フィールドに保存し、ライフタイムを明示的に管理する - JS → Rust:
&js_sys::Functionを受け取ることでRustでJavaScript関数を受け取れる