1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

他言語学習者向け:JavaScript/ECMAScriptプロトタイプ及びクラス入門

Last updated at Posted at 2023-12-03

NOTE: この記事は、弊社内のオンボーディング資料を公開したものです。

主に他言語の経験がある人向けのJS/ECMAScriptのプロトタイプに関しての理解を深めるための資料です。

JSに初めて触れる方はこちらを先に読んでください。

JSのグローバル空間

JavaScriptのグローバル空間は、巨大の1つのオブジェクトとようなものとして表現されており、コード内で宣言される変数は、このグローバル空間のメンバ変数(プロパティ、メソッド)の一部とみなされます。

var a = 'my variable!' // constやletではブロック内宣言となるので、ここではvarを使っています
console.log(globalThis.a); // my variable!

このグローバルスコープ自身を表す特殊な変数として、globalThis が用意されています(ES6以降)。

Objectのようなデータ型やFunctionArrayのような組み込みオブジェクト、windowのようなスコープもグローバル空間に所属しており、私たちが利用できるようになっています。

プロトタイプベースのJavaScript

プロトタイプベースとは、オブジェクトの生成にあたり、他のオブジェクトの一部を参照して新たなオブジェクトを作成するオブジェクト指向プログラミングの一種です。
ここでいう、「他のオブジェクトの一部」をプロトタイプと呼びます。

現在では、すでにECMAScriptやTypeScriptではクラスの概念が導入されており、JavaやC#のようなクラスベースでのオブジェクト指向プログラミングが可能になっていますが、JavaScriptは元来、プロトタイプベースであり、一部の特殊な振る舞いを理解するには、この仕組みを理解することが欠かせません。

超柔軟なプロトタイプベース

クラスベースの言語では、型であるクラスと値であるインスタンスは厳密に分離していますが、プロトタイプベースであるJavaScriptにおいては、どちらもオブジェクトとして取り扱われます。

これにより、クラスをコードで動的に拡張したり、変更することすら可能です。
この特性は、JavaScriptの際立った柔軟性の1つともいえます。

プロトタイプとクラスの違い

では、一例として、 Array型のデータを作成する際に、プロトタイプがどのように働くのか、その背景を考えてみましょう。
Arrayは配列を表す組み込みオブジェクトです。次のように利用できます。

const ary = new Array('a','b'); // ary => ['a', 'b']

これは、その他の言語でいえば、Arrayクラスから ary インスタンスを作成したとも言い換えることができます。
image.png

aryには、Arrayが持っていた関数が、プロパティ(メソッド)として利用できるようになっています。

ary.forEach(element => console.log(element));

例えば、上記の forEach は要素を列挙するメソッドですね。
では、この forEachの処理が実際に書かれた関数はどこに実装されているのでしょうか?
クラスベースの言語であれば、次のようなものと考えるでしょう。
image.png

class Array {
  forEach(callback){
    /** ここに処理が定義されているはず? **/
  }
}

しかし、昔のJavaScriptにはクラスという概念は存在しないはずです。
このnewキーワードはどのように処理されていたのでしょうか?

プロトタイプの実態

JavaScriptでは長らくクラスという概念が存在せず、クラスからインスタンスを生成するような考え方とは別の方法が用いられてきました。それがプロトタイプベースです。

プロトタイプベースであるJavaScriptでは、Array.prototypeforEachが登録されています。
確認してみましょう。

console.log(Array.prototype.forEach); // [Function: forEach]

つまり、Arrayの固有のメソッドは Array.prototype に格納されており、 new つまりコンストラクタが動作するタイミングで、aryにも同様のメソッドがセットされたことがわかります。
image.png

では、ここで forEachメソッドを試しに削除してみましょう。
オブジェクトから特定のプロパティやメソッドを削除するには delete を使います。

delete ary.forEach
console.log(ary.forEach) // あれれ?消せない???

deleteを実行したにも関わらず、forEachが存在しています。これはなぜでしょうか。

インスタンスに存在しないプロパティを格納する内部プロパティ

実は、forEachは、aryに直接登録されているのではなく、その内部プロパティ [[Prototype]] に含まれています。

この内部プロパティの表現方法はJavaScriptの処理系によって異なりますが、ここでは、一般的な __proto__で見てみましょう。

console.log(ary.__proto__.forEach); // ここに入っていた!

実は、元となったArrayオブジェクトから引き継がれたメソッドは、全て __proto__ に格納されています。

image.png

便宜的に、クラスとインスタンスという用語で説明すると、

  • Arrayクラスのメソッドは Array.prototype に定義されている
  • new Array()で作成されたインスタンスは、内部に __proto__プロパティがあり、そこにメソッドを格納する
  • インスタンスに該当するメソッドやプロパティがなければ、 __proto__プロパティを参照し、そこから呼び出す

という仕組みになっていることがわかります。

なぜ内部プロトタイプを使うのか

ary.forEachが、実際にはary.__proto__ に登録されていることはわかりました。
しかし、なぜ直接ではなく、ary.__proto__に登録しているのでしょうか。

実は、ary.__proto__は、Array.prototypeへのゲッター及びセッターとしての機能を持っています。
これにより、Arrayからnew によって作成された全ての変数が、同じメソッドを参照している形となります。

もし直接、ary.forEach にしてしまうと、どのメンバ変数が元のArrayから継承されたメソッドなのかわからなくなってしまいます。

関数が第一級関数であるJavaScriptの世界では、確かに任意の関数を任意の変数に自在に登録することができますが、「何が固有のプロパティで、何が引き継いだプロパティなのか」を明確にするためにプロトタイプが利用されている、と考えてください。

より詳しく知りたい人は、ぜひMDNの記事を読んでください。

クラス登場以前のJavaScriptにおけるクラス表現

ここまでで、prototypeと__proto__の関係について、何となく理解できたかと思います。

さて、先述の通り、JavaScriptにはもともとクラスという概念がありませんでした。
ただ、prototypeを駆使することで、クラスのようにメソッドを定義できたため、クラスベースのオブジェクト指向プログラミングのようにnewを用いてインスタンスを生成する方法も使うことができました。

では、実際にクラスが導入される前のJavaScriptでは、どのようにクラスを表現していたのでしょうか?

こんな形で定義されていました。

// コンストラクタは関数定義を用いる
var MyClass = function(arg) {
  this.arg = arg;
}

// メソッドやプロパティはprototypeに登録する。
MyClass.prototype.getArg = function() {
  return this.arg;
}
MyClass.prototype.sayHello = function() {
  console.log('hello! ' + this.arg);
}

var myInstance = new MyClass('test');

image.png

ES6以降、クラスが導入されたため、この表現は過去のものとなりましが、現在でも古いNPMライブラリや古いブラウザ向けのトランスパイルでこの方式のクラス定義を見ることができます。

クラス

ECMAScriptでは、ES6よりクラス宣言とクラス式が導入され、一般的なカプセル化を実現するためのクラス表現が可能になりました。

次のように記述します。

class Rectangle {
  #_height = 0;
  #_width = 0;
  constructor(height, width) {
    this.height = height;
    this.width = width;
  }
  
  // メソッド
  calcArea() {
    return this.height * this.width;
  }

  // アクセサ
  get height(){ return this._height; }
  set height(num){
    this._height = num >= 0 ? num : 0;
  }
  get width(){ return this._width; }
  set width(num){
    this._width = num >= 0 ? num : 0;
  }

  // 静的メソッド
  static create(num){
    return new Rectangle(num, num)
  }
}

ここでは、メンバ変数(フィールド)として height width をもち、area ゲッター、calcAreaメソッドを持つクラスが定義されています。
this キーワードによってメンバ変数にアクセスできるようになっているのが分かります。

その他の言語のクラスとの違い

アクセス修飾子

フィールド宣言では # を付与することでプライベート化することができます。

class Rectangle {
  #_height = 0;
  #_width;
  constructor(height, width) {
    this.#_height = height;
    this.#_width = width;
  }
}

プライベートフィールドは事前宣言でないと作成できないことに注意してください。

いわゆる protectedなアクセス修飾子は定義することができません。

インターフェイス・抽象クラス

型を持たないJavaScriptでは、インターフェイスの定義ができません。
同様に抽象クラス(abstract class)も利用できません。ミックスインなどの他の方法を使う必要があります。

これらの問題は、JavaScriptの型付き拡張であるTypeScriptで解決されています。

デストラクタ

デストラクタやファイナライザが存在しません。JavaScriptの処理系はほとんどの場合、自動的にガベージコレクションが行われるため、インスタンスのライフサイクルは不透明です。終了処理が必要な場合には、手動で終了処理を実装し、呼び出す必要があります。

継承

その他の言語と同様、extends キーワードを用いることで、クラスを継承することができます。

class Animal {
  constructor(name) {
    this.name = name;
  }

  speak() {
    console.log(`${this.name} makes a noise.`);
  }
}

class Dog extends Animal {
  constructor(name) {
    super(name); // 継承元クラスのコンストラクターを呼び出し、name パラメータを渡す
  }

  speak() {
    console.log(`${this.name} barks.`);
  }
}

let d = new Dog("Mitzie");
d.speak(); // Mitzie barks.

継承先のクラスのコンストラクタでは、必ず super をコールする必要があります。
また、superは、継承元クラスのメソッドへのアクセスにも用いることができます。

class Cat {
  constructor(name) {
    this.name = name;
  }

  speak() {
    console.log(`${this.name} makes a noise.`);
  }
}

class Lion extends Cat {
  speak() {
    super.speak(); // 継承元クラスのメソッドを呼び出す
    console.log(`${this.name} roars.`);
  }
}

let l = new Lion("Fuzzy");
l.speak();

多重継承・ミックスイン・トレイト

多重継承はできません。常に1つのクラスをスーパークラスとして継承元にすることができます。

一方、ミックスインに関しては、技巧的な方法で実現できます。

let calculatorMixin = (Base) =>
  class extends Base {
    calc() {}
  };

let randomizerMixin = (Base) =>
  class extends Base {
    randomize() {}
  };

// 実際に使用する例
class Foo {}
class Bar extends calculatorMixin(randomizerMixin(Foo)) {}

トレイトも存在しないため、ミックスインを併用することになります。

静的メンバ(クラス変数)

static キーワードを用いることで定義されます。

class Point {
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }

  static displayName = "Point";
  static distance(a, b) {
    const dx = a.x - b.x;
    const dy = a.y - b.y;

    return Math.hypot(dx, dy);
  }
}

静的メンバの内部におけるthisは、クラスそのものを指すことを忘れないでください。

TypeScriptによる拡張

TypeScriptのクラスでは、さらにアクセス修飾子、抽象クラス、インターフェイス、ジェネリクスなどを導入することができます。
また、クラスを型として取り扱えるため、さらに安全性が向上します。

詳しくは、TypeScript入門の記事で別に紹介します。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?