はじめに
本記事は、良いコード/悪いコードで学ぶ設計入門 を読んだ内容の中で、
特に重要と感じた部分を自分流に整理してoutputすることで理解を図るための記事です。
1.悪しき構造の弊害を知覚する
著者の仙塲さんは、強烈な炎上プロジェクトを体験したことをきっかけに、バグ発生を抑止する 「変更容易性」の高い設計の存在に出会いました。
平たくいうと、「変更容易性の低い設計思想で実装されたコードは、コードの読解に時間がかかりバグを誘発しやすいため、PJが炎上するリスクがある」ということです。
そして、変更容易性が担保されていない設計は、悪しき構造をもつため、実装の修正がのタイミングで牙を向きます。
架空のポイントサイトをもとに、その一例を示します。
複雑なネスト
// 残りポイントを判定
if (0 < pointCard.point) {
// 特別オファー対象かの判定
if (isSpecialOffer) {
// レギュラー会員かを判定
if (isRegularMember) {
...
// ポイントと商品を交換
}
}
}
このコードでは、ポイントと商品を交換するために、幾重にもネストした判定を通らなければいけません。コードの読解はもちろん、デバッグや単体テストも難儀するでしょう。
悪魔をまねきやすいデータクラス
データクラスは、データを保持するクラスです。
架空のECサービスのコードを用いて、何が危険なのかを確認していきます。
まずは、データをもつだけのデータクラスです。
数量や単価のデータを持ちます。
public class Order {
private int quantity;
private int unitPrice;
public Order(int quantity, int unitPrice) {
this.quantity = quantity;
this.unitPrice = unitPrice;
}
public int getQuantity() {
return quantity;
}
public int getUnitPrice() {
return unitPrice;
}
}
このECサイトでデータを計算して表示する表示、登録する必要があります。
public class OrderService {
public int calculateTotalPrice(Order order) {
return order.getQuantity() * order.getUnitPrice();
}
public boolean isValid(Order order) {
return order.getQuantity() > 0 && order.getUnitPrice() >= 0;
}
}
小規模なサービスであれば、特に問題は生じないかもしれません。
ただし、大規模なサイトであれば、どうなるでしょうか。
calculateTotalPriceは商品の総額を計算するメソッドですが、
「あらたに消費税や送料を追加したい」といった仕様が発生した場合は、別のクラスに類似する新しいメソッドを追加する必要が出てくるのではないでしょうか。(重複コードの問題)
また、そのようにしてクラスやメソッドを追加した場合、修正漏れやコードの可読性低下が起こることは容易に想像がつきます。
加えて、このクラス構成では、システムとして不正な状態のオブジェクトが作れてしまいます。
(不正値の混入)
Order order = new Order(-10, -500);
これを防ぐため、呼び出し元でバリデーションチェックなどを行う必要がありますが、
そのチェックもクラスごとに必要となり、サービスクラスの肥大化やコードの重複が起こりえます。
また、仮にこのOrderクラスで引数なしのデフォルトコンストラクタのみが定義されていた場合、
public Order() {
}
初期値を設定せずオブジェクトが生成可能なため、意図しないバグを産む原因となりえます。
Order order = new Order();
// まだ初期値を設定していないので必ず0円になる
System.out.print(order.getUnitPrice())
2.設計の初歩
- 変数の使い回し
- 再代入しない
複雑な計算ロジックを実装する際、下記のようなコードを書いてしまいがちですが、
このコードは実際にpriceが何を意味するのか、どんな値が返るのかが分かりづらく、
また、priceに対して他の計算処理を加えたい場合は再代入を追加することになり、バグの温床になります。
public int calulatePrice(int UnitPrice,boolean isMember) {
int price = unitPrice * quantity;
if(isMember) {
price = price -500;
}
return price;
}
下記のように変数を使いまわさず、目的ごとに変数を用意することで
可読性も上がり、バグの混入を防げます。
public int calulatePrice(int UnitPrice,boolean isMember) {
int subtotal = unitPrice * quantity;
int discount = isMember ? 500 * 0;
int totalPrice = subtotal - discount;
return totalPrice;
}
3.カプセル化の基礎 〜ひとつにまとめる〜
カプセル化とは、なんでしょうか。本書では
「データとそのデータを操作するロジックをひとつにまとめる」 と定義されています。
ではなぜ、カプセル化が必要なのか。
例えば身近なものでは、ドライヤーやイヤホンなどは、その製品単体で動作するように設計されています。
別売りの電池が必要などの例外を除き、他の部品がないと動作しないような不親切なつくりではありません。
カプセル化を意識したクラス設計も同じ考えで、そのクラス単体で動作する設計にする事が重要です。
逆に、そのクラス単体で動作する設計になっていないデータクラスのことを
「貧血ドメインモデル」 と呼びます。
今回は下記の貧血ドメインモデルを、カプセル化された成熟したモデルへ改善していきます。
製品を表すProductクラスです。
public class Product {
public String name;
public int price;
public int stock;
}
現状はコンストラクタを定義していないため、インスタンス生成時にデフォルトコンストラクタが動作します。
その際インスタンス変数に個別に値を代入して初期化する必要があり、未初期化状態を誘発する(生焼けオブジェクトと呼ぶ)状態です。
これを、クラスのインスタンス生成時に正常値が設定されるように修正します。
// コンストラクタで確実に正常値を設定する
public class Product {
Price price;
int stock;
Product(Price price,int stock) {
this.price = price;
this.stock = stock;
}
}
一見問題ないように見えますが、インスタンス生成時に不正値を渡せてしまうため、不十分です。
Product product = new Product(null,-100);
これではまだ正常値を設定できているとはいえません。
ではどうするかというと、バリデーションをコンストラクタ内に定義します。
class Product {
Price price;
int stock;
Product(Price price,int stock) {
if(price == null) {
throw new IllegalArgumentException( "価格は必須です");
}
if(price == null) {
throw new IllegalArgumentException( "在庫は0以上を設定してください");
}
this.price = price;
this.stock = stock;
}
}
不正な値で初期化しようとした時は例外がthrowされるため、正常値のみが設定されたインスタンスを生成することができるようになりました。
ちなみに、処理の対象外となる条件を先頭に定義する方法をガード節といいます。
いわゆる早期returnとよばれるものです。
さて今のままでは、Productに関する計算ロジックなどの処理はまだ別のクラスに実装されている想定です。
データとそのデータを操作するロジックが分かれている場合、実装の重複やバグの誘発などさまざまなリスクがあります。
別のクラスに実装されていたロジックをProduct自身に持たせるようにします。
class Product {
// 在庫を追加する
void add (int quantity) {
stock += quantity;
}
}
これでかなり改善されたようにみえます。
しかし、今のままでは下記のようにインスタンス変数の上書きができてしまいます。
product.stock = userStock;
// 特別期間中は在庫を増量
if(isSpecialSeason) {
product.add(additionalStock);
}
// 中略
// シーズンオフは在庫を減らす
if(isOffSeason) {
product.add(reduceStock);
}
変数の使い回しによる類似のコードはよく見るものですが、コードを追いづらくバグの温床になりえます。
これを防止するために、final修飾子を使用してインスタンス変数を不変(イミュータブル)にします。
final Price price;
final int quantity;
final修飾子を付与したため、再代入ができなくなりました。
しかし、先ほどのコードのように、変更が必要な場合はどうすべきでしょうか?
それは、変更が必要なタイミングで、新たにインスタンスを生成する事で解決します。
先ほどのadd()を修正してみましょう。
// 在庫を追加する
void add (int quantity) {
int added = stock + quantity;
return new Product(price,added)
}
修正値をもったProductのインスタンスを生成し、返すロジックにする事で、
インスタンス変数の不変を保ちつつ、変更可能となりました。
最後に、メソッドの引数も不変にしましょう。
class Product {
final Price price;
final int stock;
Product(final Price price,final int stock) {
if(price == null) {
throw new IllegalArgumentException( "価格は必須です");
}
if(price == null) {
throw new IllegalArgumentException( "在庫は0以上を設定してください");
}
this.price = price;
this.stock = stock;
}
}
かなり完成形に近づいてきました。
しかし実際に使用してみると、下記のようにエラーにはならないものの、システムの振る舞いとして異常値を設定できてしまいます。
// 割引額を定義
final int discoutPrice = 1000;
// 在庫を追加すべきところ、金額を追加してしまっている!
product.add(discoutPrice);
こんな間違いはしないよと思うかもしれませんが、大概のPJは実装者が常に入れ替わるため、様々な要因から普通に起こりえます。
この原因は在庫を表すstockのインスタンス変数のデータ型に、プリミティブを使用していることですね。
なので、Stockクラスを定義して、Stock型同士で計算を行うメソッドにします。
class Stock {
Stock add(final Stock other) {
final int added = stock + other.stock;
return new Money(price,added)
}
}
このサイトでは、在庫にもいくつかの種類があると仮定して
在庫の種類のチェックの追加してみましょう
class Stock {
Stock add(final Stock other) {
if (!stockType.equals(other.stocktype)) {
throw new IllegalArgumentException("在庫種類が異なります")
}
final int added = stock + other.stock;
return new Money(price,added)
}
}
最後に、完成形の貧血ドメインモデルからカプセル化を行なったクラスへと改善したクラスを示します。
class Product {
final Price price;
final int stock;
Product(final Price price,final int stock) {
if(price == null) {
throw new IllegalArgumentException( "価格は必須です");
}
if(price == null) {
throw new IllegalArgumentException( "在庫は0以上を設定してください");
}
this.price = price;
this.stock = stock;
}
}
このProductクラスは、完全コンストラクタと値オブジェクトという設計パターンを適用したものです。
完全コンストラクタ
生成された段階で正常値だけをもつ完全なインスタンスを生成する。
値オブジェクト(Value Object)
アプリケーションで使う値をクラスで表現する設計パターン。
完全コンストラクタ+ 値オブジェクトは、カプセル化の基本形を体現する構造の一つである。
4.不変の活用 --安定動作を構築する--
- 状態変更(変数の値変更)が可能な事を 可変(ミュータブル)、不可である事を**不変(イミュータブル)**と呼ぶ
- 不変を意識する事で、状態変更の予測を行いやすくする
- 不変は、近年のプログラミングの標準スタイルとも言われている
5.バラバラなデータとロジックをカプセル化する実践技法
- プリミティブだけで実装しようとすることを「プリミティブ執着」と呼ぶ。前章で解説したように、クラスを作成してカプセル化することでプリミティブ執着を解決することができる。結果として、重複したコードの量産などを防ぐ事ができる
- staticメソッドを安易に使用する事で、データとロジックがバラバラになってしまう。誤用しない
- Utilを安易に作らない
- staticメソッドをなるべく使わないという事は、すなわちオブジェクト指向設計においては、共通処理クラスを安易に作ることは御法度であるといえます。では、共通処理として実装するものはどんなものがあるでしょうか?代表的なものとして、以下のような横断的関心事 が挙げられます
- ログ出力
- エラー検出
- デバッグ
- 例外処理
- キャッシュ
- 同期処理
- 分散処理
- staticメソッドをなるべく使わないという事は、すなわちオブジェクト指向設計においては、共通処理クラスを安易に作ることは御法度であるといえます。では、共通処理として実装するものはどんなものがあるでしょうか?代表的なものとして、以下のような横断的関心事 が挙げられます
- 引数が多すぎるメソッドは、データとロジックがバラバラになりやすい
- 意味のある単位ごとにクラス化することが大事
6.関心の分離という考え方 --分けて整理する--
- 関係の弱いデータやロジックを分離する
- インタフェースと実装の分離
7.関心が混ざったコードを分けて整理する実践
- クラスが行う責任は、たったひとつに限定すべき。「単一責任の原則」
- 同じようなロジック、類似のロジックでも**目的が違うロジックは共通化してはいけない。目的が同じかどうかで、共通化を行うべきかを判断する 「DRY原則の誤用」
- クラスは原則として package privateであるべき
- privateメソッドが多いクラスは、別々のクラスに分離する(複数の関心事を一つのクラスで扱わない)
お読みいただきありがとうございます。
後編は、気が向いたら書こうと思います。