おそらく、プログラミング言語を作ってみたいという人はたくさんいると思いますが、敷居が高そうであったり、専門書などを読んで意味のわからなさで挫折したという人も多いと思います。 今回は、そんな方やプログラミング初心者の方にもわかるという記事を目標に書いてみました。 いくつかの処理はわかりづらくなるため飛ばしていたり、私の知識が間違っている部分もあると思うのでそこはバンバン指摘してください。
現在RpakaScriptというJavaScriptと互換性のあるAssemblyScriptを用いたスクリプト言語を開発しているSotatneです。 まだまだ勉強している段階のため、一つのアウトプットとして今回の記事を書くことにしました。 最近はReactのようなライブラリの自作に興味を持っており、仮想Domの勉強をしています。
今回解説ように制作したプログラミング言語はMITライセンスのを用いてGitHub上で公開中です。
これについては色々ツッコミが入りそうなところではありますが、すごくざっくりというとコンピュータに命令することのできるものという解釈が一般的だと思います。 現在ではPythonやJavaのような言語が主につかわれていますが、昔はパンチカードや紙テープなどの共通テストの回答用紙のようなものやも使われていたみたいです。
| パンチカード | 紙テープ | パンチカードと紙テープ |
|---|---|---|
![]() |
![]() |
![]() |
詳しい情報は井芹 康統 教授のホームページからご覧ください。 https://www.chiba-kc.ac.jp/user/~iseri/siryo/card.html
本題としてじゃあ、そのプログラミング言語をどうやって作るのかということについて解説しようと思います。 先に注意として本来は最適化などのプロセスを挟むのですが、今回は本当に必要最低限の部分だけ実装しようと考えております。 もし、他の詳しい部分に挑戦したい方はもしかしたらその記事を書くかもしれないのでそちらを参考にするか、もっと詳しいことの書いてある記事や本などで勉強してみてください
では、はじめにプログラミング言語の構造について解説したいと思います。 本来はここには最適化や中間言語などが入ってきたりしますが、今回はそれを除いたものすごくシンプルな言語についての構造です。
- 字句解析(Lexer)
- 構文解析(Parser)
- 意味解析+実行(Interpreter)
この三つによって最小限の機能を持ったPythonのようなインタプリタは実装されています。
この字句解析構文解析意味解析+実行が行なっていることをものすごくざっくりというと、 文字列を読み、それを種類分けした単語の集まりに変えて、その単語の集まりを文法のルールから構造に分けて、構造の細かい部分から実行する ということを行なっています。 なんとな〜くは何をすればいいか理解していただけましたか? ここからはソースコードを交えながらより細かく解説していこうと思います。
ここからが本題です。今回はPythonを用いて300行程度のソースコードで字句解析~意味解析+実行までを標準ライブラリのみで作ろうと思います。
字句解析はLexerとも呼ばれる字句解析機を使って行います。 まず、Lexerで何をしてほしいかを少し整理してみましょう。
1+2*3のような文字列を
[
{
"type":"NUMBER",
"val":"1"
},
{
"type":"ADD",
"val":""
},
{
"type":"NUMBER",
"val":"2"
},
{
"type":"STAR",
"val":""
},
{
"type":"NUMBER",
"val":"3"
},
]このようTokenの配列として返すようにします このように種類ごとに分けられた単語をTokenと呼びます。一部のTokenでは、それが値を持っている場合があります。
早速コードを書いてもいいのですが、まず、どのように処理をしていけばいいかを考えてみましょう。
みている文字が+だったらADD , ーだったらMINUSを返すように見ている記号に対応するToken返せばいい
- 数字(1~9)が来ると、現在の文字が文字列の何番目かをSTARTに記録する
- 数字(0~9)が来ると、今見ている文字を次の文字へと移動する
- もし、最後の文字または、数字以外のだと終了する
- 現在の文字が文字列の何番目かをENDに記録する
- 文字列のSTARTからENDー1までの文字valとしてNUMBERのTokenを返せば良い
123+のような場合
| 0番目 | 1番目 | 2番目 | 3番目 |
|---|---|---|---|
| 1 | 2 | 3 | + |
なので、STARTが0番目で、ENDが3番目。返却するvalが0~2番目までの123となる
ここまでで、Lexerをどのように実装すればいいかがなんとな〜くわかったと思うのでそのソースコードについてPythonで書こうと思います。
typesMap = {
"+": "ADD", # + の時ADDを返す
"-": "MINUS", # - の時MINUSを返す
"*": "STAR", # * の時STARを返す
"/": "SLASH", # / の時SLASHを返す
"=": "EQUAL", # = の時EQUALを返す
"\n": "NEWLINE", # \nの時NEWLINEを返す
"NUMBER": "NUMBER", # 数値の時NUMBERを返す
"IDENTIFY": "IDENTIFY", # 変数や関数の名前に当てはまる時、IDENTIFYを返す
}
# 今回返却するTypeのまとまり
def lexer(input_str):
tokens = [] # 返すTokenのList
current = 0 # 現在地について
while current < len(input_str):
# 数字のトークン化
if "1" <= input_str[current] <= "9": # 1~9の時
START = current # STARTを現在の文字が何番目かに保存
END = current
while True:
current += 1
if (
current >= len(input_str) - 1 or not input_str[current].isdigit()
): # 最後の文字、または0~9じゃない時
END = current # ENDを現在の文字が何番目かに保存
break
# Pythonの[A:B]はA~B-1までのindex番号の要素をとってくるもの
tokens.append({"type": typesMap["NUMBER"], "val": (input_str[START:END])})
# 識別子(変数名など)のトークン化
elif (
input_str[current].isalpha() or input_str[current] == "_"
): # アルファベットかアンダーバー(_)の時
START = current
END = current
while True:
current += 1 # 1つ次の文字へ移動
if (
current >= len(input_str) - 1
or (
not input_str[current].isalnum()
and not input_str[current] == "_"
and not input_str[current].isdigit()
)
): # 最後の文字でなくて、アルファベットでもアンダーバー(_)でも数字でものないとき
END = current
break
# Pythonの[A:B]はA~B-1までのindex番号の要素をとってくるもの
tokens.append({"type": typesMap["IDENTIFY"], "val": input_str[START:END]})
# 演算子のトークン化
elif (
input_str[current] in typesMap
): # typesMapの中に、当てはまる文字があるかどうか
tokens.append({"type": typesMap[input_str[current]], "val": ""})
current += 1
# 空白文字をスキップ
elif input_str[current].isspace():
current += 1
# 全て当てはまらない時エラーを出力する
else:
print("Lexer Error: Invalid character")
exit()
return tokensここまででどのようにLexerが動き、どうやって作ればいいかがわかったと思います。 これでLexerについての説明は終了です。
ここからは構文解析について書いていこうと思います。 構文解析はParserとも呼ばれる構文解析機で行います。 先ほどと同じように何をして欲しいかを整理してみましょう
[
{
"type":"NUMBER",
"val":"1"
},
{
"type":"ADD",
"val":""
},
{
"type":"NUMBER",
"val":"2"
},
{
"type":"STAR",
"val":""
},
{
"type":"NUMBER",
"val":"3"
},
]このようなTokenの配列を
[
{
"val": {
"type": "EXPR",
"op": "ADD",
"left": "1",
"right": {
"type": "EXPR",
"op": "STAR",
"left": "2",
"right": "3",
},
}
]このような配列に変換して返すようにします Lexerよりも出力する量は配列の数は減っていますが、正直一番ここが複雑だと思うので頑張ってください
おそらく、この記事を読む方はプログラミング言語の作り方の本やサイトで挫折した方や、これから挑戦しようという方が多いでしょう。 そのため、ただ処理を書くだけではなく、皆さんが作るオリジナル言語に応用を利かせたり、他の本やサイトでもここで得た知識を使えるようにするため、文脈自由文法を交えながら解説をしようと思います。
自作言語を初めて作る方は、おそらく初めましての言葉だと思います。 文脈自由文法をすごく簡単にいうと、プログラミング言語を作れるほどに強力な人間の考えて文法のルール です。ですので、これはプログラミング言語のためのものではなく言語学で考えられていたものをコンピュータ・サイエンスの領域に持ってきたものです。 これを使うメリットは、機械的に文法を解析できるという点です。これによりプログラミングで再現しやすく、効率的な解析を行えるというメリットがあります。
文脈自由文法では、機械的に処理するためにいくつかのルールがあります。 そのルールの最も重要なものが非終端記号は必ず最終的に終端義号に変換されるというものです。 おそらく大半の人が ??? という状態だと思いますが、先に規則についてくわしく解説させてもらいます。
これは、この言語はこの4つの要素からのみでできているという意味です。
ここで、
-
$V_n$ は非終端記号の集合 -
$V_t$ は終端記号の集合 -
$R$ は生成規則の集合 -
$S$ は開始記号 となります。
これで、さらに ??? となった方もいると思いますが、ここから一つ一つ解説するので安心してください。
まず、非終端記号について説明します。 例えば、
a = 100*2+3というプログラムがあったとします。
これ全体を<プログラム>とすると、この<プログラム>のは、aや=,100*2+3のような部品に分解できますよね?
この、まだ分解先を残している<プログラム>や、100*2+3のようなものを非終端記号と言います。
次に終端記号について説明します。
終端記号とは、非終端記号とは逆にこれ以上変化することのない値のことです。
例えば100や=などは、これ以上変化させることはできませんよね?
このようなものを終端記号と言います
この4つの中で唯一のルールである生成規則について説明します。 例えば
a = 100*2+3のようなソースコードがあったとしましょう。
はじめに説明した通り、非終端記号は必ず最終的に終端義号に変換されるというルールが文脈自由文法ではあります。
このルールに則って考えると、このソースコード自体が非終端記号だということができますよね?
ですが、一気にソースコードから、終端記号まで変換するのはおそらく欲しいであろう構造を作れないし、欲しい処理ではないです。
そのため、生成規則は非終端記号
ここまで、非終端記号,終端記号,生成規則と説明してきましたが、これが最後です。 文脈自由文法の最後要素である生成規則の説明の前に、一度、文脈自由文法の大切なルールをもう一度おさらいしましょう。 はじめに説明した通り、非終端記号は必ず最終的に終端義号に変換されるというものです。 つまり、プログラム自体も何かから始まるということです。開始記号はその何かを指します。 具体的に説明すると、$S\Rightarrow^*V_t$となるもので、変換される元を辿っていったら行き着く先とも言えます。
ここまでで文脈自由文法の説明が全て完了しました。 今回は、具体的な数学や論理学の用語はできるだけ使わずに説明しましたが、少し難しかったと思います。 おまけとして、今回の言語で使う予定のBNFを以下に書いているのでよければ参考にしてみてください
<program> ::= <stmt> <program> | <stmt>
<stmt> ::= <printStmt> | <exprStmt>
<printStmt> ::= "print" <assign> "\n"
<exprStmt> ::= <expr> "\n"
<expr> ::= <arith_expr> "=" <expr> | <arith_expr>
<arith_expr> ::= <term> (("+" | "-") <arith_expr>) | <term>
<term> ::= <primary> (("*" | "/") <term>) | <primary>
<primary> ::= <digit> | <identify>
<number> ::= "0" | ("1" | ... | "9") <digits>
<digits> ::= <digit> | <digit> <digits>
<identify> ::= <alphabet> <alphabets>
<alphabets> ::= <alphabet> <alphabets> | <digit> <alphabets> | ε
<alphabet> ::= "_" | ("a" | ... | "z") | ("A" | ... | "Z")
<digit> ::= "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"
一応BNFについてすごく簡単に説明すると、文脈自由文法であるプログラミング言語の文法を形式的に表す一つの方法です。
<>で括っているものが非終端記号で、""で括っているものが終端記号、$V_n$::=$V_t$や、$V_n$::=$V_n$のようになっているものが生成規則で、<program>が開始記号となっています
文脈自由文法はどうでしたか?重要な概念だとわかっても正直そこまで楽しくないですよね ここまでで、皆さんは文法をなんとな〜くどうやって定義して処理すればいいかわかったと思います。 ここからはそれをより具体的なものにしていきましょう
おそらく、何をすればいいかなんとなくここまで読み進めている方はわかるが、どのような方針で進めれば良いかわからないという方が多いと思います。 ここで役に立つのが先ほど作ったBNFです。 このBNFを整理するとして、手順を考えていきましょう
- Stmt(文)を読む
- Expr(式)を読む 主に、処理自体が全く違うものはこの二つだと思います。 では、まずStmt(文)をどうやって読めばいいかを考えてみましょう
今回定義しているStmtはprintStmtとexprStmtのたった二つだけなので結構処理は楽ですが、for文なども同じような方法で実装することができます。
以下は、Stmtの集合のようなものです
a = 10+2*4
print a \n現在のTokenがprintかどうかを確認
printの場合、printStmtの読み方で説明します
それ以外の場合、exprStmtの読み方で説明します
BNFを読むとわかる通り、printStmtは、右側に、"print"左側に<expr>を持つものです。
つまり、printが来て、次に****が来て、最後に\nがくるというふうに読めばいいということです。
イメージしずらいかもなので、例を挙げると
print 1+3+4 \nこのようなStmt(文)を読むことができます。
1+3+4は?と思うかもしれませんが、これを処理するのは**Expr(式)**で処理をします。
こちらは、printStmtの後だと簡単ですね
これは左辺を持たずに<expr>の後に\nが来ているものです。
こちらも例を挙げると
a=100*2+3
1*a \nこのようなStmt(文)を読むことができます。
この言語の文法では、a=100*2+3などの代入や変数の定義も**Stmt(式)**として扱います。
ここからは先ほどより、少し難しくなりますよ。
優先順位のことを考えながら実装する必要があるためです。
一度<expr>についてのBNFを読んで少しおさらいしてみましょう
<expr> ::= <arith_expr> "=" <expr> | <arith_expr>
<arith_expr> ::= <term> (("+" | "-") <arith_expr>) | <term>
<term> ::= <primary> (("*" | "/") <term>) | <primary>
<primary> ::= <digit> | <identify>
<number> ::= "0" | ("1" | ... | "9") <digits>
<digits> ::= <digit> | <digit> <digits>
<identify> ::= <alphabet> <alphabets>
<alphabets> ::= <alphabet> <alphabets> | <digit> <alphabets> | ε
<alphabet> ::= "_" | ("a" | ... | "z") | ("A" | ... | "Z")
<digit> ::= "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"
ここでわかることはは<identify>"="<expr>または、<arith_expr>のどちらかということです。
まず、前提としてprintStmtから、この処理に来た場合はprintはすでに読まれていて、間の<expr>のみを読めばいい状態だというのはわかりますか?
また、exprStmtの場合は先に読む必要はないので、\nの前ののみを読めばいい状態というのもわかりますね。
では、ここから本格的に処理を考えていきましょう
まず、このParserに渡されているのはTokenの配列ということは覚えていますか? わかりやすくするため、以下のようなTokenの配列が渡されたと仮定しましょう
[
{"type": "IDENTIFY", "val": "print"},
{"type": "IDENTIFY", "val": "a"},
{"type": "EQUAL", "val": ""},
{"type": "NUMBER", "val": "3"},
{"type": "STAR", "val": ""},
{"type": "NUMBER", "val": "1"},
{"type": "ADD", "val": ""},
{"type": "NUMBER", "val": "2"},
{"type": "NEWLINE", "val": ""},
]こうするとprintは読み込まれているので、現在読んでいるIndex番号は1番のIDENTIFY(a)ということがわかります。
また、<expr>は<arith_expr>"="<expr>か<arith_expr>のどちらかですまた、printは先ほども言った通りすでに読んでいるので、現在いる場所は、<arith_expr>内の最初のTokenまたは、のはずです
下に図を用意したので参考にしてください
print a = 3*1+2 \nのような場合
| 0番目 | 1番目 | 2番目 | ... |
|---|---|---|---|
| a | = | ... | |
| "print" | <arith_expr> | "=" |
と表現することもできます。 2列目はイメージしやすいようにしているため本当は、もう少し定義をしないとダメなんですが一旦このまま進めます。
では、処理の方に戻りましょう。
ここで重要なことは、現在いるのが<arith_expr>の次に来るのが=であるかということです。
ここで重要なのが、"="を読むと、現在見ているTokenを一つ進めなければならないということです。
そうじゃないと次の処理にいけませんからね
もし、=ならばそれはとしてもう一度処理する必要があり、そうでないなら<arith_expr>として処理をすれば良いということです。
この処理は、<arith_expr>を処理した後に=だったら<expr>で再度処理して、そうではなければ処理を終了すればいいということがわかります。
つまり、とりあえず<arith_expr>をどのように読み取るかを考えなければならないことがわかります。
ここからは慣れたら少し楽な処理です。
まず、<arith_expr>は<term>("+"か"ー")<arith_expr>または<term>です。
ここで重要なのが、"+"か"ー"を読むと、現在見ているTokenを一つ進めなければならないということです。
処理の関係上、ずっと同じTokenを見ていると
1+2という単純な処理が内部的には1+++++++という意味のわからないプログラムとして処理されてしまうからです。
これも先ほどと同じように<term>を処理して、その後に"+"か"ー"のどちらが来ていれば、もう一度<arith_expr>を。そうでなければ処理したを返せばいいということです。
なんだか周りくどい方法をしていると思いますか?
しかし、これには以下のような式を読むことを可能にしています。
1+2+3+4これは、イメージですがこのBNFで読み込むと以下のようになります。
{
"op":"ADD",
"left":1,
"right":{
"op":"ADD",
"left":2,
"right":{
"op":"ADD",
"left":3,
"right":4
}
}
}ここまで来たら、次はどうするのか予測できる人が多いと思いますがちゃんと説明していきます
まず、<term>は、<primary>("*"か"/")<term>または、<primary>です。
ここで重要なのが、"*"か"/"を読むと、現在見ているTokenを一つ進めなければならないということです。
理由は<arith_expr>の読み方で説明した理由と同じです
これも先ほどと同じようにまず<primary>を処理して、その後"*"か"/"ある場合は、もう一度を。そうでない場合は処理した<primary>をそのまま返せば実装できます。
結構パターン化してきましたね
ここからはかなり楽ですよ
まず、<primary>は<digit>もしくは<identify>です。つまり、自然数か、変数名を調べて当てはまる場合はそれを返して、そうじゃない場合はエラーを吐くようにすればいいだけです。
BNFでは少し複雑になっていますが、いくつかの制限があるためあのような表記になっていますが、ぶっちゃけるとこれだけです。
ここが他の処理と唯一違うのが、返すものが終端記号ということです。
また、ここで重要なことが、現在見ているTokenを一つ進めなければならないということです。
なぜかというと、現在読んでいる値を読み終わったのに、もう一度その値を読むことになってしまうからです
1これが、
[
{
"type":"NUMBER",
"val":1
},
{
"type":"NUMBER",
"val":1
}
.
.
.
]のようになってしまいますからね
気づいた方もいるかもしれませんが、Parserの処理で現在読んでいる配列の位置を移動させるときは、終端記号が来たときだけです。
では、どのように処理をすれば良いかわかったところで、以下にPythonのサンプルコードを書きます。
def parser(tokens):
parsed = []
def error(message):
# エラーメッセージを表示し、プログラムを終了する
print(message)
exit()
def stmt(current):
# トークンリストの末尾に達していない場合
if len(tokens) > current:
# 式を解析し、結果を left として取得
current, left = expr(current)
# "print" 文の場合
if (
current < len(tokens)
and left["op"] == "IDENTIFY"
and left["val"] == "print"
):
# "print" 文の引数を解析
current, val = expr(current)
# 解析結果を文としてパース済リストに追加
parsed.append({"type": "STMT", "op": "PRINT", "val": val})
else:
# その他の文の場合
parsed.append({"type": "STMT", "op": "", "val": left})
# 改行トークンのチェック、または最後のトークンでない場合
if (current < len(tokens) and tokens[current]["type"] == "NEWLINE") or (
current == len(tokens) - 1 and tokens[current]["type"] != "NEWLINE"
):
current += 1
elif current < len(tokens):
# 改行がないまたは、最後のトークンでない場合はエラーを報告
print(tokens[current]["type"])
error("Expected NEWLINE in stmt")
# 再帰的に次の文を解析
stmt(current)
return current
def expr(current):
# 算術式を解析し、結果を取得
current, val = arith_expr(current)
# 代入演算子 "=" のチェック
if current < len(tokens) and tokens[current]["type"] == "EQUAL":
current += 1
# 右辺の式を再帰的に解析
current, right = expr(current)
return current, {
"type": "EXPR",
"op": "ASSIGNMENT",
"left": val,
"right": right,
}
return current, val
def arith_expr(current):
# 項を解析し、結果を取得
current, val = term(current)
# 加算または減算演算子のチェック
while current < len(tokens) and (
tokens[current]["type"] == "ADD" or tokens[current]["type"] == "MINUS"
):
Type = tokens[current]["type"]
current += 1
# 右辺の項を再帰的に解析
current, right = term(current)
val = {"type": "EXPR", "op": Type, "left": val, "right": right}
return current, val
def term(current):
# プライマリを解析し、結果を取得
current, val = primary(current)
# 乗算または除算演算子のチェック
while current < len(tokens) and (
tokens[current]["type"] == "STAR" or tokens[current]["type"] == "SLASH"
):
Type = tokens[current]["type"]
current += 1
# 右辺のプライマリを再帰的に解析
current, right = primary(current)
val = {"type": "EXPR", "op": Type, "left": val, "right": right}
return current, val
def primary(current):
# 識別子トークンのチェック
if current < len(tokens) and tokens[current]["type"] == "IDENTIFY":
val = tokens[current]["val"]
current += 1
return current, {"type": "EXPR", "op": "IDENTIFY", "val": val}
# 数値トークンのチェック
elif current < len(tokens) and tokens[current]["type"] == "NUMBER":
val = tokens[current]["val"]
current += 1
return current, {"type": "EXPR", "op": "NUMBER", "val": val}
else:
if current < len(tokens):
print(tokens[current]["type"])
error("Expected IDENTIFY or NUMBER in primary")
stmt(0) # トークンリストの先頭から解析を開始
return parsedおそらくインタプリタで一番大変なParserについての説明が終了しました。 案外簡単でしたか?
ここからは意味解析+実行につての解説を始めようと思います。 Interpreterとも呼ばれる翻訳系で行われます。 これまでと同じように何をして欲しいかを整理してみましょう。と言いたいところですが、Interpreterではソースコードをこれまで作ってきたLexerとParserに通したものを実行していくものです。 つまり、これまでとは違い何か文字を返す関数を作るわけではありません。 一応以下に、print関数と四則演算を用いたParseに通した配列と、それによって出力されるものをまとめたものを書いておきます。
このParserを通した配列により、以下が出力されます
このような実行結果を出力します。


