JavaScriptのオブジェクト指向の理解
プログラミング::lang
序
本記事は、職場で(MDNの記事を読んで)JavaScriptのオブジェクト指向の理解に苦しんでいた人がいたために書いた内容を下地としたものである。
本記事で取り扱うのは主に旧時代のJavaScriptのオブジェクト指向(プロトタイプベース)であり、網羅的な内容ではないが俯瞰的な内容を含んでいる。
前提知識
関数値とクロージャ
JavaScriptの関数は常に値である。 もう少し丁寧な言い方をすると、関数は関数への参照として値扱いすることができる。
例えば、Rubyのメソッドは値ではない。 なので、値として扱うことはできない。
def foofunc
"Foo"
end
= foofunc
foo # foofuncの呼び出しと解釈され、
# 戻り値である文字列Fooが代入される
Perlでも同じようなものである。
sub foofunc() {
print "Ooo...\n";
"Foo";
}
my $foo = foofunc; #関数呼び出しになる
ただし、RubyにもPerlにも関数を値にする方法がある。
Rubyの場合はProc
オブジェクトは値として扱うことができる。
また、メソッドを(Object#method
によって)Method
オブジェクトへ変換することでも扱える。
いずれの場合でも#call
メソッドによって起動することができるが、省略形としてproc.()
の形式でも起動できる。
def foomethod
puts "Ooo..."
nil
end
= Object.method(:foomethod)
foofunc = foofunc
foo .() foo
Perlでは\&foofunc
の形でサブルーチンへのリファレンスを獲得すれば値になる。
また、無名サブルーチンを変数に代入した場合、自動的にサブルーチンへのリファレンスに変換される。
これをデリファレンスして呼び出すのは&$name()
だが、わかりにくいため$name-()
とも書けるようになっている。
sub foofunc() {
print "Ooo...\n";
"Foo";
};
my $foo = \&foofunc;
$foo->();
$foo(); &
JavaScriptの場合は素で関数を値として扱うことができ、これは関数への参照になる。
JavaScriptにおいてリファレンスとデリファレンスは透過的な処理なので、明示的に何かを書く必要はない。
関数呼び出しとの区別は()
をつけるかどうかで行える。
function foofunc() {
console.log("Ooo...")
}
= foofunc // foofuncを値として代入
foo foofunc() // 呼び出し
foo() // 呼び出し
また、JavaScriptの関数は名前付きか無名かによらずクロージャである。
バインディングとthis
バインディング(束縛)は言葉だけでは何に対する束縛なのか不明瞭である。 基本的にはバインディングを扱うとき、言語により何が束縛されるか異なっている。
Rubyではコンテキストを丸々もっており、「その位置で」コードが解釈されたものとして扱う機能として存在するが、JavaScriptの場合はシンプルにthis
という名前に対する束縛になっている。つまり、「this
が示すものは何か」だ。
基本的な話として、JavaScriptのthis
は「そのオブジェクト」を指す。
関数もFunction
オブジェクトなのでややこしいが、関数をプロパティとして持っているオブジェクトのことである。つまり、
= {
foo name: "Foo!",
greet: function() { console.log(this.name)}
}
.greet() foo
この場合、foo.greet
の形であることからも明らかなように、this
はfoo
である。
これだと当たり前のことのようだが、this
は動的結合である。
つまり、関数を定義したときにthis
がなんであったかというのは関係ない。
次の例を見てみよう。
= function() { console.log(this.name) }
greet
= {
foo name: "Foo!",
greet: greet
}
.greet() // Foo!
fooconsole.log(this) // {}
greet() // undefined
greet
が定義されたときはthis
は存在しなかったが、foo.greet()
は期待通りに動作する。
対してアロー関数はthis
を静的結合とする。
上記の例をアロー関数に書き換えると定義されたコンテキストでthis
を束縛するため、グローバルにgreet()
としても、foo.greet()
としても結果は同じになる。
= () => { console.log(this.name) }
greet
= {
foo name: "Foo!",
greet: greet
}
.greet() // undefined
fooconsole.log(this) // {}
greet() // undefined
JavaScriptは関数にバインディングを提供するcall
,
apply
, bind
というメソッドが存在する。
例えば次のようなことが可能である。
= function() { console.log(this.name) }
greet = { name: "Foo!" }
foo
greet() // undefined
.call(foo) // Foo! greet
しかし、アロー関数はそもそもthis
を動的結合しないから、アロー関数ではこれらは機能しない。
実際にはthis
以外にも、arguments
オブジェクトを生成しないという違いもある。
いずれにしても、バインディングによって定義したときと異なるコンテキストで解釈されるのを望まない場合にアロー関数を使うことになる。逆に言えば、バインディングがキモになるメソッドとして使うのは問題がある。
クライアントサイドJavaScriptではイベントリスナーを書くためにきわどい使い分けをする場合もある。
例えば
.onclick = function { alert(this) } // thisは要素
element.onclick = ()=> { alert(this) } // thisはWindow
element// element.addEventListener でも同じ
カスタムオブジェクトの生成とプロトタイプ
シングルトン
もっとも手っ取り早いのは新しいObjectオブジェクトを生成し、プロパティを代入することである。
= function() { console.log(this.name) }
greet = new Object
foo
.name = "Foo!"
foo.greet = greet
foo
.greet() foo
オブジェクトリテラルを使えばもっと簡単だし、オブジェクトリテラルを使った場合はさっきと同じものになる。
= function() { console.log(this.name) }
greet
= {
foo name: "Foo!",
greet: greet
}
.greet() foo
そもそもJavaScriptはそのオブジェクト指向モデル上、名前との結合が弱いが、コンストラクタはObject
である。
(foo.constructor.name
はObject
になる。)
コンストラクタ
シングルトンオブジェクトは新しいObject
オブジェクトをいじっているに過ぎないが、もう少し型らしきものを作ることができ。る。それがコンストラクタ関数である。
コンストラクタ関数はただの関数であるが、new
演算子によって呼び出されたとき、this
は新しいオブジェクトとなった状態で評価される。
= function(name) {
fooClass this.name = name
this.greet = function() { console.log(this.name)}
}
= new fooClass("Foo!")
foo = new fooClass("Bar!")
bar
.greet() // Foo!
foo.greet() // Bar! bar
もちろん、constructor
プロパティはそのコンストラクタ関数となる。
この場合、foo.constructor.name
はfooClass
である。
プロトタイプ
オブジェクトは自身にないプロパティに対するアクセスがあった場合、自身のプロトタイプオブジェクトの名前を検索する。 これを委譲という。
これについては後に改めて解説するが、JavaScriptで継承という表現がなされることがあるし、MDNもそのように言っているが、そもそもJavaScriptのデザインは委譲である。つまり、JavaScriptに継承はない。
また、委譲について調べると転送の説明になっていることがとても多いが、転送はまた違うものである。
自身のプロトタイプオブジェクトは__proto__
というプロパティになっている。
また、コンストラクタ関数にはprototype
というプロパティがあり、new
を用いてオブジェクトを生成したとき、
this.__proto__ = this.constructor.prototype
のような処理が加えられる。
プロトタイプを使った例としては次のようなものが挙げられる。
= {foo: "FOOO"}
foo
console.log(foo.length) // undefined
.__proto__ = String.prototype
foo
console.log(foo.length) // 0
クラス
最近のJavaScriptにはクラスがある。
class Foo {
constructor(name) {
this.name = name
}
greet() { console.log(this.name) }
}
= new Foo("Foo!")
foo .greet() foo
これは構文糖に過ぎない。 実際には次と等価である。
function Foo(name) {
this.name = name
}.prototype = {
Foogreet: function() { console.log(this.name) }
}
= new Foo("Foo!")
foo .greet() foo
非本質的な書き方によって他の言語に見た目を寄せる、というのは私としてはあまり好ましいことには思えないが、特に否定するつもりもない。 ただ、この記事ではそんなに熱心にクラスの話はしない。なぜならば、構文糖だからだ。
継承
継承と委譲と転送
継承というのは、名前の探索に他のクラスの空間を加えるものである。
JavaScriptには継承がないので、Rubyで示そう。
RockBand
にはplay
メソッドはないが、スーパークラスであるBand
クラスには存在するため、RockBand
オブジェクトはplay
インスタンスメソッドを持っている。
#バンドクラス
class Band
def play
puts "Play #{@genre}"
end
end
#バンドクラスを継承したロックバンドクラス
class RockBand < Band
def initialize
@genre = "Rock"
end
end
= RockBand.new
rocker
.play # Play Rock! rocker
一方、委譲は名前の探索に別のオブジェクトの空間を加えるものである。
JavaScriptで書かれたこちらも、rockBand
オブジェクトはplay
メソッドを持っていないが、そのプロトタイプオブジェクトであるband
オブジェクトはplay
メソッドがあるため、rockBand.play
メソッドを呼び出すことができる。
var band = { play: function() {console.log(`Play ${this.genre}!`)} }
var rockBand = {genre: "Rock"}
.__proto__ = band
rockBand
.play() rockBand
転送は処理を他の関数やメソッドに委ねるものだ。JavaScriptで書くと次のようになる。
var band = { play: function(genre) {console.log(`Play ${genre}!`)} }
var rockBand = {genre: "Rock", play: function(...arg) {band.play(...arg)} }
.play(rockBand.genre) rockBand
コンストラクタ関数の継承
「継承」のようなものを実現するためには、コンストラクタ関数は「親コンストラクタ関数」を実行しなければならない。 これがクラスベースの言語であればシンプルな話だが、
class Human
def initialize(name)
@name = name
end
end
class Man
def initialize(name)
@gendor = "man"
super(name)
end
end
JavaScriptの場合は最初にコンストラクタによってオブジェクトが生成される前提があるため、親コンストラクタを実行するには転送する必要がある。
ところが、コンストラクタ関数はthis
を自動的に継承してくれるわけではないため、親コンストラクタ関数に対してthis
を明示的に与える必要がある。そのため、call
メソッドによってthis
を明示的に与えて別のコンストラクタ関数へ転送する必要がある。
function Human(name) {
this.name = name
}
function Man(name) {
this.gendor = "man"
.call(this, name)
Human }
プロトタイプの複製
だが、JavaScriptのオブジェクトは自身のプロパティだけではなく、プロトタイプオブジェクトのプロパティも探索対象である。
単にコンストラクタ関数で転送を行っただけでは、そのオブジェクト自身のプロパティを書くに留まる。
もちろん、this.__proto__
へのアクセスでプロトタイプオブジェクトそのものを書き換えることもできるが、したいのはそういうことではないはずだ。
例えばHuman
オブジェクトはgreet
メソッドを持つとして、それがプロトタイプオブジェクトに定義される場合、次のようにしてMan
オブジェクトもまたgreet
メソッドを持つようにすることもできる。
function Human(name) {
this.name = name
}.prototype = {greet: function() { console.log("Hi!")}}
Human
function Man(name) {
this.gendor = "man"
.call(this, name)
Human
}.prototype = Human.prototype Man
しかし、この場合プロトタイプオブジェクトは両者同じものであるため、変更に対する影響を受ける。
function Human(name) {
this.name = name
}.prototype = {greet: function() { console.log("Hi!")}}
Human
function Man(name) {
this.gendor = "man"
.call(this, name)
Human
}.prototype = Human.prototype
Man
= new Man("Haruka")
man .prototype.greet = function() { console.log("Yeeeeeahhhh")}
Human.greet() //Yeeeeeahhhh man
それほど問題があるようには見えないかもしれない(プロトタイプオブジェクトを変更することなどまずないから)が、これが他の人も使うライブラリだったりすると思わぬ問題を引き起こす可能性がある。 そのため、別のコンストラクタ関数を「別のクラス」のように扱うには、プロトタイプオブジェクトは親コンストラクタ関数のオブジェクトそのものではなく、そのコピーであったほうが良い。
JavaScriptでオブジェクトのコピーを生成するには、Object.create
を用いる。
= {greet: function() { console.log("Hi!")}}
HumanProto function Human(name) {
this.name = name
}.prototype = Object.create(HumanProto)
Human
function Man(name) {
this.gendor = "man"
.call(this, name)
Human
}.prototype = Object.create(HumanProto)
Man
= new Man("Haruka")
man .prototype.greet = function() { console.log("Yeeeeeahhhh")}
Human.greet() // Hi! man
これで人類が突然パリピになっても大丈夫だ。
なお、Object.create(Human.prototype)
とした場合、Human.prototype
をプロトタイプとしたオブジェクトになるため、結局意味が変わらない。というのも、Object.create(Human.prototype)
は
.prototype = new Object()
Man.prototype.__proto__ = Human.prototype Man
と同じ意味で、チェーンがひとつ伸びるだけだからだ。
クラスで継承
このように、JavaScriptで継承らしきことをしようと思うと、コンストラクタ関数とプロトタイプの2段階が存在する。
JavaScriptにおいては両者はそもそも分離されているものなので、2段階に分かれていて然るべきだし、それによるメリットもあるのだが、クラスベースのオブジェクト指向言語とは違う概念である。
多くの人がクラスベースのオブジェクト指向言語の概念に凝り固まっていて、JavaScriptの「らしさ」を理解しようとしなかった。
また、そもそも__proto__
というプロパティがよくわからないし、その上オブジェクトのもとになっているのが関数ということもよくわからなかった。
だからクラスというものが作られたのだが、構文糖で見せかけの継承を実現している。
class Human {
constructor(name) { this.name = name }
greet() { console.log("Hi!") }
}
class Man extends Human {
constructor(name) {
super(name)
this.gendor = "man"
}
}
= new Man()
man .greet() man
当たり前だが、クラスベースっぽいことをしたいという前提がある場合、JavaScriptらしいプロトタイプベースの書き方をするよりも、構文糖クラスを使ったほうがずっと書きやすくすっきりしている。
なお、JavaScriptではサブクラスのコンストラクタ関数の先頭で必ずsuper
を呼ばなくてはならないという仕様で、JavaScriptでは非常に珍しい、Pythonっぽい仕様である。Javaはこういう仕様になっていない。
スッキリしたプロトタイプスタイル
プロトタイプベースのオブジェクト指向として、JavaScriptは一般的に説明される構造になっていない。 普通、プロトタイプベースのオブジェクト指向は複製をもとに説明されるのだが、JavaScriptはコンストラクタ関数があり、またちょっと違うものになっている。
Object.create
は既存オブジェクトをプロトタイプとして使用する新しいオブジェクトを生成するものである。
前述のとおり
= Object.create(foo) bar
は
= new Object
bar .__proto__ = foo bar
に等しい。
ただ、.__proto__
というのは醜いし、そもそもこのプロパティに直接アクセスすべきではない内部的なプロパティだ。
このような内部プロパティへの代入が行われる状態を避けるため、Object.create()
が追加された。
Object.create
はやや新しい関数だが、クラスよりはずっと古い。
なぜJavaScriptはプロトタイプベースなのか
これはシンプルな答えになる。JavaScriptは(アプリケーション)組み込み言語だからだ。
組み込み環境というのは容量に対する要求が厳しい。 言語処理系が小さいこと、そして処理自体が軽いことが求められる。
言語処理系が小さいことは、組み込みの負担が減るという意味もあるが、実装が楽であるという意味もある。 実装が楽であれば、既存の言語処理系を組み込むだけでなく、アプリケーションそのものに言語処理系を載せることもできる。 現代でも生き残っている例で言うとEmacsはアプリケーション自身にLisp言語処理系を載せている。
また、一般的にアプリケーションに組み込まれるスクリプトというのは拡張機能のような扱いになるため、経験に乏しくても書きやすく、記述量が少なくて済むほうが良い。
JavaScript誕生の経緯はNetscape Navigatorというウェブブラウザに搭載されたものである。当初はLiveScriptという名称であった。 注目すべき点として、JavaScriptが当初からアプリケーション組み込みを意識しており、ブラウザ専用言語ではなかった、ということが挙げられる。
プログラミング言語として評価する以前に、アプリケーション組み込み言語としてJavaScriptは非常によくできたものであった。私はJavaScript処理系を実装したこともあるが、処理系が非常に書きやすいにも関わらず、やろうと思えば大概のことは綺麗に実現できた。機能は非常に限定的で、配列もなかったが、プログラムを記述する上でPerlよりも綺麗に書けることも少なくなかった。 (Perl5は一般に思われているよりもずっと高機能な言語である。ただ、後から追加された機能に関しては非常に使いにくい。) 正直、Luaなんかよりもずっと筋がいいと思う。
実際、1996年にMicrosoftがJScriptという言語処理系(厳密に言うとそもそもがJavaScript互換の別言語だけど)を生み出して、これをWindows 98ではOSレベルで採用している。 (NetscapeのJavaScript自体が他のアプリケーションに搭載されることはほとんどなかったが、Netscapeの他のアプリケーションには搭載された)
そんなコンパクトな言語を実現する上で効果的だったのがプロトタイプオブジェクト指向だったのだ。 クラスベースにすると処理系実装は大変になるし、そもそもその処理自体も重くなる。消費するメモリ量だってかなり多い。 1995年当時のPCのメモリ容量は標準的に8MB、廉価なものだと4MB、高価なものだと16MBで32MBはハイエンドというくらいのもの。 JavaScriptだとビット単位のシビアなメモリ管理は無理だし、そうなると重複して領域を確保するとすぐメモリは枯渇する。
言語処理系で実装するのも簡単なら、使うのも簡単。 JavaScriptでプロトタイプオブジェクトを扱うのは全く簡単ではないように思うかもしれないが、無意識に使うぶんには簡単だ。
= "Foo String"
foo .__proto__ // === String.prototype foo
むしろ当時のプログラム規模を考えた上で、アプリケーション組み込みの言語にオブジェクト指向を採用する、という豪華さのほうが驚きである。 プロトタイプオブジェクト指向がクラスベースオブジェクト指向よりもコンパクトなものであるといっても、シンプルな手続き型と比べればずっとリッチだ。
なぜJavaScriptがオブジェクト指向を採用したのかは分からないが、結果的にはそれが生き残りの決め手になった。
なお、当時の話をすると、オブジェクト指向というもの自体が全く理解されておらず、Perl5がリリースされた、つまりPerlにオブジェクト指向が導入された時期であるが、これが原因で「オブジェクト指向は極めて難解で利用は困難である」と広く認識された。 ちなみに、Perl5のオブジェクト指向機能は絶望的に使いにくい。 なんといっても、オブジェクトの内臓をみせつけるようなエグい仕様だ。リファレンスもまあまあ言語の内臓をみせつけていると思うのだが、そのリファレンスをベースにカオスな世界が構築されている。 「Perlっていつもそうですよね!プログラマをなんだと思ってるんですか!」
だが、DOMが階層構造であるため、JavaScriptがオブジェクト指向であることと非常に相性がよかった。 無理なくDOMを表現・操作できたのである。
var name = forms.name.value
.onclick = showHelp help
ちなみに、なぜか後から追加されたaddEventListener
のほうが階層構造にあるべきものを引数に渡す汚い設計になっている。
.addEvnetListener("click", showHelp) help
第一引数は必須な上に渡せるものは決まっているのだから、addEventListener
がオブジェクト返せばよかったんじゃないの。
今更遅いけど。
// このコードは動かない
.addEvnetListener.click(showHelp) help
ほとんどの人は自ら積極的にオブジェクト指向は利用しないし、オブジェクト指向というものを知らなかったが、ドットでつなぐ記法は便利で愛されていた。ちょっといびつな状態だ。 ちなみに、オブジェクトを積極的に利用しないと言っても、連想配列にはなじみがあったため、JavaScriptにおいては連想配列とオブジェクトが同一であるためにその意味ではオブジェクトを使っていた。「オブジェクトを活用しない」という状態の顕著な例としてはオブジェクトのプロパティとして関数を入れるようなことはなかった、ということが挙げられる。もっとも、当時は高階関数を自ら書く人自体稀であったが。
歴史的に言ってもプロトタイプオブジェクトが積極的に活用されてきたことはなく、JavaScriptはオブジェクト指向言語である、という発言すら一笑に付されてきたくらいのものだ。JavaScriptでプロトタイプオブジェクトを活用してオブジェクト指向プログラミングしていたのはかなりのマニアと言っていいだろう(e.g. 私)。
クラスベースオブジェクト指向を定着させたのはJavaの功績だが1、Javaはそもそも当初から巨大な言語であった。その割に必要なものが欠けていたりしたが、Rubyに至るとそれを基準としてオブジェクト指向の概念が再整備された。 そのため現代のオブジェクト指向の概念はRubyに由来するものが多いのだが、JavaScriptはクラスベースに直せばその概念の多くをカバーすることができる。
JavaScriptは生き残った言語であるが、生き残った言語の多くがそうであるように、当初とは全く異なる使われ方をしている。 そして異なる使われ方をすると人々はその言語にふさわしくない要求をするものである。 これが独裁者がコントロールする言語であればrejectされる可能性が高いが(PHPのようにそうでない言語もある)、コミュニティや評議会で作られる言語は政治によってそれらの要求が飲まれていく。 そうしてまるで使い物にならない言語になってしまうわけだが、幸いにもJavaScriptは全く違うものに姿を変えながらもトップクラスに使える言語であり続けている。
だが、「変わってしまった」のは事実であり、要求と目的が変わった現代の視点から見ればプロトタイプベースオブジェクト指向に魅力を感じないのは至極当然の話である。 プロトタイプオブジェクト指向がもたらす魅力は現代において誰も求めていない。今のJavaScript処理系を個人で実装することは不可能に近いし、シビアな空間要求もなければ、JavaScript処理系を組み込もうという人もいない。 SpiderMonkeyおよびRhinoは組み込み可能ではあるが、組み込もうという人はいないだろう。 (SpiderMonkeyやRhinoを組み込んだアプリケーションそのものは存在するが、現代ではライブラリとして使うのがせいぜいである。)
だが、JavaScriptを全く非互換な新しい言語に変えるのであれば別だが、JavaScriptではあるというのであればJavaScriptの素の機能を無視するわけにもいかない。 もちろん、JavaScriptに従来とは全く異なるクラスという機能を導入することも可能だし、実際にそのような議論もあったが、結果的に構文糖に留まったのは、機能実現の上ではプロトタイプオブジェクト機能でなんら不足はなく、ここまでのJavaScriptの発展が処理系において強引に言語の解釈を変えることで実現してきたという背景もあった。
ひとことでまとめると「当時はそのほうがメリットが大きかったから」である。