Before delving into details on how to program attributes, you should consider a use case that demonstrates its utility. In the CommandLineHandler example in Listing 14.3, you dynamically set a class's properties based on the command-line option matching the property name. This approach is insufficient, however, when the command-line option is an invalid property name. /?, for example, cannot be supported. Furthermore, this mechanism doesn't provide any way of identifying which options are required versus which are optional. Instead of relying on an exact match between the option name and the property name, attributes provide a way of identifying additional metadata about the decorated constructin this case, the option that the attribute decorates. With attributes, you can decorate a property as Requiredand provide a /? option alias. In other words, attributes are a means of associating additional data with a property (and other constructs). Attributes appear within square brackets preceding the construct they decorate. For example, you can modify the CommandLineInfo class to include attributes as shown in Listing 14.7. Listing 14.7. Decorating a Property with an Attribute
In Listing 14.7, the Help and Out properties are decorated with attributes. The purpose of these attributes is to allow an alias of /? for /Help, and to indicate that /Out is a required parameter. The idea is that from within the CommandLineHandler.TryParse() method, you enable support for option aliases and, assuming the parsing was successful, you can check that all the required switches were specified. There are two ways to combine attributes on the same construct. You can either separate the attributes with commas within the same square brackets, or place each attribute within its own square brackets, as shown in Listing 14.8. Listing 14.8. Decorating a Property with Multiple Attributes
In addition to decorating properties, developers can use attributes to decorate classes, interfaces, structs, enums, delegates, events, methods, constructors, fields, parameters, return values, assemblies, type parameters, and modules. For the majority of these, applying an attribute involves the same square bracket syntax shown in Listing 14.8. However, this syntax doesn't work for return values, assemblies, and modules. Assembly attributes are used to add additional metadata about the assembly. Visual Studio's Project Wizard, for example, generates an AssemblyInfo.cs file that includes a host of attributes about the assembly. Listing 14.9 is an example of such a file. Listing 14.9. Assembly Attributes within AssemblyInfo.cs
The assembly attributes define things like company, product, and assembly version number. Similar to assembly, identifying an attribute usage as module requires prefixing it with module. The restriction on assembly and module attributes is that they appear after the using directive but before any namespace or class declarations. Return attributes, such as the one shown in Listing 14.10, appear before a method declaration but use the same type of syntax structure. Listing 14.10. Specifying a Return Attribute
In addition to assembly and return, C# allows for explicit target identifications of module, class, and method, corresponding to attributes that decorate the module, class, and method. class and method, however, are optional, as demonstrated earlier. One of the conveniences of using attributes is that the language takes into consideration the attribute naming convention, which is to place Attribute at the end of the name. However, in all the attribute uses in the preceding listings, no such suffix appears, despite the fact that each attribute used follows the naming convention. This is because although the full name (DescriptionAttribute, AssemblyVersionAttribute, and so on) is allowed when applying an attribute, C# makes the suffix optional and generally, no such suffix appears when applying an attribute; it appears only when defining one or using the attribute inline (such as typeof(DescriptionAttribute)). Custom AttributesDefining a custom attribute is relatively trivial. Attributes are objects; therefore, to define an attribute, you need to define a class. The characteristic that turns a general class into an attribute is that it derives from System.Attribute. Therefore, you can create a CommandLineSwitchRequiredAttribute class, as shown in Listing 14.11. Listing 14.11. Defining a Custom Attribute
With that simple definition, you now can use the attribute as demonstrated in Listing 14.7. So far, no code responds to the attribute; therefore, the Out property that includes the attribute will have no effect on command-line parsing. Looking for AttributesIn addition to providing properties for reflecting on a type's members, Type includes methods to retrieve the Attributes decorating that type. Similarly, all the reflection types (PropertyInfo and MethodInfo, for example) include members for retrieving a list of attributes that decorate a type. Listing 14.12 defines a method to return a list of required switches that are missing from the command line. Listing 14.12. Retrieving a Custom Attribute
The code that checks for an attribute is relatively simple. Given a PropertyInfo object (obtained via reflection), you call GetCustomAttributes() and specify the attribute sought, followed by whether to check any overloaded methods. (Alternatively, you can call the GetCustomAttributes() method without the attribute type to return all of the attributes.) Although it is possible to place code for finding the CommandLineSwitchRequiredAttribute attribute within the CommandLineHandler's code directly, it makes for better object encapsulation to place the code within the CommandLineSwitchRequiredAttribute class itself. This is frequently the pattern for custom attributes. What better location to place code for finding an attribute than in a static method on the attribute class? Initializing an Attribute through a ConstructorThe call to GetCustomAttributes() returns an array of objects that will successfully cast to an Attribute array. However, since the attribute in this example didn't have any instance members, the only metadata information that it provided in the returned attribute was whether it appeared. Attributes can also encapsulate data, however. Listing 14.13 defines a CommandLineAliasAttribute attribute. This is another custom attribute, and it provides alias command-line options. For example, you can provide command-line support for /Help or /? as an abbreviation. Similarly, /S could provide an alias to /Subfolders that indicates that the command should traverse all the subdirectories. To support this, you need to provide a constructor on the attribute. Specifically, for the alias you need a constructor that takes a string argument. (Similarly, if you want to allow multiple aliases, you need to define an attribute that has a params string array for a parameter.) Listing 14.13. Providing an Attribute Constructor
The only restriction on the constructor is that when applying an attribute to a construct, only literal values and types (like typeof(int)) are allowed as arguments. This is to enable their serialization into the resulting CIL. It is not possible, therefore, to call a static method when applying an attribute; in addition, providing a constructor that takes arguments of type System.DateTime would be of little value, since there is no System.DateTime literal. Given the constructor call, the objects returned from PropertyInfo.GetCustomAttributes() will be initialized with the specified constructor arguments, as demonstrated in Listing 14.14. Listing 14.14. Retrieving a Specific Attribute and Checking Its Initialization
Furthermore, as Listing 14.15 and Listing 14.16 demonstrate, you can use similar code in a GetSwitches() method on CommandLineAliasAttribute that returns a dictionary collection of all the switches, including those from the property names, and associate each name with the corresponding attribute on the command-line object. Listing 14.15. Retrieving Custom Attribute Instances
Listing 14.16. Updating CommandLineHandler.TryParse() to Handle Aliases
System.AttributeUsageAttributeMost attributes are intended to decorate only particular constructs. For example, it makes no sense to allow CommandLineOptionAttribute to decorate a class or an assembly. Those contexts would be meaningless. To avoid inappropriate use of an attribute, custom attributes can be decorated with System.AttributeUsageAttribute. Listing 14.17 (for CommandLineOptionAttribute) demonstrates how to do this. Listing 14.17. Restricting the Constructs an Attribute Can Decorate
If the attribute is used inappropriately, as it is in Listing 14.18, it will cause a compile-time error, a characteristic unique to predefined attributes (see page 535), as Output 14.5 demonstrates. Listing 14.18. AttributeUsageAttribute Restricting Where to Apply an Attribute
Output 14.5.
AttributeUsageAttribute's constructor takes an AttributesTargets flag. This enum provides a list of all the possible targets that the runtime allows an attribute to decorate. For example, if you also allowed CommandLineSwitchAliasAttribute on a field, you would update the AttributeUsageAttribute application as shown in Listing 14.19. Listing 14.19. Limiting an Attribute's Usage with AttributeUsageAttribute
Named ParametersIn addition to restricting what an attribute can decorate, AttributeUsageAttribute provides a mechanism for allowing duplicates of the same attribute on a single construct. The syntax appears in Listing 14.20. Listing 14.20. Using a Named Parameter
The syntax is different from the constructor initialization syntax discussed earlier. The AllowMultiple parameter is a named parameter, which is a designation that is unique to attributes. Named parameters provide a mechanism for setting specific public properties and fields within the attribute constructor call, even though the constructor includes no corresponding parameters. The named attributes are optional designations, but they provide a means of setting additional instance data on the attribute without providing a constructor parameter for the purpose. In this case, AttributeUsageAttribute includes a public member called AllowMultiple. Therefore, you can set this member using a named parameter assignment when you use the attribute. Assigning named parameters must occur as the last portion of a constructor, following any explicitly declared constructor parameters. Named parameters allow for assigning attribute data without providing constructors for every conceivable combination of which attribute properties are specified and which are not. Since many of an attribute's properties may be optional, this is a useful construct in many cases.
Predefined AttributesThe AttributeUsageAttribute attribute has a special characteristic that you didn't see in the custom attributes you have created thus far in this book. This attribute affects the behavior of the compiler, causing the compiler to sometimes report an error. Unlike the reflection code you wrote earlier for retrieving CommandLineRequiredAttribute and CommandLineSwitchAliasAttribute, AttributeUsageAttribute has no runtime code; instead, it has built-in compiler support. AttributeUsageAttribute is a predefined attribute. Not only do such attributes provide additional metadata about the constructs they decorate, but also the runtime and compiler behave differently in order to facilitate these attributes' functionality. Attributes like AttributeUsageAttribute, FlagsAttribute, ObsoleteAttribute, and ConditionalAttribute are examples of predefined attributes. They include special behavior that only the CLI provider or compiler can offer because there are no extension points for additional noncustom attributes. In contrast, custom attributes are entirely passive. Listing 14.21 includes a couple of predefined attributes; Chapter 15 includes a few more. System.ConditionalAttributeWithin a single assembly, the System.Diagnostics.ConditionalAttribute attribute behaves a little like the #if/#endif preprocessor identifier. However, instead of eliminating the CIL code from the assembly, System.Diagnostics.ConditionalAttribute will optionally cause the call to behave like a no-op, an instruction that does nothing. Listing 14.22 demonstrates the concept, and Output 14.7 shows the results. Listing 14.22. Using Reflection with Generic Types
Output 14.7.
This example defined CONDITION_A, so MethodA() executed normally. CONDITION_B, however, was not defined through either #define or by using the csc.exe /Define option. As a result, all calls to Program.MethodB() from within this assembly will do nothing and don't even appear in the code. Functionally, ConditionalAttribute is similar to placing a #if/#endif around the method invocation. The syntax is cleaner, however, because developers create the effect by adding the ConditionalAttribute attribute to the target method without making any changes to the caller itself. Note that the C# compiler notices the attribute on a called method during compilation, and assuming the preprocessor identifier exists, it eliminates any calls to the method. Note also that ConditionalAttibute does not affect the compiled CIL code on the target method itself (besides the addition of the attribute metadata). Instead, it affects the call site during compilation by removing the calls. This further distinguishes ConditionalAttribute from #if/#endif when calling across assemblies. Because the decorated method is still compiled and included in the target assembly, the determination of whether to call a method is based not on the preprocessor identifier in the callee's assembly, but rather, on the caller's assembly. In other words, if you create a second assembly that defines CONDITION_B, any calls to Program.MethodB() from the second assembly will execute. This is a useful characteristic in many tracing and testing scenarios. In fact, calls to System.Diagnostics.Trace and System.Diagnostics.Debug use this trait with ConditionalAttributes on trACE and DEBUG preprocessor identifiers. Because methods don't execute whenever the preprocessor identifier is not defined, ConditionalAttribute may not be used on methods that include an out parameter or specify a return other than void. Doing so causes a compile-time error. This makes sense because possibly none of the code within the decorated method will execute, so it is unknown what to return to the caller. Similarly, properties cannot be decorated with ConditionalAttribute. The AttributeUsage (see the section titled System.AttributeUsageAttribute, earlier in this chapter) for ConditionalAttribute is AttributeTargets.Class (starting in .NET 2.0) and AttributeTargets.Method. This allows the attribute to be used on either a method or a class. However, the class usage is special because ConditionalAttribute is allowed only on System.Attribute-derived classes. When ConditionalAttribute decorates a custom attribute, a feature started in .NET 2.0, the latter can be retrieved via reflection only if the conditional string is defined in the calling assembly. Without such a conditional string, reflection that looks for the custom attribute will fail to find it. System.ObsoleteAttributeAs mentioned earlier, predefined attributes affect the compiler's and/or the runtime's behavior. ObsoleteAttribute provides another example of attributes affecting the compiler's behavior. The purpose of ObsoleteAttribute is to help with the versioning of code, providing a means of indicating to callers that a particular member is no longer current. Listing 14.23 is an example of ObsoleteAttribute usage. As Output 14.8 shows, any callers that compile code that invokes a member marked with ObsoleteAttribute will cause a compile-time warning, optionally an error. Listing 14.23. Using ObsoleteAttribute
Output 14.8.
In this case, ObsoleteAttribute simply displays a warning. However, there are two additional constructors on the attribute. One of them, ObsoleteAttribute(string message), appends the additional message argument to the compiler's obsolete message. The second, however, is a bool error parameter that forces the warning to be recorded as an error instead. ObsoleteAttribute allows third parties to notify developers of deprecated APIs. The warning (not an error) allows the original API to work until the developer is able to update the calling code. Serialization-Related AttributesUsing predefined attributes, the framework supports the capacity to serialize objects onto a stream so that they can be deserialized back into objects at a later time. This provides a means of easily saving a document type object to disk before shutting down an application. Later on, the document may be deserialized so that the user can continue to work on it. In spite of the fact that an object can be relatively complex and include links to many other types of objects that also need to be serialized the serialization framework is easy to use. In order for an object to be serializable, the only requirement is that it includes a System.SerializableAttribute. Given the attribute, a formatter class reflects over the serializable object and copies it into a stream (see Listing 14.24). Listing 14.24. Saving a Document Using System.SerializableAttribute
Output 14.9 shows the results of Listing 14.24. Output 14.9.
Listing 14.24 serializes and deserializes a Document object. Serialization involves instantiating a formatter (this example uses System.Runtime .Serialization.Formatters.Binary.BinaryFormatter) and calling Serialization() with the appropriate stream object. Deserializing the object simply involves a call to the formatter's Deserialize() method, specifying the stream that contains the serialized object as an argument. However, since the return from Deserialize() is of type object, you also need to cast it specifically to the type that was serialized. Notice that serialization occurs for the entire object graph (all the items associated with the serialized object [Document] via a field). Therefore, all fields in the object graph also must be serializable. System.NonSerializable. Fields that are not serializable should be decorated with the System.NonSerializable attribute. This tells the serialization framework to ignore them. The same attribute should appear on fields that should not be persisted for use case reasons. Passwords and Windows handles are good examples of fields that should not be serialized: Windows handles because they change each time a window is re-created and passwords because data serialized into a stream is not encrypted and can easily be accessed. Consider the Notepad view of the serialized document in Figure 14.2. Figure 14.2. BinaryFormatter Does Not Encrypt DataListing 14.24 set the Title field and the resulting *.BIN file includes the text in plain view. Providing Custom Serialization. One way to add encryption is to provide custom serialization. Ignoring the complexities of encrypting and decrypting, this requires implementing the ISerializable interface in addition to using SerializableAttribute. The interface only requires the GetObjectData() method to be implemented. However, this is sufficient only for serialization. In order to also support deserialization, it is necessary to provide a constructor that takes parameters of type System.Runtime.Serialization.SerializationInfo and System.Runtime.Serialization.StreamingContext (see Listing 14.25). Listing 14.25. Implementing System.Runtime.Serialization.ISerializable
Essentially, the System.Runtime.Serialization.SerializationInfo object is a collection of name/value pairs. When serializing, the GetObject() implementation calls AddValue(). To reverse the process, you call one of the Get*() members. In this case, you encrypt and decrypt prior to serialization and deserialization, respectively. Versioning the Serialization. One more serialization point deserves mentioning: versioning. Objects such as documents may be serialized using one version of an assembly and deserialized using a newer version, sometimes the reverse. Without paying attention, however, version incompatibilities can easily be introduced, sometimes unexpectedly. Consider the scenario shown in Table 14.1.
Surprisingly, even though all you did was to add a new field, deserializing the original file throws a System.Runtime.Serialization.SerializationException. This is because the formatter looks for data corresponding to the new field within the stream. Failure to locate such data throws an exception. To avoid this, the 2.0 framework includes a System.Runtime.Serialization.OptionalFieldAttribute. When you require backward compatibility, you must decorate serialized fieldseven private oneswith OptionalFieldAttribute (unless, of course, a latter version begins to require it). Unfortunately, System.Runtime.Serialization.OptionalFieldAttribute is not supported in the earlier framework version. Instead, it is necessary to implement ISerializable, just as you did for encryption, saving and retrieving only the fields that are available. Assuming the addition of the Author field, for example, the implementation shown in Listing 14.26 is required for backward-compatibility support prior to the 2.0 framework: Listing 14.26. Backward Compatibility Prior to the 2.0 Framework
Serializing in GetObjectData() simply involves serializing all fields (assume here that version 1 does not need to open documents from version 2). On deserialization, however, you can't simply call GetString("Author") because if no such entry exists, it will throw an exception. Instead, iterate through all the entries that are in info and retrieve them individually.
|