見出し画像

「Typescript」でprototypeの沼にハマったので、色々調べてみた。

こんにちは
ALH開発事業部のエンジニア、TAIKIです。

今回は現在学習中のTypescriptで"prototype"という大きな沼にハマってしまい、
「恐らくこういうことなんだろうな...」という所まで何とかまとめる事ができたので、一旦投稿しようと思いました。
多分ここまで考え込まなくてもTypescriptは使えるようになるとは思うのですが、とても気になってしまったので2週間頑張って奮闘しました。
間違って認識している所も多分あるかと思いますが、限界まで調べ上げたつもりなので、
これからJavascriptやTypescriptを学び、prototypeで悩まされた時にでも参考になりましたら幸いです。


【 前提知識 】

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.プロパティ名

で、空オブジェクト(プロトタイプオブジェクト)内にプロパティが追加される。

■補足 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つのクラスの生成方法は非推奨になります。

■総括

難しいことを考える前に、
まずは普通にクラスを使ってインスタンスを生成しましょう!

【 参考文献 】


ALHについて知る


↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ 採用サイトはこちら ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓


↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ALHについてはこちら ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓


↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ もっとALHについて知りたい? ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