Skip to content

SotaTne/Make-programing-language-lesson

Repository files navigation

導入

おそらく、プログラミング言語を作ってみたいという人はたくさんいると思いますが、敷居が高そうであったり、専門書などを読んで意味のわからなさで挫折したという人も多いと思います。 今回は、そんな方やプログラミング初心者の方にもわかるという記事を目標に書いてみました。 いくつかの処理はわかりづらくなるため飛ばしていたり、私の知識が間違っている部分もあると思うのでそこはバンバン指摘してください。

自己紹介

現在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のようなインタプリタは実装されています。

この3つのやっていること

この字句解析構文解析意味解析+実行が行なっていることをものすごくざっくりというと、 文字列を読み、それを種類分けした単語の集まりに変えて、その単語の集まりを文法のルールから構造に分けて、構造の細かい部分から実行する ということを行なっています。 なんとな〜くは何をすればいいか理解していただけましたか? ここからはソースコードを交えながらより細かく解説していこうと思います。

本題-プログラミング言語の作り方

ここからが本題です。今回は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. 数字(1~9)が来ると、現在の文字が文字列の何番目かをSTARTに記録する
  2. 数字(0~9)が来ると、今見ている文字を次の文字へと移動する
  3. もし、最後の文字または、数字以外のだと終了する
  4. 現在の文字が文字列の何番目かをENDに記録する
  5. 文字列のSTARTからENDー1までの文字valとしてNUMBERTokenを返せば良い
123+

のような場合

0番目 1番目 2番目 3番目
1 2 3

なので、START0番目で、END3番目。返却するval0~2番目までの123となる

Lexerのコード

ここまでで、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よりも出力する量は配列の数は減っていますが、正直一番ここが複雑だと思うので頑張ってください

処理方法の前に

おそらく、この記事を読む方はプログラミング言語の作り方の本やサイトで挫折した方や、これから挑戦しようという方が多いでしょう。 そのため、ただ処理を書くだけではなく、皆さんが作るオリジナル言語に応用を利かせたり、他の本やサイトでもここで得た知識を使えるようにするため、文脈自由文法を交えながら解説をしようと思います。

文脈自由文法とは?

自作言語を初めて作る方は、おそらく初めましての言葉だと思います。 文脈自由文法をすごく簡単にいうと、プログラミング言語を作れるほどに強力な人間の考えて文法のルール です。ですので、これはプログラミング言語のためのものではなく言語学で考えられていたものをコンピュータ・サイエンスの領域に持ってきたものです。 これを使うメリットは、機械的に文法を解析できるという点です。これによりプログラミングで再現しやすく、効率的な解析を行えるというメリットがあります。

文脈自由文法でのルール

文脈自由文法では、機械的に処理するためにいくつかのルールがあります。 そのルールの最も重要なものが非終端記号は必ず最終的に終端義号に変換されるというものです。 おそらく大半の人が ??? という状態だと思いますが、先に規則についてくわしく解説させてもらいます。

まず、文法Gについて$G = (V_n, V_t, R, S)$と定義します

これは、この言語はこの4つの要素からのみでできているという意味です。

ここで、

  • $V_n$ は非終端記号の集合
  • $V_t$ は終端記号の集合
  • $R$ は生成規則の集合
  • $S$ は開始記号 となります。

これで、さらに ??? となった方もいると思いますが、ここから一つ一つ解説するので安心してください。

非終端記号$V_n$

まず、非終端記号について説明します。 例えば、

a = 100*2+3

というプログラムがあったとします。 これ全体を<プログラム>とすると、この<プログラム>のは、a=,100*2+3のような部品に分解できますよね? この、まだ分解先を残している<プログラム>や、100*2+3のようなものを非終端記号と言います。

終端記号$V_t$

次に終端記号について説明します。 終端記号とは、非終端記号とは逆にこれ以上変化することのない値のことです。 例えば100=などは、これ以上変化させることはできませんよね? このようなものを終端記号と言います

生成規則$R$

この4つの中で唯一のルールである生成規則について説明します。 例えば

a = 100*2+3

のようなソースコードがあったとしましょう。 はじめに説明した通り、非終端記号は必ず最終的に終端義号に変換されるというルールが文脈自由文法ではあります。 このルールに則って考えると、このソースコード自体が非終端記号だということができますよね? ですが、一気にソースコードから、終端記号まで変換するのはおそらく欲しいであろう構造を作れないし、欲しい処理ではないです。 そのため、生成規則は非終端記号$\Rightarrow$非終端記号または、非終端記号$\Rightarrow$終端記号などの少しずつ変換していくルールを持っています。また、すべての変換を非終端記号 $\Rightarrow^*$(0回以上の変換)終端記号と、表すこともできます。 文脈自由文法の説明の最後にBNF(バッカス・ナウア記法)を用いた例を書いておきます

開始記号$S$

ここまで、非終端記号,終端記号,生成規則と説明してきましたが、これが最後です。 文脈自由文法の最後要素である生成規則の説明の前に、一度、文脈自由文法の大切なルールをもう一度おさらいしましょう。 はじめに説明した通り、非終端記号は必ず最終的に終端義号に変換されるというものです。 つまり、プログラム自体も何かから始まるということです。開始記号はその何かを指します。 具体的に説明すると、$S\Rightarrow^*V_t$となるもので、変換される元を辿っていったら行き着く先とも言えます。

ここまでで文脈自由文法の説明が全て完了しました。 今回は、具体的な数学や論理学の用語はできるだけ使わずに説明しましたが、少し難しかったと思います。 おまけとして、今回の言語で使う予定のBNFを以下に書いているのでよければ参考にしてみてください

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を整理するとして、手順を考えていきましょう

大まかな処理

  1. Stmt(文)を読む
  2. Expr(式)を読む 主に、処理自体が全く違うものはこの二つだと思います。 では、まずStmt(文)をどうやって読めばいいかを考えてみましょう

Stmt(文)の読み方

今回定義しているStmtはprintStmtexprStmtのたった二つだけなので結構処理は楽ですが、for文なども同じような方法で実装することができます。 以下は、Stmtの集合のようなものです

a = 10+2*4
print a \n

Stmt(文)の処理方法

現在のTokenprintかどうかを確認 printの場合、printStmtの読み方で説明します それ以外の場合、exprStmtの読み方で説明します

printStmtの読み方

BNFを読むとわかる通り、printStmtは、右側に、"print"左側に<expr>を持つものです。 つまり、printが来て、次に****が来て、最後に\nがくるというふうに読めばいいということです。 イメージしずらいかもなので、例を挙げると

print 1+3+4 \n

このようなStmt(文)を読むことができます。 1+3+4は?と思うかもしれませんが、これを処理するのは**Expr(式)**で処理をします。

exprStmtの読み方

こちらは、printStmtの後だと簡単ですね これは左辺を持たずに<expr>の後に\nが来ているものです。 こちらも例を挙げると

a=100*2+3
1*a \n

このようなStmt(文)を読むことができます。 この言語の文法では、a=100*2+3などの代入や変数の定義も**Stmt(式)**として扱います。

Expr(式)の読み方

ここからは先ほどより、少し難しくなりますよ。 優先順位のことを考えながら実装する必要があるためです。 一度<expr>についてのBNFを読んで少しおさらいしてみましょう

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番目 ...
print a = ...
"print" <arith_expr> "="

と表現することもできます。 2列目はイメージしやすいようにしているため本当は、もう少し定義をしないとダメなんですが一旦このまま進めます。

では、処理の方に戻りましょう。 ここで重要なことは、現在いるのが<arith_expr>の次に来るのが=であるかということです。 ここで重要なのが、"="を読むと、現在見ているTokenを一つ進めなければならないということです。 そうじゃないと次の処理にいけませんからね もし、=ならばそれはとしてもう一度処理する必要があり、そうでないなら<arith_expr>として処理をすれば良いということです。 この処理は、<arith_expr>を処理した後に=だったら<expr>で再度処理して、そうではなければ処理を終了すればいいということがわかります。

つまり、とりあえず<arith_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の処理で現在読んでいる配列の位置を移動させるときは、終端記号が来たときだけです。

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ではソースコードをこれまで作ってきたLexerParserに通したものを実行していくものです。 つまり、これまでとは違い何か文字を返す関数を作るわけではありません。 一応以下に、print関数と四則演算を用いたParseに通した配列と、それによって出力されるものをまとめたものを書いておきます。

このParserを通した配列により、以下が出力されます

このような実行結果を出力します。

引用元

https://www.chiba-kc.ac.jp/user/~iseri/siryo/card.html

About

this is very simple language make in python

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages