1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

JavaScriptのClassについて part2

Last updated at Posted at 2022-07-23

初めに

今回はクラスの継承や拡張についてまとめていきたいと思います。

basic syntax

part1後の書き方の練習です。

class Person {
  constructor(name, birthYear) {
    this.name = name
    this.birthYear = Number(birthYear)
  }

  calculateAge() {
    const today = new Date()
    return today.getFullYear() - this.birthYear
  }
}
let Taro = new Person('Taro', '1980')
console.log(Taro) // Person { name: 'Taro', birthYear: 1980 }
console.log(Taro.calculateAge())

インスタンスを創るのに、constructor()への引数に任してthisオブジェクトを創り内部メソッドがthisオブジェクトをアクセスして値を利用する書き方です。

class Person {
  constructor(name) {
    this.name = name
  }

  calculateAge(birthYear) {
    this.birthYear = Number(birthYear)
    const today = new Date()
    return today.getFullYear() - this.birthYear
  }
}
let Taro = new Person('Taro')
console.log(Taro) // Person { name: 'Taro' }
console.log(Taro.calculateAge('1980')) // 42
console.log(Taro) // Person { name: 'Taro', birthYear: 1980 }

メソッド内部にthisオブジェクトを作り、呼び出されるときに付与する引数が指定の値となり、それを利用する書き方です。
calculateAge()の中にthis.でオブジェクトプロパティを生成させるから別のthisオブジェクトが生成されると思ったけれど、実際はどこでthisを使ってもclass Person内部フィールドで一つのコンテキストにまとめられているような感じです。

(まとめてconstructor()に書く、必要に応じて各関数に書く、どちらがいいかわからないんですが、場合によって使い分けが必要だと思います。)

以下はclassthisについての検証コード。

class Test {
  constructor(a) {
    // console.log(this) // Test {}
    this.a = a
  }

  test1(a) {
    // console.log(this) // Test {}
    this.a = a
    console.log(this.a)
  }
}

let test = new Test(5)
console.log(test) // Test { a: 5 }
test.test1(10) // 10
console.log(test) // Test { a: 10 }

classフィールドにあるthisは全部一つのオブジェクトにまとめられ、プロパティ名が同じであれば上書きもできます。

extends

extendsclassを拡張させる機能を持ち、今のクラス宣言にほかのクラスの子クラス(派生クラス)を生成させるキーワードです。

class Person {
  constructor(name) {
    this.name = name
  }

  calculateAge(birthYear) {
    this.birthYear = Number(birthYear)
    const today = new Date()
    return today.getFullYear() - this.birthYear
  }
}
//
class Taro extends Person {
  place(birthPlace) {
    this.birthPlace = birthPlace
    return `I come from ${this.birthPlace}`
  }
}

let taro = new Taro('Taro Yamada')
console.log(taro.calculateAge('1995')) // 27
console.log(taro.place('Japan')) // I come from Japan
console.log(taro)
// Taro { name: 'Taro Yamada', birthYear: 1995, birthPlace: 'Japan' }

part1の検証で、constructor()内部で定義されてない限り、メソッドたちはprototypeへ移行されるという不思議な動きがあると分かりました。
自分の持っていないメソッドはプロトタイプチェーンで親classprototypeへたどり着き、メソッドをアクセスする。
例えば、place()Taro.prototypecalculateAge()Person.prototypeを。

もう一度Object.getPrototypeOf()で検証してみると、

console.log(Person instanceof Function) // true

console.log(Object.getPrototypeOf(taro) === Taro.prototype) // true
console.log(Object.getPrototypeOf(Taro.prototype) === Person.prototype) // true
console.log(Object.getPrototypeOf(Person.prototype) === Object.prototype) // true

function personFn() { }
let newPersonFn = new personFn()
console.log(Object.getPrototypeOf(newPersonFn) === personFn.prototype) // true
console.log(Object.getPrototypeOf(personFn.prototype) === Object.prototype) // true

classprototypeの継承方法はコンストラクタ関数とは変わらないのですが、

console.log(Taro instanceof Function) // true

console.log(Object.getPrototypeOf(taro) === Taro.prototype) //true
console.log(Object.getPrototypeOf(Taro) === Person) // true

console.log(Object.getPrototypeOf(newPersonFn) === personFn.prototype) // true
console.log(Object.getPrototypeOf(personFn) === Function.prototype) // true

インスタンスは自分のprototype持ってないため本来なら親のprototypeからプロパティやメソッドをアクセスするのです。
class TaroFunctionインスタンスだから、プロトタイプチェーンのルールからすればclass Taro.__proto__、つまり一個上のプロトタイプチェーンは親のPerson.prototypeのはずだったけれど。

