AssemblyScript つまずきポイント

こんにちは、亀山です。

皆様、AssemblyScript はご存知でしょうか。TypeScript の文法で WebAssembly にビルドされるプログラムを記述することができるプログラミング言語です。

AssemblyScript のコンパイラからは wasm ファイルだけでなく、それを読み込むための js も自動的に生成されるため、普通の JavaScript のモジュールのように扱うことができます。

そのため WebAssembly そのものの知識が無くても、TypeScript の書き方がわかっていれば WebAssembly が書ける素敵な環境になっています。

しかし、普段どおりの TypeScript の書き方ができないことも多くあります。本記事では個人的なつまずきポイントと対処法をご紹介します。

instanceof で型が絞り込めない

class Base {}

class A extends Base {
  helloA(): void {}
}

class B extends Base {
  helloB(): void {}
}

export function add(a: i32, b: i32): i32 {
  const x: Base = new A()

  if (x instanceof A) {
    x.helloA()
  } else if (x instanceof B) {
    x.helloB()
  }

  return a + b;
}

TypeScript としては正しいコードなので VSCode 上ではエラーはハイライトされませんが、ビルド時にエラーが出ます。

ERROR TS2339: Property 'helloA' does not exist on type 'assembly/index/Base'.
    :
 19 │ x.helloA()
    │   ~~~~~~
    └─ in assembly/index.ts(19,7)

ERROR TS2339: Property 'helloB' does not exist on type 'assembly/index/Base'.
    :
 21 │ x.helloB()
    │   ~~~~~~
    └─ in assembly/index.ts(21,7)

FAILURE 2 compile error(s)

下記のように明示的にキャストすることでビルドできます。

  if (x instanceof A) {
    (<A>x).helloA()
  } else if (x instanceof B) {
    (<B>x).helloB()
  }

undefined がない

null を使います。

外側の変数を参照するクロージャが作れない

下記のような引数を使うだけのクロージャは作ることができます。

export function add(a: i32, b: i32): i32 {
  const x: i32[] = [1, 2, 3]
  return x.reduce((x, y) => x + y, 0)
}

外側の変数を参照するクロージャを書くとコンパイルエラーになります。

export function add(a: i32, b: i32): i32 {
  const x: i32[] = [1, 2, 3]
  return x.reduce((x, y) => x + y + a, 0)
}
ERROR AS100: Not implemented: Closures
   :
 3 │ return x.reduce((x, y) => x + y + a, 0)
   │                                   ~
   └─ in assembly/index.ts(3,37)

FAILURE 1 compile error(s)

外側の変数を参照しなければ、forEach を使うこともできます。外側の変数を参照することが多いので使い所は少ないかもしれませんが。

export function add(a: i32, b: i32): i32 {
  const x: i32[] = [1, 2, 3]
  x.forEach(v => console.log(v.toString()))
  return 1
}

ちゃんとコンソールが出力されます。

for-of, for-in が使えない

for-of が使えません。

