はじめに
これは2020年9月17日に明治大学FMS学科のEP演習内で発表されたやつです。まあそんなことどうでもいいんで適当に見てってください。ソースコードはgithubに置きました。
概要
プログラム自体はProcessingというjavaに皮を被せたみたいな言語を使って実装しています。実行画面はテキストエディタ、コンソール、プレビュー画面で構成されています。

言語文法
基本的にはjavaと同じような構文で書くことが出来ます。いくつかjavaとは異なる部分があるのでそこの説明を少し。
変数宣言
変数宣言は以下のような構文となります。
let a = 10;
println(a); // -> 10
println(typeof(a)); // -> int
let b: float = 10;
println(typeof(b)); // -> float
現在使用可能な組み込み型は
intfloatboolstring
の4種類とその配列です。気が向いたらそのうち増やしていきたい。
配列の宣言は以下のように書けます。
let arr1 = {0, 1, 2};
let arr2 = {{0, 1}, {1, 2}};
let arr3 = int[2][2]; // make {{0, 0}, {0, 0}}
また、似た文法の言語にRustがありますが、Rustの場合letで宣言すると定数に、mutを付けると変数になるのに対し、この言語ではletで変数が宣言されます。定数の宣言はまだ作ってないけど、JavaScriptみたいにconst文にしようかなと思ってます。
さらに、以下のような書き方をすることもできます。
let c, d = 10, 20;
println(c, d); // -> 10 20
c, d = d, c; // swap
println(c, d); // -> 20 10
関数定義
関数は以下の構文で定義することが出来ます。
fn add(a: int, b: int) -> int {
return a + b;
}
補足
かなり突貫工事で作っているので、他の言語と比べてサポート出来ていない組み込み関数が多かったり、そもそもオブジェクト指向をサポート出来ていないなどの問題が山積みだったりします。許してください...
使用方法
次に、このプログラムをどう動かすのか見ていきましょう。
ウィンドウの生成
プレビュー画面内にウィンドウ(に対応する画面)を生成します。これはProcessingと同じような書き方で行えます。

この例では、800×600のウィンドウに対応する比率の画面をプレビュー画面に表示しています。
また、Processingと同じように、background()関数を使用して背景色を指定できます。
コンソールの使用
コンソールに文字列を出力するには、println()関数を使用します。
行末に改行を出力しない場合、print()関数を使用することもできます。

コンソールに入力する機能はまだ未実装です。ごめんね。
フレームごとの処理
Processingと同じように、draw()関数を定義することでフレームごとの処理を書くことができます。
次の例では、だんだんと背景色を黒から白に変化させていく挙動を実装しています。



マウスに対する処理
マウスの座標を示すmouseX, mouseYと、mousePressed()関数を定義しておくとマウスクリック時に呼び出す機能を実装しました。なので、以下のようなクリックした部分とその上下左右のマスを反転させるこんな感じの処理を書けます。



