1. О чем статья

Принципы проектирования SOLID были представлены Робертом Мартином в его книге “Design Principles and Design Patterns” в 2000 году. Эти принципы помогают нам создавать более гибкое программное обеспечение, которое легко понимать и обслуживать

В этой статье мы обсудим “Принцип подстановки Барбары Лискофф”, который соответствует букве L в акрониме SOLID.

2. Принцип открытости/закрытости

Для того, чтобы понять принцип подстановки Лисков, нам нужно сначала понять принцип Открытости/Закрытости, это O в аббревиатуре SOLID. Принцип открытости/закрытости стимулирует нас проектировать наши программы так, чтобы добавлять новые фичи только добавляя новый код, а не модифицируя уже написанный. Когда это возможно, мы получаем слабую связанность классов в программе и вследствие приложение легче обслуживать. 

3. Пример использования принципа открытости/закрытости

Давайте посмотрим на пример банковского приложения, чтобы лучше понять принцип открытости/закрытости.

3.1. Без применения принципа открытости/закрытости

Наше банковское приложение поддерживает два типа счетов (аккаунтов): “текущий” и “сберегательный”. Они представлены классами CurrentAccount и SavingsAccount соответственно.

Сервис BankingAppWithdrawalService предоставляет функциональность вывода средств со счета.

К сожалению, есть проблема с расширением этого решения. Сервис BankingAppWithdrawalService знает про две конкретные реализации банковского аккаунта. Поэтому ​​BankingAppWithdrawalService нужно будет изменять каждый раз, когда будет добавляться новый тип аккаунта. 

3.2. Используем принцип открытости/закрытости, чтобы сделать код расширяемым

Давайте перепроектируем решение, чтобы оно соответствовало принципу открытости/закрытости. Мы защитим сервис ​​BankingAppWithdrawalService от модификации при добавлении новых типов аккаунтов через создание базового класса Account:

Мы представили новый абстрактный класс Account, который расширяют классы CurrentAccount и SavingsAccount. BankingAppWithdrawalService больше не зависит от конкретных реализаций аккаунта, поскольку зависит только от абстрактного класса и не потребует изменений, когда будет добавлен новый тип аккаунта.

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

3.3. Код на Java

Рассмотрим пример на Java. Для начала, определим класс Account:

public abstract class Account {
    protected abstract void deposit(BigDecimal amount);

    /**
     * Reduces the balance of the account by the specified amount
     * provided given amount > 0 and account meets minimum available
     * balance criteria.
     *
     * @param amount
     */
    protected abstract void withdraw(BigDecimal amount);
}

Определим BankingAppWithdrawalService: 

public class BankingAppWithdrawalService {
    private Account account;

    public BankingAppWithdrawalService(Account account) {
        this.account = account;
    }

    public void withdraw(BigDecimal amount) {
        account.withdraw(amount);
    }
}

А теперь посмотрим, как при таком дизайне классов добавление нового типа аккаунта может нарушить принцип подстановки Лисков. 

3.4. Новый тип аккаунта

Банк хочет предложить клиентам новый тип депозита с высокой процентной ставкой и фиксированным сроком. Давайте создадим новый класс FixedTermDepositAccount, чтобы поддержать эту функциональность. Депозит с фиксированным сроком это тип счета. Поэтому сделаем FixedTermDepositAccount наследником типа Account. 

public class FixedTermDepositAccount extends Account {
    // Overridden methods...
}

Тем не менее, банк не хочет позволять выводить средства со срочных депозитов. Это значит, что новый класс FixedTermDepositAccount не должен предоставлять метод withdraw, который есть в Account. Один из вариантов решения - чтобы в реализации метода withdraw класса FixedTermDepositAccount выбрасывалось исключение UnsupportedOperationException:

public class FixedTermDepositAccount extends Account {
    @Override
    protected void deposit(BigDecimal amount) {
        // Deposit into this account
    }

    @Override
    protected void withdraw(BigDecimal amount) {
        throw new UnsupportedOperationException("Withdrawals are not supported by FixedTermDepositAccount!!");
    }
}

