ウェブエンジニアでもWasmを使いたい! アフタートーク
フロントエンドカンファレンス北海道 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のインストール方法を紹介していますので,興味があればこちらも参考にしてみてください.
AssemblyScriptの型
AssemblyScriptに登場するi32
やf64
などの型は,number
のエイリアスとして型定義されています.
そのため,次のようなコードは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のi64
はtype i64 = number
として定義されています.
そのため,AssemblyScriptでi64
を扱うと,TypeScriptとしてはnumber
として扱うことができますが,ビルドされたWebAssemblyを扱う際はBigInt
として扱う必要があり,i64
はi32
やf64
などの型と同じようにインポート元を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はv128
やexternref
などのWebAssemblyで用意されている命令をTypeScriptの構文から直接扱える設計になっているためです.
AssemblyScriptはWebAssemblyをコンパイルターゲットにしている言語の中では「アセンブリ言語に対するC言語のような位置付けの言語」です.WebAssemblyに入門してみたい多くのウェブエンジニアの最初の選択肢としてAssemblyScriptを選んでみると面白いかもしれません.
最後に
北海道楽しかった!!
追記
今回,会社より交通費の支援をしていただきました.
ご支援ありがとうございます!!
-
LinuxやPHPをブラウザで動かしたり,画像処理を高速化するといった用途 ↩︎
Discussion