Вы наверняка видели множество статей на тему "Python, Rust - производительность, бла-бла-бла... Вот, реализуем foo2plus2". Вся беда в том, что все эти статьи демонстрируют очень простые примеры уровня "hello-world". Напротив, в этой статье я хочу рассказать о том, как я проектирую комплексные расширения и почему я принимаю те или иные проектные решения.

Статья является переводом и адаптацией моей же статьи на Medium. Возможно, кто-то предпочтет прочесть в оригинале - ссылка позволяет прочитать без подписки.

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

Кроме того, я не очень опытный разработчик на Rust, поэтому в моем коде могут встречаться забавные вещи - будьте готовы к фейспалмам, если вы сильно прошарены в Rust.

Благодарности

Если порыться в моих репозиториях, то можно обнаружить эволюцию подхода к разработке расширений Rust для Python: например, в Similari многие решения странные, но именно в ходе данного проекта у меня произошел существенный скачок вперед, когда я получил PR от Андрея Ткаченко.

Другая команда продемонстрировала, как использовать Sphinx для документирования расширений, и я написал об этом в соответствующей статье. Я провел несколько часов, погружаясь в среду Manylinux и особенности ее использование в Maturin GitHub Action, в результате чего появилась статья, демонстрирующая, как создавать переносимые расширения Rust для Python с помощью GitHub CI и сборке в Docker.

Хочу сказать "спасибо" всем тем, кто помог мне продвинуться в данной теме: все вы проделали огромную работу, создавая свой код без общепринятых подходов и обширной документации.

Благодарю следующие коллективы и людей за участие в моем прогрессе:

  • PyO3 team — за их усилия по предоставлению разработчикам превосходного инструмента для создания расширений Python на Rust;

  • Andrei Tkachenko — за демонстрацию возможностей использования transmute при разработке расширений Python на Rust и за огромный вклад в развитие Similari;

  • Pola.rs project team — идеи по дизайну и фрагменты кода;

  • Pometry/Raphtory team — за идеи по документированию расширений Python на Rust.

Предварительные условия

Это не вводная статья. Я предполагаю, что вы знаете Rust и можете написать расширение типа "hello world" для Python на Rust с помощью PyO3. Я предполагаю, что вы знаете, что такое GIL и как он работает.

Специфика модели управления памятью

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

Наиболее близкой структурой, представляющей модель памяти Python, в Rust является:

type WrappedMutex = Arc<Mutex<T>>;
// or
type WrappedRwLock = Arc<RwLock<T>>;

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

Одна из ключевых проблем при проектировании объектной модели совместимой с Python заключается в том, что передать ссылку (&mut или &) из Rust в Python невозможно: для этого необходимо использовать Arc<T>.

В PyO3 реализованы некоторые решения, позволяющие передавать аллоцированные в Python объекты в Rust-функции по ссылке, но обратная операция невозможна: вы не можете просто сохранить ссылку на Rust-объект в памяти Python. PyO3 поддерживает передачу объектов Rust в Python по значению, оборачивая их в объекты Python (PyObject).

Понимание моделей памяти и их особенностей их взаимодействия между собой важно для правильного проектирования кода, особенно если он освобождает GIL, поскольку освобождение GIL требует от объекта реализации трейта Ungil, который, по сути, является Send.

Если вы хотите одновременно использовать объект в Python и Rust, необходимо использовать обертку для него на базе Arc<T> или Arc<Mutex<T>>.

Пример объекта, используемого только в Python:

use pyo3::prelude::*;

#[pyclass]
struct Object {
    x: i32,
}

#[pymethods]
impl Object {
    fn inc(&mut self) {
        self.x += 1;
    }
}

Ссылка на экземпляр данного класса может быть временно передана в функцию Rust, вызываемую из Python, например:

#[pyfunction]
fn callme(o: &mut Object) {
    o.inc();
}

Или вы можете передать объект в Rust как копию (Clone) - объект в пространстве памяти Python не пропадет:

#[pyfunction]
fn callme(mut o: Object) {
    o.inc();
}

