プログラミング言語を自作した話
数年前、「Flan」というプログラミング言語を作っていました。このプログラミング言語は長い間C++でプログラミングをしてきて感じた不満をもとに、自分好みの最高のプログラミング言語を作ろうと、そういう考えで作っていました。「Flan」は言語機能的にはだいたい完成していたのですが訳*1あって開発は中断していました。
そして中断から数年経ったわけですが、このまま埋もれさせておくのももったいないなと思い、紹介だけでもすることにしました。公開予定は今のところありません。
- サンプルコード
- プログラミング言語Flanの特徴
- 実行はバイトコードインタプリタ形式
- 静的型付け
- オブジェクト指向
- 文末にセミコロン不要
- 入れ子可能なコメントアウト
- 前方宣言不要
- 暗黙のreturn
- auto型(型推論)あり
- 初期化と代入で違う構文
- 明確な文法
- 交換演算子<=>
- 3種類の参照。所有、共有、弱参照。
- すべてが参照ではない
- 参照系演算子
- 関数オブジェクト(無名関数、クロージャ)
- 引数名の省略(引数名の自動設定)
- 引数リストの展開
- static/非staticで同名のメンバ関数
- 関数テンプレート
- 関数テンプレートのテンプレート引数の推論
- 流用テンプレート関数
- 内部クラス、内部関数
- クラステンプレート
- トレイト
- トレイトテンプレート
- ファイバー(コルーチン)
- 単体テスト
- FlanIDE
- プログラミング言語Flanの実装
- プログラミング言語を作るためのステップ
- まとめ
サンプルコード
百聞は…ということでまずはソースコード例を紹介。機能を詰め込んだサンプルになっているのでちょっとわかりずらいかもしれません。 また、Pythonコードとして無理やり構文カラーを適用してるので一部変な配色になっています。
このサンプルコードが実際に動作するくらいにはプログラミング言語Flanの開発は進んでました。
## ここはコメント #- ここもコメント -# #- ここもコメント -# ## 変数 [int]: a <- 1 ## 変数定義と初期化 [int]: b <- 2 print:( a + b ) ## 3 print:( a - b ) ## -1 print:( a * b ) ## 2 print:( a / b ) ## 0 b = 5 ## 代入 ## for文, if文 [list<string>]: strList strList.Append:( 'aaa' ) ## メンバ関数呼び出し strList.Append:( 'bbb' ) strList.Append:( 'ccc' ) for i in strList if i == 'aaa' print:( 'A' ) elif i == 'bbb' print:( 'B' ) else print:( 'X' ) end end ## while文,switch文 [int]: i while i <= 3 switch( i ) case 0: assert:( i == 0 ) case 1: assert:( i == 1 ) case 2: assert:( i == 2 ) case 3: assert:( i == 3 ) end i += 1 end ## 関数 def func:[string]( [int]a, [int]b, [int]c ) [string]a + b + c ## キャストと暗黙のreturn end assert:( func:(123, 456, 789) == '123456789' ) ## クラス class Base def dump:() ## メンバ関数 print:( 'Base.dump' ) end end class A : [Base] ## 継承 class AInner ## 内部クラス end [float]: m_Value ## メンバ変数 def @init:( [float]value = 0.0#-デフォルト引数-# ) ## コンストラクタ m_Value <- value end def @del:() ## デストラクタ [stringliteral]: infoStr <- [stringliteral]@typeinfo:() ## 型情報取得 print:( '[' + infoStr + '].@del:()' ) end def dump:() |override| ## オーバーライド print:( m_Value ) if false print:( $this.m_Value ) ## this end end def s_func:[int]() |static| ## staticメンバ関数 0 end def func:[int]([int]a,[int],[int]c) ## 引数名の省略(第二引数に注目) print:( 'func: ' + a + $arg1 + c ) ## 引数名を省略した引数へのアクセス [self].s_func:() ## [self]で自身の型 end def @unittest:() ## 単体テスト end end print:( 'staticメンバ関数呼び出し:' + [A].s_func:() ) [A]: a_ins <- 1.23 ## インスタンス作成 a_ins.dump:() ## 1.230000 [owner:A]: owner_a <- [A].@new:( 4.56 ) ## newでインスタンス作成 owner_a.dump:() ## 4.560000 ## テンプレート関数 def templateFunc<T>:([T]param) assert:( param == 10 ) end templateFunc<int>:( 10 ) ## テンプレートクラス class testTemplate<T> [T]: m_ValueT end [testTemplate<int>]: tInt assert:( tInt.m_ValueT == 0 ) ## トレイト trait named_trait [string]: name <- 'no name' def getName:[string]() name end def setName:([string]) name = $arg0 end end class Charactor has named_trait end [Charactor]: charactor charactor.setName:( 'aaa' ) ## 関数オブジェクト def getFuncObj:[func0_obj<int>]() [int]: outerVariable <- 11 return def:[int]() outerVariable += 1; outerVariable end ## 関数オブジェクトを返す end [auto]: funcObj <- getFuncObj:() ## auto型 assert:( funcObj:() == 12 ) ## 関数オブジェクトは外部変数をキャプチャしているので assert:( funcObj:() == 13 ) ## 呼び出すたびに戻り値が変わる ## ファイバー class Hoge [int]: value <- 10 end [Hoge]: hoge [fiber]: fiber <- def:() hoge.value += 1 yield ## ファイバー中断 hoge.value += 1 yield hoge.value += 1 end assert:( hoge.value == 10 ) fiber.resume:() ## ファイバー実行 assert:( hoge.value == 11 ) fiber.resume:() assert:( hoge.value == 12 ) fiber.resume:() ## ファイバー実行(そして中断されずに終了) assert:( hoge.value == 13 ) fiber.resume:() ## 終了済みファイバーを実行しても assert:( hoge.value == 13 ) ## ファイバーは終了しているので、もう値は変化しない
プログラミング言語Flanの特徴
ここで紹介する以外にも言語機能はたくさんあるのですが、とりあえず大きめなやつ、または個性的なだけ紹介します。
実行はバイトコードインタプリタ形式
FlanはFlanソースコードをバイトコードへ変換し、それをFlanVM(Flanの仮想マシン)が実行することで動作します。Luaと同じ仕組みです。ただし将来的にはC++ソースコード生成による実行も考えています。
静的型付け
FlanはLuaとは違い静的型付けです。つまり、全ての変数には型があり、型が一致しないと(変換不可だと)コンパイル時点でエラーが発生します。
オブジェクト指向
FlanはC++から影響を受けたオブジェクト指向なプログラミング言語です。C++に存在する、継承やオーバーライドなどの機能はたいてい言語仕様として含まれています。ただし、多重継承はできません。そのかわりにトレイトがあります。
文末にセミコロン不要
FlanはC++とは違い、文末にセミコロンは不要です。セミコロンを使って1行に複数の文を記述することも可能です。
入れ子可能なコメントアウト
FlanはC++とは違い、コメントアウトを入れ子にすることができます。これはLuaの影響を受けています。
## ここはコメント #- ここもコメント -# #- 入れ子 #-- なコメントアウトも --# 可能 -# #- その場コメント -# #- -10 -# #!- ここはコメントではない -#
前方宣言不要
FlanはC++とは違い、前方宣言が不要です。なぜ不要かというと、構文解析(パース)を2パスで行っているからです*2。
暗黙のreturn
Flanではreturnを明示的に記述しなくても、関数内で最後の式文が自動的にreturn文になります。
def func:[int]([int]a,[int]b) a+b ## 最後の文が自動でreturnされる end
auto型(型推論)あり
Flanでは変数の型としてauto型を使うことができます。これはC++11で導入されたauto型と同じようなもので、初期化の式の型から自動で変数の型を決めることができる機能です。
[auto]: a <- 10 [auto]: b <- 'string'
初期化と代入で違う構文
C++では初期化も代入もどちらも=
で行いますが、Flanでは初期化は<-
、代入は=
で行います。初期化と代入は別の操作なのですから、別の演算子にするべきだと思ったのでこういう仕様にしました。
また、C++では代入は式なのでif文の条件式内で使えたりしましたが、Flanでの代入は文なのでそういうことはできません。
[int]: a <- 10 ## 初期化 a = 20 ## 代入
明確な文法
C言語ではこんな書き方ができます。
(int)hoge((1+2)*3);
これをFlanで書くとこうなります。
[int]hoge:((1+2)*3)
C言語では、()
の意味がいくつもあります。上の例では、キャストと関数呼び出しと式の優先順序変更がすべて()
で行われています。Flanではキャストは[]
、関数呼び出しは:()
、式の優先順序変更は()
とすべて区別されています。
なお、Flanでは[]
で囲われた中はすべて型を表します。
交換演算子<=>
変数の中身の交換というのは普遍的な操作なので演算子として存在してもいいのではと思い、交換演算子というものを仕様に入れました。
[int]: a <- 10 [int]: b <- 20 a <=> b assert:( a == 20 ) assert:( b == 10 )
3種類の参照。所有、共有、弱参照。
Flanには3種類の参照が存在します。1つは所有。これはC++のunique_ptrのようなもので、所有者が1人であることを保証します。この参照が破棄されるとき、参照先も破棄されます。2つめは共有。これはC++のshared_ptrのようなもので、複数の所有者が存在できることを表します。この参照が破棄されるとき、他に所有者がいない場合は破棄されます。3つめは弱参照です。これはC++のweak_ptrのようなもので、所有ではなく"参照"を表します。参照先が破棄されると弱参照はnullとなります。
ソースコードとしては、所有は[owner:Hoge]
、共有は[ref:Hoge]
、弱参照は[wref:Hoge]
という記述方法になります。
すべてが参照ではない
JavaやPythonやLuaなど多くのプログラミング言語ではプリミティブ型以外のすべてが参照なことが多いですが、Flanは値型と参照型が個別に存在します。
[A]: a ## 値型 [ref:A]: ref_a ## 参照型
参照系演算子
Flanの参照型への操作は、基本的にデリファレンスしてから行われます。つまりC言語でいうところの常に*ptr
が行われるということです。例えば参照型変数a
と参照型変数b
があったとして、a = b
とした場合、a
にb
への参照がコピーされるのではなく、a
の参照先にb
の参照先が代入されます。C言語で表すと*a = *b
です。
では参照をコピーしたい場合にはどうしたらいいのか。それを行えるようにするのが参照系演算子です。例えば参照をコピーしたい場合はa := b
とします。=
ではなく:=
を使うのです。
参照系演算子は他にも参照を交換するための:<=>
、参照を比較するための:==
などがあります。どれも通常の演算子の前に:
が付いているのが特徴です。
class A [int]: m_Value def @init:( [int]value ) m_Value <- value end end [ref:A]: a0 <- [A].@new:( 0 ) [ref:A]: a1 <- [A].@new:( 1 ) a0 = a1 ## 参照先の代入 a1.m_Value = 2 assert:( a0.m_Value == 1 ) assert:( a1.m_Value == 2 ) a0 := a1 ## 参照の代入 a0.m_Value = 3 assert:( a0.m_Value == 3 ) assert:( a1.m_Value == 3 )
関数オブジェクト(無名関数、クロージャ)
Flanには通常の関数とは別に、値として扱える関数オブジェクトが存在します。この関数オブジェクトは他の言語では無名関数やクロージャとも呼ばれます。Flanの関数オブジェクトはクロージャでもあるので、その関数が定義された環境を保持(キャプチャ)します。
[int]: value <- 11 [auto]: funcObj <- def:[int]() value += 1; value end ## 関数オブジェクト assert:( funcObj:() == 12 ) assert:( funcObj:() == 13 ) assert:( value == 11 ) ## キャプチャはコピーなのでコピー元には影響がない(値型の場合)
引数名の省略(引数名の自動設定)
Flanでは関数の引数名を省略することができます。省略された引数名は$argN
のような名前が自動で設定されます。このときN
には引数の位置が入ります。
def func:[int]([int],[int],[int]) $arg0 + $arg1 + $arg2 end
引数リストの展開
Flanでは関数内で$args
を使うことで引数リストを展開することができます。これは受け取った引数をそのまま他の関数に渡す場合などに便利です。
def func:[int]([int],[int],[int]) func2:( $args ) ## 引数リストを展開 end def func2:[int]([int],[int],[int]) $arg0 + $arg1 + $arg2 end
static/非staticで同名のメンバ関数
C++ではstatic/非staticで同じ名前のメンバ関数を作ることができませんでしたが、Flanでは可能です。
class A def func:[stringliteral]() 'func:()' end def func:[stringliteral]() |static| 'func:() |static|' end end [A]: a assert:( a.func:() == 'func:()' ) assert:( [A].func:() == 'func:() |static|' )
関数テンプレート
C++のように関数テンプレートが存在します。
def templateFunc<T>:([T]param) assert:( param == 10 ) end templateFunc<int>:( 10 )
関数テンプレートのテンプレート引数の推論
これもC++にある機能です。関数への引数から、関数テンプレートのテンプレート引数を推論する機能です。
def templateFunc<T,T2=bool,T3>:([T]a,[T3]t3) assert:([T].@typeinfo:() == [int].@typeinfo:() ) assert:([T2].@typeinfo:() == [bool].@typeinfo:() ) assert:([T3].@typeinfo:() == [stringliteral].@typeinfo:() ) end ## 本来はこう書く必要があるところを templateFunc<int,bool,stringliteral>:( 1, '' ) ## このようにテンプレート引数を省略して書くことが可能 templateFunc:( 1, '' )
流用テンプレート関数
流用テンプレート関数とは、引数リストを他の関数から流用するテンプレート関数です。オーバーロードされた関数群のラッパー関数を作るのに便利です。
def hoge:[stringliteral]([int]a) 'hoge:[stringliteral]([int])' end def hoge:[stringliteral]([bool]a) 'hoge:[stringliteral]([bool])' end def hoge:[stringliteral]([stringliteral]a) 'hoge:[stringliteral]([stringliteral])' end def hoge_wrapper:[stringliteral](<hoge>) ## <hoge>という記述で流用テンプレートになる print:( 'Pre Hoge' ) hoge:($args) print:( 'Post Hoge' ) end hoge_wrapper:( 3 ) ## int型を1つ引数にとるhogeが存在するのでOK hoge_wrapper:( 5, 6 ) ## int型を2つ引数にとるhogeは存在しないのでエラーになる
内部クラス、内部関数
クラスの内部でクラスを定義することができます。また、関数内部で関数やクラスを定義することも出来ます。
class A class AInner ## 内部クラス end end def func:() def InnerFunc:() ## 関数内部関数 end class InnerClass ## 関数内部クラス end end
クラステンプレート
C++のようにクラステンプレートが存在します。
class testTemplate<T> [T]: m_ValueT end [testTemplate<int>]: tInt assert:( tInt.m_ValueT == 0 )
トレイト
トレイトとはクラスに機能を持たせるための仕組みです。Wikipediaに記事がありますが、言語機能としてトレイトを持つプログラミング言語でも、その意味は微妙に違っているようです。
乱暴に説明すると、実装を持つインターフェイス(Java)です。
trait named_trait ## 名前トレイト(機能) [string]: name <- 'no name' def getName:[string]() name end def setName:([string]) name = $arg0 end end class Charactor has named_trait ## キャラクターは名前トレイト(機能)を持つ end [Charactor]: charactor charactor.setName:( 'aaa' ) ## 名前トレイト(機能)のメンバ関数を使える
トレイトテンプレート
トレイトもクラスのようにテンプレートが存在します。
ファイバー(コルーチン)
ファイバーは中断できる関数オブジェクトのようなものです。Luaにおけるコルーチンとほぼ同じものですが、Fiberの方が文字数が短いのと響きがよいのでFlanではFiberという名称にしました。
class Hoge [int]: value <- 10 end [Hoge]: hoge [fiber]: fiber <- def:() hoge.value += 1 yield ## ファイバー中断 hoge.value += 1 yield hoge.value += 1 end assert:( hoge.value == 10 ) fiber.resume:() ## ファイバー実行 assert:( hoge.value == 11 ) fiber.resume:() assert:( hoge.value == 12 ) fiber.resume:() ## ファイバー実行(そして中断されずに終了) assert:( hoge.value == 13 ) fiber.resume:() ## 終了済みファイバーを実行しても assert:( hoge.value == 13 ) ## ファイバーは終了しているので、もう値は変化しない
単体テスト
クラスに@unittest
というメンバ関数を定義すると、単体テスト用の関数になります(扱いとしてはstaticメンバ関数)。コンパイル時に単体テストフラグが立っていた場合、実行時にすべての@unittest
関数が呼び出されます。
class A [int]: m_Value <- 10 def @unittest:() [A]: a assert:( a.m_Value == 10 ) end end class B [int]: m_Value def @unittest:() [B]: b assert:( b.m_Value == 0 ) end end ## [A].@unittest:() と [B].@unittest:()が自動で呼び出される
FlanIDE
実はプログラミング言語と同時にIDE(統合開発環境)も作っていました。このIDEはQtを使って作りました。下の画像を見ればだいたいわかると思いますが、機能としては以下のようなものを実装しました。一部、実行形式がC++コード生成だったときの名残もあります。
- コードエディタ
- 行数
- 構文カラー
- エラー箇所に下線
- カーソル位置の抽象構文木の表示(ウインドウ下部参照)
- ファイルリスト
- コードモデル(クラスやメンバ一覧)の表示、ソースコードジャンプ
- エラーリスト
- VM(仮想マシン)デバッガー
VMデバッガーは最初は実装していなかったのですが、プリントデバッグでVMの動作をデバッグするのがとても辛かったので作りました。世のVM開発者の方々はどうやってデバッグをしているのでしょうか…。
プログラミング言語Flanの実装
最後にプログラミング言語Flanをどうやって実装したのかを紹介したいと思います。思い出しながら書いているので間違っている部分があるかもしれません…。また、「言語モデル」*3などFlan独特の名称を使ったりしてます。
実行までの流れ
Flanのソースコードから実行までの流れは以下のようになっています。
パーサー
パーサーはソースコードを受け取り、抽象構文木(AST)を生成します。このパーサーは、パーサージェネレータであるANTLRを使って生成しました。ANTLRはデフォルトではJavaソースコードを生成しますが、C言語コードを生成させることもできます。ちなみに、C言語用のパーサージェネレータは一般的にはlex/yaccが使われるようです。ただし、yaccには抽象構文木の生成機能はありません。
モデルファクトリ
モデルファクトリはパーサーが生成した抽象構文木から、プログラミング言語Flanの言語モデルを構築します。言語モデルとは簡単に言えば、抽象構文木から意味を読み取って新たに構築したデータ構造です。現在の実装ではこの言語モデルはバイトコード生成時だけでなくVM実行時にも必要になります。
バイトコードジェネレータ
バイトコードジェネレータは言語モデルを元に、バイトコードを生成します。
FlanVM
FlanVMはバイトコードを実行するための仮想マシンです。FlanVMにはバイトコードと言語モデルを与える必要があります。このFlanVMによってバイトコードが実行されることでようやくFlanが実行されたことになります。
プログラミング言語を作るためのステップ
前の節でFlanがどうやってプログラムを実行しているのかを紹介しました。実行の仕組みだけならこれだけで良いのですが、実際にプログラミング言語を作るとなるとより多くの作業が必要になります。その辺を含めたプログラミング言語を作るためのステップを紹介します。
なお、ここでの説明はFlanの場合のもので、必ずしもこの方法が必要というわけではありません。例えばパーサージェネレータを使わずに自分でパーサーを書くこともできます。
文法を決める
プログラミング言語を作るにはまず文法を決める必要があります。さらに文法を決める前にプログラミング言語にどんな機能を持たせる決めなくてはいけません。ここは楽しい場面ですが、機能を追加すればするほどそれを文法に落とし込むのに苦労することになります。文法は最初にすべてを決めるのではなく少しずつ付け足していくことも可能ですが、新しい文法を導入するとすでに決まっていた文法を修正する必要が出てくる場合があります。実装はあとにしても文法だけは最初から考えておいたほうがいいかもしれません。
文法を厳密に定義する
文法が決まったらそれを厳密に定義します。文法を厳密に定義することはソースコードの構造を決めることでもあります。パーサージェネレータは文法の厳密な定義を必要とします。いきなり、パーサージェネレータ用の文法定義を書いてもいいし、BNF記法で一旦書いてからそれをパーサージェネレータ用の文法定義に落とし込んでもいいでしょう。ANTLRの文法定義方法はBNF記法に近いのでいきなりANTLR用の文法定義を書き始めてもあまり困ることはないです。
パーサーを生成する
パーサージェネレータ用の文法定義ができたらパーサーを生成してもらいます。ANTLRは抽象構文木を生成してくれるのでよいのですが、yaccでは自分で抽象構文木を構築するコードを書かないといけないかもしれません。
言語モデルクラスを作成する
言語モデルとはソースコードのデータ構造です。このデータ構造を構築するためのクラス群が必要になってきます。例えばクラス、関数、変数、式、文などを表すクラスです。変数クラス、式クラス、クラスクラスなどを作っていくのはなかなか楽しいかもしれません。言語モデルクラスには各種エラー処理の実装も必要になります。型が一致しない、変数、関数が見つからないなどです。
言語モデルファクトリを作成する
言語モデルクラスができたら、それらを使って言語モデルを構築する言語モデルファクトリを実装します。言語モデルファクトリはパーサーが出力した抽象構文木を走査して言語モデルを構築していきます。
VM(仮想マシン)を作成する
言語モデルの構築までできるようになったら、あとはそれを実行する仕組みを作るだけです。実行するための仕組みとしてFlanではVMを利用しました。VMとは仮想マシンのことでソフトウェアで実装されたCPUのようなものです。CPUは機械語を読み取って動作しますが、VMはバイトコードを読み取って動作します。
VMを実装するには以下のようなことをします。
長くなってしまうので詳細は省きます。「バイトコード」「スタックマシン」当たりで検索してみてください…。
バイトコードジェネレータを作成する
VMが出来たので、そのVMが利用するバイトコードを生成するバイトコードジェネレータを作ります。バイトコードジェネレータは言語モデルを走査してバイトコードを生成していきます。
コンパイラを作成する
ここまでで「パーサー」、「言語モデルファクトリ」、「バイトコードジェネレータ」が出来ました。これら順番に使うことでソースコードからバイトコードを生成することができます。ただ、このままでは不便なのでこれらの機能をまとめた「コンパイラ」を作りましょう。「コンパイラ」はソースコードを受け取り、「パーサー」、「言語モデルファクトリ」、「バイトコードジェネレータ」を順に使い、バイトコードの生成します。
ソースコードを実行する仕組みを用意する
コンパイラとVMが出来たのであとは、ソースコードを受け取り、コンパイルし、VMにバイトコード(と言語モデル)を渡して実行する仕組みを用意するだけです。
まとめ
プログラミング言語を作るというのは、本当に楽しくて気付いたら1年くらい経過してました。Flanは現在、開発を中断していますがここまで作ったんだからいつか完成まで持っていきたいと思う…ような思わないような。*4