
2026/05/07 22:01
独自のプログラミング言語を作成するのは、思っているほど簡単ではあっても、同時に難しい面もあります。
RSS: https://news.ycombinator.com/rss
要約▶
日本語翻訳:
まとめ:
著者は、2025 年 12 月中旬に作成された独自の imperative ランゲージpslangを開発中であり、これはモディファイ可能なゲームエンジンpsemekと対をなすように設計されています。パフォーマンスが極めて重要なシミュレーション向けに、C との円滑な相互運用性、小さいフットプリント、高速なコンパイルを優先しています。現在、このプロジェクトには約 1,000 ライン(LOC)のコードが含まれており、機能的なモンテカルロ・パストレーサーを実装していますが、開発はコミュニティからのフィードバックを待つために一時停止されています。
pslangは、厳密なノミナル型付けとタブを用いたインデントベースのスコーピングを持つイージー評価された呼び出し値(call-by-value)構文を採用しています。また、13 の基本型(
i8–u64, f16–f64 など)、コンパイル時に既知のファーストクラス配列、明示的でない型キャストのない独自のポインタ構文 (mut*) をサポートします。構造体は可変性の指定子なしで公開フィールドを持ちます。コンパイラーアーキテクチャはモジュール化されており、解析(Bison/LALR(1) を使用)、IR 生成、実行を別のライブラリに分離しています。バージョン 1 は Aarch64 アーキテクチャ向けの JIT コンパイラーとして機能し、レジスタアロケータを経由せずに AAPCS64 を通じて直接メモリブロブを生成します。
今後のイテレーションでは、レジスタアロケータの導入、静的実行ファイル生成、そしてメソッド呼び出し構文 (
x.f(y))、関数オーバーロード/テンプレート、const 評価、コルーティン、演算子オーバーロードといった言語機能の大幅な拡張を目指しています。ロードマップには、定数伝播やデッドコード除去などの最適化も含まれています。究極的には、この取り組みは、低レベル制御と現代の利便性をバランスさせるための可行的なモデルを示しており、非常にモディファイ可能なゲームシミュレーションを支えることを目指しています。本文
自分のプログラミング言語を作ってみたくなるか、あるいは実際にしてしまったことがありましたか?この記事は、自作のプログラミング言語開発の冒険について綴られています。
自作プログラミング言語:思っているほど簡単ですが、同時に大変でした (2026 年 5 月 6 日)
昨年の中頃(12 月)、自分のプログラミング言語を作ることに挑戦しました。まだプロダクション品質に程遠いのですが(それでも Monte Carlo レイトレーサーの動作する 1,000 行程度のコードを書くことができました)、プロジェクトは一旦中断したため、それについて書くのが良い時だと思い立ちました。
免責事項 #1: 私は専門のプログラミング言語 (PL) デザイナーやコンパイラ実装者ではありません。この記事で自分のことについて話せると思っていても、たまに間違ったことを言っているかもしれません。
免責事項 #2: これは C/C++/Rust などのキラーランゲージでもありませんし、実際に広く使われるようなものになる見込みも皆無です。ただ楽しんで自分と話すだけです。
免責事項 #3: プログラミング言語に関する強い意見をお持ちの方は、私があなたにその言語を使うよう強制しているわけではなく、ネット上の素人に対しても何をするべきかを説くのは失礼であることを念頭に置いてください。一方で、建設的なフィードバックや提案をお待ちしています!
目次
- はじめに
- なぜ今なのか?
- モーディング (Modding)
- デザイン目標
- 言語について
- コンパイラのアーキテクチャ
- 今後の計画
はじめに
多くのプログラマは、自分だけの完璧なプログラミング言語を作りたいと夢見ています。私がプログラムを始めてから約 17 年が過ぎたので、なぜこのタイミングで言語を作ることにしたのか?それは、私の思考の中で 3 つの異なる要素が収束したからです。
もちろん、自作言語への憧れは昔からありました。異種言語やラムダ計算などの変種のインタプリタをいくつか作ったこともありますが(FALSE は特に気に入っています)、それでも本気で実用向きで玩具のように感じない「真の」言語を作る欲求には手が届きませんでした。
また、私はモディファイble な大規模なゲームの開発にも携わっており、このプロジェクト開始からずっとモディングへのアプローチを考えていました。多数のアプローチを検討した結果、「カスタムプログラミング言語の作成」が実は最もシンプルな解決策の一つであることを発見しました。
さらに、2025 年 12 月に Matt Godbolt 氏が素晴らしい Advent of Compiler Optimisations シリーズを開始しました。C++ コンパイラの生成するアセンブリを解説しながら面白そうな例を発表してくれました。優れたシリーズでありながら、私が再びアセンブリを触ってみたくなりました。
非玩具的なプログラミング言語の作成は膨大な事業に見えますが、数週間アセンブリを見て回った後、それほど大変なものではないと感じるようになりました。
モーディング (Modding)
モッキング(ここでは「ゲームの改変/拡張」)について詳しく説明します。私が主に気にしていることは以下の 3 点です。
- 私のゲームはシミュレーションが非常に重いものです。数千というカスタム ECS エンジンによってエンティティがシミュレートされています。理想的には、モーディング言語でコンポーネントのポインタをまとめて受け取り、C の for ループのようにそれらを反走できるものが望ましいです。
- モーディフィケーション内での動きを制御するのは難しく、プレイヤーへの一定の保護レベルがあると便利です。理想的には、モーディング言語は簡単にサンドボックス化できると良いです(つまり、単一のスイッチですべての I/O や同様の機能を無効化できるように)。
- モーディングをできるだけ容易にしたいものです。理想的にはスクリプトを特定のフォルダに投げるだけで、それがそのままモッドとして使えるようになることです。
これらの要件を満たすソリューションが存在しないことに少し驚きました。一般的な可能性を見ていきましょう。
Lua(または他の JIT コンパイル可能なスクリプト言語) これは標準的な選択肢ですが、実際にはサンドボックス化が極めて難しいことが分かりました。不信任される Lua コードを実行する前に、I/O などに使える既知の標準ライブラリ関数を明示的に削除するプリリュードを先頭に追加する必要があります。これらの関数のリストは GitHub gist などの形式でオンライン上に存在しています。これが機能するとしても、信頼できるソリューションとは思えません。
さらに、Lua は高級動的型付け言語であり、C ポインタに関する知識を持っていません。ECS エンティティの反走を Lua にブリッジするには、各エンティティごとにネイティブ ↔️ Lua ↔️ ネイティブの跳躍(非ゼロのオーバーヘッド)が発生するか、またはネイティブエンティティから Lua アレイを構築して再び分解するという手段しかありません。どちらの場合も良好とはいえません。
加えて、標準 Lua と LuaJIT はバージョン数年前に既に分岐しており、モーダでも私自身でも極端に混乱することがあります。
C++ モッドを「ネイティブ」で作る選択肢もあります。全ての反走問題は解消されますが、モッドの配布は地獄です。バイナリとして配布する場合、私はすべてのプラットフォームのために開発環境を提供し、バイナリアーティファクト用の中央集権的なストレージを設けなければなりません。ソースコードとして配布する場合は、ゲームに C++ コンパイラ(LLVM インストールなど)をバンドルしなければならず、これらは重量級で遅いことが知られています(基本的な LLVM インストールは現在のゲーム版よりも 10〜20 倍のディスクスペースを取ります)。
サンドボックス化も不可能になります。
int open(); を宣言・使用しているネイティブ DLL をロードすれば、ファイルシステムやネットワークにアクセスするのを防ぐ方法が基本的にないため、「死」と言うかたちです。
もちろん、Rust など他の言語にもこれ全て当てはまります。
念のためですが、モッキングを言語の目標の一つとしていますが、実際にこのように使うのかどうかはまだ確信がない上に、そのユースケースのために言語を過剰に特殊化したくないと考えています。私は基本的に楽しんで遊んでいるだけなので。
デザイン目標
私のプログラミング言語にどのようなことを望むか?実は非常に多いのですが:
- シームレスな C 連携: ネイティブゲームコードとモーディングコードの間でのブリッジが関数呼び出しと同じくらいシンプルであること
- 低水準: ネイティブエンティティの生配列を扱う必要があるため、ほぼ必然的な要件です
- 実用的でエルゴニック: モーダが合理的な容易さでコードを書けるようにしたい
- 簡単なサンドボックス化: 前述の理由により
- 小さいコンパイラフットプリント: 50MB のゲームに 1GB のコンパイラを埋め込みたくない
- 高速コンパイル: プレイヤーにモッドコンパイルで数時間待たせたくない(ただし、徹底的なキャッシングによって部分的に解決可能)
- 真のクロスプラットフォーム: 限られた数の一般的なデスクトッププラットフォームのみを支援し、特定の仮定(64 ビットまたは IEEE754 サポートなど)を行うことに満足できる
- 比較的速い: 多くの動的言語に比べて比較的低い基準ですが
- C++ の単なる再創造を避ける: 私が何年にもわたり最も好きな言語でも主要言語としていた C++ は、私のプログラミング言語観に大きな影響を与えました。可能な限りそれを避けるように努力したい(spoiler: ほとんど成功していない)
正直に言えば、ただ楽しむために言語を作っていたなら System F から始めてそれ以降を反復していれば良かったのです。しかし上記の制約があるため、それは現実的ではありません。
言語について
私が編み出したものを紹介します。これは C++、Rust、Python、Zig などの奇妙なミックスです。
概要
作業上のタイトルは、私のペットゲームエンジン「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 体などは新しいスコープを作成します。関数や構造体をあらゆるスコープ内で定義でき、そのスコープ内でのみ有効です。ローカル関数は定義されたスコープの変数にアクセスできません(クロージャではなく、スコープは名前解決のみ影響を与えます)。トップレベルのスコープ(関数内のものではない)は他のスコープと同じように扱われ、ファイルのエントリポイントを含みます(ファイルがロード/初期化される時に実行されるコード)。
main() に相当し、モジュールインポート時にグローバル変数を初期化したり、単に実行するコマンドのシーケンスからなるスクリプトを書いたりすることを可能にします。(内部的にはトップレベルスコープは匿名関数にラップされます)
基本型
チェックノート 13 の基本型があります:bool、4 つの有符号整数型、4 つの無符号整数型、3 つの浮動小数点型、および単位。数値型はよく表せるように以下の通りです:
| i8 | i16 | i32 | i64 | |
|---|---|---|---|---|
| u8 | u16 | u32 | u64 | |
| f16 | f32 | f64 |
iNN は有符号整数、uNN は無符号整数、fNN は浮動小数点です。f8 タイプがないことに注意してください(大多数のデスクトップ CPU でサポートされておらず、8 ビット浮動小数点数の定義について合意もないため)。
f16 は多くの人には役立ちませんが、我々はグラフィックスで頻繁に使用しています(HDR 色、顶点属性など)。ホスト言語に存在しないことは常に顕著な不便さです。現代の多くのデスクトップ CPU が IEEE754 f16 を実装しているため、これをそのままサポートすることはコストをかけません。
一部の人は無符号型を完全に除外すべきだという強い意見を持っていました。しかしグラフィックスと計算で無符号型を一生使用してきた私には、それがどのように機能するのか想像できません。
なお、すべての整数算術は 2 の補数方式でオーバーフローあり(UB はありません)です。
単位型は少し特殊です。単一の値
unit() を持ち、何も返さない関数の形式の戻り型です。関数の戻り型を省略すると自動的に unit が返されます。そのような関数の末尾に戻り文を省略しても自動的に挿入されます(非単位関数から何も返さないとエラーになります)。また、不透明ポインタにも使用できますが、そのためには空構造体を作成する方が良いです。
数値リテラル
デフォルトでは、
10 などの数は i32 を意味します。他のサイズには 10b(バイト)、10s(ショート)、10l(ロング) のようなサフィックスを使用できます。無符号リテラルの場合は 'u' サフィックスを追加します:10ub(無符号バイト)、10us(無符号ショート)、10u(無符号 32 ビット)、10ul(無符号ロング、つまり u64)。
浮動小数点リテラル(十進法区切りのあるもの)はデフォルトで f32 を意味します。他のサイズにもサフィックスがあります:
10.0h(ハーフ、16 ビット)、10.0d(ダブル、64 ビット)。整数部または小数部のみを省略して . を置くことはできません(例:10. や .5)ので、完全な形を書く必要があります (10.0 や 0.5)。
したがって、すべての数値リテラルには曖昧性のない型が指定されます。
配列
配列は組み込みの一次元型です。C や C++ と異なり、関数に配列を渡すことができます(ポインタではなく配列全体)、関数から返すことも、相互に代入することもできます。配列サイズは常にコンパイル時で決まります。構造体の同型フィールドを持つものにほぼ振る舞います。
i32[5] のように配列型を宣言し、[1, 2, 3, 4, 5] のように配列入力を作成します。もちろん配列はインデックスをサポートしています。
[5]i32 のような構文を主張する人もいましたが、これは [5]i32* の場合のような曖昧性(ポインタの配列か、配列へのポインタか)を招きます。これらはすべての型修正子を右側ではなく左側に置くことで解決できますが、それでより読みやすいかは疑問です。
関数型
これは基本的に C が呼ぶ「関数ポインタ」ですが、より洗練された構文で表現されています。(a, b, c) -> d は関数型で、1 つの引数の場合括弧を省略できます
a -> b。内部的には通常の関数ポインタでありクロージャではありません(データを渡しません)。
ポインター
i32* はポインター型です。デフォルトではポインターは不変(C++ の const に相当)ですが、i32 mut* と宣言することで可変ポインターにします。
主に C のように使用されます:変数のアドレス
&x を取得するか、可変ポインター &mut x を取得できます。ポインターを参照解除 *p やポインター算術 *(p + 10) も可能です。
構造体
構造体は
struct キーワードを使用して、すべてのフィールドと型をリストして宣言します:
struct string_view: size: u64 data: u8*
構造体は組み込みの関数のようなコンストラクタ
string_view(10, data) で作成します。構造体のフィールドはドット構文 v.x でアクセスできます。構造体のポインターに対しても同じドット構文でフィールドにアクセスできます。
構造体フィールドには不変性修飾子はありません:可変オブジェクトのフィールドは可変、不変オブジェクトのフィールドは不変です。また、アクセッサもありません(つまり常にパブリックです)。
これが大まかな型システムです!
メモリレイアウト
すべてのオブジェクトには保証されたメモリレイアウトがあります:基本型はサイズに等しい整列を持ちます(bool は 1 バイト)、ポインターと関数型は常に 64 ビット(同様の整列)、配列は要素と同じ整列、構造体は整列要件を満たすためパディングされます。これは主に C 連携や GPU プログラミングでの使用を簡素化するためです。
空の型
私が「空」と呼ぶ特定の型があります。実際には空ではなく、単一の有効な値を持っています。これらは
unit とフィールドを持たない空構造体です。これらの型はメモリを使用せず、サイズは文字通り 0 バイトです。関数に渡すことは何もしず、変数を宣言しても何も起こらず、そのようなフィールドを持つと構造体のサイズも影響しません。これらはコンパイル時タグや同様のものとして役立ちます。
しかし、ポインターを通じてこのようなオブジェクトを読み書きするかどうかは決まっていません。汎用コードでは有用かもしれませんが、実際には何もしません。現時点では、このような型でのポインター算術を違法にしました。
これは、C++ のルール(各オブジェクトに一意のメモリアドレスがある)に従わないことを意味します。
変数
不変の場合は
let x = 10、可変の場合は mut x = 20 のように宣言されます(値は再代入可能)。もちろん、不変変数の可変ポインターを取得することはできません。
明示的に型を指定できます
let x: i32 = 10、しかし言語は任意の式の型を曖昧なく推論できるように設計されているため、必ずしも必要ありません。ただし、何かで変数を初期化する必要があることは変わりません。
関数
関数は
func foo(x: A, y: B) -> C: に続いて関数本体のように宣言されます。戻り型が省略されると unit です。
すべての関数はプラットフォーム固有の C ABI に従います(C 連携のため、特に C コードに引数として関数ポインタを渡したり、コールバック/ECS システムなど)。
宣言順序
スコープ内(トップレベルを含む)では、関数と構造体を任意の順序で宣言できます。つまり、後方定義された構造体や関数を使用可能です。これは実装が簡単で、相互再帰的な関数やデータ構造に必要である前方宣言用の構文を発明する必要から回避されるためです。
これにより型推論は方程式求解問題へと変わる可能性があります。しかし、すべての関数は引数と戻り型の完全な表記を要求されているため、問題にはなりません。型推論は依然として非常に単純です。
制御フロー
if-else ステートメントや while ループがあります:
if x < 10: x += 15 else if x > 20: x -= 5 else: x = 0 while x > 0: r *= x x -= 1
for ループはありません(後で少し話します)。また if A then B else C のような if 式もあります。
外部関数
関数が外部であることを宣言できます:
foreign func sin(x: f64) -> f64
これはここで実装されず、他方(例:動的ライブラリ libc)にリンクする必要があります。現在インタプリタは
dlsym を使用してインタプリタ実行ファイル自体からこのような関数を取得します。
C ライブラリや他のサードパーティライブラリとのインターフェースする主要メカニズムです。レイトレーサー例はこの機能を使用して平方根計算、ファイル書込み、タイミング計算、スレッド作成にも使用しています。
型キャスト
暗黙の型キャストは決してありません。
as オペレーターを使用して手動で型をキャストできます (x as f32)。すべての数値型同士、すべてのポインター型同士(不変ポインタから可変ポインタへの切り替えは不可)、ポintptr タイプと u64 の間の相互キャストが可能です。bool には何らかも相互にキャストできません。
ただし、可変ポインター
T mut* から不変ポインター T* への暗黙的な 1 つの追加を検討中です。まだ決まっていません。
オペレーター
算術演算子、論理演算子、比較演算子など、標準的なオペレーターがほとんど揃っています。注目すべき点は
& と | および && と || が両方に存在することです。これらはすべての数値型と bool の両方で動作します(ビット演算)。違いは & や | は常に両方のオペランドを評価するのに対し、&& と || は短絡評価を行います(第 1 オペランドの結果から結論がわかる場合第 2 オペランドを評価しない)。
算術と比較演算子は同型の数値引数のペアのみ有効で、数値型の変換は行われません。
これで言語の基本機能は揃っています。多くありませんように聞こえますが、すでに合理的な快適さで実プログラムを書くことができます!
コンパイラのアーキテクチャ
プロジェクト全体をライブラリセットに分けています:
: 型システム定義types
: 抽象構文木の定義とユーティリティast
: パーサーparser
: 中間表現ir
: インタプリタinterpreter
: JIT コンパイラjit
インタプリタとコンパイラはこれらのライブラリのいずれかを使用して単なる CLI アプリです(現在は JIT モードのみが実装されています)。言語を埋め込む場合は
parser + jit ライブラリを使用します。
パーサー
パーサーには Bison パーサージェネレータを使用しました。Bison チュートリアルについて質問があったので、申し訳ありませんが知りません(ドキュメントを読んで始めただけです)。
トークン(キーワード、オペレーター、リテラルなど)を指定する lexer グラマーと、言語のパーサーグラムマがあります。グラマーは概して単純です:ファイルはステートメントリストで、ステートメントは関数宣言、制御フロー演算子、変数宣言、または単なる式であり得ます。式はリテラル、変数、オペレーター、関数呼び出しなどです。
グラムマ内でいくつかのシフト/ルールの衝突を修正する必要がありました(パーサーが文脈でどのルールを使用すべきか判断できない場合)。Bison には衝突を引き起こす正確なシナリオを示す
-Wcounterexamples という素晴らしいコマンドラインフラグがあります。私は LALR(1) グラマーを書くマスターではなく、通常 Google で調べたり試行錯誤したりして解決しました。多くの場合グラムマ全体を書き換えなければなりませんでした:
smth: A B | A C
を
smth: A x x: B | C
のように変えることです。私は C++ パーサークラスを生成する
lalr1.cc Bison スケレットを使用しています。デフォルトでは Bison はグローバル変数をパーサの状態とする C パーサーを生成します。単一のショットのパーサー(独立したコンパイラ実行ファイル内)には機能しますが、インタプリタやゲームモッド(複数のファイルを並列に解析したい場合)には適しません。C++ クラスを生成することでこの問題を解決しました。
Bison の実行を CMake スクリプトのビルドステップとして挿入しました。比較的単純でしたが、生成されたファイルを別のディレクトリに入れることに失敗しました(
parser.hpp として含める必要があり、<pslang/parser/generated/parser.hpp> のようにできません)。
パーサーの出力は解析されたファイルの AST を表す C++ オブジェクトです。
インデント
パーサーには 1 つの問題があります:インデントです。インデントにより文法が実際には文脈无关ではないため、例えば while ループのボディに属するかどうかは直前のインデントトークンの数に依存します!
これを解決するために、私は恐ろしいことをしました:各行はスタンドアロンのステートメントとして、インデントレベルを示す番号と共に解析されます。その後、単純な線形パスでインデントレベルを見てスコープを解決します。ハック的ですが機能し、非常に高速なのでこれで良しとしました。また、このパスにより
break や continue ステートメントがループ内のみ、return ステートメントが関数内のみ(トップレベルスコープは関数とはみなされない)、フィールド定義が構造体内のみであることが保証されます。
型チェック
解析後にはコンパイル前にいくつかのパスがあります。最初のパスは単にすべての識別子を解決し、識別子ノードをその変数/関数/構造体定義ノードに文字通りリンクします。
次に最も重要なパスが行われます:すべての型をチェック・推論するパスです。前述のように、型推論は基本的によく、型チェックも同様です(特定の AST ノードタイプに基づいた一連の条件のみ)。例えば、
if や while 内の式の型は bool でなければならず、足算のオペランドの型は同じ数値型または 1 つ整数と 1 つポインターである必要があります。
インタプリタ
これらは素敵で涼しいですが、実際にはコードを実行する方法はありません。それがインタプリタのためです!少なくとも第 1 バージョンのインタプリタの目的でした。現在は非常に壊れた状態であり、IR を使用して完全に書き直す予定です。
これは「木を辿るインタプリタ」(名前すら知らなかったもの)です。すべての必要な AST ノードを訪れ、対応する C++ コンストラクトを実行することでコードを実行します。主な関数は
exec() と eval() で、必要に応じて互いに呼び出すことができます。exec() は単一のステートメントを実行し、eval() は単一の式の値を計算して返します。C++ が静的型付けであるため、eval() は言語内のすべての可能な値タイプのバリエーションを返します(構造体は各フィールドに 1 つずつの名前 - 値ペアの配列として表現されます)。インタプリタはこの同じバリエーションを使用して変数値を保存します。
インタプリタの主目的は、言語内の任意のコードを実行するための簡単なクロスプラットフォーム手段を提供し、言語実装とそれによって書かれたプログラムのデバッグを支援することです(多くの検証をインタプリタに挿入できるため)。高速であることは意図されていません。
インタプリタが実行できないのは外部関数です:
eval() に直接渡すことはできず、C 呼出規約を使用して、引数の数や型が事前に不明である必要があります。おそらく vararg magic を使うか、libffi を使用する必要があるでしょう。
インタプリタはすべての内部状態(変数名、型、値)を stdout にダンプできます。これはプロパーコンパイラを作る前の主なデバッグ手段でした。
コンパイラバージョン 1
2026 年 1 月初旬の数週間は休暇中で、M1 Mac のみを持っており、そのためこのアーキテクチャでコンパイラを書くことに決めました。記事執筆時にはこれが唯一サポートされているアーキテクチャです :)
これは JIT コンパイラです:結果はメモリーブロード(正しくマップされて実行可能になるビット)と、各関数の開始へのポインター(エントリポイントを含む)です。
言語実装の大部分について「比較的単純」と言いましたが、コンパイラはそうではありませんでした。実際にはかなりトリッキーで、主にプラットフォーム固有のもののためです。
しかし、高水準部分のコンパイラは比較的シンプルでした!ほぼ古典的なスタックベースコンパイラです:式を計算する際はスタックから引数を取り、結果を再びスタックに戻します。ただし、高速化と単純化のために、コンパイラが関数の戻り型と同じ方法で式の結果を配置することを決定しました(AAPCS64、Aarch64 Mac 上の標準 C 呼出規約を使用)。
つまり、整数やポインターは x0 レジスタに返され、浮動小数点は v0 浮動小数点レジスタに返され、構造体はサイズに基づいてレジスタまたはスタックに返されます。これによりメモリアクセス操作が減少し、コンパイルされたコードが高速化され、関数呼び出しが簡素化されます。スタックは通常中間結果に使用されます。例えば二項演算の場合など。式
A + B は以下のようにコンパイルされます:
(eval A) # A の値は x0 にある push x0 # A の値はスタックトップにある (eval B) # B の値は x0 にある pop x1 # A の値は x1 にある add x0, x0, x1 # A+B の値は x0 にある
制御フロー構造のコンパイルは少しトリッキーです。すべて条件分岐(式がゼロなら if 体の跳躍)に変わりますが、単一パスコンパイルでは if/while ボディをまだコンパイルしていないためどこへ飛ぶかわかりません。これを解決するために、ゼロオフセットを持つ跳躍命令を出力し、実際の跳躍オフセットを後でターゲットオフセットを知った時に注入します。関数呼び出しにも同様に適用されます。
ターゲット CPU 命令を生成するにはサードパーティライブラリを使用できましたが、コンパイラを最小限にしようとしましたので、すべて自分で書きました。これは単にマニュアルを掘り起こして必要なビットを書き下すことに帰着しました。
Aarch64 特有
前述のように基本コンパイラは比較的シンプルですが、ターゲットアーキテクチャの特殊性がそれを複雑にします。
まず、Aarch64 のすべての命令は 32 ビットです。これは最初から良いように聞こえます(アドレス指定容易、保存容易、処理容易)ですが、レジスタに 32 ビット定数を置く方法を考えることになります。レジスタ選択には少なくとも 5 ビット必要(32 つある)、「レジスタに定数を入れる」コマンドを指示するビット、そして定数そのものに 32 ビットが必要です。数学が合いません!1 つの 32 ビット命令で入れる方法はあり得ません。64 ビット定数を望むかもしれませんしね。
代わりに、16 ビットパッチから定数を構築するか(0, 16, 32, または 48 ビットのオフセットを持つ定数 16 ビットをロードする命令がある)、または定数を常駐メモリに置いてそこから読み込む必要があります(これは私が浮動小数点定数のためにやっていることです)。
push/pop 命令はありません(x86 と異なり)が、他のレジスタからメモリアドレスへ書込み/読み出しなどを行う命令があります。これは別のレジスタプラス潜在的な 9 ビット符号付きアドレスまたは 12 ビット符号なしアドレス(×4)で計算されたメモリアドレスへのものです。それからアドレスレジスタを進めます...のようなもの。すべてのコマンドが正確に 32 ビットなので、奇妙なことをする命令が多すぎて、オフセットが符号付きか符号なしか、定数倍かどうか、コマンドがアドレスレジスタを変更するかなどを常に注意する必要があります。
スタック自体も少し奇妙です:SP レジスタ(スタックポインタ)に対して相対的に読み書きする場合、レジスタは必ず 16 ビット整列されていなければなりません。可能なオフセットは 12 ビットで制限されているため、スタックフレームが 16KB より大きいなどの場合に特別なコードを挿入する必要があります(まだ実装していません)。
呼出規約には、構造体を最大 2 つの一般目的レジスタ、浮動小数点レジスタ、またはメモリのポインター経由で関数に渡す/返す場合についていくつかの特別ケースがあります。コンパイラにこれをカバーする多くのコードがあります。
IR
基本インタプリタとコンパイラを書いた後、いくつかのコードを再利用したいと思い、他のアーキテクチャのためのコンパイラを書くことを簡素化し、最適化を追加したいと思いました(生成されたコードはまだ「最適」とは言っていません)。
答えはシンプルです:中間表現を使用しましょう!それで次にやったことです。私の IR は SSA のようなものですが、同じノードに値を再代入することを許容しており、phi-ノードを使用しないため、実際には単一割り当てではありません。よって SSA とは全く異なります。まあいいや。
IR はノードの配列で、それぞれがリテラル、入力が以前の定義されたノードであるオペレーション、跳躍(条件付きまたは無条件)、関数呼び出しなどです。値を表すノードはその値の型も保存します。再代入を許容するためには、以前の定義されたノードの値を再代入する特別な assign IR 命令があります。
条件分岐は
jump_if_zero と jump_if_nonzero ノードに分割されます(通常異なる CPU 命令に対応し、値を反転して他の命令を使用するよりも速い)。
言語が関数ポインターをサポートするため、既知の IR ノードによる呼び出しと不明なポインタ値による呼び出しのための別々の命令があります。
最適化(ノードを任意の位置で追加/削除)をさらに簡素化するため、ノードは連結リスト (
std::list) に保存され、リストイテレータを使用して参照されます。
IR の最も困難な問題は構造体のサポート方法でした。構造体値のリテラルを持っていることはできませんので、構造体値を表す特別な
alloc ノードがあります。通常はスタック上に構造体を割り当ててその値を初期化せずに作成します。その後、構造体自体は個々のフィールドへの代入で構築されます。
しかし構造体が他の構造体を含める可能性があるため、ネストされたフィールドの読み込み(例:
a.x.y)は IR において最初に a.x を新しいノードに読み込んでから、このノードの y フィールドを読むことを表します。ネストされたフィールドへの代入はさらに悪い:a.x.y = b は
Read t = a.x Write t.y = b Write a.x = t
のように表されます。これは非常に無駄で最適化が困難なので、IR において特別な扱いがあります。コピーノードは構造体の任意のネストされたフィールドを抽出でき、assign ノードは構造体の任意のネストされたフィールドへの代入を許容します。ネストされたフィールドはインデックスの配列として表されます(例:フィールド #0 を取り、そのフィールド #2 を取り、そのフィールド #5 を取る)。
コンパイラバージョン 2
その後、IR を使用して Aarch64 コンパイラを書き直しました。現在は 2 つの部分に分割されています:
AST → IR コンパイラと IR → Aarch64 コンパイラです。前者は比較的シンプルですが、後者は完全な混沌です。現在、前のスタックベースコンパイラよりも遥かに悪い状態です。関数開始時に、この関数のすべての IR ノードに必要とされるスタックスペースを割り当てます(多くは一時的な中間値であるため)。これが悪すぎるため、レイトレーサーの 1 つの関数を 2 つに分割して、以前話した 12 ビット制限内にスタックフレームが収まるようにしました。
これは予想通りですが、このコンパイラはレジスタアロケーターを使用することを意図しており、その後結果コードは桁違いに良くなることを期待します。
今後の計画
言語で現在実装されているのはこれでほぼすべてで、C++ コードが約 10k 行です。もちろんまだ長道ですが、実際に動作していることに感謝し、コンパイラは現代標準において比較的シンプルだということです!
言語に追加したいことが多すぎて以下について話します。
コンパイラ/インタプリタ
レジスタアロケーター 前述のように現在の
IR → Aarch64 コンパイラはひどく、レジスタアロケーターが本当に必要です。標準の線形スキャンアロケーターを使用することを計画しています。コンパイル速度とコード品質の間で良いトレードオフだと考えられています。
IR 最適化 IR で多くの最適化を実行できます。特に追加したいのは:
- 定数伝播
- 算術簡素化
- デッドコード除去
- イネーリング
- ループアンロール
GCC や LLVM と凌駕することは目指していませんが、3D ベクトル加算のような単純な関数が可能な限り少ない CPU 命令にコンパイルされることは良いことです。
IR インタプリタ インタプリタを IR を直接評価して書き直すことを計画しています。これはインタプリタをかなり簡素化します。
実行可能ファイルの生成 現在コンパイラは即時実行するための JIT コンパイルされたメモリーブロードしか生成できません。プラットフォーム固有フォーマットで実行可能な実行ファイルを生成することもサポートしたいです。これは主にバイナリフォーマットの仕様(ELF, Mach-O, PE)を掘り下げることを必要とします。可能であればできるだけ小さな実行ファイルを生成することを楽しみにしています。
デバッグ JIT 生成されたアセンブリを lldb でステップThrough を踏むために時間を費やしましたが、言語を適切にデバッグしたいと考えています。これはおそらく DWARF デバッグ情報形式をサポートすることを必要とし、ほとんど何も知らないため大冒険になるでしょう。
ランゲージ機能
構造体コンストラクタ 現在は構造体をすべてのフィールドを設定して
vec3i(1, 2, 3) のように作成するか、ゼロ初期化 vec3i() のようにするしかできません。構造体の名前と等しい関数を宣言することで任意のコンストラクタを支援したいと考えています:
func vec3i(x: i32, y: i32) -> vec3i: return vec3i(x, y, 0)
しかしこれが良いかどうかわかりません—そのような関数に一意の名前を与える方が良いかもしれません。
グローバル変数 現在はグローバルはサポートされていません。グローバルキーを添加する計画です(スコープ規則によってアクセスは依然として制限されるため、C の静的変数のように関局域グローバル変数を作成できます)。
トップレベル変数は実際にはグローバルではなく(
global キーワードを使用する場合のみ)、ファイルのエントリポイント関数にローカルです。ユーザーに混乱させる可能性があります。
これは Mac で問題になります:書込み可能かつ実行可能な同時メモリモップを許さないため、グローバルはコードとは別に割り当てて異なるフラグでマップし、コンパイル時オフセットではなくランタイム解決アドレスによってアクセスする必要があります。
しかし、
mprotect() を使用してマッピングの一部分のフラグを変更できるようですので、まずそれを試す予定しています。
メソッド呼出構文 コードをより読みやすくするために
x.f(y) が可能なら f(&x, y) や f(&mut x, y) の意味するようにしたいです。
多型性 (Polymorphism) おそらく最も重要な潜在的機能です。そのような言語に多型性を追加する方法はありますが、最も有望な 2 つ(私の意見では)は:
- C++ スタイル関数オーバーロード + 制限のない関数テンプレート + 構造体テンプレート(特殊化なし)
- Haskell/Rust スタイルの明示的なトレイト + テレイト制約汎用関数と構造体
C++ スタイルはより強力、単純なケースで読みやすく、コンパイラで実装しやすいですが、エラーメッセージは特に暗号のように複雑です。
明示的トレイトはいくつかの場合に読みやすい(汎用コードからどの関数が来たか理解するのが難しい場合)、コンパイラでの実装が難しい(トレイトと制約は全く新しいシステム;トレイト自体は複数パラメータトレイトをサポートするために汎用になるなど)、しかしより厳格(良い面もある悪い面もある)であり、エラーメッセージ問題を確実に解決します。
まだどちらを選ぶか決まっていません。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
関数では可能 whenever 引数推論を使用します。
オペレーターオーバーロード 多型性の何らかの形式が必須です。それ以外は比較的シンプル:
a + b のようなオペレーターはオーバーロードされた関数 add(a, b) またはトレイト Add::add メソッドを呼び出すかもしれません。
for ループ while ループで for ループをシミュレートできるため、コレクションベースループ(C++ の範囲ベースループ、Python のループなど)として使用したいと考えています。これは当然ながら、範囲/イテレータインターフェースの何らかの形式を必要とし、これも再び多型性を要求します。
自動リソース管理 エルゴニックで実用的な言語はメモリ、ファイル、ソケット、ミューテックスなどのリソース解放を助ける方法を提供する必要があると信じています。行う方法には:
- C++ スタイルの RAII + move: オブジェクトのライフサイクル終了時に呼び出される自動デストラクタ + データを一つオブジェクトから別のオブジェクトへ移動
- Zig スタイルの defer キーワード:スコープ末尾で任意コードを自動的に実行することを許容
- ライナルタイプ:オブジェクトを正確に 1 回使用する必要がある(例:解放関数に渡す)
RAII の主な欠点は暗黙的であり、隠れた命令と制御フローを追加する点です。
defer は明示的ですが、常に手動で挿入する必要があり、挿忘れるのを防ぐわけではありません。ネストされたコレクションの解放も不便です。例えばファイルの配列の場合、配列自体を解放する前に各ファイルを閉じる必要があります:
defer free(array) defer for file in array: close(file)
ライナルタイプは有望なアイデアですが:明示的(手動で
free/close を呼び出す)でありながら、リソース解放関数を使用してオブジェクトを「消費」することを強制します。しかし、ネストされたコレクションとは再びミックスしにくい例:動的ファイル配列。
どちらのオプションを選ぶか決まっていません。ライナルタイプアイデアを何らかの方法で拡張したいと考えています。
多型リテラル 推論が失敗する場合がありますいくつかのリテラルは複数の意味を持つためです。
直面した最初のケースは空配列入力です。
[1, 2, 3] のような配列では、すべての要素が同じ型であることを確認して配列の型を推論できます。しかし空配列 [] ではサイズ(ゼロ)は推論できても型はいけません!
同様の問題は null リテラルから来ます:何らポインター型を意味します。また浮動小数点型を任意の意味する
inf リテラルも望みます。
解決方法には 3 つあります:
- Haskell 様多型リテラル:言語と型システムの大規模再構築が必要で、型推論をアルゴリズムから方程式求解器に転化可能性があります
- 暗黙変換をサポートする特別な組み込み/ライブラリタイプ(C++ の
のようなもの)nullptr_t - AST で特殊リテラルとして扱いコンパイラで ad-hoc 処理を追加
後者のオプションに傾いています:特定の型を期待する場所(型が明示的に指定された変数での初期化や関数の引数など)で
null を許可します。これは最もシンプルですが、拡張性はありません(null からカスタム型を構築できない)。ただし悪くはないかもしれません。
コンパイル時評価 メタプログラミングなどに本当に楽しい機能です。まず変数は
const キーワードを使用して宣言でき、これはコンパイル時変数(実際には変数ではない)を意味します。コンパイル時式(例:配列型のサイズ)で使用可能ですが、再代入できませんし、アドレスも取得できません。
その後、コンパイル時式中で任意の適切な関数を呼び出すことができます(グローバル変数にアクセスせず、副作用を持たないなど)。関数体内では通常通り動作しますが、コンパイル時に実行され、結果はコンパイル時式です。
この機能には特定の外部関数がコンパイル時呼び出しに適していることを宣言するためのトリックが必要です(数学やメモリアリケーションなど)、否かコンパイル時評価は非常に制限されます。
型計算 メタプログラミングのために再び型上の計算を支援するのは素敵です。これらはコンパイル時にのみ行われ、なぜなら静的型付け言語でランタイムに型を持つと有用性が限られるため、型のランタイムエンコーディングスキームを発明したくないからです。
C++ スタイルコンセプトのようなものにも使用できますが、特別な構文なし—単にコンパイル時呼び出しを使用します。ただし型プロパティを確認する方法が必要です:
func comparable(t: type) -> bool: // 何らかの方法で実装... 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))。
モジュールのエントリポイントは決定論的な順序で実行されます(インポート順 + 再帰的インポートのトポロジックソート),C または C++ に典型的な初期化順序の災いを解決します。
まだ決まっていないのは、複数モジュールプログラムがメモリにどのように配置されるかです。各モジュールを別々のメモリーブロードに置くことができ、ランタイムで関数呼び出し/グローバル変数アクセスを解決できます。あるいは単一の巨大メモリアマップとして構築し、相対オフセットを使用することもできます。これはランタイムで高速ですが、複数モジュールを並列にコンパイルするのが難しくなります。
プリリュード モジュールがある場合、いくつかの基本的なユーティリティを暗黙的にすべてのプログラムに含まれる特定のプリリュードモジュールに置くことができます。
length() 関数や組み込み配列のイテレータインターフェース、文字列ビュー型、Python の range(n) のような数値範囲などを含めることができます。
文字列リテラル 言語にはまだ文字列リテラルはありません。何を意味するか分からないためです。プリリュードに不変
string_view 型があり、文字列コンテンツは実行可能メモリのどこかに置かれ、リテラル自体はこのメモリを指す string_view に変わる予定です。
標準ライブラリ 偉大なモジュールと共に偉大な標準ライブラリが来るはずです。以下のいくつかのサブセットを含んでくれるのが嬉しいです:
- 数学ライブラリ(ベクトルと行列を含む)
- メモリアリケーション、少なくとも libc からブリッジされた
関数の形式alloc/free - 動的配列
- 動的文字列と書式付け
- ハッシュテーブル
- コンソールとファイルのための I/O 施設
- ファイルシステムヘルパー
- タイミング/クロックヘルパー
- ネットワーキング
結論
これらすべてを実装していつか、ゲームのモーディングや他の何かでこの言語を使うのでしょうか?正直に言うと全く分かりません。これは野心のあるプロジェクトであり、同時に複数の野心プロジェクトを真剣に扱うのは良いアイデアではありません。気分が乗った時に作業し、現在優先事項は依然としてゲームです—未完成のゲームにはモーディングできませんから。
上記について面白いアイデアをお持ちでしたらためらわず ping してください!いずれにせよ、お読みいただきありがとうございます。