💭

ウェブエンジニアでもWasmを使いたい! アフタートーク

2024/09/04に公開

フロントエンドカンファレンス北海道 2024にて「ウェブエンジニアでもWasmを使いたい!」というタイトルで20分のトークを行いました.

当日のトークでは,WebAssemblyの特徴に触れつつ,特殊な用途[1]以外でWebAssemblyをどのように活用できそうか,実際にAssemblyScriptのコードを例に紹介しました.

AssemblyScriptはTypeScriptをWebAssemblyにコンパイルできる言語として紹介しましたが,より正確にはTypeScriptと同じ構文を持つ言語をWebAssemblyにコンパイルする言語という方が正しいかもしれません.

これはTypeScriptにはない構文などをAssemblyScriptでサポートしているという話ではなく,TypeScriptの型推論などの言語機能を活用してAssemblyScriptのコードを書くことができるようにTypeScriptの型定義を用意しているが,内部の言語設計=TypeScriptではないという話です.

そのため,同じTypeScriptのコードであってもAssemblyScriptとJavaScriptでは書いたコードの振る舞いが異なるといったハマりポイントがAssemblyScriptには存在します.

この記事では,時間の都合上本編で割愛したAssemblyScriptのネタを紹介したいと思います.

本編で語れなかったAssemblyScriptのあれこれ

設定ファイル

AssemblyScriptにはasconfig.jsonという設定ファイルがあります.

asconfig.jsonには,AssemblyScriptをビルドするときのビルド方法(SourceMapを作成するか,デバッグモードでビルドするかなど)を設定することができます.

初期化

AssemblyScriptにはasinitという初期化コマンドが用意されています.

AssemblyScriptをインストール(npm i -D assemblyscript)することで,asinitコマンドも利用できるようになります.

asinitを実行すると,AssemblyScriptを利用するための最低限のファイル構成(asconfig.jsonやエントリーポイントなど)が生成されます.

npx asinit .

技術書典のオンラインマーケットで0円で配布しているWebAssembly Cookbook vol.1でも,AssemblyScriptのインストール方法を紹介していますので,興味があればこちらも参考にしてみてください.

https://techbookfest.org/product/b0Dp5Remzfe0a6M276Sw0a

AssemblyScriptの型

AssemblyScriptに登場するi32f64などの型は,numberのエイリアスとして型定義されています.

https://github.com/AssemblyScript/assemblyscript/blob/f79391c91a0875e98a6e887b3353210b4125dc87/std/assembly/index.d.ts#L10-L17

そのため,次のようなコードはJavaScriptとしても問題なくそのまま扱うことができます.

export function add(a: i8, b: i8): i8 {
  return a + b;
}

一方で実際に実行してみると,JavaScriptとWebAssemblyでは異なる結果となります.

// JavaScript
console.log(add(127, 1)); // 128

// WebAssembly
console.log(add(127, 1)); // -128

これは,AssemblyScript上でi8が単なるnumberのエイリアスではなく,実際の8ビット整数として扱われているためです.

BigInt

WebAssemblyのi64はJavaScript上ではBigIntとして扱われますが,AssemblyScriptのi64type i64 = numberとして定義されています.

そのため,AssemblyScriptでi64を扱うと,TypeScriptとしてはnumberとして扱うことができますが,ビルドされたWebAssemblyを扱う際はBigIntとして扱う必要があり,i64i32f64などの型と同じようにインポート元をTypeScriptからビルドされたJavaScriptに変更するだけでは実行時にエラーとなるので注意が必要です.

export function add(a: i64, b: i64): i64 {
  return a + b;
}
- import { add } from "./assembly/index.ts";
+ import { add } from "./build/release.js";

- const a = 1;
- const b = 2;
+ const a = 1n;
+ const b = 2n;

console.log(add(a, b));

型キャスト

TypeScriptの世界では,asキーワードを使った型アサーションを行うことができますが,AssemblyScriptでは,asキーワードを使った場合は型キャストが行われます.

export function n2i8(a: number): i8 {
  return a as i8;
}
// JavaScript
console.log(n2i8(128)); // 128

// WebAssembly
console.log(n2i8(128)); // -128

また,asキーワード以外にも,<T>を使った型キャストも行うことができます.

export function n2i8(a: number): i8 {
  return <i8>a; // return a as i8;
}

オブジェクトリテラル

AssemblyScriptにもtypeを使ったエイリアス構文はありますが,オブジェクトリテラルをエイリアスとして扱うことができません.

そのため,次のような構文はコンパイルエラーとなります.

