Рубрики
Без рубрики

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

Линия СПЛОШНАЯ, принцип подстановки Лискова помогает структурировать объектно – ориентированный дизайн. Мы также исследуем, как он поддерживает принцип открытия/закрытия.

Автор оригинала: baeldung.

1. Обзор

Принципы SOLID design/| были введены Робертом К. Мартином в его статье 2000 года Принципы проектирования и шаблоны проектирования . ТВЕРДЫЕ принципы проектирования помогают нам создавать более ремонтопригодное, понятное и гибкое программное обеспечение.

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

2. Принцип Открытия/Закрытия

Чтобы понять Принцип подстановки Лискова, мы должны сначала понять Принцип открытия/закрытия (“О” из ТВЕРДОГО ТЕЛА).

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

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

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

3.1. Без принципа “Открыто/Закрыто”

Наше банковское приложение поддерживает два типа счетов – “текущие” и “сберегательные”. Они представлены классами Текущий счет и Сберегательный счет соответственно.

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

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

3.2. Использование принципа “Открыто/Закрыто” для расширения кода

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

Здесь мы ввели новый абстрактный Account класс, который Текущий счет и SavingsAccount расширяют.

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

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

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

И давайте определим Услугу вывода средств из банковского приложения :

public class BankingAppWithdrawalService {
    private Account account;

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

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

Теперь давайте посмотрим, как в этом дизайне новый тип учетной записи может нарушить принцип замены Лискова.

3.4. Новый Тип Учетной Записи

Теперь банк хочет предложить своим клиентам срочный депозитный счет с высоким процентным доходом.

Чтобы поддержать это, давайте представим новый Срочный депозитный счет класс. Срочный депозитный счет в реальном мире “является” типом счета. Это подразумевает наследование в нашем объектно-ориентированном дизайне.

Итак, давайте сделаем Срочный депозитный счет подкласс Счета :

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

Пока все идет хорошо. Однако банк не хочет разрешать снятие средств со счетов срочных депозитов.

Это означает, что новый Срочный депозитный счет класс не может содержательно предоставить метод вывода , который определяет Счет . Одним из распространенных обходных путей для этого является создание Срочный депозитный счет бросьте 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. Тестирование С использованием Нового Типа Учетной записи

В то время как новый класс работает нормально, давайте попробуем использовать его с Службой вывода банковских приложений :

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. Что Пошло Не Так?

Служба вывода средств из Банковского приложения является клиентом класса Счет . Он ожидает, что как 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);

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

Другими словами, Срочный депозитный счет нарушил Принцип замены Лискова.

3.7. Не можем ли Мы Справиться с Ошибкой в Сервисе вывода банковских приложений?

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

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

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

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

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

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

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

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

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

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

4.2. Когда Подтип может быть заменен на Его Супертип?

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

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

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

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

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

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

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

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

5.1. Первопричина

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

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

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

5.2. Пересмотренная схема классов

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

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

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

5.3. Рефакторингованный Сервис Вывода Средств Из Банковского приложения

Банковское приложение для вывода средств теперь необходимо использовать Выводимый счет :

public class BankingAppWithdrawalService {
    private WithdrawableAccount withdrawableAccount;

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

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

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

6. Правила

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

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

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

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

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
}

Потому что Целое число РАВНО-A Number , клиентский код, который ожидает Number , может заменить Foo на Bar без каких-либо проблем.

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

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

Давайте определим подкласс 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 в Гибридный автомобиль . Гибридный автомобиль дополнительно определяет свой собственный инвариант класса заряд , и это совершенно нормально.

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

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 задает ограничение на свойство пробег . Свойство пробег может быть установлено только один раз во время создания и не может быть сброшено после этого.

Давайте теперь определим Игрушечный автомобиль который расширяется Автомобиль:

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

    // Other properties and methods
}

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

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

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 , что позволяет использовать более широкий диапазон значений для num . Все значения num , которые действительны для Foo.doStuff , действительны для Bar.do Вещи тоже. Следовательно, клиент Foo.doStuff не замечает разницы, когда он заменяет Foo на Bar .

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

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

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

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

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

public abstract class Car {

    protected int speed;

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

    // Other methods...
}

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

Теперь давайте определим производный класс 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 в классе Car , не замечает никакой разницы, когда он заменяет HybridCar на Car .

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

7. Запах кода

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

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

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

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

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

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

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

7.3. Клиент Знает О Подтипах

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

Давайте проиллюстрируем это с помощью задания Очистки файлов :

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

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

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

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

public class ToyCar extends Car {

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

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

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

В этой статье мы рассмотрели принцип СПЛОШНОЙ конструкции замены Лискова.

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

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

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

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

Как всегда, пример кода из этой статьи доступен на GitHub .