介绍 .Net 中的语义类型






4.89/5 (104投票s)
语义类型通过让编译器确保代码的一致性,帮助您减少错误并提高可维护性。本文将介绍其工作原理以及如何以最小的开销创建语义类型。
目录
引言
静态类型有助于保持代码无错误且可维护。例如:
public int Mymethod(Person person) { ... }
这里有一些好事正在发生
- 文档:您会立刻知道此方法接受一个 Person 对象并返回一个整数。
- 机器检查:编译器也已获知。这意味着这不仅仅是可能过时的文档。编译器实际上确保您在此处阅读的内容是真实的。
- 工具:最后,Visual Studio 也已被告知——使您能够快速了解 Person 的定义。
问题在于,默认情况下,C# 只提供基于数据在计算机内存中物理表示的类型。整数是 32 位数字,字符串是字符集合,等等。因此,当出现以下情况时,编译器甚至不会发出警告:
double d = GetDistance(); double t = GetTemperature(); ... Many complicated lines further ... // Adding a temperature to a distance doesn't make sense, // but the compiler won't warn you. double probablyWrong = d + t;
好的,您可以使用更好的命名。将 d 替换为 totalDistance。将 t 替换为 surfaceTemperature。但编译器仍然不会发出警告,因为它仍然不知道 totalDistance 是距离,而不仅仅是一个 double。
另一个例子
/// <summary> /// Sends an email. /// </summary> /// <param name="emailAddress"> /// Hopefully this is a valid email address. /// But there is no way to be sure. We could be getting anything here really. /// /// If someone passes a phone number by mistake, the compiler will /// happily compile this, and we'll get a run time exception. Happy debugging. /// </param> /// <param name="message"> /// Message to send. /// </param> public void SendEmail(string emailAddress, string message) { }
问题在于,我们告诉编译器该方法可以接受任何字符串作为电子邮件地址,而实际上它只能接受一个有效的电子邮件地址,这有很大的不同。
解决这些问题的办法是告知编译器我们领域中的各种值类型——距离、温度、电子邮件地址等,即使它们可以通过某些内置类型(如 double 或 integer)在内存中表示。这样,它就可以为我们捕获更多错误。这就是语义类型的作用。
语义类型
想象一下 C# 包含一个 EmailAddress 类型,它只能包含一个有效的电子邮件地址。
// Constructor throws exception if passed in email address is invalid var validEmailAddress = new EmailAddress("kjones@megacorp.com"); var validEmailAddress2 = new EmailAddress("not a valid email address"); // throws exception
现在我们可以保证只将有效的电子邮件地址传递给 SendEmail 方法。
// emailAddress will always contain a valid email address public void SendEmail(EmailAddress emailAddress, string message) { } ... SendEmail(validEmailAddress, "message"); // can only pass an valid email address
为防止不必要的异常处理,我们需要一个静态 IsValid 方法来检查电子邮件地址是否有效。
bool isValidEmailAddress = EmailAddress.IsValid("kjones@megacorp.com"); // true bool isValidEmailAddress2 = EmailAddress.IsValid("not a valid email address"); // false
最后,我们需要一个 Value 属性来检索底层字符串值。它是只读的,以确保在 EmailAddress 创建后,它是不可变的(不可更改)。
var validEmailAddress = new EmailAddress("kjones@megacorp.com"); string emailAddressString = validEmailAddress.Value; // "kjones@megacorp.com"
这样的 EmailAddress 类型是语义类型的一个例子。
- 基于意义的类型,而非基于物理存储:EmailAddress 在物理上仍然是一个字符串。使其不同的是我们如何看待这个字符串——将其视为电子邮件地址,而不是随机的字符集合。
- 类型安全:拥有一个独立的 EmailAddress 类型使编译器能够确保您不会在需要有效电子邮件地址的地方使用普通字符串——就像编译器阻止您在需要整数的地方使用字符串一样。
- 保证有效:因为您无法基于无效的电子邮件地址创建 EmailAddress,并且在创建后无法更改它,所以您确信每个 EmailAddress 都代表一个有效的电子邮件地址。
- 文档:当您看到 EmailAddress 类型的参数时,您会立刻知道它包含一个电子邮件地址,即使参数名称不明确。
除了 EmailAddress 类型,您还可以拥有 ZipCode 类型、PhoneNumber 类型、Distance 类型、Temperature 类型等。
语义类型显然很有用,但许多人하지 采用这种方法,因为他们担心引入语义类型会涉及大量的输入和样板代码。
本文的其余部分将首先介绍如何实现语义类型,然后介绍如何提取所有公共代码,使创建新的语义类型变得简单快捷。
首次尝试创建语义类型
在了解如何创建通用语义类型之前,让我们先创建一个特定的语义类型:EmailAddress。
鉴于 EmailAddress 在物理上是一个字符串,您可能会想继承 string。
// Doesn't compile public class EmailAddress: string { }
然而,这无法编译,因为 string 是 密封的,所以您不能从中派生。int、double 等也是如此。您甚至不能继承 DateTime。
因此,我们将字符串值存储在 EmailAddress 类中。请注意,setter 是私有的。这样,类外部的代码就无法更改该值。
public class EmailAddress { public string Value { get; private set; } }
添加一个静态 IsValid 方法,该方法在给定字符串是有效电子邮件地址时返回 true。
using System.Text.RegularExpressions; public class EmailAddress { public string Value { get; private set; } public static bool IsValid(string emailAddress) { return Regex.IsMatch(emailAddress, @"^(?("")("".+?(?<!\\)""@)|(([0-9a-z]((\.(?!\.))|[-!#\$%&'\*\+/=\?\^`\{\}\|~\w])*)(?<=[0-9a-z])@))" + @"(?(\[)(\[(\d{1,3}\.){3}\d{1,3}\])|(([0-9a-z][-\w]*[0-9a-z]*\.)+[a-z0-9][\-a-z0-9]{0,22}[a-z0-9]))$", RegexOptions.IgnoreCase); } }
添加构造函数。它接受一个可能包含有效电子邮件地址的字符串。如果不是电子邮件地址,则抛出异常。
using System.Text.RegularExpressions; public class EmailAddress { public string Value { get; private set; } public static bool IsValid(string emailAddress) { return Regex.IsMatch(emailAddress, @"^(?("")("".+?(?<!\\)""@)|(([0-9a-z]((\.(?!\.))|[-!#\$%&'\*\+/=\?\^`\{\}\|~\w])*)(?<=[0-9a-z])@))" + @"(?(\[)(\[(\d{1,3}\.){3}\d{1,3}\])|(([0-9a-z][-\w]*[0-9a-z]*\.)+[a-z0-9][\-a-z0-9]{0,22}[a-z0-9]))$", RegexOptions.IgnoreCase); } public EmailAddress(string emailAddress) { if (!IsValid(emailAddress)) { throw new ArgumentException(string.Format("Invalid email address: {0}", emailAddress)); } Value = emailAddress; } }
这就完成了基础部分。请注意,通过此实现,EmailAddress 在创建后就无法更改——它是不可变的。如果您想要一个新的电子邮件地址,您必须创建一个新的 EmailAddress 对象——并且构造函数将确保您新的电子邮件地址也有效。
然而,还有最后一件事需要实现:相等性。当您使用简单的字符串存储电子邮件地址时,您期望能够按值进行比较。
string emailAddress1 = "kjones@megacorp.com"; string emailAddress2 = "kjones@megacorp.com"; bool equal = (emailAddress1 == emailAddress2); // true
因此,我们希望 EmailAddresses 也能有相同的行为。
var emailAddress1 = new EmailAddress("kjones@megacorp.com"); var emailAddress2 = new EmailAddress("kjones@megacorp.com"); bool equal = (emailAddress1 == emailAddress2); // true
因为 EmailAddress 是一个 引用类型,默认情况下,相等运算符只检查两个 EmailAddresses 是否在物理上相同。然而,我们希望比较底层的电子邮件地址。
为了实现这一点,我们必须实现 System.IEquatable<T> 接口,并重写 Object.Equals 和 Object.GetHashCode 方法以及 == 和 != 运算符(完整详情)。结果如下:
public class EmailAddress : IEquatable<EmailAddress> { public string Value { get; private set; } public EmailAddress(string emailAddress) { if (!IsValid(emailAddress)) { throw new ArgumentException(string.Format("Invalid email address: {0}", emailAddress)); } Value = emailAddress; } public static bool IsValid(string emailAddress) { return Regex.IsMatch(emailAddress, @"^(?("")("".+?(?<!\\)""@)|(([0-9a-z]((\.(?!\.))|[-!#\$%&'\*\+/=\?\^`\{\}\|~\w])*)(?<=[0-9a-z])@))" + @"(?(\[)(\[(\d{1,3}\.){3}\d{1,3}\])|(([0-9a-z][-\w]*[0-9a-z]*\.)+[a-z0-9][\-a-z0-9]{0,22}[a-z0-9]))$", RegexOptions.IgnoreCase); } #region equality public override bool Equals(Object obj) { //Check for null and compare run-time types. if ((obj == null) || (!(obj is EmailAddress))) { return false; } return (Value.Equals(((EmailAddress)obj).Value)); } public override int GetHashCode() { return Value.GetHashCode(); } public bool Equals(EmailAddress other) { if (other == null) { return false; } return (Value.Equals(other.Value)); } public static bool operator ==(EmailAddress a, EmailAddress b) { // If both are null, or both are same instance, return true. if (System.Object.ReferenceEquals(a, b)) { return true; } // If one is null, but not both, return false. // Have to cast to object, otherwise you recursively call this == operator. if (((object)a == null) || ((object)b == null)) { return true; } // Return true if the fields match: return a.Equals(b); } public static bool operator !=(EmailAddress a, EmailAddress b) { return !(a == b); } #endregion }
提取样板代码
显然,现有的 EmailAddress 类有许多不特定于电子邮件地址的样板代码。我们将把它提取到一个基类 SemanticType 中。然后可以使用它来快速定义许多语义类型。
完成后,EmailAddress 将如下所示:
public class EmailAddress : SemanticType<string> { public static bool IsValid(string value) { return (Regex.IsMatch(value, @"^(?("")("".+?(?<!\\)""@)|(([0-9a-z]((\.(?!\.))|[-!#\$%&'\*\+/=\?\^`\{\}\|~\w])*)(?<=[0-9a-z])@))" + @"(?(\[)(\[(\d{1,3}\.){3}\d{1,3}\])|(([0-9a-z][-\w]*[0-9a-z]*\.)+[a-z0-9][\-a-z0-9]{0,22}[a-z0-9]))$", RegexOptions.IgnoreCase)); } // Constructor, taking an email address. The base constructor handles validation // and storage in the Value property. public EmailAddress(string emailAddress) : base(IsValid, emailAddress) { } }
在这里,我们只指定 EmailAddress 特有的部分,而将样板代码留给基类 SemanticType(我们将在下一节中讨论)。
- SemanticType 基类将存储底层值,因此它需要是 泛型的,并且有一个类型参数,其类型为底层值的类型——在本例中为 string。
- IsValid 方法特定于 EmailAddress,因此无法提取。
- SemanticType 构造函数负责存储值,因此它需要知道如何验证它。为了实现这一点,只需将 IsValid 方法作为参数传递。如果不需要验证,则传递 null。
另一个例子是 BirthDate 语义类型。它是一个 DateTime,只是出生日期必须在过去(除非您为幼儿园提供预订),并且它们不能早于 130 年前(除非您存储已故人员的详细信息)。
public class BirthDate : SemanticType<DateTime> { // Oldest person ever died at 122 year and 164 days // http://en.wikipedia.org/wiki/List_of_the_verified_oldest_people // To be safe, reject any age over 130 years. const int maxAgeForHumans = 130; const int daysPerYear = 365; public static bool IsValid(DateTime birthDate) { TimeSpan age = DateTime.Now - birthDate; return (age.TotalDays >= 0) && (age.TotalDays < daysPerYear * maxAgeForHumans); } public BirthDate(DateTime birthDate) : base(IsValid, birthDate) { } }
创建 SemanticType 基类
让我们从最基本的声明开始:
public class SemanticType<T> { }
Value 属性
添加 Value 属性,用于存储底层值。请注意,它的类型是 T,即底层值的类型。
public class SemanticType<T> { public T Value { get; private set; } }
构造函数
现在是构造函数。它充当守门员,通过在传入的值无效时抛出异常来确保语义类型始终有效。请注意:
- 它不允许 null 作为值。如果您允许 null,那么 null 的 EmailAddress 和具有 null 值的 EmailAddress 之间会有混淆。
- 它使用通过 isValidLambda 参数传递的 IsValid 静态方法进行验证。
- 它使用派生类(通过 this.GetType() 检索)的类型来创建更有意义的异常消息。
public class SemanticType<T> { public T Value { get; private set; } protected SemanticType(Func<T, bool> isValidLambda, T value) { if ((Object)value == null) { throw new ArgumentException(string.Format("Trying to use null as the value of a {0}", this.GetType())); } if ((isValidLambda != null) && !isValidLambda(value)) { throw new ArgumentException(string.Format("Trying to set a {0} to {1} which is invalid", this.GetType(), value)); } Value = value; } }
与相等性相关的代码
现在我们可以实现与相等性相关的代码。首先重写从 Object 继承的 Equals 和 GetHashCode 方法。
public class SemanticType<T> { public T Value { get; private set; } protected SemanticType(Func<T, bool> isValidLambda, T value) { if ((Object)value == null) { throw new ArgumentException(string.Format("Trying to use null as the value of a {0}", this.GetType())); } if ((isValidLambda != null) && !isValidLambda(value)) { throw new ArgumentException(string.Format("Trying to set a {0} to {1} which is invalid", this.GetType(), value)); } Value = value; } public override bool Equals(Object obj) { //Check for null and compare run-time types. if (obj == null || obj.GetType() != this.GetType()) { return false; } return (Value.Equals(((SemanticType<T>)obj).Value)); } public override int GetHashCode() { return Value.GetHashCode(); } }
实现 IEquatable
现在我们可以通过实现其 Equals 方法来 实现 IEquatable 接口。
IEquatable.Equals 和 Object.Equals 之间的区别在于,IEquatable.Equals 是强类型的。这具有以下优点:
- 您通过编译器获得更好的类型检查。
- 当底层类型是值类型(如 integer)时,它可以提高相等性测试的效率,因为它避免了 装箱。
public class SemanticType<T> : IEquatable<SemanticType<T>> { public T Value { get; private set; } protected SemanticType(Func<T, bool> isValidLambda, T value) { if ((Object)value == null) { throw new ArgumentException(string.Format("Trying to use null as the value of a {0}", this.GetType())); } if ((isValidLambda != null) && !isValidLambda(value)) { throw new ArgumentException(string.Format("Trying to set a {0} to {1} which is invalid", this.GetType(), value)); } Value = value; } public override bool Equals(Object obj) { //Check for null and compare run-time types. if (obj == null || obj.GetType() != this.GetType()) { return false; } return (Value.Equals(((SemanticType<T>)obj).Value)); } public override int GetHashCode() { return Value.GetHashCode(); } public bool Equals(SemanticType<T> other) { if (other == null) { return false; } return (Value.Equals(other.Value)); } }
== 和 != 运算符
最后,重写 == 和 != 运算符。
public class SemanticType<T> : IEquatable<SemanticType<T>> { public T Value { get; private set; } protected SemanticType(Func<T, bool> isValidLambda, T value) { if ((Object)value == null) { throw new ArgumentException(string.Format("Trying to use null as the value of a {0}", this.GetType())); } if ((isValidLambda != null) && !isValidLambda(value)) { throw new ArgumentException(string.Format("Trying to set a {0} to {1} which is invalid", this.GetType(), value)); } Value = value; } public override bool Equals(Object obj) { //Check for null and compare run-time types. if (obj == null || obj.GetType() != this.GetType()) { return false; } return (Value.Equals(((SemanticType<T>)obj).Value)); } public override int GetHashCode() { return Value.GetHashCode(); } public bool Equals(SemanticType<T> other) { if (other == null) { return false; } return (Value.Equals(other.Value)); } public static bool operator ==(SemanticType<T> a, SemanticType<T> b) { // If both are null, or both are same instance, return true. if (System.Object.ReferenceEquals(a, b)) { return true; } // If one is null, but not both, return false. // Have to cast to object, otherwise you recursively call this == operator. if (((object)a == null) || ((object)b == null)) { return false; } // Return true if the fields match: return a.Equals(b); } public static bool operator !=(SemanticType<T> a, SemanticType<T> b) { return !(a == b); } }
ToString
ToString 由每个 Object 实现,即 .Net 中的每个类型,包括 int 和 double 等值类型。
默认情况下,这只是返回类型的名称。然而,您可能希望获得底层字符串的表示形式。对于 EmailAddress 来说,由于底层值已经是字符串,所以这不太有用;但对于 DateTime,这就会派上用场。
实现 ToString 非常简单:
public class SemanticType<T> : IEquatable<SemanticType<T>> { ... public override string ToString() { return this.Value.ToString(); } }
IComparable
假设您刚刚将代码转换为使用 EmailAddress 而不是字符串来处理电子邮件地址。问题在于,字符串可以与 List<T>.Sort 一起排序(例如,a@abc.com 排在 b@abc.com 之前)。然而,开箱即用,您无法对普通对象执行此操作。
解决方案是,所有与排序对象相关的 .Net 类都会检查对象是否实现了 IComparable<T> 接口。要实现该接口,您必须添加一个 CompareTo 方法,该方法将对象与同一类的另一个对象进行比较。
IComparable<T> 有一个非泛型对应项 IComparable。这是因为在没有泛型的时代遗留下来的。我决定不支持它,因为它违背了使用强类型在编译时捕获错误的理念。
在 SemanticType<T> 中实现 IComparable<T> 很简单——只需比较底层值。
// Does not compile public class SemanticType<T> : IEquatable<SemanticType<T>>, IComparable<SemanticType<T>> { ... public int CompareTo(SemanticType<T> other) { if (other == null) { return 1; } return this.Value.CompareTo(other.Value); } }
这里有一个问题:此代码无法编译。编译器尚未得知类型 T(底层类型)实际上实现了 CompareTo。有几种方法可以解决此问题:
- 在运行时检查 T 是否实现了 IComparable<T>,使用 Type.IsAssignableFrom。如果实现了,则转换为 IComparable<T>。如果没有,则抛出异常。
- 在 T 上添加 约束,以确保它实现了 IComparable<T>。
选项 1 将检查 T 是否实现 IComparable<T> 推迟到运行时,而选项 2 则在编译时完成。选项 2 也更简单一些。这使得选项 2 对我来说更可取。
public class SemanticType<T> : IEquatable<SemanticType<T>>, IComparable<SemanticType<T>> where T: IComparable<T> { ... public int CompareTo(SemanticType<T> other) { if (other == null) { return 1; } return this.Value.CompareTo(other.Value); } }
那么,底层值不实现 IComparable<T> 的罕见情况呢?也许您想将某些旧类型包装到语义类型中。
为了适应这种情况,在 Semantic Types Nuget 包中,我引入了一个类 UncomparableSemanticType<T> —— SemanticType<T> 的一个版本,它不实现 IComparable<T>。如果您查看该代码,会发现这些类的公共部分已被提取到一个公共基类中。因为这非常简单,所以我在这里没有讨论。
驾驭物理世界
简单的语义类型,本质上只是包装一个值,对于电子邮件地址、电话号码和其他简单数据来说效果很好。然而,当将其应用于长度、面积、重量和其他物理单位时,情况会变得更有趣。
我们人类在使用单位方面不一致。
让我们回到开头看到的那个代码片段:
double d = GetDistance(); double t = GetTemperature(); ... Many complicated lines further ... // Adding a temperature to a distance doesn't make sense, // but the compiler won't warn you. double probablyWrong = d + t;
我们可以轻松地引入 Distance 和 Temperature 语义类型,这样编译器就会捕获我们的错误。
Distance d = GetDistance(); Temperature t = GetTemperature(); ... Many complicated lines further ... // Adding a temperature to a distance doesn't make sense, // and now the compiler will catch our mistake. double probablyWrong = d + t; // doesn't compile
但是这段代码会引出新的问题:这个距离是米?公里?英尺?英寸?温度呢:摄氏度?华氏度?开尔文?
改进方法是在变量名中添加单位。
Distance distanceMeters = GetDistanceInMeters(); Temperature temperatureCelcius = GetTemperatureInCelcius();
但这会变得非常笨拙,并且很容易过时。如果您的网站既有美国用户,也有欧洲和英国用户怎么办?您现在处理的是英尺和米、磅和公斤,以及可能更多的单位。
即使您的网站目前只涉及英尺,您的市场部门可能已经在关注一个使用米的市场的机会了。遍历所有数字变量和方法来使您的网站同时处理英尺和米将不会很有趣。
问题在于,您需要将每个长度、重量等的单位保存在一个单独的变量中,而这个变量很容易不同步。此外,您将编写许多转换方法——MetersToInches、InchesToFeet 等。这明显会带来复杂性、奇怪的错误、痛苦和沮丧。
解决方案是停止在变量中存放米、英尺、英寸、公斤、磅等。相反,只需考虑长度、重量等即可。请记住,一个现实世界物体的长度是相同的,无论您说的是英寸还是米。
一个 Length 对象看起来会像这样:
public class Length { // Store weights internally as meters public double Value { get; private set; } public Length(double value) { Value = value; } // Get length in feet public double Feet { get { return Value/0.3048; } } // Get length in meters public double Meters { get { return Value; } } // Create length based on number of feet public static Length FromFeet(double feet) { return new Length(feet*0.3048); } // Create lenght based on number of meters public static Length FromMeters(double meters) { return new Length(meters); } }
而一个 weight 对象则会像这样:
public class Weight { // Store weights internally as kilograms public double Value { get; private set; } public Weight(double value) { Value = value; } // Get weight in pounds public double Pounds { get { return Value/0.45359237; } } // Get weight in kilograms public double Kilograms { get { return Value; } } // Create weight based on number of kilograms public static Weight FromKilograms(double kilograms) { return new Weight(kilograms); } // Create weight based on number of pounds public static Weight FromPounds(double pounds) { return new Weight(pounds*0.45359237); } }
现在您可以编写:
Length userHeight = Length.FromMeters(height_entered_by_european); Weight userWeight = Weight.FromKilograms(weight_entered_by_european); .... Length userHeight = Length.FromFeet(height_entered_by_american); Weight userWeight = Weight.FromPounds(weight_entered_by_american); .... // Calculate Body Mass Index, a measure of obesity. // Bmi is always calculated in kilograms and meters, including in the US and UK. double bmi = userWeight.Kilograms / (userHeight.Meters * userHeight.Meters);
现在,您总是可以清楚地知道 double 类型的一些数字应该是一个以米为单位的长度,还是以磅为单位的重量,等等。而且不再需要担心 userWeight 是以公斤还是磅为单位。如果您需要以公斤为单位的重量,只需以公斤为单位检索即可。
如果您觉得这听起来不错,但又不想编写大量带有转换等功能的类,那么可以看看 NuGet 包 Units.NET。它有几十种单位,所有单位都带有数学和比较运算符、ToString 等。这是一个非常完整的包。
一个长度乘以一个长度不再是一个长度。
如果您使用像 Units.NET 这样的包,您的单位类上很可能定义了常规的算术运算符。
public static Length operator +(Length left, Length right) { return new Length(left.Value + right.Value); } public static Length operator -(Length left, Length right) { return new Length(left.Value - right.Value); }
乘法和除法的情况会更复杂。2 米的长度加上 3 米的长度是 5 米的长度。但 2 米的长度乘以 3 米的长度是 6 平方米的面积。
public static Area operator *(Length left, Length right) { return new Area(left.Value * right.Value); }
一个面积乘以一个长度是体积。一个长度除以一个时间段是速度,等等。您可以在这里决定在哪里划线。
结论
您看到了语义类型如何通过让编译器在编译时找出错误来帮助您防止错误。它们还通过允许您指定某个事物是电子邮件地址、距离、温度等,而不是仅仅是某个字符串或 double,从而使代码更容易理解。
您还了解了如何创建一个 SemanticType 基类,该类可以轻松创建新的语义类型,而不会陷入大量的样板代码。