[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 методов противоречивы:

  1. С точки зрения наследования тип Dog.eat() является подтипом Animal.eat() (поскольку Dog является подтипом Animal).
  2. В то же время, с точки зрения типа аргументов, Dog.eat() является супертипом для Animal.eat() (как мы видели выше).

Для двух различных типов A и B не может быть этих A <: B и B <: A одновременно, поэтому существует противоречие, и mypy сообщает об этом.

Почему проблема была не такой простой

Изначально мне было нелегко понять проблему. Мне нужно было провести небольшое исследование и немного поэкспериментировать. Все встало на свои места только после того, как я увидел пример кода, похожий на тот, что с функцией feed_animal. Почему проблема не была более простой?

Я думаю, что в моем случае это было сочетание трех причин:

  1. Код выглядел нормально. Рассматриваемый код на первый взгляд выглядел правильно. Кроме того, до сих пор это работало во всех случаях. Моресо, похоже, подтвердили многочисленные тесты. В таких случаях мы иногда отключаем любые дополнительные рассуждения о коде. Мы думаем, что в коде ничего не хватает, и просто идем дальше. Если код прост, мы даже более склонны к этому.
  2. Классовые отношения были обманчивыми. Отношения между классами были несколько обманчивыми и приводили к неточной интуиции. Мы интуитивно ассоциируем мясо с собаками, и аннотации типов, кажется, подтверждают это. Тем не менее, с точки зрения типов, Meat класс вообще не имеет отношения к Dog классу. И, как мы видели, ничто не мешает нам кормить нашу собаку едой, отличной от Meat.
  3. У меня было слишком мало опыта. Во многих проектах Python, над которыми я работал, отношения между классами были довольно простыми. В большинстве случаев сложные отношения между классами не требовались, поскольку это были проекты на основе Django. И давайте посмотрим правде в глаза: во многих проектах Python фреймворк заботится обо всех структурах, а мы просто заполняем пробелы. Когда мы сталкиваемся с более сложной системой, особенно если она не сильно связана с веб-фреймворком, мы вынуждены изучать шаблоны, с которыми мы никогда раньше не сталкивались. Это заставляет нас развивать новую интуицию.

Теперь вы знаете, почему вообще произошла ошибка и почему ее было не так легко понять сразу. Хорошо, а как избавить код от ошибки? Другими словами: Как спасти Лесси ?! Ответ находится всего в одном клике: в s02e02 я показываю несколько способов спасти нашего очаровательного питомца.



Вам нужна дополнительная информация о взаимоотношениях подтипов, которые мы обсуждали здесь? Посмотрите третью часть этой серии. Вы найдете там определения ковариации, контравариантности и инвариантности типов, а также несколько примеров Python.



Если вам понравился этот пост, нажмите кнопку хлопка ниже 👏👏👏

Вы также можете подписаться на нас в Facebook, Twitter и LinkedIn.