Squaretangle
The question
Here’s some Java code:
class Square {
private int width;
public Square(int width) {
this.width = width;
}
public int getWidth() {
return width;
}
public void setWidth(int width) {
this.width = width;
}
}
class Rectangle {
private int width;
private int height;
public Square(int width, int height) {
this.width = width;
this.height = height;
}
public int getWidth() {
return width;
}
public void setWidth(int width) {
this.width = width;
}
public int getHeight() {
return height;
}
public void setHeight(int width) {
this.height = height;
}
}
Here’s the same thing in Ruby:
class Square
def initialize(width)
@width = width
end
attr_accessor :width
end
class Rectangle
def initialize(width, height)
@width = width
@height = height
end
attr_accessor :width
attr_accessor :height
end
The Ruby version is so much more concise! Let’s work with that today.
Here’s the question of the day:
Should Square extend Rectangle, or should Rectangle extend Square?
Answer 1: Rectangle ⊂ Square
A Rectangle is just a Square that also has a height. Inheritance lets us reuse the width property of Square. Let’s go for code reuse! Fantastic!…right??
class Square
def initialize(width)
@width = width
end
attr_accessor :width
end
class Rectangle < Square
def initialize(width, height)
super(width)
@height = height
end
attr_accessor :height
end
def sq_area(square)
square.width * square.width
end
def rect_area(rect)
rect.width * rect.width
end
p sq_area(Square.new(2)) # good!
p rect_area(Rectangle.new(3, 7)) # good!
p sq_area(Rectangle.new(3, 7)) # oh no
The sq_area method’s one paramter has the implicit type of “object that has a width”. Both Square and Rectangle satisfy that type — so we can feed a Rectangle to sq_area and get the wrong answer. Terrible! Maybe we just need to be careful…but what if we want code that handles both squares and rectangles, maybe together in a collection, and computes all their areas correctly?
Answer 1, second attempt: Use conditionals
We could solve this by making a generic area method that checks the class of its argument:
def area(shape)
if shape.is_a?(Rectangle)
square.width * square.height
else
square.width * square.width
end
end
We could also just check whether a height method exists, not worrying about specific class names:
def area(shape)
if shape.respond_to?(:height)
square.width * square.height
else
square.width * square.width
end
end
Both these techniques are fairly common in Ruby library methods that need to handle a parameter with several possible types. In our situation, however, it feels like we’re on thin ice: we tried for code reuse and ended up with more code! Every single method that needs to work with both classes will have to jump through this same conditional logic. And if we ever added another sort of shape…not an ideal solution.
Answer 1, third attempt: Use method overrides
Here’s a different solution:
class Square
def initialize(width)
@width = width
end
attr_accessor :width
def area
self.width * self.width
end
end
class Rectangle < Square
def initialize(width, height)
super(width)
@height = height
end
attr_accessor :height
def area
self.width * self.height
end
end
shapes = [
Square.new(2),
Rectangle.new(3, 7)
]
shapes.each do |shape|
p shape.area # Good!
end
But now any function that needs to work on both classes must move into the Square class. We can’t do this and expect it to work on Square too:
def scale(rect, factor)
rect.width *= factor
rect.height *= factor
end
…and we’ll hit a similar problem before if we do this instead:
def scale(square, factor)
square.width *= factor
end
To take advantage of that subtype relationship, we have to shove scale into the superclass too:
class Square
def initialize(width)
@width = width
end
attr_accessor :width
def area
self.width * self.width
end
def scale(s)
self.width *= s
end
# << More and more methods are going to appear here >>
end
class Rectangle < Square
def initialize(width, height)
super(width)
@height = height
end
attr_accessor :height
def area
self.width * self.height
end
def scale(s)
super(s)
self.height *= s
end
end
shapes = [
Rectangle.new(3, 7),
Square.new(2)
]
shapes.each do |shape|
shape.scale(3)
p shape.area # OK
end
A step back
The fundamental problem is that inheritance does two things:
- It creates an is-a relationship.
- It reuses code.
These two things are in tension; in the code above, they are in direct opposition. We tried to reuse width, but now we have to jump through all kinds of hoops to keep the is-a relationship from breaking.
A common symptom of misuse of inheritance is a base class that becomes a dumping ground with a huge number of methods. Here’s a classic example from Microsoft’s old Windows API. (They really messed up with that one.)
Answer 2: Square ⊂ Rectangle
Instead of aiming for code reuse, let’s honor the is-a relationship: every square is a rectangle, so Square should extend Rectangle:
class Rectangle
def initialize(width, height)
@width = width
@height = height
end
attr_accessor :width
attr_accessor :height
end
class Square < Rectangle
def initialize(width)
super(width, width)
end
# Ensure width and height are always the same
def width=(width)
@width = width
@height = width
end
def height=(height)
@width = height
@height = height
end
end
# Now this works!!
def area(rect)
rect.width * rect.height
end
p area(Square.new(2)) # Yes!
p area(Rectangle.new(3, 7)) # Hooray!
This is a better approach. But…
def scale(rect, s)
rect.width *= s
rect.height *= s
end
r = Rectangle.new(3, 7)
scale(r, 2)
p area(r) # OK, good
s = Square.new(2)
s.height = 3
scale(s, 2) # oh no
p s # OH NO
p area(s) # OOOOOHHH NOOOOOOOO
What happened? Run the code. Trace through it. See if you can figure it out.
The fundamental problem
Liskov Substitution Principle (LSP):
Given two types S and T, if S ⊂ T, then all important properties / characteristics of T must also hold for S.
The result is that it should work to substitute a value of type S in any context that expects a T, because every S is a T.
Although Answer 2 honors the is-a relationship, both answers violate this principle.
Answer 1 violates LSP because the supertype (Square) has the property that all its sides are equal, but that does not hold true for the subtype (Rectangle).
Answer 2 violates LSP because the supertype (Rectangle) has the property that setting width does not alter height and vice versa, but that does not hold true for the subtype (Square).
It turns out there’s not really a good way to model the square/rectangle relationship using inheritance. This just isn’t a thing that OOP does well. The usual solution in practical contexts is to just have a single Rectangle class, and make code that needs a square check at runtime that the width and height are equal — leaving the type system out of it.