按类型设计:如何使无效状态在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是一个值对象 (就DDD而言 ,不是.NET中的值类型 ),在对象建模中早已为人所知。


其他类型StateCodeZipCodePostalAddressPersonalName以类似的方式移植到C#。


建立联络人


因此,代码应表达规则“联系人必须包含电子邮件地址或邮政地址(或两个地址)”。 需要表达此规则,以便状态的正确性从类型定义中可见并由编译器检查。


表达各种联系状态


这意味着联系人是一个包含此人姓名和电子邮件地址或邮政地址或两者的对象。 显然,一个类不能包含三个不同的属性集;因此,必须定义三个不同的类。 所有这三个类都必须包含联系人的姓名,同时应该可以以相同的方式处理不同类型的联系人,而不知道该联系人包含哪个地址。 因此,联系人将由包含联系人名称的抽象基类以及具有不同字段集的三个实现来表示。


 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; } } 

您可以辩称, 您必须使用composition而不是继承,并且通常您需要继承行为而不是数据。 这些言论是公平的,但我认为在这里使用类层次结构是合理的。 首先,子类不仅代表基类的特殊情况,而且整个层次结构是一个概念-联系。 三种联系方式非常准确地反映了业务规则规定的三种情况。 其次,基层及其继承人之间的关系,它们之间的责任分工很容易追踪。 第三,如果层次结构确实成为问题,则可以像原始示例中那样将联系人状态分为单独的层次结构。 在F#中,记录的继承是不可能的,但是新类型的声明非常简单,因此立即进行了拆分。 在C#中,更自然的解决方案是将Name字段放在基类中。


建立联络人


创建联系人非常简单。


 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实现和负责处理联系的场所之间的处理逻辑将变得模糊。 F#中没有这样的问题,我希望C#代码不会更糟!


与C#中的模式匹配最接近的等效项是switch构造。 我们可以向Contact添加一个枚举类型属性,这将使我们能够确定联系人的实际类型并执行转换。 也可以使用C#的较新功能,并作为Contact的实例进行切换。 但是,毕竟,我们希望编译器在添加新的正确Contact状态时提示自己,在这种情况下,对新案例的处理不足,并且switch不能保证对所有可能案例的处理。


但是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 ,甚至将其后代嵌套类,将实现和构造函数的可见性声明为private ,并仅通过静态工厂方法创建实例。


 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可用

Source: https://habr.com/ru/post/zh-CN431186/


All Articles