Explore "Full-Stack" in depth!

情報系の専門学校で、今は機械学習に的を絞って学習中。プログラミングを趣味でやりつつ、IT系のあらゆる知識と技術を身に付けるべく奮闘中。

自作インタプリタ言語「Gopaz」の紹介(鋭意開発・拡張中)

目次

概要

自作のインタプリタ言語を作っています。

GoとRubyのハイブリッドみたいな言語を作りたいと考えています。
ご存知のように言語自作には途方もない時間とコード量がかかるので、
気長にやっています。

今回は自作言語の紹介実装上の工夫点等をお話できればなと思っています。
言語作ってみたいなあと思っている方は多いと思うので、
是非参考にして頂ければと思います。


前提:monkey-patch

言語自作を作成するにあたり、
言語処理系の基礎知識が得られればと思って以下の書籍を購入しました。

Go言語でつくるインタプリタ

Go言語でつくるインタプリタ

書評で述べた通り、
現状言語処理系に入門する上で最強の教材だと思います。

書評の記事はこちら。

drumato.hatenablog.com

この書籍全体を通して開発するMonkey言語に対して、
自身で気になった機能をいろいろ拡張してみたりして、
クラッチで作るインタプリタの開発フローを学びました。
これを勝手にMonkey-Patchと呼んでいます。

因みにモンキーパッチはメタプログラミングの用語とかけていたり…

Monkey-patchについての記事はこちら。

drumato.hatenablog.com

drumato.hatenablog.com

本題 Gopaz

書籍も読破し、やっぱり自分で作りたい!と思ったので、
フルスクラッチインタプリタを作り始めたのです。

それがGopazです。
RubyをイメージしてTopazとGolangをかけていたりします。

デモというか、
現状Gopazインタプリタのサポートする機能を紹介した後、
実際の出来る事を順に紹介していきます。

https://i.imgur.com/vf7KdzO.gif

ご覧の通り、

  • Gopazの構文で書かれたプログラムの実行(所謂普通のプログラム実行)
  • インタプリタ(これを Read-Eval-Print-Loop と言います)

が出来ます。

それでは、実際に出来る事を見ていきましょう。


出来ること

もしかしたら書き忘れている仕様があるかもしれませんが、
思いつく限り紹介します。

変数宣言・代入

変数宣言は var で行います。

var x := 3.14 #型を指定しないバージョン  
var x int = 200 #型を指定したバージョン

実はこの静的型付けっぽい書き方はハリボテで、
GopazはGolangの型システムに"おんぶにだっこ"なので、
あまり意味なかったりします。

型の概念自体はあるんですけど、
3.14とか3とかって書き方の時点で
リテラルの型が定まっているので、
わざわざ指定する必要は今の所無いです。

代入時に指定された型と右辺の式を比較し、
期待されたものでなければエラーを返すようには実装したいと思いますね。

Goの型システムに寄りかからず、
型システムから自作するのが一つの目標ですが、
私が本当に作りたいのはコンパイラなので、
それぐらい本格的にやるのはGopazではないかもしれません。

因みに変数に再代入する時には、

var x := "STRING"
x = "hahaha"

とやります。
ここらへんはGoに似ていますね。

コメント

既に見せたのでわかると思いますが、

# これはコメントアウト

コメントはこのように行います。

シェルスクリプトRubyPythonのような感じです。

単純に//だと 切り捨て除算と紛らわしい よなぁという理由ですね。

If「式」

Gopazでは 条件分岐は式 です。
つまり 値を返す 構文です。

具体例を見せます。

f:id:orangebladdy:20190314200729j:plain

このような事が出来るというわけです。

勿論 else ifにも対応しています。

var x := 30
if x > 10 {
  puts("x > 10") #=>puts()については後述
} elsif x > 0 {
  puts("x > 0")
} else {
  puts("ELSE")
}

elsifRubyの構文ですね。
個人的にはPythonelifより気に入っています。

if文で出来る事といえばこれくらいですが、
できればGoのように条件式の前に代入文を置けるようにしたい
というのと、

Rubyの後置if,後置unlessは実装したいですね。
Rubyで特に気に入っている仕様です。

数値計算

puts(5 + 5) #足し算
puts(5 - 5) #引き算
puts(5 * 5) #掛け算
puts(5 / 5) #割り算
puts(5 ** 5) #累乗
puts(-5) #負の符号
puts(5 // 3) #切り捨て除算
puts(5 % 3) #剰余

puts(5.0 + 5.0) #Floatにも対応
puts(5.0 - 5.0)
puts(5.0 * 5.0)
puts(5.0 / 5.0)
puts(5.0 ** 5.0)
puts(-5.0)
puts(5.0 // 3.0)
puts(5.0 % 3.0)

言わずもがな、数値計算も出来ます。

プログラミング言語の勉強ではまずはじめに
数値計算をやらされますよね。

比較演算子

puts(5 == 5)
puts(5 != 5)
puts(5 <= 5)
puts(5 >= 5)
puts(5 > 5)
puts(5 < 5)

Monkey言語には <=とか>=を実装しないんですよね。
本書の流れで中置構文解析関数は定義するので、
特に手間ではないし作ればいいと思ったのですが…
何か理由があるんでしょうか。

文字列処理

"Hello"+" "+"World" #=>Hello World
"Hello"*3 #=>HelloHelloHello
"AIUEO"[3] #=>E

Python文字列のリピート はめちゃくちゃ気に入っているので実装しました。

文字列のインデックス指定は あるケースで実装がかなり難しいのですが、
先にその対策をしておいたおかげで簡単に実装できましたね。
そのケースは後述します。

配列処理

[1,2,3] + [4,5] #=>[1,2,3,4,5]
[1,2,3][-1] #=>3

配列の結合や負のインデックス指定
自分で実装しました。
便利ですし、事実それに助けられたことも多かったので…。

組み込み関数

結構あるので簡単に。

puts("Hello,World!") #=>括弧内のオブジェクトを標準出力
typeof("Foo") #=>括弧内のオブジェクトの型を返す
len([1,2,3,4,5]) #=>配列・文字列の長さ

var x := [1,2,3,4,5]

first(x) #=>1 最初の要素
last(x) #=>5 最後の要素

rest(x) #=>[2,3,4,5] pop的な?勿論非破壊的操作

push(x,6) #=>[1,2,3,4,5,6]
x #=>[1,2,3,4,5]

max([1,3,5,7,9]) #最大値。現状intのみ対応
max(1,2,3,4,5,5,6) #任意数の整数を受け取ることも


min([1,3,5,7,9]) #maxの最小値バージョン
min(1,2,3,4,5,5,6)

sqrt(4) #=>2を返す。平方根。

これら組み込み関数は作っていて楽しいですね。


実装上の工夫

ここからはより内部の実装に踏み込んで、
個人的に工夫したことを話していきます。

Lexer

まずは字句解析器から。

type Lexer struct {
    input                  string
    position, readPosition int
    ch                     rune //byteではなくrune
    Line                   int //行番号
    Col                    int //列番号
    Filename               string //ファイル名
}

書籍からかなり改造しました。
runeにした理由については後述します。

行番号・列番号・ファイル名はエラー情報の強化に必要だったので作成。

字句解析時に入力文字列のオフセットを進めるreadChar()で、

func (l *Lexer) readChar() {
    if l.readPosition >= len(l.input) {
        l.ch = 0
    } else {
        l.ch = rune(l.input[l.readPosition])
    }
    l.position = l.readPosition
    l.readPosition++
    l.Col++
}

のように列を進めています。

マルチバイト文字対応

文字列をruneごとに読み進める事で、
マルチバイト文字に対応出来ています。

例えば識別子(変数名や予約語)をトークン化する関数では、

func (l *Lexer) readIdentifier() string {
    position := l.position
    for isLetter(l.ch) {
        l.readChar()
    }
    return string([]rune(l.input[position:l.position]))
}

のようにすることで日本語の変数も出来ます。
まぁ今のところisLetter()がアルファベットしか受け付けないので、
日本語変数は後々という感じですね。

byteからruneにする最大の利点は次の関数で得られます。

func (l *Lexer) readString() string {
    position := l.position + 1
    for {
        l.readChar()
        if l.ch == '"' || l.ch == 0 {
            break
        }
    }
    return string([]rune(l.input[position:l.position]))
}

これは文字列リテラル等のトークン化関数です。
要は二重引用符で囲まれた部分ということですね。

このようにすることで、

f:id:orangebladdy:20190314203933j:plain

の日本語の文字列をインデックス指定できるようになります。

rune毎の字句解析をしなければ(例、次のコード)、

package main

import "fmt"

func main() {
        fmt.Println(string("astこんにちはParser"[3])) //バイト毎になっている
}

f:id:orangebladdy:20190314203936j:plain

このように日本語一文字の指定が出来ません。


Parser

Parserに限らず全体的に言えることとして、
シングルファイルだとコードの見通しが立ちづらいので、
ある程度肥大化したらファイルは小分けにしましょう。
特に言語は細かくテスト書いて拡張という流れをたくさん踏むので、
小さな変更時に該当箇所を当てられるようにすべきです。

f:id:orangebladdy:20190314204247p:plain

こんな感じ。

func (p *Parser) errorFormatter() string {
    if p.l.Filename == "" {
        return ""
    } else {
        return fmt.Sprintf(errorFormat, p.l.Filename, p.l.Line, p.l.Col)
    }
}

これはParseErrorという、
プログラムが評価された結果のエラーではなく
構文解析時エラーを出力する際にフォーマットをかける関数です。

先程用意したファイル名・行・列の情報を整形して出力します。
(errorFormatはエラー文字列のテンプレとして定義された定数)


テスト駆動開発・CI

最後に、今開発技術として最も重要であろう
CIについて軽く試してみたのでその話をしたいと思います。

冒頭に貼ったGopazのリポジトリを見て頂ければわかるように、

f:id:orangebladdy:20190314205002j:plain

CircleCIとCodecovのバッジが表示されています。

リポジトリにも .circleciディレクトリがありますよね。

どうせテストを書いているのだからCIにも慣れておこう流行っているしという適当な理由ではありますが、
現在CIは最優先で習得するべき開発技術と言えそうなので、
ここでいい経験が出来て良かったです。

config.ymlは次のようになっています。

version: 2
jobs:
  build:
    docker:
      - image: circleci/golang:1.10
      

    working_directory: /go/src/gopaz/
    steps:
      - checkout

      - run: go get -v -t -d github.com/c-bata/go-prompt github.com/logrusorgru/aurora github.com/urfave/cli
      - run: go get github.com/pierrre/gotestcover
      - run: gotestcover -coverprofile=coverage.txt ./...
      - run: bash <(curl -s https://codecov.io/bash)
- run: go test -v ./...

総評

いかかで
この記事が入門者にとっても、
Golangの使い方としても、
その他多くの言語自作者にとっても有益な記事であれば幸いです。