Andriy Shyrokoryadov

.Net developer, data scientist

Принципы SOLID - вопрос №6 на собеседование по программированию

Текст к видео "SOLID - принцип подстановки Лисков" на канале YouTube

Приветствую Вас на моём канале. Сегодня мы ненадолго отвлечемся от тематики Asp.Net Core и вернёмся к вопросу принципов SOLID. На сколько я помню у нас еще осталось 2 принципа и в данном видео мы познакомимся с одним из них. Речь пойдет о принципе L, то есть Liskov Substitution Principle в англоязычной литературе или принцип подстановки Барбары Лисков. По моему субъективному мнению, это самый сложный в понимании из принципов SOLID. Эта сложность выражается не в том, что здесь используются какие-то не тривиальные подходы, а в том, что нарушение данного принципа сложно определить. Будет казаться, что все в порядке и всё работает, но могут возникнуть некоторые сценарии, в которых окажется, что всё-таки изначально данный принцип был нарушен и именно в этом заключается сложность данного принципа - сложность предвидения всех возможных сценариев нарушения данного принципа.

Принцип подстановки был сформулирован Барбарой Лисков в 1987 и данная концепция не нова. Я знаком с формулировкой данного принципа самой Барбары Лисков, а также мне известны формулировки других авторов, которые выдвинули подобные определения на основании формулировки Барбары Лисков. Однако если я вам приведу сейчас данные определения, то это только вас запутает и совсем не поможет в понимании данного материала. Поэтому я сейчас постараюсь перефразировать своими словами определение принципа L и это определение будет базироваться на том, как я понимаю данный принцип.

Определение данного принципа: любой класс-наследник базового класса может использоваться вместо базового класса. Например, у нас есть класс «Зверь» и есть его наследник «Лев». Во всех местах в коде, где используется класс «Зверь» мы можем без проблем изменить код таким образом, чтобы вместо класса «Зверь» использовался класс «Лев» и использовать данный код.

Давайте рассмотрим этот пример в коде. У нас есть абстрактный класс Animal с виртуальным методом Say. Также у нас есть наследник класса Animal - класс Lion с переопределенным методом Say. Если мы посмотрим код нашего приложения, где используются 2 данных класса – это метод RunExampleOne в нашем консольном приложении, то мы можем утверждать, что в строчке 24 в качестве типа объекта animal может использоваться как класс Animal и Lion. После запуска приложения и вызова метода RunExampleOne мы видим, что всё работает.

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

Давайте рассмотрим пример, когда нам кажется, что классы задекларированы правильно, однако существуют сценарии, когда принцип подстановки Барбары Лисков будет нарушен. Я подготовил 2 класса: Circle и Ellipse, то есть окружность и эллипс. Класс Circle т.е. Окружность наследует класс Ellipse. Так как окружность — это частный случай эллипса, в котором горизонтальная и вертикальная полуоси равны. В классе Ellipse определены свойства для вертикальной и горизонтальной полуосей. Эти свойства немного переопределены в классе Circle. Это переопределение сводится к тому, что для окружности при присваивании свойству одной полуоси автоматически присваивается значение второй полуоси. Таким образом в окружности обе полуоси всегда равны. Это соответствует свойствам окружности и эллипса и кажется логическим и правильным на первый взгляд. Давайте посмотрим на метод, который использует эти 2 класса. Это метод RunExampleTwo. В этом методу мы создаем один экземпляр класса Ellipse и один экземпляр класса Circle. Далее мы помещаем эти 2 экземпляра в массив. Мы можем так сделать, потому что эти 2 метода имеют один общий тип Ellipse. Далее мы перебираем элементы данного массива и выполняем бизнес логику, которая определена таким образом, что мы должны использовать только настоящие эллипсы, то есть такие фигуры у которых полуоси имеют разный размер. К сожалению, экземпляр класса Circle не соответствует данному условию и в данном примере не может использоваться вместо своего базового класса Ellipse. Давайте запустим данный пример и посмотрим, что произойдет. После запуска приложения с данным пример было выброшено исключение. Если класс – наследник Circle не может быть использован вместо класса – родителя Ellipse, то это является нарушением принципа подстановки. Из этой ситуации мы можем сделать вывод, что несмотря на то, что наши изначальные представления о программе были логически правильными и имели смысл, мы не смогли предвидеть, что кто-то напишет такую логику при которой окажется что принцип подстановки будет нарушен.

Что мы можем сделать в этой ситуации?

Первое что необходимо уяснить это то, что невозможно предвидеть все сценарии для класса – родителя и класса – наследника при которых принцип подстановки будет нарушен. Однако, есть возможно определить конечное число сценариев, которые важны для нашего приложения и написать под эти сценарии юнит тесты. Таким образом при добавлении новых классов наследников у нас будет возможность проверять не нарушают ли они принцип подстановки.

Второе – необходимо постараться в классах наследниках не изменять логику асессоров и мутаторов, то есть геттеров и сеттеров, то есть по-человечески – не изменять логику получения и установки значений свойств в классах наследниках. Эта логика была изменена в классе Circle и появились проблемы.

У меня есть еще 2 примера ситуаций, когда вроде бы принцип подстановки не нарушен, на сам код оставляет желать лучшего. Эти 2 ситуации легко распознать в коде.

