はじめに
「現場で役立つシステム設計の原則」で紹介されている区分オブジェクトをC#で実装できるか?考えてみた。
DDDの実装パターンでValueObject/Entity/ファーストコレクションはよく耳にする。
しかし、区分オブジェクトという概念はあまり浸透していないのではないだろうか。
著書ではJavaでEnumに振る舞いを持たせる実装が紹介されている。
が、C#のEnumは振る舞いを持てない。
Enumを使わず実装できないか考えてみる。
そもそも区分オブジェクトとは
著書では以下のように説明されている。
列挙型を使って、区分ごとのロジックをわかりやすく整理するこの方法を区分オブジェクトと呼びます。
区分定数を単なる定数ではなく、振る舞いを持ったオブジェクトとして表現します。「振る舞いを持つ」というのは、メソッドを指定して判断/加工/計算を依頼できるという意味です。
Enumで持つ各列挙子に、それぞれ異なる値とロジックを持つイメージだ。
区分の一覧がわかりやすくなるため、本家では列挙体(Enum)を使うことが推奨されている。
区分オブジェクトのメリット
- 異なる判断、加工、計算のロジックをすっきりと整理できる
- 区分ごとのロジックをどこに書くべきかわかりやすくなる
- ルールや計算方法を変更しても影響範囲をそのクラスに閉じ込めることができる
- 区分の追加や削除も使う側に影響がない
どのようにC#で実装するか
前述のとおりC#のEnumは振る舞いを記述できない。
だが、「列挙型を使って」は手段であり、目的である**「区分ごとのロジックをわかりやすく整理...」**より以下が実現できていれば、それは区分オブジェクトと呼んで差し支えないだろう。
なので、Enumは使わず、Enumのように扱えるクラスを作成してみよう。
区分オブジェクトの実装
書籍同様、「料金種別」の区分オブジェクトを作成する。
大人なら100円、子供なら50円、シニアなら80円といった具合だ。
ただし、クラスが持つ値やメソッドは書籍通りでなくオリジナルの箇所も含む。
Enumのように扱えるクラス(列挙クラス)の実装例
まずはEnumを使わずEnumのように扱えるクラスを実装してみる。
なお著書では、YenはValueObjectで実装しているが、簡単にしたいのでintにする。
sealed class FeeType
{
// 各列挙子をstaticで変数宣言する
public static readonly FeeType Adult = new FeeType(100);
public static readonly FeeType Child = new FeeType(50);
public static readonly FeeType Senior = new FeeType(80);
private int _yen;
// 外部でインスタンスできないようコンストラクタを隠蔽する
private FeeType(int yen)
{
_yen = yen;
}
// メンバ変数Yenの値をFeeTypeクラスのインスタンスメソッドとして公開する
public string GetYenDisplay() => $"{_yen}円";
}
使う側はこんな感じ
public static void Main(string[] args)
{
var adult = FeeType.Adult;
if (FeeType.Adult == adult) Console.WriteLine("adult");
Console.WriteLine(FeeType.Adult.GetYenDisplay());
}
Enumのように使えて、かつ各区分にGetYenDisplayという振る舞いを持つことができた。
このなんちゃって列挙体(以下、列挙クラスと呼ぶ)は、「C#でタイプセーフEnumを実装する」の記事にて解説しているので参考までに。
値型であるEnum同様に使うのであれば、参照型であるクラスでなく値型である構造体で記述した方が良いが、クラスの方が馴染みある方が多いと思うので今回はクラスで実装する。
もし、構造体で宣言するなら、同一判定でなく同値判定になるため、各区分に一意の要素を持つ必要がある。そのへんは注意が必要だ。
通常の列挙クラスの問題点
では、本題にもどる。
この実装は私もよく実務で取り入れている好きなパターンである。
これで値や振る舞いが持てるようにはなった。
だが、区分ごとに異なる振る舞いを持たすには、switchで分岐しなければならない。
また、値が増えるにつれてコンストラクタで渡す引数の数もどんどん増えていってしまう。
sealed class FeeType
{
// 値が増えるにつれて引数がどんどん増えてしまう
public static readonly FeeType Adult = new FeeType("大人", 100, "adult1", "adult2", "adult3");
public static readonly FeeType Child = new FeeType("子供", 50, "child1", "child2", "child3");
public static readonly FeeType Senior = new FeeType("シニア", 80, "senior1", "senior2", "senior3");
private string _label;
private int _yen;
private string _hoge1;
private string _hoge2;
private string _hoge3;
private FeeType(string label, int yen, string hoge1, string hoge2, string hoge3)
{
_label = label;
_yen = yen;
_hoge1 = hoge1;
_hoge2 = hoge2;
_hoge3 = hoge3;
}
public string GetYenDisplay() => $"{_yen}円";
// 区分ごとに振る舞いを持たせるには分岐処理を書くしかない
public void Process()
{
switch (_label)
{
case "大人":
// Adult固有の処理
return;
case "子供":
// Child固有の処理
return;
case "シニア":
// Senior固有の処理
return;
default:
throw new NotImplementedException();
}
}
}
これでは、区分オブジェクトの定義である、「区分ごとのロジックをわかりやすく整理する」が実現できていない。
列挙クラスにて区分オブジェクトを実装する
では、書籍で紹介されている区分オブジェクトの実装を、Enumを使わず列挙クラスを使って実装してみる。
まず、共通のインターフェースを作成する。
そのインターフェースを実装したクラスを、列挙したい区分数だけ作成する。
interface IFee
{
string Label { get; }
int Yen { get; }
void Process();
}
sealed class AdultFee : IFee
{
public string Label => "大人";
public int Yen => 100;
public void Process()
{
// Adult固有の処理
}
}
sealed class ChildFee : IFee
{
public string Label => "子供";
public int Yen => 50;
public void Process()
{
// Child固有の処理
}
}
sealed class SeniorFee : IFee
{
public string Label => "シニア";
public int Yen => 80;
public void Process()
{
// Senior固有の処理
}
}
次に、区分オブジェクトを作成する。
上で作成した区分ごとのクラスを区分オブジェクトクラスのクラス変数として列挙する。
sealed class FeeType
{
// 各列挙子をstaticで変数宣言する
public static readonly FeeType Adult = new FeeType(new AdultFee());
public static readonly FeeType Child = new FeeType(new ChildFee());
public static readonly FeeType Senior = new FeeType(new SeniorFee());
private IFee _fee;
// 外部でインスタンスできないようコンストラクタを隠蔽する
private FeeType(IFee fee)
{
_fee = fee;
}
// IFeeの値を加工しインスタンスメソッドとして公開する
public string GetYenDisplay() => $"{_fee.Yen}円";
// IFeeの処理をインスタンスメソッドとして公開する
public void Process() => _fee.Process();
}
使う側はこんな感じ
public static void Main(string[] args)
{
var adult = FeeType.Adult;
if (FeeType.Adult == adult) Console.WriteLine("adult");
Console.WriteLine(FeeType.Adult.GetYenDisplay());
}
コンストラクタで渡す引数は、一つのクラスインスタンスのみになりスッキリした。
また、各区分クラスに固有の処理を書くことができるようになったため、Processメソッドの分岐処理が必要なくなった。
これが区分オブジェクトの効能である。
そして、お気づきだろうか。
先に紹介した列挙クラス、後に紹介した区分オブジェクト、双方の使う側のソースコードを比較すると全く同じである。
そう、この実装の面白いところは使う側はIFeeを全く意識しないため、列挙クラスも区分オブジェクトも使い方は変わらないのだ。
使う側は、あくまでFeeTypeに対して処理を要求しており、IFeeの実装が完全に隠蔽されている。
使われる側は、IFeeを実装した各区分のクラスに値やロジックを記述し整理できている。
だが、実装してみて一つ疑問が生まれた。
わざわざIFeeを内包する必要あるのか?ということである。
どういうことか以下の実装と比較してみたい。
sealed class FeeType
{
public static readonly IFee Adult = new AdultFee();
public static readonly IFee Child = new ChildFee();
public static readonly IFee Senior = new SeniorFee();
}
使う側はこんな感じ
public static void Main(string[] args)
{
var adult = FeeType.Adult;
if (FeeType.Adult == adult) Console.WriteLine("adult");
Console.WriteLine(FeeType.Adult.GetYenDisplay());
}
GetYenDisplayメソッドをIFeeインターフェースに定義し、各FeeクラスでGetYenDisplayを実装する箇所は省略している。
これまた使う側は、列挙クラス、区分オブジェクトと全く同じである。
これでいいのでは問題。
本家のJavaでは、Enumで定義されていることにより一覧性が担保されている。
だが、C#で実装した区分オブジェクトはあくまでクラス。一覧性が担保されているとは言い難い。
そのため、このようなただクラスにstaticのクラス変数を列挙しただけのクラス(以下モジュールクラスと呼ぶ)でも比較対象になってしまうようだ。
モジュールクラスが区分オブジェクトと異なる点を以下に挙げてみる。
- モジュールクラス内で共通のインスタンスメソッドを持つことができない。例では、GetYenDisplayを各Feeクラスで実装している。
- 既存のAdultFeeやChildFeeクラスのインスタンスが別の場所でインスタンス化されるかもしれない。
つまり、インスタンスがこれだけという保証がないのである。 - IFeeを実装したクラスを外部で作成できてしまう。
1.は、例のように簡単な処理であればいいが、何行にもなるようなロジックを各クラスに重複して記述するのは良くない。
2.は、インスタンスが増えることで困るのは等値演算子。なのでEqualsをOverrideするなり、クラスを構造体に変えるなりして同値判定にすればいい。で一応解決はできそうだ。
では、3.の問題はどうか。
例えば、BabyFeeというクラスを後から追加した場合どうなるか。
区分オブジェクトもモジュールクラスも、当然どちらもクラス変数を追加しないことには列挙子が増えず、当然だがFeeType.Babyとは書けない。
しかし、モジュールクラスで実装していた場合、以下のようなことができてしまう。
まずは、BabyFeeの作成。
sealed class BabyFee : IFee
{
public string Label => "赤ちゃん";
public int Yen => 80;
public string GetYenDisplay() => $"{Yen}円";
public void Process()
{
// Baby固有の処理
}
}
使う側はこんな感じ
public static void Main(string[] args)
{
FeeProcess(FeeType.Adult));
FeeProcess(new BabyFee()); // BabyFeeも使えてしまう
}
private static void FeeProcess(IFee fee)
{
Console.WriteLine(fee.GetYenDisplay());
}
FeeProcessメソッドはIFeeを実装している実態への処理しか記述するべきではない。
よってFeeProcessメソッドはBabyFeeのインスタンスを渡しても問題なく処理を行うだろう。
だが、Enumのように、列挙子を全て自身で持つ実装を望んでいるのに、別の場所で列挙子が増やされるかもしれないというのはいかがなものか。
「Enumの列挙子の一部が別の場所で定義されている」なんて発狂ものですね。
では、区分オブジェクトはその心配がないのか。
区分オブジェクトの実装でも、BabyFeeを作って別の場所で使うことは可能である。
だが、前述した通り、区分オブジェクトであるFeeTypeはIFeeを隠蔽しており、使う側でIFeeを意識することはない。
扱うのはあくまでFeeTypeである。
```C#:Program.cs
public static void Main(string[] args)
{
// 引数の型が違うためコンパイルエラーとなる
Process(new BabyFee());
}
private static void Process(FeeType fee)
{
Console.WriteLine(fee.GetYenDisplay());
}
BabyFeeをインスタンス化してもFeeTypeとして扱えない。
FeeTypeは自身で定義したクラス変数しか受け付けないのである。
よって、このクラス内だけで自身の列挙子が完結しており、外部で列挙子が増やされる心配はない。
ただ、IFeeを実装したクラスを別の場所で作成できてしまうことは事実。
それが嫌な場合は以下のように実装すればいい。
// partialを宣言する
sealed partial class FeeType
{
// 各列挙子をstaticで変数宣言する
public static readonly FeeType Adult = new FeeType(new AdultFee());
public static readonly FeeType Child = new FeeType(new ChildFee());
public static readonly FeeType Senior = new FeeType(new SeniorFee());
// 以下省略
}
// partialを使ってFeeTypeクラスの中に区分クラスを内包する
sealed partial class FeeType
{
interface IFee
{
// 省略
}
sealed class AdultFee : IFee
{
// 省略
}
}
このようにしておけば、IFeeを実装したクラスを作成できるのは、FeeTypeの中だけと限定することができる。
まとめ
今回は、C#にて区分オブジェクトをEnumを使わずクラスで作成する方法を紹介した。
よって、区分オブジェクトの有用性まではあまり言及していない。
だが、ほとんどの方がEnumの各列挙子に、文字列を持たせたい、振る舞いを持たせたいと考えたことはあるのではないだろうか。
それを解決する方法として、属性を使ったり、拡張メソッドを使う方法等ある。
そのなかでも私は現状この区分オブジェクトが、オブジェクト指向プログラミングを行う上で最適な選択だと考えている。