しかしconsole.log(Object.getPrototypeOf(Taro) === Person) // trueっていうのは、やっぱりコンストラクタ関数と違います。これでclassextends継承は親のprototypeではなく、親のフィールドへの延長ってことが分かりました。

class Taro extends Personというのは、class Taroclass Personへのアクセスを許可する。

ではconsole.log(Object.getPrototypeOf(taro) === Taro.prototype) // trueはどういうことでしょうか。part1で検証した結果、constructor()内で定義されてないメソッドはprototypeに移行される。

console.log(Object.getOwnPropertyNames(Taro.prototype))
// [ 'constructor', 'place' ]

これがインスタンスtaroTaro.prototypeへアクセスして、place()を利用できる理由だと思います。そしてclass Personclass Taroの延長フィールドとして、自分のインスタンスtaroclass Personフィールドをアクセスできます。

overriding class field

class Taro extends Person {
  calculateAge(birthYear) {
    this.birthYear = Number(birthYear)
    return this.birthYear
  }
}

let taro = new Taro('Taro Yamada')
console.log(taro.calculateAge('1995')) // 1995

(ここからの部分は自分の解釈が多いです。)
子クラスclass Taro内部のメソッドが、親クラスのclass Personと同じ名前のメソッドが存在すれば上書きします(自フィールド)。という言い方は適切かどうかは分かりませんが。
もともと自分のフィールドにないものだから延長線上の親フィールドへたどり着きメソッドを利用するようになる。しかし今は自分のフィールドで見つけるようになったら、親フィールドへアクセスせず自フィールドのメソッドを利用するのではないかと。

実際、子クラスフィールド内で定義されたメソッドは親フィールドのメソッドを上書きするのではありません。

class Taro extends Person {
  calculateAge(birthYear) {
    this.birthYear = Number(birthYear)
    return this.birthYear
  }
}

let taro = new Taro('Taro Yamada')
console.log(taro.calculateAge('1995')) // 1995

class Jiro extends Person { }

let jiro = new Jiro('Jiro Yamada')
console.log(jiro.calculateAge('1999')) // 23

別の子クラスフィールド内部は同じ名前のメソッドを定義しなければまた親フィールドにたどり着きます。

thisの作用が大事だと感じて、classフィールドとconstructor()での違いがとても気になるので、まずは

// class field
class Person {
  name = 'Taro'
}

class Taro extends Person { }

let taro = new Taro()
console.log(taro.name)
console.log(Object.getOwnPropertyNames(taro)) // [ 'name' ]
console.log(taro.hasOwnProperty('name')) // true

// constructor
class Person {
  constructor(name) {
    this.name = name
  }
}

class Taro extends Person { }

let taro = new Taro('Taro')
console.log(taro.name)
console.log(Object.getOwnPropertyNames(taro)) // [ 'name' ]
console.log(taro.hasOwnProperty('name')) // true

どちらでもインスタンスtaroが所有していて、しかし

class Person {
  name = 'Taro'

  constructor() {
    this.name = 'Jiro'
  }
}

class Jiro extends Person { }
let jiro = new Jiro()

console.log(jiro) // Jiro { name: 'Jiro' }

constructor()thisで同じプロパティを再定義したら、classフィールドのプロパティをオーバーライドします。

ほかの例も一緒に見ていきたいと思います。

class Person {
  test = function fnInClassField() {
    console.log('class field test')
  }

  constructor() {
    console.log(this)
    // Jiro { test: [Function: fnInClassField] }

    this.test = function fnInConstructor() {
      console.log('function in constructor')
    }
  }

  test() {
    console.log('function in parent class')
  }
}

class Jiro extends Person { }
let jiro = new Jiro()

console.log(jiro)
// Jiro { test: [Function: fnInConstructor] }

同じ名前testプロパティを三つのところに用意して、constructor()内部でthisオブジェクトをログしてみたら、最初の状態ではJiro { test: [Function: fnInClassField] }だったが、下にあるthis.testに上書きされ、最終的にJiro { test: [Function: fnInConstructor] }になりました。

ではtest()はどこにいってました?

class Person {
  test() {
    console.log('function in parent class')
  }
}

class Jiro extends Person { }

let jiro = new Jiro()
console.log(jiro)
// Jiro {}
console.log(jiro.hasOwnProperty('test'))
// false
console.log(Object.getOwnPropertyNames(Person.prototype))
// [ 'constructor', 'test' ]

Person.prototypeですね。

そして子クラスでは同じ名前のメソッドが書かれても、

class Jiro extends Person {
  test() {
    console.log('function in child class')
  }
}

let jiro = new Jiro()
jiro.test()
// function in constructor
console.log(jiro.test)
// [Function: fnInConstructor]

console.log(Object.getOwnPropertyNames(Jiro.prototype))
// [ 'constructor', 'test' ]

