«1. Обзор

Принципы проектирования SOLID были представлены Робертом С. Мартином в его статье 2000 года «Принципы проектирования и шаблоны проектирования». Принципы проектирования SOLID помогают нам создавать более удобное в сопровождении, понятное и гибкое программное обеспечение.

В этой статье мы обсудим принцип подстановки Лисков, который является буквой «L» в аббревиатуре.

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

Чтобы понять принцип замещения Лискова, мы должны сначала понять принцип открытости/закрытости («О» от SOLID).

Цель принципа Open/Closed побуждает нас проектировать наше программное обеспечение таким образом, чтобы мы добавляли новые функции только путем добавления нового кода. Когда это возможно, мы имеем слабосвязанные и, следовательно, легко поддерживаемые приложения.

3. Пример использования

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

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

Наше банковское приложение поддерживает два типа счетов — «текущие» и «сберегательные». Они представлены классами CurrentAccount и SavingsAccount соответственно.

BankingAppWithdrawalService предоставляет своим пользователям функцию вывода средств:

К сожалению, есть проблема с расширением этого дизайна. Служба BankingAppWithdrawalService знает о двух конкретных реализациях account. Следовательно, 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 не может осмысленно предоставить метод снятия средств, который определяет Account. Одним из распространенных обходных путей для этого является заставить 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 для его метода снятия:

/**
 * 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);

«

«Однако, не поддерживая метод снятия, FixedTermDepositAccount нарушает спецификацию этого метода. Поэтому мы не можем надежно заменить FixedTermDepositAccount на Account.

Другими словами, FixedTermDepositAccount нарушил принцип замещения Лискова.

3.7. Разве мы не можем обработать ошибку в BankingAppWithdrawalService?

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

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

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

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

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

Subtypes must be substitutable for their base types.

Роберт С. Мартин резюмирует его:

If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T, the behavior of P is unchanged when o1 is substituted for o2 then S is a subtype of T.

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

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

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

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

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

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

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

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

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

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

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

5.1. Основная причина

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

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

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

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

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

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

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

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

public class BankingAppWithdrawalService {
    private WithdrawableAccount withdrawableAccount;

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

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

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

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

6. Правила

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

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

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

Здесь следует отметить некоторую терминологию. Широкий тип является более общим — например, Object может означать ЛЮБОЙ объект Java и шире, чем, скажем, CharSequence, где String очень специфичен и, следовательно, уже.

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

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

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

6.2. Правило подписи — типы возвращаемых значений

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

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

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

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

Поскольку Integer ЯВЛЯЕТСЯ числом, клиентский код, который ожидает число, может без проблем заменить Foo на Bar.

С другой стороны, если бы переопределенный метод в Bar возвращал более широкий тип, чем Number, например. Объект, который может включать любой подтип объекта, например. грузовик. Любой клиентский код, который полагался на возвращаемый тип 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 определяет инвариант класса, согласно которому скорость всегда должна быть ниже предела. Правило инвариантов гласит, что все методы подтипа (унаследованные и новые) должны поддерживать или усиливать инварианты класса супертипа.

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, который сохраняет инвариант класса:

В этом примере инвариант в Car сохраняется с помощью переопределенного метода ускорения в HybridCar. HybridCar дополнительно определяет свой собственный инвариантный заряд класса \u003e= 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. Свойство пробега может быть установлено только один раз во время создания и не может быть сброшено после этого.

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

    // Other properties and methods
}

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

«

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

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

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...
    }
}

6.6. Методы Правило – предварительные условия

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

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 утверждает, что значение параметра num должно быть в диапазоне от 1 до 5. Мы усилили это предварительное условие с проверкой диапазона внутри метод. Подтип может ослабить (но не усилить) предварительное условие для переопределяемого им метода. Когда подтип ослабляет предварительное условие, он ослабляет ограничения, налагаемые методом супертипа.

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

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

И наоборот, когда подтип усиливает предварительное условие (например, 0 \u003c num \u003c= 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...
}

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

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

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
    }
}

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

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

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

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

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

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

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

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

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

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

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

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

    void deleteFile(String path) throws IOException;
}

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

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
    }
}

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

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

«

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

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);
        }
    }
}

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

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

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

public class ToyCar extends Car {

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

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

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

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

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

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

В этой статье мы рассмотрели принцип проектирования Liskov Substitution SOLID.

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

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