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