Пример объекта, используемого одновременно из Rust и Python:

use parking_lot::Mutex;
use pyo3::prelude::*;
use std::sync::Arc;

struct Object {
    x: i32,
}

impl Object {
    fn inc(&mut self) {
        self.x += 1;
    }
}

#[pyclass]
#[pyo3(name = "Object")]
#[derive(Clone)]
struct ObjectWrapper(Arc<Mutex<Object>>);

#[pymethods]
impl ObjectWrapper {
    fn inc(&self) {
        let mut bound = self.0.lock();
        bound.inc();
    }
}

Теперь вы можете иметь ссылки на один и тот же объект как в Python, так и в Rust, например:

thread_local! {
    static OBJECT: ObjectWrapper = ObjectWrapper(Arc::new(Mutex::new(Object { x: 0 })))
}

#[pyfunction]
fn get_shared_object() -> ObjectWrapper {
    OBJECT.with(|o| o.clone())
}

#[pyfunction]
fn callme(o: &mut ObjectWrapper) {
    o.inc();
}

Его можно одновременно использовать из Python и Rust (в двух потоках, например). Mutex<T> защищает его от гонки, а Arc<T> обеспечивает механизм совместного использования. Вместо мьютекса можно использовать другие примитивы синхронизации, например RwLock<T>.

Нельзя безопасно написать функцию, возвращающую в Python ссылку на объект, выделенный Rust. Но можно вернуть Python ссылку на объект, выделенный в памяти Python:

fn callme<'p>(slf: PyRef<'p, Self>, _py: Python<'p>) -> PyResult<PyRef<'p, Self>> {
    let o = slf.deref();
    ...
    Ok(slf)
}

Сфера применения вышеуказанного кода ограничена, но можно, например, использовать для реализации билдеров или для реализации with.

Структура проекта

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

В принципе, у вас есть два подхода к структурированию кода:

  • с использованием пространств имен и feature-флагов, размещая код, связанный с Python, за флагами (этот подход реализован в Similari);

  • с использованием отдельных крейтов, объединенных в одно рабочее пространство (Savant-RS, RocksQ).

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

Итак, как вы видите, у меня два крейта: в queue_rs я реализовал логику и функциональность, он ничего не знает о Python. Во втором - только код, связанный с Python. Я считаю, что такая компоновка является оптимальной по ряду причин. Давайте обсудим их.

Продуктивность при работе над кодом с логикой на Rust. Я хочу, чтобы мой код быстро компилировался и тестировался без использования среды исполнения Python и ее типов данных. Такая компоновка проекта позволяет разрабатывать код с логикой с использованием только нативных типов.

Зависимость от PyO3 увеличивает время компиляции и часто делает невозможной реализацию тестов без инициализации среды Python, что неудобно, и может приводить к инетересным эффектам, поскольку GIL сериализует код, ваши тесты могут приобретать неожиданное изменчивое от запуска к запуску поведение.

Например, при использовании PyBytes необходимо иметь рабочую среду Python для создания объекта данного типа. Я же не хочу иметь дело с этими лишними операциями при работе с логикой.

Следуя вышеизложенному, я пишу свой код с учетом особенностей модели памяти, но без типов, связанных с Python, и зависимости от PyO3.

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

Однажды с помощью такого подхода я выяснил, что передача в Rust больших массивов Vec<u8> убивает производительность, и начал использовать PyBytes в своем коде, связанном с PyO3.

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

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

Структурирование кодовой базы. Я всегда знаю, где найти код - если он связан с Python, то в проекте *_py, в противном случае - в проекте *_rs. Это помогает уменьшить хаос, который возникает в случае, когда в одном файле встречается много кода, не относящегося к логике, но обеспечивающего взаимодействие с Python.

Именование объектов кода. Rust не поддерживает переопределения имен функций; обычно это означает, что если вам нужна одна и та же функция для внутренних целей (только для Rust) и для экспорта в Python, то одну из них требуется назвать как-то по-хитрому.

