1
0

More than 3 years have passed since last update.

作って理解JavaScript:JOKE開発記その7 - オブジェクトとクラス

Last updated at Posted at 2020-07-26

今回のスコープ

前回から一か月近く経ってる。
今回の実装内容自体が手間のかかるものであったという理由もありますが主な理由はゲームなど他のことをしていたためです(ぉぃ

さて、前回のあとがきでも書いたように制御構造も一通りできて次は配列かと思ったのですが、配列はオブジェクトとクラスの仕組みを作ってからの方が作りやすいだろうと思ったため、今回の内容はオブジェクトとクラスです。具体的には以下の内容です。

  • ステップ9:オブジェクト
    • まずは連想配列的な意味でのオブジェクト
    • Shorthand property namesやスプレッドもサポート
    • 次にメソッド。というかthisの扱い
  • ステップ10:クラス
    • 昔ながらのprototype。合わせてnew
    • 今どきのclass
    • JOKE実装で使っちゃったから継承もサポート
    • Arrayで使う予定なのでgetter/setterもサポート

ステップ9:オブジェクト

ステップ9段階のコードは以下にあります。
https://github.com/junjis0203/joke/tree/step0009

オブジェクトリテラル

プログラム内でオブジェクトが定義できないと話が始まらないのでまずはオブジェクトリテラルです。
http://www.ecma-international.org/ecma-262/6.0/#sec-object-initializer

オブジェクトの作成は以下のように行っています。

  1. 空のオブジェクトを作成しスタックにプッシュ(MAKE_OBJECT
  2. 名前をPUSH
  3. 値をPUSH
  4. オブジェクト、名前、値をスタックからポップしプロパティとして設定(PROP_DEFINE1
  5. 2.~4.をリテラルに書かれてるプロパティ分繰り返す

Assemblerの該当部分(後述するスプレッドの処理も含みます)
Vmの該当部分

Shorthand property names

ES2015で導入されたShorthand property names、こういうやつですね。

const foo = 123;
const bar = 'abc';
const obj = {foo, bar};

構文解析的にIdentifierの後ろに:がなければShorthand property namesとみなすようにしました。
また、「展開」の処理Parser内でしてしまうことでAssemblerVmは変更なしで動作するようにしました。

ちなみにComputed property namesも対応はできますがJOKE実装では使ってないしサポートしてません:stuck_out_tongue:

スプレッド

スプレッドの仕様見当たらないな(。´・ω・)?
とよく考えたらオブジェクトのスプレッドはES2018からでした。まあでも便利なので(JOKE実装で使ってるので)こちらはサポートします。
https://www.ecma-international.org/ecma-262/9.0/index.html#sec-object-initializer

スプレッドで問題となるのは「プロパティの数がアセンブル時点でわからない」ということです。
初めに作成したオブジェクトリテラル実装では「プロパティの数をMAKE_OBJECTの引数にし、VM実行時にスタックから引数分の名前と値を取り出す」ということをしており、スプレッドをなんとかこの仕組みに乗せれないかいろいろ試行錯誤したのですが2、結局「専用のCOPY_OBJECT命令を作った方が実装がシンプルになる」ということで既存部分を修正しました。
一度作ったものをなんとか使いまわそうとして変な実装になるよりかはそもそもの実装を変えてしまった方がいいという教訓を得ました。3

プロパティへの代入

プロパティの参照はステップ1で作っているので飛ばして代入処理です。
「変数への代入」と「オブジェクトプロパティへの代入」で処理を分けたくなかったので「変数とはスコープオブジェクトのプロパティである」のような一般化をしようと思いましたがめんどくさいのでやめました(プロパティへの代入を行うMEMBER_ASSIGN命令4を作りました)。後述のsetterのことを考えるとちゃんと分けてよかったのかなと思います。

余談ですがPythonやRubyは仕組みは違うもののどちらも「プロパティへの代入」はメソッド呼び出しとして行われます。いつものように「PythonやRubyってどうしてるっけ?」と参考にしようとしたら参考になりませんでした(笑)

this

さてthisです。JavaScriptのthisはややこしいことで有名ですがなんちゃって処理系なので「複雑な使い方」は当面サポートしない方向でいきます。つまり、

  • オブジェクトからメソッドを取り出して関数的に呼ぶのはサポートしない
  • Object.bindもサポートしない
  • アロー関数での「thisを束縛しない」動作もサポートしない
  • すなわち環境からthisを決定するのはサポートしない

thisに関する仕様(動作)はここら辺が参考になります。
http://www.ecma-international.org/ecma-262/6.0/#sec-function-calls

仕様を正確に実装していませんが(ぉぃ、以下のように実装を行いthisを実現しました。この処理はステップ10でもう少し改造が入ります。

  1. setProperty時にvalueがオブジェクトの場合はvaluethisValueを設定する(関数はオブジェクトで定義されているのでthisValueが設定される)
  2. callFunctionでオブジェクトに設定されてるthisValueを取り出しcontextに設定、呼び出す
  3. thisに対応する命令のTHISでは単にcontextに設定されているthisValueを取り出す(スタックにプッシュする)

ステップ10:クラス

オブジェクトと関連が深いからということでクラスについてもまとめて説明します。JavaScriptの場合、「クラス」というと話は長くなりますがES2015で定義されたclassのこととします。
と言いつつ、実体は・・・なのですが、そもそもJOKEを作ってみようと思った因縁の機能(?)ですね。

ステップ10段階のコードは以下にあります。
https://github.com/junjis0203/joke/tree/step0010

prototype形式とnew

V8などの内部実装がどうなってるのかはわからないですが、とりあえずJOKEではclassで定義されたクラスもprototype形式に変換して動かすことにしました。
ところでVSCodeだとprototype形式で書いているとこんなことが言われます。最近のエンジンは「クラス形式」に変換されてるっぽいですね。

prototype.jpg

ステップ10の前準備として「オブジェクト」に対応するJokeObject5を継承した(このために継承も実装することになりました)JokeFunctionを定義し、prototypeを初めから持たせておきます。こうしておけば「prototypeにメソッドを設定する」処理はステップ9で作ったプロパティへの代入がそのまま動きます。

object.js抜粋
export class JokeFunction extends JokeObject {
    constructor() {
        super();
        this.setProperty('prototype', new JokeObject());
    }

newは「空のオブジェクトを作り、それをthisとしてコンストラクタ関数に渡す(MDNでの説明ページ)」というものなのでその通りに実装しました。そこまで考えていなかったのですが(ぉぃ、ステップ9で作った「contextにthisを入れておく」仕組みがうまく使いまわせました。

vm.js抜粋
    case I.NEW:
        {
            const args = setupArguments();
            const target = stack.pop();

            const newObject = new JokeObject();
            newObject.setProperty('constructor', target);
            callFunction({...context, thisValue: newObject}, target, args);
            stack.push(newObject);
        }
        break;

ややこしいのはメソッド(prototypeに設定されているプロパティ)の取得処理です。JavaScriptで書いているため「constructorが設定されてなくても素のconstructorが取得される6」という点、「thisがprototypeに設定されているのを書き換える必要がある(コメントに書いてあるようにこの実装は気に入ってません)」点などちゃんと動くまで苦労しました。

object.js抜粋
    getProperty(name) {
        let value;
        // need because native "constructor" is found if not set
        if (this.names.includes(name)) {
           value  = this.properties[name];
        }
        if (!value && this.names.includes('constructor')) {
            const constructor = this.properties['constructor'];
            value = constructor.searchProperty(name);
            // change thisValue. any more better way?
            if (value instanceof JokeObject) {
                value = {
                    ...value,
                    thisValue: this
                };
            }
        }
        return value;
    }

class形式

さてclassです。classに関する動作仕様はこちらに書かれています。
http://www.ecma-international.org/ecma-262/6.0/#sec-runtime-semantics-classdefinitionevaluation
超長いし条件分岐もある( ゚Д゚)
というわけで読む気が起こるのも読み解くのも時間かかったわけですが、最終的に以下のように実装しました。仕様準拠ではないですが大体ちゃんと動作します。

  • 構文解析の段階でconstructorを分けておく
  • アセンブラではまずconstructorを関数として定義しその後にprototypeにメソッドを設定していく。継承については後で説明します。それと// TODO: cacheとありますが当面効率は無視です(開発記その1の方針参照)

こうすれば「prototypeで書いたのと同じこと」になるのでVMの変更はありません。
正確に言うとnew付けないで関数呼び出しされたら例外投げるとかはあるのですがとりあえず保留です。

継承

めんどくさそうなので作る気がなかった継承ですが、実装に使ってしまったので諦めて仕様を読んでみたところおもしろいことがわかりました。
http://www.ecma-international.org/ecma-262/6.0/#sec-class-definitions

ClassHeritage :
    extends LeftHandSideExpression

そう、extendsの後ろはLeftHandSideExpressionなのでハードコードなクラス名だけではなく変数でも関数呼び出しでも書けるのです!
つまり以下のコードは正しいJavaScriptプログラムです。

class Foo {}

const cls = Foo;
class Bar extends cls {}

const func = () => Foo;
class Baz extends func() {}

まあフレームワークの中ぐらいでしか使わない、というか今のところフレームワークの中でも見たことないですが(笑)

もう一つ文法的な話。superconsole.logしようとするとSyntaxErrorになるの何故なのかと思っていたのですがBNFを見て納得しました。単独にsuperと書く文法は存在しない(super()super.method()しか正しい文法ではない)のですね。
http://www.ecma-international.org/ecma-262/6.0/#sec-super-keyword

さて実装ですがAssemblerではスーパークラス(extendsの後ろにあるもの)を評価しておき、Vmでは単にスタックに着かれているものをsuperClassプロパティに設定しておきます。
スーパークラスのコンストラクタ呼び出しに対応するSUPER_CALLとスーパークラスのプロパティ参照7に対応するSUPER_PROPERTYでは設定されているsuperClassを使ってスーパークラスにアクセスします。

getter/setter

Arraylengthは特別処理しようかなと考えていたのですがよく思うとgetter/setterとして一般化できるので実装しました。

getter/setterに関する仕様はこちら。要するにgetsetを付けたメソッドを作ればいいようです(ちゃんと読み込むとそれだけではない気がする)
http://www.ecma-international.org/ecma-262/6.0/#sec-method-definitions-runtime-semantics-propertydefinitionevaluation

VmではSET_PROPERTY時にsetterがあるか、GET_PROPERTY時にgetterがあるかを調べるようにしました。

デバッグ機能

今までは-dオプション一つで「構文解析結果」「アセンブル結果」「今実行している命令」を出力していましたが出力が多くなってきたのでそれぞれ別オプションに分けました。
また、「今実行している命令」は追加情報として「今のスタック」を表示するようになりました。クラスまで来ると「動きがおかしいのだけどスタックに積まれてるデータどうなってるの?」ということが多くなってくるので役立っています(今までは都度console.logを入れていた)

あとがき

以上今回はオブジェクトとクラスの実装について説明しました。これでだいぶJavaScriptっぽいプログラムまで対応できるようになったと思います。
・・・「普通の使い方」についてはそこそこ動くと思うのですが「変わった書き方」や「仕様にも書かれている細かな文法エラー・実行時エラー」はあまりサポートできていません。まあそのうち。

ともかく次は満を持して配列(メソッド含む)です。
ただその前に、mapなどに渡すコードバックはアロー関数で書きたいのでアロー関数を実装する予定です。


  1. ステップ10終わりに名前の見直しを行ったのでHEADではDEFINE_PROPERTY。 

  2. // len is added in runtimeというコメントは試行錯誤してたときの残骸コメントですね・・・ 

  3. その際に大事なのは既存部分が壊れてないかを確認できるテストですね:grinning: 

  4. HEADではSET_PROPERTYに名前変更。 

  5. ステップ1で「とりあえずのオブジェクト表現」を作りそのまま進めてきましたがリファクタリングしました。Jokeと付いているのは素のObjectと被るためです。 

  6. JokeObjectは「JOKEを動かしているJavaScriptエンジン」での「Object(を継承したクラス)のインスタンス」のため、そちらが取得される。 

  7. 使うことがあるかはわかりませんが仕様的には「スーパークラスのメソッド呼び出し」だけではなく、「(オーバーライドしてthisではアクセスできないスーパークラスのプロパティ」を取ることができます。 

1
0
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
1
0