⛓️

JS基礎いろいろーPrototype

2024/10/21に公開

クイズ

久々の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となります
    • p1p2の違いというのは、__proto__のポイント先となります
    • p1p3の効果は同じと思って大丈夫です

もちろん、同じ仕組みが自作のオブジェクトだけではなく、ビルドインの全てのオブジェクトに存在します。さらに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を思い出してください。p1Personから作ったオブジェクトで、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.hasOwnPropertyObject.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.prototypenew Person()を付与することで、Personのプロトタイプと、Engineerのプロトタイプと分けると同時に、PersonにあるメソッドをEngineerに使わせる

ES6以降は、Classを利用することで継承がだいぶシンプルになりました。prototypeのアサインとコンストラクター借用の部分はextendssuperで解消されています。本質的には上記のように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

というようなことができます。objArray継承しているかどうかと言われると、他の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からFrontendDevBackendDevが継承するようなイメージではなく、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とのプロパティをオブジェクトに共有しています。インスタンスのオブジェクトのwheelsprototypeにあるため、変更される際は全てのインスタンスに影響されます。また、prototype丸ごとアサインしても、ポインターは元のprototypeに指すため変更はありません。
  • 問題2では、Object.createを利用して、obj1, obj2, obj3の間に__proto__で紐ついています。aobj1にあるが、obj2に追加する場合はアクセス遮断されるため値が変わります。また、deleteobj2ですると、obj2aがなくなるが、引き続きobj1の値が取れます。
  • 問題3では、コンストラクター関数を作って、そのコンストラクタ関数から作られたオブジェクトとコンストラクタ関数の間の関係が問われています。インスタンスオブジェクトの__proto__はコンストラクタ関数のprototypeプロパティにポイントしていて、さらに関数FFunctionのインスタンスオブジェクトとしてみられることができます。
  • 問題4では、classextendsを使い、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やビルトインのオブジェクトにも全部繋いているため、セキュリティの観点からプロトタイプ汚染などの問題に注意しなければならない

となります。

参考になった資料

この記事を作成する際にメインに参考している資料はこちらとなります。

多少長くなりましたが、今回のJS基礎いろいろシリーズはこれで。

GitHubで編集を提案

Discussion