中級 data-structures

WasmにおけるRustの列挙型

列挙型:Rustの超能力

Rustの列挙型は多くの言語の列挙型よりはるかに強力です。データを保持でき、メソッドを持ち、コンパイル時に網羅的なパターンマッチングを強制します。しかし、Wasm境界を越えると課題が生じます — JavaScriptにはRustのリッチな列挙型に相当する概念がありません。

C言語スタイルの列挙型:簡単なケース

データを持たないシンプルな列挙型は、wasm_bindgenを通じてJavaScriptの数値に直接マッピングされます:

#[wasm_bindgen]
pub enum Direction {
    Up = 0,
    Down = 1,
    Left = 2,
    Right = 3,
}

これにより以下のTypeScript定義が生成されます:

export enum Direction {
    Up = 0,
    Down = 1,
    Left = 2,
    Right = 3,
}

重要なルール: #[wasm_bindgen]はC言語スタイルの列挙型(バリアントにデータなし)のみサポートします。

reprによるメモリレイアウト

#[repr(u8)]   // 1バイト
enum Small { A, B, C }

#[repr(u32)]  // 4バイト、JSの数値と一致
enum JsCompatible { X, Y, Z }
#[repr(u8)]のメモリレイアウト:
┌────┐
│ 00 │  = A(1バイト)
├────┤
│ 01 │  = B(1バイト)
├────┤
│ 02 │  = C(1バイト)
└────┘

reprなしの場合、Rustは最小の表現を選択します。

複雑な列挙型:難しいケース

列挙型のバリアントにデータが含まれる場合、wasm_bindgenは直接エクスポートできません:

// これは#[wasm_bindgen]ではコンパイルできない
enum Shape {
    Circle { radius: f64 },        // データあり
    Rectangle { w: f64, h: f64 },  // データあり
    Point,                          // データなし
}

エラー: wasm_bindgenはデータフィールドを持つ列挙型をサポートしていません。回避策が必要です。

回避策1:serde-wasm-bindgen

最も人気のある解決策は、serdeを使って複雑な列挙型をJsValueにシリアライズすることです:

use serde::{Serialize, Deserialize};
use wasm_bindgen::prelude::*;

#[derive(Serialize, Deserialize)]
#[serde(tag = "type")]  // 内部タグ付き
pub enum Shape {
    Circle { radius: f64 },
    Rectangle { width: f64, height: f64 },
    Point,
}

#[wasm_bindgen]
pub fn create_shape(kind: &str, params: JsValue) -> JsValue {
    let shape: Shape = match kind {
        "circle" => Shape::Circle {
            radius: serde_wasm_bindgen::from_value(params).unwrap()
        },
        _ => Shape::Point,
    };
    serde_wasm_bindgen::to_value(&shape).unwrap()
}

Serdeのタグ付け戦略

SerdeはJSONでの列挙型表現にいくつかの方法を提供します:

戦略 属性 JSON出力
外部タグ付き(デフォルト) なし {"Circle": {"radius": 5}}
内部タグ付き #[serde(tag = "type")] {"type": "Circle", "radius": 5}
隣接タグ付き #[serde(tag = "t", content = "c")] {"t": "Circle", "c": {"radius": 5}}
タグなし #[serde(untagged)] {"radius": 5}
外部タグ付き(デフォルト):
{ "Circle": { "radius": 5.0 } }
  ~~~~~~~~   ~~~~~~~~~~~~~~~~
  バリアント   データ

