JavaScriptでオレオレクラス実装

どうもこんばんは、south37です。今日も今日とて残り時間に焦りながら書いています。自分、学習しないんですかね....

さて、前回までJavaScriptコンストラクタについてごちゃごちゃ述べてきた訳ですが、今回はその総集編(?)として関数型コンストラクタをクラス定義っぽく定義する為のコードを書いてみたいと思います。ちなみに、かなり誰得な内容ですwww

オレオレクラス実装

今回は、Pythonのクラス定義っぽさを出してみました。

Class.def 'Quo',
  constructor: (self, status) ->
    self.status = status
  get_status: (self) ->
    self.status

みたいなコードで、Quoクラス(みたいなもの)が定義されて、Class.Quoメソッドを呼ぶとquoオブジェクトが返ってきます。

myQuo = Class.Quo 'ok'
console.log myQuo.get_status() //ok

ちなみに、今日書くコードは全部CoffeeScriptで書いています。

CoffeeScriptなので括弧や波括弧が無くなりまくって分かりにくくなっていますが、Class.defには第一引数としてクラス名(上の例では'Quo')、第二引数としてconstructorやインスタンスメソッドを格納したオブジェクトを渡しています。JavaScriptのコードだと、

Class.def('Quo', {
  constructor: function (self, status) {
    self.status = status;
  },
  get_status: function (self) {
    self.status;
  }
});

になります。うん、やっぱCoffeeScriptの方がかっこいい。

constructorメソッドの中で変数の初期化等の処理を書き、その下にインスタンスメソッドを書き並べていく形式になっています。グローバルの変数汚染をClass変数だけにとどめていて、新しく生成されたコンストラクタ(上の例であればQuo)はClassオブジェクトの中に定義されていきます。

Classオブジェクトの定義は下記のようになっています。

Class = {}
Class.def = (name, methods) ->
  curry = (fn) ->
    slice = Array.prototype.slice
    storedArgs = slice.call(arguments, 1)

    ->
      newArgs = slice.call(arguments)
      args    = storedArgs.concat(newArgs)
      fn.apply(null, args)

  this[name] = ->
    obj  = {}
    self = {}
    constructor = curry(methods.constructor, self)

    constructor.apply(null, arguments)
    for methodName, method of methods
      if methodName isnt 'constructor'
        that[methodName] = curry(method, self)

    obj

順に説明していきます。
Class.defメソッドの中では、しょっぱなでcurry関数を定義しています。これは、名前の通りカリー化を行う為の関数です。このへんを見てもらえばいいんですが、引数を部分的に受け取った状態を作り出す為に今回は使ってます。具体的には、selfを受け取った状態ですね。
curryの中もそこそこややこしいんですが、さきほどのページで説明されてるので、興味があれば呼んでみてください。
ちなみに、ややこしさはJavaScriptのcall関数とapply関数の挙動が分かりにくい事に由来してるので、このページを読むといいかもです。ちょっと冗長な説明になってますが、callやapplyの、「そのオブジェクトが本来は持たないメソッドを、そのオブジェクトからの呼び出しとして実行出来る」といった性質が説明されています。具体的には、slice.call(arguments, 1)はarguments.slice(1)を実行したのと同じ処理が行われます。

curryの定義の後の、this[name] ->移行がClass.defメソッドのメインの処理です。しょっぱなでは変数を定義してます。objが最終的にreturnするオブジェクト、selfはインスタンス変数のコンテナ、constructorはselfを渡してcurry化を行った後のconstructorメソッドになってます。インスタンスメソッドからはselfにある変数にアクセスするようにする事で、変数のカプセル化を行っています。逆に言うと、パブリックな変数は持たせられない仕様です。(これに関しては、rubyみたいにgetter, setterをサクッと定義出来る仕組みがあれば問題ないと思います。)

constructor.apply(null, arguments)ってやってるのは、argumentsを引数として展開して実行する為で、イメージ的にはconstructor(arguments[0], arguments[1], ...)と同じです。先程も述べた通りapplyの挙動が全然直感的じゃないので、これはJavaScriptのダメな部分な気がします。まあ、便利なんでよく使っちゃうんですけど。

で、for文の中ではconstructor以外のメソッドをひたすらobjの中につっこんでいます。そして、最後にreturn objをしています。(objとだけ書いてあるのは、CoffeeScriptでは関数の最後に評価した値をそのままreturnするため)

とりあえずはこんな感じです。継承を可能にしたり、クラスメソッドを定義出来るようにしたりもしていきたいです。

追記

なんとなくググってみたら、classjsとかいう割と目指してた方向性のプロダクトが既にあるっぽいですね....まあ、そりゃそうか....
ただ、継承元のメソッドへのアクセス方法が__super__を介してでちょいちょい回りくどいので、その辺をもっと楽にして差別化を図りたいですね。

追記2

上記のClass.defの実装だとClass.defを実行するたびにcurryが定義される事になり、無駄があるので、ちょっと書き直してみました。

Class = {}
Class.def = do ->
  curry = (fn) ->
    slice = Array.prototype.slice
    storedArgs = slice.call(arguments, 1)

    return ->
      newArgs = slice.call(arguments)
      args    = storedArgs.concat(newArgs)
      fn.apply(null, args)

  return (name, methods) ->
    Class[name] = ->
      obj  = {}
      self = {}
      init = curry(methods.init, self)

      init.apply(null, arguments)

      for methodName, method of methods
        if methodName isnt 'init'
          obj[methodName] = curry(method, self)

      return obj

ついでに、初期化処理用のメソッド名をconstructorからinitに変えています。あと、returnが無いとやっぱり分かりにくい気がしたので追加してあります。関数を即時実行する為のdoも使用しています。

追記3

self渡さなくてもカプセル化自体は出来る事に気づきました。

Class = {}
Class.def = (classSpec) ->
  for name, methods of classSpec
    Class[name] = ->
      obj  = {}
      self = {}

      methods.init.apply(self, arguments)

      for methodName, method of methods
        if methodName isnt 'init'
          obj[methodName] = ->
            method.apply(self, arguments)

      return obj

Class.def 'Quo':
  init: (status) ->
    this.status = status
  get_status: () ->
    this.status

Class.def 'ChildQuo':
  include: 'Quo'

myQuo = Class.Quo 'ok'
console.log myQuo.get_status()