- Read Tutorial
- Watch Guide Video
Continuing on our discussion of SOLID OOP development principles, in this guide we’re going to walk through the Liskov substitution principle.
When it comes to object oriented development, the Liskov substitution principle can be slightly confusing to Ruby developers. Part of the confusion comes from the fact that this principle has more of an effect with statically typed languages, such as Java. However it’s still an important concept to understand, so stay tuned and I’ll walk us through a practical example of how this applies to all OOP languages.
Liskov Substitution Principle Definition
To be 100% transparent, I struggled through researching this topic. But what helped me understand it was:
- Talking it through with a software engineer that I respect (Chase Baker).
- Breaking down the definition into very small pieces.
With that in mind let’s walk through a dead simple definition of the concept. The Liskov substitution principle states that:
A program should have the ability to replace any instance of a parent class with an instance of one of its child classes without negative side effects.
Liskov Substitution Principle Breakdown
If that’s about as clear as mud don’t worry, this principle isn’t for the faint of heart. It helped me tremendously to break the definition apart, so let’s give that a shot.
We’ll start with the fact that we know we’re going to be working with replacing instances of classes.
Next we know that we’re going to be working with parent and child classes. This tells me that the principle revolves around object oriented inheritance.
Lastly it sounds like programs have to be able to allow for child class instances to seamlessly replace parent classes. This tells me that we need to focus on the messages that are sent, along with ensuring that our parent and child classes can’t have requirements that would cause conflicts.
Still confused? That’s fine, this concept took me longer to understand than all of the other SOLID principles combined. So you’re in good company.
Liskov Substitution Principle Example
require 'date' class User attr_accessor :settings, :email def initialize(email:) @email = email end end class AdminUser < User end user = User.new(email: "user@test.com") user.settings = { level: "Low Security", status: "Live", signed_in: Date.today } admin = AdminUser.new(email: "admin@test.com") admin.settings = ["Editor", "VIP", Date.today] puts user.settings puts admin.settings
For our code walk through I created a basic User
class in Ruby. Additionally I build an AdminUser
class that inherits from the parent User class. The classes have attributes for settings
and an email
.
In our case study we decided to make an interesting decision to use the Hash
data type for our settings in the User class. However we’re using the Array
data type for our AdminUser class settings. This seems like an innocuous issue because if we run this program both classes seem to be working fine.
{:level=>"Low Security", :status=>"Live", :signed_in=>#<Date: 2016-09-23 ((2457655j,0s,0n),+0s,2299161j)>} Editor VIP 2016-09-23
The Problem
Our problem may not be evident quite yet. However watch what happens when we need to build a new feature.
require 'date' class User attr_accessor :settings, :email def initialize(email:) @email = email end end class AdminUser < User end user = User.new(email: "user@test.com") user.settings = { level: "Low Security", status: "Live", signed_in: Date.today } admin = AdminUser.new(email: "admin@test.com") admin.settings = ["Editor", "VIP", Date.today] @user_database = [user, admin] def signed_in_today? @user_database.each do |user| if user.settings[:signed_in] == Date.today puts "#{user.email} signed in today" end end end signed_in_today?
Our new feature is a script called signed_in_today?
that will run each day and generate a report that lists out which users logged in that day. It combines both regular and admin users into an instance variable called @user_database
. From there is loops over the settings for all of the users to see if the user signed in that day or not.
This is a pretty common feature, but let’s see what happens when we run this code:
user@test.com signed in today solid.rb:28:in `[]': no implicit conversion of Symbol into Integer (TypeError) from solid.rb:28:in `block in signed_in_today?' from solid.rb:27:in `each' from solid.rb:27:in `signed_in_today?' from solid.rb:34:in `<main>'
Our script works perfectly fine for our user that was created straight from the User class. However it breaks when it comes to our admin user and gives the error: no implicit conversion of Symbol into Integer (TypeError)
.
The bug with this program is relatively straightforward. Our signed_in_today?
method is looking for settings that are stored as a Hash data type. But when it encounters the AdminUser settings, which are stored with the Array data type, it throws an error.
Liskov Substitution Principle Violation
So does this example qualify as a Liskov substitution principle violation? Let’s look at the definition again:
A program should have the ability to replace any instance of a parent class with an instance of one of its child classes without negative side effects.
In our example we attempted to substitute an instance of a child class, our AdminUser, for its parent class. And by performing this action it broke the program, which I think qualifies as a negative side effect.
The Fix
Now let’s see what we need to do to fix this. You may think it’s as easy as changing the admin settings call so that we pass it a hash, like this:
admin = AdminUser.new(email: "admin@test.com") admin.settings = { level: "Editor", status: "VIP", signed_in: Date.today }
And yes, technically that would work... for this one instance. However this doesn’t fix the core problem and the same bug will continue to happen if a developer forgets that settings need to be a hash. So what’s a better solution? Personally I’d attack the root issue and force the program to only have one option when it comes to saving settings.
require 'date' require 'ostruct' class User attr_accessor :settings, :email def initialize(email:) @email = email end def set_settings(level:, status:, signed_in:) @settings = OpenStruct.new( level: level, status: status, signed_in: signed_in ) end def get_settings @settings end end class AdminUser < User end user = User.new(email: "user@test.com") user.settings = { level: "Low Security", status: "Live", signed_in: Date.today } admin = AdminUser.new(email: "admin@test.com") admin.settings = { level: "Editor", status: "VIP", signed_in: Date.today } @user_database = [user, admin] def signed_in_today? @user_database.each do |user| if user.settings[:signed_in] == Date.today puts "#{user.email} signed in today" end end end signed_in_today?
In this code I’m importing the Ruby OpenStruct
library. Next I remove settings as an attribute of the class and instead I create a method called set_settings
that can be called and saves the setting into the OpenStruct data type. Then settings can be called from anywhere in the application for the User class and any child classes it may have, and we can trust the behavior that it will generate.
Now our AdminUser class instances can replace any instances of the User class and the program will still work properly.