- pslang は、大規模ゲームのモッディング可能性と、C++ コンパイラが生成するアセンブリへの関心から始まり、現在は約 1,000 LOC 規模の Monte-Carlo path tracer を書ける程度に動作している
- モッディング言語には C 相互運用性、低水準の配列・ポインタ処理、容易なサンドボックス化、小さなコンパイラサイズ、高速なコンパイルが必要であり、Lua と C++ ネイティブモードはそれぞれ性能面の接続・サンドボックス化・配布の面で限界がある
- pslang は命令型・即時評価・値呼び出しベースの低水準言語で、静的・厳格・名目的型システム、インデントベースのスコープ、組み込み配列、関数型、ポインタ、保証された メモリレイアウト を提供する
- コンパイラは Bison ベースのパーサー、AST 型検査、IR、インタープリタ、JIT に分かれており、現在の対応対象は Aarch64 Mac のみで、IR 導入後はレジスタ割り当て器がないため生成コード品質はまだ低い
- 現在の実装は約 10,000 行の C++ コードで、今後はレジスタ割り当て器、IR 最適化、IR インタープリタ、実行ファイル生成、デバッグ情報、多相性、モジュール、標準ライブラリといった機能を検討中である
pslang を作ることになった背景
- 約 17年 にわたってプログラミングをしてきた後、おもちゃではなく、ある程度実用を意識した言語を自分で作りたいという欲求が強くなった
- 過去には FALSE のような難解言語のインタープリタや複数のラムダ計算インタープリタを作ったが、「本物の」言語を作りたいという欲求は満たされなかった
- 開発中の 大規模ゲーム がモッディングに適した構造だったため、モッディング方式を考える中でカスタムプログラミング言語が単純な解決策の一つとして浮上した
- 2025年12月に Matt Godbolt の Advent of Compiler Optimisations を見て、C++ コンパイラが生成するアセンブリを追うようになり、再びアセンブリを扱ってみたくなった
- 現在の言語はまだプロダクション品質には程遠いが、約 1,000 LOC 規模の動作する Monte-Carlo path tracer を書けるところまで実装されている
モッディング要件と既存の選択肢の限界
- ゲームは カスタム ECS エンジン で数十万のエンティティをシミュレーションするため、モッディング言語にはコンポーネントポインタの束を受け取り、C の
for ループのように走査できることを求めている
- モードは制御が難しいため、プレイヤー保護のために サンドボックス化 が容易である必要があり、理想的には単一のスイッチですべての IO や類似機能を無効化できるべきである
- モッディングは、特定のフォルダにスクリプトを入れるだけですぐにモードとして使えるくらい簡単であるべきだ
-
Lua と JIT スクリプト言語
- Lua は標準的な選択肢だが、信頼できないコードの前に標準ライブラリの IO 関連関数を削除する前処理コードを付けるようなサンドボックス化が必要に見え、安定した解決策には感じられない
- Lua は高水準の動的型付け言語であり、C ポインタを直接理解できないため、ECS エンティティの走査を接続するには、エンティティごとに native ↔ Lua ↔ native の切り替えが発生するか、ネイティブエンティティを Lua 配列にしてから再び分解する必要がある
- 標準 Lua と LuaJIT は数バージョン前から分岐しており、モッダーと実装者の双方に混乱を与える可能性がある
-
C++ とネイティブモード
- C++ でモードを作ればエンティティ走査の問題は消えるが、バイナリ配布には全プラットフォーム向けの開発環境とバイナリアーティファクトの保管場所が必要になる
- ソースコードで配布するにはゲームに C++ コンパイラを同梱する必要があり、標準的な LLVM インストールでも現在のゲームサイズの 10〜20倍 のディスク容量を占める
- ネイティブ DLL が
int open(); を宣言して使えば、ファイルシステムやネットワークへのアクセスを防ぐのは事実上不可能で、サンドボックス化できない
- Rust のような他のネイティブ言語にも同じ問題が当てはまる
- モッディングは目標の一つだが、実際にこの言語をゲームモッディングに使うかどうかはまだ不確かで、特定の用途に過度に特化したくはない
言語設計の目標
- C 相互運用性 を途切れなく提供し、ネイティブゲームコードとモッディングコードの接続を関数呼び出しのように単純にしたい
- 生のエンティティ配列を扱う必要があるため、低水準 の機能が必要である
- モッダーが妥当な使いやすさでコードを書けるよう、実用的で使いやすいものであるべきだ
- サンドボックス化が容易であるべきで、コンパイラサイズも小さくあるべきだ
- 50MB のゲームに 1GB のコンパイラを入れたくないため、コンパイラのフットプリントを抑えたい
- プレイヤーがモードのコンパイルを長く待たなくて済むよう高速なコンパイルが必要であり、一部は広範なキャッシュで緩和できる
- 真のクロスプラットフォームを望むが、広く使われているデスクトッププラットフォーム数種や 64 ビット、IEEE754 対応といった前提は受け入れる
- ほとんどの動的言語と比べて妥当な速度が出れば十分である
- C++ が長年の主言語だったため言語観に大きな影響を与えているが、できるだけ C++ をそのまま作り直さないようにしたい
pslang の現在の言語モデル
- 仮称はゲームエンジン psemek に由来する pslang で、命令型、即時評価、値呼び出しの低水準言語である
- 型システムは静的・厳格・名目的型システムで構成される
- 基本例では関数、構造体、関数型、配列の返却をあわせて使っている
func min(x: i32, y: i32) -> i32:
return if x < y then x else y
struct vec3i:
x: i32
y: i32
z: i32
func apply(f: i32 -> i32, v: vec3i) -> vec3i:
return vec3i(f(v.x), f(v.y), f(v.z))
func as_array(v: vec3i) -> i32[3]:
return [v.x, v.y, v.z]
スコープと基本型
- インデントベースのスコープを採用し、スクリプト言語のように見せ、初心者にもより親しみやすく感じられるようにしている
- 現在インデントにはタブ文字を使っているが、将来的にはスペースに変わる可能性がある
- 関数、ループ本体、
if 本体などは新しいスコープを作り、関数と構造体はどのスコープ内でも定義でき、そのスコープ内でのみ見える
- ローカル関数は定義されたスコープの変数にアクセスできないためクロージャではなく、スコープは名前解決にのみ影響する
- トップレベルスコープは他のスコープと同様に扱われ、ファイルがロードまたは初期化される際に実行されるエントリポイントを含む
- 基本型は
bool、符号付き整数 4 種、符号なし整数 4 種、浮動小数点数 3 種、unit の合計 13個 である
i8 i16 i32 i64
u8 u16 u32 u64
f16 f32 f64
f8 は大半のデスクトップ CPU でサポートされておらず、8 ビット浮動小数点の意味についても合意がないため含めていない
f16 は一般ユーザーにはやや有用性が低いが、HDR カラーや頂点属性などグラフィックスで頻繁に使われ、最新のデスクトップ CPU の大半が IEEE754 f16 を実装しているため標準でサポートする
- すべての整数演算はオーバーフローを伴う 2 の補数方式で、未定義動作はない
unit は単一の値 unit() のみを持ち、戻り値のない関数の正式な戻り値型である
- 戻り値型を省略した関数は自動的に
unit を返し、そのような関数の末尾で return を省略すると自動挿入される
unit 関数でないのに値を返さなければエラーになる
リテラル、配列、関数型、ポインタ
- 数値
10 はデフォルトで i32 であり、10b、10s、10l のような接尾辞でサイズを指定する
- 符号なしリテラルには
u 接尾辞を付け、10ub、10us、10u、10ul のように書く
- 小数点を含む浮動小数点リテラルはデフォルトで
f32 であり、10.0h は16ビット、10.0d は64ビットである
10. や .5 のように整数部や小数部を省略することはできず、10.0、0.5 のように完全な形で書く必要がある
- すべての数値リテラルは曖昧でない型を持つ
- 配列は組み込みの第一級型であり、C/C++ と異なり配列全体を関数に渡したり返したり、相互に代入したりできる
- 配列サイズは常にコンパイル時に判明しており、同じ型のフィールドを複数持つ構造体のように動作する
- 配列型は
i32[5]、配列リテラルは [1, 2, 3, 4, 5] のように記述する
- 関数型は C の関数ポインタに近く、
(a, b, c) -> d 形式で書き、引数が1つなら a -> b のように括弧を省略できる
- 内部的に関数型はデータが一緒に渡されない通常の関数ポインタであり、クロージャではない
- ポインタ型は
i32* のように書き、デフォルトでは不変ポインタで、可変ポインタは i32 mut* として宣言する
- 変数のアドレスは
&x、可変ポインタは &mut x、デリファレンスは *p、ポインタ演算は *(p + 10) のように使う
構造体、メモリレイアウト、空型
- 構造体は
struct キーワードとフィールド一覧で宣言する
struct string_view:
size: u64
data: u8*
- 構造体は
string_view(10, data) のような組み込みの関数風コンストラクタで作成し、フィールドには v.x のようにドットでアクセスする
- 構造体ポインタでも同じドット構文でフィールドにアクセスできる
- 構造体フィールドには個別の可変性指定子はなく、可変オブジェクトのフィールドは可変で、不変オブジェクトのフィールドは不変である
- アクセス指定子はなく、フィールドは常に public である
- すべてのオブジェクトは保証されたメモリレイアウトを持ち、基本型はサイズと同じアラインメントを持ち、
bool は1バイトである
- ポインタ型と関数型は常に64ビットで、同じアラインメントを持つ
- 配列は要素と同じアラインメントを持ち、構造体はアラインメント要件を満たすためのパディングを持つ
- この保証は主に C 相互運用性と GPU プログラミング用途を単純化するためのものである
unit とフィールドを持たない構造体は、有効な値を1つだけ持つ 空型 として扱われ、実際のサイズは0バイトである
- 空型を関数に渡したり変数として宣言したりフィールドに入れたりしても、メモリ使用量や構造体サイズには影響しない
- 空型は型レベルのコンパイル時タグのような用途に使える
- 空型ポインタ経由の読み書きはまだ未決定で、現在はそのような型のポインタ演算は不正である
- C++ のように各オブジェクトが固有のメモリアドレスを持つという規則には従わない
変数、関数、制御フロー、外部関数
- 不変変数は
let x = 10、可変変数は mut x = 20 のように宣言する
- 不変変数に対する可変ポインタは作れない
let x: i32 = 10 のように型を明示できるが、すべての式の型を曖昧さなく推論できるよう設計されているため必須ではない
- すべての変数は必ず初期化しなければならない
- 関数は
func foo(x: A, y: B) -> C: の後に本体を書く形式で、戻り値型を省略すると unit になる
- すべての関数は実行プラットフォームのネイティブ C ABI に従い、C 相互運用性やコールバック、ECS システムなどへ関数ポインタとして渡すための決定である
- 同じスコープ内では関数と構造体の宣言順は自由で、後で宣言された関数や構造体を先に使うことができる
- すべての関数引数と戻り値型は完全に明示する必要があるため、宣言順の自由化が型推論を複雑にすることはない
if/else if/else 文と while ループがあり、for ループはまだない
- 式形式の
if は if A then B else C のように使う
- 外部関数は
foreign func sin(x: f64) -> f64 のように宣言し、実装は別の場所でリンクされる必要がある
- 現在のインタプリタは、そのような関数をインタプリタ実行ファイル自身から
dlsym で探す
- 外部関数は C ライブラリおよびサードパーティライブラリとの相互運用の主要メカニズムであり、raytracer の例では平方根計算、ファイル書き込み、時間計測、スレッド生成にこの機能を使っている
型キャストと演算子
- 暗黙の型キャストは一切なく、明示的なキャストは
(x as f32) のように as 演算子を使う
- すべての数値型は相互にキャストでき、すべてのポインタ型同士もキャストできるが、不変ポインタを可変ポインタに変えることは除かれる
- ポインタ型は
u64 に、u64 はポインタ型にキャストできる
bool はどの型ともキャストできない
T mut* から T* への暗黙キャストを1つ追加するかどうかを検討中である
- 算術、論理、比較などの標準演算子はおおむね提供される
&、|、&&、|| はブール値と整数の両方で動作し、& と | は両側の被演算子を常に評価し、&& と || は短絡評価を行う
- 算術と比較は同じ数値型の組み合わせに対してのみ動作し、数値型昇格はない
- 現時点では言語機能が多くないように見えるが、すでに実用的なプログラムをある程度快適に書ける
コンパイラ構成
- プロジェクトはいくつかのライブラリに分かれている
types: 型システム定義
ast: 抽象構文木の定義とユーティリティ
parser: パーサ
ir: 中間表現
interpreter: インタプリタ
jit: JIT コンパイラ
- インタプリタとコンパイラは、これらのライブラリを使う単純な CLI アプリにする構想で、現在は JIT モードのインタプリタしかない
- 言語を埋め込むには
parser と jit ライブラリを使えばよい
パーサとインデント処理
- パーサジェネレータとして Bison を使用している
- トークンは lexer grammar、言語文法は parser grammar に定義されている
- ファイルは文の一覧であり、文は関数宣言、制御フロー演算子、変数宣言、式などになり得て、式はリテラル、変数、演算子、関数呼び出しなどになり得る
- 文法では shift/reduce 衝突を何度か修正する必要があり、Bison の
-Wcounterexamples フラグで衝突を引き起こす正確な状況を確認した
lalr1.cc Bison スケルトンを使って C++ パーサクラスを生成している
- 標準の Bison はパーサ状態をグローバル変数として持つ C パーサを生成するが、インタプリタやゲームモードのように複数ファイルを並列にパースできる必要がある場合には適していない
- Bison の実行は CMake scripts のビルド段階に組み込んでいる
- パーサの出力は、パースされたファイルの AST を表す C++ オブジェクトである
- インデントのため、文法は実際には文脈自由ではなく、ある文が
while 本文に属するかどうかは前にあるインデントトークンの数に依存する
- 解決策として、各行を独立した文とインデントレベルとしてパースした後、単純な線形パス でインデントレベルを見てスコープを確定する
- この方式はハッキーではあるが、動作し、非常に高速なので受け入れている
- 同じパスで、
break と continue はループ内にのみ、return は関数内にのみ、フィールド定義は構造体内にのみ現れるよう検査する
型チェックとインタプリタ
- パース後の最初のパスでは、すべての識別子を解決し、識別子ノードを対応する変数・関数・構造体定義ノードに直接結び付ける
- 次の中核パスでは、すべての型を検査して推論する
- 型推論はおおむね単純で、特定のASTノード型に応じた条件チェックで構成される
- たとえば
if や while 内の式の型は bool でなければならず、加算の2つの被演算子は同じ数値型であるか、片方が整数でもう片方がポインタでなければならない
- 初期のインタプリタはASTノードを直接たどってC++の構文を実行するツリーウォーキングインタプリタである
- 主な関数は
exec() と eval() で、exec() は単一の文を実行し、eval() は単一の式の値を計算して返す
- C++ は静的型付けなので、
eval() は言語内の取り得るすべての値型に対応する variant を返す
- 構造体は各フィールドごとに1つの名前と値のペアの配列として表現され、変数値の保存にも同じ
variant を使う
- インタプリタの目的は、言語コードをクロスプラットフォームで実行し、実装とプログラムのデバッグを助けることであり、高速化を目的としたものではない
- 現在のインタプリタはかなり壊れた状態なので、IRベースで全面的に書き直す予定である
- 既存のインタプリタは
foreign 関数を実行できない
foreign 関数はCの呼び出し規約で呼び出す必要があり、引数の数や型を事前に把握できないため、varargの手法やlibffiが必要になる可能性がある
- インタプリタは内部状態、つまり変数の名前・型・値をstdoutにダンプでき、これはまともなコンパイラを作る前にパーサやインタプリタをデバッグする主な方法だった
最初のAarch64 JITコンパイラ
- 2026年1月初めの休暇中、手元にM1 Macしかなかったため、最初のコンパイラの対象アーキテクチャはAarch64 Macになった
- 現在サポートしているアーキテクチャもこれだけである
- コンパイラはJIT方式で、結果は実行可能ビット付きでマップされたメモリブロブと、各関数の開始位置へのポインタである
- 高水準の構造はほぼ伝統的なスタックベースコンパイラに近いが、式の結果は、Aarch64 Macの標準C呼び出し規約であるAAPCS64において、同じ戻り値型の関数が値を置くのと同じ方法で配置する
- 整数とポインタは汎用レジスタ
x0、浮動小数点は浮動小数点レジスタ v0 に返され、構造体はサイズに応じてレジスタまたはスタックで返される
- この方式はメモリアクセス回数を減らせるため、生成コードがより高速になり、関数呼び出しも単純になる
- スタックは主に二項演算のような中間結果に使われる
(eval A) # the value of A is in x0
push x0 # the value of A is on stack top
(eval B) # the value of B is in x0
pop x1 # the value of A is in x1
add x0, x0, x1 # the value of A+B is in x0
- 制御フロー構造は条件付きジャンプに変換されるが、単一パスコンパイルでは
if や while の本体をまだコンパイルしていないため、ジャンプ先が分からない
- これを解決するため、まずオフセット0のジャンプ命令を出力し、対象オフセットが分かったあとで実際のジャンプオフセットを書き込む
- 関数呼び出しにも同じ方式を適用する
- 対象CPU命令の生成にはサードパーティライブラリを使わず、コンパイラを小さく保つために自前で実装した
- 実装はinstruction manualを調べながら必要なビットを書き込む方式だった
Aarch64で厄介だった点
- Aarch64のすべての命令は32ビットなので扱いやすそうに見えるが、32ビット定数をレジスタに入れるにはレジスタ選択ビット、命令ビット、定数ビットがすべて必要で、単一の32ビット命令には収まらない
- 64ビット定数はさらに厄介である
- 定数は16ビット片をオフセット0、16、32、48ビット位置にロードする命令群で組み立てるか、定数メモリに置いてそこからロードする必要がある
- 浮動小数点定数は定数メモリからロードする方式を使っている
- x86と違ってpush/pop命令はなく、レジスタとメモリアドレスの間で読み書きしつつアドレスレジスタを調整する命令を組み合わせる必要がある
- すべての命令が厳密に32ビットなので、オフセットがsignedなのかunsignedなのか、特定の定数であらかじめ乗算されるのか、アドレスレジスタを変更するのかなどを常に意識しなければならない
- SPレジスタ基準でスタックを読み書きするとき、スタックポインタは常に16バイト境界にアラインされていなければならない
- 利用可能なオフセットは12ビットに制限されているため、スタックフレームがだいたい16KBを超えると特別なコードが必要になるが、まだ実装されていない
- 呼び出し規約には、構造体が最大2個の汎用レジスタ、浮動小数点レジスタ、あるいはメモリポインタを介して受け渡し・返却される特別なケースがあり、コンパイラコード側でこれを扱わなければならない
IR導入と2つ目のコンパイラ
- 基本的なインタプリタとコンパイラを作ったあと、コード再利用、他アーキテクチャ向けコンパイラ作成の簡略化、最適化のために中間表現(IR)を導入した
- IRはSSAに似た形で始まったが、同じノードに値を再代入でき、phiノードも使わないため、実際にはSSAではない
- IRはnodesのシーケンスであり、各ノードはリテラル、入力ノードを持つ演算、条件付き・無条件ジャンプ、関数呼び出しなどを表す
- 値を表すノードは、その値の型も保持する
- 再代入を許すため、既存ノードの値を再代入する
assign IR命令がある
- 条件付きジャンプは
jump_if_zero と jump_if_nonzero に分かれており、これは通常それぞれ異なるCPU命令に対応し、値を否定して逆の命令を使うより高速である
- 関数ポインタをサポートしているため、既知のIRノードを呼び出す命令と、不明なポインタ値を呼び出す命令が別々にある
- 最適化で任意の位置にノードを削除・挿入しやすくするため、ノードは
std::list に保存し、参照にはリストイテレータを使う
- 構造体値リテラルは作れないため、構造体値を表す
alloc ノードを置き、通常はスタック上に未初期化の構造体領域を割り当てる形でコンパイルする
- 構造体は個別のフィールドへ代入して構築される
- ネストした構造体フィールド
a.x.y を単純に表現すると、a.x を新しいノードとして読み、そのノードの y を読むことになって無駄が大きい
a.x.y = b も t = a.x、t.y = b、a.x = t のように表現すると非効率なので、IRではネストしたフィールドを特別扱いしている
copy ノードは構造体から任意のネストしたフィールドを取り出せて、assign ノードは構造体の任意のネストしたフィールドへ代入できる
- ネストしたフィールドは、「0番目のフィールドを取り、その中の2番目のフィールドを取り、その中の5番目のフィールドを取る」のようなインデックス配列で表現される
- その後、Aarch64コンパイラはAST → IRコンパイラと、IR → Aarch64コンパイラに分けて書き直した
- AST → IRは比較的単純だが、IR → Aarch64コンパイラは現在、以前のスタックベースコンパイラよりはるかに悪い状態である
- 関数開始時にその関数のすべてのIRノードに必要なだけのスタック領域を割り当てるため、たいてい短命な中間値まで含めてすべてがスタックフレームを占有してしまう
- raytracerのある関数は、前述の12ビット制限内にスタックフレームを収めるために2つに分割しなければならなかった
- このコンパイラはレジスタ割り当て器を使うことを前提としているため、今後は生成コードが桁違いに改善されると期待している
コンパイラとインタプリタの計画
- 現在の実装は約10,000行のC++コードで構成されており、現代の基準でもコンパイラが小さく、実際に動作している点に満足している
-
レジスタ割り当て器
- 現在の IR → Aarch64 コンパイラには、レジスタ割り当て器がどうしても必要
- コンパイル速度とコード品質の折衷として、標準的な線形スキャン割り当て器を使う予定
-
IR 最適化
- IR を基盤に、定数伝播、算術簡約、デッドコード削除、インライン化、ループ展開を追加したい
- GCC や LLVM に勝つことが目標ではないが、3D ベクトル加算のような単純な関数が、可能な限り少ない CPU 命令でコンパイルされてほしい
-
IR インタプリタ
- インタプリタを IR 直接評価方式で書き直す予定で、そうすればインタプリタはかなり単純になると見ている
-
実行ファイル生成
- 現在のコンパイラは、即時実行用の JIT メモリ blob しか生成しない
- プラットフォームごとの形式で実行可能バイナリも作りたいと考えており、ELF、Mach-O、PE のようなバイナリ形式の仕様を掘り下げる必要がある
- できるだけ小さな実行ファイルを作ってみるのも目標の一つ
-
デバッグ
- JIT が生成したアセンブリを lldb でかなり追ってきており、言語そのものをきちんとデバッグできるようにしたい
- そのためには DWARF デバッグ情報フォーマットのサポートが必要になる可能性が高いが、現時点ではそれについてほとんど分かっていない
追加したい言語機能
-
構造体コンストラクタ
- 現在、構造体は
vec3i(1, 2, 3) のように全フィールドを設定するか、vec3i() のように 0 初期化する方法しかない
- 構造体名と同じ名前の関数を宣言すると任意のコンストラクタとして動作させる方式を検討している
func vec3i(x: i32, y: i32) -> vec3i:
return vec3i(x, y, 0)
- ただし、そのような関数には固有の名前を付けたほうがよいかもしれず、まだ確定していない
-
グローバル変数
- 現在、グローバル変数はサポートしていない
global キーワードでグローバル変数を作る予定で、アクセスは引き続きスコープ規則の制限を受けるため、C の static 変数のような関数ローカルなグローバル変数を作れる
- トップレベル変数は、
global を使わない限り本当のグローバルではなく、ファイルのエントリポイント関数のローカル変数になる
- この構造はユーザーに混乱を与えるかもしれず、別の選択肢も検討中
- Mac は書き込み可能かつ実行可能なメモリマッピングを同時に許可しないため、グローバル変数はコードとは別に確保し、別のフラグでマップする必要があるかもしれない
- グローバルアクセスは、コンパイル時に分かっているオフセットではなく、実行時に解決されたアドレスで行う必要があるかもしれない
- ただし
mprotect() でマッピングの一部のフラグを変更できそうなので、まずはそれを試す予定
-
メソッド呼び出し構文
- 可読性のため、
x.f(y) が可能な場合は f(&x, y) または f(&mut x, y) を意味するようにしたい
-
多相性
- 最も重要な潜在機能だと見ている
- 有力な選択肢は、C++ スタイルの関数オーバーロードと制限なしの関数テンプレート・構造体テンプレート、あるいは Haskell/Rust スタイルの明示的な trait と、trait 制約付きジェネリック関数・構造体
- C++ スタイルはより強力で、単純な場合には読みやすく、コンパイラ実装も容易だが、エラーメッセージが非常に難解になりうる
- 明示的な trait は場合によっては読みやすく、エラーメッセージの問題も解決するが、trait と trait bound という新しいシステムが必要になるため、コンパイラ実装はより難しい
- まだ決めていないが、C++ をもう一度作りたくなかったにもかかわらず、かなり強く前者に傾いている
struct vec2<t: type>:
x: t
y: t
func min<t: type>(x: t, y: t) -> t:
return if x < y then x else y
- 可能なら関数引数推論も欲しい
-
演算子オーバーロード
- どの形であれ多相性が必要
a + b が add(a, b) のようなオーバーロード関数、あるいは Add::add のような trait メソッドを呼ぶ形になるかもしれない
-
for ループ
while で代用できるので、for は C++ の range-based loop や Python のループのようなコレクションベースのループとして使う予定
- そのためには range/iterator インターフェースが必要で、やはり多相性が必要
-
自動リソース管理
- 実用的で使いやすい言語には、メモリ、ファイル、ソケット、ミューテックスのようなリソース解放を助ける方法が必要だと考えている
- 候補は C++ スタイルの RAII と move、Zig スタイルの
defer、線形型
- RAII は暗黙的なので、隠れた命令と制御フローを追加してしまう欠点がある
defer は明示的だが、毎回自分で書かなければならず、書き忘れを防げないうえ、ファイル配列のような入れ子のコレクションを解放する際に不便
defer free(array)
defer for file in array:
close(file)
- 線形型は、
free や close を手動で呼ぶ明示性を保ちながら、リソース解放関数によってオブジェクトが消費されることを強制できるので有望
- しかし、動的なファイル配列のような入れ子コレクションと組み合わせにくいため、まだ決めていない
-
多相リテラル
- 空配列
[] はサイズ 0 であることは分かっても、要素型を推論できない
null は任意のポインタ型になりえ、追加したい inf リテラルは任意の浮動小数点型になりうる
- 解決策として、Haskell 式の多相リテラル、C++ の
nullptr_t のような特殊な組み込み・ライブラリ型と暗黙変換、AST の特殊リテラルとアドホックなコンパイラ処理の3つを考えている
- 現時点では
null を、明示的な型付き変数の初期化や関数引数の受け渡しのように、期待されるポインタ型が分かっている場所でのみ許可する最後の方式に傾いている
- この方式は最も単純だが拡張性がなく、
null からカスタム型を作ることはできない
-
コンパイル時評価
const キーワードでコンパイル時変数を宣言し、配列サイズのようなコンパイル時式で使えるようにしたい
const 値は再代入できず、アドレスを取ることもできない
- 適切な関数は、グローバル変数アクセスや副作用がない場合、コンパイル時式の中で呼び出せるようにできる
- 関数本体は通常の関数のように動作するが、コンパイル中に実行され、その結果がコンパイル時式になる
- 数学関数やメモリ割り当てのように、コンパイル時に呼んでも安全な
foreign 関数を示す仕組みが必要
-
型計算
- メタプログラミングのために、型に対する計算をサポートしたい
- 静的型付き言語でランタイム型エンコーディングを作りたくはなく、ランタイム型の有用性も限られるため、コンパイル時専用として計画している
- C++ concepts に近い機能も、別個の構文なしにコンパイル時呼び出しで実装できると見ている
func comparable(t: type) -> bool:
// Implemented somehow...
func min<t: comparable type>(x: t, y: t) -> t:
return if x < y then x else y
-
コルーチン
- Python や JS スタイルの
async/await 追加は、計画というより希望に近い
ライブラリとモジュール計画
-
モジュール
- すべてのコードを1つのファイルに書くのは無理があるため、モジュールが必要
import lib.sublib のような単純な文を想定しており、コードのどこにでも置けて、スコープ規則にも従う
- スコープは可視性にのみ影響し、実際のロードはコンパイル時に行われ、インポートされたモジュールのエントリポイントは現在のモジュールより先に実行される
- ライブラリ名は、コンパイラまたはインタプリタに指定したルートパス基準のファイルシステムパスと直接対応する
- 単一のソースファイルならそのファイルだけを取り込み、ディレクトリならそのディレクトリ内のすべてのファイルを何らかの順序で取り込む
- 同じディレクトリのファイルを指す構文が必要で、
import .another のような形を検討している
- 取り込んだ関数とグローバル変数は接頭辞なしで使え、曖昧な場合は
io.print(x) のようにライブラリ名の接頭辞を付けられる
- モジュールのエントリポイントは、import順序と再帰的importのトポロジカルソートに基づく決定的な順序で実行される予定で、CやC++の初期化順序問題を解決できる
- 複数モジュールのプログラムにおけるメモリ配置はまだ決まっていない
- モジュールごとに別個のメモリパッチを置き、関数呼び出しやグローバル変数アクセスをランタイムに解決することもできるし、1つの大きなメモリマッピングを作って相対オフセットを使うこともできる
- 1つの大きなマッピングはランタイムではより高速かもしれないが、複数モジュールの並列コンパイルを難しくする
-
Prelude
- モジュールが導入されれば、基本ユーティリティをすべてのプログラムに暗黙的に含まれる prelude モジュールに入れられる
- 組み込み配列用の
length() 関数、iterator インターフェース、string view 型、Python の range(n) のような数値 range などが候補
-
文字列リテラル
- 文字列リテラルはまだなく、どのような意味を持たせるべきか決められていない
- 計画としては、prelude に不変の
string_view 型を置き、文字列の内容は実行可能メモリのどこかに配置し、リテラル自体はそのメモリを指す string_view に変換するというもの
-
標準ライブラリ
- モジュールが導入されれば、標準ライブラリも必要になる
- 含めたい範囲は、ベクトルと行列を含む数学ライブラリ、
libc からリンクした alloc/free 形式のメモリ管理、動的配列、動的文字列とフォーマット、ハッシュテーブル、コンソールとファイルI/O、ファイルシステムヘルパー、時間・時計ヘルパー、ネットワーキング
現在の優先順位
- 計画した機能をいつ実装するか、この言語を実際にゲームモッディングや他の用途に使うかはまだ決まっていない
- 野心的なプロジェクトを同時に複数まじめに進めるのはよくないと考えており、現在の優先順位は依然としてゲーム開発
- ゲームが作られる前にそのゲームをモッディングすることはできないため、言語作業はやりたくなったときに進めている状態
1件のコメント
Lobste.rsの意見
ここのコメントは、このコミュニティに期待していたよりずっと辛辣に感じる
Lua のような別の言語で十分だった可能性はある。筆者が巨大な yak shaving にハマっていた可能性もある
それでも、腕が立っていてとても楽しんでいるのは明らかだし、記事の中には興味深い技術的内容もある
ゲームエンジン向けスクリプト言語をまたひとつ設計する同類のナードの文章なら、喜んで楽しく読む。vibecoding で作られた SaaS のゴミが世界を救い、筆者を金持ちにするという AI 生成の駄文をひとつ避けられるなら、こういう記事は1日に千本でも読める
「Lua や他の JIT コンパイル型スクリプト言語は標準的な選択肢だが、サンドボックス化が本当に難しい」というのは、正直かなり理解しがたい主張だ
Lua のサンドボックス化は容易だという点は、最大の長所のひとつで、Mod やプラグイン以外でも大きな利点がある。私が見たどの言語も、これには近づけていない
Lua のバージョン問題にはある程度もっともな点はあるが、実際に人々がそこまで激しく腹を立てているのはあまり見たことがない。「現代的な」Lua を何かに使っていて、別の作業のために 5.1/5.2 へ下げなければならない場合でもなければ、たいていはどちらか一方しか使わないように見える
「自分の言語を作りたい」を正当化するために調査した感じが強い。それ自体は構わないが、既存の選択肢について完全に誤った主張をするくらいなら、正直なほうがいい
仮想マシン設計や、より低レベルな部分に興味があるなら、記事で説明されているやり方ももちろんありだ。だが 言語設計 を学ぶ最良の方法とは言いがたい
いちばん簡単な例は バイトコード脱出 だ。存在を知っていれば無効化できるが、こうしたことが繰り返し起きるという事実は、より広い問題を示している。Lua 仕様の離れた部分同士がどう相互作用するかを理解してサンドボックス規則を組み立てる必要があり、どんな追加の相互作用を許すのかが明確な基本要素で安全にプログラムを合成できるような構造にはなっていない
さらにこじつけ気味の例としては、同じ Lua VM 内の異なる環境間で起きるプロトタイプ汚染がある。Redis では string の metatable を汚染できて、すると Lua 機能を使う別のデータベース利用者の権限でコードを実行できた。Lua は JavaScript のようなものよりプロトタイプ汚染の攻撃面が天文学的に小さいが、グローバルなプロトタイプがだいたい2つしかないのに、そのうちのひとつで同じことができるのは笑ってしまう
それでも Luau はこの問題にかなり有能な解決策を持っており、筆者が新しいサンドボックスを作れば、なぜ同じ問題群を暗黙にすべて回避できると考えているのかは、よく分からない
「自分のゲームはシミュレーション比重が非常に高い。カスタム ECS エンジンで数十万個のエンティティをシミュレートしている。理想的には、Mod 用言語が複数のコンポーネントポインタを受け取り、C の for ループのように走査できるとよい」という部分には、もっと良い理想がありうる
特に Unity、Unreal、Blender、Godot のようなレンダリングエンジンがこの問題をどう扱っているかを比較してみる価値がある。外部反復は毎秒メガピクセル級の話をするには十分速くなく、毎秒数十万エンティティにも合わないかもしれない。ここでは 並列性 を考えるべきだ
大規模エンジンはどれも GPU フレンドリーで、たいていは驚くほど並列化しやすい、分岐のないアルゴリズムのデータフロー記述を使っている。筆者がビジュアルエディタを嫌うことはありうるし、そういう考えも珍しくないが、だからといって for ループが答えだということにはならない
もし筆者が ECS は本質的に関係モデル的なパラダイムで、比較対象にすべき歴史的負債の大きい言語は SQL だと言っていたなら、もう少し寛容に見られたかもしれない