Перейти к основному содержимому

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

· 16 мин. чтения

Задача: Наибольшая подстрока без повторений

Для заданной строки s, найдите длину наибольшей подстроки без повторяющихся символов. Подстрока — это непрерывная непустая последовательность символов внутри строки...

ANDROMEDA 42

1. Обзор

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

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

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

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

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

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

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

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

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

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

./bfa1d9ffeab2d2c5d5849c7805de7689.png

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

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

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

./b94231212f19cc5db3946a05b693a983.png

Здесь мы представили новый абстрактный класс 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. Определение

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

./afbd801b498c52c4fa914e8a2100e110.png

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

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
}

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

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

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

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

// Other properties and methods
}

У ToyCar есть дополнительный метод reset , который сбрасывает свойство пробега . При этом 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...
}

Здесь тормозной метод 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
}
}

Метод переопределенного торможения в 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;
}

Давайте определим 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. Заключение

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

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

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

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

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

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