const x = [1, 2, 3]
let sum = 0
for (let v of x) {
  sum += v
}
ERROR AS100: Not implemented: Iterators
   :
 4 │ for (let v of x) {
   │ ~~~~~~~~~~~~~~~~~~
   └─ in assembly/index.ts(4,3)

FAILURE 1 compile error(s)

また、for-in も使えません。

const x = [1, 2, 3]
let sum = 0
for (let v in x) {
  sum += x[v]
}
ERROR TS1110: Type expected.
   :
 4 │ for (let v in x) {
   │           ^
   └─ in assembly/index.ts(4,11)

ERROR TS1005: ';' expected.
   :
 4 │ for (let v in x) {
   │          ~
   └─ in assembly/index.ts(4,10)

FAILURE 2 parse error(s)

昔ながらの for loop に書き直す必要があります。

const x = [1, 2, 3]
let sum = 0
for (let i = 0; i < x.length; i++) {
  sum += x[i]
}

number を適切な型に書き直す

既存の TypeScript のコードを AssemblyScript に書き直すときなどは、number が指定された変数を i32 などのより細かい数値型に変更する必要があります。

例えば下記のコードは i32 と number が混在していることでエラーになります。

export function add(a: i32, b: i32): i32 {
  const x: number = 42
  return a + x
}
ERROR AS200: Conversion from type 'f64' to 'i32' requires an explicit cast.
   :
 3 │ return a + x
   │        ~~~~~
   └─ in assembly/index.ts(3,10)

FAILURE 1 compile error(s)

number は AssemblyScript では f64 として扱われるため、型の異なる変数同士の演算となりコンパイルエラーになります。number を適切な型に変更します。

-  const x: number = 42
+  const x: i32 = 42

もしくは、計算式内で明示的にキャストします。浮動小数での計算が必要かどうか、注意して書き直す必要があります。書き直しやすさを重視するのであれば、一律 f64 (number) を使う運用でもいいかもしれません。(配列アクセスなどには i32 にする必要があるので、すべてに f64 を使うということはできませんが)

export function add(a: i32, b: i32): i32 {
  const x: f32 = 42
  return <i32>(<f32>a + x)
}

なお、キャストは <i32> ではなく i32(x) のようにも記述できます。

i32(f32(a) + x)

try-catch が使えない

以下のように try-catch を使うことはできません。

function validate(x: i32): void {
  if (x <= 0) {
    throw new Error("minus value")
  }
}

export function add(a: i32, b: i32): i32 {
  try {
    validate(a)
  } catch(e) {
    return 0
  }
  return 1
}

明確にこうすべきという書き換え方はありませんが、bool を返す関数などに書き換えるほかないでしょう。

function validate(x: i32): bool {
  if (x <= 0) {
    return false
  }
  return true
}

export function add(a: i32, b: i32): i32 {
  if (!validate(a)) {
    return 0
  }
  return 1
}

Union Type が無い

下記のような interface を活用したコードが書けません

interface Action<T> {
  type: T
}

interface FooAction extends Action<"foo"> {
  payload: i32
}

interface BarAction extends Action<"bar"> {
  payload: f64
}

type Actions = FooAction | BarAction

function dispatch(action: Actions): i32 {
  switch (action.type) {
    case "foo":
      return action.payload
    case "bar":
      return action.payload
  }
}

export function add(a: i32, b: i32): i32 {
  const action: FooAction = {
    type: "foo",
    payload: 12
  }
  dispatch(action)
  return 1
}

class を作り、instanceof で判別することができます。前述のとおり instanceof で絞り込めないため、冗長なコードになります。

interface Action {
}

class FooAction implements Action {
  constructor(readonly payload: i32) {}
}

class BarAction implements Action{
  constructor(readonly payload: f64) {}
}

function dispatch(action: Action): i32 {
  if (action instanceof FooAction) {
    return (<FooAction>action).payload
  } else if (action instanceof BarAction) {
    return i32((<BarAction>action).payload)
  }
  return 0
}

export function add(a: i32, b: i32): i32 {
  const action = new FooAction(12)
  dispatch(action)
  return 1
}

Object がない

{} で作った Object を interface に合致させるようなことはできません。

interface Option {
  verbose: bool
  lineCount: number
}

export function add(a: i32, b: i32): i32 {
  const option: Option = {
    verbose: true,
    lineCount: 200
  }
  return 1
}
ERROR AS100: Not implemented: Interface hidden classes
   :
 7 │ const option = <Option>{
   │                        ^
   └─ in assembly/index.ts(7,26)

FAILURE 1 compile error(s)

class を使います。

class Option {
  constructor(
    readonly verbose: bool,
    readonly lineCount: number
  ) {}
}

export function add(a: i32, b: i32): i32 {
  const option = new Option(true, 200)
  return 1
}

もしくは Map を使うことができますが、値の型が1つしか指定できないため、上記のようなオブジェクトは表現できません。書くなら下記のようになるでしょうか。

  const option = new Map<string, number>()
  option.set("verbose", 1)
  option.set("lineCount", 200)

下記のような Map は作れません。

  const option = new Map<string, bool|number>()

VSCode 上でエラーがハイライトされない

TypeScript として正しければエラーがエディタにハイライトされることはありません。そのため、上記に挙げたような TypeScript では使えるが AssemblyScript では使えない機能がコンパイルするまで分からないという問題があります。

また、かなり強めの Tree-shaking が効いており、export されている関数から参照されていないコードはコンパイルされません。そのため、外部に export しない関数やクラスをコンパイルしながら実装といった作業がしづらいです。

その他

  • ビルド時間が少し長い
  • コンパイラに watch オプションがない

最後に

以上です。TypeScript に慣れ親しんだ人ほど Union Type といった強力な言語機能を活用しているかと思いますが、それらが使えないやや苦しい環境とも言えます。しかし、TypeScript で WebAssembly が書けるというコンセプトは素晴らしく、新しい言語を覚えるよりも遥かに素早くプログラミングができます。

VSCode や npm といった普段使い慣れているツールをそのまま利用でき、普段の JavaScript のモジュールの開発と変わらない開発体験が実現しています。asinit コマンド一発で開発環境のセットアップが完了します。ぜひお試しください。