🐬

オブジェクト指向: プリミティブ型をラップする恩恵(ValueObject: 値オブジェクト)

2024/04/17に公開

概要

オブジェクト指向においては int, string, float などのプリミティブ型をそのまま利用せず,クラスや構造体によって値を包む(ラップする)ことで,特に保守性および開発効率の面において大きな恩恵があります.
これは一般にValueObject(値オブジェクト)と呼ばれる実装パターンで,本稿ではその恩恵について簡潔に説明します.

C#を用いて実装例を記載しますが,C#独自の機能の利用は避けているため,他のオブジェクト指向言語をある程度読める方であれば概ねは読み解けるかと思われます.

記事中では雰囲気でC#のコードを書いているためおかしな部分がある可能性がありますが,あくまで例示ということでご容赦ください.

プリミティブ型をラップする前の実装例

価格(price)に対して税率(taxRate)を適用して,税込価格(priceIncludeTax)を計算する処理を例として考えます.

// 消費税を適用した価格を返す関数
int ApplyTax(int price, float taxRate) {
  return int(price * taxRate);
}

// ApplyTaxを利用するケース
void Main() {
  float taxRate = 0.08f;
  int price = 100;
  // 関数を利用して税込価格を計算
  int priceIncludeTax = ApplyTax(price, taxRate);
  // 結果を出力
  System.Console.WriteLine(priceIncludeTax); // -> 108
}

この実装には以下の問題があります.

関数の引数に不正な値を渡される可能性がある

ApplyTax()の実装を知らない関数の利用者が,以下のような不正な値を誤って関数に渡す可能性があります.

// 価格や税率にマイナスの値を入れてしまう
int result = ApplyTax(-1, 0.08f);
// 税率 8% を 0.08f ではなく 8 と入力してしまう
int result = ApplyTax(100, 8);

これは次のように関数に対して入力のバリデーションを追加することで解決できますが,一方で価格や税を取り扱うすべての関数でバリデーションの処理を忘れず実装する必要が出てきます.

// [追記]価格の入力が正しいかを検証する関数
void ValidatePrice(int price) {
  if (price < 0)
    // 不正な入力に対してエラーを出力する
    throw new System.ArgumentException(String.Format("price: {0} is invalid", price), nameof(price));
}

// [追記]税率の入力が正しいかを検証する関数
void ValidateTaxRate(float taxRate) {
  if (taxRate < 0 || taxRate >= 1.0f) // (消費税は100%を超えない想定)
    // 不正な入力に対してエラーを出力する
    throw new System.ArgumentException(String.Format("taxRate: {0} is invalid", taxRate), nameof(taxRate));
}

// 消費税を適用した価格を返す関数
int ApplyTax(int price, float taxRate) {
  ValidatePrice(price); // [追記]価格の検証処理
  ValidateTaxRate(taxRate);  // [追記]税率の検証処理
  return int(price * taxRate);
}

また,入力される値自体が正しくても,そもそも計算フローとして不正な値が含まれてしまう場合があります.

// 関数を利用して税込価格を取得する
int priceIncludeTax = ApplyTax(price, taxRate);

// ...
// いろいろな処理を挟んで
// ...

// 税込価格にさらに税適用処理を行なってしまう(priceIncludeTaxをApplyTaxに再度渡すことが不正)
int finalPrice = ApplyTax(priceIncludeTax, taxRate);

こちらはこの実装の場合は原理的に回避することが難しい問題となります.

関数が返した値に対して不正な操作をされる可能性がある

ApplyTax()が返す値はint型であるため,この値を取り扱う開発者が関数を実装した我々の想定しない扱い方をしてしまう可能性があります.

// 関数を利用して税込価格を取得する
int priceIncludeTax = ApplyTax(price, taxRate);

// ...
// いろいろな処理を挟んで
// ...

// 税込価格にさらに勝手に税適用処理を行なってしまう
int finalPrice = priceIncludeTax * 1.08f;

複雑な処理になればなるほどうっかりこうした誤った処理を行なってしまうリスクは増大していきます.

利用できる関数の存在の把握が難しくなる

現時点で価格priceに対して適用できる関数はApplyTax()ValidatePrice()の2つが存在します.今後の拡張でさらに増えていくことも予想されます.

プログラムを書く際にはこれらの関数が存在していることを把握する必要があり,場合によってはすでに存在している関数を誰かが別途新たに実装してしまうなど,保守が難しい状況が生まれていきます.

プリミティブ型をラップすることによる改善

ここでは以下のプリミティブ型を対象とします.

  • 価格(int) -> Priceクラス
  • 税率(float) -> TaxRateクラス
  • 税率適用後の価格(int) -> PriceIncludeTaxクラス

それぞれをクラスとして値をラップするように実装します[1]

また,クラスのインスタンスが生成されるタイミングで入力値のチェックもしてしまいます.

// 税抜価格のクラス
class Price {
  private int _price;

  public Price(int price) {
    ValidatePrice(price); // コンストラクタで入力値を検証する
    _price = price;
  }

