JS基礎いろいろーPrototype
クイズ
久々のJS基礎いろいろシリーズです。
以下のアウトプットが正しく言えるかどうか、まずテストしてみましょう。
// 問題1
function Vehicle() {}
Vehicle.prototype.wheels = 4;
const car = new Vehicle();
const anotherCar = new Vehicle();
console.log(car.wheels); // ?
console.log(anotherCar.wheels); // ?
Vehicle.prototype.wheels = 2;
console.log(car.wheels); // ?
console.log(anotherCar.wheels); // ?
Vehicle.prototype = { wheels:100 }
console.log(car.wheels); // ?
console.log(anotherCar.wheels); // ?
// 問題2
const obj1 = { a: 1 };
const obj2 = Object.create(obj1);
const obj3 = Object.create(obj2);
console.log(obj3.a); // ?
obj2.a = 42;
console.log(obj3.a); // ?
delete obj2.a;
console.log(obj3.a); // ?
// 問題3
function F() {}
const f = new F()
f.__proto__ == ?
f.prototype == ?
F.__proto__ == ?
F.prototype == ?
// 問題4
class C extends F {
constructor () {
super()
}
}
const c = new C()
c.__proto__ == ?
c.constructor == ?
c.__proto__.__proto__ == ?
c.__proto__.__proto__.__proto__ == ?
C.__proto__ == ?
C.constructor == ?
C.prototype.constructor == ?
答えはブラウザーで試すかGPT先生に聞くのが早いが、この記事ではWHYの部分を少し探りたいと思います。
JSのオブジェクト
prototypeを理解するためには、まずオブジェクトのことを理解しなければなりません。JSのデータタイプ的には、primitiveタイプと、objectタイプに分けられることができます(参考 )。オブジェクトタイプのデータはシンプルに言えば、キーとバリューのペアで構成されたプロパティの集合(collection)として考えられます。
const person = {
name: 'John',
sayHi() {
console.log('Hi! I am ' + this.name);
}
};
プロパティの集合で考える時に、JSには2種類のプロパティタイプを定義しています。プロパティの挙動の定義はJS内部的な属性で決められていて、通常[[xxx]]
のように囲まれています。
data properties
このプロパティは、バリューの読み書きの場所(location)とその挙動を定義しています。
-
[[Configurable]]
delete
キーワードで削除されたり、プロパティの内部属性が変更可能かどうかの行為を定義している内部属性。デフォルトはtrue。 -
[[Enumerable]]
シンプルに言えば、オブジェクトに対してループをかける際に、ループのリターン値(ループ対象)になるかどうかを定義。デフォルトはtrue。 -
[[Writable]]
値(value)が変更可能かどうか。デフォルトはtrue。 -
[[Value]]
実際の値が保存されている場所。デフォルトはundefined
これらの内部属性は、Object.defineProperty
の静的メソッドで定義されることが可能です。
let person = {};
Object.defineProperty(person, "name", {
writable: false, // リードオンリーになる
enumerable: false, // for inの時にnameのキーが出なくなる
configurable: false, // 再度Object.definePropertyでnameプロパティの属性変更できなくなる
value: 'John'
})
accessor properties
accessorは名前通り、データのアクセス(とセット)が関心となります。内部属性はデータプロパティと多少違います。
-
[[Configurable]]
一緒 -
[[Enumerable]]
一緒 -
[[Get]]
プロパティがreadされる際に呼び出す関数、デフォルトはundefined
-
[[Set]]
プロパティがwriteされる際に呼び出す関数、デフォルトはundefined
上記のデータプロパティと同じく、Object.defineProperty
で定義することが可能です。
let person = {
name: 'John',
age_: 30,
};
Object.defineProperty(person, "age", {
get() {
return this.age_;
}
set(newValue) {
if (newValue < 0) {
throw new Error('Are you sure?'):
}
if (newValue > 200) {
throw new Error('Not a human!'):
}
this.age = newValue
}
})
また、上記の例で定義されたプロパティの属性はObject.getOwnPropertyDescriptor
で確認することができます。
オブジェクトを作る
オブジェクトを作るには、コンストラクター関数や、class
, Object.assign
, Object.create
, {}
で付与するなど、様々な方法があります。
コンストラクター関数はECMA6以前によく使われる方法です。ここではひとまずこのold styleから説明します。
function Person(name) {
this.name = name;
this.sayHi = function() {
console.log('Hi! I am ' + this.name);
}
}
// newで作成
let person = new Person(...)
new
演算子を使っている時に何が起こっているかというと、
- 新しいオブジェクトがメモリーに作られます
- 新しいオブジェクトのポインター
[[Prototype]]
が、コンストラクター関数のprototype
プロパティへ紐づきます -
this
が新しいオブジェクトにアサインされます - コンストラクター関数の中身が実行されます
つまり、コードで言えば以下のコメントが暗黙的に行ったわけです。
function Person(name) {
// this = {};
this.name = name;
this.sayHi = function() {
console.log('Hi! I am ' + this.name);
}
// return this;
}
以下のオブジェクトの作り方には同じ効果です(実は嘘)。
let p1 = new Person('p1');
let p2 = {name: 'p2'};
class Person {
constructor(name) {
this.name = name;
}
sayHi() {
console.log('Hi! I am ' + this.name);
}
}
// classで作ったp3
let p3 = new Person('p3');
prototypeとは何か
[[Prototype]]
とprototype
前節の例で、new
演算子でオブジェクト作る時に言及した、[[Prototype]]
とprototype
と何が違うのかというと、
-
[[Prototype]]
はオブジェクトの内部属性として、他のオブジェクトへのポインターになり、呼び出すには__proto__
を使います-
[[]]
は内部属性のため直接アクセスできないが、[[Prototype]]
と__proto__
が同一だと考えて構いません -
__proto__
は実装次第で、少なくとも主流なブラウザー、Node.jsなどのruntimeには利用可能
-
-
prototype
はとあるオブジェクトをベースに、インスタンス化するために必要なブループリント・モデルになります - 新しいオブジェクトを作る際に、上記のブループリントの
prototype
プロパティへ[[Prototype]]
で紐つきます- 例えば
p1
の場合だと、p1
の[[Prototype]]
は、Person.prototype
へ指していて、p1.__proto__ === Person.prototype
となります -
p1
とp2
の違いというのは、__proto__
のポイント先となります -
p1
とp3
の効果は同じと思って大丈夫です
- 例えば
もちろん、同じ仕組みが自作のオブジェクトだけではなく、ビルドインの全てのオブジェクトに存在します。さらにprimitive
タイプの場合は、ラップオブジェクト(Number
, String
, Boolean
など)のprototype
と繋いでいます。最終的に、全てのオブジェクトがObject.prototype
と繋ぐことになります。
let arr = [1,2,3];
assert(arr.__proto__ === Array.prototype);
const fn = () => {}
assert(fn.__proto__ === Function.prototype);
const a = 1;
assert(a.__proto__ === Number.prototype); // ES5以前ではエラーになる
const obj = {};
assert(obj.__proto__ === Object.prototype);
// ...
図で表現すると、
prototype chain
上記の図では多少理解できるかもしれませんが、なぜchain
と呼ばられるというと、下記の例でよりわかりやすいくなると思います(コンストラクター関数で同じことをやると若干面倒なので一旦ここはclass
を借ります。)。
class A {
sayA {
console.log('A')
}
sayHi {
console.log('Hi! I am A')
}
}
class B extends A {
sayB {
console.log('B')
}
sayHi {
console.log('Hi! I am B')
}
}
class C extends B {
sayC {
console.log('C')
}
sayHi {
console.log('Hi! I am C')
}
}
上記の例で言えば、__proto__
の方向は、C -> B -> A -> Object
になっているわかります。
const a = new A();
const b = new B();
const c = new C();
c.sayC() // c -> sayCはC.prototypeにある
c.sayB() // b -> Bのメソッドも使える
c.sayA() // a -> Aのメソッドも使える
c.sayHi() // Hi! I am C -> 同じメソッドは"上書き"される
assert(A.prototype.isPrototypeOf(a))
assert(a instanceof Object)
assert(b instanceof a)
assert(c instanceof b)
assert(c.__proto__ === C.prototype)
assert(c.__proto__.__proto__ === B.prototype)
assert(c.__proto__.__proto__.__proto__ === A.prototype)
assert(c.__proto__.__proto__.__proto__.__proto__ === Object.prototype)
この仕組みで何がすごいかというと、
- インスタンスのオブジェクトがポインター
__proto__
で指しているprototype
のプロパティにアクセスできるため、オブジェクトの間にprototypeにあるプロパティの共有ができる(水平の視点) -
継承する親のメソッドも
__proto__
プロパティを利用し、prototype chain
に辿って使うことができるため、子オブジェクトが親オブジェクトからプロパティの継承ができるだけではなく、メソッドの上書きすることも可能(垂直の視点)
もちろん、ここの上書きは本当に親クラスのメソッドの上書きというより、prototype
にプロパティが存在する場合優先順位が高く、prototype chain
上の同じプロパティへのアクセスが遮られる(shadow)とも言います。この仕組みによって、継承だけではなく、ポリモーフィズムも実現できるようになります。
constructor
とは
そもそも、「コンストラクター」とはなんだろう。クラスのconstructor
メソッドは、コンストラクター関数と実質同じで、new Person('p3')
を行う時に、Person
という関数をnew
で呼び出し、constructor
メソッドに定義されているものが実行されます。
クラスに存在するメソッドは、constructor
も含めて、Person.prototype
に紐つくようになります。JSにおいて関数を定義する際にfunction.prototype.constructor
にデフォルト値として、自分自身に指すオブジェクトとなっているためです。
// 下記がPerson定義時のデフォルトの挙動となる
Person.prototype = { constructor: Person }
// 他の関数も見てみると
function fn() {}
assert(fn.prototype.contructor === fn)
assert(Person.prototype.contructor === Person)
assert(Array.prototype.constructor === Array)
assert(Object.prototype.constructor === Object)
// ...
インスタンス化する際に、インスタンスのconstructor
プロパティが、クラスの関数となります。
const p1 = new Person('1')
console.log(p1.constructor === Person) // true ただ通常はinstanceofを使う
const p2 = new p1.constructor('2') // も一応可能
これらの関係を図で表現すると
ただ要注意したいのは、デフォルトのconstructor
が、関数のprototype
と紐ついていますが、これはwritable
なプロパティなので、入れ替えることが可能です。もしPerson.prototype = {}
とかにしてしまうと、デフォルトのconstructor
がなくなります。prototype
を仮にいじる必要がある時は、破壊変更になるかどうかは気をつけた方が良いでしょう。
また、prototype
にメソッドを含めてプロパティを追加することができますが、通常ステートはインスタンスに持たせるため、本当に共有したいプロパティ以外は、コンストラクター関数の中でthis
に付与した方が良いでしょう。
Person.prototype.name = 'John' // -> 通常はアンチパターン、全てのPersonインスタンスにこの値へのアクセスがあります
ちょっと待って、p1.constructor
はわかったけど、Person.constructor
はなんなのか。
前の節のprototype chain
を思い出してください。p1
はPerson
から作ったオブジェクトで、Person
は関数なので、Function
というオブジェクトから作られた関数にすぎません。なので、Person.constructor === Function
となります。
節の冒頭の疑問に戻るが、そもそもconstructor
はなんだろうというと、結局自分自身を作り出した関数にすぎません(p1.constructor === Person
, Person.constructor === Function
を考えてください)。正しくインスタンスを作るために(this
を作られたオブジェクトにバインディングするために)new
を使う必要がある、というくらいの特別要件がありますが、本質的には関数です。そのため、
Person('John') // -> newがない場合はthisがグローバルオブジェクトに紐つく
window.sayHi() // 'Hi! I am John'
let o = {};
Person.call(o, "Doe"); // oにthisが紐つく
o.sayHi() // 'Hi! I am Doe'
とのように、関数として使えるし、使い方次第でthis
のバインディングを変えることが可能です(this
についてこちらの記事に参考してください)。
結局、JSのclass
何か新しい仕組みを作っているわけではなく、根底には関数を通してオブジェクトを作成することになっています。
instanceof
前節では、p1.constructor === Person
のような書き方をしていました。これはある程度、インスタンスオブジェクトのconstructor
プロパティから、どの親オブジェクトのインスタンスなのか判定はつきます。ただconstructor
自体はwritable
なので、この方法より安定するのは、instanceof
演算子になります。
p1 instanceof Person
で何が起こっているかというと、
-
Person
の方に、静的メソッドSymbol.hasInstance
がある場合、それを呼び出します -> これも、instanceof
の挙動を弄るためのメソッドです。 - ただ実際に
Class
を作る際にSymbol.hasInstance
を実装することが滅多にないので、ここはprototype chain
に辿って行き、Object.prototype
まで続いて、なかったらfalse
p1.__proto__ === Person.prototype ? true
: p1.__proto__.__proto__ === Person.prototype ? true
: p1.__proto__.__proto__.__proto__ === Person.prototype ? true
: // ... Object.prototype まで続く、なかったらようやくfalse
通常何もしない場合は、p1.__proto__ === Person.prototype
の時点で判定がつきますが、いわば継承される場合は、prototype chain
を利用していることです。同じことは、isPrototypeOf
にも起こっています。p1 instanceof Person
を書き換えると、Person.prototype.isPrototypeOf(p1)
になります。
これと関連する話ではありますが、プロパティがインスタンスに存在するかどうかは、Object.hasOwnProperty
やObject.hasOwn
メソッドで判定することができます。それに対して、in
を使う場合はインスタンスに存在しなくても、instanceof
のようにprototype chain
で見つけることができれば判定はtrue
になるのです。
オブジェクトを作る方法の違い
JSにはオブジェクトを作る際に様々な方法が存在します。ここはまず今まで出てきたclass
とコンストラクター関数の違いを見てみます。
よくある言い方として、class
とはコンストラクター関数のsyntax sugarに過ぎない、と。class
によってprototype
の仕組みはカプセル化され、他のOOP言語からJSに乗り換える場合は素早く使えるようになります。一方で、両者は完全に一致しているわけではなく、オブジェクトの属性の文脈では少なくとも以下のような違いがあります。
-
enumerable
オブジェクトのプロパティにはイテレート可能かどうかについてenumerable
で決めていますが、class
で作られる場合、メソッドは全てenumerable:false
にはなります。通常これは意図通りの挙動で、for..in
のループにメソッドを出したい場面がほぼないでしょう。 -
[[IsClassConstructor]]: true
class
で作られたオブジェクトには[[IsClassConstructor]]
都の内部属性が存在します。class
で定義したものからオブジェクトを作る時に、new
を使わないと内部的にnew.target == undefined
になるため、この内部属性と合わせてnew
なしの作成を禁止しています。そのため、class
を使えば、前節のようにPerson('name')
との書き方がエラーで弾かれています。一方で、コンスタンター関数の場合はnew
なしでももちろん使えますが、this
が正しくバイディングできなくなる問題が生じます。 -
use strict
class
は常にstrict
モードになります。
class
とコンストラクター関数以外にも、いくつか方法がありますが、prototype
との関わりの観点から多少違いがあります。
方法 |
[[Prototype]] 紐付き |
ユースケース | 補足 |
---|---|---|---|
class |
✅ | OOP感覚のクラスとインスタンスのメンタルモデルでコーディングする時 | OOPの時の基本唯一正解 |
new + Function |
✅ |
class 以前のOOPやりたい時のOld Sytle |
new 有無で挙動がだいぶ変わる(this ) |
Object.create |
✅ | 任意のオブジェクトと紐つく時 |
create に渡されたオブジェクトに紐付ける |
Object.assign |
❌ | オブジェクトをマージしたい時 | プロパティの上書きや、shallow copyに注意 |
object literal({} ) |
✅ | シンプルにオブジェクト作りたい時 |
Object.prototype と紐付ける |
Object() constructor |
✅ | primitiveタイプのwrapperオブジェクトタイプを取りたい時(特にBigInt , Symbol ) |
new Object() は挙動が変わる |
JSON.parse |
❌ | 名前通りJSONデータをJSオブジェクトにパースしたい時 | prototype汚染要注意 |
ユースケースの面で言えば、class
は結構別軸で他の方法から離れているとも考えられます。次の節では、JSにおけるOOP関連の考え方をみてみたいと思います。
継承と委託
JSの継承
prototype chainを節には、プロパティの継承と上書き(アクセス遮断)ができるとわかりました。ただこれだけでは継承には成り立ちません。というのは、メソッド以外のプロパティも存在するため、
- 親と子のインスタンスを作る際に別々でステートを保持すること
- 子のインスタンスを作る際に、親にあるステートを継承すること
- インスタンスの間にステートの干渉がないこと
といった条件を満たす必要があります。prototype chainだけでは、仮にPerson.prototype.name = 'John'
をやってしまうと、インスタンス間のステート干渉が問題になってしまいます。
ES6以前では、さまざまなハックテクニックを通してこの問題を解決しようとしていました。
- constructor stealing/borrowing
- combination inheritance (pseudoclassical inheritance) = constructor stealing + prototype chain
- prototypal inheritance
- parasitic inheritance など
ここではconstructor stealing
+ prototype chain
のやり方を説明します。
function Person(name) {
this.name = name
this.friends = ['Jay', 'John', 'Jane']
}
Person.prototype.sayHi = function () {
console.log('Hi, I am ' + this.name)
}
function Engineer(name, lang) {
Person.call(this, name) // -> Personにthisを渡し、nameを付与する a.k.a constructor stealing/borrowing
this.lang = lang
}
Engineer.prototype = new Person() // -> Personのメソッドを継承する
Engineer.prototype.sayLang = function () { // -> このタイミングで追加すれば
console.log('Hi, I am a ' + this.lang + ' engineer' )
}
-
Person.call(this, name)
を通して、Person
の初期化を行い、さらにEngineer
のインスタンスオブジェクト(this
)に紐つくことで、this.friends
のようなレファレンスタイプのプロパティがコピされ、インスタンス間の干渉を避ける -
Engineer.prototype
にnew Person()
を付与することで、Person
のプロトタイプと、Engineer
のプロトタイプと分けると同時に、Person
にあるメソッドをEngineer
に使わせる
ES6以降は、Class
を利用することで継承がだいぶシンプルになりました。prototype
のアサインとコンストラクター借用の部分はextends
とsuper
で解消されています。本質的には上記のようにprototype
の仕組みを利用しています。
class Person {
constructor(name) {
this.name = name
this.friends = ['Jay', 'John', 'Jane']
}
// ...
}
class Engineer extends Person { // -> Engineer.prototype = new Person() に相当
constructor(name, lang) {
super(name); // -> Person.constructor(name) / Person.call(this, name) に相当
this.lang = lang;
}
// ...
}
OOP言語の継承との区別
実際に、prototypeの方式は継承の機能をしているように見えます。継承はOOPの三大柱の一つとしてOOP観点からは重要視されているが、その中心にあるのは、プログラムをより小さいパート(クラス)に分けて、各自のステートを管理し、お互いの通信(messaging)を行うことを通して機能を実現させ、複雑度を下げてより管理可能な構造にすることだと思っています。
JavaScriptにおけるprototype方式の継承と他のOOP言語における継承との違いというと、
相違点 | 通常のOOP言語 | JSの実現 |
---|---|---|
メンタルモデル | クラスとのブループリントからインスタンスを作る | prototype chainで直接他のオブジェクトと繋ぐ |
オブジェクト作成 | クラスからオブジェクト・インスタンスを作る | コンストラクター関数からオブジェクトを作る |
インスタンスステート保持 | メソッドやデータ両方保持する | データのみ保持する |
継承仕組み | メソッドを含めたプロパティを親クラスからコピーする | prototype chainにあるメソッドを共有する |
ポリモーフィズム仕組み | 親クラスと同名のメソッドを上書きする | prototype chainにある同名メソッドを実装し、アクセスを遮断する |
特に継承について、親と子という階級または階層の先入観が含まれているが、Kyle Simpson
氏が曰く、JSではOLOO a.k.a. Objects Linked to Other Objects
と読んだ方がふさわしい。その意味で言えば、階級との見方がJSの場合には多少不向きです。例えば、
let obj = {
0: 'a',
1: 'b',
length: 2
}
obj.join = Array.prototype.join
// もしくは
obj.__proto__ = Array.prototype
console.log(obj.join(',')) // 'a, b'
console.log(Array.isArray(obj)) // false, obj.__proto__ = Array.prototypeの場合ならtrue
というようなことができます。obj
はArray
を継承しているかどうかと言われると、他のOOP言語の視点からNOかと思います。objはオブジェクトのままで配列のメソッドを借りているので、誰が親か誰が子かの関係はありません。厳密に言えば継承の枠組みではなく、言葉で表現すると代用・借用に近いかもしれん(公式の言葉では次節の委託delegationになる)。このようなオブジェクト間のメソッド借用が明らかに継承の枠組みを超えていると思います。
委託組み合わせ(delegation composition)
さて、JSの継承について少しみてきましたが、実は継承について、よくcomposition over inheritance
と言われています。コンポジションまたは組み合わせについて別の記事で少し書いたことがありますが、シンプルにいうと、is-a
の継承関係を求めずに、x-able
という、機能性を求める観点からのコード再利用となります。特に、JSの場合は__proto__
は一つしかないので、マルチ継承ができないため、仮に複数のオブジェクトからメソッドを借用したい時に上記の継承パターンが無力になります。
ここでJSのprototype
の特性に合わせた組み合わせ(delegation composition)について紹介します。ここでウェブアプリ開発のロールを例に考えてみてください。
// 開発者のオブジェクト
const developer = {
name: '',
sayName() {
console.log(`Hi, I am ${this.name}`);
}
};
// フロントエンド開発に必要なスキル
const frontendSkills = {
buildUI() {
console.log(`${this.name} is building a beautiful UI!`);
}
};
// バックエンド開発に必要なスキル
const backendSkills = {
buildAPI() {
console.log(`${this.name} is building a powerful API!`);
}
};
// フロントエンドエンジニア
const frontendDev = Object.assign(Object.create(developer), frontendSkills);
frontendDev.name = 'Alice';
frontendDev.sayName(); // "Hi, I am Alice"
frontendDev.buildUI(); // "Alice is building a beautiful UI!"
// バックエンドエンジニア
const backendDev = Object.assign(Object.create(developer), backendSkills);
backendDev.name = 'Bob';
backendDev.sayName(); // "Hi, I am Bob"
backendDev.buildAPI(); // "Bob is building a powerful API!"
// フルスタックエンジニア
const fullstackDev = Object.assign(Object.create(developer), frontendSkills, backendSkills);
fullstackDev.name = 'Charlie';
fullstackDev.sayName(); // "Hi, I am Charlie"
fullstackDev.buildUI(); // "Charlie is building a beautiful UI!"
fullstackDev.buildAPI(); // "Charlie is building a powerful API!"
ここはDeveloper
からFrontendDev
、BackendDev
が継承するようなイメージではなく、x-able
の機能性の部分(スキル)を抽出しています。Object.assign
する際に、まずObject.create
によって__proto__
をdeveloper.prototype
へ紐つきます。するとdeveloper.sayName
が共有されるようになります。フルスタックエンジニアを考える時に、当然FEとBE両方のスキルが必要なので、継承の考え方だと結構厄介なケースになりますが、上記のようにスキルを組み合わせることで簡単に達成できます。
このやり方では注意したいのは、Object.assign
で複数のオブジェクトをターゲットオブジェクトへコピーする場合、
- shallow copyになっているため、他のオブジェクトへポイントするプロパティがあるときは、干渉が生じるリスクがあります。
- 同じプロパティが存在する場合、後にくるものが上書きしていきます。これはマルチ継承のダイアモンド問題という共通問題だが、JSではとにかく最新を取るというシンプルなやり方です。
組み合わせのパターンについて他の言語にはtrait
, mixins
といったものが相当します。interface
の場合はステートや実装を保持しない点でtrait
, mixins
から少し離れますが、機能性の部分を抽出して組み合わせる考え方自体はinterface
にも運用可能です(特にtsの場合)。
上記の例を、どうしてもclass
で書きたい時にどうすれば良いか、と言われると、コンストラクター関数で継承をやる時のconstructor stealingや、prototype
プロパティに対してのマージが必要になります。
class Frontend {
constructor(name) {
this.name = name;
this.prop1 = '...'
}
buildUI() {...}
}
class Backend {
constructor(name) {
this.name = name;
this.prop2 = '...'
}
buildAPI() {...}
}
class FullstackDeveloper {
constructor(name) {
// constructor stealing
Frontend.call(this, name);
Backend.call(this, name);
}
sayName() {
console.log(`Hi, I'm ${this.name}, a fullstack developer!`);
}
}
// ここはやはり不可欠、一回で済む
Object.assign(FullstackDeveloper.prototype, Frontend.prototype, Backend.prototype);
const dev = new FullstackDeveloper('Bob');
dev.sayHi(); // "Hi, I'm Bob, a fullstack developer!"
dev.buildUI(); // "Bob is building a beautiful UI!"
dev.buildAPI(); // "Bob is building a powerful API!"
委託について、Kyle Simpson氏がYou don't know JS yet
でも特筆している部分なので(こちら )、一読する価値はあるかなと思います。いずれにしても、prototype
を利用したプロパティ(メソッド)の借用、というイメージがわかれば良いかと。
実運用の注意点
prototype pollution
prototype
の性質を利用することで攻撃者がObject.prototype
とかを弄ることで、プログラム内の全てのオブジェクトを影響することが可能です。例えば、
Object.prototype.isAdmin = true;
const user = {};
console.log(user.isAdmin); // true
これは実際にユーザーインプットから起こる可能性があります。JSON.parse
を行う時に不用意にやられることが考えられます。
const maliciousPayload = JSON.parse('{"__proto__": {"isAdmin": true}, "userId": 1}');
const currentUser = await db.getUser(maliciousPayload.userId)
const updatedUser = {...currentUser, ...maliciousPayload};
これを防止するために、いくつかの考え方があります。
- コード上は
Object.prototype
のような変更を禁止する(Object.freeze
) -
__proto__
プロパティのないオブジェクトを作成する(Object.create(null)
) - ユーザーインプット、特にJSON.parseの部分をvalidation/sanitizeする(例:
obj.hasOwnProperty('__proto__') || obj.hasOwnProperty(constructor)
) - Node.jsの場合、
--disable-proto=delete
フラグを使ってObject.prototype.__proto__
のアクセスを無効にする(Node.jsドキュメントに参考) - 直接
user.isAdmin
のようなことではなく、Object.hasOwn(user, 'isAdmin') && user.isAdmin
で考える(意図的に継承する場合は多少面倒になるかもですが) - オブジェクト操作を外部ライブラリから行う場合、
prototype
汚染に対策しているライブラリーを使う
prototype
の変更
意図しないObject.prototype
の変更は全てのオブジェクトに影響するため、一番避けたいパターンではあります。それと近い考え方で、ビルトインのオブジェクトのprototype
も必要がない限りいじらない方が良いでしょう。
ここで主に2つの考え方があります。
- ビルトインから拡張のクラスを作る、例えば
// ❌
Array.prototype.last = function() {
return this[this.length - 1];
};
// ✅
class MyArray extends Array {
last() {
return this[this.length - 1];
}
}
const arr = new MyArray(1, 2, 3);
console.log(arr.last()); // 3
- どうしても
prototype
を弄る場合は、シンボルを利用して潜在的な競合可能性(将来公式で同じAPIが実装されたとか)を避ける、例えば
const lastSymbol = Symbol('last');
Array.prototype[lastSymbol] = function() {
return this[this.length - 1];
};
const arr = [1, 2, 3];
console.log(arr[lastSymbol]()); // 3
終わりに
クイズのWHY
まずはクイズ問題について少し解説します。
- 問題1では、コンストラクター関数を使って、その
prototype
プロパティを通してwheels
とのプロパティをオブジェクトに共有しています。インスタンスのオブジェクトのwheels
はprototype
にあるため、変更される際は全てのインスタンスに影響されます。また、prototype
丸ごとアサインしても、ポインターは元のprototype
に指すため変更はありません。 - 問題2では、
Object.create
を利用して、obj1, obj2, obj3の間に__proto__
で紐ついています。a
はobj1
にあるが、obj2
に追加する場合はアクセス遮断されるため値が変わります。また、delete
をobj2
ですると、obj2
のa
がなくなるが、引き続きobj1
の値が取れます。 - 問題3では、コンストラクター関数を作って、そのコンストラクタ関数から作られたオブジェクトとコンストラクタ関数の間の関係が問われています。インスタンスオブジェクトの
__proto__
はコンストラクタ関数のprototype
プロパティにポイントしていて、さらに関数F
はFunction
のインスタンスオブジェクトとしてみられることができます。 - 問題4では、
class
とextends
を使い、JSの継承を行っています。__proto__
のポイント先はコンストラクター関数と大差はありません。instance.constructor
や、Class.prototype.constructor
プロパティは、そのクラス・関数にポイントしています。最後はprototype chain
が問われていて、c -> C.prototype -> F.prototype -> Object.prototype
との順になります。
テークアウェイ
この記事の内容をシンプルにまとめると、
- プロトタイプはJS言語におけるオブジェクトの間に
targetObj.__proto__ -> sourceObj.prototype
の形で紐付きを加える仕組みである - この仕組みを通して、複数のオブジェクトに渡って、プロパティの共有または継承(借用)ができるようになっている
- コンストラクタ関数と、ES6以降の
class
で作られたクラスは本質的に同じく関数であり、new
と使うことでインスタンスオブジェクトを作っている - オブジェクトを作る方法が様々あるが、
prototype
の紐付きの有無と紐付きの対象あたりで違いが生じているため、ユースケースも多少違う - JSは他のOOP言語のメンタルモデルと違い、クラスとインスタンスというより結局全部オブジェクトではあるが、
class
を通してオブジェクトの間に起こったプロトタイプにめぐる複雑さを抽象化し、擬似的なOOPが達成できている - 継承より結合度の低い組み合わせの方法が考えられ、JSではプロトタイプを通して複数のオブジェクトからプロパティ(メソッド)を借用する委託組み合わせのパターンができる
- プロトタイプは
Object.prototype
やビルトインのオブジェクトにも全部繋いているため、セキュリティの観点からプロトタイプ汚染などの問題に注意しなければならない
となります。
参考になった資料
この記事を作成する際にメインに参考している資料はこちらとなります。
- The Modern javasCript Tutorial JSの仕組みを深掘りする意味では、MDNよりはだいぶ良い。主に7章から9章をみています。
- Professional JavaScript for Web Developers 4th edition JS開発者のバイブルとの位置付け。主に8章Objects, Classes, and Object Oriented Programmingをみています。
-
You Don't Know JS Yetシリーズ Kyle Simpson氏の名作、2nd editionはWIP。多少癖はあるので、↑の方がストレートでわかりやすいかもしれません。主に
objects-classes
をみています。 - JavaScript The Definitive Guide 7th Edition 2番目がバイブルならこちらは辞書?的な感じ。主に9章Classesをみています。
多少長くなりましたが、今回のJS基礎いろいろシリーズはこれで。
Discussion