Ответ 1
Идея LSP не запрещает полиморфизм детских классов. Скорее, он подчеркивает, что можно изменить, а что нет. В общем, это означает, что:
- Любая переопределяющая функция принимает и возвращает те же типы переопределенной функции; который включает в себя, возможно, заброшенные исключения (типы ввода могут расширять типы переопределенных и выходных типов, могут сузить их - это все равно сохранит это ограничение).
- "Правило истории" - "базовая" часть объекта "Дети" не может быть изменено дочерней функцией до состояния, которое никогда не может быть достигнуто с использованием функций базового класса. Таким образом, функция, которая ожидает объект Base, никогда не получит неожиданных результатов.
- Инварианты Базы не должны быть изменены в Ребенке. То есть любое общее предположение о поведении Базового класса должно быть сохранено Ребенком.
Две первые пули очень четко определены. "Инварианты" - это скорее вопрос. Например, если какой-либо класс в среде реального времени требует, чтобы все его функции выполнялись в течение некоторого постоянного времени, все надменные функции в его подтипах также должны придерживаться этого требования.
В вашем случае IsValid() означает что-то, и что "что-то" должно храниться под всеми дочерними типами. Например, допустим, что ваш базовый класс определяет продукт, а IsValid() указывает, действителен ли этот продукт для продажи. То, что делает каждый продукт действительным, может отличаться. Например, он должен иметь свою цену, чтобы быть действительной для продажи. Но продукт Child также должен пройти тест на электричество, прежде чем он может быть продан.
В этом примере мы соблюдаем все требования:
- Типы ввода и вывода функции не изменяются.
- Состояние объекта Base-часть дочернего объекта не изменяется таким образом, которого не может ожидать класс Base.
- Сохраняются инварианты класса: объект Child без цены все еще не может быть продан; смысл недействительности остается тем же (не разрешается продавать), он просто рассчитывается таким образом, который соответствует ребенку.
Вы можете получить некоторые дополнительные объяснения здесь.
===
Изменить - некоторые дополнительные пояснения в соответствии с примечаниями
Вся идея полиморфизма заключается в том, что одна и та же функция выполняется по-разному по каждому подтипу. LSP не нарушает полиморфизм, но описывает, что должен заботиться о полиморфизме. В частности, LSP требует, чтобы любой подтип Child
мог использоваться там, где код требует Base
и что любое допущение, сделанное для Base
hold для любого из его Child
's. В приведенном выше примере IsValis()
не означает, что "имеет цену". Скорее, это означает, что: действительно ли продукт действителен? В некоторых случаях достаточно цены. В других случаях также требуются проверки электроэнергии, а в других же могут потребоваться некоторые другие свойства. Если разработчик Base
класса не требует, чтобы, установив цену, продукт стал действительным, а скорее оставил IsValid()
в качестве отдельного теста, тогда нарушение LSP не произойдет. Какой пример допустил бы это нарушение? Пример, когда вы запрашиваете объект, если он IsValid()
, затем вызывает функцию базового класса, которая не должна изменять действительность, и эта функция изменяет Child
на недействительность. Это является нарушением правила истории LSP. Известный пример, представленный другими здесь, является квадратным как дочерний элемент прямоугольника. Но пока одна и та же последовательность вызовов функций не требует определенного поведения (опять же - не определено, что установка цены делает продукт действительным, как раз так бывает в некоторых типах) - LSP поддерживается по мере необходимости,
Ответ 3
Во-первых, ваш ответ:
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 является подтипом T. ( Лисков, 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? Потому что компиляторы не захватывают семантику класса. У вас может быть подкласс, который не является реализацией подтипа.
Для Liskov (и Wing, 1999) спецификация типа включает:
- Название типа
- Описание пространства значений типа
- Определение свойств инварианта типа и истории;
- Для каждого типа метода:
- Его имя;
- Его подпись (включая сигнальные исключения);
- Его поведение с точки зрения предварительных условий и пост-условий
Если компилятор смог обеспечить выполнение этих спецификаций для каждого класса, он мог бы (во время компиляции или времени выполнения, в зависимости от характера спецификации) сказать нам: "эй, это не подтип!".
(На самом деле есть язык программирования, который пытается захватить семантику: Эйфель. В Эйфеле инварианты, предпосылки и пост-условия являются существенными частями определения класса. Поэтому вам не нужно забота о LSP: время выполнения сделает это за вас. Это было бы неплохо, но у Эйфеля тоже есть ограничения. Этот язык (любой язык?) не будет достаточно выразительным, чтобы определить полную семантику isValid()
, потому что этот семантический не содержится в условии pre/post или инварианте.)
Теперь вернемся к примеру. Здесь единственное указание на семантику isValid
- это имя метода: оно должно возвращать true, если объект действителен, а false - иначе. Очевидно, вам нужен контекст (и, возможно, подробные спецификации или знания домена), чтобы знать, что есть и что недействительно.
Фактически, я могу представить себе дюжину ситуаций, когда любой объект типа Base
действителен, но все объекты типа Child
недопустимы (см. Код в верхней части ответа). Например, замените Base
by Passport
и Child
на FakePassword
(предполагая, что поддельный пароль - это пароль...).
Таким образом, даже если класс Base
говорит: "Я действителен", тип Base
говорит: "Почти все мои экземпляры действительны, но те, кто недействителен, должны сказать это!" Вот почему у вас есть класс Child
реализующий Base
тип (и выводящий Base
класс), который гласит: "Я недействителен".
Более интересный пример
Но я думаю, что выбранный вами пример не самый лучший для проверки условий pre/post и инвариантов: поскольку функция чиста, она не может, по дизайну, прервать любой инвариант; поскольку возвращаемое значение является логическим (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
. В частности, выполняются все условия pre/post и инвариант. Другими словами, условия LSP
удовлетворяются и, таким образом, LSP соблюдается: мы можем заменить в каждой программе любой объект типа Collection
аналоговым объектом типа RandomAccesCollection
не нарушая поведения программ.
Заключение
Как вы видите, легче уважать LSP, чем разорвать его. Но иногда мы SortedRandomAccessCollection
его (например, попытаемся сделать SortedRandomAccessCollection
который наследует RandomAccessCollection
). Кристально понятная формулировка LSP помогает нам сузить то, что пошло не так, и что нужно сделать, чтобы исправить дизайн.
В более общем плане, виртуальные (физические) логические методы не являются анти-шаблонами, если базовый класс обладает достаточным количеством плоти для реализации методов. Но если базовый класс настолько абстрактный, что каждый подкласс wil должен переопределять методы, то оставьте методы абстрактными.
Рекомендации
Есть две основные оригинальные работы Лискова: " Абстракция данных и иерархия" (1987) и " Поведенческий подтипирование с использованием инвариантов и ограничений" (1994, 1999, с JM Wing). Обратите внимание, что это теоретические работы.