[Перевод] Пишем на Python как на Rust

fc4d4b92bbbd3dbe9ee614451a3bc595

Я начал программировать на Rust несколько лет назад, и это постепенно изменило мой подход к разработке программ на других языках программирования, особенно на Python. До того, как я начал использовать Rust, я обычно писал код на Python очень динамично, без подсказок типов, повсюду передавая и возвращая словари и время от времени возвращаясь к интерфейсам со «строковой типизацией». Однако, испытав на себе строгость системы типов Rust и заметив все проблемы, которые она предотвращает, я внезапно стал сильно беспокоиться всякий раз, когда возвращался к Python и не получал тех же гарантий.

Чтобы было ясно, под «гарантиями» я здесь подразумеваю не безопасность памяти (Python достаточно безопасен для памяти как есть), а скорее «надежность» — концепцию разработки API-интерфейсов, которые очень сложно или совершенно невозможно использовать не по назначению и, таким образом, предотвратить неопределенное поведение и различные ошибки. В Rust неправильно используемый интерфейс обычно вызывает ошибку компиляции. В Python вы все еще можете выполнить такую неверную программу, но если вы используете проверку типов (например, pyright) или IDE с анализатором типов (например, PyCharm), вы все равно можете получить аналогичный уровень быстрой обратной связи о возможной проблеме.

В конце концов, я начал перенимать некоторые концепции из Rust в своих программах на Python. По сути, это сводится к двум вещам: как можно больше использовать подсказки типов и придерживаться старого доброго принципа делать недопустимые состояния непредставимыми. Я пытаюсь делать это как для программ, которые будут поддерживаться какое-то время, так и для одноразовых скриптов. В основном потому, что, по моему опыту, вторые довольно часто превращаются в первые:) По моему опыту, такой подход приводит к тому, что программы легче понимать и изменять.

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

Примечание: этот пост содержит множество мнений о написании кода на Python. Я не хочу добавлять «ИМХО» к каждому предложению, поэтому воспринимайте все в этом посте просто как мои мнения по этому поводу, а не попытки продвинуть какие-то общечеловеческие истины :) Также я не утверждаю, что изложенные идеи были все изобретено в Rust, они, конечно же, используются и в других языках.

Подсказки типов

Первое и главное — это использование подсказок типов, где это возможно, особенно в сигнатурах функций и атрибутах класса. Когда я читаю сигнатуру функции, выглядящую так:

def find_item(records, check):

я понятия не имею, что происходит, глядя только на сигнатуру функции. records — это список, словарь или подключение к базе данных? check — это boolean или функция? Что возвращает эта функция? Что произойдет в случае сбоя? Вызовет ли он исключение или вернет None? Чтобы найти ответы на эти вопросы, мне нужно либо идти читать тело функции (и часто рекурсивно тела других функций, которые она вызывает — это довольно раздражает), либо читать документацию (если она есть). Хотя документация может содержать полезную информацию о том, что делает функция, нет необходимости также использовать ее для документирования ответов на предыдущие вопросы. На многие из них можно ответить с помощью встроенного механизма — подсказок типов.

def find_item(
  records: List[Item],
  check: Callable[[Item], bool]
) -> Optional[Item]:

Мне потребовалось больше времени, чтобы записать сигнатуру? Да. Проблема ли это? Нет, если только мой код не ограничен количеством символов, которые я пишу в минуту, чего на самом деле не происходит. Явное написание типов заставляет меня думать о том, каким будет фактический интерфейс, предоставляемый функцией, и как я могу сделать его как можно более строгим, чтобы тем кто вызывает его было трудно использовать его неправильным образом. С приведенной выше сигнатурой я могу получить довольно хорошее представление о том, как я могу использовать функцию, что передать ей в качестве аргументов и что я могу ожидать от нее. Кроме того, в отличие от комментария к документу, который может легко устареть при изменении кода, когда я изменяю типы и не обновляю вызывающие функции, средство проверки типов будет ругаться (1). И если меня интересует, что такое Item, я могу просто использовать Go to definition и сразу же посмотреть, как выглядит этот тип.

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

Классы данных вместо кортежей или словарей

