Принцип замещения Лискова: обеспечение надежных классовых иерархий

В объектно-ориентированном программировании создание систем, которые являются одновременно надежными и адаптируемыми, является постоянной проблемой. Среди основополагающих принципов, направляющих разработчиков к этой цели, является принцип замены Лискова (LSP). Задуманный Барбарой Лисков в 1987 году, LSP является третьим столпом пяти принципов SOLID для разработки программного обеспечения, и он решает критический вопрос: когда вы создаете подкласс, который наследует от родительского класса, можете ли вы безопасно заменить любой экземпляр родителя экземпляром ребенка, не нарушая программу? Ответ принципа - окончательное «да» - но только если подкласс уважает контракт, определенный родителем. В этой статье подробно рассматриваются принципы замены Лискова, предоставляя четкие определения, практические примеры и стратегии, чтобы гарантировать, что ваши иерархии класса остаются надежными и гибкими.

Что такое принцип замещения Лискова?

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

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

Формальное определение и фон

Первоначальное формальное определение Барбары Лисковой звучит следующим образом:

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

Это определение подчеркивает, что подтип (S) должен быть заменён на его супертип (T) в любой программе (P), которая написана в терминах T. Следует сохранить наблюдаемое поведение программы. Эта концепция тесно связана с методологией Design by Contract (DbC), впервые предложенной Бертраном Мейером, где каждый метод имеет явные предварительные условия (что должно быть правдой до запуска метода) и постусловия (что должно быть правдой после).

Для дальнейшего чтения см. оригинал Liskov and Wing paper, формализовавший концепцию.Кроме того, статья Wikipedia о LSP даёт хороший обзор.

Почему LSP важен?

Приверженность LSP приносит несколько важных преимуществ объектно-ориентированным системам:

LSP — это не просто академическая концепция; она имеет прямые практические последствия. Например, в системе обработки платежей, если у вас есть базовый класс с методом , вы ожидаете, что все подклассы (например, , ) будут обрабатывать платежи без ошибок или побочных эффектов, которых не ожидает базовый класс.

Общие нарушения принципа замещения Лискова

Признание нарушений ЛСП является первым шагом на пути к их устранению. Вот некоторые типичные закономерности, которые нарушают принцип:

Укрепление предварительных условий

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

// Base class
class UserService {
 public void assignRole(int userId) { ... }
}
// Subclass violation
class AdminService extends UserService {
 @Override
 public void assignRole(int userId) {
 if (userId <= 0) throw new IllegalArgumentException("Invalid ID");
 ...
 }
}

Ослабление постусловий

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

Новые исключения

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

Удаление или отмена методов, которые должны быть унаследованы

Если подкласс не использует метод ничего не делать (пустое тело) или бросать , это явное нарушение.

Классический пример: прямоугольник, квадрат и ловушка

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

// Base class
class Rectangle {
 protected int width, height;
 public void setWidth(int w) { width = w; }
 public void setHeight(int h) { height = h; }
 public int getArea() { return width * height; }
}

// Subclass violation
class Square extends Rectangle {
 @Override
 public void setWidth(int w) {
 width = height = w;
 }
 @Override
 public void setHeight(int h) {
 width = height = h;
 }
}

Рассмотрим функцию, которая работает с :

void resize(Rectangle r) {
 r.setWidth(5);
 r.setHeight(10);
 assert r.getArea() == 50; // Expect 50
}

Когда пройдена , метод устанавливает ширину до 5, а затем высоту до 10 — но квадрат переопределяется также устанавливает ширину до 10, так что квадрат заканчивается шириной = 10, высотой = 10, площадью = 100. Утверждение не выполняется. не является заменимым для , потому что он меняет послеусловие: после и , площадь прямоугольника составляет 50, но площадь квадрата составляет 100.

Исправление: Избегайте наследования от . Вместо этого, проектируйте абстрактный класс с общим методом, и оба и реализуйте независимо. Альтернативно, используйте композицию: квадрат может быть прямоугольником с равными сторонами, но раскрытие заставок, которые нарушают инвариант, является реальной проблемой. Принцип часто суммируется как «Не разрывайте контракт».

Пример из реального мира: интеграция платежных шлюзов

Представьте себе систему электронной коммерции с базовым классом :

abstract class PaymentGateway {
 public abstract void charge(double amount);
 public void refund(double amount) { /* default implementation */ }
}

Подклассы включают и . Клиент, который обрабатывает платежи, может позвонить и позже . Нарушение происходит, если переопределяет , чтобы бросить исключение, потому что API PayPal требует идентификатор возврата, а не только сумму. Теперь любой код, который использует на ссылке , будет нарушаться, когда тип времени выполнения .

Как исправить: Либо (а) обеспечить, чтобы контракт базового класса включал возможность того, что может не поддерживаться (например, сделать его возвратным успешным булевым или объявить проверенное исключение), либо (б) перепроектировать иерархию так, чтобы не все шлюзы поддерживали возвраты. Например, ввести интерфейс и только позволить шлюзам, поддерживающим возвраты, реализовать его. Затем клиент проверяет , прежде чем вызывать возврат. Это уважает LSP, потому что база не обещает метод рабочего возврата.

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

Внедрение LSP требует дисциплины в проектировании и тестировании. Вот практические рекомендации:

LSP и другие принципы Solid

LSP тесно связан с другими принципами SOLID, особенно с принципом открытого/закрытого доступа (OCP) и принципом инверсии зависимостей (DIP).

Для получения полного обзора принципов SOLID ознакомьтесь со статьей SOLID Википедии .

Заключение

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

Помните, что нарушения LSP часто проявляются как тонкие несоответствия в поведении: метод, который бросает неожиданное исключение, метод, который молча ничего не делает, или метод, который изменяет состояние таким образом, который не предназначен для базового класса. Помня об этих красных флагах и применяя руководящие принципы, обсуждаемые выше, вы можете создать иерархии, которые выдерживают испытание временем. Проницательность Барбары Лисков продолжает направлять разработчиков к более надежному и гибкому программному обеспечению. Для дальнейшего изучения рассмотрите чтение Роберта К. Мартина: Agile Software Development: Principles, Patterns, and Practices, который предлагает обширные примеры LSP и других принципов SOLID.

Внешние ссылки, используемые в этой статье: