4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

作って理解JavaScript:JOKE開発記その2 - 変数

Last updated at Posted at 2020-05-21

はじめに

ノリで作り始めたJavaScript処理系JOKEのステップ2開発記です。
JOKEとはなんなのか(なんではないのか)等については開発記その1もご参照ください。

今回のスコープ

変数を宣言し利用(参照)できるようにします。ただし、

  • constとletのみ
  • var(とそこから発生する巻き上げ動作)はサポートしない
  • 宣言しないで変数を使うことはできない
  • 分割代入は今後の課題

具体的には以下のプログラムが「正しく」動作することを目指します。1

step/step0002_01.js
// 定数を宣言し参照する
const s = "foo";
console.log(s);
step/step0002_02.js
// 変数を宣言し、値を代入する
let s;
console.log(s);
s = "foo";
console.log(s);
step/step0002_03.js
// ブロックで囲めば同じ名前の変数を宣言可
// シャドーイングされていないブロック外の変数を参照できるかも確認
const s1 = "foo";
const s2 = "bar";

{
    const s1 = "FOO";
    console.log(s1, s2);
}

変数機能の仕様と実装

ソースコードは以下にあります。
https://github.com/junjis0203/joke/tree/step0002

Validator導入

constやletでの変数定義に関する仕様は以下に書かれています。
13.3.1 Let and Const Declarations

BNFは以下のようになっています。

LexicalDeclaration :
    LetOrConst BindingList ;

BindingList :
    LexicalBinding
    BindingList , LexicalBinding

LexicalBinding :
    BindingIdentifier Initializer
    ※Initializerはopt(なくてもよい)

Initializer(宣言時の初期化)はoptなので以下のプログラムは構文解析的には通ってしまいそうですがもちろんこのプログラムは駄目でブラウザでもNode.jsでもSyntaxErrorになります。

const s;

これをSyntaxErrorにすべきことは13.3.1.1に書かれています。

It is a Syntax Error if Initializer is not present and IsConstantDeclaration of the LexicalDeclaration containing this production is true.

IsConstantDeclarationの定義は13.3.1.3で書かれています。要するにconstで宣言されているという当たり前のことが(仕様として厳密に)書かれています。
ともかく、これについては構文解析時にInitializerとして解析したノードがあるかを調べれば実装できます。

厄介なのは次のケースです。
(同じスコープ内に)同じ名前で変数を宣言しているためにこれはNGです(仕様でも明記されています)

const s = "foo";
const s = "bar";

ただ、この「すでに同じ名前の変数があるか」チェックは純粋な構文解析の段階でやろうとすると複雑化が予想されます2。そこで、とりあえず構文解析は通した(ノードを作った)後に意味解析としてvalidationを行うようにしました。つまり、ParserとAssemblerの間に工程が増えたことになります。

  1. Scanner
  2. Parser
  3. Validator ← New!
  4. Assembler
  5. Vm

letはキーワードではない

上記のようにInitializerのチェック、Validatorを導入しての変数名重複のチェックによりconstはできたので次にletに取り掛かりました。
実際にはconstをキーワードとして扱うようにScannerを改造する際に仕様の対応部分を参照したのですが

Keyword :: one of
    break       do          in          typeof
    case        else        instanceof  var
    catch       export      new         void
    class       extends     return      while
    const       finally     super       with
    continue    for         switch      yield
    debugger    function    this
    default     if          throw
    delete      import      try

(。´・ω・)? あれ?letなくない?

そう!
letはキーワードではないのである!

つまり以下のように書くのは問題ありません。

function let() {}

let();

ただし、

11.6.2.1 Keywords

let and static are treated as reserved keywords through static semantic restrictions (see 12.1.1, 13.3.1.1, 13.7.5.1, and 14.5.1) rather than the lexical grammar.

13.3.1.1 Static Semantics: Early Errors

