型による設計:無効な状態をC#で表現できないようにする方法

通常、型設計に関する記事には、関数型言語(Haskell、F#など)の例が含まれています。 この概念はオブジェクト指向言語には当てはまらないように見えるかもしれませんが、そうではありません。







この記事では、 Scott Vlaschin Type Designの記事からの例を翻訳します。無効な状態慣用的な C#で表現できないようにする方法 。 また、このアプローチが実験としてだけでなく、作業コードにも適用可能であることを示します。







ドメインタイプを作成する



最初に、シリーズの前の記事の型を移植する必要があります。これは、F#の例で使用されています。







ドメインでプリミティブ型をラップする



F#の例では、電子メールアドレス、米国の郵便番号、および州コードにプリミティブの代わりにドメインタイプを使用しています。 C#でプリミティブ型をラップしてみましょう。







public sealed class EmailAddress { public string Value { get; } public EmailAddress(string value) { if (value == null) { throw new ArgumentNullException(nameof(value)); } if (!Regex.IsMatch(value, @"^\S+@\S+\.\S+$")) { throw new ArgumentException("Email address must contain an @ sign"); } Value = value; } public override string ToString() => Value; public override bool Equals(object obj) => obj is EmailAddress otherEmailAddress && Value.Equals(otherEmailAddress.Value); public override int GetHashCode() => Value.GetHashCode(); public static implicit operator string(EmailAddress address) => address?.Value; }
      
      





 var a = new EmailAddress("a@example.com"); var b = new EmailAddress("b@example.com"); var receiverList = String.Join(";", a, b);
      
      





このような実装はC#でより一般的であるため、アドレス検証をファクトリー関数からコンストラクターに移動しました。 また、文字列への比較と変換を実装する必要がありました。これは、F#でコンパイラによって実行されます。







一方で、実装は非常に膨大に見えます。 一方、ここでは電子メールアドレスの特異性は、コンストラクターでのチェック、および場合によっては比較ロジックによってのみ表現されます。 これのほとんどはインフラストラクチャコードであり、さらに変更されることはほとんどありません。 そのため、 テンプレートを作成するか、最悪の場合、クラスからクラスに一般的なコードをコピーすることができます。







プリミティブ値からのドメインタイプの作成は、関数型プログラミングの特異性ではないことに注意してください。 それどころか、プリミティブ型の使用は、OOP不良コードの兆候と見なさます。 このようなラッパーの例は、たとえばNLogNBitcoinで見ることができます。実際、TimeSpanの標準タイプはティック数のラッパーです。







値オブジェクトの作成



次に、 エントリの類似物を作成する必要があります。







 public sealed class EmailContactInfo { public EmailAddress EmailAddress { get; } public bool IsEmailVerified { get; } public EmailContactInfo(EmailAddress emailAddress, bool isEmailVerified) { if (emailAddress == null) { throw new ArgumentNullException(nameof(emailAddress)); } EmailAddress = emailAddress; IsEmailVerified = isEmailVerified; } public override string ToString() => $"{EmailAddress}, {(IsEmailVerified ? "verified" : "not verified")}"; }
      
      





F#よりも多くのコードが再び必要でしたが、ほとんどの作業はIDEでのリファクタリングによって実行できます。







EmailAddress



と同様に、 EmailContactInfo



は( .NETの値型ではなく、 DDDの意味での) オブジェクトであり、オブジェクトモデリングで長い間知られ、使用されてきました。







他のタイプStateCode



ZipCode



PostalAddress



およびPersonalName



、同様の方法でC#に移植されます。







連絡先を作成



そのため、コードは「連絡先には電子メールアドレスまたは郵便アドレス(または両方のアドレス)を含める必要があります」というルールを表現する必要があります。 状態の正確さが型定義から見え、コンパイラーによってチェックされるように、このルールを表現する必要があります。







さまざまな接触状態を表現する



つまり、連絡先とは、個人の名前とメールアドレス、住所、またはその両方を含むオブジェクトです。 明らかに、1つのクラスに3つの異なるプロパティセットを含めることはできないため、3つの異なるクラスを定義する必要があります。 3つのクラスすべてに連絡先の名前が含まれている必要があります。同時に、連絡先に含まれるアドレスがわからない場合でも、異なる種類の連絡先を同じ方法で処理できる必要があります。 したがって、連絡先は、連絡先の名前を含む抽象基本クラスと、異なるフィールドセットを持つ3つの実装によって表されます。







 public abstract class Contact { public PersonalName Name { get; } protected Contact(PersonalName name) { if (name == null) { throw new ArgumentNullException(nameof(name)); } Name = name; } } public sealed class PostOnlyContact : Contact { private readonly PostalContactInfo post_; public PostOnlyContact(PersonalName name, PostalContactInfo post) : base(name) { if (post == null) { throw new ArgumentNullException(nameof(post)); } post_ = post; } } public sealed class EmailOnlyContact : Contact { private readonly EmailContactInfo email_; public EmailOnlyContact(PersonalName name, EmailContactInfo email) : base(name) { if (email == null) { throw new ArgumentNullException(nameof(email)); } email_ = email; } } public sealed class EmailAndPostContact : Contact { private readonly EmailContactInfo email_; private readonly PostalContactInfo post_; public EmailAndPostContact(PersonalName name, EmailContactInfo email, PostalContactInfo post) : base(name) { if (email == null) { throw new ArgumentNullException(nameof(email)); } if (post == null) { throw new ArgumentNullException(nameof(post)); } email_ = email; post_ = post; } }
      
      





継承ではなく構成を使用する必要があり、一般的にはデータではなく動作を継承する必要があると主張するかもしれません。 発言は公平ですが、私の意見では、クラス階層の使用はここで正当化されます。 まず、サブクラスは基本クラスの特殊なケースを表すだけでなく、階層全体が1つの概念-連絡先です。 3つの連絡先の実装は、ビジネスルールで指定された3つのケースを非常に正確に反映しています。 第二に、基本クラスとその相続人の関係、それらの間の責任の分割は簡単に追跡できます。 第三に、階層が本当に問題になる場合は、元の例で行ったように、連絡先の状態を別の階層に分離できます。 F#では、レコードの継承は不可能ですが、新しい型は非常に単純に宣言されるため、分割はすぐに実行されました。 C#では、より自然な解決策は、名前フィールドを基本クラスに配置することです。







連絡先を作成



連絡先の作成は非常に簡単です。







 public abstract class Contact { public static Contact FromEmail(PersonalName name, string emailStr) { var email = new EmailAddress(emailStr); var emailContactInfo = new EmailContactInfo(email, false); return new EmailOnlyContact(name, emailContactInfo); } }
      
      





 var name = new PersonalName("A", null, "Smith"); var contact = Contact.FromEmail(name, "abc@example.com");
      
      





電子メールアドレスが正しくない場合、このコードは例外をスローします。これは、元の例のNone



リターンの類似物と見なすことができます。







連絡先の更新



連絡先の更新も簡単です。 Contact



タイプに抽象メソッドを追加するだけです。







 public abstract class Contact { public abstract Contact UpdatePostalAddress(PostalContactInfo newPostalAddress); } public sealed class EmailOnlyContact : Contact { public override Contact UpdatePostalAddress(PostalContactInfo newPostalAddress) => new EmailAndPostContact(Name, email_, newPostalAddress); } public sealed class PostOnlyContact : Contact { public override Contact UpdatePostalAddress(PostalContactInfo newPostalAddress) => new PostOnlyContact(Name, newPostalAddress); } public sealed class EmailAndPostContact : Contact { public override Contact UpdatePostalAddress(PostalContactInfo newPostalAddress) => new EmailAndPostContact(Name, email_, newPostalAddress); }
      
      





 var state = new StateCode("CA"); var zip = new ZipCode("97210"); var newPostalAddress = new PostalAddress("123 Main", "", "Beverly Hills", state, zip); var newPostalContactInfo = new PostalContactInfo(newPostalAddress, false); var newContact = contact.UpdatePostalAddress(newPostalContactInfo);
      
      





F#のoption.Valueと同様に、メールアドレス、郵便番号、または状態が正しくない場合、コンストラクターから例外をスローすることは可能ですが、これはC#の一般的な方法です。 もちろん、ここの作業コードまたは呼び出しコードのどこかに例外コードを提供する必要があります。







階層外の連絡先の処理



Contact



階層自体にContact



を更新するためのロジックを配置することは論理的です。 しかし、彼女の責任範囲に収まらない何かを達成したい場合はどうでしょうか? ユーザーインターフェイスに連絡先を表示するとします。







もちろん、抽象メソッドを基本クラスに再度追加し、何らかの方法で連絡先を処理する必要があるたびに新しいメソッドを追加し続けることができます。 ただし、その場合、 単独の責任原則に違反し、 Contact



階層が乱雑になり、 Contact



実装とContact



処理を実際に担当する場所との間で処理ロジックがあいまいになります。 F#にはそのような問題はありませんでした。C#コードが悪化しないようにしたいと思います。







C#でのパターンマッチングに最も近いものは、スイッチコンストラクトです。 列挙型のプロパティをContact



追加して、実際の連絡先のタイプを判別し、変換を実行できます。 C#の新しい機能を使用して、 Contact



インスタンスとして切り替えを実行することもできます。 しかし、結局のところ、新しいケースの十分な処理がなく、スイッチがすべての可能なケースの処理を保証するわけではない、新しい正しいContact



状態が追加されたときにコンパイラーにプロンプ​​トを表示することを望みました。







ただし、OOPには、タイプに応じてロジックを選択するためのより便利なメカニズムもあり、連絡先を更新するときに使用しました。 また、選択は呼び出しタイプに依存するようになったため、ポリモーフィックでなければなりません。 ソリューションは訪問者テンプレートです。 Contact



実装に応じてハンドラーを選択し、階層から連絡先処理メソッドのバインドを解除できます。また、新しいタイプの連絡先が追加された場合は、Visitorインターフェイスに新しいメソッドが追加されると、インターフェイスのすべての実装でそれを記述する必要があります。 すべての要件が満たされています!







 public abstract class Contact { public abstract void AcceptVisitor(IContactVisitor visitor); } public interface IContactVisitor { void Visit(PersonalName name, EmailContactInfo email); void Visit(PersonalName name, PostalContactInfo post); void Visit(PersonalName name, EmailContactInfo email, PostalContactInfo post); } public sealed class EmailOnlyContact : Contact { public override void AcceptVisitor(IContactVisitor visitor) { if (visitor == null) { throw new ArgumentNullException(nameof(visitor)); } visitor.Visit(Name, email_); } } public sealed class PostOnlyContact : Contact { public override void AcceptVisitor(IContactVisitor visitor) { if (visitor == null) { throw new ArgumentNullException(nameof(visitor)); } visitor.Visit(Name, post_); } } public sealed class EmailAndPostContact : Contact { public override void AcceptVisitor(IContactVisitor visitor) { if (visitor == null) { throw new ArgumentNullException(nameof(visitor)); } visitor.Visit(Name, email_, post_); } }
      
      





これで、連絡先を表示するコードを作成できます。 簡単にするために、コンソールインターフェイスを使用します。







 public sealed class ContactUi { private sealed class Visitor : IContactVisitor { void IContactVisitor.Visit(PersonalName name, EmailContactInfo email) { Console.WriteLine(name); Console.WriteLine("* Email: {0}", email); } void IContactVisitor.Visit(PersonalName name, PostalContactInfo post) { Console.WriteLine(name); Console.WriteLine("* Postal address: {0}", post); } void IContactVisitor.Visit(PersonalName name, EmailContactInfo email, PostalContactInfo post) { Console.WriteLine(name); Console.WriteLine("* Email: {0}", email); Console.WriteLine("* Postal address: {0}", post); } } public void Display(Contact contact) => contact.AcceptVisitor(new Visitor()); }
      
      





 var ui = new ContactUi(); ui.Display(newContact);
      
      





さらなる改善



ライブラリでContact



宣言され、ライブラリのクライアントでの新しい相続人の出現が望ましくない場合、 Contact



コンストラクターのスコープをinternal



に変更するか、その相続人をネストされたクラスにし、実装とコンストラクターの可視性を宣言し、静的ファクトリーメソッドのみでインスタンスを作成できます。







 public abstract class Contact { private sealed class EmailOnlyContact : Contact { public EmailOnlyContact(PersonalName name, EmailContactInfo email) : base(name) { } } private Contact(PersonalName name) { } public static Contact EmailOnly(PersonalName name, EmailContactInfo email) => new EmailOnlyContact(name, email); }
      
      





したがって、タイプサムの非拡張性を再現することは可能ですが、原則としてこれは必須ではありません。







おわりに



OOPツールで型を使用して、ビジネスロジックの正しい状態を制限する方法を示すことができたと思います。 コードはF#よりもボリュームが大きいことが判明しました。 どこかでこれはOOPの決定の相対的な煩わしさ、どこかで言語の冗長さによるものですが、解決策は非現実的とは言えません。







興味深いことに、純粋に機能的なソリューションから始めて、サブジェクト指向プログラミングとOOPパターンの推奨を思い付きました。 実際、これは驚くべきことではありません。タイプサムとVisitorパターンの類似性はかなり以前から知られているからです。 この記事の目的は、命令プログラミングで「象牙の塔」からのアイデアの適用可能性を示すほど具体的なトリックを示すことではありませんでした。 もちろん、すべてを簡単に転送できるわけではありませんが、主流のプログラミング言語の機能がますます増えているため、該当する言語の境界が拡大します。










→サンプルコードはGitHubで入手可能








All Articles