The idea of this note is inspired by the article by Alexander Bindu “
Addition to the LSP ” and can be considered as a detailed commentary on the article by Alexander.
So, the next question is, suppose, one of the team members is trying to implement the
IList of T interface in the
DoubleList of T class so that when adding an element using the
Add method, not one but two elements will be added. Since the
List of T class always adds only one element, we can assume that this behavior violates the Liskov Substitution Principle.
Now let's consider whether this is true when it comes to the .NET platform, well, let's talk about whether we can say that the method violates the Liskov substitution principle in the absence of a formal specification of what this method should do.
')
First you need to make a small amendment to the condition of the problem (yes, I understand that this is not fair, but in this case it is justified). The original task talked about the
IList of T interface implementing the
DoubleList class, but in fact the .NET Framework adds the
Add method in the
ICollection of T interface, which means it should be considered whether the
DoubleList of T class
violates the collection contract and not the
list .
NOTE
For ease of reading the code and running the examples, I will not use a generic version of the DoubleList class, but a specialized container for storing rows, i.e. in fact, our DoubleList will implement the ICollection of string interface.public class DoubleList : ICollection<string> { private readonly List<string> _backingList = new List<string>(); public void Add(string item) {
Now, suppose that somewhere in the code we use the
ICollection of string interface (that is, we have polymorphic use) and expect that the number of elements will increase strictly by one. Such behavior can be expressed either by using a formal postcondition (for example, using the Code Contract), or it can be expressed as a unit test. As we move on to contracts later, let's consider the unit test first:
[Test] public void TestAddMethodAddsOnlyOneElement() { ICollection<string> collection = new DoubleList(); int oldCount = collection.Count; collection.Add("foo"); Assert.That(collection.Count, Is.EqualTo(oldCount + 1)); }
Yes, indeed, this test will work fine when used as a
collection List of string object, and will fall if
DoubleList is used.
But there is only one question: can we really expect such behavior from the
Add method of the
ICollection of T interface? When it comes to correctness (in other words, whether it is a bug or not, and it doesn’t matter where: in the code or design), it is determined only by whether the behavior of the code corresponds to the specification or not. The specification may be formal or non-formal, but the program is incorrect only when it stops doing what it is intended for, and if no one knows what it is intended for, then no one can say that it does not work correctly.
Some expression, such as
x = y / 2 , is in itself neither correct nor incorrect. It all depends on what the relationship between
x and
y should be (for more on this, see
“Contract Design. Software Correctness” ). The situation with the
Add method is similar: without a formal or at least informal description of what this method should do, we cannot say whether it is implemented correctly by the heir. If we open the official documentation for the
ICollection (Of T) .Add Method , then all that we will see is one line in which it will be said that this method adds an element to the collection. At the same time, everything else (for example, whether it will be added exactly or in what quantity) is only our assumptions, and the only thing you can do is try to find out how other implementations of the
ICollection of T interface behave.
Let's add the following test, but this time, we’ll check that adding two identical elements will increase the number of elements by 2. It’s clear that this is a more special case than adding one element, but, again, return to the documentation of the
Add method, then there is not a word that we cannot add the same elements; the only limitation is that we do not add items to the collection for reading only. There are no other preconditions (in the official documentation, they are usually expressed as exceptions), and therefore no other checks are needed before calling the
Add method:
[TestCaseSource("GetAllCollections")] public void TestAddToCollectionTwiceAddsTwoElement(ICollection<string> collection) { int oldCount = collection.Count; if (collection.IsReadOnly) { Console.WriteLine("Current collection type ({0}) is readonly. Skipping it...", collection.GetType()); return; } collection.Add("foo"); collection.Add("foo"); Assert.That(collection.Count, Is.EqualTo(oldCount + 2)); }
Once again I draw attention to the verification of the preconditions of the
Add method, contracts are not a game with only one goal, therefore we must fulfill our part of the contract so that the called code executes its own. The test itself is parameterized (from NUnit), whose input values will be taken from the
GetAllCollections method. I will not bother with searching for all existing classes that implement the
ICollection of T interface, but simply add some popular types of collections manually:
private static ICollection<string>[] GetAllCollections() { return new ICollection<string>[] { new List<string>(), new HashSet<string>(), new Collection<string>(), new BindingList<string>(), new LinkedList<string>(), new ObservableCollection<string>(), new ReadOnlyCollection<string>(new List<string>()), new SortedSet<string>(), new string[]{}, }; }
Now we run the above test and see that at least two types of collections add only one element, although we asked to add two. These are, of course, collection types that do not allow duplicate storage, such as
HashSet of T and
SortedSet of T.Now let's return to the original question:
does the implementation of the Add method of the DoubleList class violate the Liskov substitution principle? Answer:
No, it does not break!The
Add method of the
ICollection of T interface does not impose any restrictions on how many elements will be in the collection after the addition, which means that we cannot require a certain rule from all classes that implement this interface. In fact, the following precondition is more plausible (based on the behavior of collections implementing
ICollection of T ): after calling the
Add method, the next call to the
Contains method should return
true and the number of elements in the collection should not
decrease (ie, newCount> = oldCount).
In this case, if in our test we replace the existing statement with:
Assert.IsTrue(collection.Contains("foo"));
then all tests will pass successfully.
Conclusion
We can safely say that
DoubleList does not violate the principle of replacing Liskov, or it violates, along with some standard classes of collections from BCL. On the other hand, I agree that the design of the
DoubleList class
is “crooked”, but not because of a violation of a certain principle, which is practically impossible to understand the formulation in my right mind (*), I would rather appeal to the fact that such behavior is intuitively unclear and will certainly lead to accompanying problems.
It is not for nothing that Meyer, in his thick book (see additional references), assigns such an important role to the formal specification of programs with the help of statements (preconditions, postconditions and invariants). It is the absence of a formal description of what the method should do that makes it so difficult to write an heir that would work “correctly”, since no one can say what is “right”. The signature of the method (and a possible comment) is too informal to understand "
will the behavior change if you use the heir instead of the base class ".
Next time we will look at how
Code Contracts (the support of which is partially included in the mscorlib) can help in solving our problem, and on what kind of formal post-condition the
Add method has.
-
(*) I will move away from the usual rule for forming a footnote directly in the text and will do a separate subsection on the definition of the Liskov replacement principle.
The principle of replacement Liskov. Definition
In the excellent book by James Coplien, “
Programming in C ++, ” there is an excellent, and completely incomprehensible definition of the Liskov principle of substitution:
... if for every object o1 of type S there exists an object o2 of type T such that for all programs P defined in context T, the behavior of P does not change when o1 is replaced by o2, then S is the base type for T.In general, everything is fine, except for the fact that it is completely unclear what “
in the context of T ” means and it is not at all clear what it means “the
behavior of P will not change ” when this behavior is not described anywhere.
Another popular, but no more understandable definition can be found in the
book of Bob Martin :
It should be possible to substitute any of its subtypes instead of the base type .
This definition is simpler, but hardly clearer. In any object-oriented programming language, there is an implicit conversion from the heir to the base class (when accounting for the use of open inheritance), so this requirement should be attributed to the compiler or programming language, rather than to the classes defined by users. Martin himself in his book (as well as in the article
The Liskov Substituion Principle ) talks about the importance of contracts, and in particular about the importance of preconditions and postconditions for specifying the behavior of virtual methods.
Contracts - this is the very possibility of specifying the
behavior of P , o
which is stated in the original definition, and it is thanks to this specification that we will be able to guarantee (or at least understand) that the behavior of the heir corresponds to the behavior of the base class or interface.
Additional links
- Bertrand Meyer. Object-oriented design of software systems
- Robert K. Martin. Principles, patterns and techniques of agile development in C #
- Robert C. Martin The Liskov Substitution Principle
- Robert C. Martin Design Principles and Patterns
- Design by contract. About software correctness
- Design by contract. Inheritance