Всем привет, меня зовут Константин. Я занимаюсь разработкой на 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 на примере из реального проекта.