Зарегистрируйтесь, чтобы продолжить обучение

Принцип подстановки Лисков Python: Погружаясь в классы

Переопределение методов — мощный инструмент ООП, но его неправильное использование может привести к сломанному полиморфизму и другим архитектурным проблемам. В этом уроке мы рассмотрим принцип подстановки Лисков (Liskov Substitution Principle — LSP), который помогает правильно строить иерархии типов и избегать указанных проблем.

Как работает принцип подстановки Лисков

Предположим, что мы решили создать свой класс Logger, который будет записывать различные сообщения:

class Logger:
    def log(self, level, message):
        # Код
        pass

# Использование
logger = Logger()
logger.log('debug', 'Doing work')
logger.log('info', 'Useful for debugging')

Logger позволяет записывать сообщения с разным уровнем важности: от debug и до emergency.

Сигнатура метода log() устроена так, что первым параметром всегда передается уровень сообщения, а вторым — сообщение. Само сообщение — это строка произвольного формата, а уровнем может быть один из восьми вариантов.

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

Предположим, что теперь мы хотим переопределить метод log, чтобы уровень логирования передавался вторым параметром. Это позволит установить значение по умолчанию для часто используемого уровня. Для этого мы создаем подкласс MyLogger и переопределяем метод log:

class MyLogger(Logger):
    def log(self, message, level='debug'):
        # Реализуем новую сигнатуру log
        pass

# Использование
logger = MyLogger()
logger.log('Doing work')  # По умолчанию debug
logger.log('Useful for debugging', 'info')

Какие проблемы могут возникнуть с этим кодом? Если мы передаем экземпляр MyLogger в функцию или метод, который ожидает Logger, то это может вызвать ошибку. Это произойдет, потому что они используют метод log в соответствии с оригинальной сигнатурой, которая отличается от переопределенной.

# Предположим, что какой-то компонент системы хочет работать с логгером Logger, но внутрь передается MyLogger
logger = MyLogger()
database.setLogger(logger)

database.doSomething()
# Внутри вызывается логгер
# logger.log('info', 'boom!')

В 1987 году Барбара Лисков сформулировала принцип подстановки (Liskov Substitution Principle — LSP), следование, которому позволяет правильно строить иерархии типов:

Пусть q(x) является свойством, верным относительно объектов x некоторого типа T. Тогда q(y) также должно быть верным для объектов y типа S, где S является подтипом типа T.

Многие разработчики пытались переформулировать это правило так, чтобы оно было интуитивно понятным. Самая простая формулировка звучит так:

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

В примере выше функция setLogger(logger) ожидает на вход объект, который соответствует сигнатуре методов Logger. А мы передали ей MyLogger, который не следует первоначальной сигнатуре. Согласно принципу, код должен продолжать работать как ни в чем не бывало, но этого не происходит из-за нарушения интерфейса.

Этот принцип любят показывать на иерархиях наследования классов, но этот принцип относится к интерфейсам, а не классам. Иерархии классов не обязаны следовать ему, хотя было бы неплохо.

Почему вообще понадобился этот принцип? Почему бы не поручить эту работу языку? К сожалению, технически невозможно убедиться в соблюдении принципа Лисков. Поэтому его выполнение ложится на плечи разработчиков.

Теперь рассмотрим некоторые ключевые правила, которые необходимо учитывать при проектировании иерархий типов.

Правила проектирования иерархий типов

Существует несколько правил, которые надо учитывать при работе с типами:

  • Предусловия не могут быть усилены в подклассе
  • Постусловия не могут быть ослаблены в подклассе
  • Исторические ограничения

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

Например, в нашем логгере предусловием является то, что метод log() первым параметром принимает один из восьми уровней сообщений. Принцип Лисков утверждает, что мы не можем создать класс, реализующий этот интерфейс (логически), который может обрабатывать меньшее число уровней. Это и называется усилением предусловий, то есть требования становятся жестче — вместо восьми уровней, например, пять.

Попытка использовать объект такого класса закончится ошибкой, когда какая-то из систем попробует передать ему уровень, который не поддерживается. Причем не важно, приведет это к ошибке или логгер молча проглотит это сообщение и не запишет его в журнал. Главное, что поведение стало отличаться.

Встречаются ситуации, когда разработчики не видят причину такого поведения и начинают лечить следствия. В местах, где используются подобные объекты, добавляются проверки на типы. А это убивает полиморфизм.

С постусловиями ситуация аналогичная, но наоборот. Допустимо, если метод возвращает урезанный набор значений, так как этот набор все равно укладывается в требования интерфейса. А вот расширять возврат нельзя, так как появляются значения, которые не были предусмотрены интерфейсом. Это относится и к исключениям.

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

Выводы

Важно помнить, что при работе с объектно-ориентированным программированием, особенно при работе с наследованием и переопределением методов, мы должны следовать принципу подстановки Лисков. Это поможет избежать потенциальных проблем. Еще это важно при проектировании иерархий типов и интерфейсов.

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


Дополнительные материалы

  1. Circle-ellipse problem

Аватары экспертов Хекслета

Остались вопросы? Задайте их в разделе «Обсуждение»

Вам ответят команда поддержки Хекслета или другие студенты

Для полного доступа к курсу нужен базовый план

Базовый план откроет полный доступ ко всем курсам, упражнениям и урокам Хекслета, проектам и пожизненный доступ к теории пройденных уроков. Подписку можно отменить в любой момент.

Получить доступ
1000
упражнений
2000+
часов теории
3200
тестов

Открыть доступ

Курсы программирования для новичков и опытных разработчиков. Начните обучение бесплатно

  • 130 курсов, 2000+ часов теории
  • 1000 практических заданий в браузере
  • 360 000 студентов
Отправляя форму, вы принимаете «Соглашение об обработке персональных данных» и условия «Оферты», а также соглашаетесь с «Условиями использования»

Наши выпускники работают в компаниях:

Логотип компании Альфа Банк
Логотип компании Aviasales
Логотип компании Yandex
Логотип компании Tinkoff
Рекомендуемые программы
профессия
Программирование на Python, Разработка веб-приложений и сервисов используя Django, проектирование и реализация REST API
10 месяцев
с нуля
Старт 26 декабря

Используйте Хекслет по-максимуму!

  • Задавайте вопросы по уроку
  • Проверяйте знания в квизах
  • Проходите практику прямо в браузере
  • Отслеживайте свой прогресс

Зарегистрируйтесь или войдите в свой аккаунт

Отправляя форму, вы принимаете «Соглашение об обработке персональных данных» и условия «Оферты», а также соглашаетесь с «Условиями использования»