Type Design Guidelines for Reusable .NET Libraries
The .Interop Subnamespace
Many frameworks need to support interoperability with legacy components. Due diligence should be used in designing interoperability from the ground up. However, the nature the problem often requires that the shape and style of such interoperability APIs is often quite different from good managed framework design. Thus, it makes sense to put functionality related to interoperation with legacy components in a subnamespace.
You should not put types that completely abstract unmanaged concepts and expose them as managed into the Interop subnamespace. It is often the case that managed APIs are implemented by calling out to unmanaged code. For example the System.IO.FileStream class calls out to Win32 CreateFile. This is perfectly acceptable and does not imply that the FileStream class needs to be in System.IO.Interop namespace as FileStream completely abstracts the Win32 concepts and publicly exposes a nice managed abstraction.
DO use a namespace with the .Interop suffix to contain types that
provide interop functionality for a base namespace.
DO use a namespace with the .Interop suffix for all code in a Primary
Interop Assembly (PIA).
Choosing Between Class and Struct
One of the basic design decisions every framework designer faces is whether to design a type as a class (a reference type) or as a struct (a value type). Good understanding of the differences in the behavior of reference types and value types is crucial in making this choice.
Reference types are allocated on the heap, and garbage-collected, whereas value types are allocated either on the stack or inline in containing types and deallocated when the stack unwinds or when their containing type gets deallocated. Therefore, allocations and deallocations of value types are in general cheaper than allocations and deallocations of reference types.
Arrays of reference types are allocated out-of-line, meaning the array elements are just references to instances of the reference type residing on the heap. Value type arrays are allocated in-line, meaning that the array elements are the actual instances of the value type. Therefore, allocations and deallocations of value type arrays are much cheaper than allocations and deallocations of reference type arrays. In addition, in a majority of cases value type arrays exhibit much better locality of reference.
|
Value types get boxed when cast to object or one of the interfaces they implement. They get unboxed when cast back to the value type. Because boxes are objects that are allocated on the heap and are garbage collected, too much boxing and unboxing can have a negative impact on the heap, the garbage collector, and ultimately the performance of the application.
Reference type assignments copy the reference, whereas value type assignments copy the entire value. Therefore assignments of large reference types are cheaper than assignments of large value types.
Finally, reference types are passed by reference, whereas value types are passed by value. Changes to an instance of a reference type affect all references pointing to the instance. Value type instances are copied when they are passed by value. When an instance of a value type is changed, it of course does not affect any of its copies. Because the copies are not created explicitly by the user, but rather implicitly when arguments are passed or return values are returned, value types that can be changed can be confusing to many users. Therefore value types should be immutable. (1)
|
As a rule of thumb, majority of types in a framework should be classes. There are, however, some situations in which the characteristics of a value type make it more appropriate to use structs.
CONSIDER defining a struct instead of a class if instances of the type are
small and commonly short-lived or are commonly embedded in other
objects.
DO NOT define a struct unless the type has all of the following characteristics:
- It logically represents a single value, similar to primitive types (int, double, etc.).
- It has an instance size under 16 bytes.
- It is immutable.
- It will not have to be boxed frequently.
In all other cases, you should define your types as classes.
|
Choosing Between Class and Interface
In general, classes are the preferred construct for exposing abstractions.
The main drawback of interfaces is that they are much less flexible than classes when it comes to allowing for evolution of APIs. Once you ship an interface, the set of its members is fixed forever. Any additions to the interface would break existing types implementing the interface.
A class offers much more flexibility. You can add members to classes that have already shipped. As long as the method is not abstract (i.e., as long as you provide a default implementation of the method), any existing derived classes continue to function unchanged.
Let's illustrate the concept with a real example from the .NET Framework. The System.IO.Stream abstract class shipped in version 1.0 of the framework without any support for timing out pending I/O operations. In version 2.0, several members were added to Stream to allow subclasses to support timeout-related operations, even when accessed through their base class APIs.
public abstract class Stream {
public virtual bool CanTimeout {
get { return false; }
}
public virtual int ReadTimeout{
get{
throw new NotSupportedException(
);
{
set {
throw new NotSupportedException(
);
}
}
}
public class FileStream : Stream {
public override bool CanTimeout {
get { return true; }
}
public override int ReadTimeout{
get{
{
set {
}
}
}
The only way to evolve interface-based APIs is to add a new interface with the additional members. This might seem like a good option, but it suffers from several problems. Let's illustrate this on a hypothetical IStream interface. Let's assume we had shipped the following APIs in version 1.0 of the Framework.
public interface IStream {
}
public class FileStream : IStream {
}
If we wanted to add support for timeouts to streams in version 2.0, we would have to do something like the following:
public interface ITimeoutEnabledStream : IStream {
int ReadTimeout{ get; set; }
}
public class FileStream : ITimeoutEnabledStream {
public int ReadTimeout{
get{
{
set {
}
}
}
But now we would have a problem with all the existing APIs that consume and return IStream. For example StreamReader has several constructor overloads and a property typed as Stream.
public calss StreamReader {
public StreamReader(IStream stream){
}
public IStream BaseStream { get {
} }
}
How would we add support for ITimeoutEnabledStream to Stream- Reader? We would have several options, each with substantial development cost and usability issues:
Leave the StreamReader as is, and ask users who want to access the timeout-related APIs on the instance returned from BaseStream property to use a dynamic cast and query for the ITimeoutEnabledStream interface.
StreamReader reader = GetSomeReader();
ITimeoutEnabledStream stream = reader.BaseStream as
ITimeoutEnabledStream;
if(stream != null){
stream.ReadTimeout = 100;
}
This option unfortunately does not perform well in usability studies. The fact that some streams can now support the new operations is not immediately visible to the users of StreamReader APIs. Also, some developers have difficulties understanding and using dynamic casts.
Add a new property to StreamReader that would return ITimeout- EnabledStream if one was passed to the constructor or null if IStream was passed.
StreamReader reader = GetSomeReader();
ITimeoutEnabledStream stream = reader.TimeoutEnabledBaseStream;
if(stream!= null){
stream.ReadTimeout = 100;
}
Such APIs are only marginally better in terms of usability. It's really not
obvious to the user that the TimeoutEnabledBaseStream property getter
might return null, which results in confusing and often unexpected Null-
ReferenceExceptions.
1. Immutable types are types that don't have any public members that can modify this instance. For example, System.String is immutable. Its members, such as ToUpper, do not modify the sting on which they are called, but rather return a new modified string and leave the original string unchanged.

RICO MARIANI The preceding is often true but it's a very broad generalization
that I would be very careful about. Whether or not you get better
locality of reference when value types get boxed when cast to an array of
value types will depend on how much of the value type you use, how much
searching you have to do, how much data reuse there could have been with
equivalent array members (sharing a pointer), the typical array access patterns,
and probably other factors I can't think of at the moment. Your mileage
might vary but value type arrays are a great tool for your toolbox.