Typescriptを学習中、prototypeに対する挙動について調べる機会があったので、
復習も兼ねて投稿します。
間違っている箇所等があればぜひご指摘ください!💦
2022/8/29 投稿の一部を下記の通り編集しました。
【修正前】■結論と解説 ~ES2015仕様以前
【修正後】■結論と解説 ~ES5仕様以前
【 前提知識 】
1. Javascriptは「プロトタイプベース」
Javascript本格入門より
Javascriptには「インスタンス化・インスタンス」という概念は存在するが、
「クラス」という概念はなく、プロトタイプ(雛形)と言う概念が存在する。
■Javascriptのクラスの作成方法(ES2015以前)
let A = function() {}; // クラスの作成完了。
let a = new A(); // インスタンスの完成。
let B = function ( firstName, lastName ) { // クラスの生成とコンストラクタ
this.firstName = firstName; // プロパティの生成
this.lastName = lastName;
this.getName = function() {
return this.firstName + this.lastName;
}
};
let b = new B("山田", "太郎");
console.log(b.getName()); // 山田 太郎
ポイント
■Javascriptでは、function(functionオブジェクト)に擬似的なクラスとしての役割を与えていた。
■functionで作成した擬似的クラスをnew演算子でインスタンス化する。
■クラスとして生成したfunctionの中に初期化処理を書く = コンストラクタ
■コンストラクタの生成はクラスの生成とほぼ同じ
2. prototypeは2種類ある。
Javascriptには「prototype」が2種類存在する。
①prototypeプロパティ
②[[prototype]](__ proto __)
■prototypeプロパティについて
- コンストラクタ上にあるプロパティ -
・コンストラクタ
生成時に自動的に追加されるprototype
というプロパティ。
・prototypeプロパティは生成時、空オブジェクト(プロトタイプオブジェクト)を参照している。
・同じprototype
でも、コンストラクタが違えば
、それは違うprototypeプロパティ
になる。
const Sample = function(){
// some code;
}
Sample {
prototype: {}
}
・クラス(コンストラクタ名).prototype.プロパティ名
で、空オブジェクト(プロトタイプオブジェクト)内にプロパティが追加される。
const Sample = function(){
// some code;
}
Sample.prototype.methodA = function(){
// some code.
};
Sample {
prototype: {
methodA: function (){ /*some code*/ };
}
}
■補足 Object.prototype
・Objectオブジェクトのコンストラクタ
上にある、prototype
プロパティ。
・toString()
やvalueOf()メソッド
が利用できるのは、
prototype
プロパティが指す、プロトタイプオブジェクト内
に定義されている。
・Objectは全てのオブジェクトが継承するものであるので、全てのオブジェクトがこのObject.prototype
の参照を持っている。
※イメージ
Object {
prototype: {
toString: ...
valueOf: ...
}
}
■[[prototype]] (__ proto __)
- インスタンス上の「内部プロパティ」 -
・インスタンス上
に作成される内部プロパティ。
・ .prototype
のように直接アクセスすることができないプロパティ。
・インスタンス生成時、[[prototype]]の参照先
は、コンストラクタ上
にあったprototypeプロパティが参照していたオブジェクト(プロトタイプオブジェクト)と同じ参照を持つようになる。
※イメージ
const Sample = function(){
// some code;
}
Sample.prototype.methodA = function(){
// some code.
};
let s1 = new Sample();
Sample {
/* :some code分プロパティ */
/* [[prototype]]: */ → Sample.prototypeが参照していたプロトタイプを参照。
}
■補足:プロトタイプチェーン
イメージ図
const Sample = function(firstName, lastName){
this.firstName = firstName;
this.lastName = lastName;
}
Sample.prototype.methodA = function(){ /* some code. */ };
let s1 = new Sample("Sample","man");
s1.firstName ~
s1.methodA ~
s1.toString();
s1 {
firstName: "Sample",
lastName : "man",
/* [[prototype]] */: → {
methodA():function(){/*some code.*/} ,
/* [[prototype]] */: → {
toString(): ...
valueOf(): ...
}
}
}
上記のインスタンス化されたs1
が自身のプロパティにアクセスする時、
①firstName
はSample直属のプロパティにアクセス。
②methodA
は直属のプロパティにはないので、[[prototype]]
を経由してmethodA
にアクセス。
③toString
は直属にもmethodA
があるプロトタイプオブジェクトにもないので、methodA
内のプロトタイプの[[prototype]]を経由し、Objectのプロトタイプにアクセス。
このように、[[prototype]]を経由した数珠繋ぎ状のオブジェクトの繋がりを、プロトタイプチェーンという。
【 本題 】
■prototypeに関する問題
Typescriptを学習中に以下のようなコードを拝見したのでコーディングしたところ、
結果はコンパイルエラーになり、Javascriptでコーディングすると問題なく実行ができたので、原因を調査しました。
【問題①】
const foo = { a: 1 };
const date = new Date();
const arr = [1, 2, 3];
// どのオブジェクトもhiプロパティが無いことを確認
console.log(foo.hi, date.hi, arr.hi);
// undefined undefined undefined
// プロトタイプにプロパティを追加する
Object.prototype.hi = "Hi!";
// どのオブジェクトもhiプロパティを持つようになる
console.log(foo.hi, date.hi, arr.hi);
// Hi! Hi! Hi!
問題の箇所
Object.prototype.hi = "Hi!";
■結論と解説
【 結論 】
Typescriptでは、
予めオブジェクトに期待しているプロパティがなかったり、期待していないプロパティが代入されてしまうとエラーを起こす。
という制限をかけています。
【 解説 】
Typescriptの型定義ファイルlib.es5.d.ts
には、Object型は以下のように定義されています。
■lib.es5.d.ts
interface Object {
constructor: Function;
toString(): string;
toLocaleString(): string;
valueOf(): Object;
hasOwnProperty(v: PropertyKey): boolean;
isPrototypeOf(v: Object): boolean;
propertyIsEnumerable(v: PropertyKey): boolean;
}
interface ObjectConstructor {
new(value?: any): Object;
(): any;
(value: any): any;
/** A reference to the prototype for a class of objects. */
readonly prototype: Object;
getPrototypeOf(o: any): any;
:
:
:
}
【前提知識】のところでも説明したように、.prototype
でアクセスできるのは、コンストラクタ上にあるprototypeプロパティ
です。
そしてObjectのコンストラクタ上にあるprototype
はObject型
となっています。
interface ObjectConstructor {
/** A reference to the prototype for a class of objects. */
readonly prototype: Object;
}
TypescriptではObject型は下記のように予め定義されています。
interface Object {
constructor: Function;
toString(): string;
toLocaleString(): string;
valueOf(): Object;
hasOwnProperty(v: PropertyKey): boolean;
isPrototypeOf(v: Object): boolean;
propertyIsEnumerable(v: PropertyKey): boolean;
}
これが理由により、Typescriptで制限した行為に引っかかり、エラーになります。
Object.prototype.hi = "Hi!";
hi
はObject型
の定義に含まれておらず、期待していないプロパティを勝手に代入しようとしていると判断され、型エラーになリます。
Javascriptの場合は動的に処理を実行してくれるので、なければシステムが勝手に追加する処理が働いてしまうので、特に意識しなくても処理が完了してしまいます。
【問題②】
次にObjectではなく、それぞれのオブジェクトの生成パターンでprototypeを利用してみたところ、それぞれ異なる結果が起きたので調べてみました。
// ES2015以降
class Sample2 {
name: string;
constructor(name: string){
this.name = name;
}
}
Sample2.prototype.age = 26; // コンパイルエラー。 プロパティ'age'は型'Sample2'に存在しません。
// オブジェクトリテラルでの生成
let Sample3 = {
name: "Smith",
}
Sample3.prototype // コンパイルエラー。 プロパティ 'prototype' は型 '{ name: string; }' に存在しません。
// ES2015以前①
let Sample1 = function(name: string){
this.name = name;
}
Sample1.prototype.age = 26;
// ES2015以前②
let Sample1 = function(){};
Sample1.prototype.name = "sample";
■結論と解説 ~ES2015仕様以降~
【 結論 】
Typescriptは「クラスベースのような振る舞いをする」ので、クラスやinterfaceで事前にプロパティの定義が必要
【 解説 】
①クラスも関数オブジェクト(functionオブジェクト)の一部なので、クラス生成時には自動的にprototypeも生成される。
→ クラス名.prototypeがエラーにならない理由
②Typescriptではルール上
、クラスのプロパティにアクセスするには事前にプロパティを定義し、クラスの中にそのプロパティがあることを認識させなければいけない。
逆にプロパティに定義を記載すれば、プロパティがクラスのどこか(クラスフィールド
・インスタンスフィールド
・プロトタイプオブジェクト内
)にある事を認識してくれる(そういう事のはず...)。
class Sample2 {
name: string;
age: number;
gender = "men";
constructor(name: string){
this.name = name;
}
}
Sample2.prototype.age = 26; // エラーなし
Sample2.prototype.gender = "women"; // エラーなし
【 番外 ~Typescriptはあくまで「プロトタイプベース」~ 】
Typescriptはクラスベースのような振る舞いをするだけなので、恐らく完全な静的型付けではないです。
理由としては、以下が挙げられます。
■Prototypeを利用することもできる。
class Sample2 {
age: number;
}
Sample2.prototype.age = 26;
let sample2 = new Sample2();
console.log(sample2.age); // 26
■ブラケット記法を使えば、動的にプロパティを追加することができてしまう。
class Sample2{
age: number;
};
Dog.prototype.age =26;
let sample2 = new Sample2();
sample2["firstName"] = "sample";
console.log(sample2["firstName"]); // sample
■そもそもJavascriptは、クラスベースであるかのようにクラスが書けるだけの
シンタックスシュガー(糖衣構文)を提供しているだけである。
同じものを、よりわかりやすいとされる構文で書くために使われる構文のことを「シンタックスシュガー」 >(糖衣構文) という。
プロトタイプベースの書き方と比べてclassを用いる方が、 Java などの静的型付け言語を利用している方にとって馴染みのある書き方でわかりやすいから追加されただけで、機能は従来から変更していない様子。
要は書き方が違うだけでプロトタイプベースには変わりないということみたいです。
■結論と解説 ~オブジェクトリテラルでの生成~
【 結論 】
オブジェクトリテラルは、関数ではない。
【 解説 】
オブジェクトリテラルで生成したオブジェクトは、厳密には関数(Functionオブジェクト)ではないそうです。
よって、内部プロパティ(__proto__,[[prototype]])
は持っていても、prototypeプロパティ
は持っていないことになります。
let Sample3 = {
name: "Smith",
}
console.log(Sample3.toString()); // [object Object]
Sample3.prototype.~ // コンパイルエラー
【 補足 】
オブジェクトリテラルで生成したオブジェクトは、Object
を元に新しいインスタンスオブジェクト
を生成していることと同等の扱いになるそうです。
生成されたインスタンスからprototypeプロパティ
はアクセスできません。
これはnew演算子で生成したオブジェクトも同様にエラーになります。
class Sample2 {
name: string;
age: number;
gender = "men";
constructor(name: string){
this.name = name;
}
}
let s2 = new Sample2("sample");
s2.prototype // プロパティ 'prototype' は型 'Sample2' に存在しません。
解決方法としては、Object.create
メソッドを使用して、新たにオブジェクトを生成することでとりあえず解決できました。
let Sample3 = {
name: "Smith",
}
let s3 = Object.create(Sample3);
s3.prototype.name = "Michael"; // エラーは出ない。
【 番外 ~ Object.createしたその後 ~ 】
Object.createメソッド
により、prototypeにアクセスできるようになりましたが、これは結果的にはコンパイルは通るも実行できず、エラーになります。
なぜコンパイルは通り
、実行でエラーになるのか
調べてみました。
let Sample3 = {
name: "Smith",
}
let s3 = Object.create(Sample3);
s3.prototype.name = "Michael";
// 実行後 → Cannot read property 'firstName' of undefined
【 結論 】
①Object.createで作成された新たなオブジェクトは、any型になる。
②Object.createで生成された新たなオブジェクトは、新たなプロトタイプは持たない。
【 解説① 】
Object.createメソッドで作成された新たなオブジェクトはany型になります。
any型は特殊な型になり、以下のような特徴を持ちます。
■どんな型のものも代入できる。
■any型の値を参照する際の制約もない
特に重要なのは2つ目の特徴で、これがコンパイルエラーにならない理由になります。
なので、以下のようなコードが全てコンパイルを通過するようになり、実行時でエラーもしくは、そのまま実行できてしまいます。
let Sample3 = {
name: "Smith",
}
let s3 = Object.create(Sample3);
/**
* 以下は全て実行時にエラーとなる。
*/
s3.prototype.age = 26;
s3.prototype.gender = "man";
s3.prototype.weight = 55.5;
/**
* 以下は実行時もエラーにならず、動的に追加できてしまう。
*/
s3.age = 26;
s3.gender = "man";
s3.weight = 55.5;
console.log(s3.age + "," + s3.gender + "," + s3.weight);
// 26,man,55.5
prototypeを操作しようとするソースの場合、新たに記載した3つのプロパティは存在しませんが、any型なので参照に制限がなく、コンパイルが通ってしまいます。
【 解説② 】
こちらは下記のサイトに解説が載っています。
- いくつかの解決しない方法
不足しているオブジェクトメソッドを新しいオブジェクトの「プロトタイプ」に直接追加してもうまくいきません。
なぜなら、新しいオブジェクトは本当のプロトタイプを持っておらず、プロトタイプを直接追加することはできないからです。
ocn = Object.create( null ); // "null" オブジェクトを生成 (既出と同じ)
ocn.prototype.toString = Object.toString;
// エラー: Cannot set property 'toString' of undefined
ocn.prototype = {}; // プロトタイプを生成してみる
ocn.prototype.toString = Object.toString;
// 新しいオブジェクトにはメソッドがないので、標準オブジェクトから代入してみる
> ocn.toString()
// エラー: ocn.toString is not a function
■結論と解説 ~ES5仕様以前~
【 結論 】
オブジェクトリテラルでの生成の時と同様、any型の値の参照は制約がない
【 解説 】
// ES2015以前①
let Sample1 = function(name: string){
this.name = name;
}
Sample1.prototype.age = 26;
// ES2015以前②
let Sample1 = function(){};
Sample1.prototype.name = "sample";
function(){}
で生成しているのは関数、つまりFunctionオブジェクト
ということになります。
そしてTypescriptの定義ファイル(lib.es5.ts)では、Functionは以下のように定義されています。
interface Function {
/**
* Calls the function, substituting the specified object for the this value of the function, and the specified array for the arguments of the function.
* @param thisArg The object to be used as the this object.
* @param argArray A set of arguments to be passed to the function.
*/
apply(this: Function, thisArg: any, argArray?: any): any;
/**
* Calls a method of an object, substituting another object for the current object.
* @param thisArg The object to be used as the current object.
* @param argArray A list of arguments to be passed to the method.
*/
call(this: Function, thisArg: any, ...argArray: any[]): any;
/**
* For a given function, creates a bound function that has the same body as the original function.
* The this object of the bound function is associated with the specified object, and has the specified initial parameters.
* @param thisArg An object to which the this keyword can refer inside the new function.
* @param argArray A list of arguments to be passed to the new function.
*/
bind(this: Function, thisArg: any, ...argArray: any[]): any;
/** Returns a string representation of a function. */
toString(): string;
prototype: any;
readonly length: number;
// Non-standard extensions
arguments: any;
caller: Function;
}
prototype
はany型
と定義されています。
よって参照の制限がないのでコンパイルには引っかからず、prototype.~
という構文を自由に書けるようになってしまいます。
【補足】
Typescriptで上記の2つのオブジェクトは、
tsconfig.json
で"noImplicitAny": true
としていると,どちらもインスタンス化できないです。
また、
Typescriptでは上記2つのクラスの生成方法は非推奨になります。
■総括
難しいことを考える前に、
まずは普通にクラスを使ってインスタンスを生成しましょう!
【 参考文献 】