Gestion générique de la mémoire

Ce cours a pour but de montrer que la gestion de la mémoire en C(++) peut être réalisée octet par octet, indépendamment du type de donnée stockée. Ce type de programmation est fréquemment utilisé dans le cadre de la programmation générique, pour proposer des fonctionnalités qui sont indépendantes du type de données manipulées.

Mise en garde et limitations

Utiliser ce type de programmation requiert une maîtrise fine de la mémoire et de sa gestion. Ce cours n'est qu'une introduction et ne doit pas être considéré comme suffisant pour maîtriser entièrement le concept. Nous ne voyons en particulier pas en détail les notions d'alignement des données qui sont essentielles pour maîtriser pleinement le concept.

Utilisation du type void*

Il existe un type particulier en C(++), permettant d'introduire de la généricité dans un programme. Tout d'abord, un exemple d'une fonction générique dans la bibliothèque standard, utilisant ce type.

memcpy

Cette fonction est disponible dans la bibliothèque standard dans l'entête string.h (C) ou cstring (C++). Elle permet de recopier des données d'un emplacement à un autre, quelles que soient les données recopiées. Extrait de la page de manuel :

NAME memcpy - copy memory area

SYNOPSIS

  #include <string.h>

  void *memcpy(void *dest, const void *src, size_t n);

DESCRIPTION The memcpy() function copies n bytes from memory area src to memory area dest. The memory areas must not overlap. Use memmove(3) if the memory areas do over‐ lap.

RETURN VALUE The memcpy() function returns a pointer to dest.

In [1]:
#include <cstring>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <cassert>
Out[1]:

In [2]:
{
    int a = 6 ;
    int b = 10 ;

    std::printf("%d %d\n", a, b) ;

    std::memcpy(&b, &a, sizeof(int)) ;

    std::printf("%d %d\n", a, b) ;
}
6 10
6 6
Out[2]:

Le type void

La fonction memcpy utilise un type particulier pour être générique : le type void. En anglais, void signifie rien. C'est d'ailleurs pour ça qu'une procédure (qui ne renvoie pas de valeur) a void pour type de retour. Il n'est pas possible de déclarer d'objet de type void en C(++) :

In [3]:
void c ;
input_line_5:2:7: error: variable has incomplete type 'void'
 void c ;
      ^

Il est par contre possible de déclarer une variable de type void * :

In [4]:
void *ptr ;
Out[4]:
(void *) nullptr

Le type void * désigne une adresse générique. En effet, quelle que soit la donnée, une adresse mémoire a toujours le même format. Sur la plupart des machine modernes, il s'agit d'un nombre encodé sur 64 bits soient 8 octets (32 / 4 pour les machines plus anciennes).

In [5]:
std::printf("%lu\n",sizeof(void*)  ) ;
std::printf("%lu\n",sizeof(int*)   ) ;
std::printf("%lu\n",sizeof(double*)) ;
std::printf("%lu\n",sizeof(char*)  ) ;
8
8
8
8
Out[5]:
(int) 2

Le langage C(++) vous permet (à vos risques et périls) de convertir une adresse d'un type en une autre. En faisant cela, vous cassez le système de vérification de types du compilateur, et devenez responsable de la correction de votre code sur ces aspects. Un exemple (à ne pas reproduire) :

In [6]:
{
    int itab[2] ;
    double *ptr_dbl ;

    itab[0] =  -1 ;
    itab[1] = 1 ;

    ptr_dbl = (double*) itab ; //conversion de type d'adresse
    std::printf("%g\n", *ptr_dbl) ;

    itab[0] = 0 ;
    std::printf("%g\n", *ptr_dbl) ;

    itab[1] = 13 ;
    std::printf("%g\n", *ptr_dbl) ;

    *ptr_dbl = 5.2 ;
    std::printf("%d %d\n", itab[0], itab[1]) ;
}
4.24399e-314
2.122e-314
2.75859e-313
-858993459 1075104972
Out[6]:

Ici, nous avons créé un tableau de deux entiers. Chaque entier occupe 4 octet, et la norme du C assure que les deux entiers sont stockés côte à côte en mémoire. L'espace mémoire réservé pour le tableau correspond donc à un ensemble de 8 octets consécutifs. Nous avons ensuite converti l'adresse du premier des entiers (le pointeur correspondant au tableau) en un pointeur sur un double. Le type double sert à représenter des nombres décimaux, avec une précision deux fois supérieure à celle du type float. Pour cette précision, un double occupe 8 octets.