Использование подсказок типов — это одно, но это просто описание интерфейса ваших функций. Второй шаг — сделать эти интерфейсы как можно более точными и «закрытыми». Типичным примером является возврат нескольких значений (или одного сложного значения) из функции. Ленивый и быстрый подход состоит в том, чтобы вернуть кортеж:

def find_person(...) -> Tuple[str, str, int]:

Отлично, мы знаем, что возвращаем три значения. Что за значения? Является ли первая строка именем человека? Вторая строка — это фамилия? Что за число? Это возраст? Позиция в каком-то списке? Номер социального страхования? Этот тип типизации непонятен, и пока вы не заглянете в тело функции, вы не узнаете, что здесь происходит.

Следующим шагом для «улучшения» может быть возврат словаря:

def find_person(...) -> Dict[str, Any]:
    ...
    return {
        "name": ...,
        "city": ...,
        "age": ...
    }

Теперь у нас есть представление о том, что представляют собой отдельные возвращаемые атрибуты, но нам снова нужно изучить тело функции, чтобы это понять. В каком-то смысле тип стал еще хуже, потому что теперь мы даже не знаем количество и типы отдельных атрибутов. Кроме того, когда эта функция изменяется, а ключи в возвращаемом словаре переименовываются или удаляются, нет простого способа выяснить это с помощью средства проверки типов, и поэтому вызывающие её функции обычно приходится изменять вручную раз за разом изменяя и запуская приложение пока оно не заработает.

Правильное решение — возвращать строго типизированный объект с именованными параметрами, которые имеют соответствующие типы. В Python это означает, что мы должны создать класс. Я подозреваю, что кортежи и словари так часто используются в таких ситуациях, потому что это намного проще, чем определить класс (и придумать для него имя), создать конструктор с параметрами, сохранить параметры в полях и т. д. Начиная с Python 3.7 (и раньше с пакетным полифиллом), есть гораздо более быстрое решение — классы данных.

@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 может даже сделать эти изменения за меня. Кроме того, такие явно определённые типы можно использовать совместно с другими функциями и классами.

Алгебраические типы данных

Одна вещь в Rust, которой мне, вероятно, больше всего не хватает в большинстве основных языков — это алгебраические типы данных (АТД) (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))

Вы даже можете выбрать, как будет сериализоваться тег union, как и в случае с serde. Я долго искал подобную функциональность, потому что это довольно полезно для (де)сериализации типов-объединений. Довольно неприятно было реализовывать это в большинстве других библиотек сериализации, которые я пробовал (например, dataclasses_json или dacite).

Например, при работе с моделями машинного обучения я использую типы-объединения для хранения различных типов нейронных сетей (например, моделей классификации или сегментации CNN) в одном формате файла конфигурации. Также это удобно использовать для версионирования различных форматов данных (в моем случае файлов конфигурации), например:

Config = ConfigV1 | ConfigV2 | ConfigV3

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

Использование newtype

В Rust довольно часто определяют типы данных, которые не добавляют никакого нового поведения, а служат просто для указания домена и предполагают использования какого-либо другого, довольно общего типа данных — например, целых чисел. Этот шаблон называется «newtype» (3), и его также можно использовать в Python. Вот хороший пример:

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

# Define a new type called "CarId", which is internally an `int`
CarId = NewType("CarId", int)
# Ditto for "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")
# Type error here -> DriverId used instead of CarId and vice-versa
info = db.get_ride_info(driver_id, car_id)

Это очень простой шаблон, который может помочь обнаружить ошибки, которые иначе трудно обнаружить. Это особенно полезно, например. если вы имеете дело с большим количеством различных типов идентификаторов (CarId и DriverId) или с различными физическими величинами (скорость, длина, температура и т. д.), которые не следует смешивать вместе.

Использование функций-конструкторов

Что мне очень нравится в 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:
  """
  Правила:
  - Не вызывать `send_message` до вызова `connect` и `authenticate`.
  - Не вызывать `connect` или `authenticate` несколько раз.
  - Не вызывать `close` до вызова `connect`.
  - Не вызывать ничего после `close`.
  """
  def __init__(self, address: str):

  def connect(self):
  def authenticate(self, password: str):
  def send_message(self, msg: str):
  def close(self):

