多言語コード変換アプリを作るならどう設計するか|AST・IR・生成器・検証まで

この記事について

この記事では、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、さらに別の入力言語へ広げやすくなります。