コードが見切れているので、別に載せておきます。
size(500, 500);
let board = int[5][5];
let count = 0;
board[0][2] = 1;
board[1][1] = 1;
board[1][2] = 1;
board[2][3] = 1;
board[2][4] = 1;
board[3][3] = 1;
fn draw() -> void {
background(0);
fill(255, 255, 0);
count = 0;
for(let i = 0; i < 5; i++) {
for(let j = 0; j < 5; j++) {
if(board[i][j] == 1) {
rect(100 * i, 100 * j, 100, 100);
}
else {
count++;
}
}
}
if(count == 25) {
textSize(32);
textAlign(CENTER);
text("CLEAR!", width / 2, height / 2);
}
}
fn mousePressed() -> void {
let x = mouseX / 100;
let y = mouseY / 100;
for(let i = max(0, x - 1); i <= min(4, x + 1); i++) {
board[i][y] = 1 - board[i][y];
}
for(let i = max(0, y - 1); i <= min(4, y + 1); i++) {
board[x][i] = 1 - board[x][i];
}
board[x][y] = 1 - board[x][y];
}
使用できる組み込み関数
上のプログラムで、Processingで見慣れた関数がいくつか登場しましたね。Processingで使用できる関数のいくつかは、この自作言語でも使用できるように実装されています。現在使用できる組み込み関数は、Processingにあるのもないのも含めて以下の全てです。
size()background()fill()stroke()strokeWeight()textSize()textAlign()text()circle()rect()ellipse()println()print()-
typeof()(変数の型を調べる) millis()-
wait()(wait(ms) => msミリ秒間処理を停止する) random()-
min()(複数の引数の中で最小の値を返す) -
max()(複数の引数の中で最大の値を返す) -
range()(指定された範囲の整数値が順に並んだ配列を返す)
内部の実装
では、内部の詳しい実装の話に移りましょう。ここから先は、Processingに関する知識が不十分な方やプログラミングの経験があまり多くない方には少し難しいかもしれません。
エディタの実装
文字の入力
keyPressed, keyPressed()関数、keyTyped()関数などを駆使して実装しています。
まず、draw()関数内にある以下のコードを見てみましょう。
if(keyPressed) {
if(prevKey.equals(String.valueOf(key))) {
if(keyt < dur) keyt++;
else {
keyt = dur / 2;
if(key != CODED) keyTyped();
}
}
else {
keyt = 0;
if(key != CODED) keyTyped();
}
}
else {
keyt = 0;
prevKey = "";
}
prevKeyは前回入力されたキーを保持しておく変数です。keytはキーが入力されてから何フレームが経過したかを表します。durはkeytの閾値として設定された値です。
前回と同じキーが入力されている間、keytが閾値durを超えたらkeytにdur / 2を代入し、keyがShiftやControlなどの符号化されたキーではないとき、keyTyped()関数を呼び出しています。前回とは異なるキーが入力されていたら、keytのチェックはせずkeytに0を代入します。
また、keyPressed()関数は以下のような実装になっています。
void keyPressed() {
if(key == CODED) {
if(keyCode == LEFT) {
cursor.prev();
beforeCursor.prev();
}
else if(keyCode == RIGHT) {
cursor.next();
beforeCursor.next();
}
else if(keyCode == UP) {
cursor.up();
beforeCursor.up();
}
else if(keyCode == DOWN) {
cursor.down();
beforeCursor.down();
}
else {
keyFlags.put(keyCode, true);
}
}
}
cursor, beforeCursorはCursor型オブジェクトであり、行と列を表すメソッドline, rowを持っています。cursorは現在のカーソル位置を表しています。beforeCursorは範囲選択を実装するために使っているものなのでまた後ほど説明します。また、keyFlagsはHashMap<Integer, Boolean>型オブジェクトであり、十字キー以外の符号化されたキーを示すkeyCodeが入力されているかどうかを表します。
このkeyPressed()関数では、十字キーが押されたときカーソルを動かし、それ以外の符号化されたキーが入力されたときkeyFlagsを変更する、といった処理を行っています。また、keyFlagsがtrueになっているキーが離された時にkeyFlagsを変更するため、以下のようにkeyReleased()関数を定義しました。
void keyReleased() {
if(key == CODED) {
if(keyFlags.get(keyCode) != null) {
keyFlags.put(keyCode, false);
}
}
keyPressed = false;
}
keyPressedをfalseにする処理は、本来する必要がない処理のはずなのですが、なぜか書かないとkeyPressedの挙動がおかしくなるので書いておきます。
keyTyped()関数では、それぞれ入力されたキーに対応する挙動を記述しています。ここに書くと長くなるので、見たい方はソースコードのリンクから確認してみるといいかもです。
文字列の表示
入力された文字列は、行ごとにArrayList<String>型オブジェクトに格納されています。それを順に表示していくだけですね。
行数がエディタの表示範囲を超えているとき、エディタの右側にスクロールバーを表示しています。スクロールバーの位置にしたがって文字列を出力する高さを変化させます。
機能の実装
エディタを名乗るには、さすがに文字を入力できるだけだとメモ帳となんら変わりないので、いくつか機能が必要になります。現在実装しているエディタの機能として以下が挙げられます。
- マウスを用いた範囲選択
- 選択されている範囲の文字列をクリップボードへコピー
- クリップボードからのペースト
- 全選択
-
(に対する)や{に対する}などの簡易な補完 - コードブロック内での自動インデント
- 行数の表示 など
範囲の選択を実装するにあたって、beforeCursorとcursorの一方を範囲の始端、他方を範囲の終端とすることで実現しています。あとはまあkeyTyped()関数のなかにごちゃごちゃ書いているので興味があれば見てみてください。
コンソールの実装
コンソールも同じようにArrayList<String>型オブジェクトに文字列を格納しています。表示もエディタと同じようにしています。
言語処理系の実装
今回実装した言語は簡易な動的型付けのインタプリンタ言語です。ただし、僕は動的型付け言語が好きでは無いので、コードの見た目は型推論を行っている静的型付け言語のような見た目になっています。
処理の流れは少し特殊で、本来行われる字句解析と構文解析をして、その抽象構文木に対して解釈・実行を行うのですが、今回実装したものは諸々を省いた雑な実装になっています。
例えば、以下のコードを実行することを考えてみましょう。
let a = 10;
このとき、Lexerクラスは以下のような計算結果を生成します。
[
Token("let", "let"),
Token("id", "a"),
Token("expr", "expr"),
Token("int", "10"),
Token("endExpr", "endExpr"),
Token("endLet", "endLet")
]
これは、ArrayList<Token>型のオブジェクトです。Token型のオブジェクトは、kindとvalueの2つのフィールドを持っています。Token(a, b)と書いてあるのは、kindの値がa, valueの値がbのToken型オブジェクトを表していると思ってください。
そして、この配列を命令列として解釈し、実行します。let文の実行は以下のコードにより行われます。
.
.
.
else if(res.get(i).kind.equals("let")) {
ArrayList<String> vars = new ArrayList<String>();
ArrayList<String> values = new ArrayList<String>();
i++;
String type = "", exprType = "";
if(res.get(i).kind.equals("type")) {
type = res.get(i).value;
i++;
}
while(!res.get(i).kind.equals("endLet")) {
if(res.get(i).kind.equals("id")) {
vars.add(res.get(i).value);
varNames.add(loc + "$" + res.get(i).value);
}
else if(res.get(i).kind.equals("expr")) {
ArrayList<Token> expr = new ArrayList<Token>();
i++;
int exprNum = 0;
while(exprNum > 0 || !res.get(i).kind.equals("endExpr")) {
if(res.get(i).kind.equals("expr")) exprNum++;
if(res.get(i).kind.equals("endExpr")) exprNum--;
expr.add(res.get(i));
i++;
}
exprType = calc(expr, loc);
values.add(expr.get(0).value);
}
i++;
}
int vs = values.size();
for(int j = 0; j < vars.size(); j++) {
if(j < vs) {
if(type.isEmpty()) {
variables.put(loc + "$" + vars.get(j), new Variable(exprType, vars.get(j), values.get(j)));
}
else {
variables.put(loc + "$" + vars.get(j), new Variable(type, vars.get(j), values.get(j)));
}
}
else {
if(type.isEmpty()) {
variables.put(loc + "$" + vars.get(j), new Variable(exprType, vars.get(j), values.get(vs - 1)));
}
else {
variables.put(loc + "$" + vars.get(j), new Variable(type, vars.get(j), values.get(vs - 1)));
}
}
}
}
resはLexerの計算結果を受け取るArrayList<Token>型の変数です。variablesは変数を保存しておく、HashMap<String, Variable>型の変数です。varNamesはスコープも考慮した変数の名前を保存しているArrayList<String>型の変数です。変数のスコープ管理は、$を用いています。具体的には、グローバル領域にある変数aのスコープを考慮した名前はmain$aであり、グローバル領域にある関数func()内で定義された変数aのスコープを考慮した名前はmain$func$aとなります。
exprからendExprは式を表しているので、exprからendExprのToken列をcalc()関数に渡してあげることで、式を計算することができます。
このようにして、let文を実行することができます。
他の文についても同じようにToken列を生成して実行することを行っていますので、それぞれの詳しい実装はstatement()関数の実装を参照してください。
おしまい
他にも実装について解説できる部分は多くあるのですが、ネタは鮮度が命ですので、この作品を発表した当日中に記事を投稿したいということでここら辺で解説を終わらせたいと思います。解説を追加して欲しいなどの要望や質問、提案、指摘、罵倒、嘲笑などがありましたら、この記事にコメントするなり僕のTwitterアカウントに直接リプライやDMを送るなりしてください。
雑な実装、雑な解説、雑の終わらせ方と全てにおいて雑であったことを深くお詫びします。ごめんね。
より詳しく丁寧な言語処理系の実装についての解説が聞きたい人はこちらの記事をご覧ください。