It is a Syntax Error if the BoundNames of BindingList contains "let".

つまり、constやletで宣言する変数名としてletを使うことはできません。
頑張れば「仕様通りに」実装できますが、頑張る意味はあまりないのでJOKEではletは単純にキーワードにしました。

代入に関する仕様

代入の「左辺」に相当するLeftHandSideExpressionですが、BNFを単純に解釈するといろいろな「式」を含むことになってしまいます。

NewExpression :
    MemberExpression
    ※Memberとあるがこれ以下にただの識別子参照もある

CallExpression :
    MemberExpression Arguments

LeftHandSideExpression :
    NewExpression
    CallExpression

このBNFだけ見ると「え?これもOK?」となりますがもちろん駄目です。3

foo() = 123;

代入演算子には単純な「BNF的にはOK」に加えて以下の仕様が記載されています。

12.14.1 Static Semantics: Early Errors

It is an early Reference Error if LeftHandSideExpression is neither an ObjectLiteral nor an ArrayLiteral and IsValidSimpleAssignmentTarget of LeftHandSideExpression is false.

IsValidSimpleAssignmentTargetってなんじゃいということについてはこの単語で検索かければわかりますが普通に考えればわかるように「ただの変数参照」の場合はtrueで他はfalseです。これについては純粋な構文解析の時点で「ただの変数参照か」はわかるのでその時点でエラーにしました。

なお上記のように仕様では「Reference Error」となっていますがブラウザやNode.jsではSyntaxErrorになるし4、どちらかというと「文法的におかしい」面の方が強いのでJOKEでもSyntaxErrorにしました5

追記:a = 2 = 3みたいなのはSyntaxErrorになりますが(こっちで試してた)、上で示しているfoo() = 123ReferenceErrorになるようなのでステップ3以降で直すことにします。

言語仕様以外の改造

以下では言語仕様以外の改造、すなわち開発していくうえでやりやすいようにしたという機能について説明します。

テスト方法の整備

ステップ1の段階で回帰テスト的な仕組みは作ったのですが、「例外起こらなければOK(言語的に変な改造を入れてしまったら例外が起こるだろう)」というものでした。
もちろん例外が起こらなければよいというだけでは駄目で、「想定する出力なのか(まだ演算はできませんが1+1がちゃんと2か等)」を調べる必要があります。これを実装しました。仕組みは以下のようになります。5

  1. ファイルへの出力を行うWriteStreamを作る
  2. 1.で作ったWriteStreamを使ってConsoleオブジェクトを作る
  3. 作ったConsoleオブジェクトでconsoleを差し替える(ここがミソ)
  4. JokeEngineに入力プログラムを与える(プログラムを実行する)。するとプログラムの出力(console.logしたもの)はファイルに出力される
  5. WriteStreamを閉じる
  6. あらかじめ用意されている「想定する出力」と同じかチェックする

具体的にはtest.jsのrunAndVerify関数参照なのですが、「WriteStreamが閉じたことはコールバック(やPromise)ではなくイベントで伝えられる」という点が厄介でした。最終的に「Promiseを素で使う」ことで実現しました。ここasync関数では実現できません。「Promise≠async関数」というのは最近どこかで見た気がするのですが、単純に置き換えはできないという事例に遭遇できて有益でした(やってることがややこしいということもありますが)

なお、「こういう場合はこういうエラーになるべき」というテストは(条件分岐等が)複雑になるので今のところ作っていません。手動でステップのプログラムを書き換えて「うん、OK(思ってる通りのエラーになった)」と確認はしていますが、エラーケースを考え出すときりがない(変数だけでもたくさん思いつくがそれ全部テストに書く?書くべきなんだろうけど)ということもありためらっています。

メインプログラム

開発記その1でも「字句解析段階でダンプするとこうですよー」というのをお見せしていますが、JOKEプログラムを書き換えて出力コピペしたら元に戻す、ではなくちゃんとオプションで指定できるようにしました。