3.5. Тестируем использование нового типа аккаунта

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

Account myFixedTermDepositAccount = new FixedTermDepositAccount();
myFixedTermDepositAccount.deposit(new BigDecimal(1000.00));

BankingAppWithdrawalService withdrawalService = new BankingAppWithdrawalService(myFixedTermDepositAccount);
withdrawalService.withdraw(new BigDecimal(100.00));

Неудивительно, что код падает с ошибкой

Withdrawals are not supported by FixedTermDepositAccount!!

Здесь точно что-то не то с дизайном потому, что комбинация допустимых объектов приводит к ошибке.  

3.6. Что пошло не так?

Сервис BankingAppWithdrawalService - клиент класса Account. Сервис ожидает, что Account и его наследники гарантируют поведение, которое задекларировано в классе Account для метода withdraw:

/**
 * Reduces the account balance by the specified amount
 * provided given amount > 0 and account meets minimum available
 * balance criteria.
 *
 * @param amount
 */
protected abstract void withdraw(BigDecimal amount);

Тем не менее, не поддерживая метод withdraw, класс FixedTermDepositAccount нарушает спецификацию этого метода. Таким образом, мы не можем вместо FixedTermDepositAccount подставить его родительский класс Account так, чтобы все работало так как нужно. Другими словами, FixedTermDepositAccount нарушает принцип подстановки Лисков.

3.7. Можем ли мы обработать ошибку в BankingAppWithdrawalService?

Мы могли бы изменить дизайн так, чтобы пользователь метода withdraw класса Account был бы в курсе потенциальной ошибки при вызове. Однако, это означало бы, что клиенту требуется специальное знание о поведении подтипа. Это нарушает принцип открытости/закрытости. 

Другими словами, для того, чтобы принцип открытости/закрытости работал нормально, все подтипы должны быть заменимыми на свой супертип без необходимости модифицировать код клиента. Соблюдение принципа подстановки Лисков обеспечивает эту взаимозаменяемость.

Давайте теперь подробно рассмотрим принцип подстановки Лисков.

4. Принцип подстановки Барбары Лисков

4.1. Определение

Роберт С. Мартин:

Подтипы должны быть заменяемы на своих базовые типы.

Барбара Лисков, давшая определение принципа в 1988 году, дала более математическое определение:

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

Давайте разберемся в этих определениях.

4.2. Когда подтип заменим на супертип?

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

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

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

Это дополнительное ограничение, которое принцип подстановки Лисков накладывает на объектно-ориентированное проектирование.

Давайте теперь реорганизуем наше банковское приложение, чтобы решить проблемы, с которыми мы столкнулись ранее.

5. Рефакторинг

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

5.1. Главная причина

В этом примере наш FixedTermDepositAccount не был поведенческим подтипом Account.

В дизайне Account неправильно предполагалось, что все подтипы Account допускают снятие средств. Следовательно, все подтипы Account, включая FixedTermDepositAccount, который не поддерживает снятие средств, унаследовали метод снятия.

Хотя мы могли бы обойти это, расширив контракт Account, существуют альтернативные решения.

5.2. Пересмотренная диаграмма классов

Давайте по-другому спроектируем нашу иерархию счетов:

Поскольку не все типы счетов поддерживают снятие средств, мы переместили метод withdraw из класса Account в новый абстрактный подкласс WithdrawableAccount. И CurrentAccount, и SavingsAccount позволяют снимать средства. Так что теперь они стали подклассами нового WithdrawableAccount. 

Это означает, что BankingAppWithdrawalService может доверять правильному типу счета для обеспечения функции снятия средств.

5.3. Переработанный BankingAppWithdrawalService

BankingAppWithdrawalService теперь необходимо использовать WithdrawableAccount:

public class BankingAppWithdrawalService {
    private WithdrawableAccount withdrawableAccount;

    public BankingAppWithdrawalService(WithdrawableAccount withdrawableAccount) {
        this.withdrawableAccount = withdrawableAccount;
    }

    public void withdraw(BigDecimal amount) {
        withdrawableAccount.withdraw(amount);
    }
}

