Статьи

SOLID — принцип замещения Лискова

Это третья из серии публикаций, посвященных принципам программного обеспечения SOLID. Ранее мы рассматривали принцип единой ответственности и принцип Open-Close . В этом посте я проведу вас через L в SOLID, принцип подстановки Лискова.

Основная идея, лежащая в основе принципа Open-Closed, достигается с помощью наследования, то есть введения новых классов для новой функциональности и сохранения классов, связанных с существующей функциональностью, без изменений. Но что отличает хорошую структуру наследования от плохой? Вот тут-то и вступает в действие принцип подстановки Лискова.

Принцип подстановки Лискова — это простой, но эффективный способ улучшить код. Однако не так просто определить, когда код нарушает принцип. Как правило, фрагмент кода, который нарушает принцип подстановки Лискова, также нарушает принцип Open-Close.

Принцип замещения Лискова

Первоначально написано Барбарой Лисков, он утверждает:

Если для каждого объекта o1 типа S существует объект o2 типа T, такой, что для всех программ P, определенных в терминах T, поведение P остается неизменным, когда o1 заменяется на o2, тогда S является подтипом T

Начиная с оригинальной статьи, появились более простые определения, и они выглядят так:

Если S является подтипом T, то объекты типа T могут быть заменены объектами типа S без изменения каких-либо желательных свойств программы.

Это звучит достаточно просто, но это больше, чем кажется на первый взгляд. Кроме того, существует ряд требований, которым должен следовать каждый производный класс при наследовании от базового класса.

Требования к подписи

Требования к подписи наследования будут объяснены на примере. Предположим, существует три класса, так что направление отношений — «Автомобиль» — «Автомобиль»> «Форд», т.е. «Автомобиль» является супертипом «Автомобиль», а «Автомобиль» является базовым классом «Форд».

Контравариантность аргументов метода в подтипе

Согласно этому правилу, дисперсия должна быть противоположна направлению отношения наследования, т.е. каждый входной параметр в методах подтипа S должен быть одинаковым или супертипом соответствующих входных параметров в базовом классе T.

01
02
03
04
05
06
07
08
09
10
11
12
//Supertype
void drive(Car v);
 
//Invalid subtype
void drive(Ford f);
 
//Valid subtype
void drive(Vehicle v);
 
//or
 
void drive(Car c);

Ковариантность возвращаемых типов в подтипе

Это означает, что дисперсия должна быть в направлении отношения наследования, то есть выходные данные каждого метода подтипа S должны быть одинаковыми или подтипом соответствующих выходных параметров в базовом классе T

01
02
03
04
05
06
07
08
09
10
11
12
//Supertype
Car getInstance();
 
//Invalid subtype
Vehicle getInstance();
 
//Valid subtype
Car getInstance();
 
//or
 
Ford getInstance();

Нет новых исключений

Никакие новые исключения не должны создаваться методами подтипа, за исключением тех случаев, когда сами эти исключения являются подтипами исключений, создаваемых методами базового класса.

01
02
03
04
05
06
07
08
09
10
11
12
//Supertype
Car getInstance() throws CarNotFoundException;
 
//Invalid subtype
Vehicle getInstance() throws VehicleNotFoundException;
 
//Valid subtype
Car getInstance() throws CarNotFoundException;
 
//or
 
Ford getInstance() throws FordNotFoundException;

Требования к сигнатуре, как правило, легко распознать, потому что в большинстве статически типизированных современных языков компилятор обеспечивает безопасность типов и указывает на ошибку. Трудно распознать поведенческие требования.

Поведенческие условия, которым должно удовлетворять наследство, рассматриваются далее.

Поведенческие требования

Инварианты супертипа должны быть сохранены в подтипе

Инвариант — это условие, на которое можно положиться при выполнении программы. Инвариантность должна оставаться неизменной при реализации подтипа.

Предпосылки не могут быть усилены в подтипе

Предварительным условием является условие или предикат, которые всегда должны быть истинными непосредственно перед выполнением некоторого раздела кода. Предпосылки могут быть изменены в подтипе, но они могут быть только ослаблены, но не усилены.

Постусловия не могут быть ослаблены в подтипе

Постусловие — это условие или предикат, которые всегда должны быть истинными сразу после выполнения некоторого раздела кода. Постусловия могут быть изменены в подтипе, но они могут быть только усилены, но не ослаблены.

Давайте посмотрим, что они имеют в виду, беря обычно используемый пример.

пример