class Personconstructor()を上書きしません。
class Jiro内部のtest()もさきほどのルールのように自分のprototypeへ移行しました。
console.log(jiro.test)呼び出しは、class Personconstructor()に一番さきにたどり着きました。

さらに、もしconstructor()のプロパティtestを消して、どちらがさきにたどり着くのかな?

jiro.test() // class field test
console.log(jiro.test) // [Function: fnInClassField]

class Personのプロパティtestもなかったら、

jiro.test() // function in child class
console.log(jiro.test) // [Function: test]

Jiro.prototypeですね。

となると、アクセスの優先順位は、
constructor() → 親フィールド → 自分のprototype → 親のprototype

ここまで少しまとめてみたいと思います。なぜかclasstypeofではfunctionであったが、内部の構造としてはオブジェクトに近いと感じています。

  • classフィールドで書いたプロパティがconstructor()によって最終的に上書きされたり、上書きされずにthisオブジェクトのプロパティに残ります。
  • メソッドは変数で保存されてなかったら、thisオブジェクトではなく.prototypeへ。
  • 上の二点によってアクセス順位が付けられています。
  • constructor()のプロパティにはthisが必須で、インスタンスのthisオブジェクトを生成するためにも、外部から引数の導入先を指定するためにもthisに頼っています。

super

superキーワードの使い方としてですが、

  • 子クラスのcontructor()thisを使う前にsuper()呼び出さねばなりません。
  • 子クラスのないメソッドなら、super.を通して親クラスのメソッドを使えるようになります。

下は子クラスにsuper()を経由して親フィールドへ

class Person {
  constructor(name) {
    this.name = name
    console.log(this)
  }
}

class Taro extends Person {
  constructor(birthYear, birthPlace) {
    super()
    this.birthYear = birthYear
    this.birthPlace = birthPlace
    console.log(this)
  }
}

let taro = new Taro('1995', 'Tokyo')
console.log(taro.name) // undefined
console.log(Object.getOwnPropertyNames(taro)) // [ 'name', 'birthYear', 'birthPlace' ]
console.log(taro.hasOwnProperty('birthYear')) // true
console.log(taro.hasOwnProperty('birthPlace')) // true
class Person {
  constructor(name) {
    this.name = name
  }

  calculateAge(birthYear) {
    this.birthYear = birthYear
    const today = new Date()
    return today.getFullYear() - this.birthYear
  }

  place(birthPlace) {
    this.birthPlace = birthPlace
    return `${this.name} comes from ${this.birthPlace}`
  }
}
// super
class Taro extends Person {
  personalInfo(birthYear, birthPlace) {
    setTimeout(() => console.log(`${this.name} is ${super.calculateAge(birthYear)} years old, and ${super.place(birthPlace)}.`), 1000)
  }
}

let taro = new Taro('Taro Yamada')

console.log(taro.name) // Taro Yamada
console.log(taro.calculateAge('1995')) // 27
console.log(taro.place('Japan')) // Taro Yamada comes from Japan
taro.personalInfo('1995', 'Japan')
// Taro Yamada is 27 years old, and Taro Yamada comes from Japan.

正直に言って、ここまでの検証から見るとsuperキーワードはthisの参照先の示しみたいな機能を持つものだと感じています...自分のconstructorを再定義する前にsuper()thisの参照を一致させる仕事をしているみたいな感じです。(じゃないと親constructorで定義されたプロパティとメソッドが使えなくなる。)

use Arrow functions

参考文章のClass inheritanceではちょっと気になるところがありますので、

class Taro extends Person {
  personalInfo(birthYear, birthPlace) {
    setTimeout(() => {
      console.log(super.calculateAge(birthYear))
      console.log(super.place(birthPlace))
    }, 1000)
  }
}

let taro = new Taro('Taro Yamada')
taro.personalInfo('1995', 'Japan')
// 27
// Taro Yamada comes from Japan

Web APIsなどの外部関数なら、そのなかのcallbackはアロー関数のように書いても構いません、ちゃんと動けます。むしろアロー関数じゃなきゃ動けなくなる。

class Taro extends Person {
  personalInfo() {
    setTimeout(() => console.log(this), 1000)
  }
}

let taro = new Taro('Taro Yamada')
taro.personalInfo()
// Taro { name: 'Taro Yamada' }
class Taro extends Person {
  personalInfo() {
    setTimeout(function () {
      console.log(this)
    }, 1000)
  }
}
let taro = new Taro('Taro Yamada')
taro.personalInfo()
// Timeout {
//   ...
// }

JavaScriptのthisについて -- this in Arrow function
JavaScriptのthisについて -- API asynchronous callbacks
やはり問題なのはthisです。アロー関数ならthisは親のコンテキストを継承するので、ここの親フィールドはTaro {}だからthisコンテキストもそれを継承し、ちゃんとプロパティとメソッドをアクセスできる状態です。
Web APIscallbackがアロー関数じゃなければ、thisコンテキストは普通はwindowに参照していますが、ここはTimeout {}自体を参照する、なんか興味深いことですが、ここまでの知識がないためまず課題として残しておきます。

