無駄と文化

実用的ブログ

【wasm-bindgen】いろいろな型注釈による wasm の挙動の違いを調べよう

Rust で wasm を書くときの話題です。

 

Rust で書いたプログラムは wasm にコンパイルすることが可能です。さらに wasm-bindgen を使うと JavaScript から呼び出しやすいインターフェースを自動生成してくれます。

例えば下記のようなコードを書くと、

// Rust

#[wasm_bindgen]
pub fn add(x: JsValue, y: JsValue) -> JsValue {
    x + y
}

こんな感じの .d.ts ファイルを生成してくれます。

// .d.ts

export function add(x: any, y: any): any;

想像に難くない生成結果ですが、any だらけになっているのが惜しい感じがしますね。
もちろん Rust コードをいい感じに型注釈するともっといい .d.ts ファイルを生成させられます。

どんな型注釈によってどんな .d.ts ファイルが生成されるのか見てみましょう。

 

この記事を書くにあたって事前に書いた Rust コードとテストコード、生成した .d.ts ファイルの全てを下記のリポジトリに置いています。

github.com

 

3行まとめ

  • String, f64, Vec<T> などの型を使うと string, number, T; に解釈させられる
  • js_sys の型を使うと string, number, object, any; に解釈させられる
    (が、JavaScript から呼んでみると型検査されていないことが分かる)
  • Rust 側で型検査を忘れずにやろう

 

wasm-bindgen まわりの型付けいろいろ

JsValue

最初の例でも見たように JsValue 型は .d.ts 側で any に解釈されます。
そもそも JsValue が "JavaScript のあらゆる値" を表現するための構造体なので、JsValue で型注釈したものが any になるのは妥当ですね。

// Rust

#[wasm_bindgen]
pub fn add(x: JsValue, y: JsValue) -> JsValue {
    x + y
}
// .d.ts

export function add(x: any, y: any): any;

 

String, f64, Vec<T>

String, f64, Vec<JsValue> などで型注釈すると、.d.ts 側ではそれぞれ string, number, any[] に解釈されます。

// Rust

#[wasm_bindgen]
pub fn want_string(x: String) -> String {
    return x;
}

#[wasm_bindgen]
pub fn want_number(x: f64) -> f64 {
    return x;
}

#[wasm_bindgen]
pub fn want_array(x: Vec<JsValue>) -> Vec<JsValue> {
    console::log_1(&x.clone().into());
    return x;
}
// .d.ts

export function want_string(x: string): string;

export function want_number(x: number): number;

export function want_array(x: any[]): any[];

 

String などのお馴染みの型で注釈することにはいくつかの利点があります。
JsValue で型付けすると String として使う前に変換が必要です。一方で String として型付けしておけば Rust 関数で値を受け取った時点で String 型になっているので楽できます。

// Rust

#[wasm_bindgen]
pub fn want_any(x: JsValue) {
    if let Some(s) = x.as_string() {
        // x は JsValue 型, s は String 型
        do_something_with_string(s);
    }
}

#[wasm_bindgen]
pub fn want_string(x: String) {
    // x は String 型
    do_something_with_string(x);
}

もう一つ、JavaScript から want_string() 関数を呼ぶときに string 以外の値を渡すと例外を投げて落ちてくれます。

// JavaScript

want_string(42);
// => RuntimeError: unreachable

強い静的型付けの Rust 言語ユーザーからすると、想定していない値が渡されたときに即座に落ちてくれるのは安心ですね。

 

js_sys::{Array, JsString, Number, Object}

.d.ts 側で string や number に解釈させる方法は他にもあります。
js_sys の JsString, Number, Object, Array で型注釈する方法です。

// Rust

use js_sys::{Array, JsString, Number, Object};

#[wasm_bindgen]
pub fn maybe_want_string(x: JsString) -> JsString {
    return x;
}

#[wasm_bindgen]
pub fn maybe_want_number(x: Number) -> Number {
    return x;
}

#[wasm_bindgen]
pub fn maybe_want_object(x: Object) -> Object {
    return x;
}

#[wasm_bindgen]
pub fn maybe_want_array(x: Array) -> Array {
    return x;
}
// .d.ts

export function maybe_want_string(x: string): string;

export function maybe_want_number(x: number): number;

export function maybe_want_object(x: object): object;

export function maybe_want_array(x: Array<any>): Array<any>;

いい感じですね。
特に .d.ts 側で object に解釈させるのは js_sys::Object でないとできないと思います。

 

が、先ほど String, f64, Vec<T> で型注釈したときとは異なり、js_sys の型で注釈したときには想定していない値を渡しても例外を投げてくれません。

// JavaScript

maybe_want_string(42); // エラーは無い
// => 42

maybe_want_number(['hello', 42]); // エラーは無い
// => ['hello', 42]

maybe_want_array('hello'); // エラーは無い
// => 'hello'

例外が投げられるでもなく暗黙に型変換されるでもなく、渡した値がそのまま維持されているようですね。

 

js_sys の型で注釈するときは自分で検査が必要

js_sys::JsString などで注釈しても値の検査が行われないのは分かったとして。検査する方法が無いのかというともちろんあります。
js_sys::JsString に対して is_string(), as_string() などのメソッドが実装されているので、自分で型検査を書けばいいのです。

// Rust

#[wasm_bindgen]
pub fn really_want_string(x: JsString) -> Result<JsString, JsError> {
    if x.is_string() {
        console::log_1(&x.clone().into());
        Ok(x)
    } else {
        Err(JsError::new("Expected a string"))
    }
}

// または

