Implementing the Liskov Substitution Principle
Understand how to apply the Liskov Substitution Principle in C# by analyzing a text processing example. Learn to adjust class inheritance and method overriding to avoid unexpected behavior, ensuring your derived classes maintain base class functionality without breaking existing code. This lesson guides you through practical steps to adhere to SOLID principles while enhancing software design.
We'll cover the following...
We have the following console application that reads the textual content of a file, encloses every paragraph in the p HTML tags, and makes conversion of specific Markdown markers into equivalent HTML tags.
Note: Run the following code. The program will ask for the file to convert. Enter
sample.txtas the name of the file. When the program stops execution, press any key to return to the terminal. Now, enter thecat sample.htmlcommand to see the content after conversion.
using System.Text;
using System.Text.RegularExpressions;
namespace TextToHtmlConvertor;
public class TextProcessor
{
public virtual string ConvertText(string inputText)
{
var paragraphs = Regex.Split(inputText, @"(\r\n?|\n)").Where(p => p.Any(char.IsLetterOrDigit));
var sb = new StringBuilder();
foreach (var paragraph in paragraphs)
{
if (paragraph.Length == 0)
{
continue;
}
sb.AppendLine($"<p>{paragraph}</p>");
}
sb.AppendLine("<br/>");
return sb.ToString();
}
}We have the TextProcesor.cs file with the following content:
We also have a class that derives from it, which is called the MdTextProcessor class. It overrides the ConvertText method, written on lines 12–24 below, by adding some processing steps to it. Basically, it checks the text for specific Markdown markers and replaces them with corresponding HTML tags. Both the markers and the tags are fully configurable via a dictionary. The class MdTextProcessor looks as follows:
This structure implements the open-closed principle really well, but it doesn’t implement the Liskov substitution principle. Although the overridden ConvertText method makes a call to the original method in the base class and calling this method on the derived class will still process the paragraphs, the method implements some additional logic, which might produce completely unexpected results. We’ll see this via a unit test.
Here’s a test to validate the basic functionality of the original ConvertText method. We initialize an instance of the TextProcessor object in the constructor, pass some arbitrary input text, and then check whether the expected output text has been produced.
Note: We’ve deliberately inserted some symbols into the input text that have a special meaning in the Markdown document format. However,
TextProcessoron its own is completely agnostic of Markdown, so those symbols are expected to be ignored. Therefore, this test will successfully pass.
Since our textProcessor variable is of the TextProcessor type, it’ll be set to an instance of MdTextProcessor easily. So, without modifying our test method in any way or changing the data type of the textProcessor variable, we can assign an instance of MdTextProcessor to the variable.
The test will now fail. The output from the ConvertText method will change because those Markdown symbols will be converted to HTML tags. This is exactly how other places in our code might end up behaving differently from how they were intended to behave.
Incorporating the Liskov substitution principle
However, there’s a very easy way to address this issue. If we go back to our MdTextProcessor class and change the override of the ConvertText method into a new method that we call ConvertMdText without changing any of its content, our test will, once again, pass.
And we still have our code structured in accordance with the single responsibility principle because the method is purely responsible for converting text and nothing else. We still have 100% saturation because the new method fully relies on the existing functionality from the base class, so our inheritance wasn’t pointless.
The complete implementation of the Liskov substitution principle can be found below.
namespace TextToHtmlConvertor;
public class FileProcessor
{
private readonly string fullFilePath;
public FileProcessor(string fullFilePath)
{
this.fullFilePath = fullFilePath;
}
public string ReadAllText()
{
return System.Web.HttpUtility.HtmlEncode(File.ReadAllText(fullFilePath));
}
public void WriteToFile(string text)
{
var outputFilePath = Path.GetDirectoryName(fullFilePath) + Path.DirectorySeparatorChar +
Path.GetFileNameWithoutExtension(fullFilePath) + ".html";
using var file = new StreamWriter(outputFilePath);
file.Write(text);
}
}We’re still acting in accordance with the open-closed principle, but we no longer violate the Liskov substitution principle. Every instance of the derived class will have the base class functionality inherited, but all of the existing functionality will work exactly as it did in the base class. So, using objects made from derived classes won’t break any existing functionality that relies on the base class.