オブジェクト内で同じ意味を持つ変数が複数存在する場合(つまり、DRY な状態になっていない)、不整合が発生する可能性がある。
例えば、注文を表す Order
と、商品を表す Item
を考えてみる。Order
は複数の Item
オブジェクトをまとめる役割である。Item
にはそれぞれ商品の名前と価格を持っている。更に、Order
クラスには合計金額を持たせたい。この場合、以下のように書けると思う。(アクセス修飾子は適当気味)
また、sum()
, printOrder()
は本質的にはどうでもいい関数なので、これ以降は省略する。
// 合計を計算する(以後省略) function sum(numbers: number[]) { return numbers.reduce((prev, curr) => prev + curr, 0); } // order をいい感じに表示する(以後省略) function printOrder(order: Order) { console.log(`ORDER ID: ${order.id}`); console.log(`Items:`); for (const i of order.items) { console.log(`${i.name} ${i.price}円`); } console.log(""); console.log(`Total: ${order.totalPrice}円`); } class Item { constructor(readonly name: string, private _price: number) {} get price(): number { return this._price; } setPrice(price: number): void { this._price = price; } } class Order { constructor( readonly id: string, readonly items: Item[], private _totalPrice: number, ) {} get totalPrice(): number { return this._totalPrice; } setTotalPrice(totalPrice: number): void { this._totalPrice = totalPrice; } } const items = [ new Item("Apple", 120), new Item("Beef", 300), new Item("Milk", 200), ]; const totalPrice = sum(items.map((i) => i.price)); const order = new Order("ORDER_01", items, totalPrice); printOrder(order); /* [LOG]: "ORDER ID: ORDER_01" [LOG]: "Items:" [LOG]: "Apple 120円" [LOG]: "Beef 300円" [LOG]: "Milk 200円" [LOG]: "" [LOG]: "Total: 620円" */
実行してみると、ちゃんと動いてるように見える。
ただ、この書き方はあまり良くない。
以下のコードのように、商品 "Beef" の価格を 500 円に変更したとする。万が一 order.totalPrice
の更新を忘れてしまうと、誤った情報を表示することになってしまう。
これは、商品の「金額」という情報が order.totalPrice
と item.price
両方が持っているためで、いわゆる知識が重複している状態(DRY 違反)だといえる。
// Beef の価格を変更する order.items[1].setPrice(500); // order.totalPrice の更新を忘れて表示してしまう printOrder(order); /* [LOG]: "Total: 620円" [LOG]: "ORDER ID: ORDER_01" [LOG]: "Items:" [LOG]: "Apple 120円" [LOG]: "Beef 500円" [LOG]: "Milk 200円" [LOG]: "" [LOG]: "Total: 620円" 【間違い!!!!】 */
一度計算した値を保持するのではなく、以下のコードのように order.totalPrice
が必要になる度計算してあげるように変更すると、そのような問題は防げる。
再計算によるパフォーマンスの悪化よりも、保守性や不整合が起きた時のリスクを考えると DRY な状態にした方が合理的な場合が多いのでできればこうしたい。
class Item { constructor(readonly name: string, private _price: number) {} get price(): number { return this._price; } setPrice(price: number): void { this._price = price; } } class Order { constructor(readonly id: string, readonly items: Item[]) {} totalPrice(): number { return sum(this.items.map((i) => i.price)); // 都度計算する } } const items = [ new Item("Apple", 120), new Item("Beef", 300), new Item("Milk", 200), ]; const order = new Order("ORDER_01", items); printOrder(order); // Beef の価格を変更する order.items[1].setPrice(500); // order.totalPrice の更新は必要ない printOrder(order); /* [LOG]: "ORDER ID: ORDER_01" [LOG]: "Items:" [LOG]: "Apple 120円" [LOG]: "Beef 500円" [LOG]: "Milk 200円" [LOG]: "" [LOG]: "Total: 820円" 【合ってる!】 */
...まぁできるだけそうしたいんだけど、DRY に違反にせざるを得ない状況というのがあった。
整合性が崩れたデータを読めないようにする
以下の Rectangle
のように DRY でなくても、1クラスに完全に隠蔽することができれば、そのクラスを慎重に作ることで外からどう扱われようと不整合が起きない状態にできる。
class Rectangle { private height: number = 0; private width: number = 0; private area: number = 0; setSize(height: number, width: number): void { this.height = height; this.width = width; this.area = this._calculateArea() } getArea(): number { return this.area; } private _calculateArea(): number { return this.height * this.width; } }
ただ、前の例のように Order
, Item
と 2 つ以上のクラスが関係していると、完全にカプセル化することが難しく不整合が起きる可能性が高まってしまう。
そこで、Order.totalPrice
を読もうとしたときに、不整合が起きている可能性があればエラーを出してしまうのはどうか?と考えてみた。おかしなデータのまま処理を進めるよりは止めた方がいい。
アイデアとしては以下の図のように依存関係を持った Dirty flag でオブジェクトをラップする。値が変更されたときには、そのオブジェクトに依存しているオブジェクトを dirty にしてあげることで、不整合な値を読んでしまうことを防ぐという感じ。
【2022-04-25 追記】Observerパターンだと教えていただきました
このようなアイデアは Observer パターンとしてあるもの。 Observer パターン を用いて totalPrice
が Item
に subscribe し、Item
が変更されたときには totalPrice
側に通知をして isDirty = true
にする(もしくは最新の状態に更新してしまう)。
これで以下と全く同じ動きになる。もし似たようなことをしたければ使いたい言語の Observer パターンの実装があるか探してみるのが良さそう。
以降は消すのももったいないので残しておくが、非推奨。
【非推奨】 自作の
Dirtiable
クラス
class Dirtiable<T> { private _data: T; private _isDirty: boolean = false; private _dependencies: Set<Dirtiable<any>> = new Set(); private onDirtyRead = (): void => { throw new Error("Dirty read occurred!!"); }; constructor(data: T, onDirtyRead?: () => void) { if (onDirtyRead) { this.onDirtyRead = onDirtyRead; } this._data = data; } get(): T { if (this._isDirty) { this.onDirtyRead(); } return this._data; } set(data: T): void { this._data = data; this.clean(); this._dirtyDependencies(new Set<Dirtiable<any>>()); } dependsOn(dependenciesTo: Dirtiable<any>[]): void { for (const d of dependenciesTo) { d._addDependency(this); } } private _addDependency(dependency: Dirtiable<any>): void { this._dependencies.add(dependency); } isDirty(): boolean { return this._isDirty; } clean(): void { this._isDirty = false; } // 自身と、自身に依存している Dirtible を dirty にする。 // 依存関係にループがあったとしても、無限に再起呼び出しされないようにするため複雑になっている dirty(): void { this._dirtyAll(new Set<Dirtiable<any>>()); } private _dirtyAll( alreadyDirtied: Set<Dirtiable<any>>, ): void { this._isDirty = true; alreadyDirtied.add(this); this._dirtyDependencies(alreadyDirtied); } private _dirtyDependencies(alreadyDirtied: Set<Dirtiable<any>>): void { for (const d of this._dependencies) { if (alreadyDirtied.has(d)) { continue; } d._dirtyAll(alreadyDirtied); } } } class Item { private _price: Dirtiable<number>; constructor(readonly name: string, price: number) { this._price = new Dirtiable<number>(price); } get price(): Dirtiable<number> { return this._price; } setPrice(price: number): void { this.price.set(price); } } class Order { private _totalPrice: Dirtiable<number>; constructor( readonly id: string, readonly items: Item[], totalPrice: number, ) { this._totalPrice = new Dirtiable<number>(totalPrice); // items.price に依存させる this.totalPrice.dependsOn(items.map((item) => item.price)); } get totalPrice(): Dirtiable<number> { return this._totalPrice; } } const items = [ new Item("Apple", 120), new Item("Beef", 300), new Item("Milk", 200), ]; const totalPrice = sum(items.map((i) => i.price.get())); const order = new Order("ORDER_01", items, totalPrice); printOrder(order); // Beef の価格を変更する order.items[1].setPrice(500); // order.totalPrice の更新を忘れて表示してしまう printOrder(order); /* [ERR]: Dirty read occurred!! */
上のように、Dirtiable
で Item.price
, Order.totalPrice
をラップする。Order.totalPrice --> Item.price
という依存関係があるので Order
のコンストラクタで設定しておく。
この状態で不整合を起こして表示しようとしてみる。すると、Dirty read occurred!!
というエラーがでて失敗する。いい感じ。
これをちゃんと表示できるようにするためには、"Beef" の価格を変更した後に、order.totalPrice
を更新してあげる必要がある。
このように、更新漏れは例外の発生で気づけるようになる。
// Beef の価格を変更する order.items[1].setPrice(500); // 再設定してあげる const newTotalPrice = sum(items.map((i) => i.price.get())); order.totalPrice.set(newTotalPrice); printOrder(order); /* [LOG]: "Total: 620円" [LOG]: "ORDER ID: ORDER_01" [LOG]: "Items:" [LOG]: "Apple 120円" [LOG]: "Beef 500円" [LOG]: "Milk 200円" [LOG]: "" [LOG]: "Total: 820円" */
個人的には面白いアイデアなのかなーと思った。このような仕組みやライブラリって実際使われていたりするのかが気になる。
きっと誰かがライブラリとして実装していたり、もっと素晴らしい方法が無いかと思い調べてみたが、探し方が悪いのか全然見つからなかった。どう調べれば見つかるんだろうか...。