Раньше мне приходилось делать что-то в духе:

fn callme(x: i64) -> i64 {
...
}

#[pyfunction]
#[pyo3(name = "callme")]
fn callme_py(x: i64) -> i64 {
    callme(x)
}

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

use lib_rs as rust;

#[pyfunction]
fn callme(x: i64) -> i64 {
    rust::callme(x)
}

Рано или поздно кто-то захочет получить сборку без Python. Так было с Similari, люди хотели получить код без зависимости от Python. Это стало проблемой. В результате Andrei Tkachenko предложил большой PR, поместив функциональность, связанную с Python, за feature-флаг.

Обертки Python для объектов

Теперь, зная мой подход, вы, наверное, начали думать, что у меня дублируется больше кода, чем требуется. Да, это так, вместо аннотирования одного объекта с #[pyclass] у меня две реализации:

pub struct Object {
    x: i32,
}

impl Object {
    pub fn inc(&mut self) {
        self.x += 1;
    }
}
use pyo3::prelude::*;
use lib_rs as rust;

#[pyclass]
struct Object(rust::Object);

#[pymethods]
impl Object {
    fn inc(&mut self) {
        self.0.inc()
    }
}

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

Обертка объекта в Python представляет собой кортежную структуру с единственным аргументом

Обычно я не использую традиционные структуры Rust в своих крейтах *_py : только кортежные структуры. Ранее я писал что-то вроде:

struct Object {
    inner: rust::Object
}

Однако теперь я вижу недостатки такой компоновки:

  • Я обнаружил, что иногда я использую "inner", иногда "object", "internal" и т.д. Это вносит хаос.

  • Код выглядит менее унифицированным, при кортежной компоновке обращение к внутреннему объекту всегда происходит через .0. .

  • Вы можете легко использовать std::mem::transmute ???? с кортежеподобными структурами.

  • Для Option и Result можно использовать простую упаковку вида:

#[pyfunction]
fn callme() -> Option<Object> {
    if ... {
       None
    } else {
       let res_opt = Some(rust::Object);
       res_opt.map(Object) // here it is    
    }
}

Магия transmute. Расположение в памяти X и struct Y(X) в Rust одинаково, поэтому с помощью transmute можно "безопасно" преобразовывать кортежные структуры Python в кортежные структуры Rust на месте, без перераспределения памяти. Особенно это актуально, когда необходимо преобразовать большой вектор (Vec<T>) между rust::Object и Python'овским Object.

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

struct X {
  i: i64
}

struct W(X)

let x = X { i: 1 };
let w: W = unsafe { std::mem::transmute(x) };
let x: X = unsafe { std::mem::transmute(w) };

let vx = vec![X { i: 1 }];
let wx: Vec<W> = unsafe { std::mem::transmute(vx) };

Перечисления с вариантами без полей

Когда мне нужны такие перечисления, я дублирую их в двух крейтах и реализую в своем крейте *_py trait From для обоих направлений:

// in lib_rs

enum X {
  A,
  B,
}

// in lib_py

enum X {
  A,
  B
}

impl From<rust::X> for X {
...
}

impl From<X> for rust::X {
...
}

Честно говоря, эти перечисления не дают мне покоя. Такие перечисления прекрасно поддерживаются PyO3, и я могу просто использовать для них #[pyclass], но это требует либо переноса их в отдельный крейт с зависимостью от PyO3, либо добавления зависимости от PyO3 в мой крейт *_rs. На данный момент я решил остановиться на приведенном выше решении.

Возможно, стоит попробовать помещать их в отдельный крейт enums, который будет зависимостью для lib_rs и lib_py. Я еще не решил.

Enum с вариантами, содержащими поля

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

use pyo3::prelude::*;

mod rust {
    pub enum Pet {
        Dog { name: String, age: u8 },
        Hamster { name: String },
    }
}

#[pyclass]
struct Pet(rust::Pet);

#[pyclass]
struct Dog {
    name: String,
    age: u8,
}

#[pyclass]
struct Hamster {
    name: String,
}

