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

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

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

Начнем с самого простого. Предположим, что у нас есть несколько типов документов: обычный и его расширение - складской. Ещё у нас есть реестр, который умеет работать с документами различных типов.

from typing import TypeVar, Generic

# Определяем переменную типа обобщающую тип документа
DocumentT = TypeVar('DocumentT')

# Создадим тип документ
class Document: ...

# Создадим тип скласдкой документ
class WarehouseDocument(Document): ...

# Создадим реестр документов
class DocumentsRegistry(Generic[DocumentT]): ...

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

def operation(document_registry: DocumentsRegistry[Document]): ...

document_registry = DocumentsRegistry[Document]()
operation(document_registry)

В данной реализации type checker не нашел никаких ошибок. Рассмотрим вариант, в котором будем передавать реестр в функцию, умеющую работать со всеми подтипами документов реестра, содержащего только складские документы WarehouseDocument.

def operation(document_registry: DocumentsRegistry[Document]): ...

document_registry = DocumentsRegistry[WarehouseDocument]()
operation(document_registry)

# Получаем следующую ошибку при проверке типов
Argument of type "DocumentsRegistry[WarehouseDocument]" cannot be assigned to parameter "document_registry" of type "DocumentsRegistry[Document]" in function "operation"  
  "DocumentsRegistry[WarehouseDocument]" is incompatible with "DocumentsRegistry[Document]"  
    Type parameter "DocumentT@DocumentsRegistry" is invariant, but "WarehouseDocument" is not the same as "Document"

Type checker сразу говорит нам, о том, что тип DocumentsRegistry[WarehouseDocument] не совместим с типом DocumentsRegistry[Document]. Ошибка выглядит странно, ведь тип WarehouseDocument является производным от типа Document, следовательно обязан реализовать все методы, атрибуты родительского типа и принцип подстановки Лисков не нарушается.

Для того, чтобы разобраться почему type checker выдает ошибку, обратим внимание на следующую строку: Type parameter "DocumentT@DocumentsRegistry" is invariant, but "WarehouseDocument" is not the same as "Document". Она говорит о том, что универсальный тип DocumentT является инвариантом, но WarehouseDocument - не тоже самое, что Document.

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

Отношение типов

Для начала определимся с понятием "тип". Для этого проведем небольшое исследование и соберем несколько определений в различных источниках.

  • Тип данных (тип) - множество значений и операций над этими значениями (IEEE Std 1320.2-1998).

  • Тип данных - класс данных, характеризуемый членами класса и операциями, которые могут быть к ним применены (ISO/IEC/IEEE 24765-2010)

  • Тип данных - категоризация абстрактного множества возможных значений, характеристик и набор операций для некоторого атрибута (IEEE Std 1320.2-1998)

  • Тип данных - множество допустимых значений (ISO/IEC 9075-1:2023)

Во всех определениях есть ключевое слово - множество, следовательно, тип - это тоже множество. Но как же так, во втором определении речь идет не о множестве, а о классе данных? Давайте посмотрим на определение слова "класс".

  • Класс — термин, употребляемый в теории множеств для обозначения произвольных совокупностей множеств, обладающих каким-либо определённым свойством или признаком.

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

Теперь проведем параллель между наследованием/обобщением типов и множествами. Для множеств наследование будет эквивалентно выделению в множестве подмножества, а обобщению - переход от множества к надмножеству.

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

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

class Rational: 
	...

class Integer(Rational): 
	...

class Natural(Integer): 
	...

Теперь можно смело переходить к видам отношений типов.

Инвариантность

Инвариантность - самое простое отношение типов, оно говорит о том, что тип A является типом B.

Если рассматривать с точки зрения множеств, то множество A инвариантно множеству B, когда множество A полностью соответствует множеству B. То есть оба множества состоят из одних и тех же элементов.

В нашем примере с документами, каждый складской документ WarehouseDocument является документом Document, но не каждый документ Document является складским документом WarehouseDocument. Следовательно, тип Document не является инвариантом по отношению к типу WarehouseDocument, о чем и говорит ошибка type checker'а.

Ковариантность

Ковариантность - отношение типов, при котором сохраняется иерархия типов в сторону уточнения, то есть все производные типы считаются совместимы с базовым типом.

Рассмотрим на примере множеств. Для достижения ковариантного отношения множество B должно быть подмножеством для множества A. То есть множество A должно содержать все элементы из множества B.

В примере с документами тип Document, определяющий все множество возможных документов, содержит в себе все элементы из множества определяемого типом WarehouseDocument. Это следует из того, что каждый складской документ WarehouseDocument является документом Document, но не каждый документ Document является складским документом WarehouseDocument.

Ковариантность - это как раз то, что мы бы хотели видеть в примере из начала статьи. Давайте исправим ошибку и укажем, что наш универсальный тип является ковариантным.

from typing import TypeVar, Generic

# Определяем переменную типа обобщающую тип документа
DocumentT = TypeVar('DocumentT', covariant=True)

# Создадим тип документ
class Document: ...

# Создадим тип скласдкой документ
class WarehouseDocument(Document): ...

# Создадим реестр документов
class DocumentsRegistry(Generic[DocumentT]): ...

def operation(document_registry: DocumentsRegistry[Document]): ...

