Thursday, December 22, 2016

The Liskov Substitution Principle (LSP) flashback

Violations of the Liskov Substitution principle make your code behavior unexpected, therefore hard predictable & bad maintainable...

Liskov Substitution Principle

Subtypes must be behaviorally substitutable for their base types - official formulation
That is, if S is a subtype of T, then objects of type T in a program P may be replaced with objects of type S without altering any of the desirable properties(correctness) of that program P.

Objects in a program should be replaceable with instances of their subtypes without altering the correctness of that program. - general form
It means that we must ensure that new derived classes extend the base classes without changing ancestor's behavior.
Child classes should never break the parent class’ type definitions - simplified interpretation
Otherwords, Subclass should override the parent class’ methods in a way that does not break functionality from a client’s point of view.

Barbara Liskov Substitution Principle benefits:

  • the way to characterize good inheritance hierarchies;
  • decreases a possibility of creation of hierarchies that do not conform Open-Closed Principle (OCP).

If doSomething(SomeClass duck) method works with instances of SomeClass, it does so with instances of any subclass of SomeClass.


Classic LSP example (Rectangle & Square)

  • Is Square a Rectangle?
  • Yes.

So, since square is a rectangle (mathematically) we can implement square as a descendant of rectangle:

Implementation with violation of LSP:

Java
public class Square extends Rectangle {

    @Override
    public void setWidth(int width) {
        super.setWidth(width);
        super.setHeight(width);
    }

    @Override
    public void setHeight(int height) {
        super.setHeight(height);
        super.setWidth(height);
    }
}

Formally we could pass Square wherever Rectangle is expected, but by doing so we break everyone's assumptions about the behavior of Rectangle:

Java
@Test
public void testRectangleArea() throws Exception {
    Rectangle rectangle = new Square();
    rectangle.setWidth(20);
    rectangle.setHeight(4);
    assertEquals(80, rectangle.area());
}

In the following use-case violation is even more implicit:

Java
public void clientMethod(Rectangle rectangle) {
    rectangle.setWidth(20);
    rectangle.setHeight(4);
    assert(rectangle.area() == 80);   // gonna fail with square
}

As U see, Square is behaviorally not a correct substitution for Rectangle, which means this hierarchy violates Liskov Substitution Principle (LSP). Changing the height/width of a square behaves differently from changing the height/width of a rectangle. Actually, there is no reason to have two different methods: setWidth & setHeight for the square at all.

Validity is not Intrinsic

Inspecting the Square/Rectangle hierarchy in isolation did not show any problems. In fact it even seemed like a self-consistent design. We had to inspect clients to identify the problems.

  • Inspecting a model hierarchies in isolation can not provide meaningful validation.
  • Therefore, the validity of a model must be judged against the possible users of the model.

We need to anticipate the assumptions that clients will make about our classes.


LSP Compliant Solution - Siblings

We make Rectangle & Square siblings via introducing the interface Shape for common methods:

Java
public interface Shape {
    long area();
}
  • Clients of Shape cannot make any assumptions about setters behavior.
  • To change properties of the shapes, clients have to work with the concrete implementations.
Java
public class Square implements Shape {

    private int size;

    public Square(int size) {
        this.size = size;
    }

    @Override
    public long area() {
        return size * size;
    }

    public void setSize(int size) {
        this.size = size;
    }
}
  • Square doesn't contain different methods for setting width & height.
Java
public class Rectangle implements Shape {

    private int width;
    private int height;

    public Rectangle(int width, int height) {
        this.width = width;
        this.height = height;
    }

    @Override
    public long area() {
        return width * height;
    }

    public void setWidth(int width) {
        this.width = width;
    }

    public void setHeight(int height) {
        this.height = height;
    }
}
  • Rectangle has consistent behavior regardless of its properties mutation.
Java
@Test
    public void testRectangleArea() throws Exception {
        Shape rectangle = new Rectangle(10, 5);
        assertEquals(50, rectangle.area());

        rectangle.setHeight(3);
        assertEquals(30, rectangle.area());
    }

    @Test
    public void testSquareArea() throws Exception {
        Shape square = new Square(5);
        assertEquals(25, square.area());
    }

LSP Compliant Solution - Proper Abstraction

This time, we will just enhance the initial implementation through the introducing the proper abstraction in order to eliminate any wrong behavioral assumptions.

As width = height is a rule of a Square not Rectangle, we can't just extend one from another without the LSP violation. Wait, what about Quadrangle? Does it restricts the area calculation logic either to one or another?

Java
public class Rectangle extends Quadrangle {

    public Rectangle(int width, int height) {
        super(width, height);
    }
}

So, our Rectangle is exact Quadrangle, but with meaningful behavioral rules.

