[Перевод] Пишем на Python, как будто это Rust

07c72f74e768421515a272d58b2ec79e.jpg

Я начал программировать 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.

© Habrahabr.ru