Во-первых, ваш ответ:
class Base
{
[pure]
public virtual bool IsValid()
{
return false;
}
}
class Child : Base
{
public override bool IsValid()
{
return true;
}
}
По сути, LSP говорит (это определение «подтипа»):
Если для каждого объекта o1 типа S существует объект o2 типа T такой, что для всех программ P, определенных в терминах T, поведение P остается неизменным, когда o1 заменяется на o2, тогда S является подтипом Т. (Лисков, 1987)
"Но я не могу заменить o1
типа Base
на любой o2
типа Child
, потому что они, очевидно, ведут себя по-разному!" Чтобы ответить на это замечание, мы должны сделать объезд.
Что такое подтип?
Во-первых, обратите внимание, что Лисков говорит не только о классах, но и о типах. Классы являются реализациями типов. Есть хорошие и плохие реализации типов. Мы попытаемся их различить, особенно когда речь идет о подтипах.
Вопрос, лежащий в основе принципа подстановки Лискова: что такое подтип? Обычно мы предполагаем, что подтип является специализацией своего супертипа и расширением его возможностей:
> The intuitive idea of a subtype is one whose objects provide all the behavior of objects of another type (the supertype) plus something extra (Liskov, 1987)
С другой стороны, большинство компиляторов предполагают, что подтип - это класс, который имеет как минимум те же методы (то же имя, одну и ту же сигнатуру, включая ковариацию и исключения), либо унаследованные, либо переопределенные (или определенные в первый раз), и ярлык (inherits
, extends
, ...).
Но эти критерии неполны и ведут к ошибкам. Вот два печально известных примера:
SortedList
является (?) Подтипом List
: он представляет отсортированный список (специализация).
Square
является (?) Подтипом Rectangle
: он представляет собой прямоугольник с четырьмя равными сторонами (специализация).
Почему SortedList
не List
? Из-за семантики типа List
. Тип - это не только набор подписей, но и методы имеют семантику. Под семантикой я подразумеваю все разрешенные варианты использования объекта (помните Витгенштейна: «значение слова - это его использование в языке»). Например, вы ожидали найти элемент в той позиции, где вы его поместили. Но если список всегда отсортирован, вновь вставленный элемент будет перемещен в «правильное» место. Таким образом, вы не найдете этот элемент в той позиции, где вы его поместили.
Почему Square
не Rectangle
? Представьте, что у вас есть метод set_width
: с квадратом вы тоже должны изменить высоту. Но семантика set_width
заключается в том, что она изменяет ширину, но оставляет высоту неизменной.
(Квадрат - это не прямоугольник? Этот вопрос иногда приводит к бурному обсуждению, поэтому я подробно остановлюсь на этом предмете. Мы все узнали, что квадрат - это прямоугольник. Но это верно в небе чистой математики, где объекты являются неизменяемыми. Если вы определите ImmutableRectangle
(с фиксированной шириной, высотой, положением, углом и вычисленным периметром, площадью, ...), то ImmutableSquare
будет подтипом ImmutableRectangle
в соответствии с LSP. На первый взгляд, такие неизменяемые классы не кажутся очень полезными, но есть способ справиться с этим: замените сеттеры методами, которые создадут новый объект, как это было бы на любом функциональном языке. Например, ImmutableSquare.copyWithNewHeight(h)
вернет новый. .. ImmutableRectangle
, чья высота равна h
, а ширина равна size
площади.)
Мы можем использовать LSP, чтобы избежать этих ошибок.
Зачем нам нужен LSP?
Но почему, на практике , нужно ли нам заботиться о LSP? Поскольку компиляторы не фиксируют семантику класса. У вас может быть подкласс, который не является реализацией подтипа.
Для Лискова (и Wing, 1999) спецификация типа включает в себя:
- Название типа
- Описание пространства значений типа
- Определение инварианта типа и свойств истории;
- Для каждого метода типа:
- Его название;
- его подпись (в том числе сигнальные исключения);
- Его поведение в терминах предусловий и постусловий
Если бы компилятор мог применять эти спецификации для каждого класса, он мог бы (во время компиляции или во время выполнения, в зависимости от характера спецификации) сказать нам: «эй, это не подтип!».
(На самом деле, существует язык программирования, который пытается уловить семантику: Eiffel. В Eiffel инварианты, предусловия и постусловия являются важными частями определения класса. Поэтому вы не не нужно заботиться о LSP: среда выполнения сделает это за вас. Это было бы неплохо, но у Eiffel есть и ограничения. Этот язык (любой язык?) не будет достаточно выразительным, чтобы определить полную семантику isValid()
, потому что эта семантика не содержится в условии до / после или в инварианте.)
Теперь вернемся к примеру. Здесь единственным указанием на семантику isValid
является имя метода: он должен возвращать true, если объект действителен, и false в противном случае. Очевидно, вам нужен контекст (и, возможно, подробные спецификации или знание предметной области), чтобы знать, что является, а что нет.
На самом деле, я могу представить дюжину ситуаций, когда любой объект типа Base
действителен, но все объекты типа Child
недопустимы (см. Код в верхней части ответа). Например. замените Base
на Passport
и Child
на FakePassword
(при условии, что поддельный пароль является паролем ...).
Таким образом, даже если класс Base
говорит: «Я действительный», тип Base
говорит: «Почти все мои экземпляры действительны, но те, кто недействителен, должны это сказать!» Вот почему у вас есть класс Child
, реализующий тип Base
(и производный класс Base
), который говорит: «Я не действителен».
Более интересный пример
Но я думаю, что выбранный вами пример не лучший для проверки предварительных / постусловных условий и инвариантов: поскольку функция чистая, она не может, по замыслу, нарушить любой инвариант; так как возвращаемое значение является логическим (2 значения), интересного постусловия нет. Единственное, что вы можете иметь, это интересное предварительное условие, если у вас есть какие-то параметры.
Давайте рассмотрим более интересный пример: коллекцию. В псевдокоде у вас есть:
abstract class Collection {
abstract iterator(); // returns a modifiable iterator
abstract size();
// a generic way to set a value
set(i, x) {
[ precondition:
size: 0 <= i < size() ]
it = iterator()
for i=0 to i:
it.next()
it.set(x)
[ postcondition:
no_size_modification: size() = old size()
no_element_modification_except_i: for all j != i, get(j) == old get(j)
was_set: get(i) == x ]
}
// a generic way to get a value
get(i) {
[ precondition:
size: 0 <= i < size() ]
it = iterator()
for i=0 to i:
it.next()
return it.get()
[ postcondition:
no_size_modification: size() = old size()
no_element_modification: for all j, get(j) == old get(j) ]
}
// other methods: remove, add, filter, ...
[ invariant: size_positive: size() >= 0 ]
}
В этой коллекции есть некоторые абстрактные методы, но методы set
и get
уже являются телесными методами. Кроме того, мы можем сказать, что они
все в порядке для связанного списка, но не для списка, поддерживаемого массивом. Давайте попробуем создать лучшую реализацию для коллекции с произвольным доступом:
class RandomAccessCollection {
// all pre/post conditions and invariants are inherited from Collection.
// fields:
// self.count = number of elements.
// self.data = the array.
iterator() { ... }
size() { return self.count; }
set(i, x) { self.data[i] = x }
get(i) { return self.data[i] }
// other methods
}
Очевидно, что семантика get
и set
в RandomAccessCollection
соответствует определениям класса Collection
. В частности, все предварительные / последующие условия и инвариант выполнены. Другими словами, условия LSP
выполняются, и, следовательно, соблюдается LSP: в каждой программе мы можем заменить любой объект типа Collection
на аналог объект типа RandomAccesCollection
без нарушения поведения программ.
Заключение
Как видите, легче уважать LSP, чем нарушать его. Но иногда мы ломаем его (например, пытаемся создать SortedRandomAccessCollection
, который наследует RandomAccessCollection
). Кристально чистая формулировка LSP помогает нам сузить то, что пошло не так и что нужно сделать, чтобы исправить дизайн.
В более общем смысле, виртуальные (телесные) булевы методы не являются анти-паттернами, если базовый класс имеет достаточно плоти для реализации методов. Но если базовый класс настолько абстрактен, что каждому подклассу придется переопределять методы, оставьте методы абстрактными.
Ссылки
Существуют две основные оригинальные работы Лискова: Абстракция данных и иерархия (1987) и Поведенческое подтипирование с использованием инвариантов и ограничений (1994, 1999, с J. M. Wing). Обратите внимание, что это теоретические работы.