The Liskov Substitution Principle (LSP) is an object-oriented design principle that puts some restrictions on the classes that inherit other classes or implement some interfaces. It is one of the five SOLID principles that aim to make the code easier to maintain and extend in the future.
In simple terms, LSP says that derived classes should keep promises made by base classes. This also applies to interfaces, and it means that classes that implement some interface, should keep the promises made by that interface.
This article is published from the DNC Magazine for .NET Developers and Architects . Download this magazine from here [Zip PDF] or Subscribe to this magazine for FREE and download all previous and current editions
In more formal terms, this is called a contract. And in order not to violate LSP, classes must make sure that they abide by the contract (that they have with their consumers) which they inherited from the base class or the interface.
One definition of LSP is that “Subtypes must be substitutable for their base types”. It is not clear though what “substitutable” means exactly.
Some people say that this means that if some class, say ClassA, depends on IService; then you should be able to inject any class that implements IService into ClassA without breaking the application. And if this is not the case, i.e. one of the implementations of IService would break the application if it was injected into ClassA, then they would consider that this implementation is not actually an “IService” and thus they would create a new interface for it.
In this article, I am going to argue that this is not the case (or at least that it shouldn’t be the case), and that we should look at LSP in terms of contracts only.
Although the examples I provide in this article are for interfaces, a similar discussion can be made for base classes.
Liskov Substitution Principle – An example
Let’s start with an example. Let’s say we have a web site that allows users to back up their files by uploading them to the server. It allows them also to download the files later if they want to. Let’s assume that the requirements state that textual files should be saved to a database so that their content can be indexed and later queried. Other files should be saved to the file system. Now let’s say that we decided to design such system like this:
The following figure is a UML diagram for the involved types:
And the following graph shows the object graph that is composed in the Composition Root :
And here is how it is composed in code (with the C# language):
var fileManagementController = new FileManagementController( new FileStoreRouter( fileStoreForNonTextualFiles: new FileSystemStore(folderPath), fileStoreForTextualFiles: new DatabaseStore(connectionString)));
The FileManagementController class is an ASP.NET controller. It receives requests to upload files via the UploadFile() method. The FileManagementController has a dependency on IFileStore (via constructor injection) and it simply uses such dependency to store the file (by invoking the StoreFile method). The FileManagementController does not care how the file store works. As far as it is concerned, its job ends by giving the file to the IFileStore dependency. In the Composition Root, the FileManagementController is injected with a FileStoreRouter. This class receives two IFileStore implementations in the constructor; fileStoreForTextualFiles and fileStoreForNonTextualFiles. The responsibility of this class is to decide whether the file is a textual file and to route method invocations to the correct IFileStore.
The FileManagementController class also receives requests to download files that were previously uploaded, this is done through the DownloadFile() method.
In the Composition Root, into FileStoreRouter, an instance of FileSystemStore is injected for fileStoreForNonTextualFiles, and an instance of DatabaseStore is injected for fileStoreForTextualFiles.
Now, it is clear that if we swap FileSystemStore and DatabaseStore, i.e., we inject FileSystemStore for fileStoreForTextualFiles and DatabaseStore for fileStoreForNonTextualFiles, we break the application because the requirements state that textual files should go to the database, not the file system.
Now, the question is: do these classes, i.e., FileSystemStore and DatabaseStore violate the Liskov Substitution Principle? Should we create IFileSystemStore and IDatabaseStore and make FileStoreRouter depend on these two interfaces instead?
I am arguing that the FileSystemStore and DatabaseStore classes do not violate LSP.
Let’s think about the IFileStore contract.
The contract of the IFileStore interface has not been formally defined, so let’s try to define it here based on what makes sense.
The implementer of the interface is obliged to:
- Receive the file upon the invocation of StoreFile and store it somewhere, it doesn’t matter where.
- Return the file content later if the GetFileContent method was invoked given a name of a file that was stored previously.
- In case the file does not exist, the GetFileContent method is expected to throw a FileNotFoundException exception.
- Accept a filename that has a length that is less or equal to 250 characters and that contains alphanumeric characters or the dot character.
- Accept a file content that is of size less or equal to 10MB.
The consumer of the interface is obliged to:
- Not pass a file name whose length exceeds 250 characters or that contains characters that are not alphanumeric and that are not the dot character.
- Not pass null values.
That’s it. That is our definition of the IFileStore contract.
An implementation of the IFileStore interface violates the LSP if it violates any of the conditions of the contract. For example, if one implementation does not accept an alphanumeric filename that has a length of 200 characters, then it violates LSP.
Simple and Complex Contracts
Some contracts have more conditions than others in terms of number and complexity. For example, some contract conditions require that we invoke methods in a certain order. This is called temporal coupling . Others might require that the parameters that we pass have certain constraints. The contract for IFileStore has such a condition. Other conditions require that we ask the dependency some question before we invoke some method to see it we can invoke it. An example of such contract is the contract for the ICollection<T> interface in the .NET framework; it contains a property called IsReadOnly that we can use to determine if we are allowed to invoke methods that change the content of the collection.
It follows logically that the more conditions a contract has in terms of number and complexity, the easier it is for an implementer of the contract interface to violate LSP. Simply put, there would be more ways in which it could violate LSP.
The Single Responsibility Principle (SRP) and the Interface Segregation Principle (ISP)
Two other principles of SOLID design are the SRP and the ISP. The SRP simply states that a class should do one thing only and thus has only one reason to change. ISP states that a client should not be forced to depend on methods that it does not use.
If we apply the ISP, the result would probably be more interfaces that contain lesser methods. And if we apply the SRP, the result would be more classes that contain lesser methods. In summary, applying these two principles will make our contracts simpler (since the interfaces are smaller) and will generate more classes that each abide to a simpler contract. So, applying these principle makes it easier for us not to violate the LSP.
We can apply these principles to the classes and interfaces in the example above. In our example, the ISP is not violated because the FileManagementController depends on both of the methods of IFileStore. However, we could argue that the SRP is violated by having a single controller process both upload and download requests. We can split it into two controllers; UploadController and DownloadController. Now, each one of these controllers require only a single method from the IFileStore interface. Now, they do violate the ISP.
To not violate the ISP, we can split the IFileStore interface into two interfaces: IFileStoreWriter and IFileStoreReader. We also split each class into two; one for reading and one for writing. Here is how the types would look like:
Please note that this UML diagram does not show all of the types that are related to the IFileStoreReader interface for reasons of brevity. These skipped types are the FileStoreReaderRouter, FileSystemStoreReader and DatabaseStoreReader classes.
The original contract stated that the GetFileContent() method should return the content of a file that was previously stored using the StoreFile() method. Now, after we split it into two contracts, no individual class or interface has such responsibility since no class or interface has the two methods together. Where did this responsibility go?
The Composition Root
Applying these SOLID principles, we are making our individual classes and interfaces simpler. Instead of having few big classes that have big responsibilities and know much about the system, we are having small and simple classes that have smaller responsibilities and that know less about the system. We are having more contracts that each have lesser conditions.
Where is this responsibility going? Where is this knowledge of the system going?
To the Composition Root.
The Composition Root is the place in an application where we wire all our classes together to create the object graph that constitutes the application. After we apply the SRP and the ISP, the responsibility of the Composition Root is much higher.
Before applying these principles, the Composition Root had only a few big puzzle pieces to put together. Now, it has a lot of smaller puzzle pieces that it needs to put together.
In our example, the Composition Root is responsible for making sure that the IFileStoreReader implementation that is injected into the DownloadController, can return the content of files uploaded by using the IFileStoreWriter implementation injected into the UploadController.
Before splitting IFileStore into these two interfaces, the Composition Root had no such responsibility. The IFileStore implementation had this responsibility then.
Although it is harder to break the LSP now, we can still break the application by composing our object graph in an incorrect way. E.g., by injecting some implementation of some interface into the wrong place.
But now, the responsibility of not breaking the application is where it should be; the entity that constitutes the application, i.e., the Composition Root.
To summarize the responsibilities:
- Individual classes have the responsibility to abide by the implementer part of the contracts they implement (usually a single contract if we apply the SOLID principles)
- Individual classes have the responsibility to abide by the consumer part of the contracts of the dependencies that they have.
- The Composition Root has the responsibility of creating the individual objects and wiring them correctly so that the application does what it is supposed to do. This is not an easy thing to do because individual classes are highly composable and we can easily compose them in a way that does not match the application’s requirement.
The Null Object Pattern
One pattern that is related to the argument of this article is the Null Object Pattern. With this pattern, we create an implementation of a specific interface that actually does nothing. There are many uses for such a pattern. For example, sometimes when we want to modify the application to turn off a specific feature. Instead of changing the code in the consuming class, we simply inject a Null Object to it.
For example, in the case of our IFileStoreWriter, we could create a NullFileStoreWriter that does nothing when the StoreFile method is invoked.
Imagine what would happen if we inject this implementation into every place where IFileStoreWriter is expected. This will most probably break the application. Does the Null Object Pattern violate LSP?
When we apply SOLID principles, the SRP and the ISP in particular, we get a large number of classes and contracts. Such configuration means that we have a set of highly composable components that we initially compose in a particular way but that can be easily composed in a different way to meet new requirements.
Such composition happens in the Composition Root. In this article, I argued that the Composition Root has a bigger responsibility of not breaking the application and thus individual classes need only care about not violating the contracts they deal with in order not to violate the LSP.
转载本站任何文章请注明：转载至神刀安全网，谢谢神刀安全网 » Liskov Substitution Principle and the Composition Root – A Perspective