Hoe bibliotheken Rust uitvoeren in Python (met PyO3)

Dit artikel bouwt een vergelijkbare brug, klein genoeg om in één keer te lezen: een JSON-parser geschreven in Rust, beschikbaar gesteld aan Python, zodat je deze kunt importeren als elk ander pakket. De laatste stap — het omzetten van het Rust-resultaat naar Python-objecten — is het belangrijkste punt om te begrijpen voordat je iets poort: voor een parser als deze kan dit meer kosten dan het parsen zelf.

De vier stappen van Rust naar import

Om Rust-code in Python te krijgen, zijn vier stappen nodig:

  1. Schrijf een normale Rust-module.
  2. Annoteer deze met PyO3-macro's.
  3. Laat maturin deze compileren en installeren.
  4. Importeer het resultaat.

#[pyfunction] en #[pymodule] zijn de twee Rust-macro's die de koppeling verzorgen. Een Rust attribute macro lijkt op een Python-decorator: het herschrijft de functie waar het boven staat, in dit geval door de "lijm" toe te voegen die Python in staat stelt de functie aan te roepen en die de typeconversies en referentie-tellingen (reference counting) op de grens beheert.

Maturin compileert vervolgens de crate naar een gedeelde bibliotheek (.so, .dylib, .dll) en plaatst deze in je virtuele omgeving, waardoor de import gewoon werkt.

Waar een basis-tutorial vaak stopt bij het teruggeven van een enkel getal, begint het interessante deel zodra je een structuur teruggeeft in plaats van een scalaire waarde.

De parser produceert eerst een Rust-waarde

De structuur die deze parser teruggeeft is een JSON-boom. In een "Python to Rust"-cohort schrijven studenten zes weken lang een JSON-parser vanaf nul in Rust — een handmatige tokenizer en een recursive-descent parser zonder serde — om deze vervolgens via PyO3 aan Python bloot te stellen. Sommige versies versloegen de C-gebaseerde json-module van CPython op real-world fixtures; andere waren tot 3,5 keer sneller dan de Python-versie.

De parser produceert een eenvoudige Rust-enum. Een Rust-enum kan een van verschillende vormen aannemen en elke variant kan data bevatten, wat een JSON-boom zuiver in kaart brengt:

pub enum JsonValue {
    Null,
    Boolean(bool),
    Number(f64),
    String(String),
    Array(Vec<JsonValue>),
    Object(HashMap<String, JsonValue>),
}

Deze boom bevindt zich volledig in Rust. Python ziet deze nooit; de PyO3-laag fungeert als een dunne adapter daarop.

Eén functie beschikbaar stellen

Het beschikbaar stellen van een functie aan Python kost twee regels:

#[pyfunction]
fn parse_json<'py>(py: Python<'py>, input: &str) -> PyResult<Bound<'py, PyAny>> {
    parse(input)?.into_pyobject(py)
}

Voor een Python-lezer is de signatuur het meest interessant:

  • py: Python<'py> is een token dat toegang geeft tot de Python-interpreter. Dit wordt doorgegeven aan PyO3-API's die toegang nodig hebben tot Python-objecten. Bij traditionele Python-builds is deze toegang gekoppeld aan het vasthouden van de GIL (Global Interpreter Lock).
  • Bound<'py, PyAny> is een handle naar een Python-object van elk type; de Rust-zijde van wat je zou beschouwen als een PyObject.
  • PyResult<T> is Result<T, PyErr>: het geeft de waarde terug, of een fout die PyO3 opwerpt als een Python-exceptie.
  • De ? propageert die fout. Als parse faalt, stopt de functie vroegtijdig en ziet Python een exceptie; anders wordt de JsonValue uitgepakt en gaat het proces verder.

parse(input)? doet het eigenlijke werk, en .into_pyobject(py) bouwt de Python-objecten waar de aanroeper om vroeg. Deze laatste aanroep is waar de kosten zitten: er moeten Python-objecten worden aangemaakt voor de knooppunten in de boom, en bij een groot document kan dat meer werk kosten dan het parsen zelf.

De terugreis is het duurste onderdeel

De conversie is niet gratis. .into_pyobject doorloopt de volledige JsonValue-boom en bouwt deze opnieuw op als native Python-objecten: een dict per object, een list per array, en een float of str per blad. Deze vertaling wordt verzorgd door de IntoPyObject-trait te implementeren, die PyO3 aanroept om een Rust-waarde om te zetten naar een Python-waarde:

impl<'py> IntoPyObject<'py> for JsonValue {
    fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
        match self {
            JsonValue::Null => Ok(py.None().into_bound(py)),
            JsonValue::Number(n) => Ok(n.into_pyobject(py)?.to_owned().into_any()),
            JsonValue::Object(obj) => {
                let py_dict = PyDict::new(py);
                for (k, v) in obj {
                    py_dict.set_item(k, v.into_pyobject(py)?)?;  // recursie
                }
                Ok(py_dict.into_any())
            }
            // ... arrays, strings, booleans
        }
    }
}

Een document met 100.000 waarden betekent dat er ongeveer 100.000 Python-objecten worden aangemaakt op de grens, allemaal nadat het parsen volledig is afgerond. Bij een groot document kan deze materialisatie-loop, en niet het parsen, de totale tijd domineren.

Fouten passeren de grens op dezelfde manier

Niet alleen de retourwaarde moet worden vertaald. Een parse-fout is een getypeerde Rust-error, terwijl Python een exceptie verwacht. Een From-implementatie (de trait die Rust gebruikt om het ene type in het andere om te zetten) laat de ? het werk doen:

impl From<JsonError> for PyErr {
    fn from(err: JsonError) -> PyErr {
        match err {
            JsonError::UnterminatedString { position } => PyValueError::new_err(
                format!("Unterminated string starting at position {position}")
            ),
            // ... één arm per error variant, waarbij de positie behouden blijft
        }
    }
}

Nu veroorzaakt ongeldige input een ValueError waarin de offset staat waar het parsen stopte. Het pad voor het lezen van bestanden krijgt dezelfde behandeling automatisch: std::io::Error converteert al naar de bijbehorende Python-exceptie, waardoor een ontbrekend pad een FileNotFoundError veroorzaakt.

De aanroeper krijgt Python-semantiek zonder dat de Rust-laag doorsijpelt.

Wat dit betekent voor je eigen port

Als de Rust-functie die je poort een scalaire waarde teruggeeft, kun je dit doen en doorgaan. De grens is meestal klein genoeg om te negeren.

Als de functie een grote structuur teruggeeft, is de conversie je werkelijke kostenpost. Dit is het volgende punt om te optimaliseren zodra de parser zelf snel genoeg is. Het vooraf alloceren van de PyDict kan aan de marges helpen, maar de grootste winst is architecturaal: materialiseer niet de hele boom als de aanroeper niet alle delen nodig heeft. Geef een lazy, door Rust ondersteunde weergave terug en bouw Python-objecten alleen op aanvraag.

Wanneer je PyO3 gebruikt, moet je dus de grens profileren, niet alleen het algoritme. Rust snel laten draaien is het makkelijke halve werk. Wat je bouwt bij de uitgang — de reis van Rust-waarden naar Python-objecten — is het halve werk dat bepaalt of de port de moeite waard was.