Сегодня мы исследуем принцип на букву L, который практически никто не понимает.
Предыдущая часть:
Итак, L обозначает Liskov Substitution. Вот эта бодрая бабуля, дай ей бог здоровья –
– Барбара Лисков, которая удостоилась своего именного принципа в 1987 году.
Звучит он максимально невменяемо для нормального человека, я попробую немного упростить:
Если у объекта класса T есть некое доказуемое/проверяемое свойство, то такое же точно свойство должно быть у объекта класса S, который наследуется от T.
Слово Substitution означает "подстановка". То есть если подставить класс S вместо класса T, программа не должна сломаться. Именно по причине того, что S обладает теми же свойствами, которые программа ожидает от T.
Наивный подход
Предположим, есть класс User с методом login(). Если отнаследовать от него класс User2, то он унаследует и метод login(). Следовательно, можно подставлять вместо класса User класс User2 и его поведение не изменится.
Выглядит это настолько очевидно, что становится непонятно – неужели ради этого городили целый принцип?
Но Лисков роет глубже. И обычно этот принцип объясняют на примерах, его нарушающих.
Одним из таких, довольно неудачных примеров, является притча о прямоугольнике и квадрате.
Есть класс прямоугольника Rectangle, у которого есть методы для задания ширины и высоты: setWidth() и setHeight(). Затем появляется класс квадрата – Square, который наследуется от Rectangle и конечно наследует методы задания ширины и высоты.
Но возникает неудобство: ведь мы знаем, что ширина и высота квадрата всегда равны, но вызовом разных методов можно отдельно поменять ширину и высоту.
Что ж, тогда мы в классе Square пишем свои методы setWidth() и setHeight(). Они будут перекрывать методы родительского класса и суть их в том, чтобы при изменении ширины сразу менять и высоту, а при изменении высоты – ширину. Так мы сможем гарантировать, что квадрат останется квадратом. И класс Square можно подставлять вместо класса Rectangle, так как у них одинаковые свойства...
Формально – да, если вызвать setWidth() или setHeight() у квадрата, то оно сработает так же, как у прямоугольника. Но при этом мы не учитываем побочных эффектов.
Например, программа вызывает getHeight(), сохраняя результат в переменной height, а затем вызывает setWidth(). Ожидания программы заключаются в том, что значение переменной height сейчас соответствуют истинной высоте прямоугольника, которая никем не менялась. Но в случае с квадратом высота изменится вместе с шириной, и поэтому программа получит то, что не ожидала.
Это и есть нарушение принципа подстановки Лисков.
То есть свойства класса это не только его атрибуты или методы. Свойства это наблюдаемые эффекты от его поведения, включая побочные.
Что же нам делать с примером про прямоугольник и квадрат? Во-первых, он абсолютно надуманный и непонятно вообще для чего нужны эти прямоугольники и квадраты. Зачем наследовать квадрат от прямоугольника, тоже непонятно. Если программа ожидает независимого изменения ширины и высоты, то с квадратом вместо прямоугольника она не сможет работать в принципе никак. Это задача, у которой нет правильного ответа.
Во-вторых, ну если вы приняли решение, что квадрат будет работать вот так, значит и эффекты от него вы должны ожидать вот такие. Эти эффекты нужно просто сделать общими с прямоугольником. Программа вообще не должна ожидать, что изменив ширину прямоугольника, она получит неизменённую высоту. Если вы прописали это в контракте, следовательно принцип вы не нарушите.
В целом-то проблема зависит именно от того, как прописан контракт. Скажем, я бы решил её так: сделал бы метод setBoundingRectangle(width, height), который задавал бы ширину и высоту, но не самой фигуры, а ограничивающего её прямоугольника.
Таким образом, прямоугольник всегда вписывается сам в себя, квадрат вписывается так, чтобы не превышать размер по меньшей стороне, а кроме того, мы теперь можем оперировать другими подклассами, такими как треугольник и даже круг. Все они при подстановке будут выполнять контракт по вписыванию в прямоугольник. Правда, родительский класс теперь лучше назвать не прямоугольником, а просто фигурой (Shape).
Другой анти-пример это летающие птицы. Скажем, есть класс Bird, у которого есть метод fly(). И вот мы наследуем утку Duck от класса Bird, и она умеет летать. Но вот мы отнаследовали страуса Ostrich от класса Bird, потому что он тоже птица, но летать он не умеет. Принцип подстановки нарушен, программа сломалась.
Принцип подстановки связан именно с поведением объектов. И если он ломается, значит это сигнал о том, что поведение спроектировано неправильно.
В данном случае было ошибкой назначать метод fly() базовому классу Bird, предполагая, что все птицы летают. Очевидно, не все, значит, такой метод не должен присутствовать в базовом классе.
Но давайте опять смотреть на контракт и на то, что мы от него хотим.
Если предположить, что метод fly() возвращает true или false (и мы должны обрабатывать этот результат соответственно), то очевидно, им может пользоваться и страус, просто всегда возвращать false.
Но вообще лучше убрать метод fly() из Bird. Если углубиться в тему, мы можем выяснить, что и полёты бывают разные. Типа как стриж или как орёл. Кроме того, существуют и летающие животные, и летающие рыбы, и насекомые, и даже семена растений.
Таким образом, лучше сделать класс FlyingMethod с методом fly(), и от него можно наследовать любые типы полётов. Что касается птиц, то в класс Bird мы добавим атрибут flyingMethod, которому присвоим один из классов полёта.
Далее при создании утки будем назначать свойственный ей класс полёта в конструкторе:
var duck = new Duck(new FlyingMethodNormal());
duck.flyingMethod.fly();
Для страуса же мы можем иметь "нулевой" метод, который ничего не делает:
var ostrich = new Ostrich(new FlyingMethodNone());
ostrich.flyingMethod.fly();
Ну и как можно видеть, таким образом мы можем создать не только птицу, но и белку-летягу, и летучую рыбу, и пчелу. Так что вопрос о специализации класса Bird открыт – может, он теперь и вовсе не нужен, а вместо него будет Animal?
Здесь мы от наследования перешли к композиции. И да, когда вы замечаете, что используете интерфейсы в качестве контракта и композицию вместо наследования, это говорит о том, что вы используете принцип подстановки Лисков, потому что именно к этому он и склоняет.
Читайте дальше: