SOLID OOP Development: Liskov Substitution Principle
Continuing on our discussion of SOLID OOP development principles, in this guide we’re going to walk through the Liskov substitution principle.
Guide Tasks
  • Read Tutorial
  • Watch Guide Video
Video locked
This video is viewable to users with a Bottega Bootcamp license

Continuing on our discussion of SOLID OOP development principles, in this guide we’re going to walk through the Liskov substitution principle.

large

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.