Rust で wasm を書くときの話題です。
Rust で書いたプログラムは wasm にコンパイルすることが可能です。さらに wasm-bindgen を使うと JavaScript から呼び出しやすいインターフェースを自動生成してくれます。
例えば下記のようなコードを書くと、
#[wasm_bindgen]
pub fn add(x: JsValue, y: JsValue) -> JsValue {
x + y
}
こんな感じの .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 になるのは妥当ですね。
#[wasm_bindgen]
pub fn add(x: JsValue, y: JsValue) -> JsValue {
x + y
}
export function add(x: any, y: any): any;
String, f64, Vec<T>
String, f64, Vec<JsValue> などで型注釈すると、.d.ts 側ではそれぞれ string, number, any[] に解釈されます。
#[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;
}
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 型になっているので楽できます。
#[wasm_bindgen]
pub fn want_any(x: JsValue) {
if let Some(s) = x.as_string() {
do_something_with_string(s);
}
}
#[wasm_bindgen]
pub fn want_string(x: String) {
do_something_with_string(x);
}
もう一つ、JavaScript から want_string()
関数を呼ぶときに string 以外の値を渡すと例外を投げて落ちてくれます。
want_string(42);
強い静的型付けの Rust 言語ユーザーからすると、想定していない値が渡されたときに即座に落ちてくれるのは安心ですね。
js_sys::{Array, JsString, Number, Object}
.d.ts 側で string や number に解釈させる方法は他にもあります。
js_sys の JsString, Number, Object, Array で型注釈する方法です。
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;
}
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 の型で注釈したときには想定していない値を渡しても例外を投げてくれません。
maybe_want_string(42);
maybe_want_number(['hello', 42]);
maybe_want_array('hello');
例外が投げられるでもなく暗黙に型変換されるでもなく、渡した値がそのまま維持されているようですね。
js_sys の型で注釈するときは自分で検査が必要
js_sys::JsString などで注釈しても値の検査が行われないのは分かったとして。検査する方法が無いのかというともちろんあります。
js_sys::JsString に対して is_string()
, as_string()
などのメソッドが実装されているので、自分で型検査を書けばいいのです。
#[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
を渡してみましょう。
really_want_string(42);
このように。
遊んでみる
型検査されないことを利用して遊んでみましょう。2つの数を加算する関数を書きます。
#[wasm_bindgen]
pub fn add(x: Number, y: Number) -> Number {
x + y
}
コンパイル後の .d.ts ファイルでは number で型注釈されています。
export function add(x: number, y: number): number;
とはいえこれは TypeScript の型定義ファイルです。JavaScript コードを書けば number 以外の値を渡してもコンパイラに怒られることはありません。
add(3, 4);
いいですね。
add('foo', 'bar');
なんと、+
演算子を使って加算したつもりが文字列の結合ができてしまいました。
ということはこんなこともできますね。
add(3, '4');
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'
のような文字列が数として振る舞う場面があります。
- '42';
'3' * '4';
Rust で書いたいくつかのコードをコンパイルして "数として評価できる文字列" に対する振る舞いを見てみましょう。
#[wasm_bindgen]
pub fn unary_negation(x: f64) -> f64 {
-x
}
unary_negation('42');
Rust 側で f64 で型注釈した関数に JavaScript 側で '42'
を渡すと、文字列をパースして 42
として渡してくれるようです。
これは便利。
#[wasm_bindgen]
pub fn unary_negation(x: JsValue) -> f64 {
match x.as_f64() {
Some(x) => -x,
None => Number::NAN,
}
}
unary_negation('42');
JsValue に対して .as_f64()
で f64 への変換を試みています。が、文字列をパースしてくれないようですね。
None => Number::NAN
によって NaN が返されました。
#[wasm_bindgen]
pub fn unary_negation(x: Number) -> Number {
-x
}
unary_negation('42');
js_sys::Number に対して直接 -
を作用させた場合。予想通り JavaScript の単項マイナス演算子 -
がそのまま呼ばれるために数として解釈されました。
まとめ
wasm-bindgen を使うと結構いろいろな書き方ができるんですが、それぞれに挙動が少しずつ違いました。
- String, f64 などで型注釈すると割と柔軟に型変換してから渡してくれる
- js_sys::JsString, js_sys::Number などで型注釈すると自前で型検査する必要があり、柔軟さは無い (余計な変換をしないので信頼できるとも言える)
- js_sys::JsString, js_sys::Number などに対して演算子を適用すると JavaScript の演算子が呼ばれる
違いを把握しておかないと分かりづらい不具合を埋めてしまいそうですね。
特に wasm の内部で発生したエラーメッセージは読みづらくなりがちなので。
私からは以上です。