The Liskov Substitution Principle
- 10/15/2014
- Introduction to the Liskov substitution principle
- Contracts
- Covariance and contravariance
- Conclusion
Covariance and contravariance
The remaining rules of the Liskov substitution principle all relate to covariance and contravariance. Generally, variance is a term applied to the expected behavior of subtypes in a class hierarchy containing complex types.
Definitions
As previously demonstrated, it is important to cover the basics of this topic before diving in to the specifics of the LSP’s requirements for variance.
Covariance
Figure 7-4 shows a very small class hierarchy of just two types: the generically named Supertype and Subtype, which are conveniently named after their respective roles in the inheritance structure. Supertype defines some fields and methods that are inherited by Subtype. Subtype enhances the Supertype by defining its own fields and methods.
FIGURE 7-4 Supertype and Subtype have a parent/child relationship in this class hierarchy.
Polymorphism is the ability of a subtype to be treated as if it were an instance of the supertype. Thanks to this feature of object-oriented programming, which C# supports, any method that accepts an instance of Supertype will also be able to accept an instance of Subtype without any casting required by either the client or service code, and also without any type sniffing by the service. To the service, it has been handed an instance of Supertype, and this is the only fact it is concerned with. It doesn’t care what specific subtype has been handed to it.
Variance enters the discussion when you introduce another type that might use Supertype and/or Subtype through a generic parameter.
Figure 7-5 is a visual explanation of the concept of covariance. First, you define a new interface called ICovariant. This interface is a generic of type T and contains a single method that returns this type, T. Because the out keyword is used before the generic type argument T, this interface is well named because it exhibits covariant behavior.
The second half of the class diagram details a new inheritance hierarchy that has been created thanks to the covariance of the ICovariant interface. By plugging in the values for the Supertype and Subtype classes that were defined previously, ICovariant<Supertype> becomes a supertype for the ICovariant<Subtype> interface.
FIGURE 7-5 Due to covariance of the generic parameter, the base-class/subclass relationship is preserved.
Polymorphism applies here, just as it did previously, and this is where it gets interesting. Thanks to covariance, whenever a method requires an instance of ICovariant<Supertype>, you are perfectly at liberty to provide it with an instance of ICovariant<Subtype>, instead. This will work seamlessly thanks to the simultaneous interoperating of both covariance and polymorphism.
So far, this is of limited general use. To firm up this explanation, I’ll move away from class diagrams and instructive type names to a more real-world scenario. Listing 7-20 shows a class hierarchy between a general Entity base class and a specific User subclass. All Entity types inherit a GUID unique identifier and a string name, and each User has an EmailAddress and a DateOfBirth.
LISTING 7-20 In this small domain, a User is a specialization of the Entity type.
public class Entity { public Guid ID { get; private set; } public string Name { get; private set; } } // . . . public class User : Entity { public string EmailAddress { get; private set; } public DateTime DateOfBirth { get; private set; } }
This is directly analogous to the Supertype/Subtype example, but with a more directed purpose. This small domain is going to have the Repository pattern applied to it. The Repository pattern provides you with an interface for retrieving objects as if they were in memory but that could realistically be loaded from a very different storage medium. Listing 7-21 shows an EntityRepository class and its UserRepository subclass.
LISTING 7-21 Without involving generics, all inheritance in C# is invariant.
public class EntityRepository { public virtual Entity GetByID(Guid id) { return new Entity(); } } // . . . public class UserRepository : EntityRepository { public override User GetByID(Guid id) { return new User(); } }
This example is not the same as that previously described because of one key difference: in the absence of generic types, C# is not covariant for method return types. In fact, a compilation error is generated due to an attempt to change the return type of the GetByID method in the subclass to match the User class.
error CS0508: 'SubtypeCovariance.UserRepository.GetByID(System.Guid)': return type must be 'SubtypeCovariance.Entity' to match overridden member 'SubtypeCovariance.EntityRepository.GetByID(System.Guid)'
Perhaps experience tells you that this will not work, but the reason is a lack of covariance in this scenario. If C# supported covariance for general classes, you would be able to enforce the change of return type in the UserRepository. Because it does not, you have only two options. You can amend the UserRepository.GetByID method’s return type to be Entity and use polymorphism to allow you to return a User in its place. This is dissatisfying because clients of the UserRepository would have to downcast the return type from an Entity type to a User type, or they would have to sniff for the User type and execute specific code if the expected type was returned.
Instead, you should redefine EntityRepository as a generic class that requires the Entity type it intends to operate on via a generic type argument. This generic parameter can be marked out, thus covariant, and the UserRepository subclass can specialize its parent base class for the User type. Listing 7-22 exemplifies this.
LISTING 7-22 Make base classes generic to take advantage of covariance and allow subclasses to override the return type.
public interface IEntityRepository<TEntity> where TEntity : Entity { TEntity GetByID(Guid id); } // . . . public class UserRepository : IEntityRepository<User> { public User GetByID(Guid id) { return new User(); } }
Rather than maintaining EntityRepository as a concrete class that can be instantiated, this code has converted it into an interface that removes the default implementation of GetByID. This is not entirely necessary, but the benefits of clients depending on interfaces rather than implementations have been demonstrated consistently, so it is a sensible reinforcement of that policy.
Note also that there is a where clause applied to the generic type parameter of the Entity-Repository class. This clause prevents subclasses from supplying a type that is not part of the Entity class hierarchy, which would have made this new version more permissive than the original implementation.
This version prevents the need for UserRepository clients to mess around with downcasting because they are guaranteed to receive a User object, rather than an Entity object, and yet the inheritance of EntityRepository and UserRepository is preserved.
Contravariance
Contravariance is a similar concept to covariance. Whereas covariance relates to the treatment of types that are used as return values, contravariance relates to the treatment of types that are used as method parameters.
Using the same Supertype and Subtype class hierarchy as previously discussed, Figure 7-6 explores the relationship between types that are marked as contravariant via generic type parameters.
FIGURE 7-6 Due to contravariance of the generic parameter, the base-class/subclass relationship is inverted.
The IContravariant interface defines a method that accepts a single parameter of the type dictated by the generic parameter. Here, the generic parameter is marked with the in keyword, meaning that it is contravariant.
The subsequent class hierarchy can be inferred, indicating that the inheritance hierarchy has been inverted: IContravariant<Subtype> becomes the superclass, and IContravariant<Supertype> becomes the subclass. This seems strange and counterintuitive, but it will soon become apparent why contravariance exhibits this behavior—and why it is useful.
In Listing 7-23, the .NET Framework IEqualityComparer interface is provided for reference and an application-specific implementation is created. The EntityEqualityComparer accepts the previous Entity class as a parameter to the Equals method. The details of the comparison are not relevant, but a simple identity comparison is used.
LISTING 7-23 The IEqualityComparer interface allows the definition of function objects like EntityEqualityComparer.
public interface IEqualityComparer<in TEntity> where TEntity : Entity { bool Equals(TEntity left, TEntity right); } // . . . public class EntityEqualityComparer : IEqualityComparer<Entity> { public bool Equals(Entity left, Entity right) { return left.ID == right.ID; } }
The unit test in Listing 7-24 explores the affect that contravariance has on the EntityEqualityComparer.
LISTING 7-24 Contravariance inverts class hierarchies, allowing a more general comparer to be used wherever a more specific comparer is requested.
[Test] public void UserCanBeComparedWithEntityComparer() { SubtypeCovariance.IEqualityComparer<User> entityComparer = new EntityEqualityComparer(); var user1 = new User(); var user2 = new User(); entityComparer.Equals(user1, user2) .Should().BeFalse(); }
Without contravariance—the innocent-looking in keyword applied to generic type parameters—the following error would be shown at compile time.
error CS0266: Cannot implicitly convert type 'SubtypeCovariance.EntityEqualityComparer' to 'SubtypeCovariance.IEqualityComparer<SubtypeCovariance.User>'. An explicit conversion exists (are you missing a cast?)
There would be no type conversion from EntityEqualityComparer to IEqualityComparer-<User>, which is intuitive because Entity is the supertype and User is the subtype. However, because the IEqualityComparer supports contravariance, the existing inheritance hierarchy is inverted and you are able to assign what was originally a less specific type to a more specific type via the IEqualityComparer interface.
Invariance
Beyond covariant or contravariant behavior, types are said to be invariant. This is not to be confused with the term data invariant used earlier in this chapter as it relates to code contracts. Instead, invariant in this context is used to mean “not variant.” If a type is not variant at all, no arrangement of types will yield a class hierarchy. Listing 7-25 uses the IDictionary generic type to demonstrate this fact.
LISTING 7-25 Some generic types are neither covariant or contravariant. This makes them invariant.
[TestFixture] public class DictionaryTests { [Test] public void DictionaryIsInvariant() { // Attempt covariance... IDictionary<Supertype, Supertype> supertypeDictionary = new Dictionary<Subtype, Subtype>(); // Attempt contravariance... IDictionary<Subtype, Subtype> subtypeDictionary = new Dictionary<Supertype, Supertype>(); } }
The first line of the DictionaryIsInvariant test method attempts to assign a dictionary whose key and value parameters are of type Subtype to a dictionary whose key and value parameters are of type Supertype. This will not work because the IDictionary type is not covariant, which would preserve the class hierarchy of Subtype and Supertype.
The second line is also invalid, because it attempts the inverse: to assign a dictionary of Supertype to a dictionary of Subtype. This fails because the IDictionary type is not contravariant, which would invert the class hierarchy of Subtype and Supertype.
The fact that the IDictionary type is neither covariant nor contravariant leads to the conclusion that it must be invariant. Indeed, Listing 7-26 shows how the IDictionary type is declared, and you can tell that there is no reference to the out or in keywords that would specify covariance and contravariance, respectively.
LISTING 7-26 None of the generic parameters of the IDictionary interface are marked with in or out.
public interface IDictionary<TKey, TValue> : ICollection<KeyValuePair<TKey, TValue>>, IEnumerable<KeyValuePair<TKey, TValue>>, IEnumerable
As previously proven for the general case—that is, without generic types—C# is invariant for both method parameter types and return types. Only when generics are involved is variance customizable on a per-type basis.
Liskov type system rules
Now that you have a grounding in variance, this section can circle back and relate all of this to the Liskov substitution principle. The LSP defines the following rules, two of which relate directly to variance:
- There must be contravariance of the method arguments in the subtype.
- There must be covariance of the return types in the subtype.
- No new exceptions are allowed.
Without contravariance of method arguments and covariance of return types, you cannot write code that is LSP-compliant.
The third rule stands alone as not relating to variance and bears its own discussion.
No new exceptions are allowed
This rule is more intuitive than the other LSP rules that relate to the type system of a language. First, you should consider: what is the purpose of exceptions?
Exceptions aim to separate the reporting of an error from the handling of an error. It is common for the reporter and the handler to be very different classes with different purposes and context. The exception object represents the error that occurred through its type and the data that it carries with it. Any code can construct and throw an exception, just as any code can catch and respond to an exception. However, it is recommended that an exception only be caught if something meaningful can be done at that point in the code. This could be as simple as rolling back a database transaction or as complex as showing users a fancy user interface for them to view the error details and to report the error to the developers.
It is also often inadvisable to catch an exception and silently do nothing, or catch the general Exception base type. Both of these two scenarios together are even more discouraged. With the latter scenario, you end up attempting to catch and respond to everything, including exceptions that you realistically have no meaningful way of recovering from, like OutOfMemoryException, StackOverflowException, or ThreadAbortException. You could improve this situation by ensuring that you always inherit your exceptions from ApplicationException, because many unrecoverable exceptions inherit from SystemException. However, this is not a common practice and relies on third-party libraries to also follow this practice.
Listing 7-27 shows two exceptions that have a sibling relationship in the class hierarchy. It is important to note that this precludes the ability to create a catch block specifically targeting one of the exception types and to intercept both types of exception.
LISTING 7-27 Both of these exceptions are of type Exception, but neither inherits from the other.
public class EntityNotFoundException : Exception { public EntityNotFoundException() : base() { } public EntityNotFoundException(string message) : base(message) { } } //. . . public class UserNotFoundException : Exception { public UserNotFoundException() : base() { } public UserNotFoundException(string message) : base(message) { } }
Instead, in order to catch both an EntityNotFoundException and a UserNotFoundException with a single catch block, you would have to resort to catching the general Exception, which is not recommended.
This problem is exacerbated in the potential code taken from the EntityRepository and UserRepository classes, as shown in Listing 7-28.
LISTING 7-28 Two different implementations of an interface might throw different types of exception.
public Entity GetByID(Guid id) { Contract.Requires<EntityNotFoundException>(id != Guid.Empty); return new Entity(); } //. . . public User GetByID(Guid id) { Contract.Requires<UserNotFoundException>(id != Guid.Empty); return new User(); }
Both of these classes use code contracts to assert a precondition: that the provided id parameter must not be equal to Guid.Empty. Each uses its own exception type if the contract is violated. Think for a second about the impact that this would have on a client using the repository. The client would need to catch both kinds of exception and could not use a single catch block to target both exceptions without resorting to catching the Exception type. Listing 7-29 shows a unit test that is a client to these two repositories.
LISTING 7-29 This unit test will fail because a UserNotFoundException is not assignable to an EntityNotFoundException.
[TestFixture(typeof(EntityRepository), typeof(Entity))] [TestFixture(typeof(UserRepository), typeof(User))] public class ExceptionRuleTests<TRepository, TEntity> where TRepository : IEntityRepository<TEntity>, new() { [Test] public void GetByIDThrowsEntityNotFoundException() { var repo = new TRepository(); Action getByID = () => repo.GetByID(Guid.Empty); getByID.ShouldThrow<EntityNotFoundException>(); } }
This unit test fails because the UserRepository does not, as required, throw an EntityNotFound-Exception. If the UserNotFoundException was a subclass of the type EntityNotFoundException, this test would pass and a single catch block could guarantee catching both kinds of exception.
This becomes a problem of client maintenance. If the client is using an interface as a dependency and calling methods on that interface, it should not know anything about the classes behind that interface. This is a return to the argument concerning the Entourage anti-pattern versus the Stairway pattern. If new exceptions that are not part of an expected exception class hierarchy are introduced, clients must start referencing these exceptions directly. And—even worse—clients will have to be updated whenever a new exception type is introduced.
Instead, it is important that every interface have a unifying base class exception that conveys the necessary information about an error from the exception reporter to the exception handler.