[Перевод] Пишем на Python, как будто это Rust
Я начал программировать Rust несколько лет назад, и эта работа постепенно позволила мне изменить подход к проектированию программ и на других языках. В особенности заметен этот эффект был на Python. Прежде, чем я приступил к использованию Rust, я обычно писал код Python в очень динамичном стиле со свободной типизацией, без подсказок типов. Я повсюду передавал и возвращал словари, от случая к случаю прибегая к интерфейсам со «строковой типизацией ». Правда, ощутив на себе всю строгость системы типов Rust и познакомившись со всеми теми проблемами, которые Rust решает «по природе», я вдруг сильно разволновался, когда пришлось вернуться к Python — и оказалось, что там и близко нет таких гарантий, как в Rust.
Честно говоря, под «гарантиями» я здесь подразумеваю не безопасность работы с памятью (в Python безопасность памяти обеспечивается вполне неплохо), а скорее «разумность» — такой подход к проектированию API, при котором ими становится очень сложно или просто невозможно злоупотреблять. Так предотвращаются неопределённые поведения и всевозможные баги. Если в Rust некорректно использован интерфейс, это почти всегда приводит к ошибке компиляции. В Python выполнить такую некорректную программу, тем не менее, удаётся. Но, если вы пользуетесь инструментом проверки типов (например, pyright
) или IDE с анализатором типов (например, PyCharm), то также можете с сопоставимой скоростью получать обратную связь о возможных проблемах.
В конце концов, я стал брать на вооружение в моих программах на Python некоторые концепции из Rust. В сущности, такая практика сводится к двум вещам: в максимально возможном объёме пользоваться подсказками типов, а также придерживаться старого доброго принципа: исключить возможность представления недопустимых состояний. Я стараюсь делать как первое, так и второе не только в тех программах, которые планируется хотя бы некоторое время поддерживать, но и в одноразовых скриптах. В основном потому, что, как подсказывает опыт, вторые часто превращаются в первые. Насколько могу судить, создаваемые при таком подходе программы получаются более понятными и легче поддаются изменениям.
В этом посте на нескольких примерах будет показано, как такие паттерны применяются в программах на Python. Не нужно быть семи пядей во лбу, чтобы их освоить, но мне всё равно кажется, что было бы полезно эти возможности документировать.
Замечание: в этом посте содержится много оценочных суждений о том, как писать код на Python. Не хочется к каждому предложению добавлять «ИМХО», так что считайте всё изложенное в этом посте просто моими размышлениями на заданную тему, а не попытками преподать некие универсальные истины:). Кроме того, я не утверждаю, что все представленные здесь идеи были изобретены в Rust — естественно, они используются и в других языках.
Подсказки типов
Перво-наперво рекомендую использовать подсказки типов везде, где это возможно, в особенности с сигнатурами функций и атрибутами классов. Когда мне попадается сигнатура функции, выглядящая примерно так:
def find_item(records, check):
мне совершенно не понятно из самой сигнатуры, что делает эта функция. Что она записывает — список, словарь или соединение с базой данных? А check
— это булево значение или функция? Что возвращает эта функция? Что произойдёт, если выполнить функцию не удастся — выбросит ли она исключение или вернёт None
? Чтобы ответить на эти вопросы, мне придётся либо прочитать всё тело функции (а далее в обратном порядке тела всех тех функций, что она вызывает — это очень раздражает, знаете ли), или прочитать документацию по ней (если таковая имеется). Конечно, документация может содержать ценную информацию о том, что делает функция, документация не должна быть необходима как для использования функции, так и для получения ответов на вышеприведённые вопросы. Многие ответы на эти вопросы можно получить при помощи встроенного механизма — подсказок типов.
def find_item(
records: List[Item],
check: Callable[[Item], bool]
) -> Optional[Item]:
Ушло ли у меня больше времени, чтобы написать эту сигнатуру? Да. Проблема ли это? Нет, если только, программируя в таком стиле, не возникает узких мест вида «сколько символов я способен набрать в минуту», а такого на самом деле не происходит. Явно расписывая все типы, я вынужден продумывать, каков будет конкретный интерфейс, предоставляемый данной функцией, и как сделать такой интерфейс настолько строгим, насколько это возможно, чтобы вызывающей стороне было максимально сложно ошибиться при использовании данной функции. Имея дело с вышеприведённой сигнатурой, я могу составить весьма полное впечатление о том, как использовать эту функцию, что передавать ей в качестве аргументов, а также какие результаты она ожидаемо должна возвращать. Более того (чего не скажешь о комментариях к документации, которые легко могут устареть, когда код изменится), если я изменю типы, но попутно не обновлю вызыватели этой функции, то механизм проверки типов станет на меня ругаться1. А если мне станет интересно, что представляет собой Item
, я просто могу перейти к его определению (Go to definition
) и сразу же посмотреть, как выглядит этот тип.
Сноска 1
Честно говоря, то же может быть справедливо и для описаний параметров типов в комментариях к документации, если вы пользуетесь некоторым структурированным форматом (например, reStructuredText). Возможно, в таком случае механизм проверки типов сможет этим воспользоваться и предупредить вас, что ваши типы не подходят. Но если вы так или иначе пользуетесь механизмом проверки типов, то кажется, что целесообразнее опираться на «нативный» механизм, предназначенный для этой цели –, а именно, на подсказки типов.
Поймите, я в этом отношении не ситх абсолютист, и, если для описания единственного параметра требуется снабдить его пятью вложенными подсказками типов, то я просто махну рукой и сообщу более простой, хотя и неточный тип. По моему опыту, такая ситуация случается нечасто. А если такое и происходит, то обычно это сигнал, что с кодом есть проблемы. Если параметр вашей функции может быть числом, кортежем строк или словарём, в котором строки отображаются на целые числа, то функции не повредит рефакторинг — попробуйте её упростить.
Вместо кортежей или словарей используем классы данных
Одно дело — пользоваться подсказками типов, но такой подход всего лишь позволяет описать, каковы интерфейсы ваших функций. Нужен и второй шаг — сделать эти интерфейсы настолько ясными и «зафиксированными», насколько это возможно. Типичный пример — возврат от функции множества значений (или единственного комплексного значения). В данном случае можно было бы поступить дёшево и сердито — вернуть кортеж:
def find_person(...) -> Tuple[str, str, int]:
Отлично, нам известно, что мы возвращаем три значения. Что это за значения. Является ли первая строка чьим-то именем? Вторая — фамилия? А что означает число? Это возраст? Позиция в некотором списке? Номер СНИЛС? Подобная типизация непрозрачна и, если не заглянете в тело функции — то не узнаете, что в ней происходит.
Продолжаем «улучшать» функцию. Теперь попробуем сделать так, чтобы она возвращала словарь:
def find_person(...) -> Dict[str, Any]:
...
return {
"name": ...,
"city": ...,
"age": ...
}
Итак, теперь мы представляем, что представляет из себя каждый из возвращённых атрибутов в отдельности — но, опять же, чтобы это выяснить, нам пришлось изучить тело функции. В каком-то смысле тип даже стал хуже, поскольку теперь нам не известно, сколько типов, а также каковы типы отдельных атрибутов. Более того, когда эта функция изменится, а ключи в возвращённом словаре окажутся удалены или переименованы, то выявить эти изменения при помощи инструмента проверки типов в любом случае будет непросто. Следовательно, как правило, в таких случаях приходится менять вызывателей, а эта работа делается только вручную и очень раздражает, поскольку сводится к циклу пуск-отказ-изменили код.
Верное решение в такой ситуации — вернуть строго типизированный объект с именованными параметрами, у которого есть прикреплённый тип. В Python это означает, что нам придётся создать класс. Подозреваю, что кортежи и словари так часто используются в подобных ситуациях, поскольку это гораздо проще, чем определять класс (и придумывать для него название), создавать параметризованный конструктор, сохранять параметры в полях и т.д. В Python 3.7 и выше (а впоследствии — и с пакетом polyfill) существует гораздо более быстрое решение. Это классы данных —dataclasses
.
@dataclasses.dataclass
class City:
name: str
zip_code: int
@dataclasses.dataclass
class Person:
name: str
city: City
age: int
def find_person(...) -> Person:
Вам всё равно придётся придумать имя для созданного класса, но в остальном этот код настолько лаконичен, насколько вообще может быть, а вам при этом удаётся снабдить аннотациями типов все атрибуты.
При работе с таким классом данных я получаю код, явно описывающий, что именно возвращает эта функция. При вызове этой функции и работе с возвращённым значением мне на помощь приходит автозавершение, встроенное в IDE — благодаря нему, я могу посмотреть, как называются атрибуты функции, и каковы их типы. Возможно, это звучит тривиально, но, по-моему, такой расклад очень способствует производительности. Более того, при рефакторинге кода и при изменении атрибутов моя IDE и система проверки типов обязательно станут ругаться и покажут мне все те места, где нужно внести изменения. Мне при этом даже программу выполнять не придётся. В некоторых простых случаях рефакторинга (например, при переименовании атрибута) IDE даже может внести эти изменения за меня. Кроме того, при работе с явно именованными типами я могу собрать словарь терминов (Person
, City
), а затем предоставить его в совместное использование другим функциям и классам.
Алгебраические типы данных
Одна штука из Rust, которой мне, пожалуй, наиболее не хватает в большинстве мейнстримовых языков программирования — это алгебраические типы данных (ADT).
Сноска 2
Они же — размеченные объединения, тип-суммы, запечатанные классы, т.д.
Это невероятно мощный инструмент, предназначенный для явного описания контуров тех данных, с которыми работает мой код. Например, когда я работаю с пакетами в Rust, я могу явно перечислять все разнообразные виды тех пакетов, что могут быть получены, а также присваивать разные поля данных каждому из них:
enum Packet {
Header {
protocol: Protocol,
size: usize
},
Payload {
data: Vec
},
Trailer {
data: Vec,
checksum: usize
}
}
А сопоставление с шаблоном позволяет мне реагировать на конкретные варианты; при этом, у меня ещё есть компилятор, занимающийся проверкой — тем самым он помогает мне вообще ничего не упустить:
fn handle_packet(packet: Packet) {
match packet {
Packet::Header { protocol, size } => ...,
Packet::Payload { data } |
Packet::Trailer { data, ...} => println!("{data:?}")
}
}
Это бесценно при необходимости убедиться, что недействительные состояния в то же время не могут быть представлены. Так избегаются многочисленные ошибки времени выполнения. Алгебраические типы данных особенно полезны в языках со статической типизацией. Если в таких языках вы хотите единообразно работать со всеми типами из некоторого набора, то вам потребуется разделяемое ими «имя», по которому вы и будете на них ссылаться. Без алгебраических типов данных это обычно достигается при помощи ООП-интерфейсов и/или наследования. Интерфейсы и виртуальные методы уместны при работе с незакрытым множеством пользовательских типов. Но, когда такое множество типов закрыто, а вы хотите удостовериться, что у вас обрабатываются все возможные варианты, вам гораздо лучше подойдут алгебраические типы данных и сопоставление с шаблоном.
В динамически типизированном языке, таком, как Python, нет особой необходимости предусматривать разделяемое имя для набора типов — ведь даже не обязательно именовать те типы, что используются в программе. Однако, вам всё равно могут пригодиться сущности, подобные алгебраическим типам данных. Например, можно создать тип-объединение:
@dataclass
class Header:
protocol: Protocol
size: int
@dataclass
class Payload:
data: str
@dataclass
class Trailer:
data: str
checksum: int
Packet = typing.Union[Header, Payload, Trailer]
# или `Packet = Header | Payload | Trailer` в Python 3.10 и выше
Здесь Packet
определяет новый тип, который может представлять собой либо заголовок, либо полезную нагрузку, либо хвостовой пакет. Теперь я могу использовать этот тип (имя) во всей оставшейся части программы — если захочу убедиться, что только эти три класса являются действительными. Обратите внимание: здесь нет никакой явной «метки», которая бы прикреплялась к классам. Поэтому, если мы хотим различать эти классы, то нам пришлось бы использовать, к примеру, instanceof
или сопоставление с шаблоном:
def handle_is_instance(packet: Packet):
if isinstance(packet, Header):
print("header {packet.protocol} {packet.size}")
elif isinstance(packet, Payload):
print("payload {packet.data}")
elif isinstance(packet, Trailer):
print("trailer {packet.checksum} {packet.data}")
else:
assert False
def handle_pattern_matching(packet: Packet):
match packet:
case Header(protocol, size): print(f"header {protocol} {size}")
case Payload(data): print("payload {data}")
case Trailer(data, checksum): print(f"trailer {checksum} {data}")
case _: assert False
Грустно, но здесь нам требуется (или, скорее, следует) включать наболевшие ветки assert False
, чтобы функция завершалась аварийно, когда получает неожиданные данные. В Rust мы в таком случае получали бы ошибку времени компиляции.
Примечание: на Reddit нашлись люди, указывавшие мне, что assert False
на самом деле полностью сокращается в оптимизированной сборке (python -O ...
). Следовательно, было бы более надёжно напрямую выбрасывать исключение. Также существует typing.assert_never
, употребляемое в Python 3.11 и выше, явно сообщающее системе проверки типов, что при сваливании в эту ветку должна происходить «ошибка времени компиляции».
Приятное свойство типа-объединения заключается в том, что он определяется за пределами того класса, что входит в объединение. Следовательно, классу не известно, что он включается в объединение, и таким образом снижается степень связности кода. Вы даже можете создавать множество разных объединений, использующих один и тот же тип:
Packet = Header | Payload | Trailer
PacketWithData = Payload | Trailer
Типы объединений весьма полезны для автоматической (де)сериализации. Недавно я нашёл потрясающую библиотеку для сериализации. Она называется pyserde, и основана на serde — прославленном Rust-фреймворке для сериализации. Среди прочего она позволяет опираться на аннотации типов при сериализации и десериализации типов объединений, не требуя для этого никакого дополнительного кода:
import serde
...
Packet = Header | Payload | Trailer
@dataclass
class Data:
packet: Packet
serialized = serde.to_dict(Data(packet=Trailer(data="foo", checksum=42)))
# {'packet': {'Trailer': {'data': 'foo', 'checksum': 42}}}
deserialized = serde.from_dict(Data, serialized)
# Data(packet=Trailer(data='foo', checksum=42))
Можно даже выбрать, как именно будет сериализоваться метка объединения — точно как в случае с serde
. Я долго искал подобный функционал, поскольку возможность (де)сериализации типов-объединений очень полезна. Правда, было весьма обременительно реализовывать такую возможность при помощи других библиотек сериализации, которые я пробовал (например, dataclasses_json
или dacite
).
Приведу пример. При работе с моделями машинного обучения я пользуюсь объединениями для хранения различных типов нейронок (например, модели свёрточных нейронных сетей для классификации или сегментирования) в конфигурационных файлах, имеющих один общий формат. Кроме того, я убедился, что полезно версионировать различные форматы файлов (в моём случае — конфигурационные файлы), вот так:
Config = ConfigV1 | ConfigV2 | ConfigV3
Десериализовав Config
, я могу прочитать все более ранние версии конфигурационного формата, и таким образом поддерживать обратную совместимость.
Использование новых типов
В Rust достаточно распространена практика определять такие типы данных, которые не добавляют никакого нового поведения. Они служат просто для указания предметной области и задуманного варианта использования какого-либо другого типа данных. Этот тип данных может быть самым общим — таковы, например, целые числа. Такой паттерн называется «новый тип» (newtype) и также может использоваться в Python. Вот пример для затравки:
Сноска 3
Не надо ругаться — конечно, у новых типов есть и другие варианты использования, а не только тот, что описан здесь.
class Database:
def get_car_id(self, brand: str) -> int:
def get_driver_id(self, name: str) -> int:
def get_ride_info(self, car_id: int, driver_id: int) -> RideInfo:
db = Database()
car_id = db.get_car_id("Mazda")
driver_id = db.get_driver_id("Stig")
info = db.get_ride_info(driver_id, car_id)
Заметили ошибку?
…
…
Аргументы для get_ride_info
поменялись местами. Это не ошибка с типом, поскольку как номер машины, так и номер водительского удостоверения — это просто целые числа. Соответственно, оба типа корректны, хотя с семантической точки зрения вызов функции и построен неверно.
Вот как можно решить эту проблему: определим отдельные типы для различных видов идентификаторов, и для этого воспользуемся «NewType»:
from typing import NewType
# Определим новый тип "CarId", который на внутрисистемном уровне является `int`
CarId = NewType("CarId", int)
# То же для "DriverId"
DriverId = NewType("DriverId", int)
class Database:
def get_car_id(self, brand: str) -> CarId:
def get_driver_id(self, name: str) -> DriverId:
def get_ride_info(self, car_id: CarId, driver_id: DriverId) -> RideInfo:
db = Database()
car_id = db.get_car_id("Mazda")
driver_id = db.get_driver_id("Stig")
# Здесь ошибка типа -> DriverId используется вместо CarId и наоборот
info = db.get_ride_info(driver_id , car_id )
Это очень простой паттерн, который может помочь отлавливать ошибки, которые иным способом сложно заметить. Это особенно полезно, например, в тех случаях, когда приходится иметь дело со множеством разнообразных ID (CarId
и DriverId
) или какими-нибудь метриками (Speed
и Length
и Temperature,
т.д.), которые требуется смешивать при работе.
Использование конструирующих функций
Из тех вещей, что мне особенно нравятся в Rust, отмечу следующее: в этом языке нет конструкторов как таковых. Вместо них программисты стремятся использовать обычные функции для создания экземпляров структур (в идеале — надлежащим образом инициализированных). В Python нет перегрузки конструкторов; следовательно, если вам требуется сконструировать объект несколькими разными способами, то иногда у вас получается метод __init__
со множеством параметров. Эти параметры (каждый по-своему) служат инициализации и, в сущности, вместе использоваться не могут.
Мне нравится поступать иначе: создавать явно поименованные «конструирующие» функции, при работе с которыми сразу ясно, как собрать объект и из каких данных:
class Rectangle:
@staticmethod
def from_x1x2y1y2(x1: float, ...) -> "Rectangle":
@staticmethod
def from_tl_and_size(top: float, left: float, width: float, height: float) -> "Rectangle":
В таком случае конструирование объекта становится гораздо чище, а пользователи класса лишаются возможности передать недействительные данные при конструировании объекта (например, если скомбинировать y1
и width
).
Программирование инвариантов при помощи типов
Существует универсальный и очень мощный подход — программировать инварианты на уровне самой системы типов. В противном случае инварианты можно было бы только отслеживать во время выполнения. В Python (а также в других мейнстримовых языках) часто доводится встречать классы, представляющие собой большие лохматые клубки изменяемого состояния. Один из источников этой неразберихи — код, призванный отслеживать инварианты объекта во время выполнения. В таком коде приходится рассматривать множество ситуаций, которые теоретически могут сложиться, поскольку в принципе система типов их не исключает («что, если клиенту поступит запрос на отключение, а в это время кто-то попробует послать ему сообщение, но сокет на этот момент по-прежнему будет считаться подключённым», т.д.).
Клиент
Вот типичный пример:
class Client:
"""
Rules:
- Do not call `send_message` before calling `connect` and then `authenticate`.
- Do not call `connect` or `authenticate` multiple times.
- Do not call `close` without calling `connect`.
- Do not call any method after calling `close`.
"""
def __init__(self, address: str):
def connect(self):
def authenticate(self, password: str):
def send_message(self, msg: str):
def close(self):
…легко, правда? Всего-то и требуется, что внимательно прочитать документацию и обеспечить, что описанные в ней правила никогда не будут нарушаться (чтобы не вызвать неопределённое поведение или отказ программы). Есть альтернатива — можно наполнить класс различными утверждениями и проверять все вышеупомянутые правила во время выполнения. В результате получится путаный код, будут упущены какие-нибудь пограничные случаи, а отклик (если что-то станет происходить не так) получится замедленным. Сравните — работа во время компиляции или во время выполнения. Ключ проблемы в том, что клиент может одновременно существовать в различных (взаимоисключающих) состояниях. Но вы не смоделировали эти состояния по отдельности, и они оказались слиты в единый тип.
Рассмотрим, как можно поправить эту ситуацию, распределив различные состояния по разным типам.
Сноска 4
Этот паттерн ещё называется typestate
Для начала — вопрос: нужен ли нам вообще клиент
Client
, который ни к чему не подключён? Как будто бы нет. В любом случае, такой неподключённый клиент так или иначе ничего не сможет сделать, пока вы не вызоветеconnect
. Так зачем же вообще допускать существование такого состояния? Можно создать конструирующую функциюconnect
, которая станет возвращать подключённый клиент:
def connect(address: str) -> Optional[ConnectedClient]:
pass
class ConnectedClient:
def authenticate(...):
def send_message(...):
def close(...):
Если функция сработает успешно, то вернёт клиент, воплощающий инвариант «подключено». В этой функции вы больше не сможете вызвать connect
и, следовательно, не сможете плодить путаницу. Если же эта функция завершится неуспешно, то она может выбросить исключение, вернуть None
или какую-нибудь явную ошибку.
Схожий подход применим с состоянием
authenticated
. Можно ввести и другой тип — он содержит инвариант, означающий, что клиент одновременно подключён и аутентифицирован:
class ConnectedClient:
def authenticate(...) -> Optional["AuthenticatedClient"]:
class AuthenticatedClient:
def send_message(...):
def close(...):
Только когда у нас фактически появится экземпляр AuthenticatedClient
, мы сможем приступить к рассылке сообщений.
Последняя проблема — с методом
close
. В Rust (благодаря семантике разрушающего перемещения) удаётся выразить следующий факт: при вызове методаclose
клиент больше использовать нельзя. В Python это, в сущности, невозможно, поэтому приходится искать обходной путь. Возможное решение — вновь прибегнуть к отслеживанию во время выполнения, ввести на клиенте булев атрибут, а вclose
иsend_message
прописать, что он ещё не закрыт. Другой подход — целиком избавиться от методаclose
и просто использовать клиент как менеджер контекста:
with connect(...) as client:
client.send_message("foo")
# Здесь клиент закрывается
Когда метода close
в наличии нет, невозможно дважды нечаянно закрыть клиент.
Сноска 5
1. Если, конечно, не постараться как следует и не вызвать магический метод __exit__
вручную.
Строго типизированные ограничивающие рамки
Детектирование объектов — это задача из области компьютерного зрения, которой мне иногда приходится заниматься, в случаях, когда программа должна выделить на картинке множество ограничивающих рамок. В принципе, ограничивающие рамки — это просто пафосное название прямоугольников, к которым прикреплены какие-то данные. При реализации детектирования объектов такие рамки повсюду. Одна раздражающая деталь при работе с ними такова: иногда они бывают нормализованы (координаты и размеры прямоугольника находятся в интервале [0.0, 1.0]
), а иногда денормализованы (координаты и размеры зависят от параметров того изображения, к которому они прикреплены). При прогоне ограничивающей рамки через множество функций, обрабатывающих, например, препроцессинг или постпроцессинг данных, здесь легко запутаться — например, дважды нормализовать ограничивающую рамку. В результате возникают ошибки, отлаживать которые достаточно хлопотно.
Со мной такое пару раз случалось, поэтому однажды я решил раз и навсегда разобраться с этой проблемой, разделив две эти разновидности ограничивающих рамок на отдельные типы:
@dataclass
class NormalizedBBox:
left: float
top: float
width: float
height: float
@dataclass
class DenormalizedBBox:
left: float
top: float
width: float
height: float
В случае такого разделения нормализованные и денормализованные ограничивающие рамки уже так просто не смешаешь, поэтому проблема в основном решена. Но в код можно внести ещё некоторые улучшения, чтобы сделать его эргономичнее:
@dataclass
class BBoxBase:
left: float
top: float
width: float
height: float
# Композиция
class NormalizedBBox:
bbox: BBoxBase
class DenormalizedBBox:
bbox: BBoxBase
Bbox = Union[NormalizedBBox, DenormalizedBBox]
# Наследование
class NormalizedBBox(BBoxBase):
class DenormalizedBBox(BBoxBase):
Добавить проверку во время выполнения, помогающую убедиться, что нормализованная ограничивающая рамка действительно нормализована:
class NormalizedBBox(BboxBase):
def __post_init__(self):
assert 0.0 <= self.left <= 1.0
...
Добавить способ преобразовывать два этих представления друг в друга. В некоторых случаях нам, возможно, понадобится явное представление, но в других нас устроит и обобщённый интерфейс («BBox любого типа»). В таком случае мы должны иметь возможность преобразовать «BBox любого типа» в одно из двух представлений:
class BBoxBase:
def as_normalized(self, size: Size) -> "NormalizeBBox":
def as_denormalized(self, size: Size) -> "DenormalizedBBox":
class NormalizedBBox(BBoxBase):
def as_normalized(self, size: Size) -> "NormalizedBBox":
return self
def as_denormalized(self, size: Size) -> "DenormalizedBBox":
return self.denormalize(size)
class DenormalizedBBox(BBoxBase):
def as_normalized(self, size: Size) -> "NormalizedBBox":
return self.normalize(size)
def as_denormalized(self, size: Size) -> "DenormalizedBBox":
return self
Работая с этим интерфейсом, мы дважды выигрываем: приобретаем раздельные типы для обеспечения корректности и унифицированный интерфейс для эргономичности.
Обратите внимание: если захотите добавить в родительский/базовый класс какие-либо разделяемые методы, возвращающие экземпляр соответствующего класса, то можете воспользоваться typing.Self
из Python 3.11:
class BBoxBase:
def move(self, x: float, y: float) -> typing.Self: ...
class NormalizedBBox(BBoxBase):
...
bbox = NormalizedBBox(...)
# Тип `bbox2` - это `NormalizedBBox`, а не просто `BBoxBase`
bbox2 = bbox.move(1, 2)
Более безопасные мьютексы
Мьютексы и блокировки в Rust обычно предоставляются за очень симпатичным интерфейсом, у которого есть два достоинства:
Блокируя мьютекст, вы приобретаете в ответ объект guard, который автоматически разблокирует мьютекс в момент разрушения. При этом задействуется прославленный механизм RAII:
{
let guard = mutex.lock(); // здесь происходит блокировка
...
} // здесь происходит автоматическая разблокировка
Таким образом, не получится, что вы случайно забыли разблокировать мьютекс. Очень похожий механизм также часто используется в C++, хотя, явный интерфейс lock
/unlock
без объекта guard также доступен для std::mutex
. Поэтому вероятность некорректного использования всё равно сохраняется.
Данные, защищённые мьютексом, хранятся непосредственно в мьютексе (структуре). Когда код спроектирован так, невозможно получить доступ к защищённым данным, не заблокировав при этом сам мьютекс. Чтобы получить guard, нужно сначала заблокировать мьютекс, а затем вы будете обращаться к данным, используя сам guard:
let lock = Mutex::new(41); // Создать мьютекс, внутри которого хранятся данные
let guard = lock.lock().unwrap(); // Приобрести guard
*guard += 1; // Изменить данные при помощи guard
Эта ситуация резко контрастирует с обычными мьютексными API, присутствующими в мейнстримовых языках (в том числе, в Python), где мьютекс существует отдельно от предохраняемых им данных. Следовательно, легко забыть, что перед обращением к данным нужно заблокировать сам мьютекс:
mutex = Lock()
def thread_fn(data):
# Приобрести мьютекс. Ссылки на защищённую переменную нет.
mutex.acquire()
data.append(1)
mutex.release()
data = []
t = Thread(target=thread_fn, args=(data,))
t.start()
# Здесь можно обратиться к данным, не блокируя мьютекс.
data.append(2) # Упс
Притом, что в Python мы не можем рассчитывать на те же преимущества, которые получаем в Rust, не всё потеряно. С блокировками Python реализуется интерфейс менеджера контекстов, и это означает, что они могут использоваться с блоком with
. Так можно гарантировать, что они будут автоматически разблокироваться в конце области видимости. А немного потрудившись, можно добиться ещё большего:
import contextlib
from threading import Lock
from typing import ContextManager, Generic, TypeVar
T = TypeVar("T")
# Обобщаем мьютекс для хранимого в нём значения.
# Таким образом можно получить правильную типизацию от метода `lock`.
class Mutex(Generic[T]):
# Храним защищённое значение внутри мьютекса
def __init__(self, value: T):
# Предусматриваем в имени два нижних подчёркивания, чтобы было немного сложнее случайно
# обратиться к значению извне.
self.__value = value
self.__lock = Lock()
# Предоставляем метод `lock` менеджера контекстов, который блокирует мьютекс,
# предоставляет защищённое значение, а затем разблокирует мьютекс, когда
# менеджер контекстов заканчивается.
@contextlib.contextmanager
def lock(self) -> ContextManager[T]:
self.__lock.acquire()
try:
yield self.__value
finally:
self.__lock.release()
# Создаём мьютекс, обёртывающий данные
mutex = Mutex([])
# Блокируем мьютекс на всю область видимости блока `with`
with mutex.lock() as value:
# Здесь значение типизируется как `list`
value.append(1)
Если код спроектирован так, то доступ к защищённым данным можно получить только после фактической разблокировки мьютекса. Да, это по-прежнему Python, поэтому инварианты всё равно можно нарушать, например, храня вне мьютекса другой указатель на защищённые данные. Но, если вы не действуете злонамеренно, то описанный подход позволяет значительно обезопасить работу с интерфейсом мьютекса в Python.
Как бы то ни было, уверен, что найдутся и другие «паттерны разумности», которые я использую в своём коде на Python, но на данный момент больше ничего не вспоминается. Если у вас найдутся другие примеры, схожие идеи или комментарии — можете изложить их на Reddit.