Всем привет, меня зовут Константин. Я занимаюсь разработкой на Java в Tinkoff.ru и люблю SOLID. В этой статье мы сформулируем принцип подстановки Лисков, покажем его связь с принципом Открытости-Закрытости, узнаем, как правильно формировать иерархию наследования и ответим на философский вопрос о том, является ли квадрат прямоугольником.

Прежде чем перейти непосредственно к рассматриваемой теме, я хочу сделать шаг назад и обозначить очень важный тезис, необходимый для правильного и полного понимания того, о чем будет говориться в статье.
Вспомним принцип открытости-закрытости. Он говорит о том, что в хорошо спроектированных программах новая функциональность вводится путем добавления нового кода, а не изменением старого, уже работающего. И это есть основа написания поддерживаемого и переиспользуемого кода. Принцип подстановки Лисков (далее LSP) — это по сути гайдлайн того, как реализовать этот принцип при построении иерархии наследования классов в объектно-ориентированных языках программирования. По сути правильная иерархия наследования в ООП — это иерархия, построенная согласно LSP, чтобы отвечать принципу открытости-закрытости.
Давайте же его сформулируем:
Функции, использующие указатели или ссылки на базовые классы, должны иметь возможность использовать объекты классов-наследников, не зная об этом.
В первый раз Барбара Лисков сформулировала его так:
Для этого нужно, чтобы выполнялось следующее свойство подстановки: если для каждого объекта o1 типа S существует такой объект o2 типа T, что для всех программ P, определенных в терминах T, поведение программы P не изменяется, если вместо o2 подставить o1, то S — подтип T.
Таким образом, если функция не отвечает LSP, то это фактическ�� значит, что, если она использует указатель или ссылку на базовый класс, то должна знать обо всех наследниках базового класса. Такая функция нарушает принцип открытости-закрытости, потому что ее необходимо изменять каждый раз, когда появляется новый наследник базового класса.
Простой пример нарушения LSP
Представим, что у нас есть такая функция:
void drawShape(Shape shape) {
if (shape instanceof Square) {
drawSquare((Square) shape);
} else {
drawCircle((Circle) shape);
}
}В ней используется определение класса во время выполнения и в зависимости от результата вызывается нужная функция с явным приведением класса аргумента. И если добавится новый наследник класса Shape, ее нужно будет изменить, так как иначе произойдет вызов drawCircle, которая в лучшем случае выкинет ошибку, а в худшем — отработает некорректно.
То есть drawShape должна знать обо всех наследниках класса Shape. Более того, каждый раз, когда мы вводим нового наследника класса Shape либо удаляем его, мы должны вносить в неё изменения. То есть функция не закрыта от изменений в иерархии классов Shape, что по сути и является нарушением принципа открытости-закрытости.
Квадрат и прямоугольник
Теперь давайте рассмотрим, как можно нарушить LSP не столь очевидным способом. Предположим, мы разрабатываем программу, которая работает с геометрическими фигурами. В ней есть класс для работы с прямоугольниками:
public class Rectangle {
private int width;
private int height;
public int getWidth() {
return width;
}
public void setWidth(int width) {
this.width = width;
}
public int getHeight() {
return height;
}
public void setHeight(int height) {
this.height = height;
}
public int perimeter() {
return 2 * height + 2 * width;
}
}Пока все хорошо. Однако заказчик приходит к нам с требованием, что помимо прямоугольников пользователям нужен удобный функционал для работы с квадратами. Так как мы работаем в парадигме ООП, которая, в частности, говорит нам, что классы должны моделировать объекты реального мира, то наша первая мысль — нужно ввести класс Square. Но как именно?
Известно, что наследование реализует отношение «является» (ISA). То есть, когда мы говорим, что новый тип объекта находится в отношении ISA со старым типом объекта, это значит, что новый класс должен быть наследником старого.
Квадрат — это очевидно прямоугольник. Следовательно, они находятся в отношении ISA. В свою очередь из этого следует то, что класс Square должен быть наследником класса Rectangle. Казалось бы, все логично, однако в наших рассуждениях есть изъян, который может привести к неочевидным, но серьезным проблемам.
Для начала обратим внимание на то, что классу Square не нужны оба поля height и width, достаточно одного поля, которое можно было просто назвать side, однако же при такой реализации он их унаследует. Помимо того, что это просто приводит к лишней трате памяти на хранение одного ненужного поля (а в случае, если программа генерирует достаточно много объектов данного класса, это может стать серьезной проблемой), оно также приводит к усложнению кода. Дело в том, что класс Square также наследует setWidth и setHeight. Очевидно, что их необходимо переопределить следующим образом:
@Override
public void setWidth(int width) {
super.setWidth(width);
super.setHeight(width);
}
@Override
public void setHeight(int height) {
super.setHeight(height);
super.setWidth(height);
}
Теперь при установке ширины объекта Square будет установлена и его длина. То же самое при установке длины. Таким образом гарантируется целостность инвариантов объектов Square. С математической точки зрения они будут оставаться корректными фигурами. Тем не менее, помимо того, что теперь наш код содержит лишние вызовы функций, это еще и страшный выстрел в ногу, и сейчас мы увидим почему.
Тесты
Предположим, при написании класса Rectangle мы написали простой тест:
public class RectangleTest {
@Test
public void perimeter() {
Rectangle rectangle = new Rectangle();
rectangle.setHeight(5);
rectangle.setWidth(7);
int result = rectangle.perimeter();
assertEquals(24, result);
}
} Очевидно, что при введении класса Square мы должны написать тесты и для него. Так как класс Square находится в соотношении ISA с классом Rectangle, будет логично предположить, что тесты на Rectangle должны быть справедливы и для Square. А чтобы не дублировать код, мы напишем тесты следующим образом:
public class RectangleTest {
@Test
public void perimeter() {
Rectangle rectangle = initRectangle();
rectangle.setHeight(5);
rectangle.setWidth(7);
int result = rectangle.perimeter();
assertEquals(24, result);
}
protected Rectangle initRectangle() {
return new Rectangle();
}
}
public class SquareTest extends RectangleTest {
@Override
protected Rectangle initRectangle() {
return new Square();
}
}Очевидно, что тест SquareTest.perimeter провалится, так как результат вызова функции perimeter будет не 24, как написано в тесте, а 28. И тут мы долж��ы задать себе очень важный вопрос: правильно ли написан тест, в котором предполагается, что при изменении длины прямоугольника его ширина не изменяется? Очевидно — да. Наш тест наглядно демонстрирует код, который корректно работает с объектом класса Rectangle, но ломается при работе с объектами класса Square. То есть не для каждого объекта типа Square существует объект типа Rectangle такой, что определённая в терминах Rectangle программа (в данном случае тест) не меняется, если вместо объекта типа Rectangle подставить объект типа Square. Следовательно Square — не подтип Rectangle, следовательно LSP нарушается.
Так квадрат — это не прямоугольник?
Квадрат — это, конечно, прямоугольник, но вот объект класса Square — это определенно не объект класса Rectangle. Дело в том, что поведение объекта класса Square не согласовано с поведением объекта класса Rectangle. Ведь квадрат ведет себя иначе, чем прямоугольник.
Принцип подстановки Лисков наглядно показывает, что в ООП отношение ISA относится именно к поведению. Причем не к внутреннему, а к внешнему поведению, от которого зависят клиенты. Мы всегда должны помнить об этом, когда говорим про моделирование объектов реального мира в парадигме ООП.
С этой точки зрения важность написания тестов становится еще более очевидной. Ведь тесты — это тоже клиенты нашей модели, которые позволяют проверить ее правильность с точки зрения предположений, которые могут сделать пользователи. Ведь понять, правильна модель или нет, мы можем только через ее использование.
Ну а что тогда делать?
Тут я буду банален: все зависит от задачи. Не существует единственно верной модели на все случаи жизни. Мы всегда должны отталкиваться от требований к нашему программному обеспечению и той функциональности, которую хотим предоставить.
Например, и у квадрата, и у прямоугольника есть периметр и, вероятно, в некоторых частях программы нам нужны его значения независимо от того, с какой фигурой мы работаем. В этом случае легко вынести этот метод в интерфейс Shape и прописать его имплементации в классах Square и Rectangle.
Также и у квадрата, и у прямоугольника есть четыре стороны и четыре угла. Возможно, мы захотим получать координаты этих углов в пространстве. Тогда можно вынести интерфейс либо абстрактный класс Quadrangle с соответствующими методами, он также может пригодиться, если мы захотим ввести в свою программу ромбы, параллелограммы, трапеции и прочее. Вариантов великое множество.
Заключение
Принцип открытости-закрытости говорит нам о том, что в поддерживаемых и переиспользуемых программах новая функциональность вводится путем добавления нового кода, а не изменением уже существующего.
Принцип подстановки Лисков поясняет нам, как этого добиться, когда мы говорим о построении иерархий классов. Если код, оперирующий ссылками на базовые классы, должен знать обо всех его наследниках и изменяться с появлением каждого нового наследника, то этот код не отвечает принципу подстановки Лисков, а значит, не отвечает и принципу открытости-закрытости.
На эту статью меня во многом вдохновила статья Роберта Мартина 1996 года — The Liskov Substitution Principle. Очень рекомендую с ней ознакомиться. В ней также разобран пример с квадратом и прямоугольником, но с особенностями, специфическими для C++. Кроме того, рассматривается тема проектирования по контракту, а также дан интересный пример нарушения LSP на примере из реального проекта.
