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

Posté par  . Licence CC By‑SA.
18
6
nov.
2022

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 python3

from ctypes import *

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

use std::ffi::CStr;
use std::ffi::CString;
use std::os::raw::c_char;
use std::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]
pub unsafe extern "C" fn test_string(ptr_source: *mut c_char) -> *const c_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) 
  let v = 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 
  let s = CString::new( v ).unwrap();
  let p = 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_p
resultat = 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_p
resultat = malib.test_string( message.encode() ) # par défaut, ".encode()" est en UTF-8

print( "[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)]
pub struct MonObjet {
  pub a: c_int
}

#[no_mangle]
pub unsafe extern "C" fn test_structure(mut monobj: 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]
pub unsafe extern "C" fn test_structure_ref(ptr_monobj: *mut MonObjet) {
  // 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)
  let monobj = &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 
class MyStruct(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)]
pub struct ValeurRetour {
    pub a: c_int,
    pub contenu: Vec<c_int>
}

#[no_mangle]
#[allow(improper_ctypes_definitions)]
pub unsafe extern "C" fn test_array( nbre: c_int, ptr_tab: *mut [c_int] ) {
    let tab = &mut *ptr_tab; 
    println!("[RUST] -(5)-> {:?}", nbre);
    for i in 0..(nbre as usize) {
        println!("[RUST] -(6)-> [{:?}] {:?}", i, tab[i]);
    }
} 

#[no_mangle]
#[allow(improper_ctypes_definitions)]
pub unsafe extern "C" fn test_array_retour_simple( nbre: c_int, ptr_tab: *mut [c_int] ) -> *mut c_int {
    let tab = &mut *ptr_tab; 
    println!("[RUST] -(7)-> {:?}", nbre);
    let nbre = nbre as usize; // une version courte (mais sale) du casting : attention aux valeurs max admissibles par le système pour 'usize' dans un tel cas 
    for i in 0..nbre {
        println!("[RUST] -(8)-> [{:?}] {:?}", i, tab[i]);
        tab[i] += 1;
        println!("[RUST] -(8')-> [{:?}] {:?}", i, tab[i]);
    }
    let mut nouveau_vecteur: Vec<c_int> = vec![42; nbre+1]; 
    // pas propre mais fonctionnel 
    let p = nouveau_vecteur.as_mut_ptr();
    std::mem::forget(nouveau_vecteur);
    p 
} 

#[no_mangle]
pub unsafe extern "C" fn test_array_retour_complexe( nbre: c_int ) -> *mut ValeurRetour {
    // 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); 
    let nbre_c_int: c_int = (nbre+1).try_into().unwrap(); 
    let nbre_usize: usize = (nbre+1).try_into().unwrap(); 
    let vecteur_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 29 2022, 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
<class '__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 "<stdin>", line 2, in <module>
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 29 2022, 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] for i in range(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] for i in range(0,len(tableau)+1) ] )

