Liskov substitution principle: LSP
This principle states that:
If your father accepts Animals, you must accept also Animals, not just Cats.
Covariance of return types in the sub-type:
If your father returns Animals, you are safe to only return Cats, because all Cats are also Animals.
No new exceptions:
If your father throws an AnimalException you are free to throw AnimalTooLargeException but not a ChairException.
Preconditions cannot be strengthened in a sub-type:
Imagine that you have a VeterinaryClinic with an operation called sacrifice(pet: Animal), with a contract saying that for an animal to be sacrificed it must have a deadly disease (hasADeadlyDisease property returning true).
Later you derived a VainVeterinaryClinic with a new rule: you only sacrifice pretty and deadly ill animals. If they are ugly and have a deadly illness you let then suffer. Now this sub class has a more restrictive precondition (contract) in order to sacrifice animals. Where your code expect a Vet Clinic instance and you pass the Vain one you could receive an exception when trying to sacrifice an ugly deadly ill animal.
Postconditions cannot be weakened in a sub-type:
Continuing with this cruel example, if you have a post-condition for the sacrifice(pet: Animal) method indicating that after the method completes the animal must be dead (isAlive returning false), then a sub-type of the Vet Clinic class can not alter this behavior leaving animals alive after calling the sacrifice operation. This will be a more permissive (weak) contract than the one defined by the super-type.
Invariants of the super-type must be preserved in a sub-type:
If Animal class defines the name property as immutable, then Cats can not be renamed. For Animal instances the name is an invariant through the life-time of each object. So for Cats it must be also true.
History constraint:
Imagine an animal cage with two operations enter(a: Animal) and takeOut(): Animal; the case also have a read-only property called isEmpty: boolean. If you look back a sequence on calls to these methods (the history of an instance), you can easily predict the final value of isEmpty.
Then if you derive a CatCage from AnimalCage the former must ensure that the same relation (on the later) holds true. You cannot have a CatCage saying that is empty after an enter(a: Animal) call.
These rules are better explained using an strongly typed language such as Java and C#, you can check out the following test classes and their associated class under test: NonGenericPetCageTests.java, and GenericPetCageTests.java (on the java sub-directory).
"objects in a program should be replaceable with instances of their sub-types without altering the correctness of that program."This introduces us to the concept of Substitutability.
Substitutability is a principle in object-oriented programming. It states that, in a computer program, if S is a sub-type of T, then objects of type T may be replaced with objects of type S (i.e., objects of type S may substitute objects of type T) without altering any of the desirable properties of that program (correctness, task performed, etc.).Put it simple: If you say you are a Duck be a one but fully. LSP differs from Duck typing (0) in that the former is more restrictive than the later. Duck typing says that for an object to be used in a particular context it must have a certain list of methods and properties, leaving out the details about what happens when you use those methods and properties (the object's internals). So how restrictive are the LSP sub-typing rules? here is a summary of the rules:
- Requirements on signatures
- Contravariance (1) of method arguments in the sub-type.
- Covariance (1) of return types in the sub-type.
- No new exceptions should be thrown by methods of the sub-type, except where those exceptions are themselves sub-types of exceptions thrown by the methods of the super-type.
- Behavioral conditions
- Preconditions (2) cannot be strengthened in a sub-type.
- Postconditions (2) cannot be weakened in a sub-type.
- Invariants (2) of the super-type must be preserved in a sub-type.
- History constraint (the "history rule"): sub-types must not allow state changes that are impossible for its super-type, this is possible because sub-types may include new methods that can alter its state in ways not defined by the parent type. So the history that you get after calling a certain methods sequence must be the same for the super-type and the sub-type.
Principle requirements explained (top-down):
Contravariance of method arguments:If your father accepts Animals, you must accept also Animals, not just Cats.
Covariance of return types in the sub-type:
If your father returns Animals, you are safe to only return Cats, because all Cats are also Animals.
No new exceptions:
If your father throws an AnimalException you are free to throw AnimalTooLargeException but not a ChairException.
Preconditions cannot be strengthened in a sub-type:
Imagine that you have a VeterinaryClinic with an operation called sacrifice(pet: Animal), with a contract saying that for an animal to be sacrificed it must have a deadly disease (hasADeadlyDisease property returning true).
Later you derived a VainVeterinaryClinic with a new rule: you only sacrifice pretty and deadly ill animals. If they are ugly and have a deadly illness you let then suffer. Now this sub class has a more restrictive precondition (contract) in order to sacrifice animals. Where your code expect a Vet Clinic instance and you pass the Vain one you could receive an exception when trying to sacrifice an ugly deadly ill animal.
Postconditions cannot be weakened in a sub-type:
Continuing with this cruel example, if you have a post-condition for the sacrifice(pet: Animal) method indicating that after the method completes the animal must be dead (isAlive returning false), then a sub-type of the Vet Clinic class can not alter this behavior leaving animals alive after calling the sacrifice operation. This will be a more permissive (weak) contract than the one defined by the super-type.
Invariants of the super-type must be preserved in a sub-type:
If Animal class defines the name property as immutable, then Cats can not be renamed. For Animal instances the name is an invariant through the life-time of each object. So for Cats it must be also true.
History constraint:
Imagine an animal cage with two operations enter(a: Animal) and takeOut(): Animal; the case also have a read-only property called isEmpty: boolean. If you look back a sequence on calls to these methods (the history of an instance), you can easily predict the final value of isEmpty.
- enter(a) => takeOut() => enter(a): isEmpty is false
- enter(a) => takeOut() => enter(a) => takeOut(): isEmpty is true
Then if you derive a CatCage from AnimalCage the former must ensure that the same relation (on the later) holds true. You cannot have a CatCage saying that is empty after an enter(a: Animal) call.
These rules are better explained using an strongly typed language such as Java and C#, you can check out the following test classes and their associated class under test: NonGenericPetCageTests.java, and GenericPetCageTests.java (on the java sub-directory).
Side note: Who is Barbara Liskov?
Very, very short BioBarbara Liskov (born November 7, 1939 as Barbara Jane Huberman) is an American computer scientist who is an institute professor at the Massachusetts Institute of Technology (MIT) and Ford Professor of Engineering in its School of Engineering's electrical engineering and computer science department.Among her achievements we found that, with Jeannette Wing, she developed a particular definition of sub-typing, commonly known as the Liskov substitution principle (LSP). For more information check her bio at MIT web site. Why I'm writing about her?In our industry (software development), and in particular my country the Dominican Republic, we have a huge disparity between male and female personnel. I believe that this is holding us back because as with any aspect of the life, the diversity is good to avoid biases and narrow thinking. |
Code sample
Code samples are on github repo lsolano/blog.solid.demoThe code was written using JavaScript over Node.js v4.2.4. The sample API is about a logging library.We'll focus our attention to the Appenders cluster (family). The main components are:
- The API entry point called SOLIDLogs, from there get instances of loggers and appenders
- The Logger, with very simple implementation: supports only debug() and error() operations.
- The Appender interface (contract) and all implementations: Console, REST, DB, etc. Each appender is responsible for sending the messages from logger to its destination based on some configuration
All Level information was left out to keep the API as simple as possible. We are assuming that the level is ALL so always debug and error messages are sent to appenders.
This is the first version of the Appeder Base (BlackHoleAppender), and its derivatives ConsoleAppender and MySQLAppender.
BlackHoleAppender (aka AppenderBase)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | function BlackHoleAppender(args) { this.name = (args.name || 'defaultAppender'); }; BlackHoleAppender.prototype.normalizeMessage = function(message, category) { return message; } BlackHoleAppender.prototype.append = function(message, category) { var finalMessage = this.normalizeMessage(message, category); this.write(finalMessage, category); }; BlackHoleAppender.prototype.write = function(finalMessage, category) { /* To be implemented by sub-classes */ }; module.exports = BlackHoleAppender; |
ConsoleAppender
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | const maxMessageLength = Math.pow(2, 30); var AppenderBase = require('./BlackHoleAppender.js'); function ConsoleAppender(cons, maxLength) { this.console = (cons || console); this.maxLength = maxLength || 0; var finalArgs = ['ConsoleAppender']; Array.prototype.forEach.call(arguments, function(arg) { finalArgs.push(arg); }); AppenderBase.apply(this, Array.prototype.slice.call(finalArgs)); } ConsoleAppender.prototype = new AppenderBase('ConsoleAppender'); ConsoleAppender.prototype.write = function(finalMessage, category) { switch (category) { case "Error": this.console.error(finalMessage); break; default: this.console.debug(finalMessage); } }; ConsoleAppender.prototype.normalizeMessage = function(message, category) { var msg = (message || ''); var allowedLength = (this.maxLength || maxMessageLength); msg = msg.length > allowedLength? msg.substring(0, allowedLength) : msg; return msg; } module.exports = ConsoleAppender; |
MySQLAppender
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | const maxMessageLength = Math.pow(2, 14); var AppenderBase = require('./BlackHoleAppender.js'); function MySQLAppender(rdbmsRepository, maxLength) { this.repository = rdbmsRepository; this.maxLength = maxLength || 0; var finalArgs = ['MySQLAppender']; Array.prototype.forEach.call(arguments, function(arg) { finalArgs.push(arg); }); AppenderBase.apply(this, Array.prototype.slice.call(finalArgs)); } MySQLAppender.prototype = new AppenderBase('MySQLAppender'); MySQLAppender.prototype.write = function(finalMessage, category) { switch (category) { case "Error": this.repository.persist( { text: finalMessage, category: category }); break; default: this.repository.persist( { text: finalMessage, category: "Debug" }); } }; MySQLAppender.prototype.normalizeMessage = function(message, category) { var msg = (message || ''); var allowedLength = (this.maxLength || maxMessageLength); msg = msg.length > allowedLength? msg.substring(0, allowedLength) : msg; return msg; } module.exports = MySQLAppender; |
To see how the API is used take a look to the following files: /js/test/dirtyTest.js and /js/test/cleanTest.js.
What is wrong with this code: LSP?
Here we have one base class and two derivatives. The base does nothing with the passed message on the normalization step, it just returns the same thing. So its contract is "allow all messages".In contrast, Console and MySQL appenders have a limit on the message length. So they indeed are tightening their base-type's contract . That means that when an Appender is expected, and we pass the Console or MySQL version parts of the message could me silently truncated. This is a direct violation to the LS principle. Sub-types of the base appender must accept all the same input range managed by the base-type.
Cleaning the code
Honoring the LSP
By the time of this writing (2016) we have the following limitations in the length of an string when targeting the following platforms / products:
- UTF-8 is a "variable-length" encoding raging from 1 to 4 bytes per character, it can encode all UNICODE characters, so we must assume that UTF-8 will be used to store the message sent to the appenders.
- The worst case scenario with UTF-8 is that all characters in a string use 4 bytes so we must divide the total bytes capacity of the storage media by 4 to know the safe possible maximum length.
- JavaScript implementations can handle from 2^20 to 2^32 bytes per string. If we divide 2^32 by 4 we get 2^30, so for the ConsoleAppender the max allowed message length will be 2^30.
- MySQL (5.x) has a limit for string (varchar) columns of 2^16, again divided by 4 yields 2^14
- SQL-Server has a 2^30 bytes limit, divided by 4 gives us 2^28
With that information, and knowing that in order to honor the LSP the sub-types of BlackHoleAppender must allow at least the same message size as the super-type, we must force the base appender to handle the minimum possible message size, that is 2^14 from the MySQL implementation.
In order to do that we should "declare" the fact that know the appenders handle an explicit message max size. Also we must decide what to do when the message is longer than expected. To solve this we'll introduce the max length limit as the property maxLength, and the behavior when exceeded as an enumeration with only two possible values: Truncate (default), and Ignore.
As an invariant no sub-type of BlackHoleAppender should limit messages to a shorter length than its parent, in order to comply with the LSP. All these changes are captured in the following diagram:
Clean logging API |
BlackHoleAppender (cleaned):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 | var msgHandling = require('../messageHandling'); function BlackHoleAppender(name, config) { this.name = (name || 'blackHole'); this.maxLength = (!!config.baseMaxLength? config.baseMaxLength : 0); this._messageHandling = msgHandling.truncate; }; Object.defineProperty(BlackHoleAppender.prototype, 'messageHandling', { get: function() { return this._messageHandling; }, set: function(messageHandling) { return this._messageHandling = messageHandling; } }); BlackHoleAppender.prototype.normalizeMessage = function(message, category) { var msg = (message || ''); if (msg.length > this.maxLength) { if (this.messageHandling === msgHandling.truncate) { msg = msg.substring(0, this.maxLength); } else { msg = null; } } return msg; } BlackHoleAppender.prototype.append = function(message, category) { var finalMessage = this.normalizeMessage(message, category); if (!!finalMessage) { this.write(finalMessage, category); } }; BlackHoleAppender.prototype.write = function(finalMessage, category) { /* To be implemented by sub-classes */ }; module.exports = BlackHoleAppender; |
ConsoleAppender (cleaned):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | var AppenderBase = require('./BlackHoleAppender.js'); function ConsoleAppender(cons, config) { var finalArgs = ['console', config]; Array.prototype.forEach.call(arguments, function(arg) { finalArgs.push(arg); }); AppenderBase.apply(this, Array.prototype.slice.call(finalArgs, 2)); this.maxLength = Math.max(config.consoleMaxLength, this.maxLength); this.console = (cons || console); } ConsoleAppender.prototype = new AppenderBase('console', {}); ConsoleAppender.prototype.write = function(finalMessage, category) { switch (category) { case "Error": this.console.error(finalMessage); break; default: this.console.debug(finalMessage); } }; module.exports = ConsoleAppender; |
API usage example (for real usage see mocha tests):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | /* Optional: Appenders configuration override */ var configOverride = null, /* Boundary interface: How to "talk" with mySQL */ repository = new DBMSXYZRepo(), /* The logger */ logger = SOLIDLogs.getLogger(), /* Some appender */ appender = SOLIDLogs.getMySQLAppender(repository, configOverride); appender.messageHandling = msgHandling.ignore; /* default is msgHandling.truncate */ logger.addAppender(appender); logger.debug('Hello World!'); logger.error('The World Is On Fire!!!'); |
Agile Link
With the LS principle we gain predictability. I like the following meaning found in the Oxford dictionary:The fact of always behaving or occurring in the way expected.
Simply put with LSP we avoid surprises (aka WTFs). We avoid wasting time chasing bugs from bad-behaving components. If we are designing some framework we can create tests to be run by people making extensions or sub-types of our base types, also for people implementing our contracts (pure abstract, interfaces, or just words on paper).
With components designed like this, we are able to improve our estimates for change requests. Our velocity does not bounce dramatically and we get a sense of confidence both internal (the team) and external (stakeholders). This benefits help to build a Long Term Team, reduce stress level and staff turnover rate.
Series links
Previous, SOLID Principles: OCP (2 of 4)Next, SOLID Principles: ISP (4 of 4)
(0) Duck typing
No comments:
Post a Comment
enter your comments here...