この記事について
この記事では、C、Python、JavaScript、TypeScript、Goのような複数言語に対応する コード変換アプリ を作るなら、どんな設計にすると拡張しやすいかをまとめます。
最初に思いつきやすいのは、文字列置換で変換する方法です。
printf("hello\n");
↓
print("hello")
このくらいなら文字列置換でもできます。
でも、少し複雑になるとすぐに限界が来ます。
for (int i = 0; i < n; i++) {
sum += x;
}
これをPythonにするなら、単純にセミコロンや波括弧を消せばいいわけではありません。
for i in range(0, n, 1):
sum += x
構文の意味を理解して、別の言語の自然な形に組み替える必要があります。
だから、ちゃんと作るなら次の流れがよさそうです。
元コード
↓
AST
↓
共通IR
↓
言語別生成器
↓
出力コード
↓
検証
この記事では、この構成を実装寄りに整理します。
先に結論
多言語コード変換アプリを作るなら、中心に置くべきなのは 共通IR です。
CからPythonへ直接変換
CからJavaScriptへ直接変換
CからGoへ直接変換
のように、入力言語と出力言語を直接つなぐと、対応言語が増えるほど組み合わせが爆発します。
例えば入力が3言語、出力が5言語なら、単純には15通りの変換器が必要になります。
そこで、いったん共通IRに落とします。
C
↓
C AST
↓
共通IR
├─ Python生成器
├─ JavaScript生成器
├─ TypeScript生成器
├─ Go生成器
└─ 疑似コード生成器
こうすると、C以外の入力言語を増やすときも、
Python AST → 共通IR
JavaScript AST → 共通IR
の入口を追加すれば、既存の出力生成器を使い回せます。
全体構成
アプリ全体の構成は、次のように分けると扱いやすいです。
frontend
入力欄
出力言語選択
出力 / AST / IR / 診断 / Compiler Lab 表示
server
変換API
パーサー呼び出し
変換パイプライン実行
core
parser
AST -> IR
generators
validators
tests
フロントエンド側で全部やることもできますが、Cパーサーや生成器を安定して扱うなら、最初はサーバー側に寄せたほうが楽です。
なぜASTが必要なのか
ASTは、ソースコードを構文として分解した木構造です。
例えば、次のCコードがあります。
int add(int a, int b) {
return a + b;
}
これをASTとして見ると、ざっくり次のようになります。
function_definition
return_type: int
name: add
parameters:
int a
int b
body:
return_statement
binary_expression
left: a
operator: +
right: b
文字列として見ると return a + b; ですが、ASTとして見ると、
これはreturn文
返しているのは足し算
左辺はa
右辺はb
とわかります。
この情報があるから、Pythonなら、
return a + b
Goなら、
return a + b
のように、それぞれの言語に合わせて出せます。
tree-sitterを使う
Cの構文解析を自分で全部書くのは大変です。
そこで、構文解析には tree-sitter のような既存パーサーを使うのが現実的です。
Cコード
↓
tree-sitter-c
↓
C AST
tree-sitterを使う利点は、対応言語が多いことです。
- C
- C++
- JavaScript
- TypeScript
- Python
- Go
- Rust
- Java
など、いろいろな言語のパーサーがあります。
将来的に入力言語を増やすなら、同じ考え方で、
Python AST -> 共通IR
JavaScript AST -> 共通IR
Go AST -> 共通IR
を追加していけます。
共通IRとは何か
IRはIntermediate Representationの略で、日本語では中間表現です。
ソースコードそのものでもなく、特定言語のASTそのものでもない、変換しやすい共通形式です。
例えばCのコードがこうだとします。
int total = 0;
これをIRでは、次のように持ちます。
{
"kind": "VariableDeclaration",
"name": "total",
"type": "int",
"initializer": {
"kind": "Literal",
"value": 0
}
}
こうしておくと、Python生成器はこう出せます。
total = 0
JavaScript生成器はこう出せます。
let total = 0;
Go生成器はこう出せます。
var total int = 0
同じIRから、言語ごとに自然な形を作れるのがポイントです。
IRに入れたいもの
最初に対応するIRは、基本構文に絞ると進めやすいです。
Program
Function
Parameter
VariableDeclaration
Assignment
Return
If
While
For
Print
Input
Break
Continue
Expression
Unsupported
特に大事なのは Unsupported です。
変換できない構文を無理に壊れたコードとして出すより、
この構文はまだ対応していない
とIRに残して、診断に出したほうが安全です。
例えば、まだ struct に対応していないなら、無理に変換しないで、
Unsupported: struct declaration is not supported yet
として表示します。
Cの標準関数をどう変換するか
コード変換で難しいのが、標準関数です。
例えばCでは、
printf("total = %d\n", total);
scanf("%d", &n);
rand() % 100 + 1;
のように書きます。
Pythonなら、だいたいこうなります。
print(f"total = {total}")
n = int(input())
random.randint(1, 100)
JavaScriptなら、例えばこうです。
console.log(`total = ${total}`);
n = Number(prompt("") ?? 0);
Math.floor(Math.random() * 100) + 1;
ここで大事なのは、Cの関数名をそのまま残さないことです。
Python出力に、
scanf("%d", &n)
が残っていたら、それは変換ミスです。
そのため、標準関数は対応表を作ります。
stdio.h
printf -> Print IR
scanf / scanf_s -> Input IR
stdlib.h
rand -> Random IR
srand -> SeedRandom IR
malloc / free -> Unsupported or high-level allocation
time.h
time -> Time IR or random seed helper
math.h
sqrt -> MathCall IR
sin -> MathCall IR
cos -> MathCall IR
すべてを一気に対応する必要はありません。
まずはよく使う関数から対応し、未対応のものは診断に出すのが現実的です。
生成器は言語ごとに分ける
IRからコードを出す部分は、言語ごとに分けます。
generators/
python.ts
javascript.ts
typescript.ts
go.ts
pseudocode.ts
例えば If IRを受け取ったとき、Pythonはこうです。
if total > 10:
print(total)
else:
print("small")
JavaScriptはこうです。
if (total > 10) {
console.log(total);
} else {
console.log("small");
}
Goはこうです。
if total > 10 {
fmt.Println(total)
} else {
fmt.Println("small")
}
同じIRでも、インデント、波括弧、セミコロン、標準出力の書き方が違います。
だから生成器を分けて、それぞれの言語らしい出力にします。
文字列置換だけでは危ない例
例えば、Cにこういうコードがあります。
count++;
Pythonには count++ はありません。
正しくは、
count += 1
です。
もし文字列置換でセミコロンだけ消してしまうと、
count++
という壊れたコードが出ます。
同じように、Cの乱数もそのまま残すと壊れます。
srand((unsigned)time(NULL));
answer = rand() % 100 + 1;
Pythonでは、
import random
random.seed()
answer = random.randint(1, 100)
JavaScriptでは、
answer = Math.floor(Math.random() * 100) + 1;
のように変える必要があります。
このような変換は、構文と意味を見ないと安全にできません。
出力後バリデーションを入れる
変換器には、出力後の検査が必要です。
例えばPython出力に、次のようなCの残骸が残っていたら危険です。
scanf(
scanf_s(
printf(
&n
count++
rand()
srand(
time(
行末のセミコロン
単独の { }
こういうものを言語ごとにチェックします。
Python出力:
scanf, printf, &変数, ++, --, セミコロンを警告
JavaScript出力:
scanf, printf, &変数, srand, rand, time を警告
Go出力:
scanf, printf, #include を警告
これにより、変換器が未対応構文をうっかり素通ししたときに気づけます。
ゴールデンテストを作る
多言語変換では、テストがかなり大事です。
おすすめは、入力Cコードと期待する出力をセットにした ゴールデンテスト です。
tests/fixtures/
for-input-sum.json
guessing-game.json
stdio-scanf-int.json
compiler-structured-control.json
例えば、Cの入力がこうです。
printf("予想: ");
scanf("%d", &guess);
count++;
Pythonの期待値はこうです。
print("予想: ", end="")
guess = int(input())
count += 1
JavaScriptの期待値はこうです。
guess = Number(prompt("予想: ") ?? 0);
count++;
これをテストに固定しておくと、あとから変換器を改造したときに壊れたらすぐわかります。
Compiler Labという遊び
コード変換アプリに、学習用の Compiler Lab を入れるのも面白いです。
これは、IRをさらに自作バイトコードへ落として、そこから逆算する機能です。
C Source
↓
AST
↓
IR
↓
Bytecode
↓
Structured Decompile
例えば、
sum += x;
をバイトコードにすると、次のようになります。
LOAD sum
LOAD x
ADD
STORE sum
これを逆算すると、
sum = sum + x
になります。
さらに制御構造も復元できます。
LABEL loop
JUMP_IF_FALSE endloop
...
JUMP loop
LABEL endloop
を見て、
while condition
...
end while
や、
for i = 0; i < n; i += 1
...
end for
のように戻します。
これは実行用コードではなく、解析用の疑似コードです。
ただ、プログラムがどんな命令列になり、それをどう逆算できるかを見るにはかなり楽しい機能です。
UIにあると便利なタブ
変換アプリのUIには、次のタブがあると便利です。
出力:
実行できる変換結果
IR:
共通IRのJSON表示
AST:
パーサーが読んだ構文木
Compiler:
自作バイトコードと構造化デコンパイル
診断:
未対応構文や危険な出力の警告
特にIRと診断タブがあると、変換ミスの原因を追いやすくなります。
「なぜこのPythonが出たのか」を見るときに、元のC AST、共通IR、生成後コードを並べて見られるとかなり便利です。
最初に対応するならこの範囲
最初からC全体を変換しようとすると大きすぎます。
まずは次の範囲に絞ると、実用感もありつつ作りやすいです。
関数定義
引数
戻り値
int / float / char の変数宣言
代入
四則演算
if / else if / else
for
while
printf
scanf / scanf_s
break / continue
return
この範囲だけでも、合計計算、数当てゲーム、簡単な練習問題くらいは変換できます。
次に増やすなら、
配列
文字列
ポインタ
構造体
typedef
math.h
ファイル入出力
の順がよさそうです。
難しいところ
多言語コード変換で難しいのは、文法の違いだけではありません。
言語ごとの考え方が違います。
例えばCにはポインタがあります。
int *p = &x;
PythonやJavaScriptには、Cと同じ意味のポインタはありません。
この場合は、単純に置き換えるのではなく、設計を変える必要があります。
また、Cでは配列とポインタが近い関係にありますが、PythonのリストやJavaScriptの配列とは性質が違います。
int nums[5];
Pythonなら、
nums = [0] * 5
のようにできますが、すべてのケースでこれが正しいとは限りません。
だから、変換器は無理に全部を自動変換するより、
安全に変換できるものは変換する
危ないものはTODOや診断に出す
という方針のほうが堅いです。
AIを使う場合と使わない場合
ASTとIRを使う方式は、基本的にAIなしでも作れます。
メリットは、出力が安定しやすいことです。
同じ入力なら同じ出力
テストしやすい
失敗箇所を特定しやすい
一方、AIを使うと、人間っぽい補正やコメント生成は得意です。
例えば、
このポインタ処理はPythonではリストに置き換えると自然です
のような説明や提案には向いています。
ただし、変換器の中心をAIだけにすると、同じ入力でも結果が揺れたり、存在しない関数を作ったりする可能性があります。
そのため、個人的には次の構成がよさそうです。
AST/IRベースの変換器
安定した機械的変換を担当
AI補助
TODOの説明
未対応構文の提案
変換結果のレビュー
中心はルールベース、補助にAI。
このくらいの距離感が安全です。
まとめ
多言語コード変換アプリを作るなら、いきなり C -> Python の文字列置換から始めるより、次の構成にしたほうが伸ばしやすいです。
Source Code
↓
AST
↓
Common IR
↓
Target Generator
↓
Output Validator
さらに学習用や解析用として、
IR
↓
Bytecode
↓
Structured Decompile
を入れると、コードが命令列になり、そこから逆算される流れも見えるようになります。
ポイントは、変換できないものを無理に変換しないことです。
変換できる構文は自然なコードにする
危ない構文は診断に出す
テストで回帰を防ぐ
IRを中心にして対応言語を増やす
この方針なら、最初は小さく始めても、あとからPython、JavaScript、Go、TypeScript、さらに別の入力言語へ広げやすくなります。