Q1RecurringTask に書いていない weekly.complete() の定義が見つかる段は?
オブジェクト指向まとめ — class 構文と this の早見表
このカテゴリで扱った class の書き方を、メンバーの置き場所、名前を探す順、this の決まり方、機能の足し方、エラーの止まる時点で整理する早見表です。
タスク管理のコードでは、static や # のメンバー、extends した子クラス、取り出したメソッドが 1 つのファイルに混ざります。違いを整理していないと、undefined や TypeError の原因をたどれません。
この記事では、class 構文 と this を中心に、このカテゴリの書き方を早見表で扱います。
置き場所でメンバーを読む — class 構文の早見表
ほかの人が書いた Task の class を読むとします。static・#・get の付いた行が同じ波括弧に並び、task.fromJSON(text) が TypeError になる理由や、JSON.stringify(task) に完了の状態が出ない理由は、1 行ずつ読んでも分かりません。
class の本体に書いたものは、書き方で 置き場所(インスタンス自身・全インスタンスが共有するメソッドを置く Task.prototype・class そのもののどれか)が決まります。名前を読むときは、インスタンス自身から Task.prototype、Object.prototype の順にたどります。
class Task {
static count = 0; // Task そのものが 1 つだけ持つ
priority = "通常"; // new のたびに task 自身に入る
#done = false; // task に入るが、class の外からは読めない
constructor(title) {
this.title = title;
Task.count += 1;
}
complete() { this.#done = true; } // Task.prototype に置かれ、全インスタンスで共有する
get status() { return this.#done ? "完了" : "未着手"; }
static fromJSON(text) { return new Task(JSON.parse(text).title); }
}
// インスタンスから読める名前と、読めない名前を確かめる
const task = Task.fromJSON('{"title":"見積書を送る"}');
task.complete();
console.log(task.status, Task.count); // 完了 1
console.log(Object.keys(task).join(", ")); // priority, title
console.log(JSON.stringify(task)); // {"priority":"通常","title":"見積書を送る"}
console.log(task.hasOwnProperty("complete"), typeof task.fromJSON); // false undefined
- task 自身 → Task.prototype → Object.prototype の順に探す
title・priority— キーに並び、JSON に出る
complete()・get status()— 探すと見つかる
#done— task に保存されるが、class の中のthis.#doneでしか読めない
count・fromJSON()— 探す範囲の外にあるtask.fromJSONはundefined
status は Task.prototype にあり、#done は task にあってもキーに並ばないため、どちらも Object.keys と JSON.stringify に出ません。JSON に含めたいときは toJSON で出す値を返します。下の表は、このカテゴリの書き方を置き場所で並べたものです。
| 書き方 | 置き場所 | task から読むと |
|---|---|---|
| constructor の this.title | task 自身 | キーと JSON に出る |
| クラスフィールド priority | task 自身 | キーと JSON に出る |
| private フィールド #done | task 自身(class 内だけ) | キーにも JSON にも出ない |
| メソッド complete() | Task.prototype | 呼べる。キーには出ない |
| get / set status | Task.prototype | 括弧なしで読む |
| static count / fromJSON() | Task そのもの | undefined |
どの定義が動くかをたどる — extends と super
毎週くり返すタスクを class RecurringTask extends Task で作ったとします。weekly.describe() の行を見ても、動くのが子クラスと親のどちらの describe か、子クラスに書いていない complete がなぜ呼べるのかは、その行からは読み取れません。
名前は プロトタイプチェーン を自身から順に探し、最初に見つけた定義が動きます。子クラスのオーバーライドは親より先に見つかり、super.describe() は親から探し始めます。ポリモーフィズムで種類ごとに書き分けたメソッドも、この順で見つかります。
class Task {
constructor(title) { this.title = title; }
describe() { return `タスク: ${this.title}`; }
complete() { return `${this.title} を完了`; }
}
// 子クラス: super() で親の constructor を呼び、describe だけを書き直す
class RecurringTask extends Task {
constructor(title, weekday) { super(title); this.weekday = weekday; }
describe() { return `${super.describe()}(毎週${this.weekday})`; }
}
// どの定義が動くかを、呼んで確かめる
const weekly = new RecurringTask("週報を出す", "金曜");
console.log(weekly.describe()); // タスク: 週報を出す(毎週金曜)
console.log(weekly.complete()); // 週報を出す を完了
console.log(`${weekly}`); // [object Object]
console.log(weekly instanceof Task, typeof weekly.archive); // true undefined
archive のようにどの段にも無い名前は undefined で、呼ぶと TypeError です。${weekly} が [object Object] なのは、最後の段にある toString の既定の定義が動くためです。
呼び方で this を見分ける — this の早見表
タスクの完了処理を、変数に入れて後から呼んだり、setTimeout に渡したりするとします。task.complete() では動くメソッドも、渡し方によって this が task ではなくなり、TypeError で止まったり、this.title が undefined になったりします。
this に入る値は、関数の書き方で 決まる時点 が違います。class に書いた通常のメソッドは呼んだ行のドットの左で決まり、アロー関数のフィールドは new でインスタンスを作るときに決まります。bind の戻り値は、bind に渡した値に固定された別の関数です。
class Task {
onComplete = () => `${this.title} を完了`; // this は new のときの task
constructor(title) { this.title = title; }
complete() { return `${this.title} を完了`; } // this は呼んだ行で決まる
}
const task = new Task("請求書を送る");
// ドットの左に task を書いて呼ぶ
console.log(task.complete()); // 請求書を送る を完了
// 変数に取り出してから呼ぶ
const runMethod = task.complete;
const runArrow = task.onComplete;
const runBound = task.complete.bind(task);
console.log(runArrow()); // 請求書を送る を完了
console.log(runBound()); // 請求書を送る を完了
// runMethod(); // TypeError: Cannot read properties of undefined (reading 'title')
コードの runBound も、取り出した後で task のまま動きます。setTimeout(task.complete, 100) ではブラウザが this に window を入れるので、アロー関数で包みます。下の表は、呼び方ごとに this が決まる時点を並べたものです。
| 呼び方 | this が決まる時点 | 取り出して渡すと |
|---|---|---|
| 通常のメソッド | 呼んだ行のドットの左 | task を失い TypeError |
| bind の戻り値 | bind を呼んだとき | task のまま動く |
| アロー関数のフィールド | new のとき | task のまま動く |
| () => task.complete() で包む | 包んだ中で task.complete() を呼んだとき | task のまま動く |
| Object.assign で足したメソッド | 呼んだ行のドットの左 | 書き込み先を失う |
子クラスに書いた同名メソッドが動かない
class RecurringTask extends Task { onComplete() { … } } と書いても、親 Task のアロー関数のフィールドが weekly 自身に入って先に見つかり、weekly.onComplete() は親の関数を動かします。書き直す関数は親もメソッドで書きます。
機能を足す場所を選ぶ — 継承・prototype・合成
動いているタスク管理に、あとから機能を足すとします。1 件だけにピン留めを付けたいのか、作成済みの全タスクにラベル表示を付けたいのか、チームのタスクにだけ担当者を付けたいのかで書く場所を誤ると、付けたつもりのタスクで TypeError になります。
このカテゴリで扱った足し方は、機能の 書き込む先(インスタンス自身・Task.prototype・extends でつなぐ新しい段)が違います。書き込む先が決まると、その名前をチェーンで見つけられるインスタンスの範囲も決まります。
class Task {
constructor(title) { this.title = title; }
}
const first = new Task("見積書を送る"); // 先に 2 件作っておく
const second = new Task("議事録をまとめる");
// 1 件だけに書き込む
Object.assign(first, { pin() { return `${this.title} をピン留め`; } });
// Task.prototype に代入する
Task.prototype.label = function () { return `タスク: ${this.title}`; };
// ミックスインで段を重ねた class を作る
const Assignable = (Base) => class extends Base {
assign(name) { return `${this.title} を ${name} さんに割り当て`; }
};
class TeamTask extends Assignable(Task) {}
console.log(typeof second.pin, second.label()); // undefined タスク: 議事録をまとめる
console.log(typeof first.assign); // undefined
console.log(new TeamTask("週報を出す").assign("佐藤")); // 週報を出す を 佐藤 さんに割り当て
作成済みの second にも label が届くのは、second がチェーンで Task.prototype を探すためです。prototype への代入は、本体を書き換えられない class に使います。子クラスに書いたメソッドは、ミックスインと同じく子クラスで作ったものにだけ届きます。
1 件ずつ足した機能は typeof で確かめる
Object.assign で first にだけ pin を書き込んでも、first も second も instanceof Task は true です。class では見分けられないので、呼ぶ前に typeof task.pin === "function" で持っているかを確かめます。
止まる時点で原因を探す — エラーの早見表
class のコードでエラーが出たとき、止まった行と直す行が離れていることがあります。子クラスの constructor の書き間違いは new を実行したときに中の行で止まり、class の外に書いた # の名前では、その行より上にある console.log の表示まで出ません。
エラーは、止まる 時点(コードを読み込む段階・new で constructor が動くとき・メソッドを呼んだとき)で 3 つに分けられます。読み込む段階の SyntaxError では 1 行も実行されず、ほかの 2 つはエラーの行より前の行が動いてから止まります。
class Task {
#done = false;
constructor(title) { this.title = title; }
complete() { return `${this.title} を完了`; }
}
class RecurringTask extends Task {
constructor(title, weekday) {
this.weekday = weekday; // super() より前に this を書いた
super(title);
}
}
console.log("確認を始めます"); // ② と ③ では表示され、① の行があると表示されない
// ① 読み込む段階: console.log(new Task("請求書を送る").#done);
// SyntaxError: Private field '#done' must be declared in an enclosing class
// ② new のとき: new RecurringTask("週報を出す", "金曜");
// ReferenceError: Must call super constructor in derived class before accessing 'this' …
// ③ 呼んだとき: const run = new Task("請求書を送る").complete; run();
// TypeError: Cannot read properties of undefined (reading 'title')
② と ③ は直前の表示まで出てから止まるので、最後に出た表示の後ろから原因の行を探せます。下の表は、このカテゴリで出てきたエラーを、止まる時点の早い順に並べたものです。
| エラー文 | 原因の書き方 | 直し方 |
|---|---|---|
| SyntaxError: Private field | class の外で task.#done | getter を通して読む |
| SyntaxError: … export named | default export を { Cart } で読む | 波括弧を外す |
| ReferenceError: Must call super | super() より前に this | super() を先に呼ぶ |
| TypeError: … without 'new' | class を new なしで呼ぶ | new を付ける |
| TypeError: Cannot read properties | 取り出したメソッドを run() と呼ぶ | bind かアロー関数で包む |
| RangeError: Maximum call stack | setter で同名に代入/super. を省く | 別名に入れる/super. を付ける |
理解度チェック
まずは1問ずつ答えてみましょう。
Q2アロー関数のフィールドを const run = task.onComplete; run(); と呼ぶと、this は?
Q3console.log の後の new で ReferenceError が出たとき、その表示は?