Ressources :
Objectifs :
L'intérêt est d'offrir les performances du C mais la sûreté mémoire de Rust, au sein de l'interpréteur Python.
Attention : il n'y a pas ici d'usage de :
PyO3 qui est le binding de Python en Rust. Nb : une partie de ce qui est indiquée ici, fonctionne pour l'interfaçage vers Lua par exemple.
J'utilise volontaire le terme “étendre” et non pas seulement le rendre plus efficace, même si c'est régulièrement ce qui est présenté comme argument fondamental.
Extrait de l'article RedHat cité plus haut :
Pourquoi est-ce important pour un développeur Python?
Elias (un membre du Rust Brazil Telegram Group) a mieux décrit Rust.
Rust est un langage qui vous permet de créer des abstractions de haut niveau, mais sans renoncer à un contrôle de bas niveau - c'est-à-dire le contrôle de la façon dont les données sont représentées en mémoire, le contrôle du modèle de thread que vous souhaitez utiliser, etc.
Rust est un langage qui peut généralement détecter, lors de la compilation, les pires erreurs de parallélisme et de gestion de la mémoire (telles que l'accès aux données sur différents threads sans synchronisation, ou l'utilisation de données après leur désallocation), mais vous donne une échappée dans le cas où vous sais vraiment ce que tu fais.
Rust est un langage qui, parce qu'il n'a pas de runtime, peut être utilisé pour s'intégrer à n'importe quel runtime; vous pouvez écrire une extension native dans Rust qui est appelée par un programme node.js, ou par un programme python, ou par un programme en ruby, lua etc. et, cependant, vous pouvez écrire un programme dans Rust en utilisant ces langages. - «Elias Gabriel Amaral da Silva»
Il existe un tas de packages Rust pour vous aider à étendre Python avec Rust.
Cette étendue profite à la fois à Python évidemment, mais aussi à Rust comme nous le verrons finalement : nous pouvons nous offrir le luxe d'avoir par exemple des lambdas générés durant l'exécution renvoyées à Rust, sans l'effort de passer par des outils lourd de compilation JIT - ou simplement profiter des bibliothèques déjà disponibles dans Python (hash, XML, etc.).
En Python, le langage est en soi seul. Cependant son implémentation standard, CPython, nous autorise deux autres langages : TCL via le module Tkinter (même si son usage est déconseillé par ce biais) et la manipulation de code C durant l'exécution via le module ctypes.
Cette manipulation de code C se réalise en réalité au travers du code compilé, et non au travers du langage lui-même (on “écrit” pas un code source en C, on manipule des données compilées via Python).
En soi la représentation de l'information et du traitement se font sur la base de la représentation d'un code compilé C conforme (format pivot).
Le “défi” est finalement assez simple :
Il reste “un piège” cependant : la protection de la possession et du partage mutable (exclusif) ou non-mutable (non-exclusif) en Rust, qui travaille avec des références forcément constitante en mémoire. Plusieurs stratégies sont offertes :
Rust permet lors de la compilation, de ne pas profiter de certaines de ses améliorations (notamment l'organisation des structures), au profit d'un code machine conforme à ce que produirait un code C compilé par exemple avec GCC (voir ''ABI-C''). L'opération est permise grâce à l'attribut no_mangle (aucune mutilation).
Aucune “caisse” n'est nécessaire : le compilateur se charge de tout. Seul le fichier Cargo.toml sera modifié dans notre cas.
Ainsi cette fonction sera parfaitement conforme une fois compilée :
#[no_mangle] pub extern "C" fn hello_rust() -> *const u8 { "Hello, world!\0".as_ptr() }
Là encore, l'opération est facile car l'interpréteur standard est écrit en C et offre un module à disposition.
Ce code :
import ctypes class Bidule(ctypes.Structure): # classe d'un type particulier _fields_ = [ ("i", ctypes.c_int) ] type_bidule = ctypes.POINTER(Bidule) b = Bidule( i = ctypes.c_int(5) ) addr = ctypes.addressof(b) ptr = ctypes.cast(addr, type_bidule) print( "addresse :", addr, type(addr) ) print( "pointeur :", ptr ) print( "valeur avant (pointeur) :", ptr[0].i) print( "valeur avant (variable python): ", b.i ) ptr[0].i += 1 print( "valeur après (pointeur): ", ptr[0].i) print( "valeur après (variable python): ", b.i )
… devrait vous retourner quelque chose comme :
julien@JulienGPortable:~/Développement/libpy$ python3 ./pointeur.py addresse : 140262630272784 <class 'int'> pointeur : <__main__.LP_Bidule object at 0x7f917040a6c0> valeur avant (pointeur) : 5 valeur avant (variable python): 5 valeur après (pointeur): 6 valeur après (variable python): 6
Il y a bien une “transparence” entre l'adresse retournée, la création d'un pointeur manipulée en C, et la variable manipulée en Python.
Trois étapes :
cargo (ici libpy), éditer le fichier TOML ; lib.rs qui servira de support à notre code Rust appelé dans Python. Editer dans le même temps notre script Python ;
Concernant le fichier TOML justement, il doit ressembler finalement à ceci (cdylib : bibliothèque dynamique C) :
[package] name = "libpy" version = "0.1.0" authors = ["julien <julien.garderon@gmail.com>"] edition = "2018" [dependencies] [lib] name = "py" crate-type = ["cdylib"]
Nous allons dabord afficher un simple message. Votre script Python sera enregistré à la racine du projet Cargo :
import ctypes # (1) importer votre lib au sein de l'interpréteur malib = ctypes.CDLL("./target/release/libpy.so") # (2) faire appel directement à la fonction voulu # -> on y envoi malib.afficher( "bonjour - éèà".encode( "utf-8" ) )
use std::ffi::CStr; use std::os::raw::c_char; #[no_mangle] pub extern fn afficher( s: *const c_char) { unsafe { println!( "votre message : {:?}", CStr::from_ptr( s ).to_str().unwrap().to_string() ); } }
Notez l'usage du bloc unsafe (obligatoire), afin de manipuler le pointeur incertain qui nous est “transmis” par Python. Celui passe d'abord dans une fonction nous permettant de produire possible (d'où unwrap) un &str, lui-même transformé en String (purement décoratif dans notre cas).
Nous pouvons compiler et lancer l'exécution de l'interpréteur, le résultat sera conforme aux attentes (notamment la gestion des caractères accentués) :
julien@JulienGPortable:~/Développement/libpy$ cargo build --release && python3 ./test.py Finished release [optimized] target(s) in 0.02s votre message : "bonjour - éèà"
Même code que précédemment, cette fois-ci en passant un int et en retourner un.
Cela nécessite désormais de :
Pour Python, nous allons éditer l'objet qui gère la fonction importée. En effet “malib.afficher” est un objet appelable, mais pas une fonction.
Son édition est facile, et on en profitera pour la renommer de manière plus courte :
import ctypes malib = ctypes.CDLL("./target/release/libpy.so") fct_afficher = malib.afficher fct_afficher.argtypes = [ ctypes.c_char_p, ctypes.c_int ] fct_afficher.restype = ctypes.c_int; r = fct_afficher( "bonjour - éèà".encode( "utf-8" ), 42 ) print("le retour est :", r)
Concernant Rust, j'ai modifié ma fonction, qui gère désormais un retour permettant de déterminer si une erreur est survenue lors de la gestion du pointeur (0 = OK ; -1 = KO) :
use std::ffi::CStr; use std::os::raw::c_char; use std::os::raw::c_int; # on a besoin de gérer des int désormais #[no_mangle] pub extern fn afficher( s: *const c_char, i: *const c_int ) -> i8 { unsafe { match CStr::from_ptr(s).to_str() { Ok( texte ) => println!( "[{}] votre message : {:?}", i as i8, texte ), Err( _ ) => return -1 } 0 } }
La compilation et l'exécution se déroulent sans encombre :
julien@JulienGPortable:~/Développement/libpy$ cargo build --release && python3 ./test-2.py Finished release [optimized] target(s) in 0.02s [42] votre message : "bonjour - éèà" le retour est : 0
Le principe n'est pas fondamentalement différent de ce que l'on a vu jusqu'à présent. La seule différence tient au type de classe dans Python : il utilisera, là encore, une fonctionnalité de ctypes.
import ctypes malib = ctypes.CDLL("./target/release/libpy.so") class Bidule(ctypes.Structure): # la classe _fields_ = [ ("i", ctypes.c_int) ] fct_afficher = malib.afficher fct_afficher.argtypes = [ ctypes.c_char_p, ctypes.c_int # l'objet appelable n'acceptera pas un "Bidule" ] fct_afficher.restype = ctypes.c_int; r = fct_afficher( "bonjour - éèà".encode( "utf-8" ), Bidule( i = 42 ) # transformation déjà gérée lors de la déclaration de la classe ) print("le retour est :", r)
Dès à présent, compilons voir ce que ça donne : Python se plaint ! Pourtant il est d'usage (à raison) de dire que le langage n'est pas fondamentalement typé… sauf que c'est vrai si l'on reste hors de ctypes. Dans le cas présent, le type est fondamental.
Aucun risque donc - ouf ! -, d'envoyer “par erreur” un mauvais type à Rust :
julien@JulienGPortable:~/Développement/libpy$ cargo build --release && python3 ./test-2.py Finished release [optimized] target(s) in 0.00s Traceback (most recent call last): File "./test-2.py", line 17, in <module> r = fct_afficher( ctypes.ArgumentError: argument 2: <class 'TypeError'>: wrong type
Modifiez en conséquence :
fct_afficher.argtypes = [ ctypes.c_char_p, ctypes.POINTER( Bidule ) ]
Au sein de Rust, il faudra déclarer Bidule, et gérer aussi ce nouvel objet, sans complexité particulière :
use std::ffi::CStr; use std::os::raw::c_char; use std::os::raw::c_int; #[no_mangle] // important : votre structure doit être conforme au C #[derive(Debug)] pub struct Bidule { pub i: c_int } #[no_mangle] pub extern fn afficher( s: *const c_char, b: *const Bidule ) -> i8 { unsafe { let b: &Bidule = match b.as_ref() { // je veux une référence non-mutable Some( b ) => b, None => return -1 }; match CStr::from_ptr(s).to_str() { Ok( texte ) => println!( "[{:?}] votre message : {:?}", b, texte ), Err( _ ) => return -1 } 0 } }
Enfin :
julien@JulienGPortable:~/Développement/libpy$ cargo build --release && python3 ./test-2.py Compiling libpy v0.1.0 (/home/julien/Développement/libpy) Finished release [optimized] target(s) in 0.21s [Bidule { i: 42 }] votre message : "bonjour - éèà" le retour est : 0
Pour aller plus loin, imaginons agir sur l'objet “Bidule” échangé entre eux. Il suffit de “caster” en Rust, notre pointeur en référence mutable, modifier la signature de la fonction. Au sein de Python, rajoutons un affichage de plus :
import ctypes malib = ctypes.CDLL("./target/release/libpy.so") class Bidule(ctypes.Structure): _fields_ = [ ("i", ctypes.c_int) ] fct_afficher = malib.afficher fct_afficher.argtypes = [ ctypes.c_char_p, ctypes.POINTER( Bidule ) ] fct_afficher.restype = ctypes.c_int; b = Bidule( i = 42 ) r = fct_afficher( "bonjour - éèà".encode( "utf-8" ), b ) print("le retour est :", r) print("Bidule.i =", b.i)
use std::ffi::CStr; use std::os::raw::c_char; use std::os::raw::c_int; #[no_mangle] #[derive(Debug)] pub struct Bidule { pub i: c_int } #[no_mangle] pub extern fn afficher( s: *const c_char, b: *mut Bidule ) -> i8 { // 'mut' au lieu de 'const' unsafe { let mut b: &mut Bidule = match b.as_mut() { // &mut Bidule Some( b ) => b, None => return -1 }; b.i += 1; match CStr::from_ptr(s).to_str() { Ok( texte ) => println!( "[{:?}] votre message : {:?}", b, texte ), Err( _ ) => return -1 } 0 } }
Enfin, le résultat tant attendu se confirme :
julien@JulienGPortable:~/Développement/libpy$ cargo build --release && python3 ./test-2.py Finished release [optimized] target(s) in 0.02s [Bidule { i: 43 }] votre message : "bonjour - éèà" le retour est : 0 Bidule.i = 43
La simili-fonction “fct_afficher” dans Python, se comporte avec Rust de manière transparente.
Nous allons mettre en oeuvre une fonction gérée par Python, et l'exécuter en Rust.
import ctypes malib = ctypes.CDLL("./target/release/libpy.so") # définition de classe (en réalité d'une structure) class Bidule(ctypes.Structure): _fields_ = [ ("i", ctypes.c_int) ] # mise en oeuvre des wrappers pour la fonction envoyée à Rust # ainsi que la fonction qui va gérer l'exécution côté Rust rappel_type = ctypes.CFUNCTYPE( ctypes.c_int, ctypes.POINTER( Bidule ) ) def recevoir( obj_type ): def _sup_fct( fct ): @rappel_type # un décorateur dans un autre, c'est possible ! def _fct( adresse ): # ici je reçois le pointeur que Rust a reçu objet = ctypes.cast( adresse, ctypes.POINTER( obj_type ) )[0] return fct( objet ) return _fct return _sup_fct executer_rust = malib.executer executer_rust.argtypes = [ ctypes.POINTER( Bidule ),] executer_rust.restype = ctypes.c_int; # cette fonction sera exécutée "côté Rust" @recevoir( Bidule ) def tester( objet ): if objet.i > 10: objet.i = 0 return 1 else: return 0 # le reste... fct_afficher = malib.afficher fct_afficher.argtypes = [ ctypes.c_char_p, ctypes.POINTER( Bidule ) ] fct_afficher.restype = ctypes.c_int; b = Bidule( i = 42 ) executer_rust( b, tester ) # 'i' va passer à 0 car i > 10 r = fct_afficher( "bonjour - éèà".encode( "utf-8" ), b ) print("le retour est :", r) print("Bidule.i =", b.i)
use std::ffi::CStr; use std::os::raw::c_char; use std::os::raw::c_int; #[no_mangle] #[derive(Debug)] pub struct Bidule { pub i: c_int } // la définition d'un type facilite la maintenance et la lisibilité type FctRappel = fn( *mut Bidule ) -> i8; #[no_mangle] pub extern fn executer( b: *mut Bidule, fctrappel: FctRappel ) -> i8 { println!("Rust exécute quelque chose..."); // j'aurai pu ici agir sur mon bidule fctrappel( b ) // ou ici, et renvoyer un i8 } #[no_mangle] pub extern fn afficher( s: *const c_char, b: *mut Bidule ) -> i8 { unsafe { let mut b: &mut Bidule = match b.as_mut() { Some( b ) => b, None => return -1 }; b.i += 1; match CStr::from_ptr(s).to_str() { Ok( texte ) => println!( "[{:?}] votre message : {:?}", b, texte ), Err( _ ) => return -1 } 0 } }
Ma fonction “tester” remet à 0 si le 'i' de mon bidule est supérieur à 10, ce qui est le cas ici. Mon résultat de console est parfaitement conforme :
julien@JulienGPortable:~/Développement/libpy$ cargo build --release && python3 ./test-2.py Finished release [optimized] target(s) in 0.02s Rust exécute quelque chose... [Bidule { i: 1 }] votre message : "bonjour - éèà" le retour est : 0 Bidule.i = 1
La partie sur Python est la plus complexe. La génération d'une fonction ne pose pas de problème, mais il faut la transformer grâce à ctypes.CFUNCTYPE, qui se comporte comme un décorateur. J'associe ce décorateur à un autre, recevoir qui va permettre à Python de récupérer le pointeur qui a voyagé côté Rust, afin qu'il soit à nouveau un objet Python classique. Évidemment le fonctionnement réel est différent et il n'y a pas deux espaces séparés…
Nous avons donc envoyer à Rust quelque chose qu'il connaît (la définition d'un type FctRappel).
L'intérêt d'envoyer des fonctions à Rust, est de pouvoir construire des fonctions de rappel en plus du retour, que Rust peut gérer en fonction comme une autre.
C'est au développeur de bien connaître, côté Rust ou Python, en assurant le clonage d'une structure, si le pointeur peut être menacé côté Python.
Les énumérations peuvent être remplacées côté Python par des set, avec une correspondance côté Rust pour un usage facilité dans les match.