Q13 つとも書いていない class の draft で、const [firstLine] = draft; を実行すると?
特殊メソッド — toString / toJSON / Symbol.iterator
決まった場面で JavaScript が呼ぶ toString・toJSON・Symbol.iterator を扱います。表示の形の決め方、JSON に出す値の選び方、class を for...of で回す書き方を学べます。
注文をテンプレートリテラルに入れると [object Object] と表示され、JSON.stringify では getter の合計が入らず、for...of では TypeError で止まります。
この記事では、特殊メソッド(JavaScript が決まった場面で名前を探して呼ぶメソッド)のうち、表示・JSON への変換・繰り返しで呼ばれる 3 つを扱います。
文字列に入れたときの表示を決める — toString
通知文に注文を入れようとして、受付: ${order} と書いたとします。表示されるのは注文番号でも金額でもなく [object Object] です。画面ごとに order.id と order.total を並べ直すと、同じ組み立てがあちこちに増えます。
toString(値を文字列にする場面で JavaScript が呼び、戻り値をその文字列として使うメソッド)は、${}・String()・配列の join で呼ばれます。class に書かないと、チェーンの先の Object.prototype にある既定の toString が [object Object] を返します。
// toString を書いていない class
class DraftOrder {
constructor(id, total) { this.id = id; this.total = total; }
}
// toString を書いた class
class Order {
constructor(id, total) { this.id = id; this.total = total; }
toString() { return `注文 ${this.id}(${this.total} 円)`; }
}
const draft = new DraftOrder("A-101", 3200);
const order = new Order("A-102", 4800);
// テンプレートリテラルに入れると、toString が呼ばれる
console.log(`受付: ${draft}`); // 受付: [object Object]
console.log(`受付: ${order}`); // 受付: 注文 A-102(4800 円)
// String() と配列の join も、同じ toString を使う
console.log(String(order)); // 注文 A-102(4800 円)
console.log([order, new Order("A-103", 1500)].join(" / ")); // 注文 A-102(4800 円) / 注文 A-103(1500 円)
呼ぶ側の ${} は、2 つの段で同じです。表示の形を変えるときは class の toString を 1 か所直せば、埋め込んだすべての行に反映されます。console.log(order) のようにインスタンスをそのまま渡したときは、toString は呼ばれません。
保存する JSON に合計金額を含める — toJSON
注文をサーバーに送るため、JSON.stringify(order) で文字列にしたとします。明細を #lines に持ち、合計を getter の total で計算する class では、どちらもプロパティのキーに並ばないため、送られるのは {"id":"A-102"} だけです。
toJSON(JSON.stringify が値を文字列にする前に呼び、その戻り値を代わりに文字列にするメソッド)を書くと、送りたい値を並べたオブジェクトを返せます。Date が JSON で日時の文字列になるのも、Date がこのメソッドを持つためです。
class Order {
#lines; // 明細(外からは読めない)
constructor(id, lines) { this.id = id; this.#lines = lines; }
get total() { return this.#lines.reduce((sum, line) => sum + line.price * line.quantity, 0); }
// JSON.stringify のときに呼ばれ、戻り値が文字列になる
toJSON() { return { id: this.id, itemCount: this.#lines.length, total: this.total }; }
}
const order = new Order("A-102", [{ price: 1200, quantity: 2 }, { price: 2400, quantity: 1 }]);
const other = new Order("A-103", [{ price: 1500, quantity: 1 }]);
// インスタンス自身のキーは id だけ
console.log(Object.keys(order).join(", ")); // id
// toJSON の戻り値が文字列になる
console.log(JSON.stringify(order)); // {"id":"A-102","itemCount":2,"total":4800}
// 配列に入れると、要素ごとに toJSON が呼ばれる
console.log(JSON.stringify([order, other]));
// [{"id":"A-102","itemCount":2,"total":4800},{"id":"A-103","itemCount":1,"total":1500}]
#lines は外から読めないままで、JSON に出す値だけを class の中で選んでいます。itemCount のように、インスタンスがプロパティとして持っていない値も、戻り値のオブジェクトに足せば JSON に出せます。JSON.parse で戻すと、この形の普通のオブジェクトになり、Order のインスタンスには戻りません。
toJSON が文字列を返すと二重に変換される
return JSON.stringify({ id: this.id, total: this.total }); のように文字列を返すと、その文字列がもう一度 JSON に変換されます。JSON.parse で 1 回戻しても文字列のままで、.total は undefined です。オブジェクトを返します。
明細を for...of で回す — Symbol.iterator
注文の明細を 1 行ずつ画面に並べたいとします。明細は #lines に隠してあるので外からは読めず、for (const line of order) と書くと TypeError: order is not iterable(反復可能ではない)で止まります。明細を公開の配列で持っていても、インスタンスそのものは回せません。
Symbol.iterator(for...of が呼ぶメソッドの名前に使う、組み込みの重複しないキーの値)の名前でメソッドを書くと、for...of は戻り値の next() で 1 件ずつ受け取ります。
class では function* 名前() を *名前() と書きます。名前を [Symbol.iterator] と角括弧で書くと、その値が名前です。
class Order {
#lines; // 明細(外からは読めない)
constructor(id, lines) { this.id = id; this.#lines = lines; }
// for...of が呼ぶメソッド。yield した明細が 1 件ずつ変数に入る
*[Symbol.iterator]() {
for (const line of this.#lines) yield line;
}
}
const order = new Order("A-102", [{ name: "ドリップコーヒー", quantity: 2 }, { name: "マグカップ", quantity: 1 }]);
// class の外から、明細を 1 件ずつ受け取る
for (const line of order) {
console.log(`${line.name} × ${line.quantity}`); // ドリップコーヒー × 2 / マグカップ × 1 の 2 行
}
// 展開も、書くたびにメソッドを呼ぶので 2 回目も 2 件
console.log([...order].length, [...order].length); // 2 2
読み切ったジェネレータが空になる点は、イテレータとジェネレータの記事のとおりです。呼ぶ側に渡るのは yield された明細だけで、#lines の配列そのものは渡らないので、外から明細を足したり消したりはできません。
配列をそのまま返すと for...of で止まる
return this.#lines; と配列を返すと、配列には next() が無いので for...of は TypeError: undefined is not a function で止まります。配列は反復可能ですが、イテレータではありません。* を付けて要素ごとに yield します。
書き忘れを結果の形から探す — 3 つのメソッド
表示や JSON が思った形と違っても、${order} や JSON.stringify(order) の行には、呼ばれるメソッドの名前が書かれていません。どのメソッドを書き忘れたかは、呼ぶ側の書き方と結果の形から逆にたどって決めます。
3 つとも書いていない class のインスタンスでも、呼ぶ側の書き方は、それぞれ決まった名前をプロトタイプチェーンで探します。見つかるのは Object.prototype にある既定の toString だけで、toJSON と [Symbol.iterator] は null まで探しても見つかりません。
// toString・toJSON・[Symbol.iterator] を 1 つも書いていない class
class DraftOrder {
#lines;
constructor(id, lines) { this.id = id; this.#lines = lines; }
get total() { return this.#lines.reduce((sum, line) => sum + line.price * line.quantity, 0); }
}
const draft = new DraftOrder("A-101", [{ name: "ドリップコーヒー", price: 1200, quantity: 2 }]);
// toString は Object.prototype で見つかり、toJSON は無いので自身のキーだけを並べる
console.log(`${draft}`); // [object Object]
console.log(JSON.stringify(draft)); // {"id":"A-101"}
// [Symbol.iterator] はどこにも無いので止まる
try {
console.log([...draft]);
} catch (error) {
console.log(error.message); // draft is not iterable
}
JSON は止まらずに {"id":"A-101"} を返すので、#lines の明細と total が抜けたまま送っても、受け取った側で初めて気づきます。下の表は、同じ名前を探すほかの書き方と、書いていないときの結果を並べたものです。
| 呼ぶ側の書き方 | 探すメソッド | 書いていないときの結果 |
|---|---|---|
| JSON.stringify([draft]) | toJSON(要素ごと) | [{"id":"A-101"}] |
| for (const line of draft) | [Symbol.iterator] | TypeError: draft is not iterable |
| const [firstLine] = draft | [Symbol.iterator] | TypeError: draft is not iterable |
理解度チェック
まずは1問ずつ答えてみましょう。
Q2toJSON で { id: this.id } を返す注文 2 件の配列を JSON.stringify すると?
Q3*[Symbol.iterator]() を持つ cart で [...cart] を 2 回書くと、2 回目の長さは?