- 9.6 LINQ Support
- 9.7 Optional Feature Pattern
- 9.8 Simulating Covariance
- 9.9 Template Method
- 9.10 Timeouts
- 9.11 XAML Readable Types
- 9.12 And in the End...
9.7 Optional Feature Pattern
When designing an abstraction, you might want to allow cases in which some implementations of the abstraction support a feature or a behavior, whereas other implementations do not. For example, stream implementations can support reading, writing, seeking, or any combination thereof.
One way to model these requirements is to provide a base class with APIs for all nonoptional features and a set of interfaces for the optional features. The interfaces are implemented only if the feature is actually supported by a concrete implementation. The following example shows one of many ways to model the stream abstraction using such an approach.
// framework APIs public abstract class Stream { public abstract void Close(); public abstract int Position { get; } } public interface IInputStream { byte[] Read(int numberOfBytes); } public interface IOutputStream { void Write(byte[] bytes); } public interface ISeekableStream { void Seek(int position); } public interface IFiniteStream { int Length { get; } bool EndOfStream { get; } } // concrete stream public class FileStream : Stream, IOutputStream, IInputStream, ISeekableStream, IFiniteStream { ... } // usage void OverwriteAt(IOutputStream stream, int position, byte[] bytes){ // do dynamic cast to see if the stream is seekable ISeekableStream seekable = stream as ISeekableStream; if(seekable==null){ throw new NotSupportedException(...); } seekable.Seek(position); stream.Write(bytes); }
You will notice the .NET Framework's System.IO namespace does not follow this model, and with good reason. Such factored design requires adding many types to the framework, which increases general complexity. Also, using optional features exposed through interfaces often requires dynamic casts, and that in turn results in usability problems.
Sometimes the benefits of factored design are worth the drawbacks, but often they are not. It is easy to overestimate the benefits and underestimate the drawbacks. For example, the factorization did not help the developer who wrote the OverwriteAt method avoid runtime exceptions (the main reason for factorization). It is our experience that many designs incorrectly err on the side of too much factorization.
The Optional Feature Pattern provides an alternative to excessive factorization. It has drawbacks of its own but should be considered as an alternative to the factored design described previously. The pattern provides a mechanism for discovering whether the particular instance supports a feature through a query API and uses the features by accessing optionally supported members directly through the base abstraction.
// framework APIs public abstract class Stream { public abstract void Close(); public abstract int Position { get; } public virtual bool CanWrite { get { return false; } } public virtual void Write(byte[] bytes){ throw new NotSupportedException(...); } public virtual bool CanSeek { get { return false; } } public virtual void Seek(int position){ throw new NotSupportedException(...); } ... // other options } // concrete stream public class FileStream : Stream { public override bool CanSeek { get { return true; } } public override void Seek(int position) { ... } ... } // usage void OverwriteAt(Stream stream, int position, byte[] bytes){ if(!stream.CanSeek || !stream.CanWrite){ throw new NotSupportedException(...); } stream.Seek(position); stream.Write(bytes); }
In fact, the System.IO.Stream class uses this design approach. Some abstractions might choose to use a combination of factoring and the Optional Feature Pattern. For example, the Framework collection interfaces are factored into indexable and nonindexable collections (IList<T> and ICollection<T>), but they use the Optional Feature Pattern to differentiate between read-only and read-write collections (ICollection<T>.IsReadOnly property).
CONSIDER using the Optional Feature Pattern for optional features in abstractions.
The pattern minimizes the complexity of the framework and improves usability by making dynamic casts unnecessary.
DO provide a simple Boolean property that clients can use to determine whether an optional feature is supported.
public abstract class Stream { public virtual bool CanSeek { get { return false; } } public virtual void Seek(int position){ ... } }
Code that consumes the abstract base class can query this property at runtime to determine whether it can use the optional feature.
if(stream.CanSeek){ stream.Seek(position); }
DO use virtual methods on the base class that throw NotSupportedException to define optional features.
public abstract class Stream { public virtual bool CanSeek { get { return false; } } public virtual void Seek(int position){ throw new NotSupportedException(...); } }
The method can be overridden by subclasses to provide support for the optional feature. The exception should clearly communicate to the user that the feature is optional and which property the user should query to determine if the feature is supported.