カミナシ エンジニアブログ

株式会社カミナシのエンジニアが色々書くブログです

日本語で記述できるプログラミング言語「お抹茶」を作りながら学ぶインタプリタの仕組み

アイキャッチ画像 「日本語で記述できるプログラミング言語「お抹茶」を作りながら学ぶインタプリタの仕組み」

はじめに

プログラミング言語って自分で作れるの?と思っていた僕ですが、実はインタプリタ型の簡単な言語なら意外と作れることがわかりました。

今回は「お抹茶」という日本語で書けるプログラミング言語を作ってみたので、その実装方法を紹介します。

インタプリタ言語を作るのに必要な3つの要素

プログラミング言語を作るというと難しそうですが、実は基本的な仕組みは意外とシンプルです。インタプリタ型言語を作るには、以下の3つの要素が必要になります。

1. トークナイザー(字句解析器)

ソースコードを「トークン」と呼ばれる最小単位に分解する部分です。例えば 定義 x = 10 というコードを ["定義", "x", "=", "10"] のような配列に変換します。人間が読む文章を単語単位に区切るようなイメージです。

2. パーサー(構文解析器)

トークンの配列を「AST(抽象構文木)」というツリー構造に変換する部分です。これによってプログラムの構造が明確になり、どの演算を先に実行すべきかといった優先順位も表現できます。文章の文法構造を理解するようなものです。

3. エバリュエーター

ASTを辿りながら実際にコードを実行する部分です。コードを逐次評価して、変数への値の代入、条件分岐の判定、関数の呼び出しなど、プログラムの動作を実現します。

これら3つが揃えば、基本的なプログラミング言語が作れます!

実は既にある日本語プログラミング言語

実は日本語でプログラムを書ける言語はすでにいくつか存在します。

  • なでしこ:日本語プログラミング言語の代表格。教育用として使われることも多い
  • プロデル:Windows向けアプリケーションが作れる日本語プログラミング言語
  • ドリトル:オブジェクト指向の教育用プログラミング言語

今回作る「お抹茶」は、これらの先輩言語へのリスペクトを込めつつ、もっとシンプルで理解しやすい実装を目指しました。(難しいから簡単化しただけですが...w)

今回の方針:基本的な言語の構文を日本語化する

正直に言うと、今回の「お抹茶」言語は革新的な構文を目指したわけではありません。基本的にはJavaScriptやPythonの構文に近いものをそのまま日本語化したものです。

なぜこのアプローチを取ったかというと:

  • 学習目的に特化:インタプリタの仕組みを理解することが主目的
  • シンプルな実装:既存の構文パターンを使うことで、複雑さを抑える
  • 理解しやすさ重視:JavaScriptやPythonを知っている人なら、すぐに理解できる

まさに直訳のように英語のキーワードを日本語に置き換えただけ、とイメージしてもらえればわかりやすいです。これにより、言語設計の難しさに悩まずに、インタプリタ実装の本質に集中できました。

お抹茶言語の設計

できること