下はクロージャの検証です。

class Saburo extends Person {
  add(a) {
    return (b) => {
      console.log(this)
      return a + b
    }
  }
}
let saburo = new Saburo()
console.log(saburo.add(1)(2))
// Saburo {}
// 3
class Saburo extends Person {
  add(a) {
    return function (b) {
      console.log(this)
      return a + b
    }
  }
}

let saburo = new Saburo()
console.log(saburo.add(1)(2))
// undefined
// 3

当たり前かもしれないけど、クロージャでもアロー関数じゃないとthisの参照が変わってしまう。
(使えるは使えるけれど、thisにかかわるプロパティは使えませんが)

class Saburo extends Person {
  constructor(a, b) {
    super()
    this.a = a
    this.b = b
  }
  add() {
    return () => {
      console.log(this)
      return this.a + this.b
    }
  }
}
let saburo = new Saburo(1, 2)
console.log(saburo.add()())
// Saburo { a: 1, b: 2 }
// 3

(でも多分、たいていのプロはこういう書き方はしないと思います...。)

Web APIsではなくても、なるべくthisコンテキストを一致するため、メソッド内部の関数はアロー関数の書き方を使った方がいいと思います。

super & [[HomeObject]]

あまり使ったことのないsuperに出会って、最初superthisを子クラスである自フィールドへ変えたのでしょうか?という素朴な疑問持ってたけれど、いろいろ調べてわかったこと、以下は自分で解釈しました。

[[Super: internals, [[HomeObject]]]]
[[HomeObject]]

自分のオブジェクトで定義されたthisコンテキストを、ほかのオブジェクトを経由して参照先を再指定することができません。

thisについて色々と調べたから、classをオブジェクトとして見ると、内部のメソッド(constructor()もメソッドも)がImplicit bindingthisは一個上のコンテキストつまりclassを指しています。そうなると親クラスも子クラスも自分のthisを持ち、子クラスが延長線のままではthisを使うという自フィールドに新しく定義しようとする行為はエラーになってしまいます。(thisは自フィールドなのに、メソッドは親フィールド。じゃどっちを参照すればいい?って混乱しちゃうよね)

なのでsuperを通してthisを定義する(その前thisはuninitialized)、子クラスthisが自フィールドに指してもらうというのは、superthisの参照を親フィールドでも自フィールドでもなく、[[HomeObject]] プロパティの値を参照せよって指示を出したわけだと思います。そしてconstructor()内でsuper()を使えばそれ以降のthisの参照が確定して、もし親フィールドのメソッドをアクセスしようとするならsuper.を通してメソッドをそのまま使わせてもらう。
super.thisコンテキストを上書きするのではなく、super.でメソッドをコピーして自フィールドに持ち込むという意味です。superthis[[HomeObject]] へ参照させ、次第にコピーされたメソッドは自フィールドのプロパティを使うようになる)

How to extend a class without having to use super in ES6? - stackoverflow

class Person {
  constructor(birthPlace = 'America') {
    this.name = 'Saburo'
    this.birthYear = 2000
    this.birthPlace = birthPlace
  }

  test() {
    console.log(`I am ${this.name}, I was born on ${this.birthYear} in ${this.birthPlace}`)
  }
}

class Saburo extends Person {
  constructor(birthPlace) {
    // name = 'Saburo'
    // ReferenceError: name is not defined
    // this.name = 'Saburo'
    // ReferenceError: Must call super constructor in derived class before accessing 'this' or returning from derived constructor
    super()
    this.birthPlace = birthPlace
  }
}

let saburo = new Saburo('Hokkaidou')
saburo.test()
// I am Saburo, I was born on 2000 in Hokkaidou

//
class Shiro extends Person { }
let shiro = new Shiro()
shiro.test()
// I am Saburo, I was born on 2000 in America

thisはuninitialized状態だと、ReferenceError参照先エラーで、自フィールドにないプロパティがnot definedになったり、自フィールドのthisをアクセスする前にsuperの呼び出しが必須になったりする。
saburo.test()nameを出力できるのは決して自フィールドにnameを持ってるわけではなく、親フィールドにアクセスしてプロパティの値を参照し、birthPlaceを変えたのも、決して親フィールドのプロパティを変えたのでなく、super()でthisが確定し、自フィールドのthisオブジェクトにbirthPlaceを見つかったからさらに親フィールドへアクセスする必要もなくなった。

自分の解釈や推測が多いけれど何だか前の検証からも納得できる結論に導いている。次回は静的プロパティとメソッドをまとめていきたいと思います。

1
1
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
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?