Вы, вероятно, уже видели немало статей с заголовками вроде «Python, Rust — производительность, бла-бла-бла…». Печально, но почти все эти статьи демонстрируют лишь самые простые примеры уровня «hello world». В отличие от них, в этой статье я хочу поделиться тем, как я проектирую крупные расширения для реальных проектов и почему принимаю при этом те или иные решения.

На данный момент я написал четыре библиотеки для Python на Rust (1, 2, 3, 4) и приобрёл некоторый опыт, но не чувствую, что достиг того уровня, чтобы утверждать, что правильно, а что нет. Некоторые из моих подходов вдохновлены работами других, другие — результат анализа и рефакторинга кода, и всё же я не уверен, что это лучший путь для работы с Rust и Python.

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

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


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

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

Моя большая благодарность:
  • Команде PyO3 — за их усилия по созданию отличного инструмента для плавной разработки расширений;
  • Андрею Ткаченко — который показал, как можно использовать transmute при разработке расширений Python на Rust, и за его большой вклад в Similari;
  • Команде проекта Pola.rs — за идеи по проектированию и фрагменты кода;
  • Команде Pometry/Raphtory — за их идеи по документированию расширений Python на Rust.

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


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

Особенности модели памяти


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

Arc<Mutex<Object>>

Но в Python мьютексом выступает GIL. Проблема в том, что нельзя передать из Rust в Python ссылку (&mut или &) напрямую — для этого нужно использовать . PyO3 реализует некоторые улучшения, позволяющие передавать объекты, выделенные Python, в функции Rust по ссылке, но обратную операцию сделать нельзя: невозможно хранить ссылку на Rust-объект внутри Python-объекта. PyO3 поддерживает передачу Rust-объектов в Python по значению, оборачивая их в объекты Python.

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

Если вы хотите разделять объект между Python и Rust, необходимо использовать обёртку для или .

Пример объекта, выделенного Python и используемого только из 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 и 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 shared_object() -> ObjectWrapper {
    OBJECT.with(|o| o.clone())
}

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

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

В 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 в Python для объектов.

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


Структура проекта сильно влияет на продуктивность и удобство работы с кодом при использовании PyO3. Обычно, изучив для начала руководство по PyO3, вы создаёте проект, в котором Rust-код тесно связан с Python-аннотациями — из-за этого код невозможно скомпилировать без установленного Python и протестировать без инициализации Python-рантайма.

В принципе, у вас есть несколько подходов к структурированию кода:
  • использовать пространства имён и фиче-флаги, помещая код, связанный с Python, за флагами (этот подход реализован в Similari);
  • с отдельными крейтами, объединенными в одно рабочее пространство (Savant-RS, RocksQ).
Изначально я придерживался первого подхода, но сейчас считаю, что второй намного лучше.

image

Итак, как видите, у меня есть два пакета Rust (крейта): в queue_rs я реализовал логику и функциональность; он ничего не знает о Python. Во втором есть только код, связанный с Python.

Я считаю, что такая структура проекта — наилучшая, и на то есть множество причин. Давайте их рассмотрим.

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

Зависимость от PyO3 увеличивает время компиляции и зачастую делает невозможным написание тестов без инициализации Python-окружения.

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

Поэтому я пишу код с учётом особенностей модели памяти, но без использования Python-специфичных типов и зависимости от PyO3.

Мои бенчмарки работают только с кодом на Rust, так что я точно знаю — они измеряют эффективность оптимизированного кода, а не накладные расходы.

Однажды это помогло мне обнаружить, что передача в Rust сильно снижает производительность, поэтому в коде, связанном с PyO3, я начал использовать PyBytes.

Продуктивность при работе с кодом, связанным с Python. Когда я занимаюсь Python-составляющей, мне нужно реализовывать только преобразование между типами Python, а не основную логику. Время компиляции сокращается, потому что логическая часть остаётся неизменной.
Особенно компиляция раздражает при работе над документацией — я часто меняю аннотации и пересобираю код. Мои наблюдения по сборке документации для 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. Это стало проблемой. В результате Андрей Ткаченко предложил большой pull request, в котором функциональность, связанная с Python, была вынесена за фиче-флаг.

Обертки для объектов 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 (struct) в своих *_py проектах — только кортежные структуры. Ранее я писал что-то вроде:

struct Object {
    inner: rust::Object
}

Однако сейчас я вижу и недостатки такого подхода:
  • Иногда я использую inner, иногда — object, internal и т.д. Это вносит хаос.
  • Код выглядит менее однородным, ведь при использовании кортежных структур всегда приходится обращаться через .0.
  • С такими структурами легко (и небезопасно) применять std::mem::transmute ?.
  • Вы можете использовать простую обертку для опций:
#[pyfunction]
fn callme() -> Option<Object> {
    if ... {
       None
    } else {
       let res_opt = Some(rust::Object);
       res_opt.map(Object) // here it is    
    }
}

Магия transmute. В Rust макет памяти для типов X и Y(X) совпадает, поэтому можно использовать transmute для безопасного преобразования кортежных структур Python в кортежные структуры Rust без выделения новой памяти. Особенно тогда, когда нужно конвертировать между Rust-объектом и Python-объектом.

Подробнее об этом читайте в справочнике Unsafe Code Guidelines Reference.

Не поймите меня неправильно, я не пропагандирую 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) };

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


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

// 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. Я еще не решил.

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


В настоящее время они не поддерживаются в 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 (в основном я так или иначе использую его) в 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)))
}

// у вас в py
// try:
//     u8_overflow_plus(255, 255)
// кроме ValueError:
//     # невозможно

Я очень часто использую Result в своем коде. Реализация идеальна: вы можете использовать модель кодирования Rust, которая прозрачно отображает исключения в Python. Я нахожу это удивительным: если он возвращает Ok(smth), то получается обычное значение Python; если он возвращает Err — код 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> {
    ...
}

В функциях:

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

// может быть вызвано в python
// inc()
// inc(1)
// inc(i=10)
// inc(x=1, i=10)
// inc(1, 10)

В методах (параметр self нужно игнорировать):

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

Создание объектов


Обычно вы помечаете метод символом #[new], чтобы указать, что он используется как __init__ в Python. Однако при создании перечислений я обычно использую другой подход. Давайте вспомним наш перечислитель 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. Эмпирическим путем я обнаружил, что код обычно становится медленнее, когда функция завершается быстрее, чем за 1000ns, и я освобождаю 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()
        }
    })
}

Сторона, вызывающая код может решить, освобождать его или нет, передав no_gil=True или False.

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

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

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

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

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

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



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

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

Он медленный, когда речь идет о передаче больших байтовых буферов. Он обеспечивает гибкость: вы можете передавать байты Python или [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)))
}

Версионирование библиотеки


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

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

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

Пакеты 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() {
    // Здесь будет использоваться логгер, установленный в `my_module` — он будет отправлять сообщение `info`
    // в логирующие механизмы Python.
    info!("Something!");
}

#[pymodule]
fn my_module(_py: Python<'_>, m: &PyModule) -> PyResult<()> {
    // Именно здесь удобно установить логгер, действующий в направлении Rust -> Python 
    pyo3_log::init();

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

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

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

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


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

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

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

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

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


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

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

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

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

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


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

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

Моя цель — предоставить API для доступа к отдельным AttributeValue с синтаксисом [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.

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

Демо проект


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

Заключение


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

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