document_registry = DocumentsRegistry[WarehouseDocument]()
operation(document_registry)

Для этого достаточно при создании переменной типа указать ключевой аргумент covariant=True. Теперь ошибки нет и type checker работает так, как мы от него ожидаем.

Контравариантность

Остался еще один вид отношения типов - контравариантность, он похож на ковариантность, но работает в обратную сторону. Иными словами, все обобщенные типы являются совместимыми с дочерним типом.

Снова рассмотрим пример на множествах. Для контравариантности множество B должно быть надмножеством для множества A. Другим словами, множество B должно содержать все элементы из множества A.

Ограничения переменных типа

Говоря об универсальных типах, нельзя не упомянуть про возможность ограничивать переменные типов. Существует 2 варианта ограничения: через ключевой аргумент bound и через позиционные аргументы в переменной типа. Вернемся к нашему примеру с документами и рассмотрим один из вариантов использования ограничений. На этот раз у нас будет два типа документов: обычный базовый документ Document из прошлого примера и документ-черновик DocumentDraft. Каждый базовый тип образует свою иерархию наследования.

class Document: ...

class WarehouseDocument(Document): ...

class DocumentDraft: ...

class WarehouseDocumentDraft(DocumentDraft): ...

В первом примере укажем ограничения через позиционные аргументы для переменной типа.

DocumentT = TypeVar('DocumentT', Document, DocumentDraft, covariant=True)

Далее создадим реестр документов, который будет служить контейнером.

class DocumentsRegistry(Generic[DocumentT]): ...

Напишем функцию, которая будет принимать два реестра документов и возвращать новый реестр документов. Что будет делать функция - не важно. Главное - описать ее сигнатуру.

def operation(
        registry1: DocumentsRegistry[DocumentT],
        registry2: DocumentsRegistry[DocumentT]
    ) -> DocumentsRegistry: ...

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

result_registry = operation(
    DocumentsRegistry[WarehouseDocument](),
    DocumentsRegistry[WarehouseDocument]()
)

Данный код проходит проверку типов. Теперь попробуем дженерик в одном из аргументов заменить на тип WarehouseDocumentDraft.

result_registry = operation(
    DocumentsRegistry[WarehouseDocument](),
    DocumentsRegistry[WarehouseDocumentDraft]()
)

Argument of type "DocumentsRegistry[Document]" cannot be assigned to parameter "registry2" of type "DocumentsRegistry[DocumentT@operation]" in function "operation"  
  "DocumentsRegistry[Document]" is incompatible with "DocumentsRegistry[DocumentDraft]"  
    Type parameter "DocumentT@DocumentsRegistry" is covariant, but "Document" is not a subtype of "DocumentDraft"  
      "Document" is incompatible with "DocumentDraft"

Из ошибки видно, что сначала type checker нашел базовые типы, а далее указал нам на то, что Document и DocumentDraft несовместимы. В данном случае type checker выбирает один из типов, указанных в позиционных аргументах переменной типа, затем пытается разрешить все типы аргументов функции от выбранного.

Теперь переопределим переменную типа, используя указание ограничения через позиционный аргумент bound.

DocumentT = TypeVar(
    'DocumentT',
    bound=Union[Document, DocumentDraft],
    covariant=True
)

result_registry = operation(
    DocumentsRegistry[WarehouseDocumentDraft](),
    DocumentsRegistry[WarehouseDocument]()
)

# Нет ошибок проверки типов

Теперь код, который до этого выдавал ошибку при проверке типов, ошибок не выдает. В данном случает type checker уже разрешает типы от Union[Document, DocumentDraft], так как и тип Document, и тип DocumentDraft входят в множество Union[Document, DocumentDraft], проверка type checker'а проходит успешно.

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

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

Надеюсь, статья была для вас полезна, и вы узнали что-то новое.

Комментарии (6)


  1. alexey52
    04.04.2024 19:18

    Я прочитал и ничего не понял! Питон базово знаю. Наверное это все-таки не "простой" уровень сложности, или изложено не очень доходчиво.

    Но всё равно спасибо.


    1. amigo2208 Автор
      04.04.2024 19:18
      +1

      Спасибо за замечание, уровень сложности поменяли. Для лучшего понимания могу посоветовать ознакомится с PEP 484 раздел Generics.


  1. werevolff
    04.04.2024 19:18
    +1

    Спасибо. Хотелось бы ещë почитать про типизацию в Python. Я еë активно использую, включая дженерики с ограничениями, но русскоязычных статей на эту тему мало. Особенно, в разрезе связи с математикой. На всякий случай, подпишусь.


  1. vitaliyterletskiy
    04.04.2024 19:18
    +1

    Начинаю изучать python, подскажите где можно найти полезные курсы или книги с обучающим материалом, буду очень благодарен!


    1. amigo2208 Автор
      04.04.2024 19:18

      Для изучения могу посоветовать платформу stepik.org, на ней есть множество бесплатных курсов по python разных уровней сложности. Из литературы можно начать с классики - Изучаем Python. | Лутц Марк


  1. sswwssww
    04.04.2024 19:18

    Вы что-то намудрили с примерами для контравариантности(почему то указываете флаг covariant=True для примера контравариантности). В TypeVar же можно просто передать аргумент contravariant=True и это будет работать.