#[pymethods]
impl Pet {
    fn is_dog(&self) -> bool {
        matches!(&self.0, Dog { name: _, age: _ })
    }

    fn is_hamster(&self) -> bool {
        matches!(&self.0, Hamster { name: _ })
    }

    fn as_dog(&self) -> Option<Dog> {
        if let rust::Pet::Dog { name, age } = &self.0 {
            Some(Dog {
                name: name.clone(),
                age: *age,
            })
        }
        None
    }

    fn as_hamster(&self) -> Option<Hamster> {
        if let rust::Pet::Hamster { name } = &self.0 {
            Some(Hamster { name: name.clone() })
        }
        None
    }
}

Работа с исключениями Python

С помощью PyO3 можно легко преобразовать Rust Result (я использую anyhow) в Python PyResult, вариант Err которого является Python-исключением:

use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;

mod rust {
    use anyhow::Result;
    pub fn u8_overflow_plus(add: u8, to: u8) -> Result<u8> {
        if add > u8::MAX - to {
            bail!("overflow")
        } else {
            Ok(add + to)
        }
    }
}

#[pyfunction]
fn u8_overflow_plus(add: u8, to: u8) -> PyResult<u8> {
    rust::u8_overflow_plus(add, to)
        .map_err(|e| PyValueError::new_err(format!("u8_overflow_plus: {}", e)))
}

// in your Python code
//
// try:
//     u8_overflow_plus(255, 255)
// except ValueError:
//     # impossible

Я очень часто использую Result в своем коде. Реализация в PyO3 очень элегантная: вы можете использовать модель кодирования Rust, которая прозрачно отображается на исключения в Python. Я нахожу это волшеным: если он возвращает Ok(smth), то получается обычное значение Python; если возвращает Err(e) - код Python вызывает исключение.

Работа с аргументами по умолчанию для функций и методов

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

Конструкторы:

