Алгебраические типы данных и Python
- суббота, 10 июля 2021 г. в 00:36:54
Возможно, кто-то из читателей, увидев заголовок этой статьи, подумает что-нибудь вроде:
"Что?! Алгебраические типы данных?! Это же что-то из мира функциональных языков программирования. Python?! Ну нет... Где Python со своей динамической утиной типизацией, а где типы данных, и уж тем более алгебраические..."
Что-то в таком духе. Но, на самом деле, в Python есть своя система типов, кроме того Python считается языком со строгой типизацией, а благодаря mypy и аннотациям, корректность используемых типов может быть проверена статически без непосредственного запуска программы. Помимо всех этих достаточно интересных особенностей, в Python есть возможность работать и с алгебраическими типами данных. Так что же это за типы данных, как они выглядят в Python, и какую практическую пользу приносят — попробуем разобраться в этой статье.
Примечание. Далее в примерах я буду использовать возможности Python 3.9 и 3.10, а также mypy версии 0.910 (со следующими настройками) для статической проверки корректности типов.
Скорее всего, если вы занимаетесь программированием в каком-то виде, то вы уже используете алгебраические типы данных, возможно, не подозревая об этом. Под этим несколько академическим понятием скрывается очень простая идея композиции — как из одних типов данных получить другие, составные и более сложные (именно на эту идею композиции типов данных и намекает картинка на обложке этой статьи). Итак, алгебраический тип данных — это такой тип данных, который является композицией других типов. Вот такое незатейливое определение. Алгебраические же эти типы данных из-за того факта, что новые составные типы получаются с помощью аналогов простых алгебраических операций сложения и умножения. Чтобы разобраться, что же могут представлять из себя сумма и произведение типов данных, для начала стоит понять, что такое тип данных вообще.
Самый простой способ думать о типе данных — это представить этот тип как множество всех его значений. Например, тип bool
— это двухэлементное множество, которое содержит элементы True
и False
. Следующий перечисляемый тип Color
:
from enum import Enum
class Color(Enum):
Red = "r"
Green = "g"
Blue = "b"
является множеством, которое содержит 3 элемента. Тип данных int
можно представить как множество всех целых чисел. А тип данных str
— это множество, элементами которого являются пустая строка, строки "привет"
и "hello"
, строка с текстом вашей любимой песни, и так далее. В общем, как несложно заметить, множество значений типа данных str
содержит довольно много элементов.
Если мы можем думать о каком-то типе данных как о множестве значений, то что же представляет из себя одноэлементное множество, или вовсе пустое множество? В Python есть несколько типов данных, которые имеют только одно значение. Например, None
является единственным значением типа NoneType
. В аннотациях типов именно это значение используется для обозначения того факта, что функция ничего не возвращает (фактически, является процедурой, а не функцией):
def do_something_interesting(value: int) -> None:
...
Другим примером может быть пустой кортеж (tuple):
from typing import Tuple
empty: Tuple[()] = ()
Значение ()
является единственным элементом множества всех значений типа Tuple[()]
. Есть еще несколько похожих примеров, но общий смысл, думаю, понятен. Подобные типы данных, которые содержат только одно значение, называются единичными типами данных. Определение собственного единичного типа данных может принести вполне практическую пользу. Рассмотрим следующий пример:
from enum import Enum
from typing import Union
class Default(Enum):
Value = 0
def process(value: Union[int, None, Default] = Default.Value) -> int:
if value is Default.Value:
return 0
elif value is None:
return 1
else:
# В этой ветке mypy "понимает", что value имеет тип int,
# и поэтому операция умножения допустима
return value * 2
Как видно из примера, None
является допустимым значением аргумента функции. Использование типа Default
и его единственного значения Default.Value
позволяет отличить ситуацию, когда функция вызвана без аргументов process()
, от ситуации, когда None
явно передается в качестве аргумента функции process(None)
, и выполнить соответствующую ветку условного оператора if
.
Отлично, но вернемся к типам данных и множествам. Мы рассмотрели единичные типы данных, которые представимы в виде одноэлементного множества. Осталось разобраться с пустым множеством. Так зачем же может быть нужен тип данных, значений которого вообще не существует? Подобный тип данных называют ненаселенным (uninhabited type), и обычно он используется, чтобы показать невычислимость какого-то выражения, например, брошено исключение, выход из программы, бесконечный цикл, и т.п. В Python также есть поддержка такого типа данных:
from typing import NoReturn
def stop() -> NoReturn:
raise RuntimeError("Не получится!")
Если же мы что-то напутаем и попробуем вернуть из такой функции значение какого-то другого типа, например:
from typing import NoReturn
def stop(value: int) -> NoReturn:
if value < 0:
return 42
raise RuntimeError("Не получится!")
то, запустив mypy, мы сразу увидим ошибку:
error: Return statement in function which does not return
return 42
Кроме того, если тип какой-то переменной объявлен NoReturn
, то мы не сможем присвоить этой переменной ни одно значение, так как NoReturn
является ненаселенным типом, как мы выяснили, и значений такого типа не существует:
from typing import NoReturn
value: NoReturn = None
other_value: NoReturn = ()
При статической проверке типов mypy сообщит об ошибке что-то вроде:
error: Incompatible types in assignment (expression has type "None",
variable has type "NoReturn")
value = None
error: Incompatible types in assignment (expression has type "Tuple[]",
variable has type "NoReturn")
other_value: NoReturn = ()
Сложно представить себе язык программирования без какой-либо поддержки типов-произведений (product type). Это, скорее всего, самый простой способ получить новый тип данных на основе существующих, объединив их вместе. Рассмотрим простейший пример типа-произведения — кортеж из двух элементов, или пара:
from enum import Enum
from typing import Tuple
class ChessPieceType(Enum):
"""Тип шахматной фигуры."""
King = "Король"
Queen = "Ферзь"
Rook = "Ладья"
Bishop = "Слон"
Knight = "Конь"
Pawn = "Пешка"
class ChessPieceColor(Enum):
"""Цвет шахматной фигуры."""
White = "Белый"
Black = "Черный"
ChessPiece = Tuple[ChessPieceType, ChessPieceColor]
# Белый король
white_king: ChessPiece = (ChessPieceType.King, ChessPieceColor.White)
# Черный ферзь
black_queen: ChessPiece = (ChessPieceType.Queen, ChessPieceColor.Black)
В приведенном выше примере ChessPiece
— это тип-произведение пара, который является композицией двух других типов: типа шахматной фигуры ChessPieceType
и цвета ChessPieceColor
. Уже можно догадаться, почему такой тип называется произведением. Тип ChessPieceType
имеет 6 возможных значений, а тип ChessPieceColor
имеет 2 возможных значения. Множество возможных значений типа ChessPiece
является декартовым произведением множеств возможных значений двух других типов, и содержит 12 элементов:
Другими словами, шахматная фигура ChessPiece
— это тип фигуры ChessPieceType
И цвет фигуры ChessPieceColor
. Мы могли бы добавить еще какие-нибудь свойства шахматной фигуры нашему типу-произведению, например, позицию на доске PositionOnBoard
:
# продолжение предыдущего примера
# Шахматная фигура — это тип фигуры
# И цвет фигуры
# И позиция фигуры на доске
ChessPiece = Tuple[ChessPieceType, ChessPieceColor, PositionOnBoard]
В таком случае количество возможных значений типа ChessPiece
увеличится до 768 (6 типов x 2 цвета x 64 возможные позиции).
Помимо кортежей, в разных языках программирования к типам-произведениям можно отнести разнообразные структуры (struct), классы образцы (case class), записи (record), и т.п. Одним из способов определить тип-произведение в Python является использование декоратора dataclass из соответствующего модуля стандартной библиотеки. Например, определим следующий тип:
from dataclasses import dataclass
from enum import Enum
class Role(Enum):
Administrator = 0
Editor = 1
Reader = 2
@dataclass(frozen=True)
class User:
name: str
role: Role
is_active: bool
Тип данных User
является композицией типов str
, Role
и bool
. Получается, что при таком определении, пользователь User
— это имя пользователя str
И роль пользователя Role
И флаг активности bool
.
При использование типов-произведений mypy помогает отследить корректность обращений к каждому полю:
# продолжение предыдущего примера
def get_user_info(user: User) -> str:
# Автору этого кода почему-то захотелось прибавить число 42
# к имени пользователя...
name = user.name + 42
return f"{name}: {user.role.name} {user.is_active}"
Конечно, mypy сразу проинформирует нас об ошибке в таком коде:
error: Unsupported operand types for + ("str" and "int")
name = user.name + 42
Наглядным примером аналогии с математическими операциями может быть использование ненаселенного типа, который мы рассматривали в предыдущем разделе статьи, при определении типа-произведения:
from dataclasses import dataclass
from typing import NoReturn
@dataclass(frozen=True)
class Ooops:
number: int
string: str
nothing: NoReturn
Сколько может быть возможных значений у подобного типа данных? Количество возможных значений типа int
x количество возможных значений типа str
x 0 (не существует значений типа NoReturn
). Получается, что у типа Ooops
вообще нет возможных значений, и такой тип также будет ненаселенным. Действительно, если мы попробуем как-то создать значение этого типа:
# продолжение предыдущего примера
Ooops(42, "hello", None)
То получим ошибку от mypy:
error: Argument 3 to "Ooops" has incompatible type "None"; expected "NoReturn"
Ooops(42, "hello", None)
В завершении этого раздела стоит отметить, что в большинстве языков программирования использование типов-произведений — это единственный способ создания новых типов данных на основе существующих.
Тип-сумма (sum type) — это такой тип данных, значения которого могут быть одним из нескольких взаимоисключающих вариантов. Поддержка типов-сумм реже встречается в различных языках программирования по сравнению с типами-произведениями. В языках, в которых такая поддержка присутствует, они могут также называться размеченными объединениями (tagged union) или просто вариантами (variant). Чаще всего тип-сумма и имеется в виду, когда говорят об алгебраическом типе данных.
Простейшим примером типа-суммы может быть тип bool
. Что представляет из себя значение типа bool
? Это True
ИЛИ False
— два взаимоисключающих варианта. Еще одним примером может быть следующий перечисляемый тип Fruit
:
from enum import Enum
class Fruit(Enum):
Apple = "яблоко"
Banana = "банан"
Peach = "персик"
Значение типа Fruit
— это Fruit.Apple
ИЛИ Fruit.Banana
ИЛИ Fruit.Peach
. На первый взгляд кажется, что это очень простая, очевидная идея. Но далее мы увидим, что типы-суммы являются очень мощным средством моделирования предметной области программы.
Тип-сумма — это не только простое перечисление значений. Каждый вариант типа-суммы может иметь свой собственный тип. В Python для выражения этой идеи может быть использован тип Union
из модуля typing
:
from enum import Enum
from typing import Union
class Fruit(Enum):
Apple = "яблоко"
Banana = "банан"
Peach = "персик"
class Vegetable(Enum):
Cabbage = "капуста"
Carrot = "морковь"
Food = Union[Fruit, Vegetable]
apple: Food = Fruit.Apple # OK
carrot: Food = Vegetable.Carrot # OK
# Mypy error: Incompatible types in assignment (expression has type "int",
# variable has type "Union[Fruit, Vegetable]")
hmmm: Food = 42
В данном примере значение типа Food
— это значение типа Fruit
ИЛИ значение типа Vegetable
. Количество возможных значений типа Food
— это сумма количества возможных значений типов Fruit
и Vegetable
, всего 5 возможных фруктов и овощей.
Мы можем объявлять объединение любых произвольных типов, например, мы могли бы определить адрес следующим образом:
from dataclasses import dataclass
from typing import Union
@dataclass(frozen=True)
class AddressDetails:
city: str
street: str
house_number: int
Address = Union[str, AddressDetails]
Здесь Address
— это строка str
ИЛИ AddressDetails
. Далее мы могли бы использовать Address
для аннотаций типов, причем mypy позволит статически проверить корректность обращений к каждому варианту типа-объединения:
# продолжение предыдущего примера
def do_something_with_address(address: Address) -> None:
if isinstance(address, str):
print("Адрес — это строка:", address)
elif isinstance(address, AddressDetails):
print(f"Адрес: {address.city}, {address.street}, {address.house_number}")
else:
assert False, "Больше вариантов нет!"
Стоит обратить внимание, что мы должны явно указать, что делать с каждым вариантом типа-объединения, иначе mypy будет сообщать об ошибках.
Примечание. Строго говоря, Union — это не совсем тип-сумма из-за того факта, что типы вариантов должны быть разными. Например, в системе типов Python Union[int, int]
— это тоже самое, что и просто int
, и мы не сможем понять значение какого из вариантов фактически используется. Это ограничение можно обойти, определив дополнительные типы-"обёртки" для каждого варианта. Но оставим это различие в стороне для простоты изложения.
Еще одним примером типа-суммы может быть тип Optional
из модуля typing
стандартной библиотеки Python, который выражает простую идею, что значение может отсутствовать. Тип Optional[T]
эквивалентен типу Union[T, None]
, значением этого типа является значение произвольного типа T
ИЛИ значение None
. Если мы работаем со значениями типа Optional
, то мы должны явно обрабатывать случай, когда значение отсутствует:
from dataclasses import dataclass
from typing import Optional
@dataclass(frozen=True)
class Article:
id: int
title: str
def fetch_article(article_id: int) -> Optional[Article]:
"""Получаем статью по её ID каким-то образом."""
def show_article() -> None:
article = fetch_article(42)
print("Заголовок статьи:", article.title)
При проверке типов mypy найдёт следующую ошибку:
error: Item "None" of "Optional[Article]" has no attribute "title"
print("Заголовок статьи:", article.title)
Действительно, если мы определили, что значение может отсутствовать, то мы должны явно обработать такой случай:
# продолжение предыдущего примера
def show_article() -> None:
article = fetch_article(42)
if article is None:
print("Статьи не существует.")
else:
# Здесь mypy "понимает", что article точно не None,
# т.к. выше в коде этот случай обработан
print("Заголовок статьи:", article.title)
Использование типа Optional
позволяет mypy предупредить нас, если мы вдруг забудем обработать случай None
, что очень часто позволяет избежать досадных ошибок в программе (см. Ошибка на миллиард долларов).
Использование типов-сумм и типов-произведений при проектирование собственных типов данных выглядит как конструктор (ещё раз вернемся к картинке на обложке статьи). Мы объединяем и комбинируем одни типы данных, и получаем другие, более сложные типы данных. Рассмотрим ещё один, более приближенный к реальности, пример. Допустим, мы разрабатываем систему аутентификации пользователей на сайте. Мы можем определить следующие типы данных:
from dataclasses import dataclass
from typing import Union
@dataclass(frozen=True)
class SignInWithEmail:
email: str
password: str
@dataclass(frozen=True)
class SignInWithSms:
phone_number: str
code: str
@dataclass(frozen=True)
class SignInWithSecretToken:
token: str
SignInMethod = Union[
SignInWithEmail,
SignInWithSms,
SignInWithSecretToken,
]
Пользователи могут попасть на наш сайт, зная адрес электронной почты и пароль ИЛИ получив код в SMS на номер телефона ИЛИ используя секретный токен. Далее мы можем использовать тип данных SignInMethod
следующим образом:
# продолжение предыдущего примера
from typing import NoReturn
def assert_never(value: NoReturn) -> NoReturn:
raise AssertionError(f"Необработанный вариант: {value}")
def sign_in(method: SignInMethod) -> None:
if isinstance(method, SignInWithEmail):
print("Вход на сайт по email и паролю:", method.email, method.password)
elif isinstance(method, SignInWithSms):
print("Вход на сайт через SMS-код:", method.phone_number, method.code)
elif isinstance(method, SignInWithSecretToken):
print("Вход на сайт с секретным токеном:", method.token)
else:
assert_never(method)
В каждой ветке условного оператора if
mypy проверит корректность обращения к атрибутам. Например, мы не сможем обратиться к номеру телефона phone_number
из ветки, в которой используется метод входа по электронной почте и паролю. Функция assert_never
— это известный трюк, который позволяет mypy проверить, что разобраны все варианты типа-объединения (exhaustiveness checking). Если мы расширим определение типа SignInMethod
, добавив вход по секретному числу (идея так себе), но забудем обработать этот вариант:
# продолжение предыдущего примера
SignInMethod = Union[
SignInWithEmail,
SignInWithSms,
SignInWithSecretToken,
int,
]
def sign_in(method: SignInMethod) -> None:
# Этот метод остался без изменений
...
То mypy сообщит нам, что один из вариантов не учтён:
error: Argument 1 to "assert_never" has incompatible type "int"; expected "NoReturn"
assert_never(method)
Такой подход напоминает механизм сопоставления с образцом (pattern matching), который присутствует в некоторых языках программирования. А использование mypy для статической проверки типов позволяет проверить, что все варианты типа-объединения явно обработаны. Более того, начиная с версии 3.10 и в Python появилась поддержка сопоставления с образцом (подробнее можно почитать в PEP-622). Код из предыдущего примера в Python 3.10 будет выглядеть следующим образом:
# Python 3.10
# продолжение предыдущего примера
def sign_in(method: SignInMethod) -> None:
match method:
case SignInWithEmail(email=email, password=passw):
print("Вход на сайт по email и паролю:", email, passw)
case SignInWithSms(phone_number=pn, code=code):
print("Вход на сайт через SMS-код:", pn, code)
case SignInWithSecretToken(token=token):
print("Вход на сайт с секретным токеном:", token)
# Выведет: Вход на сайт по email и паролю: email@example.org password123
sign_in(SignInWithEmail("email@example.org", "password123"))
# Выведет: Вход на сайт через SMS-код: 79001234567 abcd
sign_in(SignInWithSms("79001234567", "abcd"))
# Выведет: Вход на сайт с секретным токеном: secret-token
sign_in(SignInWithSecretToken("secret-token"))
Примечание. На момент написания статьи mypy 0.910 не поддерживает новый механизм сопоставления с образцом, но работа над этим уже идёт.
Несмотря на простоту самой идеи, алгебраические типы данных являются очень мощным и выразительным средством моделирования предметной области программы. Итак, какие же преимущества есть у проектирования типов данных подобным образом? Можно выделить несколько важных моментов:
Использование алгебраических типов данных позволяет моделировать сущности предметной области более наглядно и просто. Имя пользователя — это (имя И фамилия) ИЛИ (фамилия И инициалы). Продукт в книжном магазине — это книга ИЛИ газета ИЛИ сувенир. И тому подобное.
Тип-сумма часто является более простой альтернативой иерархии классов в ООП. Не нужно выделять какую-то базовую абстракцию, которая определяет общие характеристики для всех вариантов. Не нужны интерфейсы и их реализация (см. паттерн посетитель). Данные просто используются, а mypy помогает статически проверить, что использование данных корректно с точки зрения объявленных типов.
Используя алгебраические типы данных, мы можем делать невалидные состояния непредставимыми. Например, тип-данных SignInMethod
из предыдущего раздела мы могли бы определить следующим образом:
from dataclasses import dataclass
from typing import Optional
@dataclass(frozen=True)
class SignInMethod:
email: Optional[str] = None
password: Optional[str] = None
phone_number: Optional[str] = None
code: Optional[str] = None
token: Optional[str] = None
sign_in_with_email = SignInMethod(
email="email@example.org",
password="password123",
)
sign_in_with_token = SignInMethod(token="secret-token")
...
Чем это хуже использования типа-суммы? Такой подход плох тем, что можно создать концептуально невалидное значение SignInMethod()
, все атрибуты которого будут None
. Наш тип данных позволяет создать такое значение. Используя же тип Union[SignInWithEmail, SignInWithSms, SignInWithSecretToken]
, мы более строго определяем возможные значения и делаем невалидные состояния непредставимыми в наших типах данных.