…легко, не так ли? Вам просто нужно внимательно прочитать документацию и убедиться, что вы никогда не нарушаете упомянутые правила (чтобы не вызвать неопределенное поведение или сбой). Альтернативой является заполнение класса различными проверками, которые проверяют все упомянутые правила во время выполнения, что приводит к беспорядочному коду, пропущенным пограничным случаям и более медленной обратной связи, когда что-то не так (во время компиляции или выполнения). Суть проблемы в том, что клиент может находиться в различных (взаимоисключающих) состояниях, но вместо того, чтобы моделировать эти состояния по отдельности, они все сливаются в единый тип.

Давайте посмотрим, сможем ли мы это улучшить, разделив различные состояния на отдельные типы (4).

  • Прежде всего, есть ли вообще смысл иметь клиента, который ни к чему не подключен? Похоже что нет. Такой неподключенный клиент всё равно ничего не может сделать, пока вы не вызовете 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 (благодаря деструктивной move-семантике) мы можем выразить тот факт, что при вызове метода close вы больше не можете использовать клиент. Это невозможно в Python, поэтому нам придётся использовать обходной путь. Одно из решений может состоять в том, чтобы вернуться к проверке время выполнения, ввести boolean-атрибут в клиенте и проверить в close и send_message, что он еще не был закрыт. Другим подходом может быть полное удаление метода close и просто использование клиента в качестве менеджера контекста:

with connect(...) as client:
    client.send_message("foo")
# Тут клиент закрыт

Если метод close недоступен, вы не можете случайно закрыть клиент дважды (5).

Строго типизированные ограничивающие рамки

Обнаружение объектов — это задача компьютерного зрения, над которой я иногда работаю, когда программа должна обнаружить набор ограничивающих рамок на изображении. Ограничивающие рамки — это в основном обычные прямоугольники с некоторыми прикрепленными данными, и когда вы реализуете обнаружение объектов, они повсюду. Раздражает то, что иногда они нормированы (координаты и размеры прямоугольника лежат в интервале [0.0, 1.0]), а иногда денормализованы (координаты и размеры ограничены размерами изображения, на котором они находятся). Когда вы передаёте ограничивающую рамку через множество функций, например для пред или постобработки, её легко испортить, и, например, нормализовать ограничивающую рамку дважды, что приводит к ошибкам, которые довольно сильно раздражают при отладке.

Это случалось со мной несколько раз, поэтому однажды я решил решить эту проблему навсегда, разделив эти два типа bbox на два отдельных типа:

@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

# Composition
class NormalizedBBox:
  bbox: BBoxBase

class DenormalizedBBox:
  bbox: BBoxBase

Bbox = Union[NormalizedBBox, DenormalizedBBox]

# Inheritance
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++, хотя явный интерфейс блокировки/разблокировки без 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)  # Oops

Хотя мы не можем получить в Python те же преимущества, что и в Rust, не все потеряно. Блокировки Python реализуют интерфейс context manager, что означает, что вы можете использовать их в блоке with, чтобы убедиться, что они автоматически разблокируются в конце области действия. И, приложив немного усилий, мы можем пойти еще дальше:

import contextlib
from threading import Lock
from typing import ContextManager, Generic, TypeVar

T = TypeVar("T")

# Делаем мьютекс с обобщенным типом.
# IТак мы можем получить правильный тип в методе `lock`.
class Mutex(Generic[T]):
  # Храним данные внутри мьютекса 
  def __init__(self, value: T):
    # Используем имена с подчеркиванием чтобы было сложнее случайно
    # обратиться к ним снаружи.
    self.__value = value
    self.__lock = Lock()

  # contextmanager метод `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.

  1. Справедливости ради, это также может быть верно для описаний типов параметров в комментариях к документам, если вы используете какой-либо структурированный формат (например, reStructuredText). В этом случае средство проверки типов может использовать это и предупреждать вас, если типы не совпадают. Но если вы все равно используете typechecker, мне кажется, лучше использовать «родной» механизм указания типов — подсказки типов.

  2. также известные как tagged unions, тип-суммы, запечатанные классы и т. д.

  3. Да, у newtype есть и другие варианты использования, кроме описанного здесь.

  4. Известно как шаблон typestate.

  5. Если вы сильно не постараетесь и, например, вызовите магический метод __exit__ вручную.

© Habrahabr.ru