Что касается FixedTermDepositAccount, мы сохраняем Account в качестве его родительского класса. Следовательно, он наследует только функциональность deposit, которое он может надежно выполнить, и больше не наследует метод вывода средств, который ему не нужен. Этот новый дизайн позволяет избежать проблем, которые мы видели ранее.

6. Правила

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

В своей книге «Разработка программ на Java: абстракция, спецификация и объектно-ориентированное проектирование» Барбара Лисков и Джон Гуттаг сгруппировали эти правила в три категории — правила сигнатуры, правила свойств и правила методов.

Некоторые из этих практик уже применяются в Java в правилах переопределения.

6.1. Правило сигнатуры - типы аргументов метода

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

Правила переопределения методов Java поддерживают это правило, обеспечивая точное совпадение типов аргументов переопределенного метода с типами аргументов метода супертипа.

6.2. Правило сигнатуры - возвращаемые типы

Тип возвращаемого значения переопределенного метода подтипа может быть уже, чем тип возвращаемого значения метода супертипа. Это называется ковариацией возвращаемых типов. Ковариация указывает, когда подтип принимается вместо супертипа. Java поддерживает ковариантность возвращаемых типов. Давайте посмотрим на пример:

public abstract class Foo {
    public abstract Number generateNumber();    
    // Other Methods
}

Метод generateNumber в Foo имеет возвращаемый тип Number. Давайте теперь переопределим этот метод, возвращая более узкий тип Integer:

public class Bar extends Foo {
    @Override
    public Integer generateNumber() {
        return new Integer(10);
    }
    // Other Methods
}

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

С другой стороны, если бы переопределенный метод в Bar возвращал более широкий тип, чем Number, например, Object, который может включать любой подтип Object, например, Truck, то любой клиентский код, который полагался на возвращаемый тип Number, не смог бы обработать Truck!

К счастью, правила переопределения методов Java не позволяют переопреденному методу возвращать более широкий тип.

6.3. Правило сигнатуры - исключения

Метод подтипа может генерировать меньше исключений или более узкие (но не дополнительные или более широкие) исключения, чем у метода супертипа.

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

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

6.4. Правило настроек - инварианты класса

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

Давайте посмотрим на пример:

public abstract class Car {
    protected int limit;

    // invariant: speed < limit;
    protected int speed;

    // postcondition: speed < limit
    protected abstract void accelerate();

    // Other methods...
}

Класс Car указывает инвариант класса, согласно которому значение переменной speed всегда должно быть ниже значения переменной limit. Правило инвариантов гласит, что все методы подтипа (унаследованные и новые) должны поддерживать или усиливать инварианты класса супертипа.

Давайте определим подкласс Car, сохраняющий инвариант класса:

public class HybridCar extends Car {
    // invariant: charge >= 0;
    private int charge;

      @Override
    // postcondition: speed < limit
    protected void accelerate() {
        // Accelerate HybridCar ensuring speed < limit
    }

    // Other methods...
}

В этом примере инвариант в Car сохраняется с помощью переопределенного метода accelerate в HybridCar. HybridCar дополнительно определяет свой собственный инвариант charge >= 0, и это совершенно нормально.

А если инвариант класса не сохраняется подтипом, это нарушает работу любого клиентского кода, основанного на супертипе.

6.5. Правило свойств - ограничения истории

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

Давайте посмотрим на пример:

public abstract class Car {

    // Allowed to be set once at the time of creation.
    // Value can only increment thereafter.
    // Value cannot be reset.
    protected int mileage;

    public Car(int mileage) {
        this.mileage = mileage;
    }

    // Other properties and methods...

}

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

Давайте теперь определим ToyCar, который расширяет Car:

public class ToyCar extends Car {
    public void reset() {
        mileage = 0;
    }

    // Other properties and methods
}

У ToyCar есть дополнительный метод reset, который сбрасывает свойство mileage. При этом ToyCar игнорирует ограничение, наложенное его родителем на свойство mileage. Это ломает любой клиентский код, основанный на ограничении в родительском типе. Итак, ToyCar не может быть заменен на Car.

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

