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

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

Одна вещь в Rust, которой мне, вероятно, больше всего не хватает в большинстве основных языков - это алгебраические типы данных (АТД)[2]. Это невероятно мощный инструмент для явного описания типов данных, с которыми работает мой код. Например, когда я работаю с пакетами в Rust, я могу явно перечислить все различные виды пакетов, которые можно получить, и присвоить каждому из них разные данные (поля):

enum Packet {
  Header {
    protocol: Protocol,
    size: usize
  },
  Payload {
    data: Vec<u8>
  },
  Trailer {
    data: Vec<u8>,
    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(<error>driver_id</error>, <error>car_id</error>)

Это очень простой шаблон, который может помочь обнаружить ошибки, которые иначе трудно обнаружить. Это особенно полезно, например. если вы имеете дело с большим количеством различных типов идентификаторов (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__ вручную.

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


  1. mclander
    22.05.2023 21:13
    +3

    Прикольно! В питоне тоже есть typescript)


  1. Alexander428
    22.05.2023 21:13
    +3

    Классная статья! Вам удалось собрать в одной статье много полезных советов. Я сам пишу и на Rust и на Python, тоже пытаюсь использовать "плюшки" Rust везде, где можно, но некоторые способы продемонстрированные здесь более лаконичные чем мои. Спасибо)

    Единственное что, разве в Python с его GIL нужны мютексы? Разве что для модуля multiprocessing какого-нибудь.


    1. ris58h Автор
      22.05.2023 21:13
      +2

      Это перевод. Можете попробовать спросить автора в комментариях к его посту на Reddit.


      1. Alexander428
        22.05.2023 21:13