PS C:\work\joke> node --experimental-modules .\joke.js -d .\step\step0001.js   
(node:16800) ExperimentalWarning: The ESM module loader is experimental.
(node:16800) ExperimentalWarning: Package name self resolution is an experimental feature. This feature could change at any time
Scanner result:
  [
    { type: 'IDENTIFIER', identifier: 'console', lineno: 5 },
    (中略)
    { type: 'END', lineno: 6 }
  ]

Parser result:
  {
    type: 'STATEMENTS',
    statements: [
      {
        type: 'EXPRESSION_STATEMENT',
        expression: {
          type: 'CALL',
          target: {
            type: 'MEMBER',
            object: { type: 'IDENTIFIER_REFERENCE', identifier: 'console' },
            property: 'log'
          },
          arguments: [ { type: 'STRING', string: 'Hello' } ]
        }
      }
    ]
  }

Assembler result:
  [
    { command: 'PUSH', operand: 'console' },
    (中略)
    { command: 'POP' }
  ]

Hello

この改造を入れる際に問題だったのが字句解析です。
ステップ1段階では「Parserからnext呼ばれたときに都度」解析していましたがそうするとダンプコードが散らばってしまうので「初めにトークン列にしてしまい、オプションが指定されてたらトークン列をダンプ」するようにしました。nextが呼ばれた場合は単純にトークン列中の「現在のトークン」を指す添え字を変えるだけです。

現時点の残課題

VMスタック

今はVM(スタックマシン)のスタックとして「JavaScriptの配列」を素で使っており、「pushしすぎ(全命令コードを実行した後に残り物がないか)」はチェックしていますが、「popしすぎ」はチェックできていません(空配列をpopしても例外等は起きません。何かバグってたらpopしすぎも起こるはずです)
popしてるところ全部に「popしすぎ」チェックを入れるのは現実的ではないのでVmStackみたいな薄いラッパーオブジェクトを作りpopメソッド呼び出し時にチェックしようかなと考えています。

例外表示

SyntaxError等のエラーは今は素の(JOKEを実行している)JavaScript(処理系)の例外を投げています。この際問題になるのは

  • JOKE実装のエラーなのか(JOKEを動かすJavaScript処理系が投げた例外なのか)
  • 「JOKEが動かしているJavaScriptプログラム」のエラーなのか

が区別できていないことです。joke.jsではどちらもcatchしてしまっているので「あっ例外起きた。想定するプログラムのエラーかな…、これはJOKEのバグやん」ということが多々ありました。これも直したいと思います(JOKE自身が例外処理を実装するのはまだ先なので、安直な解決策としては例外メッセージに[JOKE]と入れるぐらいしか思いつかないのですが)

今後の予定

以上、JOKEステップ2として変数機能と言語仕様以外の改造について説明してきました。
次回実装するもの、ベタに考えると演算ですが変数を実装した理由としては関数の引数の仕組みを作りたかったということがあります。
というわけで一足飛びに「関数作るぜ!」と思ったのですが、「でも関数呼び出してconsole.logするだけじゃ意味ないな」とも思ったのでやはり演算を実装し、満を持して関数呼び出しを作ることになるかなと思います。条件分岐やループはその後の予定です。

  1. リテラルはまだ文字列しかサポートしてません。

  2. 今の構文解析はBNFに対応する「関数」を定義しScannerだけを引数で渡していますが(戻り値は解析結果のノード)、「今定義されている変数」などの「解析状況」を渡す必要が出てくる、あるいは状態を保持しないといけなくなったから関数を諦めてメソッドに書き直すといった複雑化が予想されます。

  3. 参照を返せるC++ならいけそう。

  4. Chrome使ってるのでよく思うと両方V8?

  5. テストシステムについてはセルフホスティングの対象外にしているのでNode.jsの機能をばんばん使っています。 2

4
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?