This lesson is not translated yet – the Russian original is below.
TODO: отвалидировать – текст собран автоматически из материалов курса на Google Drive, проверьте формулировки и код.
Объекты дочерних классов должны полностью заменять объекты базовых классов без изменения правильности программы.
Если где-то используется базовый тип, подтип должен работать там же, не ломая логику. Наследник не должен ужесточать контракты родителя и нарушать его поведение.
Классический пример
public class Rectangle
{
public virtual int Width { get; set; }
public virtual int Height { get; set; }
public int Area => Width * Height;
}
// Квадрат «является» прямоугольником в геометрии, но не в коде
public class Square : Rectangle
{
public override int Width
{
set { base.Width = value; base.Height = value; }
}
public override int Height
{
set { base.Width = value; base.Height = value; }
}
}
// Код, написанный для Rectangle, ломается на Square
void Resize(Rectangle r)
{
r.Width = 4;
r.Height = 5;
// Ожидаем 20, для квадрата получим 25
Console.WriteLine(r.Area);
}
class Rectangle {
public:
virtual void set_width(int value) { width_ = value; }
virtual void set_height(int value) { height_ = value; }
int Area() const { return width_ * height_; }
virtual ~Rectangle() = default;
protected:
int width_ = 0;
int height_ = 0;
};
// Квадрат «является» прямоугольником в геометрии, но не в коде
class Square : public Rectangle {
public:
void set_width(int value) override {
width_ = value;
height_ = value;
}
void set_height(int value) override {
width_ = value;
height_ = value;
}
};
// Код, написанный для Rectangle, ломается на Square
void Resize(Rectangle& r) {
r.set_width(4);
r.set_height(5);
// Ожидаем 20, для квадрата получим 25
std::cout << r.Area() << std::endl;
}
// В Go наследования нет, и подменить поведение через встраивание не выйдет:
// метод базового типа продолжит работать с базовыми полями.
// Нарушение LSP здесь проявляется на интерфейсах.
type Shape interface {
Resize(width, height int)
Area() int
}
type Rectangle struct {
width, height int
}
func (r *Rectangle) Resize(w, h int) { r.width, r.height = w, h }
func (r *Rectangle) Area() int { return r.width * r.height }
type Square struct {
side int
}
// Квадрат вынужден игнорировать один из аргументов – контракт нарушен
func (s *Square) Resize(w, h int) { s.side = h }
func (s *Square) Area() int { return s.side * s.side }
func Resize(s Shape) {
s.Resize(4, 5)
// Ожидаем 20, для квадрата получим 25
fmt.Println(s.Area())
}
class Rectangle:
def __init__(self):
self._width = 0
self._height = 0
@property
def width(self):
return self._width
@width.setter
def width(self, value):
self._width = value
@property
def height(self):
return self._height
@height.setter
def height(self, value):
self._height = value
@property
def area(self):
return self._width * self._height
# Квадрат «является» прямоугольником в геометрии, но не в коде
class Square(Rectangle):
@Rectangle.width.setter
def width(self, value):
self._width = value
self._height = value
@Rectangle.height.setter
def height(self, value):
self._width = value
self._height = value
def resize(r: Rectangle) -> None:
r.width = 4
r.height = 5
# Ожидаем 20, для квадрата получим 25
print(r.area)
class Rectangle {
protected w = 0;
protected h = 0;
set width(value: number) {
this.w = value;
}
set height(value: number) {
this.h = value;
}
get area(): number {
return this.w * this.h;
}
}
// Квадрат «является» прямоугольником в геометрии, но не в коде
class Square extends Rectangle {
set width(value: number) {
this.w = value;
this.h = value;
}
set height(value: number) {
this.w = value;
this.h = value;
}
}
function resize(r: Rectangle): void {
r.width = 4;
r.height = 5;
// Ожидаем 20, для квадрата получим 25
console.log(r.area);
}
public class Rectangle {
protected int width;
protected int height;
public void setWidth(int width) {
this.width = width;
}
public void setHeight(int height) {
this.height = height;
}
public int getArea() {
return width * height;
}
}
// Квадрат «является» прямоугольником в геометрии, но не в коде
public class Square extends Rectangle {
@Override
public void setWidth(int width) {
this.width = width;
this.height = width;
}
@Override
public void setHeight(int height) {
this.width = height;
this.height = height;
}
}
void resize(Rectangle r) {
r.setWidth(4);
r.setHeight(5);
// Ожидаем 20, для квадрата получим 25
System.out.println(r.getArea());
}
open class Rectangle {
protected var w = 0
protected var h = 0
open var width: Int
get() = w
set(value) {
w = value
}
open var height: Int
get() = h
set(value) {
h = value
}
val area: Int get() = w * h
}
// Квадрат «является» прямоугольником в геометрии, но не в коде
class Square : Rectangle() {
override var width: Int
get() = w
set(value) {
w = value
h = value
}
override var height: Int
get() = h
set(value) {
w = value
h = value
}
}
fun resize(r: Rectangle) {
r.width = 4
r.height = 5
// Ожидаем 20, для квадрата получим 25
println(r.area)
}
Что даёт соблюдение
- Полиморфизм действительно работает. Код, написанный под базовый тип, пишется один раз и не требует правок при появлении нового наследника.
- Исчезают проверки типа. Пропадает
if (obj is Square)– а вместе с ним и вопрос «все ли места я поправил, когда добавил новый подкласс». - Тесты базового типа проверяют всех наследников. Один набор проверок на контракт, прогоняемый для каждой реализации, ловит нарушения автоматически.
- Иерархию можно читать сверху вниз. Достаточно знать базовый класс, чтобы понимать, что делает любой из его потомков.
Как это ловить
Нарушение LSP почти всегда выглядит одинаково: в коде появляется проверка типа наследника или метод, который у одного из наследников выбрасывает «не поддерживается». Оба признака означают, что иерархия построена неверно, а отношение между классами – не «является».