6.6. Правило методов - предусловия

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

public class Foo {

    // precondition: 0 < num <= 5
    public void doStuff(int num) {
        if (num <= 0 || num > 5) {
            throw new IllegalArgumentException("Input out of range 1-5");
        }
        // some logic here...
    }
}

Здесь предварительное условие для метода doStuff гласит, что значение параметра num должно находиться в диапазоне от 1 до 5. Мы усилили это предварительное условие проверкой диапазона внутри метода. Подтип может ослабить (но не усилить) предварительное условие для переопределяемого им метода. Когда подтип ослабляет предварительное условие, он ослабляет ограничения, налагаемые методом супертипа.

Давайте теперь переопределим метод doStuff с ослабленным предварительным условием:

public class Bar extends Foo {

    @Override
    // precondition: 0 < num <= 10
    public void doStuff(int num) {
        if (num <= 0 || num > 10) {
            throw new IllegalArgumentException("Input out of range 1-10");
        }
        // some logic here...
    }
}

Здесь предварительное условие в переопределенном методе doStuff ослаблено до 0 < num <= 10, что позволяет использовать более широкий диапазон значений для num. Все значения num, допустимые для Foo.doStuff, действительны и для Bar.doStuff. Следовательно, клиент Foo.doStuff не замечает разницы, когда заменяет Foo на Bar.

И наоборот, когда подтип усиливает предварительное условие (например, 0 < num <= 3 в нашем примере), он применяет более строгие ограничения, чем супертип. Например, значения 4 и 5 для num допустимы для Foo.doStuff, но больше не действительны для Bar.doStuff.

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

6.7. Правило методов - постусловия

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

Давайте посмотрим на пример:

public abstract class Car {

    protected int speed;

    // postcondition: speed must reduce
    protected abstract void brake();

    // Other methods...
}

Здесь метод brake класса Car задает постусловие, согласно которому скорость Car должна уменьшиться в конце выполнения метода. Подтип может усилить (но не ослабить) постусловие для переопределяемого им метода

Теперь давайте определим производный класс от Car, который усиливает постусловие:

public class HybridCar extends Car {

   // Some properties and other methods...

    @Override
    // postcondition: speed must reduce
    // postcondition: charge must increase
    protected void brake() {
        // Apply HybridCar brake
    }
}

Метод переопределенного brake в HybridCar усиливает постусловие, дополнительно гарантируя увеличение charge. Следовательно, любой клиентский код, полагающийся на постусловие метода brake в классе Car, не замечает разницы, когда он заменяет HybridCar на Car.

И наоборот, если бы HybridCar ослабил постусловие переопределенного метода brake, это больше не гарантировало бы снижение speed. Это может привести к поломке клиентского кода при использовании HybridCar вместо Car.

7. Запахи кода

Как мы можем обнаружить подтип, который нельзя заменить своим супертипом в реальном мире?

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

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

Мы видели пример этого ранее в примере с нашим банковским приложением.

До рефакторинга в классе Account был дополнительный метод withdraw, который не нужен его подклассу FixedTermDepositAccount. Класс FixedTermDepositAccount обошел эту проблему, создав исключение UnsupportedOperationException для метода снятия. Однако это был всего лишь хак, чтобы скрыть слабость в проектировании иерархии наследования.

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

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

Вот пример. Давайте определим интерфейс файловой системы:

public interface FileSystem {
    File[] listFiles(String path);

    void deleteFile(String path) throws IOException;
}

Давайте определим ReadOnlyFileSystem, реализующую FileSystem:

public class ReadOnlyFileSystem implements FileSystem {
    public File[] listFiles(String path) {
        // code to list files
        return new File[0];
    }

    public void deleteFile(String path) throws IOException {
        // Do nothing.
        // deleteFile operation is not supported on a read-only file system
    }
}

Здесь ReadOnlyFileSystem не поддерживает операцию удаления файла и поэтому не предоставляет реализацию.

7.3. Клиент знает про подтипы

Если в клиентском коде необходимо использовать instanceof или понижающее приведение, то, скорее всего, нарушены как принцип открытости/закрытости, так и принцип подстановки Лисков.