  // 発展: 実際はプリミティブ型の値を外に出すgetterは存在しない方が良い
  public int ToInt() {
    return _price;
  }

  // 価格の入力が正しいかを検証する関数
  private void ValidatePrice(int price) {
    if (price < 0)
      // 不正な入力に対してエラーを出力する
      throw new System.ArgumentException(String.Format("price: {0} is invalid", price), nameof(price));
  }
}

// 税率のクラス
class TaxRate {
  private float _rate;

  public TaxRate(float rate) {
    ValidateTaxRate(rate); // コンストラクタで入力値を検証する
    _rate = rate;
  }

  public ToFloat() {
    return _rate;
  }

  // 税率の入力が正しいかを検証する関数
  private void ValidateTaxRate(float taxRate) {
    if (taxRate < 0 || taxRate >= 1.0f) // (消費税は100%を超えない想定)
      // 不正な入力に対してエラーを出力する
      throw new System.ArgumentException(String.Format("taxRate: {0} is 
  invalid", taxRate), nameof(taxRate));
  }
}

// 税込価格のクラス
class PriceIncludeTax {
  private int _priceIncludeTax;

  // PriceクラスとTaxRateクラスを受け取ってクラスのインスタンスを生成する
  // ApplyTax() で行なっていた処理
  public PriceIncludeTax(Price price, TaxRate taxRate) {
    // 価格と税率から税込価格を計算
    _priceIncludeTax = price.ToInt() * taxRate.ToFloat();
  }

  // 出力用関数
  public string ToString() {
    return _priceIncludeTax.ToString();
  }
}

これにより,税率適用処理の利用側はこのように変化します.

// 修正前
void Main() {
  float taxRate = 0.08f;
  int price = 100;
  // 関数を利用
  int priceIncludeTax = ApplyTax(price, taxRate);
  // 結果を出力
  System.Console.WriteLine(priceIncludeTax); // -> 108
}

// 修正後
void Main() {
  TaxRate taxRate = new TaxRate(0.08f);
  Price price = new Price(100);
  // 関数を利用
  PriceIncludeTax priceIncludeTax = new PriceIncludeTax(price, taxRate);
  // 結果を出力
  System.Console.WriteLine(priceIncludeTax.ToString()); // -> 108
}

記述量はやや増えますが,修正前にあった問題点がどのようになったか見ていきましょう.

関数の引数に不正な値を渡される可能性がある

修正前

// 価格や税率にマイナスの値を入れてしまう
int result = ApplyTax(-1, 0.08f);
// 税率 8% を 0.08f ではなく 8 と入力してしまう
int result = ApplyTax(100, 8);
// 関数を利用して税込価格を取得する
int priceIncludeTax = ApplyTax(price, taxRate);

// ...
// いろいろな処理を挟んで
// ...

// 税込価格にさらに税適用処理を行なってしまう(priceIncludeTaxをApplyTaxに再度渡すことが不正)
int finalPrice = ApplyTax(priceIncludeTax, taxRate);

修正後

PriceTaxRateを生成する際のコンストラクタに入力を検証する処理が含まれているため,そもそも不正な値を生成することさえできない状態になりました.

// Priceのコンストラクタの検証処理でこの時点でエラーとなるため,不正な値が存在すらできない.
Price price = new Price(-1); // -> Error!
// TaxRateのコンストラクタの検証処理でこの時点でエラーとなるため,不正な値が存在すらできない.
TaxRate taxRate = new TaxRate(8); // -> Error!
PriceIncludeTax priceIncludeTax = new PriceIncludeTax(price, taxRate);

また,税込価格に税を追加する処理を再度行ってしまうリスクについても,税抜価格と税込価格とで異なるクラスになっているため,実行前の段階でエラーとして回避することができます.

// 税込価格を取得する
PriceIncludeTax priceIncludeTax = new PriceIncludeTax(price, taxRate);

// priceIncludeTaxはPriceIncludeTax型であるため
// Price型のみを受け付ける税込価格のコンストラクタに再度渡すことができず
// 税込価格にさらに税適用処理を行なうことができない
PriceIncludeTax finalPrice = new PriceIncludeTax(priceIncludeTax, taxRate); // -> Compile Error!

関数が返した値に対して不正な操作をされる可能性がある

修正前

// 関数を利用して税込価格を取得する
int priceIncludeTax = ApplyTax(price, taxRate);

// ...
// いろいろな処理を挟んで
// ...

// 税込価格にさらに勝手に税適用処理を行なってしまう
int finalPrice = priceIncludeTax * 1.08f;

修正後

PriceIncludeTax型に対して出来る操作はPriceIncludeTaxクラスに書かれているメソッドのみに限られるため,使用者が想定外の取り扱いをしてしまう危険性を限りなく小さく抑えることができます.

// 税込価格を取得する
PriceIncludeTax priceIncludeTax = new PriceIncludeTax(price, taxRate);

// 税込価格にさらに勝手に税適用処理を行なおうとしても
// priceIncludeTax型に対して * を適用することはできない
int finalPrice = priceIncludeTax * 1.08f; // Compile Error!
掛け算をしたい場合は?

ところで,もし「税込価格に対して,個数を掛け算して合計金額を出したい」といった場合は,「個数を表すクラス(Count)」「合計金額を表すクラス(TotalAmount)」を用意して,掛け算を行うメソッドをPriceIncludeTaxクラスに追加するような対応が一例になります.

// 税込価格のクラス
class PriceIncludeTax {
  private int _priceIncludeTax;

  PriceIncludeTax(Price price, TaxRate taxRate) {
    // 価格と税率から税込価格を計算
    _priceIncludeTax = price.ToInt() * taxRate.ToFloat();
  }

  // [追記] 個数(Count)を受け取って合計金額(TotalAmount)を返す関数
  TotalAmount Multiply(Count count) {
    return new TotalAmount(_priceIncludeTax * count.ToInt())
  }
}

PriceIncludeTax(税込価格)とTotalAmount(合計金額)はどちらも「金額」ですが,取りうる操作が異なるため別のクラスとするのが適当でしょう(税込価格と異なり,合計金額に対して個数を掛ける操作は存在しません).

足し算などあらゆる操作についても同様です.
必要な操作のみをメソッドとして実装し,不正な操作が存在しないようにすることが保守性の向上につながります.

利用できる関数の存在の把握が難しくなる

税込価格に相当するPriceIncludeTaxクラスを用意し,税込価格に対して可能な操作をPriceIncludeTaxクラスのメソッドとして実装していくことで,税込価格に対して可能な操作はすべてPriceIncludeTaxクラスさえ見ればわかる状態になります.

たとえば税込価格のクラスを次のように拡張していった場合でも,クラスのメソッドを見るだけで税込価格に対して可能な操作がわかる状態になっています.

// 税込価格のクラス
class PriceIncludeTax {
  private int _priceIncludeTax;
  private TaxRate _taxRate;

  // 税込価格のクラスのコンストラクタ
  PriceIncludeTax(Price price, TaxRate taxRate) {
    // 価格と税率から税込価格を計算
    _priceIncludeTax = price.ToInt() * taxRate.ToFloat();
    _taxRate = taxRate;
  }

  // 個数(Count)を受け取って合計金額(TotalAmount)を返す
  TotalAmount Multiply(Count count) {
    return new TotalAmount(_priceIncludeTax * count.ToInt());
  }

  // 価格表示用の文字列(独自クラス: DisplayText)を返す
  DisplayText ToDisplayText() {
    // 12345678 -> "¥12,345,678" のように数値を文字列に変換する
    return new DisplayText(String.Format("¥{0:#,0}", _priceIncludeTax));
  }

  // 適用されている税率を取得する
  TaxRate GetTaxRate() {
    return _taxRate;
  }

  // 与えられた税込価格と比較して,より大きければ true を返す
  bool MoreExpensiveThan(PriceIncludeTax other) {
    return _priceIncludeTax > other._priceIncludeTax;
  }
}

Price,TaxRateクラスなどについても同様です.

まとめ

プリミティブ型はさまざまな処理を実現するためさまざまな操作を行うことができますが,保守という観点において「さまざまな操作を行うことができる」ことは「バグを生み出す不正な操作を可能にしている」と言い換えられます.
プリミティブ型を独自のクラスに置き換えることで値に対する操作を限定し,不正な操作・不正な値が入り込む余地を無くしていきましょう.

実際にやってるの?

筆者がGo言語で実装した10000行以上程度のゲームサーバのケースでは,およそすべての値に対して独自の型を与えて実装を進めました.
記述量こそ若干増えるものの,保守性を高める設計はむしろある程度大きなプロダクトの開発において強力です.
不正な値が存在せず,関数の不正な利用もできない世界ではバグが入り込む余地が非常に小さく,多くの場合で型が合うように関数に値を埋めてくだけでちゃんと動いてくれるようになり,開発体験が大きく向上しました.
型による制約によってそもそも書くべきコードも多くの場合に一意に定まり,AIやエディタ機能による入力補完の恩恵も強く受けられるようになります.

具体的には以下のようなものをそれぞれ独立した独自の型として設定しています.

  • ユーザID
  • ユーザの名前
  • パスワード
  • ハッシュ化したパスワード
  • アイテムID
  • アイテムの価格
  • アイテム購入の費用
  • プレイヤーの所持金
  • プレイヤーの経験値
  • ある行動によって得られる経験値量
  • プレイヤーのレベル
  • etc.

あとがき

今回はValueObject(値オブジェクト)の入り口として「プリミティブ型をラップするメリット」に限り触れたため,ValueObjectとして推奨される「不変性」「等値」あたりについては省略しています.
より詳細な解説はインターネットにたくさんあるため,気になった方はさらに調べてみてください.

脚注
  1. パフォーマンスの観点からC#ではstructでの実装が望ましいですが,C#に馴染みのない方でもわかりやすいようにclassとしています. ↩︎

Discussion