print( "--- partie 3.2 - création d'un objet de retour de taille indéterminée à l'appel" ) 
class ValeurRetour(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.a 
v = r3.contents.contenu 
print( "[PYTHON] ===>", a, [ v[i] for i in range(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 ! :)

  • # Merci

    Posté par  . Évalué à 3. Dernière modification le 07 novembre 2022 à 23:30.

    Je n'ai pas encore tout lu, mais cette article / partage m'intéresse.
    Je suis programmeur C++ expérimenté et programmeur Rust en apprentissage, ça pourrais m'intéresser si j'ai besoin de faire interagir Rust avec du Python.

    Pas que j'aime le Python ou que j'ai envie d'en faire, mais s'il y avait besoin d'intérragir avec du code existant et/ou fait par quelqu'un d'autres.

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

    Pour moi c'est le monde de l'interprété qui est cruel.
    Le compilateur (et outils de check associé) permette de détecter tellement d'erreurs de programmation et autre bugs alors qu'ils ne sont détecté qu'à l'exécution dans le cas de langage interprété.
    Et c'est encore plus flagrant en Rust, j'ai tellement plus confiance en mon programme une fois qu'il compile (sa qualité, son nombres réduit de bugs).
    C'est plus reposant pour l'esprit, et ça permet de plus ce concentrer sur les problématique à résoudre, et moins sur la technique pour les résoudre.

    • [^] # Re: Merci

      Posté par  . Évalué à 3. Dernière modification le 06 novembre 2022 à 19:17.

      C'est plus reposant pour l'esprit, et ça permet de plus ce concentrer sur les problématique à résoudre, et moins sur la technique pour les résoudre.

      Je suis entièrement d'accord avec toi. Rust a vraiment des qualités intrinsèques tant la définition du langage comme grammaire que du fonctionnement de son compilateur (et ses outils associés), que c'est un plaisir. Avec la garantie de la mémoire comme une sublime cerise. Il nous oblige à faire correctement.

      Bien sûr j'ai rajouté un peu d'humour sur la différence entre interprété et compilé. Du reste j'ai plaisir à Python pour la beauté de résoudre certains aspects métier, en faisant une (forte) abstraction du sous-jacent technique.

      Pour les projets perso, je ne me vois pas commencer un projet ou un développement un peu conséquent, si d'abord passer par la case "script pythonique" pour tenter d'abord de bien saisir ce que j'ai à faire et les ramifications. Après le passage à Rust est juste l'outil le plus correct pour tout mettre en œuvre ou simplement "accélérer" un traitement comme ce week-end.

      Au passage, merci pour ton commentaire sur l'intérêt de l'article.

      • [^] # Re: Merci

        Posté par  . Évalué à 2.

        Oui j'avais compris l'humour mais j'avais envie d'en profiter ^
        En effet, les langages interprété sont souvent présenté comme plus facile, plus simple d'accès. Hors je trouve que dans les faits c'est vraiment pas forcement le cas.
        La simplicité de façade se traduit souvent par des inconvénients (notamment ceux que je citais précédemment.

        Alors autant C/C++ avait quand même des subtilités qui pouvais compliquer leur utilisations par des néophyte ou programmeur occasionnel.
        Autant Rust, avec son aspect explicite et tous les outils qui viennent avec rend les choses vraiment plus accessible. Les outils en deviennent de bon guide pour l'apprentissage.

    • [^] # Re: Merci

      Posté par  . Évalué à 1.

      Ooops, j'ai raté ma citation, un modérateur peux corriger ?
      Il faut arrêter la citation a partir de "Pour moi c'est le monde de l'interprété qui est cruel."

  • # memforget et fuite mémoire

    Posté par  (site web personnel) . Évalué à 3.

    // 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);

    N'y a-t-il pas une fuite mémoire là ?
    Certes tu es obligé de dire à rust de ne pas desalouer la variable pour la passer à python, mais il faut bien la libérer à un moment. Sur un cas de test comme ça c'est pas bien grave, mais comment tu gères ça en vrai sur un programme réel ?

    Matthieu Gautier|irc:starmad

    • [^] # Re: memforget et fuite mémoire

      Posté par  . Évalué à 3.

      Si j'en crois la doc, Python récupère le pointeur et créé via cTypes, tout le nécessaire pour gérer son cycle de vie. Donc non, il n'y a pas de risque.

      En somme je garde la mémoire allouée pour qu'elle passe sous la supervision Python via un pointeur, sous la forme d'une objet référençable interne : l'objet cible (ou la référence elle-même) qui l'utilisera formera sa première référence incrémentée.

      Si l'objet cible n'est plus utilisé, il sera supprimé par le GC qui décrémentera le compteur interne de l'objet référençable, qui tombera à 0 et sera donc lui aussi supprimé.

      • Rust -> emprunt et temps de vie statique
      • Python -> compteur de références

      De ce que j'en ai compris - à confirmer par un connaisseur -, c'est le principe de Numpy.

      • [^] # Re: memforget et fuite mémoire

        Posté par  (site web personnel) . Évalué à 4. Dernière modification le 07 novembre 2022 à 14:39.

        Si j'en crois la doc, Python récupère le pointeur et créé via cTypes, tout le nécessaire pour gérer son cycle de vie. Donc non, il n'y a pas de risque.

        C'est quelle doc ?

        Python n'a aucun moyen de savoir comment il doit désallouer la mémoire, ni même si il doit la désallouer.

        Si tu wrap du code c++ et que tu retournes un pointeur sur un class, il faut appeler le
        destructeur. Python ne le connais pas. Pareil au niveau de Rust, il y a peut-être des drop à appeler.
        Voir même, il ne faut pas détruire la donnée. Image que ta box est dans un stockage de ta lib rust et que tu veux juste retourner un pointer sur cette valeur tout en gardant sa gestion mémoire dans rust : tu peux en récupérer un pointer en "coerçant" la référence (https://doc.rust-lang.org/std/primitive.pointer.html#common-ways-to-create-raw-pointers)
        mais python ne doit pas supprimer la mémoire. Donc Python ne doit pas le faire par défaut.

        Si tu veux que le code python supprime la mémoire, il faut repasser le pointer à rust pour qu'il le supprime comme il faut, probablement avec une fonction de ce type (si tu as utiliser Box::into_raw, à adapter aux cas d'usages) :

        #[no_mangle]
        #[allow(improper_ctypes_definitions)]
        pub unsafe extern "C" fn free_box( ptr: *mut c_int ) {
            drop(Box::from_raw(ptr));
        } 

        Tu peux lancer ton programme avec valgrind pour voir si il y a des fuites mémoire.

        Matthieu Gautier|irc:starmad

        • [^] # Re: memforget et fuite mémoire

          Posté par  . Évalué à 3. Dernière modification le 07 novembre 2022 à 19:53.

          Mea maxima culpa.

          https://doc.sagemath.org/html/en/thematic_tutorials/numerical_sage/ctypes_examples.html

          La doc est pas claire justement, car elle indique que le pointeur donné est transformé en une référence interne, elle-même utilisée ailleurs si nécessaire. En reprenant la stratégie globale d'allocation des variables Python, ça ne m'a pas choqué. Compte tenu que tu déclares le type, j'ai bêtement pensé qu'il connaissait donc sa taille calculable et par conséquent, faisait le nécessaire.

          C'est moche. Je regarde pour la fuite mémoire et j'écris cette semaine une édition de cet article.

          Merci !

          Edit : Wrapper "__del__" pour appel à Box comme tu m'indiques.

        • [^] # Re: memforget et fuite mémoire

          Posté par  . Évalué à 2.

          @Maga : voici une proposition de complément de l'article. Si tu l'as l'occasion / la possibilité d'y jeter un coup d’œil… (peur d'une coquille supplémentaire)


          Mea maxima culpa

          Errare humanum est, perseverare diabolicum : j'ai fait une erreur. Pris dans mon élan je n'ai pas évoqué la question de la libération de la mémoire. En réalité, j'avais tort sur ma vision de la gestion de la mémoire par cTypes.

          Mon idée était la suivante : cTypes n'évoque à aucun moment la question de la libération de la mémoire. J'ai supposé que c'est la stratégie de gestion de la mémoire de Python qui s'applique, et donc qu'il était en capacité de prendre en charge pour nous.

          Las, le commentaire de GaMa, tape juste : quoiqu'il arrive ça pêche sur l'usage, car un pointeur renvoie vers une zone qui peut avoir elle-même d'autres pointeurs (voir ici et ). Il faut gérer côté Python cet aspect, en demandant côté Rust dans notre cas, la libération de la mémoire. Et il ne peut pas connaître du reste, toutes les tailles à désallouer (typiquement le tableau mais pour d'autres éléments, il connaît la taille).

          Dans notre bibliothèque Rust, rajoutons donc le nécessaire :

          #[no_mangle]
          pub unsafe extern "C" fn liberer_valeurretour( ptr: *mut ValeurRetour ) {
              drop(Box::from_raw(ptr));
          } 

          Côté Python, c'est plus compliqué. On doit gérer désormais la durée de vie de la mémoire derrière le pointeur, mais l'appeler au bon moment pour ne pas désallouer côté Rust et utiliser côté Python.

          Pour cela, le mieux que j'ai trouvé est d'avoir un objet intermédiaire dont on aura le signal de destruction. ("__del__"). C'est lui qui permettra de faire appel à la désallocation :

          print( "--- partie 3.2 - création d'un objet de retour de taille indéterminée à l'appel" ) 
          
          class ValeurRetour(Structure): # on n'utilise pas directement 
            _fields_ = [ 
              ( "a", c_int ),
              ( "contenu", POINTER(c_int) )
            ] 
          
          malib.test_array_retour_complexe.restype = POINTER( ValeurRetour )
          malib.liberer_valeurretour.argtypes = [POINTER( ValeurRetour ),] # on généralise à toutes les valeurs retours 
          
          class EnveloppeValeurRetourLibDistante():
          
            def __init__( self ): 
              self.obj_ptr = malib.test_array_retour_complexe( 
                len(tableau) 
              ) 
              self.a = self.obj_ptr.contents.a 
              c = self.obj_ptr.contents.contenu 
              self.contenu = [ c[i] for i in range(0,self.a) ]
          
            def __del__(self): 
              # l'objet d'enveloppe de l'objet pointeur Python va être supprimé :
              # il déclenche donc la libération de la mémoire allouée par Rust 
              malib.liberer_valeurretour( self.obj_ptr )
          
          obj_enveloppe = EnveloppeValeurRetourLibDistante()
          
          print( "[PYTHON] ===>", obj_enveloppe.a, obj_enveloppe.contenu)

          Si la comparaison d'attributs vous est importante, gardez à l'esprit que cTypes créée un nouvel objet interne à chaque appel à l'objet pointé, comme l'illustre l'exemple de la documentation :

          from ctypes import *
          i = c_int(42)
          pi = pointer(i)  
          pi.contents is pi.contents # vaut False 

          Cependant si vous vous ne souciez pas de ce point, pensez au décorateur "property" pour envelopper plus aisément l'appel à l'objet lié par le pointeur, dans l'objet d'enveloppe :

          class EnveloppeValeurRetourLibDistante():
            # (...)
            @property
            def a(self):
              return self.obj_ptr.contents.a 
          
            @property
            def contenu(self):
              return self.obj_ptr.contents.contenu

          Enfin vous pouvez retrouver très facilement le jeu de poupées imbriquées qui sert à la mécanique interne ("tout est objet en Python") :

            def __init__( self ): 
              self.obj_ptr = malib.test_array_retour_complexe( 
                len(tableau) 
              ) 
              print( self.obj_ptr )
              print( self.obj_ptr.contents )
              print( self.obj_ptr.contents.contenu.contents )

          Au premier niveau, on retrouve bien un objet "pointeur" à la sauce Python :

          <__main__.LP_ValeurRetour object at 0x7fdd12f7f940>

          Qui permet d'accéder via ".contents" à l'objet "reconstitué" via la classe du même nom,

          <__main__.ValeurRetour object at 0x7fdd12f7c6c0>

          Qui permet lui-même d'accéder à mon tableau au travers d'un autre pointeur :

          <__main__.LP_c_int object at 0x7fdd12f7c3c0>

          Qui, devinez quoi ?, me permet d'accéder à ma valeur (la première case qui est à 42, puis de pouvoir d'itérer sur le tableau en mémoire).

          • [^] # Re: memforget et fuite mémoire

            Posté par  (site web personnel) . Évalué à 4.

            Ça me semble mieux :)

            J'émets cependant une réserve sur le __del__. Ça peut poser quelques soucis avec le garbage collecteur de python (https://stackoverflow.com/questions/1481488/what-is-the-del-method-and-how-do-i-call-it)

            En l’occurrence, ça peut bloquer le gc pour chercher des cycles à détruire. Et en fait, tu n'as aucune garantie que __del__ soit appelé par python (il y a de fortes chances que oui, mais c'est pas garantie).

            Si je devais faire la même chose, je partirais plus sur un wrapper (EnveloppeValeurRetourLibDistante) écrite en c++ avec un destructeur en c++ et qui serait wrappé en python (et tout ça probablement avec cython). Mais on sort un peu du sujet d'origine (utiliser ctypes). [*]

            Et une autre réserve. Ça marche parce que tu copies les valeurs du tableau rust dans un tableau python. (D’ailleurs, tu pourrais libérer la mémoire juste après ça). Mais dans un cas plus complexe (par exemple une structure qui à des pointers sur d'autres zone mémoire) il faut faire attention à ne pas garder un pointer vers une de ces zones et libérer la structure en même temps. Sinon, rust risque de libérer la mémoire alors que python continue de pointer dessus.

            En fait, si je devais faire la même chose, je ne le ferais probablement pas et j'utiliserais des libs déjà existantes faites pour ça. La gestion mémoire est un sujet complexe (c'est pas pour rien qu'on a inventer des langages haut niveau qui la gère pour nous) et la gérer correctement dans une couche glue entre deux langages qui n'ont pas les mêmes logiques de gestion mémoire n'est pas une mince affaire.


            [*] Peut être qu'un jour je m'y amuserais, vous aurez droit à un journal si c'est le cas.

            Matthieu Gautier|irc:starmad

            • [^] # Re: memforget et fuite mémoire

              Posté par  . Évalué à 2.

              Merci de ton retour et de tes alertes !

              Sur la partie __del__, j'ai bien conscience qu'il existe des cas limites. Comme tu le soulignes, rien n'empêche d'exploiter par le développeur en appelant malib.liberer_valeurretour( obj_ptr ) "au bon moment" (celui qui serait attendu).

              Sur le deuxième point c'est effectivement un corollaire à ton commentaire initial : ne connaissant pas tous les pointeurs vers un objet en mémoire, on ne connaît pas l'usage de tous ces pointeurs potentiels.
              Par contre je m'interroge sur le caractère automatique de la désallocation de la stratégie d'emprunt et de suppression de Rust. Peut-être auras-tu une réponse ?

              Premier cas :

              struct MonObjet {
                champ: UnAutreObjet // possession "stricte"
              }

              … c'est le cas de mon exemple. La mémoire est libéré par Rust, car il a la possession. Si un pointeur existe vers cette valeur en mémoire, c'est embêtant mais pas pour l'objet source : pour celui-ci, pas de problème.

              Si par contre j'avais eu un partage mutable ou non :

              struct MonObjet {
                  champ: &UnAutreObjet // multiples emprunts non-mutables possibles 
              }
              struct MonObjet {
                  champ: &mut UnAutreObjet // un seul emprunt mutables possible 
              }

              … Rust n'aurait pas libéré la mémoire, car il n'a pas de possession mais d'emprunt. La désallocation dans de tel cas, invaliderait gravement le fonctionnement des durées de vie.

              Ça marche parce que tu copies les valeurs du tableau rust dans un tableau python.

              Dans l'exemple, ma compréhension du phénomène est la suivante : le passage dans liberer_valeurretour supprime le tableau car j'ai déclaré côté Rust, la pleine possession de celui-ci.

              "la gérer correctement dans une couche glue entre deux langages qui n'ont pas les mêmes logiques de gestion mémoire n'est pas une mince affaire."

              Ce journal à au moins cet avantage de (bien) mettre en lumière qu'on peut se prendre facilement les pieds dans le tapis, oui. Mais sinon on ne progresse pas :)

              PS : hâte de lire ça si tu fais un journal sur le sujet.

              • [^] # Re: memforget et fuite mémoire

                Posté par  (site web personnel) . Évalué à 3.

                Premier cas :

                struct MonObjet {
                  champ: UnAutreObjet // possession "stricte"
                }

                MonObjet est propriétaire de champ. Donc à la destruction de MonObject, champ sera détruit aussi.
                Et rust garantie qui rien de pointera dessus parce qu'il n'autorise pas de références qui vivent plus longtemps que MonObjet. Sauf si tu fais du unsafe (ce qui est le cas ici), rust ne sait pas. C'est à toi de savoir comment python et rust fonctionne pour rien casser

                Pour

                struct MonObjet {
                    champ: &UnAutreObjet // multiples emprunts non-mutables possibles 
                }

                Déjà, c'est faux. C'est :

                struct MonObjet<'lifetime_autreobject> {
                     champ: &'lifetime_autreobject UnAutreObjet
                }

                Et ça veut dire que MonObjet, qui emprunte (a une référence vers) UnAutreObjet, ne peut pas vivre plus longtemps que UnAutreObjet. (Même règle que plus haut, mais dans l'autre sens).
                Si python garde un pointeur sur UnAutreObjet, le problème n'arrivera pas quand MonObjet est détruit mais quand UnAutreObjet (ou son propriétaire) sera détruit.

                Dans l'exemple, ma compréhension du phénomène est la suivante : le passage dans liberer_valeurretour supprime le tableau car j'ai déclaré côté Rust, la pleine possession de celui-ci.

                Tout à fait.

                pub struct ValeurRetour {
                    pub a: c_int,
                    pub contenu: Vec<c_int>
                }

                Tu déclare une structure qui est propriétaire du vecteur qui est propriétaire des entiers.

                Avec let valeur_retour = Box::new(ValeurRetour{...}); tu crées une ValeurRetour dans une box et valeur_retour est propriétaire de la box (et donc ValeurRetour)
                Avec Box::into_raw( valeur_retour ), tu quittes la propriété (au profit de personne), ta box est vidé et tu récupère le pointeur sur valeur_retour.
                Avec Box::from_raw(ptr), tu reprends la propriété. À la destruction de la box, le valeur est détruite (ainsi que le contenu).

                Entre temps, niveau python tu as deux options :

                class ValeurRetour(Structure): 
                    _fields_ = [ 
                      ( "a", c_int ),
                      ( "contenu", POINTER(c_int) )
                    ] 
                malib.test_array_retour_complexe.restype = POINTER( ValeurRetour )
                
                # Premier cas (le bon)
                def good():
                    tableau_de_rust = malib.test_array_retour_complexe(5) 
                    a = r3.contents.a 
                    v = r3.contents.contenu  # Un pointeur vers la mémoire interne de `vec`
                    print( "[PYTHON] ===>", a, [ v[i] for i in range(0,a) ] )
                    # À la destruction de `tableau_de_rust`, on va appeler `__del__` qui va désallouer
                    # la mémoire dans rust. On a certes encore v qui pointe vers quelque chose, mais on
                    # peut plus l'utiliser, donc osef.
                
                # Le mauvais cas
                def bad():
                    tableau_de_rust = malib.test_array_retour_complexe(5) 
                    a = r3.contents.a 
                    v = r3.contents.contenu  # Un pointeur vers la mémoire interne de `vec`
                    print( "[PYTHON] ===>", a, [ v[i] for i in range(0,a) ] )
                    return v
                    # À la destruction de `tableau_de_rust`, on va appeler `__del__` qui va désallouer
                    # la mémoire dans rust.
                    # Par contre on a v qui pointe encore vers la mémoire interne désallouée et ça va merder quand on va l'utiliser

                Matthieu Gautier|irc:starmad

  • # Edition de l'article ?

    Posté par  . Évalué à 1.

    Si un administrateur ou un habitué du site connaît le meilleur moyen pour demander l'édition de ce journal (à qui je l'envoi… ? comment ?) car je sèche un peu sur l'aide du site…

Suivre le flux des commentaires

Note : les commentaires appartiennent à celles et ceux qui les ont postés. Nous n’en sommes pas responsables.