Quantcast
Channel: AFPy's Planet
Viewing all articles
Browse latest Browse all 3409

cTypes + Rust = approfondir une relation d'amour et d'eau (fraîche)

$
0
0

Sommaire

nb : dans cet article, je n'évoque que l'interpréteur officiel, CPython (3.4+) et l'usage de modules standard à l'interpréteur (donc pas d'exemples de cffi, quel qu’en soient les qualités par ailleurs !).

Introduction

Ce week-end, j'ai fait une énième recherche sur une bricole pour la communication Python et Rust via cTypes. Sur ces "détails" qu'on oublie aussi vite qu'on se casse les dents dessus lorsqu'on ne pratique pas.

Comme ma mémoire est encore plus limitée que le nombre d'onglets et de marques-page sur Firefox, je me suis dit que la prochaine fois autant tomber directement sur un article francophone qui fait un résumé global. Et que ça peut en aider d'autres.

Bien sûr il existe des bibliothèques toutes prêtes pour faciliter la communication entre ces deux mondes :

  • soit pour interagir avec l'interpréteur Python,
  • soit "en ramenant" Python au sein d'un applicatif.

Aujourd'hui la bibliothèque Pyo3 est probablement la plus aboutie et la mieux supportée ; j'aime bien en faire la pub car je la trouve géniale - et par extension à deux langages que j'apprécie.

Lorsque j'évoque la possibilité de "ramener" Python, c'est avoir non seulement la possibilité d'évaluer une expression mais aussi l'ensemble d'un script. Comme pour Lua sur les consoles de jeux vidéos, cela ouvre des possibilités infinies (même si parfois complexes) pour étendre votre applicatif actuel à de nouvelles sources de données ou possibilité de programmation.

Bref : c'est bien, c'est bon, mangez-en. [fin de la minute pub]

Du reste si cette possibilité semble la plus intéressante, elle s'avère complexe à mettre en œuvre si votre interpréteur Python est dans un ensemble plus large, avec beaucoup de code C ou C++ par exemple, et qu'il s'agit de rentrer dans les pas de versions précédentes ou du code partagé entre plusieurs applicatifs (dont tous ne seraient pas du Python) ; n'est-ce pas le principe des .so après tout ? La bascule de C/C++ à Rust pourrait déclencher des problématiques si vous restez sur le seul usage de Pyo3.

Mais rien n'est insurmontable, surtout pour le serpent : cTypes est votre amie.

Présente par défaut depuis les temps immémoriaux dans l'interpréteur standard, cette bibliothèque vous permet de consommer des .so (assez) facilement avec - c'est assez rare pour être souligné -, un respect strict du typage lors de l'appel de fonction. En effet, vous quittez alors le monde merveilleux (et lâche) de l'interprété, pour le terrible et cruel (et implacable) monde du C, où tout doit être connu (si possible à l'avance).

Voici un pense-bête rédigé pour s'en sortir.

Préparer l'environnement

Dans votre console préférée, créez un projet de bibliothèque :

julien@julien-Vostro-7580:~/Developpement/$ cargo new --lib rust-python
Created library `rust-python` package

Puis éditez comme suit votre fichier Cargo.toml :

[package]name="rust-python"version="0.1.0"edition="2021"[lib]name="rust_python"crate-type=["cdylib"][dependencies]

A la racine de votre projet, créez également le fichier test-ffi.py avec le contenu suivant :

#!/usr/bin/env python3fromctypesimport*malib=cdll.LoadLibrary("target/release/librust_python.so")

A partir de cet instant, malib correspond à un objet permettant d'accéder au contenu de la bibliothèque. Lors d'un appel de fonction à celle-ci, vous pouvez indiquer les types de paramètres et du retour directement dans le code Python.

Par exemple le code suivant précise pour la fonction test_string les types attendus :

malib.test_string.argtypes=[c_char_p,]# arguments d'appel attendus, ici un seul malib.test_string.restype=c_char_p# type du retour resultat=malib.test_string(message.encode())# appel de la fonction partagée, avec la récupération du résultat 

Enfin ajoutez le nécessaire dans les sources Rust (lib.rs) :

usestd::ffi::CStr;usestd::ffi::CString;usestd::os::raw::c_char;usestd::os::raw::c_int;

Notez que les chemins sont relatifs à votre compilation Rust :

  • target/debug pour cargo build ;
  • target/release pour cargo build --release.

A partir de là, vous pourrez ajouter les blocs de code les uns à la suite des autres et les tester avec :

  • cargo build && ./test-ffi.py (compilation plus rapide, message d'erreur plus complet mais moins efficace à l'usage)
  • cargo build --release && ./test-ffi.py (compilation moins rapide mais plus efficace à l'usage)

Morceaux choisis

nb : il existe déjà des tutoriaux sur les types simples, tels que les entiers. Je ne les mets pas directement en exemple ici. De même il y a de nombreux autres cas généraux que je l'indique pas ; cependant les exemples fournis ici me semble-t-il, permettent de s'en sortir !

Partie 1 - Les chaînes de caractères

Quelques ressources pour approfondir :

Côté Rust :

#[no_mangle]pubunsafeextern"C"fntest_string(ptr_source: *mutc_char)->*constc_char{// je récupère en argument un pointer vers un type 'c_char' // je dois d'abord en faire un CStr et grâce à "to_string_lossy" // toutes les valeurs non-conformes UTF-8, seront éliminées (remplacées précisément) // puis j'assigne à 'v' le résultat (une chaîne de caractère) letv=CStr::from_ptr(ptr_source).to_string_lossy().to_string();println!("[RUST] -( 1 )-> {:?}",ptr_source);println!("[RUST] -( 2 )-> {:?}",v);// pour renvoyer ma chaîne, je dois modifier son type pour être conforme // (par exemple : ajouter "\0" à la fin, car on perd la taille fixée en c_char) // ainsi que d'obtenir le pointeur associée lets=CString::new(v).unwrap();letp=s.as_ptr();// au regard de Rust, 's' est ma chaîne c_char et 'p' est le pointeur // si "je n'oublie pas" 's' avant de quitter la fonction, Rust va désallouer la mémoire// le pointeur 'p' renvoyé serait donc invalide // 'std::mem::forget' nous permet de forcer cet "oubli de désallocation" lors de la compilation std::mem::forget(s);p}

Côté Python :

print("--------------------")print("--- partie 1 - chaînes de caractère seules (UTF8)")print("--------------------")message="&é\"'(-è_çà)"print("--- partie 1.1 - sans précision sur le type d'argument")malib.test_string.restype=c_char_presultat=malib.test_string(# n'ayant pas indiqué le type d'argument attendu, je dois faire une transformation # moi-même. Attention cependant, il est toujours préférable d'indiquer le bon type c_char_p(bytes(message,"utf-8")))print("--- partie 1.2 - avec précision sur le type d'argument")malib.test_string.argtypes=[c_char_p,]# ici la précision du type malib.test_string.restype=c_char_presultat=malib.test_string(message.encode())# par défaut, ".encode()" est en UTF-8print("[PYTHON] ===>",resultat.decode())

Résultat :

julien@julien-Vostro-7580:~/Developpement/rust-python$ cargo build && ./test-ffi.py 
    Finished dev [unoptimized + debuginfo] target(s) in 0.00s
--------------------
--- partie 1 - chaînes de caractère seules (UTF8)
--------------------
--- partie 1.1 - sans précision sur le type d'argument[RUST] -( 1 )-> 0x7f67723d3e90[RUST] -( 2 )-> "&é\"'(-è_çà)"--- partie 1.2 - avec précision sur le type d'argument[RUST] -( 1 )-> 0x7f67723d3e90[RUST] -( 2 )-> "&é\"'(-è_çà)"[PYTHON] ===>&é"'(-è_çà)

Facile.

Partie 2 - Les structures

Côté Rust :

#[repr(C)]#[derive(Debug)]pubstructMonObjet{puba: c_int}#[no_mangle]pubunsafeextern"C"fntest_structure(mutmonobj: MonObjet){// du fait que l'aligment se fait sur le C, on peut directement récupérer l'objet println!("[RUST] -(3)-> {:?}",monobj);// et comme on l'a déclaré "mut(able)" alors on peut agir dessus ; voir ci-après (1)monobj.a+=1;println!("[RUST] -(4)-> {:?}",monobj);}#[no_mangle]pubunsafeextern"C"fntest_structure_ref(ptr_monobj: *mutMonObjet){// le format '&mut *' semble étrange mais est parfaitement valide. On déréférence d'abord le pointeur, puis on créé un emprunt (mutable) au format Rust pour agir dessus ; voir ci-après (2)letmonobj=&mut*ptr_monobj;println!("[RUST] -(3)-> {:?}",monobj);monobj.a=3;println!("[RUST] -(4)-> {:?}",monobj);}

nb (1) : attention à la déclaration de l'objet en argument dans test_structure. Si mut n'était pas déclaré, il serait impossible d'agir sur l'objet, conformément aux règles de la Rouille…

error[E0594]: cannot assign to `monobj.a`, as `monobj` is not declared as mutable
  --> src/lib.rs:69:5
   |
67 | pub unsafe extern "C" fn test_structure(monobj: MonObjet) {
   |                                         ------ help: consider changing this to be mutable: `mut monobj`
68 |     println!("[RUST] -(3)-> {:?}", monobj); 
69 |     monobj.a += 1; 
   |     ^^^^^^^^^^^^^ cannot assign

For more information about this error, try `rustc --explain E0594`.
error: could not compile `analyse-terme` due to previous error

nb (2) : dans le cas que je présente, l'emprunt est nécessaire car la structure MonObjet n'implémente pas le trait de copie, comme le signale très bien le compilateur…

error[E0507]: cannot move out of `*ptr_monobj` which is behind a raw pointer
  --> src/lib.rs:75:22
   |
75 |     let mut monobj = *ptr_monobj; 
   |                      ^^^^^^^^^^^
   |                      |
   |                      move occurs because `*ptr_monobj` has type `MonObjet`, which does not implement the `Copy` trait
   |                      help: consider borrowing here: `&*ptr_monobj`

For more information about this error, try `rustc --explain E0507`.
error: could not compile `analyse-terme` due to previous error

Côté Python :

print("--------------------")print("--- partie 2 - structures et passage par référence")print("--------------------")print("--- partie 2.1 - envoi par valeur (l'objet initial n'est pas modifié)")# il s'agit d'une classe un peu particulière, qui prend l'héritage de "Structure" :# Structure permet via un attribut lui-aussi particulier "_fields_", d'indiquer à cType # ce qui est nécessaire d'envoyer à la fonction C partagée classMyStruct(Structure):_fields_=[("a",c_int)]monobjet=MyStruct()monobjet.a=2# monobjet.b = 3 --> vous pouvez essayer sans problème, mais l'attribut n'étant pas déclaré dans _fields_, le champ ne sera pas transmis # notez que je n'ai pas déclaré le type d'arguments attendus resultat=malib.test_structure(monobjet# j'envoi l'objet via un pointeur )print("[PYTHON] ===>",monobjet.a)# pas de modification sur l'objet initial, a = 2 print("--- partie 2.2 - envoi par référence (l'objet initial est modifié)")resultat=malib.test_structure_ref(byref(monobjet)# j'envoi une référence à l'objet via un pointeur )print("[PYTHON] ===>",monobjet.a)# modification sur l'objet initial, a = 3 

Résultat :

julien@julien-Vostro-7580:~/Developpement/rust-python$ cargo build && ./test-ffi.py 
    Finished dev [unoptimized + debuginfo] target(s) in 0.00s

(...)

--------------------
--- partie 2 - structures et passage par référence
--------------------
--- partie 2.1 - envoi par valeur
[RUST] -(3)-> MonObjet { a: 2 }
[RUST] -(4)-> MonObjet { a: 3 }
[PYTHON] ===> 2
--- partie 2.2 - envoi par référence (l'objet initial est modifié)
[RUST] -(3)-> MonObjet { a: 2 }
[RUST] -(4)-> MonObjet { a: 3 }
[PYTHON] ===> 3

Simple non ?

Partie 3 - Les tableaux

Le cas de transfert des tableaux est un peu plus délicat. Il n'est pas comparable à une chaîne de caractère. En effet une chaîne de caractères représente, comme son nom l'indique, un tableau contigu de caractères (peu importe leur taille individuelle). Cependant pour le tableau d'entiers par exemple, ce procédé ne fonctionne pas… car la valeur "0" est une valeur légitime.

Il faut donc passer deux éléments :

  • le tableau en lui-même, ici sous la forme d'un pointeur,
  • la taille réelle du tableau derrière le pointeur.

De la même façon, cela me permet d'introduire l'usage de Box au lieu de l'oubli par mem::forget() comme vu jusque là pour la gestion des pointeurs de Rust vers C.

Côté Rust :

#[repr(C)]#[derive(Debug)]pubstructValeurRetour{puba: c_int,pubcontenu: Vec<c_int>}#[no_mangle]#[allow(improper_ctypes_definitions)]pubunsafeextern"C"fntest_array(nbre: c_int,ptr_tab: *mut[c_int]){lettab=&mut*ptr_tab;println!("[RUST] -(5)-> {:?}",nbre);foriin0..(nbreasusize){println!("[RUST] -(6)-> [{:?}] {:?}",i,tab[i]);}}#[no_mangle]#[allow(improper_ctypes_definitions)]pubunsafeextern"C"fntest_array_retour_simple(nbre: c_int,ptr_tab: *mut[c_int])->*mutc_int{lettab=&mut*ptr_tab;println!("[RUST] -(7)-> {:?}",nbre);letnbre=nbreasusize;// une version courte (mais sale) du casting : attention aux valeurs max admissibles par le système pour 'usize' dans un tel cas foriin0..nbre{println!("[RUST] -(8)-> [{:?}] {:?}",i,tab[i]);tab[i]+=1;println!("[RUST] -(8')-> [{:?}] {:?}",i,tab[i]);}letmutnouveau_vecteur: Vec<c_int>=vec![42;nbre+1];// pas propre mais fonctionnel letp=nouveau_vecteur.as_mut_ptr();std::mem::forget(nouveau_vecteur);p}#[no_mangle]pubunsafeextern"C"fntest_array_retour_complexe(nbre: c_int)->*mutValeurRetour{// notre 'nbre' reçu depuis le monde Python via un c_int, devra changer pour être utilisé dans deux contexte différent println!("[RUST] -(9)-> {:?}",nbre);letnbre_c_int: c_int=(nbre+1).try_into().unwrap();letnbre_usize: usize=(nbre+1).try_into().unwrap();letvecteur_retour=Box::new(ValeurRetour{a: nbre_c_int,// ici un entier au format c_int contenu: vec![42;nbre_usize]// ici un usize pour définir la taille finale du vecteur, même si ce dernier aurait pu être conçu "à la volée" en ajoutant progressivement des valeurs - ici c'est juste plus efficient });println!("[RUST] -(10)-> {:?}",vecteur_retour);// plus propre que 'mem::forget()' Box::into_raw(vecteur_retour)}

Côté Python :

Peut-être une expression va vous choquer : (c_int * len(tab_valeurs))(*tab_valeurs). Elle est pourtant tout à fait correcte ! Une fois décomposée, elle est très simple à comprendre :

tab_valeurs=[1,2,3,4]taille_tab_valeurs=len(tab_valeurs)taille_fixe_tableau_c_int=c_int*taille_tab_valeurs

… La dernière partie est l'assignation des valeurs contenues dans tab_valeurs vers taille_fixe_tableau_c_int, comme si l'on faisait une boucle for. Attention une telle boucle n'est pas réellement possible (d'où l'appel de fonction) :

julien@julien-Vostro-7580:~/Developpement/rust-python$ python3
Python 3.10.4 (main, Jun 292022, 12:14:53)[GCC 11.2.0] on linux
Type "help", "copyright", "credits" or "license"for more information.
>>> from ctypes import * 
>>>tab_valeurs=[1, 2, 3, 4]>>>taille_tab_valeurs= len(tab_valeurs)>>>taille_fixe_tableau_c_int= c_int * taille_tab_valeurs
>>> taille_fixe_tableau_c_int
'__main__.c_int_Array_4'>>>>for i, v in enumerate(tab_valeurs): 
...   taille_fixe_tableau_c_int[i]= v
... 
Traceback (most recent call last):
  File "", line 2, in 
TypeError: '_ctypes.PyCArrayType' object does not support item assignment

Vous aurez noté au passage le type : Class __main__.c_int_Array_4 (avec 4 qui correspond à la taille) ; notre multiplication est en réalité la construction d'un objet.

Pour l'étoile, c'est exactement le même principe que l'argument du reste pour une fonction ou lors de la construction des listes ou dictionnaires:

julien@julien-Vostro-7580:~/Developpement/rust-python$ python3
Python 3.10.4 (main, Jun 292022, 12:14:53)[GCC 11.2.0] on linux
Type "help", "copyright", "credits" or "license"for more information.
>>>a=[0,1,2,3]>>>b=[*a,]>>> b
[0, 1, 2, 3]>>>c={"ok":1}>>>d={**c,}>>> d
{'ok': 1}>>>d={**c,'ko':0}>>> d
{'ok': 1, 'ko': 0}

… plus de mystère !

print("--------------------")print("--- partie 3 - tableaux et passage par référence")print("--------------------")print("--- partie 3.1 - envoi par référence (l'objet initial est modifié)")tab_valeurs=[1,2,3,4]tableau=(c_int*len(tab_valeurs))(*tab_valeurs)malib.test_array(len(tableau),byref(tableau))print("[PYTHON] ===>",len(tableau),list(tableau))print("--- partie 3.2 - envoi par référence x2 et acquisition pré-connue retours")malib.test_array_retour_simple.restype=POINTER(c_int)r1=malib.test_array_retour_simple(len(tableau),byref(tableau))print("[PYTHON] =( r1 )=>",len(tableau),list(tableau))print("[PYTHON] =( r1 )=>",len(tableau)+1,[r1[i]foriinrange(0,len(tableau)+1)])r2=malib.test_array_retour_simple(len(tableau),byref(tableau))print("[PYTHON] =( r2 )=>",len(tableau),list(tableau))print("[PYTHON] =( r2 )=>",len(tableau)+1,[r1[i]foriinrange(0,len(tableau)+1)])print("--- partie 3.2 - création d'un objet de retour de taille indéterminée à l'appel")classValeurRetour(Structure):_fields_=[("a",c_int),("contenu",POINTER(c_int))]malib.test_array_retour_complexe.restype=POINTER(ValeurRetour)r3=malib.test_array_retour_complexe(len(tableau))a=r3.contents.av=r3.contents.contenuprint("[PYTHON] ===>",a,[v[i]foriinrange(0,a)])

Parfait.

Conclusion

Nous avons fait un tour rapide mais j'espère assez complet du propriétaire. En fonction de vos retours (coquilles ou ajouts majeurs), je demanderais peut-être à un admin de pouvoir passer un dernier coup de polish sur l'article…

En attendant, bon code à tous ! :)

Commentaires :voir le flux Atomouvrir dans le navigateur


Viewing all articles
Browse latest Browse all 3409

Trending Articles