お抹茶言語では以下のことができます:

  • 変数宣言(定義
  • 条件分岐(もし ならば それ以外
  • ループ(繰返 から まで
  • 関数定義と呼び出し(関数 返す
  • 配列操作(配列定義、要素アクセス、代入)
  • ブロック構文({} による複数文のグループ化)
  • 出力(表示

扱えるデータ型

  • 数値型(整数・小数)
  • 文字列型
  • 配列型(インデックスアクセス可能)
  • 真偽値型(

文法の例

// 変数と条件分岐
定義 メッセージ = "こんにちは、世界!"
表示 メッセージ

定義 数値 = 10
もし 数値 > 5 ならば {
  表示 "大きい"
} それ以外 {
  表示 "小さい"
}

// 配列の操作
定義 配列 = [1, 2, 3, 4, 5]
繰返 i = 0 から 4 まで {
  表示 配列[i]
}

実装の詳細

それでは、先ほど紹介した3つの要素をTypeScriptで実装していきます。

1. トークナイザーの実装

トークナイザーでは、ソースコードを正規表現でパターンマッチングして分解します。

// トークンのパターン定義(一部抜粋)
const tokenPatterns: RegExp[] = [
  // 予約語
  /^(定義|もし|ならば|それ以外|繰返|から|まで|関数|返す|表示|真|偽)/,
  // 数値
  /^-?\\d+(\\.\\d+)?/,
  // 文字列
  /^"([^"]*)"/,
  // 識別子(変数名・関数名)
  /^[a-zA-Z_\\u3040-\\u309F\\u30A0-\\u30FF\\u4E00-\\u9FAF][a-zA-Z0-9_\\u3040-\\u309F\\u30A0-\\u30FF\\u4E00-\\u9FAF]*/,
  // 演算子と記号
  /^(==|!=|<=|>=|&&|\\|\\||[+\\-*/%=<>!()])/,
  // 配列
  /^[\\[\\]]/,
  // ブロック
  /^[{}]/,
];

正規表現でパターンマッチングをして、文字列を順番に読み進めていきます。日本語の識別子を扱うために、ひらがな・カタカナ・漢字のUnicode範囲を指定しているのがポイントです。

例えば 定義 x = 10 というコードは ["定義", "x", "=", "10"] というトークン配列に変換されます。

実装では最初に//から行末までのコメントを削除したうえで、上から順にtokenPatternsを適用してマッチした部分だけを切り出しています。空白文字はトークンに追加せずに読み飛ばす設計なので、余計なノイズが入らずパーサーの実装をシンプルに保てます。

2. パーサーの実装

パーサーはトークン列を受け取り、ASTを構築します。ここで重要なのは演算子の優先順位を正しく処理することです。

// ASTノードの型定義(一部)
interface IfNode {
  type: 'if';
  condition: ASTNode;
  then: ASTNode[];
  else: ASTNode[];
}

interface ArrayAccessNode {
  type: 'arrayAccess';
  array: ASTNode;
  index: ASTNode;
}

interface BlockNode {
  type: 'block';
  statements: ASTNode[];
}

これらで使っているtype文字列(例: 'if''arrayAccess')は、このあと紹介するエバリュエーターのswitch文でも同じ値を参照しているため、そのままコピーしても齟齬が出ないようになっています。

演算子の優先順位を考慮しながら、再帰的にパースしていきます。例えば 1 + 2 * 3 では、掛け算が先に評価されて 1 + 6 = 7 になるように、再帰下降パーサーという手法を使って実装します。再帰下降パーサーは、文法の各ルールに対応した関数を再帰的に呼び分けて式を読み進めるシンプルな手法で、再帰の流れを追いかけながら構文木がどんどん組み上がっていくイメージです。

// 配列アクセスの処理
if (peek() === '[') {
  let array: ASTNode = { type: 'identifier', name };
  while (peek() === '[') {
    consume(); // [
    const indexExpr = parseExpression();
    expect(']');
    array = { type: 'arrayAccess', array, index: indexExpr };
  }
  return array;
}

// ブロックのパース
const parseBlock = (): BlockNode => {
  expect('{');
  const statements: ASTNode[] = [];
  while (peek() !== '}' && peek() !== undefined) {
    statements.push(parseStatement());
  }
  expect('}');
  return { type: 'block', statements };
};

parseLogicalOrparseLogicalAnd→…と段階的に関数を呼び下げる構造になっていて、優先順位の高い演算ほど深い場所で処理されるようになっています。字句解析で用意したpeek(先読み)とconsume(読み進め)のヘルパーを組み合わせることで、関数呼び出しや配列アクセスのような後置記法も自然に扱えるのがわかりやすいポイントです。

3. エバリュエーターの実装

エバリュエーターはASTを辿りながら、実際にプログラムを実行します。

evaluate(node: ASTNode): RuntimeValue {
  switch (node.type) {
    case 'number':
      return node.value;

    case 'arrayAccess': {
      const array = this.evaluate(node.array) as RuntimeValue[];
      const index = this.evaluate(node.index) as number;
      if (!Array.isArray(array)) {
        throw new Error('配列アクセスは配列に対してのみ可能です');
      }
      return array[index];
    }

    case 'block': {
      let lastValue: RuntimeValue = null;
      for (const statement of node.statements) {
        const result = this.evaluate(statement);
        // ブロック内でreturnがあった場合は即座に返す
        if (result && typeof result === 'object' && 'type' in result && result.type === 'return') {
          return result;
        }
        lastValue = result;
      }
      return lastValue;
    }
    // ...
  }
}

変数はMapで管理し、関数呼び出し時は新しいスコープを作ることで、ローカル変数を実現しています。実装では呼び出し前の環境を丸ごとコピーしてから引数を書き込み、処理が終わったら復元することで簡易的なスコープチェーンを再現しています。また、ループ変数のスコープも適切に管理し、ネストされたループが正しく動作するようにしました。

二項演算はbinaryケースで一括して扱い、足し算だけは片方が文字列なら自動的に文字列連結に切り替えるようにしています。それ以外の演算は数値同士であることを前提に評価しているので、不要な型変換が起きません。論理演算子もここにまとめてあり、ASTレベルで短絡評価は行いませんが、式全体の見通しを良くする狙いであえて一か所に集約しています。

グローバル環境 (定義 変数 = 値)
  -> 各関数呼び出しで生成されるローカル環境 (引数・一時変数)

環境をこのように階層化しておけば、同名の変数でも外側と内側で役割を分けて扱えるため、予期せぬ上書きを防げます。

サンプルプログラム集

1. Hello World

まずは定番の挨拶プログラムから:

// 挨拶プログラム
定義 名前 = "太郎"
定義 年齢 = 20

表示 "こんにちは、"
表示 名前
表示 "さん!"

もし 年齢 >= 20 ならば {
  表示 "成人おめでとうございます!"
} それ以外 {
  表示 "もうすぐ成人ですね!"
}

実行結果:

こんにちは、
太郎
さん!
成人おめでとうございます!

2. 関数の定義と呼び出し

簡単な計算をする関数の例:

// シンプルな関数の例
関数 二倍(x) {
  返す x * 2
}

関数 二乗(x) {
  返す x * x
}

関数 合計(a, b, c) {
  返す a + b + c
}

表示 "5の2倍は:"
表示 二倍(5)

表示 "7の二乗は:"
表示 二乗(7)

表示 "1+2+3は:"
表示 合計(1, 2, 3)

実行結果:

5の2倍は:
10
7の二乗は:
49
1+2+3は:
6

3. ループ処理

繰返文を使った例:

// 1から10まで数える
表示 "1から10まで数えます:"
繰返 i = 1 から 10 まで {
  表示 i
}

// 偶数だけ表示
表示 "偶数だけ:"
繰返 i = 1 から 10 まで {
  もし i % 2 == 0 ならば {
    表示 i
  }
}

実行結果(一部):

1から10まで数えます:
1
2
3
...
偶数だけ:
2
4
6
8
10

4. FizzBuzz

そして定番のFizzBuzz:

// FizzBuzzプログラム(お抹茶言語)
繰返 i = 1 から 100 まで {
  もし i % 15 == 0 ならば {
    表示 "FizzBuzz"
  } それ以外 {
    もし i % 3 == 0 ならば {
      表示 "Fizz"
    } それ以外 {
      もし i % 5 == 0 ならば {
        表示 "Buzz"
      } それ以外 {
        表示 i
      }
    }
  }
}

日本語の予約語を使うことで、プログラムの流れが直感的にわかりやすくなりました。繰返もし〜ならばといった表現は、自然な日本語に近い形で書けます。

実行すると、きちんと1から100までの数値でFizzBuzzが出力されます(最初の15個):

1
2
Fizz
4
Buzz
Fizz
7
8
Fizz
Buzz
11
Fizz
13
14
FizzBuzz

5. バブルソート(配列の操作)

配列とネストされたループを使った、完全なバブルソートの実装:

// バブルソート(ブロック構文版)

// 配列を使った実装
定義 配列 = [5, 2, 8, 1, 9]
定義 サイズ = 5

表示 "ソート前:"
繰返 i = 0 から 4 まで {
  表示 配列[i]
}

// バブルソートアルゴリズム
繰返 i = 0 から 3 まで {
  繰返 j = 0 から 3 まで {
    もし 配列[j] > 配列[j+1] ならば {
      定義 一時 = 配列[j]
      配列[j] = 配列[j+1]
      配列[j+1] = 一時
    }
  }
}

表示 "ソート後:"
繰返 i = 0 から 4 まで {
  表示 配列[i]
}

実行結果:

ソート前:
5
2
8
1
9
ソート後:
1
2
5
8
9

配列のインデックスアクセス(配列[i])と代入(配列[j] = 値)が自然に書けるようになりました。これにより、より実用的なアルゴリズムも実装できます。

6. 再帰関数(フィボナッチ数列)

再帰を使った関数の例:

// フィボナッチ数列を出力するプログラム

関数 フィボナッチ(n) {
  もし n <= 0 ならば {
    返す 0
  } それ以外 {
    もし n == 1 ならば {
      返す 1
    } それ以外 {
      返す フィボナッチ(n - 1) + フィボナッチ(n - 2)
    }
  }
}

// フィボナッチ数列の最初の10項を出力
表示 "フィボナッチ数列:"
繰返 i = 0 から 9 まで {
  表示 フィボナッチ(i)
}

実装で学んだこと

インタプリタの3要素が綺麗に分離できる

トークナイザー、パーサー、エバリュエーターという3つの要素が、それぞれ独立した責務を持っていることがよくわかりました。これにより、各部分を個別にテストしたり、改良したりすることができます。

ブロック構文の重要性

最初は単一文のみのサポートでしたが、{}によるブロック構文を導入することで、言語の表現力が格段に向上しました。これは、言語設計において構文の選択がいかに重要かを実感する経験でした。

おわりに

プログラミング言語を作るというと難しそうですが、トークナイザー、パーサー、エバリュエーターという3つの要素に分けて考えれば、意外と理解しやすいことがわかりました。

今回はJavaScriptの構文を日本語化し、さらにブロック構文や配列サポートを追加することで、実用的な言語に近づけることができました。特に、完全なバブルソートアルゴリズムが実装できるようになったのは大きな進歩です。

今回のコードは全てGitHubで公開しています。

https://github.com/kawanoRiku0/omacha

ぜひ自分だけのオリジナル言語を作ってみてください。インタプリタの仕組みを理解することで、普段使っているプログラミング言語への理解も深まるはずです。

もし「お抹茶」言語に興味を持っていただけたら、ぜひ改良のアイデアを教えてください!プログラミング言語を作る楽しさを、多くの人と共有できれば嬉しいです。