export type Point = {
  x: i32;
  y: i32;
};
ERROR TS1110: Type expected.
   :
 1 │ export type Point = {
   │                     ^
   └─ in assembly/index.ts(1,21)

AssemblyScript上でオブジェクトリテラルを扱いたい場合,コンストラクタを持たないクラスを定義することで,構文中でもオブジェクトリテラルを扱うことができます.

class Point {
  x!: i32;
  y!: i32;
}

export function foo(p: Point): Point {
  return p;
}

export function newPoint(x: i8, y: i8): Point {
  return { x, y };
}
console.log(foo({ x: 1, y: 2 })); // { x: 1, y: 2 }

console.log(newPoint(1, 2)); // { x: 1, y: 2 }

一方で,次のようにコンストラクタを持つクラスを定義した場合,JavaScript側から直接オブジェクトリテラルを渡そうとすると,実行時エラーとなります.

class Point {
  x: i32;
  y: i32;

  // コンストラクタを定義
  constructor(x: i32, y: i32) {
    this.x = x;
    this.y = y;
  }
}
console.log(foo({ x: 1, y: 2 })); // 実行時エラー

コンストラクタを持つクラスの場合,JavaScript側からAssemblyScript上のオブジェクトを作成することができないため,次のようにヘルパー関数を用意する必要があります.

export function newPoint(x: i32, y: i32): Point {
  return new Point(x, y);
}
console.log(newPoint(1, 2)); // { x: 1, y: 2 }

プロパティへのアクセス

AssemblyScriptのコード上は,パブリックな(エクスポートされている)クラスやそのプロパティに対してアクセスすることはできますが,JavaScript側からパブリックなクラスやフィールドに直接アクセスすることはできません.

そのため,JavaScript側からクラスのプロパティなどにアクセスしたい場合は次のようにヘルパー関数をJavaScript側に提供する必要があります.

// exportできない
class Point {
  x: i32;
  y: i32;

  constructor(x: i32, y: i32) {
    this.x = x;
    this.y = y;
  }
}

// コンストラクタのヘルパー関数
export function newPointer(x: i32, y: i32): Point {
  return new Point(x, y);
}

// プロパティのヘルパー関数
export function getX(p: Point): i32 {
  return p.x;
}

v128

本編では紹介しませんでしたが,WebAssemblyには,SIMD用のv128型が用意されています.このv128型は128ビットをnバイトずつ区切って扱うことが出来る型で,i8を16個まとめて計算したり,i32を4個まとめて計算することができます.

export function malloc(size: usize): usize {
  return heap.alloc(size);
}

export function free(ptr: usize): void {
  heap.free(ptr);
}

export function i32x4add(a: usize, b: usize, result: usize): void {
  const v128a = v128.load(a);
  const v128b = v128.load(b);
  const v128c = i32x4.add(v128a, v128b);
  v128.store(result, v128c);
}

このAssemblyScriptを実行するJavaScriptのコードは次のようになります.

import { free, i32x4add, malloc, memory } from "./build/release.js";

const ptr = [malloc(16), malloc(16), malloc(16)];

const a = new Int32Array(memory.buffer, ptr[0], 4);
for (let i = 0; i < a.length; i++) a[i] = i + 1;
console.debug(a);

const b = new Int32Array(memory.buffer, ptr[1], 4);
for (let i = 0; i < b.length; i++) b[i] = i * 10 + 10;
console.debug(b);

const result = new Int32Array(memory.buffer, ptr[2], 4);

i32x4add(...ptr);
console.debug(result);
ptr.forEach(free);

※ WebAssemblyは数値をリトルエンディアンで扱いますが,JavaScript上のInt32Arrayがリトルエンディアンかどうかは実行するプラットフォームに依存するため,より正確に数値を扱うにはDataViewを使用する必要があります.

JavaScriptで定義した関数を利用する

AssemblyScriptには,JavaScriptの関数を利用するための@externalデコレータが用意されています.

// assembly/index.ts
@external("../greeter.js", "sayHello")
declare function sayHello(name: string): void;

export function greet(): void {
  sayHello("asuka");
}
// greeter.js
export function sayHello(name) {
  console.log(`Hello, ${name}!`);
}
// index.js
import { greet } from "./build/release.js";

greet(); // Hello, asuka!

JavaScriptのオブジェクトを扱う

AssemblyScriptのオブジェクトクラスを利用した場合,JavaScriptのオブジェクトは一度AssemblyScriptのオブジェクトに変換されます.一方で,JavaScriptのオブジェクトをAssemblyScriptのオブジェクトに変換せず,JavaScriptのオブジェクトのまま扱いたい場面があるかもしれません.

WebAssemblyには,JavaScriptのオブジェクトをそのまま(= WebAssemblyからは何か分からない状態で)扱うための型としてexternrefが用意されています.

AssemblyScriptにはexternrefが定義されているため,次のようなコードを書くことができます.

// assembly/index.ts
@external("../dom.js", "getElementById")
declare function getElementById(id: string): externref;

@external("../dom.js", "greet")
declare function greet(dom: ref_extern, msg: string): void;

export function main(): void {
  const dom = getElementById("app");
  if (dom) {
    greet(dom, "Hello, World!");
  }
}
// dom.js
export function getElementById(id) {
  return document.getElementById(id);
}

export function greet(dom, msg) {
  dom.appendChild(document.createTextNode(msg));
}
<!-- index.html -->
<html>
  <head>
    <script type="module">
      import { main } from "./build/release.js";
      main();
    </script>
  </head>
  <body>
    <div id="app"></div>
  </body>
</html>

まとめ

この記事では本編で紹介できなかったAssemblyScriptを実際に扱う上でハマりそうなポイントを中心に紹介しました.

本編で「AssemblyScriptはRustからビルドするよりもコンパクトなWebAssemblyをビルドできる」と紹介しましたが,その理由はAssemblyScriptはv128externrefなどのWebAssemblyで用意されている命令をTypeScriptの構文から直接扱える設計になっているためです.

AssemblyScriptはWebAssemblyをコンパイルターゲットにしている言語の中では「アセンブリ言語に対するC言語のような位置付けの言語」です.WebAssemblyに入門してみたい多くのウェブエンジニアの最初の選択肢としてAssemblyScriptを選んでみると面白いかもしれません.

最後に

北海道楽しかった!!

追記

今回,会社より交通費の支援をしていただきました.
ご支援ありがとうございます!!

脚注
  1. LinuxやPHPをブラウザで動かしたり,画像処理を高速化するといった用途 ↩︎

株式会社モニクル

Discussion