Lorsque nous affichons *ptr_dbl, nous allons chercher dans la mémoire 8 octets (la taille d'un double étant donné que ptr_dbl est de type double*). Ces 8 octets sont ensuite interprétés comme un double selon la norme, et affichés selon cette interprétation.

Nous n'avons fait que convertir le type d'adresse. Les 8 octets sont donc partagés. Modifier le tableau d'entier ou le double poité par ptr_dbl modifie dans les deux cas les octets.

Une implémentation naïve de memcpy

Nous allons utiliser cette idée pour proposer une implémentation naïve de memcpy. En C(++), il existe plusieurs types dont la taille fait 1 octet :

In [7]:
std::printf("%lu\n", sizeof(char)) ;
std::printf("%lu\n", sizeof(unsigned char)) ;
1
1
Out[7]:
(int) 2

En convertissant une adresse quelconque en une adresse de type char * ou unsigned char * il est ainsi possible de manipuler les octets un par un et de recopier le nombre d'octets souhaité.

In [8]:
void my_memcpy(void* dest, const void* from, unsigned int octets ) {
    unsigned char* c_dest = (unsigned char*) dest ;
    unsigned char* c_from = (unsigned char*) from ;
    for(int i = 0; i < octets; ++i) {
        c_dest[i] = c_from[i] ;
    }
}
Out[8]:

In [9]:
{
    int i1 = 12 ;
    int i2 = 7 ;
    double d1 = 5.3 ;
    double d2 = 3.4 ;

    std::printf("%d %d | %g %g\n", i1, i2, d1, d2) ;

    my_memcpy(&i2, &i1, sizeof(int)) ;
    my_memcpy(&d2, &d1, sizeof(double)) ;

    std::printf("%d %d | %g %g\n", i1, i2, d1, d2) ;
}
12 7 | 5.3 3.4
12 12 | 5.3 5.3
Out[9]:

Notez bien que cette implémentation est naïve et que des considérations plus subtiles doivent être utilisées pour la rendre efficace, en particulier le fait qu'aller chercher les octets un par un en mémoire est très inefficace.

Application : tableau dynamique générique (naïf)

Nous allons nous servir de ces concepts pour programmer un tableau dynamique générique.

In [3]:
typedef struct dyntab_ {
    char* data ;
    int taille ;
    int capacite ;
    int octets ;
} dyntab ;
Out[3]:

Commençons par l'initialisation et la destruction de notre tableau dynamique. Nous choisirons comme capacité initiale un élément.

In [4]:
void dyntab_init(dyntab* tab, int octets) {
    tab->data = (char*) malloc(octets) ;
    tab->taille = 0 ;
    tab->capacite = 1 ;
    tab->octets = octets ;
}
Out[4]:

In [5]:
void dyntab_free(dyntab* tab) {
    free(tab->data) ;
}
Out[5]:

Ajoutons ensuite le nécessaire pour faire grandir l'espace de stockage en cas de besoin. Nous choisirons comme politique d'agrandissement de doubler la capacité du tableau à chaque agrandissement.

In [6]:
void dyntab_grow(dyntab* tab) {
    tab->capacite *= 2 ;
    char* tmp = (char*)realloc(tab->data, tab->capacite*tab->octets) ;
    assert(tmp) ;
    tab->data = tmp ;
}
Out[6]:

La fonction d'ajout est générique, elle ne dépend pas du type de données stockées, et prend donc en paramètre l'adresse générique où se trouvent les données à stocker.

In [7]:
void dyntab_push(dyntab* tab, const void* src) {
    if(tab->capacite == tab->taille) {
        dyntab_grow(tab) ;
    }
    std::memcpy(tab->data + tab->octets * tab->taille, src, tab->octets) ;
    ++tab->taille ;
}
Out[7]:

De même la fonction d'accès à l'élément d'un certain indice est générique, et demande l'adresse de la zone mémoire où la valeur de l'élément doit être recopiée.

In [8]:
void dyntab_get(const dyntab* tab, int index, void* dest) {
    std::memcpy(dest, tab->data + tab->octets * index, tab->octets) ;
}
Out[8]:

Testons maintenant notre implémentation d'un tableau dynamique pour des entiers.

In [16]:
{
    dyntab tab ;
    dyntab_init(&tab, sizeof(int)) ;

    for(int i = 0; i < 20; ++i) {
        dyntab_push(&tab, &i) ;
        printf("%d %d\n", tab.taille, tab.capacite) ;
    }

    printf("[ ") ;
    for(int i = 0; i < tab.taille; ++i) {
        int elem ;
        dyntab_get(&tab, i, &elem) ;
        printf("%d ", elem) ;
    }
    printf("]\n") ;

    dyntab_free(&tab) ;
}
1 1
2 2
3 4
4 4
5 8
6 8
7 8
8 8
9 16
10 16
11 16
12 16
13 16
14 16
15 16
16 16
17 32
18 32
19 32
20 32
[ 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 ]
Out[16]:

Le problème de l'affichage

Dans le code précédent, pour tester le fonctionnement de notre tableau dynamique, nous avons écrit une petite boucle pour afficher les données du tableau. Pour éviter d'avoir à le refaire, nous aimerions doter notre tableau dynamique d'une fonction d'affichage. Essayons de la coder.

In [17]:
void dyntab_affiche_proto(dyntab* tab) {
    printf("[ ") ;
    for(int i = 0; i < tab->taille; ++i) {
        void* elem = tab->data + i * tab->octets ;
        printf("" /* que mettre ici ??? */) ;
    }
    printf("]\n") ;
}
Out[17]:

Le problème est ici que dans la fonction d'affichage, nous avons besoin de savoir comment un élément individuel doit être affiché. Sauf que selon les données stockées dans le tableau, le procédé d'affichage est différent. Il est même possible que les données stockées ne soient pas affichables directement, par exemple si vous stockez vos propres structures dans le tableau. Nous avons donc besoin de fournir en plus à la méthode d'affichage la fonction qui réalise l'affichage d'un élément. C'est le but de la section qui suit.

Pointeurs de fonctions

En C(++), chaque fonction est compilée pour produire un ensemble d'instructions processeur. Ces instructions sont stockées dans le fichier exécutable obtenu suite à la compilation. Lorsque vous lancez le programme, ces instructions sont chargées en mémoire, et le système d'exploitation va les lire pour réaliser l'exécution de votre programme. Il commence par le main, et saute de fonction en fonction au fil des appels réalisés. Une fonction a donc une adresse en mémoire qui désigne l'endroit où se trouvent ses instructions.

In [18]:
int f(int i) {
    return i+1 ;
}

void* ptr_f = (void*) f ;

std::printf("%p\n", ptr_f) ;
std::printf("%p\n", f) ;
0x7f7d1c813000
0x7f7d1c813000
Out[18]:
(int) 15

La zone mémoire dans laquelle les instructions du programme est chargé n'est ni la pile, ni le tas. Cette zone est accessible en lecture uniquement, et le système d'exploitation se chargera de tuer votre programme s'il n'y accède pas correctement. La lecture est autorisée :

In [ ]:
double* ptr_dbl = (double*) ptr_f ;
double test_d = *ptr_dbl ;

printf("%g\n", test_d) ;
-6.21759e-251
Out[ ]:
(int) 14

L'écriture est interdite.

In [ ]:
*ptr_dbl = 5.2 ;
std::printf("%d\n", f(2)) ;

La syntaxe d'un pointeur de fonction est définie comme suit :

<type de retour> (*<nom>)(<type param1>, <type param2>, ...)

Par exemple :

In [9]:
int f(int i) {
    return i+1 ;
}
    
int (*g)(int) = f ;
std::printf("%d\n", g(2)) ;
3
Out[9]:
(int) 2

Comme n'importe quel pointeur, il est possible de changer l'adresse stockée par le pointeur. Ainsi il est possible de faire pointer g successivement sur plusieurs fonctions.

In [10]:
int f2(int i) {
    return 2*i ;
}

g = f2 ;
printf("%d\n", g(2)) ;
g = f ;
printf("%d\n", g(2)) ;
4
3
Out[10]:
(int) 2

Fonctions en paramètres

Les pointeurs de fonction permettent en particulier de passer des fonctions en paramètre d'autres fonctions pour déterminer le comportement d'un programme générique. Un exemple de fonction de la bibliothèque standard utilisant un paramètre de ce type est la fontcion qsort. Extrait de la page de manuel correspondante :

NAME qsort, qsort_r - sort an array

SYNOPSIS

  #include <stdlib.h>

  void qsort(void *base, size_t nmemb, size_t size,
             int (*compar)(const void *, const void *));

  void qsort_r(void *base, size_t nmemb, size_t size,
             int (*compar)(const void *, const void *, void *),
             void *arg);


Feature Test Macro Requirements for glibc (see feature_test_macros(7)):

  qsort_r(): _GNU_SOURCE

DESCRIPTION

  The  qsort() function sorts an array with nmemb elements of size size.  The base
  argument points to the start of the array.

  The contents of the array are sorted in ascending order according to a  compari‐
  son function pointed to by compar, which is called with two arguments that point
  to the objects being compared.

  The comparison function must return an integer less than, equal to,  or  greater
  than  zero  if  the  first  argument is considered to be respectively less than,
  equal to, or greater than the second.  If two members compare  as  equal,  their
  order in the sorted array is undefined.

  The  qsort_r() function is identical to qsort() except that the comparison func‐
  tion compar takes a third argument.  A pointer is passed to the comparison func‐
  tion  via arg.  In this way, the comparison function does not need to use global
  variables to pass through arbitrary arguments, and is  therefore  reentrant  and
  safe to use in threads.

RETURN VALUE

  The qsort() and qsort_r() functions return no value.

La fonction qsort sert à trier un tableau, quel que soit son contenu, et quelle que soit la façon de comparer les éléments contenus dans le tableau.

In [11]:
int compare_int(const void* i1, const void* i2) {
    const int* ii1 = (const int*) i1 ;
    const int* ii2 = (const int*) i2 ;
    return *ii1 - *ii2 ;
}
Out[11]:

In [12]:
{
    int tab[] = {3, 0, 1, 8, 6, 9, 4, 5, 7, 2} ;
    std::qsort(tab, 10, sizeof(int), compare_int) ;

    std::printf("[ ") ;
    for(int i = 0; i < 10; ++i) {
        std::printf("%d ", tab[i]) ;
    }
    std::printf("]\n") ;
}
[ 0 1 2 3 4 5 6 7 8 9 ]
Out[12]:

In [13]:
int compare_str(const void* p1, const void* p2) {
    const char** c1 = (const char**) p1 ;
    const char** c2 = (const char**) p2 ;
    return std::strcmp(*c1, *c2) ;
}
Out[13]:

In [14]:
{
    const char* tab[] = {"ludo", "mabrouka", "catherine", "jean-pierre", "said", "isabelle", "brigitte"} ;
    std::qsort(tab, 7, sizeof(char*), compare_str) ;

    std::printf("[ ") ;
    for(int i = 0; i < 5; ++i) {
        std::printf("%s ", tab[i]) ; 
    }
    std::printf("]\n") ;
}
[ brigitte catherine isabelle jean-pierre ludo ]
Out[14]:

Application pour l'affichage de tableaux dynamiques

Nous pouvons maintenant ajouter écrire une fonction d'affichage d'un tableau dynamique, et qui prend en paramètre la fonction d'affichage d'un élément. Écrivons une fonction d'affichage pour un entier passé via son adresse générique :

In [15]:
void print_int(const void* p) {
    const int* pi = (const int*) p ;
    printf("%d", *pi) ;
}
Out[15]:

Le type d'une telle fonction est donc : void (*)(const void*). Nous pouvons écrire la fonction d'affichage du tableau dynamique qui prend une telle fonction en paramètre. Attention pour interpréter le code qui suit, il est probablement nécessaire de réexécuter les cellules définissant le tableau dynamique plus haut, car nous avons tué l'interpréteur en tentant d'écrire à l'adresse d'une fonction.

In [16]:
void dyntab_print(const dyntab* tab, void (*elem_print)(const void* elem)) {
    printf("[ ") ;
    for(unsigned int i = 0; i < tab->taille; ++i) {
        elem_print(tab->data + i * tab->octets) ;
        printf(" ") ;
    }
    printf("]\n") ;
}
Out[16]:

Il est maintenant temps de tester notre implémentation !

In [17]:
{
    dyntab t ;
    dyntab_init(&t, sizeof(int)) ;

    for(int i = 0; i < 10; ++i) {
        dyntab_push(&t, &i) ;
    }

    dyntab_print(&t, print_int) ;

    dyntab_free(&t) ;
}
[ 0 1 2 3 4 5 6 7 8 9 ]
Out[17]:

Testont désormais notre implémentation pour un autre type de taille différente.

In [18]:
void print_double(const void* p) {
    const double* pd = (const double*) p ;
    printf("%g", *pd) ;
}
Out[18]:

In [19]:
{
    dyntab t ;
    dyntab_init(&t, sizeof(double)) ;

    for(int i = 1; i < 10; ++i) {
        double d = 1. / i ;
        dyntab_push(&t, &d) ;
    }

    dyntab_print(&t, print_double) ;

    dyntab_free(&t) ;
}
[ 1 0.5 0.333333 0.25 0.2 0.166667 0.142857 0.125 0.111111 ]
Out[19]:

Mise en garde sur l'alignement des données

Les données ne sont pas rangées complètement aléatoirement dans la mémoire, et les processeurs n'accèdent en réalité pas indifféremment à chaque octet de la mémoire. Le compilateur prend bien garde à respecter ces aspects. Un exemple de ce que fait le compilateur sans nous le dire :

In [20]:
struct bla1 {
    char c1 ;
    double d ;
    char c2 ;
} ;

struct bla2 {
    char c1 ;
    char c2 ;
    double d ;
} ;
Out[20]:

In [21]:
printf("%lu %lu %lu\n", sizeof(char), sizeof(double), sizeof(bla1)) ;
printf("%lu %lu %lu\n", sizeof(char), sizeof(double), sizeof(bla2)) ;
1 8 24
1 8 16
Out[21]:
(int) 7

Cette bizarrerie est dûe à l'alignement. Nous ne ferons pas un cours sur le sujet, mais vous devez maîtriser ces sujet si vous souhaitez aller plus loin sur ces notions de gestion de la mémoire.