使用属性的乐趣
滥用属性,乐在其中。
引言
这周我一直在玩自定义属性,我决定要充分利用它们,让人们大吃一惊,展示一些你可能根本不需要,或者不应该做的事情。我说,生活没有一点奇思妙想还有什么意思?
Attribute
的目的是附加某种形式的元数据到一个属性、对象或方法上,并通过反射,在运行时访问这些元数据。我将这个范例扩展到了包含一个功能,该功能检查属性的值是否“有效”。
我为什么这样做
我最近写了一篇关于 CSV 文件解析的文章。我写那段代码是因为我必须从 CSV 文件导入数据,并且由于我的环境的性质,我必须付出极大的努力来确保数据处理链中的某个人类没有以某种方式搞砸数据。为此,我为每个预期的列数据建立了值,这些值指示在导入文件时遇到了问题。这些值通常是字符串的“ERROR”,数值的 -1,等等。所以,我想,“嘿!我将利用这个机会来玩自定义属性。”
为了辩护,我当时正处于培根高潮期,所以这进一步证实了当你处于幸福状态时,不应该编码的想法,因为那些通常让你非常烦恼的事情似乎都不那么重要了。我开始了一个 Lounge 主题讨论这段代码,有人建议我说明为什么我不得不这样做。嗯,这部分很有趣——我根本不需要这样做(事实上,我已经有一个替代方案,代码量更少)。我写的所有代码都遵循了我(大声)表达的嘲讽(这可能被误认为是突然发作的妥瑞氏症候群),当我读到“属性不应该提供功能”时。胡说!没错!胡说!
代码
InvalidValueAttribute 类
类声明很重要,因为在那里你可以告诉属性它可以在哪里使用,以及是否允许多个实例。具有讽刺意味的是,这本身也是通过一个属性完成的,如下所示。
// This attribute is only available for properties, and more than one instance of this
// attribute can be specified for a given property. Each instance of attribute must use
// a different trigger type. It is up to the programmer to ensure that Equal and NotEqual
// are not set to the same TriggerValue. To say the results would be unexpected is a huge
// understatement.
[System.AttributeUsage(System.AttributeTargets.Property, AllowMultiple=true)]
public class InvalidValueAttribute : System.Attribute
为了控制比较的执行方式,我在类内部定义了一个枚举。
/// <summary>
/// A flag used to indicate how the comparison for validity is performed
/// </summary>
public enum TriggerType
{
Valid, // comparison returns TRUE if the property value is != the trigger value
Equal, // comparison returns TRUE if the property value is == the trigger value
NotEqual, // comparison returns TRUE if the property value is != the trigger value
Over, // comparison returns TRUE if the property value is <= the trigger value
Under // comparison returns TRUE if the property value is >= the trigger value
};
然后我实现了配置属性。
/// <summary>
/// Gets/sets a flag indicating how the valid status is determined.
/// </summary>
public TriggerType Trigger { get; protected set; }
/// <summary>
/// Gets/sets the value that will be used to determine if the property value is valid
/// </summary>
public object TriggerValue { get; protected set; }
/// <summary>
/// Gets/sets the expected type that the property value will/should be
/// </summary>
public Type ExpectedType { get; protected set; }
/// <summary>
/// Gets/sets the value that was compared against the trigger value so that the
/// TriggerMessage can be constructed.
/// </summary>
public object PropertyValue { get; protected set; }
此属性允许包含被修饰属性的对象显示有效性状态。
/// <summary>
/// Gets the trigger message. Called by the method performing the validity check, usually
/// if the value is not valid.
/// </summary>
public string TriggerMsg
{
get
{
string format = string.Empty;
switch (this.Trigger)
{
case TriggerType.Valid :
case TriggerType.Equal : format = "equal to"; break;
case TriggerType.NotEqual : format = "not equal to"; break;
case TriggerType.Over : format = "greater than"; break;
case TriggerType.Under : format = "less than"; break;
}
if (!string.IsNullOrEmpty(format))
{
format = string.Concat("Cannot be ", format, " '{0}'. \r\n Current value is '{1}'.\r\n");
}
return (!string.IsNullOrEmpty(format)) ? string.Format(format, this.TriggerValue, this.PropertyValue) : string.Empty;
}
}
随着类的发展,构造函数变得有些复杂。这主要发生在处理 DateTime
对象时。可能应该添加更多代码来验证 triggerValue
是否可以强制转换为预期的类型(如果已指定),但我将把它留给程序员自行决定,并作为一项练习(那就是你)。
/// <summary>
/// Constructor
/// </summary>
/// <param name="triggerValue">The value against which a property value wuill be compared for validity</param>
/// <param name="trigger">The trigger type (default value is TriggerType.Valid)</param>
/// <param name="expectedType">The expected property value type (optional value, default = null)</param>
public InvalidValueAttribute(object triggerValue, TriggerType trigger=TriggerType.Valid, Type expectedType=null )
{
// Note about DateTime properties:
// Since an attribute constructor parameter must be a constant, we can't specify an actual
// DateTime object as the trigger value. The code will attempt to accomodate a DateTime by
// converting the property value to a long, and comparing that to the trigger value.
// However, you can work around this by actually specifying the expectedType parameter
// as typeof(DateTime). If you do this, the TriggerValue should also be >= 0, but the
// constructor will normalize the trigger valu so that it will fall into the acceptable
// range for a long (Int64).
if (this.IsIntrinsic(triggerValue.GetType()))
{
this.Trigger = trigger;
if (expectedType != null)
{
if (this.IsDateTime(expectedType))
{
// let's try to avoid stupid programmer tricks
long ticks = Math.Min(Math.Max(0, Convert.ToInt64(triggerValue)), Int64.MaxValue);
// instantiate a datetime with the ticks
this.TriggerValue = new DateTime(ticks);
}
else
{
this.TriggerValue = triggerValue;
}
this.ExpectedType = expectedType;
}
else
{
this.TriggerValue = triggerValue;
this.ExpectedType = triggerValue.GetType();
}
}
else
{
throw new ArgumentException("The triggerValue parameter must be a primitive, string, or DateTime, and must match the type of the attributed property.");
}
}
我将要演示的类中的最后一个方法是 IsValid
方法(因为这就是我们这一切工作的核心)。此方法执行适当的比较(由 Trigger
属性指定),将属性值与构造函数中指定的 TriggerValue
进行比较。
/// <summary>
/// Determines if the specified value is valid (dependant oin the Trigger property's value).
/// </summary>
/// <param name="value">The value of the property attached to this attribute instance.</param>
/// <returns></returns>
public bool IsValid(object value)
{
// assume the value is not valid
bool result = false;
// save the value for use in the TriggerMsg
this.PropertyValue = value;
// get the type represented by the value
Type valueType = value.GetType();
if (this.IsDateTime(valueType))
{
// ensure that the trigger value is a datetime
this.TriggerValue = this.MakeNormalizedDateTime();
// and set the ExpectedType for the following comparison.
this.ExpectedType = typeof(DateTime);
}
// If the type is what we're expecting, we can compare the objects
if (valueType == this.ExpectedType)
{
switch (this.Trigger)
{
case TriggerType.Equal : result = this.IsEqual (value, this.TriggerValue); break;
case TriggerType.Valid :
case TriggerType.NotEqual : result = this.IsNotEqual (value, this.TriggerValue); break;
case TriggerType.Over : result = !this.GreaterThan(value, this.TriggerValue); break;
case TriggerType.Under : result = !this.LessThan (value, this.TriggerValue); break;
}
}
else
{
throw new InvalidOperationException("The property value and trigger value are not of compatible types.");
}
return result;
}
类中其余的方法是辅助方法、类型检查器和实际的比较方法,它们实际上并没有那么有趣。它们仅在此处包含以求完整性。随意最小化以下代码块,使文章看起来更短。因为它们的预期用途很明显(至少对我来说),我不会解释它们中的任何一个。此外,我相信您现在已经迫不及待地想告诉我为什么我一开始就不应该写这段代码了。
/// <summary>
/// Adjusts the TriggerValue to a DateTime if the TriggerValue is an integer.
/// </summary>
/// <returns></returns>
private DateTime MakeNormalizedDateTime()
{
DateTime date = new DateTime(0);
if (this.IsInteger(this.TriggerValue.GetType()))
{
long ticks = Math.Min(Math.Max(0, Convert.ToInt64(this.TriggerValue)), Int64.MaxValue);
date = new DateTime(ticks);
}
else if (this.IsDateTime(this.TriggerValue.GetType()))
{
date = Convert.ToDateTime(this.TriggerValue);
}
return date;
}
#region type detector methods
// These methods can be converted into extension methods, but in keeping with my chosen
// style of code organization, that would have required another file to be created, and
// for purposes of illustration, I didn't feel it was warranted.
// No comments are applied to the methods because I think their functionality is pretty
// obvious by their names.
protected bool IsUnsignedInteger(Type type)
{
return ((type != null) &&
(type == typeof(uint) ||
type == typeof(ushort) ||
type == typeof(ulong)));
}
protected bool IsInteger(Type type)
{
return ((type != null) &&
(this.IsUnsignedInteger(type) ||
type == typeof(byte) ||
type == typeof(sbyte) ||
type == typeof(int) ||
type == typeof(short) ||
type == typeof(long)));
}
protected bool IsDecimal(Type type)
{
return (type != null && type == typeof(decimal));
}
protected bool IsString(Type type)
{
return (type != null && type == typeof(string));
}
protected bool IsDateTime(Type type)
{
return ((type != null) && (type == typeof(DateTime)));
}
protected bool IsFloatingPoint(Type type)
{
return ((type != null) && (type == typeof(double) || type == typeof(float)));
}
protected bool IsIntrinsic(Type type)
{
return (this.IsInteger(type) ||
this.IsDecimal(type) ||
this.IsFloatingPoint(type) ||
this.IsString(type) ||
this.IsDateTime(type));
}
protected bool LessThan(object obj1, object obj2)
{
bool result = false;
Type objType = obj1.GetType();
if (this.IsInteger(objType))
{
result = (this.IsUnsignedInteger(objType) && this.IsUnsignedInteger(obj2.GetType())) ?
(Convert.ToUInt64(obj1) < Convert.ToUInt64(obj2)) :
(Convert.ToInt64(obj1) < Convert.ToInt64(obj2));
}
else if (this.IsFloatingPoint(objType))
{
result = (Convert.ToDouble(obj1) < Convert.ToDouble(obj2));
}
else if (this.IsDecimal(objType))
{
result = (Convert.ToDecimal(obj1) < Convert.ToDecimal(obj1));
}
else if (this.IsDateTime(objType))
{
result = (Convert.ToDateTime(obj1) < Convert.ToDateTime(obj2));
}
else if (this.IsString(objType))
{
result = (Convert.ToString(obj1).CompareTo(Convert.ToString(obj2)) < 0);
}
return result;
}
protected bool GreaterThan(object obj1, object obj2)
{
bool result = false;
Type objType = obj1.GetType();
if (this.IsInteger(objType))
{
result = (this.IsUnsignedInteger(objType) && this.IsUnsignedInteger(obj2.GetType())) ?
(Convert.ToUInt64(obj1) > Convert.ToUInt64(obj2)) :
(Convert.ToInt64(obj1) > Convert.ToInt64(obj2));
}
else if (this.IsFloatingPoint(objType))
{
result = (Convert.ToDouble(obj1) > Convert.ToDouble(obj2));
}
else if (this.IsDecimal(objType))
{
result = (Convert.ToDecimal(obj1) > Convert.ToDecimal(obj1));
}
else if (this.IsDateTime(objType))
{
result = (Convert.ToDateTime(obj1) > Convert.ToDateTime(obj2));
}
else if (this.IsString(objType))
{
result = (Convert.ToString(obj1).CompareTo(Convert.ToString(obj2)) > 0);
}
return result;
}
protected bool IsEqual(object obj1, object obj2)
{
bool result = false;
Type objType = obj1.GetType();
if (this.IsInteger(objType))
{
result = (this.IsUnsignedInteger(objType) && this.IsUnsignedInteger(obj2.GetType())) ?
(Convert.ToUInt64(obj1) == Convert.ToUInt64(obj2)) :
(Convert.ToInt64(obj1) == Convert.ToInt64(obj2));
}
else if (this.IsFloatingPoint(objType))
{
result = (Convert.ToDouble(obj1) == Convert.ToDouble(obj2));
}
else if (this.IsDecimal(objType))
{
result = (Convert.ToDecimal(obj1) == Convert.ToDecimal(obj1));
}
else if (this.IsDateTime(objType))
{
result = (Convert.ToDateTime(obj1) == Convert.ToDateTime(obj2));
}
else if (this.IsString(objType))
{
result = (Convert.ToString(obj1).CompareTo(Convert.ToString(obj2)) == 0);
}
return result;
}
protected bool IsNotEqual(object obj1, object obj2)
{
return (!this.IsEqual(obj1, obj2));
}
#endregion type detector methods
示例用法
为了测试我的属性,我写了下面的类。想法是,在我的模型中设置完属性后,我可以检查 IsValid
属性,以确保我可以将导入的对象保存到数据库。出于示例目的,我只设置了四个有属性修饰的属性。任何没有用属性修饰的都不包含在 IsValid
代码中。
首先看到的是实际上属于模型并用我们的属性修饰的属性(请参阅注释了解正在发生的事情的描述)。
// DateTime(JAN 01 2000).Ticks - to fulfill the "constant expression" requirement
// for attribute parameters.
public const long TRIGGER_DATE = 630822816000000000;
// just playing around
public const string TRIGGER_STRING = "ERROR";
/// <summary>
/// Get/set Prop1 (an integer). A value of -1 is invalid
/// </summary>
[InvalidValue(-1, InvalidValueAttribute.TriggerType.Valid)]
public int Prop1 { get; set; }
/// <summary>
/// Get/set Prop2 (a double). A value that is not between 5d and 10d (inclusive)
/// is invalid. Demonstrates the multiple instances of the attribute that can
/// be attached to a property.
/// </summary>
[InvalidValue(5d, InvalidValueAttribute.TriggerType.Under)]
[InvalidValue(10d, InvalidValueAttribute.TriggerType.Over)]
// To test the distinct attributes requirement, uncomment the next line and
// run the application.
//[InvalidValueAttribute(5d, InvalidValueAttribute.TriggerType.Under)]
public double Prop2 { get; set; }
/// <summary>
/// Get/set Prop3 (a string). A value of "ERROR" is invalid.
/// </summary>
[InvalidValue(TRIGGER_STRING, InvalidValueAttribute.TriggerType.Valid)]
public string Prop3 { get; set; }
// An attribute argument must be a constant expression, typeof expression or array
// creation expression of an attribute parameter type. This means that for a
// DateTime, you MUST determine the trigger value ahead of time and set a constant
// to the appropriate Ticks value (in this case a constant called TRIGGER_DATE).
// The attribute class will adapt to DateTime comparisons when appropriate.
[InvalidValue(TRIGGER_DATE, InvalidValueAttribute.TriggerType.Over, typeof(DateTime))]
public DateTime Prop4 { get; set; }
接下来,我们看到不属于实际模型但用于帮助示例应用程序显示结果的属性。
//////////////////////////
// These properties are not evaluated in the IsValid property code because they're
// not decorated with the InvalidValueAttribute attribute. They are simply used for
// controlling the console output or the validation code.
/// <summary>
/// Get the trigger date value as a DateTime (used to display TRIGGER_DATE constant
/// as a DateTime in the console output.
/// </summary>
public DateTime TriggerDate { get { return new DateTime(TRIGGER_DATE); } }
/// <summary>
/// Get/set the flag that indicates that the IsValid property should short-circuit
/// to false on the first invalid property detected (just a helper for the demo app).
/// If this is false, all of the invalid property error meessages will be appended to
/// the InvalidPropertyMessage string for display in the console.
/// </summary>
public bool ShortCircuitOnInvalid { get; set; }
/// <summary>
/// Get/set the InvalidPropertyMessage.
/// </summary>
public string InvalidPropertyMessage { get; private set; }
最后,我们来到大家在此的原因——IsValid
属性。此属性检索所有用 InvalidValueAttribute
属性修饰的属性,并处理每个属性的每个属性实例。当然,如果你不允许自己的自定义属性有多个实例,那么你可以跳过 foreach 代码,但对我的目的来说,我需要这样做。我还要求每个实例使用唯一的 Trigger
枚举器,因为对于我来说,拥有任何给定 Trigger
的多个实例都没有意义。
/// <summary>
/// Gets a flag indicating whether or not this object is valid. Validity is determined by
/// properties decorated with InvalidValueAttribute and their associated attribute
/// parameters. Only attributed properties are validated.
/// </summary>
public bool IsValid
{
get
{
// Reset the error message
this.InvalidPropertyMessage = string.Empty;
// Assume this object is valid
bool isValid = true;
// Get the properties for this object
PropertyInfo[] infos = this.GetType().GetProperties();
foreach(PropertyInfo info in infos)
{
// Get all of the InvalidValueAttribute attributes for the property.
// We do this because the attribute is configured to allow multiple
// instances to be applied to each property. This allows us to
// specify a more versatile array of error conditions that must all
// be valid for a given property.
var attribs = info.GetCustomAttributes(typeof(InvalidValueAttribute), true);
// if we have more than one attribute
if (attribs.Count() > 1)
{
// make sure they're all distinct (we don't want to allow more than one of
// each TriggerType)
var distinct = attribs.Select(x=>((InvalidValueAttribute)(x)).Trigger).Distinct();
// If the number of attributes found is not equal to the number of distinct
// attributes found, throw an exception.
if (attribs.Count() != distinct.Count())
{
throw new Exception(string.Format("{0} has at least one duplicate InvalidValueAttribute specified.", info.Name));
}
}
// Now we validate with each attribute in turn.
foreach(InvalidValueAttribute attrib in attribs)
{
// Get the property's value
object value = info.GetValue(this, null);
// See if the property is valid
bool propertyValid = attrib.IsValid(value);
// If it's not valid
if (!propertyValid)
{
// The object itself isn't valid
isValid = false;
// Create the error message for this property and add it to the objects
// error message string
this.InvalidPropertyMessage = string.Format("{0}\r\n{1}", this.InvalidPropertyMessage,
string.Format("{0} is invalid. {1}", info.Name, attrib.TriggerMsg));
// If we want to short circuit on the first error, break here.
if (this.ShortCircuitOnInvalid)
{
break;
}
}
}
}
// return the object's isValid status.
return isValid;
}
最后,该应用程序不过是一个控制台应用程序,它实例化示例类,并在修复无效属性时向控制台输出状态消息。发布代码真的没有意义,因为它只会使文章更长,毫无实际用途。
我沿途学到的
就像大多数编程工作一样,我沿途学到了一些小东西。以我的年纪,我大概第二天早餐就会忘记它们,但嘿,这就是让阿尔茨海默病/痴呆症发作如此有趣的原因之一。从积极的一面来看,我仍然记得培根,所以总有积极的一面……
你无法从这里到达那里
尽管我希望如此,但你无法引用你的属性所附加到的属性。这尤其不方便,因为我无法在枚举(在其父类中)并检索属性对象实例之前,获得对唯一 Trigger
方面的理解。
医生,我抬起胳膊时会痛……
当你向属性传递参数时,参数必须是常量表达式、typeof
表达式或数组创建表达式。这使得你无法指定除基本类型之外的任何内容,这些基本类型不需要使用 new
来实例化(例如 DateTime
、StringBuilder
等)。这个限制迫使我在写这篇文章时重新考虑构造函数。
我不确定这是否值得
这个练习产生了大量的代码,这些代码可能只在数据加载场景中有用,在这种场景下,你需要万无一失地确保加载/导入的数据符合特定的值限制。根据我读到的一切,属性不应该这样使用,但我的天性是在被告知“我不能这样做”或“我不应该那样做”时感到沮丧和反抗。接受这一点,承认我可能打破了一些看似随意的规则或违反了一个同样随意的最佳实践,所以不要觉得你需要告诉我。
文章历史
- 2018 年 2 月 25 日 - 修复了一些拼写错误,主要是因为我现在有更好的事情来打发时间。
- 2016 年 9 月 27 日 - 首次发布(可能立即跟着十几次微小的编辑,修复了在点击大橙色按钮之前未发现的拼写/语法错误)。