#[pymethods]
impl PersistentQueueWithCapacity {
    #[new]
    #[pyo3(signature=(path, max_elements = 1_000_000_000))]
    fn new(path: &str, max_elements: usize) -> PyResult<Self> {
    ...
}

Методы (self пропускаем в описании сигнатуры):

#[pymethods]
impl PersistentQueueWithCapacity {
    #[pyo3(signature=(n = 1_000_000_000))]
    fn set(&mut self, n: usize) {
    ...
}

Функции:

#[pyfunction]
#[pyo3(signature=(x=0, i=1))]
fn inc(x: i64, i: i64) -> i64 {
...
}

// can be called in python
// inc()
// inc(1)
// inc(i=10)
// inc(x=1, i=10)
// inc(1, 10)

Конструирование объектов

Обычно вы помечаете метод символом #[new], чтобы указать, что он используется как __init__ в Python. Если объект конструируется в Rust, вы можете вообще не иметь публично доступного конструктора.

При построении перечислений я обычно использую несколько иной подход, основанный на #[staticmethod]. Вспомним наше перечисление Pet из предыдущего примера:

use pyo3::prelude::*;

mod rust {
    pub enum Pet {
        Dog { name: String, age: u8 },
        Hamster { name: String },
    }
}

#[pyclass]
struct Pet(rust::Pet);

#[pyclass]
struct Dog {
    name: String,
    age: u8,
}

#[pyclass]
struct Hamster {
    name: String,
}

#[pymethods]
impl Pet {
    #[staticmethod]
    fn dog(name: String, age: u8) -> Self {
        Self(rust::Pet::Dog { name, age })
    }
    #[staticmethod]
    fn hamster(name: String) -> Self {
        Self(rust::Pet::Hamster { name })
    }
}

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

dog = Pet.dog("Pike", 3)
hamster = Pet.hamster("Wheely")

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

Работа с GIL

Освобождение GIL не всегда является хорошей идеей. Когда код выполняется быстро, я не оптимизирую операции с GIL. Эмпирическим путем я обнаружил, что код обычно становится медленнее, если функция завершается быстрее, чем за 1000нс, и я освобождаю GIL внутри нее. Иногда я не уверен, нужно ли освобождать GIL или нет. В такой ситуации я реализую функцию, позволяя пользователю выбрать поведение:

#[pyo3(signature = (items, no_gil = true))]
fn push(&mut self, items: Vec<&PyBytes>, no_gil: bool) -> PyResult<()> {
    let data = items.iter().map(|e| e.as_bytes()).collect::<Vec<&[u8]>>();
    Python::with_gil(|py| {
        let mut f = move || {
            self.0
                .push(&data)
                .map_err(|e| PyRuntimeError::new_err(format!("Failed to push item: {}", e)))
        };

        if no_gil {
            py.allow_threads(f)
        } else {
            f()
        }
    })
}

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

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

ACQUIRE_GIL     <<<<<
  CALL function
    DO STUFF
  ENDCALL
RELEASE_GIL

Когда освобождаем:

ACQUIRE_GIL     <<<<<
  CALL function
    RELEASE_GIL
      DO STUFF
    ACQUIRE_GIL <<<<<
  ENDCALL
RELEASE_GIL

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

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

PyBytes вместо Vec<u8>


Раньше при работе с двоичными данными я использовал следующие конструкции (в PyO3 нельзя передавать &Vec<u8> или &[u8]):

#[pyfunction]
fn bypass_and_ret(v: Vec<u8>) -> Vec<u8> {
    v
}

Он медлителен при передаче больших байтовых буферов, однако дает гибкость: можно передавать Python bytes или [1, 2, 3]. В настоящее время при работе с большими двоичными данными я предпочитаю использовать &PyBytes (да, его можно передавать через &):

use pyo3::prelude::*;
use pyo3::types::PyBytes;

#[pyfunction]
fn bypass_and_ret(v: &PyBytes) -> PyObject {
    let data = v.as_bytes();
    Python::with_gil(|py| PyObject::from(PyBytes::new(py, data)))
}

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

Версия библиотеки

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

mod rust {
    pub fn version() -> &'static str { env!("CARGO_PKG_VERSION") }
}

#[pyfunction]
fn version() -> String { rust::version() }L

Довольно занудная штука - использовать это в каждой либе, но, как говорилось в рекламе: "Попробовал раз, ем и сейчас". Такой уж я человек :)

Вложенные пакеты Python

У PyO3 есть проблемы с вложенными пакетами Python. Погуглив и почитав обсуждения, я обнаружил, что следующий подход отлично работает:

#[pymodule]
fn rocksq_blocking(_: Python, m: &PyModule) -> PyResult<()> {
    Ok(())
}

#[pymodule]
fn rocksq_nonblocking(_: Python, m: &PyModule) -> PyResult<()> {
    Ok(())
}

#[pymodule]
fn rocksq(py: Python, m: &PyModule) -> PyResult<()> {
    m.add_function(wrap_pyfunction!(version, m)?)?;

    m.add_wrapped(wrap_pymodule!(rocksq_blocking))?;
    m.add_wrapped(wrap_pymodule!(rocksq_nonblocking))?;

    let sys = PyModule::import(py, "sys")?;
    let sys_modules: &PyDict = sys.getattr("modules")?.downcast()?;

    sys_modules.set_item("rocksq.blocking", m.getattr("rocksq_blocking")?)?;
    sys_modules.set_item("rocksq.nonblocking", m.getattr("rocksq_nonblocking")?)?;

    Ok(())
}

Ведение логов

В PyO3 используется отдельный крейт (pyo3-log), предоставляющий возможность направлять журналы Rust в систему протоколирования Python (из документации по PyO3):

use log::info;
use pyo3::prelude::*;

#[pyfunction]
fn log_something() {
    // This will use the logger installed in `my_module` to send the `info`
    // message to the Python logging facilities.
    info!("Something!");
}

#[pymodule]
fn my_module(_py: Python<'_>, m: &PyModule) -> PyResult<()> {
    // A good place to install the Rust -> Python logger.
    pyo3_log::init();

    m.add_function(wrap_pyfunction!(log_something))?;
    Ok(())
}

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

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

Строковое представление объектов

Чтобы помочь пользователям разобраться во внутренней структуре объекта, вы можете предоставить им методы __repr__ и __str__ . Если ваша структура реализует трейт Debug, то вы можете использовать следующий подход:

impl Struct { 
   fn __repr__(&self) -> String {
        format!("{:?}", &self.0)
    }

