On the practical level, I side with Greg Burghardt's answer, because it appears to me that network protocols are designed to take this decision out of the hand of application developers. Compression and encryption tend to be taken care of by the transport layer, hence names such as "Transport Layer Security (TLS)", for real reasons such as ensuring that neither compression nor information security would be compromised.
My original answer below focuses on the object-oriented design issue, and is intended to deal with a generic stream of (possibly encoded) byte data, e.g. data represented by a Stream
, in the context of matching that data stream with one or more file stream transformers or decoders that will work together to decode the file.
Your question highlights a tension (struggle) between two approaches:
(1) the code behavior should be driven by wrapping the data class with a type such as EncryptedFileSource
(as opposed to an ordinary IDataSource
).
(2) the code behavior should be driven by observing the data that can be read off on the instance, for example, extracting the "magic bytes" from the beginning of the data stream.
As far as file handling is concerned (where the set of file formats is an open set, i.e. unconstrained), the second approach tend to produce the correct behavior needed by the application. Once the correct code behavior is picked using the second approach, it can be indicated using the type system via approach one.
To understand why this is the case, let's consider the situation where a data source is unencrypted, but the programmer somehow wraps that data source within an EncryptedFileSource
. Should the program try to run the decryption code on it?
To prevent misuse of the first approach, it is important that the responsibility for analyzing the actual object content (approach 2) and creating the object wrappers (approach 1) be combined into one design pattern, and the implementation of that design pattern needs to be well-tested. The end result is a Decorator Factory, already mentioned in amon's answer.
The Decorator Factory would be given a data source, examine the first few bytes and extract the magic number, determine the type of Decorator needed to "unwrap" it, and instantiate that Decorator. The Decorator Factory may need to repeat the process until the end result is fully decoded. For example, if the data is compressed and then encrypted, it may need to instantiate a first Decorator to decrypt, and then instantiate a second Decorator to decompress.
It just happens that, when we solve the problem with the Decorator Factory, that we will not need to perform any downcasting (type-checking against concrete sub-types), which is widely considered as a code smell (a reason for concern, or an undesirable trait).
However, some of the solutions to the downcasting code-smell are inappropriate.
In particular, as far as file format handling and transformation is concerned, it would not be appropriate to add an IsEncrypted
flag to the abstract interface IDataSource
.
To explain why it is unnecessary and inappropriate to add flags to the abstract interface to solve a situation where Decorator is more appropriate, I will borrow an example from the Decorator chapter of the book, Head First Design Patterns.
Consider the abstract ICoffee
interface. Demanding that an ICoffee
interface be telling (as in "tell, don't ask") requires us to have all of these as properties on ICoffee
: GramsOfCocoa
, PacketsOfSplenda
, MeasuresOfAgaveSyrup
, MeasuresOfCaneSyrup
, NumEspressoShots
, WithIce
, Blended
, Microwaved
, MeasuresOfMintSyrup
, and so on. The potentially unbounded nature of add-on attributes would make it undesirable to tell such information on the parent abstract interface.
In practice, "Tell, don't ask" is usually preempted by yet another adage: "Show, don't tell".
In the case of handling compressed and encrypted files, to show is to perform the decompression and decryption on behalf of the consumer of the data, so that the consumer of the data need not be concerned about the work of decompressing or decrypting. This is what Decorator pattern is best for.
In contrast to the Decorator example from the Head First Design Patterns chapter, where the application hard-codes the construction of a "Russian-doll" of multiple Decorator instances around a base Coffee
class, the Decorator Factory pattern is needed for the file handling scenario, because the construction of the Decorator instances is driven by the data, which can only be examined at runtime (while the application is running).
Even though it may be inappropriate to expose a descriptor for unbounded combinations on the abstract interface, it might still be useful sometimes. However, keep in mind that the descriptor might be incomplete, that there are certain combinations of data that cannot be fully described by such a descriptor. For example, a data stream that is compressed twice; or encrypted twice; or encrypted and then compressed (despite a programmer's common sense).
Rest assured that these quirky data sources can still be handled correctly by a Decorator Factory. The consumer will still get to the final data after the processing of multiple Decorators, even if the handling is too strange for the descriptor to represent.
In terms of file handling, there is one example where a descriptor will be appropriate even if it is theoretically incomplete: a MIME type. One can always concoct some combination of file format or data transformation that do not have a corresponding MIME type. MIME type resolves this incompleteness issue by disallowing such creation. Instead, an application is programmed to support a known, finite set of MIME types, and anything that isn't within that set don't need to be supported by the application.
Going back to the Open-Closed Principle. What problem was it intended to address?
- Making sure that, when new situations arise, one can extend the behavior of the existing system by writing new classes and plugging them into the existing system, without having to modify the code of any objects (classes) already in the existing system.
- Making sure that, when we extend the system, the outcome is not fragile overall, such as ensuring that every combination of situations that may arise, every code path that is actually possible, are handled correctly.
Downcasting is often said to be the ultimate code smell that predicts whether a system will become fragile after extension or modification. Moreover, the following code sample captures the problem succinctly:
switch (fruit)
{
case Genus.Citrus citrus:
return new Peelers.CitrusPeeler(citrus);
case Genus.Vitis vitis:
return new Peelers.VitisPeeler(vitis);
default:
// you may replace this line with return null,
// but it doesn't resolve the problem.
throw new NotSupportedException("Unknown fruit.");
}
The indictment of violating OCP in the code example is based on the failure to handle new additions to the types of objects handled without having to modify the code above. Passing new object types to the code above leads to the exception being thrown, or worse, silently ignored.
Techniques that remove the need for downcasting removes that smell, but do not always resolve an OCP issue.
At a library or framework level, for a task such as file handling, the OCP issue is not resolved unless the library or framework implements a Plugin system allowing its users to register new handlers for detecting and decoding new file types. It's the only solution compliant with OCP for this task.
The Plugin system consists of a Decorator factory that allows new Decorator classes to be registered, each with their unique file header (magic number) detection. This is how most image file format handlers work.
Removing the downcasting by blindly applying "Tell, don't Ask" simply shifts the OCP issue elsewhere.
There are a number of do's and dont's when applying the Decorator Factory pattern to file handling. It would be too long to cover these on this question. Hopefully this answer will give a head start.
Streams coming from a network connection, decryption/encryption algorithm, or decompression/compression algorithm are very likely to be non-rewindable. This means, for example, after reading the first few bytes for the file header, the stream cannot be reset in order to be used in the actual file processing. This means the framework must insert a buffered or spooled stream (as a Decorator) in between any two steps where a rewind operation is needed but not natively supported. All Stream
objects must correctly indicate the rewind ability (via Stream.CanSeek
). If a particular library implementation of Stream
is lying, it is the framework's responsibility to immediately wrap such non-compliant, lie-telling stream with: either a lightweight Stream
that doesn't add any behavior except returning Stream.CanSeek => false
, or a properly buffered or spooled Stream
around the original.
Note that buffering or spooling the stream data has data security implications. For example, if the stream is big (more than a few megabytes) and uses the disk temporarily, care needs to be taken if the data need to be secured from theft if there are untrusted applications running on the same computer that can steal part of the data from the disk. (This is an issue for servers only; not on end-user computers.)