Рассмотрим первую ситуацию. Эту ситуацию я называю «неиспользованные методы». То есть если мы видим, что некоторые методы в классе наследнике переопределяют методы в базовом классе, но тело данных методов пустое. То есть, данные методы по факту не нужны в классе – наследнике. Чтобы имплементация в базовом классе не мешала, её просто переопределили на пустой метод. Данная свидетельствует о том, что базовый класс был подобран неправильно. Посмотрим на код.

У нас есть класс «механическая коробка передач» ManualTransmission с двумя методами PressClutch, выжми сцепление и SwitchGear, переключи передачу. Для зрителей, которые не управляют автомобилем небольшая техническая ремарка. В автомобиле с механической коробкой передач для переключения передачи необходимо выжать сцепление. Вернемся к коду. У нас также есть класс AutomaticTransmission, который наследует ManualTransmission. В автоматической коробке передач отсутствует сцепление в том виде, в котором оно есть в механической коробке. Некоторые люди могут назвать сцеплением автоматической коробки - гидротрансформатор автоматической коробки переключения передач, однако разговор сейчас не об этом. Метод PressClutch, выжми сцепление в классе AutomaticTransmission не нужен, так как в автоматической коробке передач нет необходимости выжимать сцепление для переключения передач. Эти классы используются в методе RunExampleThree, в котором создаются две коробки передач: автоматическая и механическая. Далее в каждой из коробок переключается передача. Давайте запустим этот код и посмотрим, как это работает. Код работает без проблем, но наличие пустых методов – это признак плохого кода.

Как мы можем изменить существующий код, чтобы всё было, так сказать, по фен-шую. Такое решение уже готово. Мы должны из классов AutomaticTransmission и ManualTransmission выделить общую функциональность в отдельный класс, который станет новым базовым классом для классов AutomaticTransmission и ManualTransmission. Этим классом будет класс Transmission в котором есть уже знакомый нам метод SwitchGear и есть новый метод EnableGearSwitching, включи возможность переключения передачи. В теле этого метода мы определяем значение свойства IsGearSwitchingEnabled. По сути это некоторого рода нажатие сцепления в механической коробке передач, но более обобщённое название, более обобщенная функциональность, которая не привязано ни к одному из типов коробок передач. Посмотрим как реализованы наследники класса Transmission: классы Automatic и Manual.

В классе Automatic мы только определяем метод SwitchGear, а метод EnableGearSwitching, включи возможность переключения передачи, нас полностью устраивает – в большом упрощении переключение передачи в автоматической коробке всегда возможно. С другой стороны, в классе Manual для механической коробки мы добавили имплементацию нажима сцепления. Давайте посмотрим, как работают эти класс. Код с их использованием ничем не отличается от предыдущего примера: 2 коробке в которых переключаются передачи. Данный код работает практически так же, как и в предыдущем примере, однако дизайн нашего кода лучше, чем был. Нет пустых неиспользуемых методов.

Это была первая ситуация. А теперь вторая ситуация, которую я называю ифология от большого количества ключевых слов if в коде. У нас есть базовый класс Shape в котором рассчитывается площадь фигуры. Также у нас есть 2 класса – наследника Circle, окружность, и Rectangle, прямоугольник. Когда мы рассчитываем площадь фигуры в методе CalculateSquare определенным в классе Shape, то мы проверяем с помощью оператора if какой у нас по факту тип объекта и в зависимости от данного типа определяем по соответствующей формуле площадь. В этом коде несколько проблем:

  • слишком много операторов if, пока их только 2, но по мере добавления наследников их будет всё больше и больше;
  • когда мы добавляем наследника класса Shape мы должны изменять класс Shape – это плохо, отсутствует изолированность изменений
  • класс Shape ничего не должен знать о своих наследниках, а он знает и причем очень много.

Давайте запустим код с использованием этих классов. Этот код определён в методе RunExampleFour. Опять-таки создаем массив из пары объектов и на каждом объекте выполняем общую операцию. Запускаем приложение, всё работает так, как мы и ожидали, однако с точки зрения архитектуры код не очень качественный. Как мы можем это исправить? Очень просто – метод CalculateSquare должен быть абстрактным. Каждый наследник может реализовать данный метод по своему усмотрению. Каждая инструкция из блока if в старом коде стала реализаций метода CalculateSquare в классах наследниках. Давайте запустим этот код. Результат работы абсолютно одинаков, однако во втором случае у нас нет зависимости базового класса Shape от классов наследников, а также мы избавились от множества инструкций if.

Давайте подведем итог – сегодня мы познакомились с принципом подстановки Барбары Лисков, не самым легким из пяти принципов, а также рассмотрели пару ситуаций, когда данный принцип не был нарушен, однако сам код требовал улучшений. На конец небольшая шутка – мой коллега во время собеседования попытался спасти кандидата. Он спросил кандидата, в принципе подстановки Лисков – Лисков это мужчина или женщина. К сожалению, кандидат ответил, что Лисков был мужчиной. К сожалению или, к счастью, кандидат не получил у нас работу.

Благодарю Вас за внимание. Если Вам понравилось данное видео, то вы знаете, что делать: лайк, подписка, колокольчик, комментарий. До новых встреч на канале.

Пример кода из видео на GitHub

Для открытия файла проекта необходимо Visual Studio 2019.