JavaScript/クラス
クラス
編集- オブジェクト指向の用語については「オブジェクト指向プログラミング」を参照のこと。
概要
編集ECMAScript2015(ES6)で導入された、キーワード class (クラス)は、オブジェクトを作成するためのテンプレートです[1]。 クラスは、データと、それ自身を処理するコードとともにカプセル化します。 クラスを導入した後もECMAScript/JavaScriptはプロトタイプベースのオブジェクト指向スクリプティング言語ですが、ES5までのクラス似のセマンティクスとは異なる構文やセマンティクスを持っています。
- コード例
class Hello { constructor(name = "world") { this.name = name; } toString() { return `Hello ${this.name}` } print() { console.log(String(this)) } } const hello = new Hello() hello.print() const hello2 = new Hello("my friend") hello2.print() console.log( `typeof Hello === ${typeof Hello} Object.getOwnPropertyNames(hello) === ${Object.getOwnPropertyNames(hello)} Object.getOwnPropertyNames(hello.__proto__) === ${Object.getOwnPropertyNames(hello.__proto__)}`)
- 表示結果
Hello world Hello my friend typeof Hello === function Object.getOwnPropertyNames(hello) === name Object.getOwnPropertyNames(hello.__proto__) === constructor,toString,print
- クラス定義
class Hello {
- クラス定義の開始部分です。
- クラス名Helloで、今回は特にスーパークラスを継承せずに定義しています。
- とはいえ、暗黙に Object を継承しているので Object がスーパークラスだと言えます。
- また、クラス定義中は strict モードになるので、宣言なしに変数を使うなどのラフなコードは書けません。
- コンストラクター
constructor(name = "world") { this.name = name; }
- new 演算子でクラスのインスタンスを作るとき呼出されます。
- thisは、new演算子で作りかけのインスタンスです。
- constructorというメソッド名は固定なので名称変更は不能、またクラス内に2つ以上は定義できません。
- 文字列化メソッド
toString() { return `Hello ${this.name}` }
- Object.prototype.toStringは、文字列化するメソッドです。
- 固有メソッド
print() { console.log(String(this)) }
- printメソッドは継承元のObjectのprototypeににはないのでクラスHelloに固有なメソッドです。
- String(this)は明示的な文字列化メソッドの呼び出しで、(上で定義した)this.toString()が暗黙に呼びだされます。
- インスタンス化(パラメータ省略)とメソッド呼び出し
const hello = new Hello() hello.print()
- クラスのインスタンス化は、new演算子にクラス名と引数リストを伴って行います。
- この例では、引数を省略していますが
()
は必要です。 - 表示結果
Hello world
- パラメータが省略されたので、ディフォルトの "world" を使用。
- インスタンス化(パラメータあり)とメソッド呼び出し
const hello2 = new Hello("my friend") hello2.print()
- 引数に "my friend" を与えてインスンス化
- 表示結果
Hello my friend
- 今度は、"my friend" を伴って表示。
- 特徴的な値
console.log( `typeof Hello === ${typeof Hello} Object.getOwnPropertyNames(hello) === ${Object.getOwnPropertyNames(hello)} Object.getOwnPropertyNames(hello.__proto__) === ${Object.getOwnPropertyNames(hello.__proto__)}`)
- 表示結果
typeof Hello === function Object.getOwnPropertyNames(hello) === name Object.getOwnPropertyNames(hello.__proto__) === constructor,toString,print
- クラス定義された識別子の typeof は function になります[2]。
- Object.getOwnPropertyNamesは、オブジェクトの(継承ではなく)直接のプロパティの一覧をArrayで返します[3]。
- __proto__ は Object.prototype のアクセサープロパティ (ゲッター関数およびセッター関数) です。
包含と継承
編集クラス間の関係で混乱しやすいのが、包含と継承です。
- 包含
- クラスAがクラスBを1つまたは複数プロパティに持っています。⇒ クラスAはクラスBを包含しています。
- 継承
- クラスAのプロパティPを参照したとき、A.Pが定義されていなければクラスBのプロパティPが参照されます。⇒ クラスAはクラスBを継承しています。
- 包含と継承のサンプルコード
class Point { x y constructor(x = 0, y = 0) { console.info("Point::constructor") this.x = x this.y = y } move(dx = 0, dy = 0) { this.x += dx this.y += dy console.info('Point::move') return this } } class Shape { location constructor(x = 0, y = 0) { this.location = new Point(x, y) console.info("Shape::constructor") } move(x, y) { this.location.move(x, y) console.info('Shape::move') return this } } class Rectangle extends Shape { width height constructor(x = 0, y = 0, width = 0, height = 0) { super(x, y) this.width = width this.height = height console.info("Rectangle::constructor") } } console.info("Create a Rectangle!") let rct = new Rectangle(12, 32, 100, 50) console.info("rct = ", rct) console.info('rct instanceof Rectangle => ', rct instanceof Rectangle) console.info('rct instanceof Shape => ', rct instanceof Shape) console.info('rct instanceof Point => ', rct instanceof Point) rct.move(11, 21) console.info("rct = ", rct) let rct2 = new Rectangle(1, 2, 10, 150) console.info("rct = ", rct) console.info("rct2 = ", rct2)
- 実行結果
Create a Rectangle! Point::constructor Shape::constructor Rectangle::constructor rct = Rectangle { location: Point { x: 12, y: 32 }, width: 100, height: 50 } rct instanceof Rectangle => true rct instanceof Shape => true rct instanceof Point => false Point::move Shape::move rct = Rectangle { location: Point { x: 23, y: 53 }, width: 100, height: 50 } Point::constructor Shape::constructor Rectangle::constructor rct = Rectangle { location: Point { x: 23, y: 53 }, width: 100, height: 50 } rct2 = Rectangle { location: Point { x: 1, y: 2 }, width: 10, height: 150 }
- 包含関係
class Shape { location constructor(x = 0, y = 0) { this.location = new Point(x, y)
- ShapeはPointを包含しています。
- 継承関係
class Rectangle extends Shape { width height constructor(x = 0, y = 0, width = 0, height = 0) { super(x, y)
- RectangleはShapeを継承しています。
super(x, y)
はスーパークラス(この場合はShape)のコンストラクターの呼び出し。
ES/JSは、単一継承しかサポートしませんが包含やMix-inを使うことで、多重継承を使う動機となる「機能の合成」は実現できます。
ES6 の class を使ったコードと相当するES5のコード
編集ECMAScript/JavaScriptには複素数型がありません。 実装の触りだけですね実際に動くコードを観てみます。
- ES6版
class Complex { constructor(real = 0, imag = 0) { return Object.assign(this,{ real, imag }) } toString() { return `${this.real}+${this.imag}i` } cadd(n) { return new Complex(this.real + n.real, this.imag + n.imag) } } Complex.prototype.csub = function(n) { return new Complex(this.real - n.real, this.imag - n.imag) } let a = new Complex(1, 1) let b = new Complex(2, 3) console.info("a = " + a) console.info("b = " + b) console.info("a + b = " + a.cadd(b)) console.info("a - b = " + a.csub(b)) console.info("a instanceof Complex =>", a instanceof Complex)
- ES5版
function Complex(real = 0, imag = 0) { return Object.assign(this, { real, imag }) } Object.assign(Complex.prototype, { toString(){ return `${this.real}+${this.imag}i` }, cadd(n){ return new Complex(this.real + n.real, this.imag + n.imag) }, }) Complex.prototype.csub = function(n) { return new Complex(this.real - n.real, this.imag - n.imag) } let a = new Complex(1, 1) let b = new Complex(2, 3) console.info("a = " + a) console.info("b = " + b) console.info("a + b = " + a.cadd(b)) console.info("a - b = " + a.csub(b)) console.info("a instanceof Complex =>", a instanceof Complex)
- 実行結果(ES6/ES5双方同じ)
a = 1+1i b = 2+3i a + b = 3+4i a - b = -1+-2i a instanceof Complex => true
- ES5では、constructorに相当する関数がclassに対応するクロージャを提供します。
- オブジェクトにtoString()メソッドを定義する文字列化をオーバーライドできます(ES5以前からの機能)
- オブジェクトにcadd()メソッドを定義しています(ES/JSでは演算子オーバーロードできないので、名前付き関数にしました)。
- オブジェクトへのメソッドの追加
Complex.prototype.csub = function(n) { return new Complex(this.real - n.real, this.imag - n.imag) }
- ES5でもES6でも、オブジェクトの prototype にプロパティを追加することで、オブジェクトに新しいメソッドを追加することができます。
- このコードでは、
.real
,.imag
は制限なくアクセスできますが、アクセサプロパティを定義することでアクセスを制限できます(ここではコードを簡素にすることを優先しました) - 「#アクセサプロパティ」も参照
- class構文を使った継承とfunctionを使った継承の間の小さな差ですが、classは関数と違って巻上げ (Hoisting) が起こりません。
- 「JavaScript/関数#関数の巻上げ」も参照
クラス式
編集関数宣言に対する関数式と同じ様に、クラス宣言に対してはクラス式があります。
- 構文
class [クラス名] [extends スーパークラス名] { // クラス定義 }
- コード例
var cls1 = class {/*クラス定義*/} function f() { return class {/*クラス定義*/} } var cls2 = f() console.log(`cls1.name = "${cls1.name}" cls2.name = "${cls2.name}"`)
- 実行結果
cls1.name = "cls1" cls2.name = ""
- クラス式ではクラス本体のクラス名は省略可能で、省略された場合にクラス式がスカラ変数の初期値あるいはスカラ変数に代入されていた場合 Class.nameはスカラ変数の変数名になります。
- 関数の戻り値でクラス式を返した場合、Class.nameは "" となります。
クラス式の呼出すときは、一般のクラスと同様に呼出し元で new
演算子を使います。
- 構文
new クラス式の値([引数1[,引数2[..., 引数n]]])
アクセサプロパティ
編集カプセル化をしたい場合にそのクラスのインスタンスのプロパティにダイレクトにアクセスしたのでは本末転倒です(クラスの内部構造が変わったら、プロパティを参照するコードを全て変更するはめになります)。 そこでプロパティアクセス(obj.propやobj[propString]によるアクセス)をオーバーライドするアクセサプロパティが役に立ちます。 アクセサプロパティは、実際には存在しないプロパティが存在しているかのように見せる仕掛けです[4]。
#ES6 の class を使ったコードと相当するES5のコードのES6の例を題材にアクセサプロパティを定義してみます。
- アクセサプロパティの使用例
class Complex { constructor(real = 0, imag = 0) { this.ary = new Float64Array([real, imag]) } get real() { return this.ary[0] } set real(n) { return this.ary[0] = n } get imag() { return this.ary[1] } set imag(n) { return this.ary[1] = n } toString() { return `${this.real}+${this.imag}i` } cadd(n) { return new Complex(this.real + n.real, this.imag + n.imag) } csub(n) { return new Complex(this.real - n.real, this.imag - n.imag) } } let a = new Complex(1, 1) let b = new Complex(2, 3) console.info(`a = ${a} b = ${b} a.cadd(b) = ${a.cadd(b)} a.csub(b) = ${a.csub(b)}`)
- 実行結果
a = 1+1i b = 2+3i a.cadd(b) = 3+4i a.csub(b) = -1+-2i
- 内部構造の変更
class Complex { constructor(real = 0, imag = 0) { this.ary = new Float64Array([real, imag])
- real, imagのむき出しのプロパティから2要素のFloat64Arrayに変更
- アクセサプロパティ
get real() { return this.ary[0] } set real(n) { return this.ary[0] = n } get imag() { return this.ary[1] } set imag(n) { return this.ary[1] = n }
- real, imagを擬似プロパティとして、それぞれのセッターとゲッターを定義。
- これで、n.real や n.imag で値を参照でき、n.real = 110 のように左辺値化もできます(n.real(), n.imag()や n.real(110) ではないことに注意してください)。
staticプロパティ
編集クラスにはstaticプロパティを定義できます。 クラスの正体はFunctionオブジェクトなので、関数のプロパティを定義していることになります。
- staticプロパティの使用例
class Test { static min(){ return 100; } static get MAX(){ return 10000; } static n = 123 } console.log(`Test.min() = ${Test.min()} Test.MAX = ${Test.MAX} Test.n = ${Test.n}`)
- 実行結果
Test.min() = 100 Test.MAX = 10000 Test.n = 123
フィールド宣言
編集クラスでは、フィールドを明示的に宣言することで、クラス定義がより自己文書化され、フィールドが常に存在するようになります[5]。 フィールドはデフォルト値を持つことも持たないことも宣言できます。
- [ フィールドの使用例]
class ClassWithPublicField { x constructor(x = 42) { this.x = x } value(c){ return this.x } static getValue(c){ return c.x } } var x = new ClassWithPublicField() var y = new ClassWithPublicField(195) console.log(x.value()) console.log(y.value()) console.log(ClassWithPublicField.getValue(x)) f = ClassWithPublicField.getValue console.log(f(y))
- 実行結果
42 195 42 195
x
は(パブリック)フィールドの宣言で、x にはメソッドのthisを介して、this.x
の様に参照します。- トリッキーですが、staticメソッドからもフィールドを参照でき、staticメソッドの値を使った呼出しでもまたフィールドを参照できます。
プライヴェートフィールド
編集クラスのインスタンスには、クラスの外からはプロパティアクセスのできないプライヴェートフィールドを設けることができます[5]。 これはカプセル化に役立つ機能です。
- プライヴェートフィールドの使用例
class ClassWithPrivateField { #x constructor(x = 42) { this.#x = x } value(c){ return this.#x } static getValue(c){ return c.#x } } var x = new ClassWithPrivateField() var y = new ClassWithPrivateField(195) console.log(x.value()) console.log(y.value()) console.log(ClassWithPrivateField.getValue(x)) f = ClassWithPrivateField.getValue console.log(f(y))
- 実行結果
42 195 42 195
#x
はプライヴェートフィールドの宣言で、#x にはメソッドのthisを介して、this.#x
の様に参照します。- トリッキーですが、staticメソッドからもプライヴェートフィールドを参照でき、staticメソッドの値を使った呼出しでもまたプライヴェートフィールドを参照できます。
ユーザー定義クラスのインスタンス配列
編集ユーザー定義クラスのインスタンスを配列化する為には、コンストラクターのパラメータをコレクションにしたものを、イテレーションし Array::map() でコンストラクターに渡すと簡素に表現できます。
class Drink { #name #price constructor(name, price) { this.#name = name this.#price = price } toString() { return `${this.#name}: ${this.#price}` } } const drinks = Object.entries({ milk: 180, juice: 150 }).map(pair => new Drink(...pair)) drinks.forEach(drink => console.log(String(drink)))
- 実行結果
milk: 180 juice: 150
JavaScriptでは、ユーザー定義クラスのインスタンスもオブジェクトなので、Classのフィールドも(このコードのようにプライベートフィールドにしない限り)プロパティとしてアクセスできてしまいますが、これではカプセル化を破壊してしまいますし、より厄介なのはプロパティの綴りを間違えても警告もエラーも出ず、発見困難なバグの原因となってしまいます。 このため、Object.prototype.toString の様な共通化されたメソッド(インターフェースとも考えられます)を使い、内部構造を隠すことが肝要です。
脚註
編集- ^ “Classes - JavaScript // MDN” (2021年11月10日). 2021年11月20日閲覧。
- ^ このあたりが、プロトタイプベース継承の糖衣構文たる所以です
- ^ “Object.getOwnPropertyNames() - JavaScript // MDN” (2021年11月21日). 2021年11月22日閲覧。
- ^ ECMA-262::15.4 Method Definitions
- ^ 5.0 5.1 “Classes - JavaScript // MDN § Field declarations” (2021年11月10日). 2021年11月24日閲覧。
参考文献
編集- “Draft ECMA-262 / November 20, 2021 // ECMAScript® 2022 Language Specification§15.7 Class Definitions” (2021年11月20日). 2021年11月24日閲覧。
- “Classes - JavaScript // MDN” (2021年11月10日). 2021年11月24日閲覧。