#[wasm_bindgen]
pub fn really_want_string(x: JsString) -> Result<JsString, JsError> {
    match x.as_string() {
        Some(_) => {
            console::log_1(&x.clone().into());
            Ok(x)
        }
        None => Err(JsError::new("Expected a string")),
    }
}

x: JsString に対して 42 を渡してみましょう。

// JavaScript

really_want_string(42);
// => Error: Expected a string

このように。

 

遊んでみる

型検査されないことを利用して遊んでみましょう。2つの数を加算する関数を書きます。

// Rust

#[wasm_bindgen]
pub fn add(x: Number, y: Number) -> Number {
    x + y
}

コンパイル後の .d.ts ファイルでは number で型注釈されています。

// .d.ts

export function add(x: number, y: number): number;

とはいえこれは TypeScript の型定義ファイルです。JavaScript コードを書けば number 以外の値を渡してもコンパイラに怒られることはありません。

// JavaScript

add(3, 4);
// => 7

いいですね。

// JavaScript

add('foo', 'bar');
// => 'foobar'

なんと、+ 演算子を使って加算したつもりが文字列の結合ができてしまいました。

ということはこんなこともできますね。

// JavaScript

add(3, '4');
// => '34'

3 + '4''34'、なんてこった。

add() 関数を呼び出しているのは JavaScript 世界とはいえ、実装は Rust です。
Rust では 3 + '4' のような演算は許されません。いったい何が起こっているのでしょうか。

 

js_sys の演算を追って見る

js_sys のソースコードを見にいきましょう。

まず + 演算子は Add トレイト を実装することで任意の型に適用できるようになります。
今回の例では js_sys::Number 同士を + しているので impl<'a> Add<Number> for &'a Number が参照されます。実装を見てみましょう。

Number in js_sys - Rust

forward_js_binop!(impl Add, add for Number);

なんとシンプル。マクロを使ってたった1行で実装されていました。

lib.rs - source

macro_rules! forward_js_binop {
    (impl $imp:ident, $method:ident for $t:ty) => {
        impl $imp<&$t> for &$t {
            type Output = $t;

            #[inline]
            fn $method(self, other: &$t) -> Self::Output {
                $imp::$method(JsValue::as_ref(self), JsValue::as_ref(other)).unchecked_into()
            }
        }

        forward_deref_binop!(impl $imp, $method for $t);
    };
}

マクロ定義を雰囲気で読むと JsValue::as_ref(self) で &JsValue に変換してから演算しているようですね。

では JsValue 同士の + 演算の定義を知るために impl Add<&JsValue> for JsValue のソースを見にいくと。

JsValue in wasm_bindgen - Rust

impl Add for &JsValue {
    type Output = JsValue;

    /// Applies the binary `+` JS operator on two `JsValue`s.
    ///
    /// [MDN documentation](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Addition)
    #[inline]
    fn add(self, rhs: Self) -> Self::Output {
        unsafe { JsValue::_new(__wbindgen_add(self.idx, rhs.idx)) }
    }
}

forward_deref_binop!(impl Add, add for JsValue);

__wbindgen_add を呼んでいるようです。
コメントを読むと JavaScript の + 演算子に相当するものみたいですね。

 

まとめると、
js_sys::Number 同士の + が Rust っぽくない挙動をしていたのは、ズバリ JavaScript の + を呼んでいる実装だからです。
number + number, string + string, number + string の結果が JavaScript で演算したときと一致していることからも確かめられます。

 

おまけ: 数として評価できる文字列について見ていく

JavaScript では '42' のような文字列が数として振る舞う場面があります。

// JavaScript

// 単項マイナス演算子
- '42';
// => -42

// 乗算演算子
'3' * '4';
// => 12

Rust で書いたいくつかのコードをコンパイルして "数として評価できる文字列" に対する振る舞いを見てみましょう。

 

// Rust

#[wasm_bindgen]
pub fn unary_negation(x: f64) -> f64 {
    -x
}
// JavaScript

unary_negation('42');
// => -42

Rust 側で f64 で型注釈した関数に JavaScript 側で '42' を渡すと、文字列をパースして 42 として渡してくれるようです。
これは便利。

 

// Rust

#[wasm_bindgen]
pub fn unary_negation(x: JsValue) -> f64 {
    match x.as_f64() {
        Some(x) => -x,
        None => Number::NAN,
    }
}
// JavaScript

unary_negation('42');
// => NaN

JsValue に対して .as_f64() で f64 への変換を試みています。が、文字列をパースしてくれないようですね。
None => Number::NAN によって NaN が返されました。

 

// Rust

#[wasm_bindgen]
pub fn unary_negation(x: Number) -> Number {
    -x
}
// JavaScript

unary_negation('42');
// => -42

js_sys::Number に対して直接 - を作用させた場合。予想通り JavaScript の単項マイナス演算子 - がそのまま呼ばれるために数として解釈されました。

 

まとめ

wasm-bindgen を使うと結構いろいろな書き方ができるんですが、それぞれに挙動が少しずつ違いました。

  • String, f64 などで型注釈すると割と柔軟に型変換してから渡してくれる
  • js_sys::JsString, js_sys::Number などで型注釈すると自前で型検査する必要があり、柔軟さは無い (余計な変換をしないので信頼できるとも言える)
  • js_sys::JsString, js_sys::Number などに対して演算子を適用すると JavaScript の演算子が呼ばれる

違いを把握しておかないと分かりづらい不具合を埋めてしまいそうですね。
特に wasm の内部で発生したエラーメッセージは読みづらくなりがちなので。

 

 

私からは以上です。