Книга: Чистая архитектура. Искусство разработки программного обеспечения
Назад: 8. Принцип открытости/закрытости
Дальше: 10. Принцип разделения интерфейсов

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

Martin_Page_103_Image_0001.tif 

В 1988 году Барбара Лисков написала следующие строки с формулировкой определения подтипов.

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

Чтобы понять эту идею, известную как принцип подстановки Барбары Лисков (Liskov Substitution Principle; LSP), рассмотрим несколько примеров.

Руководство по использованию наследования

Представьте, что у нас есть класс с именем License, как показано на рис. 9.1. Этот класс имеет метод с именем calcFee(), который вызывается приложением Billing. Существует два «подтипа» класса License: PersonalLicense и BusinessLicense. Они реализуют разные алгоритмы расчета лицензионных отчислений.

Martin_Page_104_Image_0001.tif 

Рис. 9.1. Класс License и его производные, соответствующие принципу LSP

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

Проблема квадрат/прямоугольник

Классическим примером нарушения принципа подстановки Барбары Лисков может служить известная проблема квадрат/прямоугольник (рис. 9.2).

Martin_Page_105_Image_0001.tif 

Рис. 9.2. Известная проблема квадрат/прямоугольник

В этом примере класс Square (представляющий квадрат) неправильно определен как подтип класса Rectangle (представляющего прямоугольник), потому что высоту и ширину прямоугольника можно изменять независимо; а высоту и ширину квадрата можно изменять только вместе. Поскольку класс User полагает, что взаимодействует с экземпляром Rectangle, его легко можно ввести в заблуждение, как демонстрирует следующий код:

Rectangle r = ...

r.setW(5);

r.setH(2);

assert(r.area() == 10);

Если на место ... подставить код, создающий экземпляр Square, тогда проверка assert потерпит неудачу. Единственный способ противостоять такому виду нарушений принципа LSP — добавить в класс User механизм (например, инструкцию if), определяющий ситуацию, когда прямоугольник фактически является квадратом. Так как поведение User зависит от используемых типов, эти типы не являются заменяемыми (совместимыми).

LSP и архитектура

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

Подразумеваемые интерфейсы могут иметь множество форм. Это могут быть интерфейсы в стиле Java, реализуемые несколькими классами. Или это может быть группа классов на языке Ruby, реализующих методы с одинаковыми сигнатурами. Или это может быть набор служб, соответствующих общему интерфейсу REST.

Во всех этих и многих других ситуациях применим принцип LSP, потому что существуют пользователи, зависящие от четкого определения интерфейсов и замещаемости их реализаций.

Лучший способ понять значение LSP с архитектурной точки зрения — посмотреть, что случится с архитектурой системы при нарушении принципа.

Пример нарушения LSP

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

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

Допустим, что для водителя с именем Bob адрес URI отправки заказа выглядит так:

purplecab.com/driver/Bob

Наша система добавит в конец этого URI информацию о заказе и пошлет его методом PUT:

purplecab.com/driver/Bob

       /pickupAddress/24 Maple St.

       /pickupTime/153

       /destination/ORD

Это явно означает, что все службы должны соответствовать общему интерфейсу REST. Они должны единообразно интерпретировать поля pickupAddress, pickupTime и destination.

Теперь предположим, что компания такси Acme наняла несколько программистов, которые ознакомились со спецификацией недостаточно внимательно. Они сократили имя поля destination до dest. Компания Acme — крупнейшая компания такси в нашем регионе, и бывшая жена президента компании Acme вышла замуж за президента нашей компании, и... В общем, вы поняли. Что может произойти с архитектурой нашей системы?

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

Решить поставленную задачу проще всего простым добавлением инструкции if в модуль, занимающийся пересылкой заказов:

if (driver.getDispatchUri().startsWith("acme.com")) ...

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

Например, представьте, что компания Acme добилась большого успеха, купила компанию Purple Taxi и объединенная компания решила сменить имя и адрес веб-сайта и объединить все системы оригинальных компаний. Получается, что теперь мы должны добавить еще одну инструкцию if для «purple»?

Архитектор должен изолировать систему от ошибок, подобных этой, и добавить модуль, управляющий созданием команд доставки заказов в соответствии с параметрами, указанным для URI в базе данных с настройками. Настройки могли бы выглядеть как-то так:

URI            Формат команды

Acme.com      /pickupAddress/%s/pickupTime/%s/dest/%s

*.*           /pickupAddress/%s/pickupTime/%s/destination/%s

В результате архитектор вынужден добавить важный и сложный механизм из-за того, что интерфейсы не всех REST-служб оказались совместимыми.

Заключение

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

Barbara Liskov. Data Abstraction and Hierarchy, SIGPLAN Notices 23, 5 (May 1988).

Назад: 8. Принцип открытости/закрытости
Дальше: 10. Принцип разделения интерфейсов