Outils pour utilisateurs

Outils du site


python:rust_python

Rust et Python associés grâce au C

Ressources :

Objectifs :

  1. faire correspondre des types et des structures entre Python et Rust ;
  2. envoyer l'un à l'autre de manière indifférenciée ;
  3. exécuter une fonction créée en Python dans un contexte Rust (fonction ou lambda, générée lors de l'exécution).

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 :

  • cpython comme extern crate comme présenté ici (la méthode certainement la plus efficace, mais pas forcément la plus simple) ;
  • ou la caisse externe 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.

Pourquoi étendre Python avec Rust ?

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.).

Explications générales...

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 :

  • transformer une variable ou une fonction Python en sa représentation C vers Rust
  • … ou l'inverse.

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 :

  • le cas (fréquent) d'une variable “supprimée” par l'interpréteur Python ne se présente pas ;
  • le cas se présente et il faudra soit “cloner” la donnée et laisser le clone être géré par Rust…
  • … ou enfin gérer soi-même la mémoire du clone (non-présenté ici, car déprécié ici).

... de Rust vers du C

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()
}

... de Python vers du C

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.

Mise en oeuvre triviale

Trois étapes :

  1. créer un nouveau projet avec cargo (ici libpy), éditer le fichier TOML ;
  2. éditer le fichier lib.rs qui servira de support à notre code Rust appelé dans Python. Editer dans le même temps notre script Python ;
  3. compiler et exécuter notre code.

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"]

Première étape : 'Hello World'

Nous allons dabord afficher un simple message. Votre script Python sera enregistré à la racine du projet Cargo :

test.py
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" ) 
) 
lib.rs
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 - éèà"

Seconde étape : 'Hello World + 1 = 1'

Même code que précédemment, cette fois-ci en passant un int et en retourner un.

Cela nécessite désormais de :

  • définir précisément les arguments attendus et le retour, en type, afin que Python fasse la transformation attendu dans le bon format (“int” n'existe pas directement, seulement l'équivalent d'un “numérique”) ;
  • recevoir et renvoyer les bonnes valeurs pour Rust.

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 :

test.py
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) :

lib.rs
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

Troisième étape : les objets en Python

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.

test.py
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 :

lib.rs
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 :

test.py
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) 
lib.rs
 
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.

Mise en oeuvre avancée

Le code source

Nous allons mettre en oeuvre une fonction gérée par Python, et l'exécuter en Rust.

test.py
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) 
lib.rs
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

Commentaires

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.

Cas des threads et des énumérations ?

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.

python/rust_python.txt · Dernière modification: 2020/05/10 18:30 de julieng

Outils de la page