Давайте проиллюстрируем это с помощью FilePurgingJob:

public class FilePurgingJob {
    private FileSystem fileSystem;

    public FilePurgingJob(FileSystem fileSystem) {
        this.fileSystem = fileSystem;
    }

    public void purgeOldestFile(String path) {
        if (!(fileSystem instanceof ReadOnlyFileSystem)) {
            // code to detect oldest file
            fileSystem.deleteFile(path);
        }
    }
}

Поскольку модель FileSystem принципиально несовместима с файловыми системами только для чтения, ReadOnlyFileSystem наследует метод deleteFile, который не может поддерживать. В этом примере кода используется проверка instanceof для выполнения работы, исключая определенный подтип.

7.4. Метод подтипа постоянно возвращает одно и то же значение

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

public class ToyCar extends Car {

    @Override
    protected int getRemainingFuel() {
        return 0;
    }
}

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

8. Заключение

В этой статье мы рассмотрели принцип проектирования “Принцип подстановки Барбары Лисков”. Принцип подстановки Лисков помогает нам проектировать “хорошие” иерархии наследования. Это помогает нам предотвратить появление иерархий наследования, которые не соответствуют принципу открытости/закрытости.

Любая модель наследования, которая придерживается принципа подстановки Лисков, будет неявно следовать принципу открытости/закрытости.

Для начала мы рассмотрели вариант использования, который пытается следовать принципу открытости/закрытости, но нарушает принцип подстановки Лисков. Затем мы рассмотрели определение принципа подстановки Лисков, понятие поведенческого наследования и правила, которым должны следовать подтипы.

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

Комментарии (9)


  1. panzerfaust
    05.06.2023 07:20

    Остается только один вопрос. Какой процент современных энтерпрайзных джава-проектов реально использует "богатую" доменную модель, в которой в принципе возможна описанная проблема с наследниками Account?


    1. sshikov
      05.06.2023 07:20

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


  1. Naf2000
    05.06.2023 07:20
    +3

    Интересно про запахи плохого кода. Описание Вы дали (не совсем я согласен), но как решить проблему - осталось "за кадром".


  1. aleksandy
    05.06.2023 07:20
    +2

    Метод подтипа постоянно возвращает одно и то же значение

    Чем

    class Parent {
      abstract int getValue();
    }
    
    class Child0 extends Parent {
      int getValue() {
        return 0;
      }
    }
    
    class Child1 extends Parent {
      int getValue() {
        return 1;
      }
    }
    

    хуже

    class Parent {
      int value;
      
      Parent(int v) {
        this.value = v;
      }
      
      int getValue() {
        return this.value;
      }
    }
    
    class Child0 extends Parent {
      Child0() {
        super(0);
      }
    }
    
    class Child1 extends Parent {
      Child1() {
        super(1);
      }
    }


    1. Andrey_Solomatin
      05.06.2023 07:20
      +1

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


  1. Andrey_Solomatin
    05.06.2023 07:20
    +2

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


    Один из вариантов решения, сделать это исключение частью контракта.
    Формально может правило и не нарушенно, но по факту все проблемы которые это нарушение добавляет имеются, потому что никто этих контрактов не читает.

    https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/Collections.html
    The "destructive" algorithms contained in this class, that is, the
    algorithms that modify the collection on which they operate, are specified
    to throw UnsupportedOperationException if the collection does not
    support the appropriate mutation primitive(s), such as the set
    method. These algorithms may, but are not required to, throw this
    exception if an invocation would have no effect on the collection. For
    example, invoking the sort method on an unmodifiable list that is
    already sorted may or may not throw UnsupportedOperationException.


  1. z250227725
    05.06.2023 07:20

    Какая реакция предполагается, если запрошенная сумма списания превысит остаток по счёту?


  1. breninsul
    05.06.2023 07:20

    UnsupportedOperationException

    Красота!!


    1. breninsul
      05.06.2023 07:20

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

      При этом бизнес-логика все-равно будет обернута ифами/кетчами и по факту будет знать про реализации.