[s02e01] Понимание контравариантности
Это сообщение в блоге основано на реальном случае. Все началось с запуска средства проверки типов mypy на каком-то коде. Я получил странное сообщение. Я не понял сообщения. Мне не понравилось то, что я этого не понял 😐 Поэтому я начал немного разбираться. Результаты этого исследования помогли мне лучше понять формальные особенности систем типов. Я также уверен, что это поможет мне написать лучший код.
Это сообщение в блоге - , которое я искал, пытаясь понять ошибку. Ни один из найденных мной источников не заставил меня задуматься: А, теперь я понял! Сначала мне нужно было немного поэкспериментировать. Это сообщение в блоге более или менее пересказывает мой путь. Первая часть посвящена моему расследованию и процессу осмысления его результатов. Вторая часть - о возможных исправлениях кода. Третья часть посвящена определению и объяснению ключевых понятий для понимания обсуждаемых здесь вопросов: ковариантности, контравариантности и инвариантности; Кроме того, вы найдете там ссылки на источники, которые я использовал при написании всех трех частей.
Это сообщение для вас, если:
- вы знаете синтаксис Python
- ты знаешь, что такое наследование
- вы знаете основы системы типов Python; достаточно прочитать первый сезон моей серии сообщений в блоге (см. s01e01 и s01e02)
- вы не знаете, что такое «ковариация», «контравариантность» и «инвариантность», и ...
- вы хотели бы знать, что это за «отклонения» 😉
Этот пост может быть не для вас, если:
- вы знаете, что «функция ковариантна по типу возвращаемого значения, но контравариантна по типам аргументов» означает 🤓
Я постараюсь пройти все шаг за шагом и быть как можно более информативным. В результате этот пост может быть многословным для некоторых из вас. Я пишу его для себя несколько месяцев назад, так что ¯ \ _ (ツ) _ / ¯. С другой стороны, это будет непросто и, вероятно, потребует некоторого дополнительного внимания. Может потребоваться перечитать некоторые отрывки. Так что заранее напрягите мозг-мускулы! 💪
Все фрагменты кода совместимы с Python 3.6+. Тем не менее, обсуждаемые здесь проблемы применимы ко всем версиям Python и в целом ко всем языкам программирования. Я использую проверку типов mypy 0.630.
Дело
Давайте изучим следующий код:
Это несколько запутанный код, на котором я запускал mypy. Настоящий код был посвящен адаптерам и событиям, которые проходят через них, но отношения между классами были точно такими же.
Код может показаться прекрасным: животное ест (общее) Food
, собака ест (более конкретно) Meat
. Тем не менее, mypy не доволен. Для строки, отмеченной комментарием # mypy error
, отображается следующая ошибка:
error: Argument 1 of "eat" incompatible with supertype "Animal"
.
Mypy, как настоящий Pythonista, считает с нуля, поэтому «аргумент 1» означает food
, а не self
. Но что с ошибкой? Это в основном означает, что тип аргумента food
из Dog.eat()
несовместим с типом аргумента food
из Animal.eat()
. Хорошо, но что означает это? В чем несовместимость? Может показаться естественным, что более общий Food
используется более общим Animal
, а менее общий Meat
используется менее общим Dog
.
На этом этапе вы можете подумать, что если код выглядит нормально и работает (он был тщательно протестирован!), Стоит ли вам беспокоиться об ошибке? Эти «теоретические» несоответствия на самом деле не помогают в реальной разработке программного обеспечения, верно? Надеюсь, после прочтения остальной части сообщения в блоге вы убедитесь, что эта «теория» действительно полезна и может предотвратить катастрофические ошибки. Итак, приступим к расследованию! 🕵️️
Расследование
Эксперимент
Мы знаем, что что-то определенно не так с типами аргументов функций, которые «несовместимы» между классами Animal
и Dog
. Давайте посмотрим, что произойдет, когда мы поменяем местами типы food
аргумента: Animal
съест Meat
, а Dog
будет есть общий Food
:
Нет ошибки. Разве это не странно? Теперь более общее Animal
ест более конкретное Meat
и менее общее Dog
ест более общее Food
, и с типами все нормально. Нам нужно больше улик! 🔎
Общие правила присвоения
Во-первых, давайте немного отступим и подведем итоги правил назначения, которые обсуждались в этой записи блога.
Присвоение a_dog
переменной an_animal
- переменной типа Animal
— является типобезопасным, поскольку каждый Dog
имеет значение Animal
, что означает, что Dog
является подтипом Animal
, вкратце: Dog <: Animal
. (Символ <:
гласит: «является подтипом».) Другими словами, типобезопасно использовать a_dog
везде, где ожидается an_animal
.
Присвоение an_animal
a_dog
- переменной типа Dog
- небезопасно для типов, поскольку не каждый Animal
является Dog
; Animal
не является подтипом Dog
.
Итак, в общем, базовое типобезопасное присвоение выглядит так:
# SubType <: SuperType supertype_variable: SuperType subtype_variable: SubType supertype_variable = subtype_variable
Типобезопасно использовать объект SubType
везде, где ожидается объект SuperType
.
Правила назначения функций
Мы знаем, что у функций тоже есть типы. Таким образом, к ним также применяются правила присваивания типов. Определим две простые функции. Первый принимает Animal
и возвращает None
. Второй принимает Dog
и также возвращает None
.
Типы этих двух функций соответственно:
Callable[[Animal], None] Callable[[Dog], None]
Теперь давайте попробуем присвоить dog_run
animal_run
, как мы (успешно) присвоили a_dog
an_animal
:
Mypy не радует, интересно. Пока что…
Почему в первом случае есть ошибка, а во втором - нет?
Подтипы функций
Похоже, что тип animal_run
является подтипом типа dog_run
или, в более общем смысле, Callable[[Animal], None] <: Callable[[Dog], None]
. И это действительно так, здесь нет ошибки.
Очевидно, в случае аргументов функции подтип работает наоборот по сравнению с простыми объектами: функция с более общим типом аргумента является подтипом функции с менее общим типом аргумента:
для SubType <: SuperType
это правда, что Callable[[SuperType], None] <: Callable[[SubType], None]
.
(Эта функция формально называется контравариантностью. Я определяю ее в третьей части этой серии сообщений в блоге: s02e03.)
Это в основном означает, что когда ожидается Callable[[Dog], None]
(например, dog_run()
) функция, Callable[[Animal], None]
(например, animal_run()
) функция также приемлема, но не наоборот.
Давайте подтвердим это с помощью mypy. Я буду использовать обе run
функции, определенные ранее:
Эксперимент 1
Во-первых, я определю функцию make_dog_run
, которая принимает функцию Callable[[Dog], None]
в качестве параметра и использует ее в Dog
. Далее я вызову его с Callable[[Dog], None]
и с Callable[[Animal], None]
функциями. Если Callable[[Animal], None] <: Callable[[Dog], None]
, то mypy должен принимать оба вызова.
Как и ожидалось, ошибок нет.
Эксперимент 2
Теперь я определю функцию make_animal_run
, которая принимает функцию Callable[[Animal], None]
в качестве параметра, и вызову ее с теми же двумя функциями run
: dog_run
и animal_run
. Если Callable[[Animal], None] <: Callable[[Dog], None]
, mypy должен отклонить вызов make_animal_run
с Callable[[Dog], Any]
function.
Передача dog_run
в make_animal_run
отклоняется mypy, как и ожидалось.
Оба эксперимента показывают, что на самом деле Callable[[Animal], None] <: Callable[[Dog], None]
так, по крайней мере, согласно mypy. И это действительно имеет смысл, если задуматься. Мы не должны вызывать make_animal_run
с dog_run
. Почему? an_animal
, для которого мы вызываем переданную run_dog
функцию, может не быть Dog
и run_dog
может не подходить для нее. Представим себе это:
Теперь мы передали Animal
(каждый Kangaroo
- это Animal
) make_animal_run
вместе с dog_run
. А внутри make_animal_run
мы фактически заставляем кенгуру бегать как собака. Это так глупо, что даже mypy знает об этом 🙃
Полученные результаты
Исходный код небезопасен
Для удобства повторю код, с которого мы начали:
Давайте посмотрим, как исходная ошибка mypy указала не только на некоторые «теоретические несоответствия», но и на настоящую ошибку в нашем коде. Это будет очень похоже на наш пример с кенгуру, но с классами.
Все может показаться прекрасным: Chocolate
- это подтип Food
, поэтому я могу использовать его всякий раз, когда используется Food
, как в Animal.eat
. Конечно, но посмотрите на это:
Лесси - это Dog
, который является подтипом Animal
, поэтому я должен безопасно передать ее feed_animal
. Могу ли я раскомментировать код и запустить его? … Нет! Стоп! 🚫 Это отравит Лесси! Она может есть только Meat
(как указано в методе Dog.eat
), а Chocolate
вреден для нее. Слепо следуя плохо построенным отношениям типов, мы можем навредить нашей собаке или вызвать другие катастрофические результаты в производстве.
Поэтому код с поменявшими местами food
типов (см. Выше) не вызывал никаких mypy-ошибок. Этот код нельзя использовать с функцией типа feed_animal
. Это невозможно, потому что использование Animal
’s eat
для кормления собаки не может заставить собаку есть пищу, несовместимую с типом параметра eats
food
, поскольку это общий Food
.
Так что mypy действительно был на чем-то реальном. Это было не просто «теоретически», то есть непрактично. В хорошем смысле это было «теоретически». Хорошая теория имеет приложение к практике, а теория типизации Python - хорошая теория. Давайте вернемся к нему на мгновение, чтобы уточнить детали.
Что такое «несовместимость»
Напомним определение подтипа. Из определения следует, что:
набор значений становится меньше в процессе выделения подтипов, а набор функций становится больше.
Итак, каждая функция из Animal
также входит в набор функций из Dog
: если Animal может что-то делать, мы можем быть уверены, что Dog также может это делать (по наследству). Следовательно, мы должны безопасно использовать метод / функцию каждогоAnimal
для экземпляра Dog
. Наоборот, это неверно: Animal
не может делать все, что Dog
. Например, Animal
не может лаять. И это работает, как мы могли видеть, и вне контекста класса - и animal_run
, и dog_run
являются автономными функциями. Это неудивительно, поскольку методы - это просто функции с первым параметром, установленным на self
(который имеет фиксированный тип класса, к которому прикреплен метод). Каждая функция / метод, определенные для SuperType
объектов, должны быть применимы к SubType
объектам, а это неверно как в kangaroo-run-like-a-dog, так и в dog-eats-chocolate случаи.
Итак, под «несовместимым» mypy подразумевается, что отношения между типами обоих eat
методов противоречивы:
- С точки зрения наследования тип
Dog.eat()
является подтипомAnimal.eat()
(посколькуDog
является подтипомAnimal
). - В то же время, с точки зрения типа аргументов,
Dog.eat()
является супертипом дляAnimal.eat()
(как мы видели выше).
Для двух различных типов A
и B
не может быть этих A <: B
и B <: A
одновременно, поэтому существует противоречие, и mypy сообщает об этом.
Почему проблема была не такой простой
Изначально мне было нелегко понять проблему. Мне нужно было провести небольшое исследование и немного поэкспериментировать. Все встало на свои места только после того, как я увидел пример кода, похожий на тот, что с функцией feed_animal
. Почему проблема не была более простой?
Я думаю, что в моем случае это было сочетание трех причин:
- Код выглядел нормально. Рассматриваемый код на первый взгляд выглядел правильно. Кроме того, до сих пор это работало во всех случаях. Моресо, похоже, подтвердили многочисленные тесты. В таких случаях мы иногда отключаем любые дополнительные рассуждения о коде. Мы думаем, что в коде ничего не хватает, и просто идем дальше. Если код прост, мы даже более склонны к этому.
- Классовые отношения были обманчивыми. Отношения между классами были несколько обманчивыми и приводили к неточной интуиции. Мы интуитивно ассоциируем мясо с собаками, и аннотации типов, кажется, подтверждают это. Тем не менее, с точки зрения типов,
Meat
класс вообще не имеет отношения кDog
классу. И, как мы видели, ничто не мешает нам кормить нашу собаку едой, отличной отMeat
. - У меня было слишком мало опыта. Во многих проектах Python, над которыми я работал, отношения между классами были довольно простыми. В большинстве случаев сложные отношения между классами не требовались, поскольку это были проекты на основе Django. И давайте посмотрим правде в глаза: во многих проектах Python фреймворк заботится обо всех структурах, а мы просто заполняем пробелы. Когда мы сталкиваемся с более сложной системой, особенно если она не сильно связана с веб-фреймворком, мы вынуждены изучать шаблоны, с которыми мы никогда раньше не сталкивались. Это заставляет нас развивать новую интуицию.
Теперь вы знаете, почему вообще произошла ошибка и почему ее было не так легко понять сразу. Хорошо, а как избавить код от ошибки? Другими словами: Как спасти Лесси ?! Ответ находится всего в одном клике: в s02e02 я показываю несколько способов спасти нашего очаровательного питомца.
Вам нужна дополнительная информация о взаимоотношениях подтипов, которые мы обсуждали здесь? Посмотрите третью часть этой серии. Вы найдете там определения ковариации, контравариантности и инвариантности типов, а также несколько примеров Python.
Если вам понравился этот пост, нажмите кнопку хлопка ниже 👏👏👏
Вы также можете подписаться на нас в Facebook, Twitter и LinkedIn.