Математически квадрат — это прямоугольник. Обратите внимание на слова « это ». Однако при кодировании квадрат действительно является прямоугольником с равными обеими сторонами? Большинство людей сказали бы «да», и они неверно истолковывают отношение « есть » и моделируют отношения между прямоугольником и квадратом с наследованием.

В этом случае вы должны иметь возможность использовать Квадрат везде, где можете использовать класс Rectangle, не так ли? Но это приводит к неожиданным проблемам и нарушению принципа подстановки Лискова. Давайте посмотрим, почему?

Давайте определим класс Rectangle следующим образом с требованиями к поведению, как упомянуто во встроенных комментариях.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
/* The rectangle class and its behavioural conditions */
 
public class Rectangle {
    /*   
     * Invarients:
     * Height and width cannot change together in the same method i.e.
     *  setHeight() should not change width
     *  setWidth() should not change height
     */
    private int height;
    private int width;
 
    /*
     * Post condition:
     * height==newHeight && width==oldwidth
     *
     * Pre condition:
     * The rectangle is not a rhombus, if it is, then it is a square.
     * The setHeight should only execute on a rectangle.
     */
    public void setHeight(int newHeight) {
        this.height = newHeight;
    }
 
    /*
     * Post condition:
     * width==newWidth && height==oldHeight
     *
     * Pre condition:
     * The rectangle is not a rhombus, if it is, then it is a square.
     * The setWidth should only execute on a rectangle.
     */
    public void setWidth(int newWidth) {
        this.width = newWidth;
    }
     
    public int getWidth() {
        return width;
    }
     
    public int getHeight() {
        return height;
    }
}

В первой попытке использование наследования для расширения прямоугольника на квадрат, вероятно, приведет к чему-то вроде:

01
02
03
04
05
06
07
08
09
10
11
12
public class Square extends Rectangle {
 
    @Override
    public void setHeight(int height) {
        super.setHeight(height);
    }
 
    @Override
    public void setWidth(int width) {
        super.setWidth(width);
    }
}

Но есть проблема, не так ли? Вы можете получить два разных измерения для сторон — и когда это произойдет, это определенно не квадрат. Легко исправить. Большинство людей сделали бы это.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
public class Square extends Rectangle {
 
    @Override
    public void setHeight(int height) {
        super.setHeight(height);
        super.setWidth(height);
    }
 
    @Override
    public void setWidth(int width) {
        super.setWidth(width);
        super.setHeight(width);
    }
}

Приведенный выше код не соответствует принципу подстановки Лискова для всех правил поведения. т.е. когда мы передаем экземпляр Square, используя тип Rectangle.

  1. Согласно правилу инварианта, указанному в базовом классе Rectangle, не разрешается устанавливать оба измерения в одном и том же методе. Таким образом, правило поведения на инварианте не выполняется. Это само по себе является достаточно веской причиной, чтобы не моделировать Square, наследуя его от Rectangle.
  2. Постусловие, определенное в базовом классе Rectangle, не будет выполнено для методов setHeight и setWidth. Поскольку мы изменяем как высоту, так и ширину как в setWidth (), так и в setHeight (), проверка старых значений завершится неудачно. Мы не можем удалить условие post, чтобы проверить старые значения, потому что это ослабит post-условие.
  3. Предварительное условие не будет выполнено, потому что операция над базовым классом разрешена, только если объект не является ромбом. Поскольку квадрат — это ромбы, а прямоугольник — это не ромб, предварительное условие, существующее в классе Rectangle, не будет выполнять код, когда в него передается объект Square. Мы не можем добавить никаких условий, чтобы разрешить операцию, если она является квадратом, потому что это усилит предварительное условие.

Итак, как правильно моделировать наследование?

Наиболее важным аспектом наследования является то, что мы должны моделировать наследование на основе поведения, а не свойств объекта .

Общая тенденция заключается в моделировании объектов в коде на основе свойств объектов реального мира. Но это оказывается неверным, потому что объекты в коде не являются реальными объектами, они просто представляют реальный объект .

Например, прямоугольные и квадратные объекты в предыдущих примерах сами по себе не являются прямоугольниками и квадратами, они просто представляют эти фигуры. Представления не всегда должны иметь те же отношения между ними, что и реальные объекты, которые они представляют . В качестве другого примера, когда я играю в видеоигры с моей женой, мы оба представлены нашими аватарами для xbox, но сами аватары не женаты друг на друге!

Ссылка: SOLID: принцип замещения Liskov от нашего партнера JCG Дипака Каранта в блоге Software Yoga .