Java
public class Square extends Quadrangle {

    public Square(int size) {
        super(size, size);
    }

    @Override
    public void setWidth(int width) {
        setSize(width);
    }

    @Override
    public void setHeight(int height) {
        setSize(height);
    }

    private void setSize(int size) {
        super.setWidth(size);
        super.setHeight(size);
    }

Our Square is also descendant of Quadrangle with it's own width = height rule.

  • Rectangle & Square are siblings with common ancestor Quadrangle.
  • When client receives Quadrangle, he can't form strict area behavior assumptions, cause to do so he needs more info about this abstraction.
Java
@Test
public void testRectangleArea() throws Exception {
    Quadrangle quadrangle = new Rectangle(10, 5);
    assertEquals(50, quadrangle.area());

    quadrangle.setWidth(20);
    quadrangle.setHeight(4);
    assertEquals(80, quadrangle.area());
}

@Test
public void testSquareArea() throws Exception {
    Quadrangle quadrangle = new Square(5);
    assertEquals(25, quadrangle.area());

    // if someone'll mutate our quadrangle
    // it won't violate our assumptions about it's area
    // cause client understands that it might be either square or rectangle.
    quadrangle.setWidth(20);
    quadrangle.setHeight(4);
    assertEquals(16, quadrangle.area());
}

Anyway i like the 1st solution more than this one, cause it left no doubts & narrowed the square behavior to the only needed methods.


LSP Compliant Solution - Immutability

Here we make the initial implementation immutable (eliminate all setters), leaving NO way for properties mutation or forming the wrong assumptions regarding the objects behavior.

Java
public class Rectangle {

    private int width;
    private int height;

    public Rectangle(int width, int height) {
        this.width = width;
        this.height = height;
    }

    public long area() { return width * height; }

    public int getWidth() { return width; }

    public int getHeight() { return height; }
}

All properly constructed Squares will be valid Rectangles. When we creating an immutable Square we enforce our invariants without an influence on the base class behavior.

Now, Wherever the client method will use rectangle, it would expect only its area value without making any assumptions regardless its behavior related to its properties mutation.

Java
public class Square extends Rectangle {

    private int size;

    public Square(int size) {
        super(size, size);
    }

    public int getSize() { return size; }
}

Now the width & height literally are not state anymore, those are rather the properties of the shape identity. Once we change width & height, we would be talking about another shape, than before.

Java
@Test
public void testRectangleArea() throws Exception {
    Rectangle rectangle = new Rectangle(10, 5);
    assertEquals(50, rectangle.area());
}

@Test
public void testSquareArea() throws Exception {
    Rectangle square = new Square(5);
    assertEquals(25, square.area());
}

The fact, that immutability solved our problem, doesn't mean we could not thing about the proper classes hierarchy in our application:

  • ImmutableCar can't be ImmutableBicycle, cause despite that it's also kind of vehicle, its usage and expected behavior is different.

LSP and OCP relationships

In most cases, violation of LSP as a consequence violates OCP as well.

Cause if we have a clientFunction f parameterized over type T. If object S which is subtype of T violates LSP, when it's passed to f among the objects of type T might cause f to misbehave.

In this scenario f is fragile in the presence of S and not closed against derivations of T anymore.


LSP violation code smells

  • Subclass overrides a method of the superclass by an empty method.
  • Subclass, which expects, that certain methods inherited from the superclass should not be called by clients.
  • Subclass that throws additional (unchecked) exceptions.
  • ...

Extra Examples of LSP Violations

  • Tesla can't be a descendant of Ferrari or its FuelCar abstraction, cause to start the engine it should be charged first. Clients of the FuelCar do not expect this.

  • java.util.Properties(From Java Doc) :

    Because Properties inherits from Hashtable, the put and putAll methods can be applied to a Properties object. Their use is strongly discouraged as they allow the caller to insert entries whose keys or values are not Strings. The setProperty method should be used instead. If the store or save method is called on a "compromised" Properties object that contains a non-String key or value, the call will fail.


Final Thoughts

Programmers do not define entities that are something, but entities that behave somehow.

The LSP is about the building hierarchies with predictable Behavior.

It's not enough for instances of sub-classes to provide all declared method in their parent class. These methods should also behave appropriately. If you give to your client an object, which substitutes its ancestor with violation of LSP, you might brake your client assumptions regarding the behavior of the object used by him. A client method should not be able to distinguish whether it works with some class or its descendant by behavior.

We also ensured, that immutability is simple and extremely powerful concept, which respects many of software-design principles.

P.S. I've touched SOLID two years ago, so this is more detailed article about LSP ^_^


see Also


No comments:

Post a Comment