    fn __str__(&self) -> String {
        self.__repr__()
    }
}

Методы позволяют пользователю использовать стандартные подходы Python, чтобы понять структуру и состояние объекта.

Хеширование объектов

Если вам необходимо, чтобы ваш объект использовался в качестве хэш-ключа, вам необходимо предоставить реализацию для __hash__ :

impl Struct {
    fn __hash__(&self) -> u64 {
        let mut hasher = DefaultHasher::new();
        self.hash(&mut hasher);
        hasher.finish()
    }
}

Когда я хочу запретить использовать объекты в качестве ключей, я использую:

#[classattr]
const __hash__: Option<Py<PyAny>> = None;

Реализация операций с массивами для объекта

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

#[pyclass]
#[derive(Debug, Clone)]
pub struct AttributeValuesView(Arc<Vec<rust::AttributeValue>>)

Моя цель была в том, чтобы предоставить API для доступа к отдельным AttributeValue с помощью синтаксиса attribute_value[i]. Для того чтобы это стало возможным, необходимо реализовать две (только чтение) или три (чтение-запись) dunder-функции:

#[pymethods]
impl AttributeValuesView {
    fn __getitem__(&self, index: usize) -> PyResult<AttributeValue> {
        let v = self
            .0
            .get(index)
            .ok_or(PyIndexError::new_err("index out of range"))
            .map(|x| x.clone())?;
        Ok(AttributeValue(v))
    }

    fn __len__(&self) -> PyResult<usize> {
        Ok(self.0.len())
    }
}

Если вам необходимо реализовать установку элемента, то вам также понадобится __setitem__ dunder:

fn __setitem__(&mut self, idx: isize, value: impl FromPyObject) -> PyResult<()>

Замечательно, что в PyO3 есть раздел, посвященный настройкам классов, где можно найти и другие полезные функции.

Документирование

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

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

Сборка и публикация кода в PYPI

При использовании нативных расширений нельзя просто установить с произвольной версией Python. Расширение должно быть собрано и слинковано с нужными версиями заранее или на этапе установки. Для автоматизации этого процесса можно использовать систему сборки с помощью Docker и GitHub Actions (или другой системы CI/CD).

Руководство по сборке таких расширений с помощью GitHub Actions вы найдете в моей отдельной статье. Я не публикую его здесь, чтобы сэкономить размер статьи.

Демонстрационный проект

Я подготовил небольшой проект RocksQ (персистентная очередь на основе RocksDB), в котором можно найти много вещей, реализованных в соответствии с моим руководством.

Заключение

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

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


  1. JohnScience
    13.10.2023 18:37

    Меня это интересует, но мне хотелось бы исполнять async Rust. Оценил бы, если бы ты рассмотрел этот вариант.


    1. ivankudryavtsev Автор
      13.10.2023 18:37

      Async Rust живет в неком рантайме внутри Rust. Вы можете запустить рантайм в треде, например, и отправлять туда задачи. В общем, вопрос сильно обширный, но вот это может вам подать идею:

      https://github.com/insight-platform/RocksQ/blob/main/queue_rs/src/nonblocking.rs

      ну и соответствующую реализацию в Python смотрите. В целом async Python и async Rust никак не связаны. Если вы умеете взаимодействовать с async рантаймом из Rust, то проблем быть не должно.


    1. ivankudryavtsev Автор
      13.10.2023 18:37

      Вот прям пример с async кодом у себя нашел: https://github.com/insight-platform/savant-rs/blob/main/etcd_dynamic_state/src/parameter_storage.rs

      Как это мапится в Python - ищите в проекте.