        А, не заметил)


    1. mayorovp
      22.05.2023 21:13
      +5

      GIL избавляет вас от повреждения памяти, и гарантирует что любые объекты, с которыми вы работаете, остаются корректными объектами Python, но он не защищает состояние этих объектов от гонок.


      Банальный код вроде foo.bar += 1 уже содержит гонку если объект foo разделяемый и не защищён мьютексом.


      1. me21
        22.05.2023 21:13

        Насколько я помню, это верно даже для кода i += 1, потому что она компилируется в несколько инструкций байт-кода Python, а значит, не атомарна.


        1. mayorovp
          22.05.2023 21:13

          Ну, переменные редко бывают разделяемыми между потоками, поэтому я и привёл чуть-чуть более сложный пример. А так да, i += 1 не атомарна.


      1. jacob1237
        22.05.2023 21:13
        +1

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

        Даже lock-free структуры используют в том или ином виде атомарные операции CPU (cmpxchg и его друзья), что примерно раза в 2 медленнее чем подобные неатомарные операции.

        Поэтому GIL, можно сказать, гарантирует отсутствие головной боли у контрибьюторов CPython :)


  1. Helltraitor
    22.05.2023 21:13
    +1

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

    Использует assert

    Другим подходом может быть полное удаление метода close и просто использование клиента в качестве менеджера контекста

    Автор, видимо, не в курсе что менеджеры и были созданы для решения данной задачи

    По поводу мьютексов: менеджеры работают и тут https://docs.python.org/3/library/threading.html#using-locks-conditions-and-semaphores-in-the-with-statement

    Правда, поскольку нет системы владения, нет смысла и передавать в мьютекс что-то (хотя могли бы фичу такую добавить)


    1. ris58h Автор
      22.05.2023 21:13
      +1

      Автор, видимо, не в курсе что менеджеры и были созданы для решения данной задачи

      Автор в курсе. В одном из параграфов об этом упоминается:

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


  1. MountainGoat
    22.05.2023 21:13
    +8

    В Питоне есть незаслуженно забытый TypedDict, позволяющий фиксировать по типам содержимое словаря. Хорош, чтобы вернуть из функции 3-4 разных значения, но при этом dataclass делать не хочется, так как больше нигде они пачкой не идут.


    1. morijndael
      22.05.2023 21:13
      +1

      Так ведь TypedDict тоже надо объявлять. Только датакласс вам ещё сгенерирует конструктор, проверит типы, и вообще сделает всю рутину

      TypedDict лучше оставить для легаси, которое использовало словари, и API которого так просто не изменить


      1. ri_gilfanov
        22.05.2023 21:13

        Типизированные словари лучше ложатся на SELECT запросы к базам данных.

        Если ключ employee_pk в словаре TaskSelectDict содержит None, значит клиент хочет получить задачи, к которым не назначен исполнитель.

        Если этого ключа в словаре нет -- клиент хочет получить задачи, независимо от того назначен им исполнитель или нет.

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

        Только датакласс вам ещё сгенерирует конструктор

        У словарей по умолчанию есть конструктор.

        проверит типы

        Если речь о статической проверке, MyPy умеет проверять типы и для TypedDict, и для dataclass.

        Во время исполнения -- ни TypedDict, ни dataclass никаких проверок типов не делают. Если, конечно, Вы не имели ввиду dataclass из библиотеки Pydantic.


  1. ReadOnlySadUser
    22.05.2023 21:13
    -7

    Не хочу разводить холивар, но в сущности вы изобрели более страшную и менее производительную версию С# :)


    1. Amka
      22.05.2023 21:13
      +2

      Не холивар, так обесценивание)) Красота дело субьективное, конечно. Но с точки зрения питона это большой и отличный шаг вперёд.


  1. gnomeby
    22.05.2023 21:13
    +3

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

    Наверное проще сразу взять другой язык, а в питоне просто больше интеграционных тестов написать.

    Но жменьку полезных советов оценил.


  1. Gadd
    22.05.2023 21:13
    +4

    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
    )

    Заметили ошибку?

    Тут можно так же использовать keyword-only аргументы

    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

    В итоге имя аргумента придется задавать явно, например следующий вызов будет смотреться как минимум странно:

    info = db.get_ride_info(car_id=driver_id, driver_id=car_id)

    Попытка вызова метода без явного указания имени аргументов будет вызывать ошибку при попытке запуска скрипта.

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


    1. LinearLeopard
      22.05.2023 21:13

      Можно создать разные типы с помощью NewType https://docs.python.org/3/library/typing.html#typing.NewType

      Тогда CarId и DriverId будут разные типы, которые нельзя будет смешивать (типо разные BBox из статьи), но в некоторым местах надо будет кастить, что не особо удобно, особенно в тестах, потому что там будет много таких кастов, но терпимо.

      def get_car_id():
          result = ...
          return CarId(result)


  1. candyboah
    22.05.2023 21:13
    +2

    Связка питона и раста лучшая для ML и всей остальной черной работы.


  1. Paskin
    22.05.2023 21:13

    У меня такое впечатление, что производительность тут явно будет принесена в жертву безопасности.


    1. ris58h Автор
      22.05.2023 21:13
      +2

      Какие основания для такого впечатления? Это проверки времени компиляции.


      1. gnomeby
        22.05.2023 21:13

        Какого же времени компиляции, всё в рантайме будет создаваться и оперироваться более сложными типами.


        1. ris58h Автор
          22.05.2023 21:13
          +3

          всё в рантайме будет создаваться

          Расскажите пожалуйста что именно "всё" и какими более сложными типами оперировать. PEP-484 вам в помощь.


          1. andreymal
            22.05.2023 21:13
            +7

            Очевидно, датаклассы. Когда счёт объектов идёт на тысячи — уже начинает быть заметно, что манипуляции с датаклассами в несколько раз медленнее манипуляций с кортежами. А ещё вы не использовали slots, на чём потеряли ещё несколько процентов производительности


            UPD: а впрочем, есть же typing.NamedTuple. Если задача не требует мутабельных объектов, то, наверно, можно попробовать позаменять датаклассы на NamedTuple, совместив таким образом удобство датаклассов и скорость кортежей. А если мутабельность всё-таки нужна, то там же рядышком есть typing.TypedDict — в рантайме обычный dict, немножко медленнее чем кортежи, но всё ещё быстрее датаклассов


            1. ris58h Автор
              22.05.2023 21:13
              -1

              Очевидно, датаклассы.

              Только они или как было заявлено "всё"? По ним согласен.


              1. gnomeby
                22.05.2023 21:13

                NewType тоже.

                Changed in version 3.10: NewType is now a class rather than a function. There is some additional
                runtime cost when calling NewType over a regular function. However, this
                cost will be reduced in 3.11.0.

                -----

                Ну хорошо, не всё вообще, а всё что заменяет примитивные типы на более сложные.


    1. mayorovp
      22.05.2023 21:13
      +7

      Вряд ли она просядет сильно (да и если экономить на всех спичках — в первую очередь надо менять язык). Кроме того, часть советов тут вообще никак не влияет на рантайм.


    1. slonopotamus
      22.05.2023 21:13
      +19

      Производительность уже принесена в жертву в момент выбора питона.


      1. Paskin
        22.05.2023 21:13

        Совсем не факт, особенно в сложных приложениях. Мне как-то пришлось переписывать с С++/STL на Java некий компонент телекоммуникационого приложения - просто "переведенный" построчно, он уже работал почти в 2 раза быстрее за счет более оптимизированных библиотек.
        Не говоря уже о времени, которое занимает написание одного и того же кода...


        1. slonopotamus
          22.05.2023 21:13

          за счет более оптимизированных библиотек

          ... которые написаны на чём? На сишечке. И при чём тут питон тогда?


          1. shpaker
            22.05.2023 21:13
            +2

            А какая разница на чем оно под капотом написано если ты этого никогда не видишь и не трогаешь?


          1. Paskin
            22.05.2023 21:13

            При том, что Питон - это гораздо более удобное средство вызова "оптимизированных библиотек", чем С/С++. Позволяющее быстро получить работающий код, а потом - если такая нужда возникнет - его оптимизировать. А не наоборот, что как известно - "the root of all evil".


  1. kapkekes
    22.05.2023 21:13

    Поправьте, если не прав, но ведь альтернативные конструкторы (секция про функции-конструкторы) создаются через @classmethod, а не @staticmethod, нельзя же внутри метода класса прямо ссылаться на него.

    К тому же, в лоб указывать тип "Rectangle" тоже идея не лучшая: получится, что отнаследованные классы всё ещё будут возвращать Rectangle, а не себя. Сейчас (начиная с 3.11) эту напасть можно решать через typing.Self, раньше как-то нужно было оперировать на typing.TypeVar.

    А статья неплохая, нашёл полезные моменты, спасибо.


    1. ris58h Автор
      22.05.2023 21:13
      +1

      Про typing.Self в статье есть упоминание в последнем параграфе соответствующей части.