内部タグ付き(#[serde(tag = "type")]):
{ "type": "Circle", "radius": 5.0 }
  ~~~~~~~~~~~~~~~~  ~~~~~~~~~~~~~~
  判別子             データ(フラット化)

隣接タグ付き(#[serde(tag = "t", content = "c")]):
{ "t": "Circle", "c": { "radius": 5.0 } }
  ~~~~~~~~~~~~~~  ~~~~~~~~~~~~~~~~~~~~~~~~
  判別子           データ(ネスト)

回避策2:構造体による手動変換

パフォーマンスが重要なコードでは、serdeのオーバーヘッドを避けて手動変換します:

#[wasm_bindgen]
pub struct ShapeData {
    kind: u8,          // 0=Circle, 1=Rect, 2=Point
    param1: f64,       // radiusまたはwidth
    param2: f64,       // 0またはheight
}

#[wasm_bindgen]
impl ShapeData {
    pub fn circle(radius: f64) -> ShapeData {
        ShapeData { kind: 0, param1: radius, param2: 0.0 }
    }

    pub fn rectangle(width: f64, height: f64) -> ShapeData {
        ShapeData { kind: 1, param1: width, param2: height }
    }

    pub fn area(&self) -> f64 {
        match self.kind {
            0 => std::f64::consts::PI * self.param1 * self.param1,
            1 => self.param1 * self.param2,
            _ => 0.0,
        }
    }
}

回避策3:tsifyによるTypeScriptユニオン型

tsifyクレートは適切なTypeScript判別共用体を生成します:

use tsify::Tsify;
use serde::{Serialize, Deserialize};

#[derive(Tsify, Serialize, Deserialize)]
#[tsify(into_wasm_abi, from_wasm_abi)]
#[serde(tag = "type")]
pub enum GameEvent {
    Move { x: f64, y: f64 },
    Attack { target_id: u32, damage: f64 },
    Heal { amount: f64 },
    Disconnect,
}

これにより以下が生成されます:

export type GameEvent =
    | { type: "Move"; x: number; y: number }
    | { type: "Attack"; target_id: number; damage: number }
    | { type: "Heal"; amount: number }
    | { type: "Disconnect" };

TypeScriptの型ナローイングが完璧に機能します:

function handleEvent(event: GameEvent) {
    switch (event.type) {
        case "Move":
            console.log(`Moving to (${event.x}, ${event.y})`);
            break;
        case "Attack":
            console.log(`Attacking ${event.target_id} for ${event.damage}`);
            break;
    }
}

パフォーマンス比較

メソッド シリアライゼーションコスト 型安全性 JS側の使いやすさ
C言語スタイル列挙型 なし(u32) 優秀 良好
serde-wasm-bindgen 中程度(JSON風) 良好 優秀
手動構造体 なし 手動 普通
tsify 中程度 優秀 優秀

境界を越えるOptionとResult

Option<T>プリミティブ型の場合、JavaScriptでT | undefinedにマッピングされます:

#[wasm_bindgen]
pub fn find_user(id: u32) -> Option<String> {
    if id == 1 { Some("Alice".into()) } else { None }
}
const name: string | undefined = find_user(1); // "Alice"
const none: string | undefined = find_user(99); // undefined

Result<T, E>Errの場合にJavaScript例外をスローします:

#[wasm_bindgen]
pub fn parse_number(s: &str) -> Result<f64, JsError> {
    s.parse::<f64>().map_err(|e| JsError::new(&e.to_string()))
}
try {
    const n = parse_number("42.5"); // 42.5
    const bad = parse_number("abc"); // スロー!
} catch (e) {
    console.error(e.message); // "invalid float literal"
}

まとめ

  1. C言語スタイルの列挙型#[wasm_bindgen]で直接使える — JSの数値にマッピングされる
  2. 複雑な列挙型(データ付き)には回避策が必要:serde-wasm-bindgen、手動構造体、またはtsify
  3. serdeのタグ付け戦略はJSONでの列挙型バリアントの表現方法を制御する — JSには内部タグ付きが通常最適
  4. tsifyはTypeScript判別共用体を生成し、両側で完全な型安全性を提供する
  5. **Option<T>T | undefinedに、Result<T, E>**は戻り値またはスローにマッピングされる
  6. ホットパスではserdeのオーバーヘッドを避ける — C言語スタイル列挙型か手動変換構造体を使用する

試してみる