From 7985d99686ac1a1e5510d75c7f5706c957168ca3 Mon Sep 17 00:00:00 2001 From: Yves Chevallier Date: Sat, 2 May 2020 20:08:09 +0200 Subject: [PATCH] Work on data-structures --- _templates/layout.html | 4 + assets/figures | 2 +- assets/src/memoize.c | 34 ++ content/20-datatype.rst | 7 +- content/40-functions.rst | 140 ++++++ content/55-memory-management.rst | 51 ++ content/77-algorithms.rst | 21 + content/78-translation-units.rst | 64 +-- content/82-testing.rst | 4 +- content/85-data-structures.rst | 769 +++++++++++++++++++++++++++---- content/90-advanced-topics.rst | 176 ++++++- index.rst | 5 +- 12 files changed, 1155 insertions(+), 122 deletions(-) create mode 100644 _templates/layout.html create mode 100644 assets/src/memoize.c diff --git a/_templates/layout.html b/_templates/layout.html new file mode 100644 index 0000000..ca03e16 --- /dev/null +++ b/_templates/layout.html @@ -0,0 +1,4 @@ +{% extends "!layout.html" %} +{% block extrahead %} + +{% endblock %} \ No newline at end of file diff --git a/assets/figures b/assets/figures index 375cf5c..1f0648a 160000 --- a/assets/figures +++ b/assets/figures @@ -1 +1 @@ -Subproject commit 375cf5cb0d22741143254e03fb547f3f8f1d7ec3 +Subproject commit 1f0648ac69b81b6ef746292ea72d00a297192de8 diff --git a/assets/src/memoize.c b/assets/src/memoize.c new file mode 100644 index 0000000..a198e90 --- /dev/null +++ b/assets/src/memoize.c @@ -0,0 +1,34 @@ +#include +#include + +#define SIZE 1000 + +bool cache_input[SIZE] = { false }; +int cache_output[SIZE]; + +int memoize(int input, int output) { + cache_input[input % SIZE] = true; + cache_output[input % SIZE] = output; + return output; +} + +bool memoize_has(int input) { + return cache_input[input % SIZE]; +} + +int memoize_get(int input) { + return cache_output[input % SIZE]; +} + +int fib(int n) +{ + if (memoize_has(n)) return memoize_get(n); + if (n < 2) return 1; + return memoize(n, fib(n - 1) + fib(n - 2)); +} + +int main() { + for (int i = 0; i < 40; i++) { + printf("%d\n", fib(i)); + } +} \ No newline at end of file diff --git a/content/20-datatype.rst b/content/20-datatype.rst index 3fb2406..930c19a 100644 --- a/content/20-datatype.rst +++ b/content/20-datatype.rst @@ -733,10 +733,15 @@ L'utilisation d'un type énuméré peut être la suivante : } } +Type incomplet +============== + +Un type incomplet est un qualificatif de type de donnée décrivant un objet dont sa taille en mémoire n'est pas connue. + Type vide (*void*) ================== -Le type ``void`` est particulier car c'est un type qui ne vaut rien. Il est utilisé comme type de retour pour les fonctions qui ne retournent rien : +Le type ``void`` est particulier. Il s'agit d'un type dit **incomplet** car la taille de l'objet qu'il représente en mémoire n'est pas connue. Il est utilisé comme type de retour pour les fonctions qui ne retournent rien : .. code-block:: c diff --git a/content/40-functions.rst b/content/40-functions.rst index b5c5075..b60fde4 100644 --- a/content/40-functions.rst +++ b/content/40-functions.rst @@ -279,6 +279,146 @@ En des termes plus corrects, mais nous verrons cela au chapitre sur les pointeur Retenez simplement que lors d'un passage par référence, on cherche à rendre la valeur passée en paramètre modifiable par le *caller*. +Récursion +========= + +La récursion, caractère d'un processus, d'un mécanisme récursif, c'est à dire qui peut être répété un nombre indéfini de fois par l'application de la même règle, est une méthode d'écriture dans laquelle une fonction s'appelle elle même. + +Au chapitre sur les fonctions, nous avions donné l'exemple du calcul de la somme de la suite de fibonacci jusqu'à ``n`` : + +.. code-block:: c + + int fib(int n) + { + int sum = 0; + int t1 = 0, t2 = 1; + int next_term; + for (int i = 1; i <= n; i++) + { + sum += t1; + next_term = t1 + t2; + t1 = t2; + t2 = next_term; + } + return sum; + } + +Il peut sembler plus logique de raisonner de façon récursive. Quelque soit l'itération à laquelle l'on soit, l'assertion suivante est valable : + + fib(n) == fib(n - 1) + fib(n - 2) + +Donc pourquoi ne pas réécrire cette fonction en employant ce caractère récursif ? + +.. code-block:: c + + int fib(int n) + { + if (n < 2) return 1; + return fib(n - 1) + fib(n - 2); + } + +Le code est beaucoup plus simple a écrire, et même à lire. Néanmoins cet algorithme est notoirement connu pour être mauvais en terme de performance. Calculer ``fib(5)`` revient à la chaîne d'appel suivant. + +Cette chaîne d'appel représente le nombre de fois que ``fib`` est appelé et à quel niveau elle est appelée. Par exemple ``fib(4)`` est appelé dans ``fib(5)`` : + +.. code-block:: text + + fib(5) + fib(4) + fib(3) + fib(2) + fib(1) + fib(2) + fib(1) + fib(0) + fib(3) + fib(2) + fib(1) + fib(0) + fib(1) + +Si l'on somme le nombre de fois que chacune de ces fonctions est appelée : + +.. code-block:: text + + fib(5) 1x + fib(4) 1x + fib(3) 2x + fib(2) 3x + fib(1) 4x + fib(0) 2x + ----------- + fib(x) 13x + +Pour calculer la somme de fibonacci, il faut appeler 13 fois la fonction. On le verra plus tard mais la complexité algoritmique de cette fonction est dite :math:`O(2^n)`. C'est à dire que le nombre d'appels suit une relation exponentielle. La réelle complexité est donnée par la relation : + +.. math:: + + T(n) = O\left(\frac{1+\sqrt(5)}{2}^n\right) = O\left(1.6180^n\right) + +Ce terme 1.6180 est appelé `le nombre d'or `__. + +Ainsi pour calculer fib(100) il faudra sept cent quatre-vingt-douze trillions soixante-dix mille huit cent trente-neuf billions huit cent quarante-huit milliards trois cent soixante-quatorze millions neuf cent douze mille deux cent quatre-vingt-douze appels à la fonction `fib` (792'070'839'848'374'912'292). Pour un processeur Core i7 (2020) capable de calculer environ 100 GFLOPS (milliards d'opérations par seconde), il lui faudra tout de même 251 ans. + +En revanche, dans l'approche itérative, on constate qu'une seule boucle ``for``. C'est à dire qu'il faudra seulement 100 itérations pour calculer la somme. + +Généralement les algorithmes récursifs (s'appelant eux même) sont moins performants que les algorithmes itératifs (utilisant des boucles). Néanmoins il est parfois plus facile d'écrire un algorithme récursif. + +Notons que tout algorithme récursif peut être écrit en un algorithme itératif, mais ce n'est pas toujours facile. + +Mémoïsation +=========== + +En informatique la `mémoïsation `__ est une technique d'optimisation du code souvent utilisée conjointement avec des algorithmes récursifs. Cette technique est largement utilisée en `programmation dynamique `__. + +Nous l'avons vu précédemment, l'algorithme récursif du calcul de la somme de la suite de Fibonacci n'est pas efficace du fait que les mêmes appels sont répétés un nombre inutiles de fois. La parade est de mémoriser pour chaque appel de ``fib``, la sortie correspondante à l'entrée. + +Dans cet exemple nous utiliserons un mécanisme composé de trois fonctions : + +- ``int memoize(Cache *cache, int input, int output)`` +- ``bool memoize_has(Cache *cache, int input)`` +- ``int memoize_get(Cache *cache, int input)`` + +La première fonction mémorise la valeur de sortie ``output`` liée à la valeur d'entrée ``input``. Pour des raisons de simplicité d'utilsation, la fonction retourne la valeur de sortie ``output``. + +La seconde fonction ``memoize_has`` vérifie si une valeur de correspondance existe pour l'entrée ``input``. Elle retourne ``true`` en cas de correspondance et ``false`` sinon. + +La troisième fonction ``memoize_get`` retourne la valeur de sortie correspondante à la valeur d'entrée ``input``. + +Notre fonction récursive sera ainsi modifiée comme suit : + +.. code-block:: c + + int fib(int n) + { + if (memoize_has(n)) return memoize_get(n); + if (n < 2) return 1; + return memoize(n, fib(n - 1) + fib(n - 2)); + } + +Quant aux trois fonctions utilitaires, voici une proposition d'implémentation. Notons que cette implémentation est très élémentaire et n'est valable que pour des entrées inférieures à 1000. Il sera possible ultérieurement de perfectionner ces fonctions, mais nous aurons pour cela besoin de concepts qui n'ont pas encore été abordés, tels que les structures de données complexes. + +.. code-block:: c + + #define SIZE 1000 + + bool cache_input[SIZE] = { false }; + int cache_output[SIZE]; + + int memoize(int input, int output) { + cache_input[input % SIZE] = true; + cache_output[input % SIZE] = output; + return output; + } + + bool memoize_has(int input) { + return cache_input[input % SIZE]; + } + + int memoize_get(int input) { + return cache_output[input % SIZE]; + } + ------ .. exercise:: Dans la moyenne diff --git a/content/55-memory-management.rst b/content/55-memory-management.rst index ba7a61c..356b470 100644 --- a/content/55-memory-management.rst +++ b/content/55-memory-management.rst @@ -59,6 +59,7 @@ Lorsqu'un programme à besoin de mémoire, il peut générer un appel système p L'allocation se fait sur le `tas` (*heap*) qui est de taille variable. À chaque fois qu'un espace mémoire est demandé, ``malloc`` recherche dans le segment un espace vide de taille suffisante, s'il ne parvient pas, il exécute l'appel système `sbrk `__ qui permet de déplacer la frontière du segment mémoire et donc d'agrandir le segment. +.. _fig_allocation: .. figure:: ../assets/figures/dist/memory/malloc.* Mémoire de programme @@ -265,3 +266,53 @@ allez piétiner les zones mémoires voisines sans en avoir la permission. Le compilateur (en réalité l'éditeur de liens - le *linker*) vous permet de spécifier la taille de la pile ; c'est une de ses nombreuses options. +Variables automatiques +====================== + +Une variable est dite *automatique* lorsque sa déclaration est faite au sein d'une fonction. La variable d'itération ``int i`` dans une boucle ``for`` est dite automatique. C'est à dire que le compilateur à le choix de placer cette variable : + +- sur la pile ; +- dans un registre mémoire processeur. + +Jadis, le mot clé ``register`` était utiliser pour forcer le compilateur à placer une variable locale dans un registre processeur pour obtenir de meilleures performances. Aujourd'hui, les compilateurs sont assez malins pour déterminer automatiquement les variables souvent utilisées. + +Fragmentation mémoire +===================== + +On peut observer à la figure :numref:`fig_allocation` qu'après un appel successif de ``malloc`` et de ``free`` des espaces mémoires non utilisés peuvent apparaître entre des régions utilisées. Ces *trous* sont appelés fragmentation mémoire. + +Dans la figure suivante, on suit l'évolution de l'utilisation du *heap* au cours de la vie d'un programme. Au début ➀, la mémoire est libre. Tant que de la mémoire est allouée sans libération (``free``), aucun problème de fragmentation ➁. Néanmoins, après un certain temps la mémoire devient fragmentée ➂ ; il reste dans cet exemple 2 emplacements de taille 2, un emplacement de taille 5 et un emplacement de taille 8. Il est donc impossible de réserver un espace de taille 9 malgré que l'espace cumulé libre est suffisant. + +.. figure:: ../assets/figures/dist/memory/fragmentation.* + +Dans une petite architecture, l'allocation et la libération fréquente d'espaces mémoire de taille arbitraire est malvenue. Une fois que la fragmentation mémoire est installée, il n'existe aucun moyen de soigner le mal si ce n'est au travers de l'ultime solution de l'informatique : `éteindre puis redémarrer `__. + +MMU +--- + +Les systèmes d'exploitations modernes (Windows, Linux, macOS...) utilisent tous un dispositif matériel nommé `MMU `__ pour *Memory Management Unit*. La MMU est en charge de créer un espace mémoire **virtuel** entre l'espace physique. Cela crée une indirection supplémentaire mais permet de réorganiser la mémoire physique sans compromettre le système. + +En pratique l'espace de mémoire virtuelle est toujours beaucoup plus grand que l'espace physique. Cela permet de s'affranchir dans une large mesure de problèmes de fragmentation car si l'espace virtuel est suffisament grand, il y aura statistiquement plus de chance d'y trouver un emplacement non utilisé. + +La programmation sur de petites architectures matérielles (microcontrôleurs, DSP) ne possèdent pas de MMU et dès lors l'allocation dynamique est généralement à proscrire à moins qu'elle soit faite en connaissance de cause et en utilisant des mécanisme comme les *memory pool*. + +Dans la figure ci-dessous. La mémoire physique est représentée à droite en termes de pages mémoires physiques (*Physical Pages* ou **PP**). Il s'agit de blocs mémoires contigus d'une taille fixe, par exemple 64 kB. Chaque page physique est mappée dans une table propre à chaque processus (programme exécutable). On y retrouve quelques proriétés utiles à savoir est-ce que la page mémoire est accessible en écriture, est-ce qu'elle peut contenir du code exécutable ? Une propriété peut indiquer par exemple si la page mémoire est valide. Chacune de ces entrées est considérée comme une page mémoire virtuelle (*virtual page* **VP**). + +.. figure:: ../assets/figures/dist/memory/mmu.* + +Erreurs de segmentation (*segmentation fault*) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Lorsqu'un programme tente d'accéder à un espace mémoire qui n'est pas mappé dans la MMU, ou que cet espace mémoire ne permet pas le type d'accès souhaité : par exemple une écriture dans une page en lecture seule. Le système d'exploitation tue le processus avec une erreur *Segmentation Fault*. C'est la raison pour laquelle, il n'est pas systématique d'avoir une erreur de segmentation en cas de jarinage mémoire. Tant que les valeurs modifées sont localisées au sein d'un bloc mémoire autorisé, il n'y aura pas d'erreur. + +L'erreur de segmentation est donc générée par le système d'exploitation en lèvant le signal **SIGSEGV** (Violation d'accès à un segment mémoire, où erreur de segmentation). + +Memory Pool +----------- + +Un *memory pool* est une méthode faisant appel à de l'allocation dynamique de blocs de taille fixe. Lorsqu'un programme doit très régulièrement allouer et désalouer de la mémoire, il est préférable que les blocs mémoire ait une taille fixe. De cette façon, après un ``free``, la mémoire libérée est assez grande pour une allocation ultérieure. + +Lorsqu'un programme est exécuté sous Windows, macOS ou Linux, l'allocation dynamique standard ``malloc``, ``calloc``, ``realloc`` et ``free`` sont performants et le risque de crash dû à une fragmentation mémoire est rare. + +En revanche lors de l'utilisation sur de petites architectures (microcontrôleurs) qui n'ont pas de système sophistiqués pour gérer la mémoire, il est parfois nécessaire d'écrire son propre système de gestion de mémoire. + diff --git a/content/77-algorithms.rst b/content/77-algorithms.rst index e6bdf3c..7c5c5fd 100644 --- a/content/77-algorithms.rst +++ b/content/77-algorithms.rst @@ -177,6 +177,27 @@ Programmation dynamique .. todo:: Compléter +Algorithmes de tris +=================== + +Heap Sort +--------- + + + +.. code-block:: text + + 8 + | + ----+---- + / \ + 4 12 + -- -- + / \ / \ + 20 6 42 14 + / \ / \ / \ / \ + 11 3 35 7 9 11 50 16 + ----- .. exercise:: Intégrateur de Kahan diff --git a/content/78-translation-units.rst b/content/78-translation-units.rst index 4e5ef20..30dc7a0 100644 --- a/content/78-translation-units.rst +++ b/content/78-translation-units.rst @@ -1,4 +1,6 @@ +.. _TranslationUnits: + =================== Compilation séparée =================== @@ -31,7 +33,7 @@ Ainsi, lorsque le programme commence à être volumineux, sa lecture, sa compré ├── complex.h └── main.c -Le programme principal et la fonction ``main`` est contenu dans ``main.c`` quant au module *complex* il est composé de deux fichiers : ``complex.h`` l'en-tête et ``complex.c``, l'implémentation du module. +Le programme principal et la fonction ``main`` est contenu dans ``main.c`` quant au module *complex* il est composé de deux fichiers : ``complex.h`` l'en-tête et ``complex.c``, l'implémentation du module. Le fichier ``main.c`` devra inclure le fichier ``complex.h`` afin de pourvoir utiliser correctement les fonctions du module de gestion des @@ -44,7 +46,7 @@ nombres complexes. Exemple : int main() { Complex c1 = { .real = 1., .imag = -3. }; - complex_fprint(stdout, c1); + complex_fprint(stdout, c1); } .. code-block:: c @@ -86,43 +88,43 @@ Cet exemple sera compilé dans un environnement POSIX de la facon suivante : gcc -c main.c -o main.o gcc complex.o main.o -oprogram -lm -Nous verrons plus bas les éléments théoriques vous permettant de mieux comprendre ces lignes. +Nous verrons plus bas les éléments théoriques vous permettant de mieux comprendre ces lignes. Module logiciel =============== -Les applications modernes dépendent souvent de nombreux modules logiciels externes aussi utilisés dans d'autres projets. C'est avantageux à plus d'un titre : +Les applications modernes dépendent souvent de nombreux modules logiciels externes aussi utilisés dans d'autres projets. C'est avantageux à plus d'un titre : - les modules externes sont sous la responsabilité d'autres développeurs et le programme a développer comporte moins de code ; - les modules externes sont souvent bien documentés et testés et il est facile de les utiliser ; - la lisibilité du programme est accrue car il est bien découpé en des ensembles fonctionnels ; - les modules externes sont réutilisables et indépendants, ils peuvent donc être réutilisés sur plusieurs projets. -Lorsque vous utiliser la fonction ``printf``, vous dépendez d'un module externe nommé ``stdio``. En réalité l'ensemble des modules ``stdio``, ``stdlib``, ``stdint``, ``ctype``... sont tous groupé dans une seule bibliothèque logicielle nommée ``libc`` disponible sur tous les systèmes compatibles POSIX. Sous Linux, le pendant libre ``glibc`` est utilisée. Il s'agit de la biblothèque `GNU C Library `__. +Lorsque vous utiliser la fonction ``printf``, vous dépendez d'un module externe nommé ``stdio``. En réalité l'ensemble des modules ``stdio``, ``stdlib``, ``stdint``, ``ctype``... sont tous groupé dans une seule bibliothèque logicielle nommée ``libc`` disponible sur tous les systèmes compatibles POSIX. Sous Linux, le pendant libre ``glibc`` est utilisée. Il s'agit de la biblothèque `GNU C Library `__. -Un module logiciel peut se composer de fichiers sources, c'est à dire un ensemble de fichiers ``.c`` et ``.h`` ainsi qu'une documentation et un script de compilation (``Makefile``). Alternativement, un module logiciel peut se composer de bibliothèques déjà compilées sous la forme de fichiers ``.h``, ``.a`` et ``.so``. Sous Windows on rencontre fréquemment l'extension ``.dll``. Ces fichiers compilés ne donnent pas accès au code source mais permettent d'utiliser les fonctionnalités quelles offrent dans des programmes C en mettant à disposition un ensemble de fonctions documentées. +Un module logiciel peut se composer de fichiers sources, c'est à dire un ensemble de fichiers ``.c`` et ``.h`` ainsi qu'une documentation et un script de compilation (``Makefile``). Alternativement, un module logiciel peut se composer de bibliothèques déjà compilées sous la forme de fichiers ``.h``, ``.a`` et ``.so``. Sous Windows on rencontre fréquemment l'extension ``.dll``. Ces fichiers compilés ne donnent pas accès au code source mais permettent d'utiliser les fonctionnalités quelles offrent dans des programmes C en mettant à disposition un ensemble de fonctions documentées. Compilation avec assemblage différé =================================== -Lorsque nous avions compilé notre premier exemple `Hello World `__ nous avions simplement appelé ``gcc`` avec le fichier source ``hello.c`` qui nous avait créé un exécutable ``a.out``. En réalité, GCC est passé par plusieurs sous étapes de compilation : +Lorsque nous avions compilé notre premier exemple `Hello World `__ nous avions simplement appelé ``gcc`` avec le fichier source ``hello.c`` qui nous avait créé un exécutable ``a.out``. En réalité, GCC est passé par plusieurs sous étapes de compilation : 1. **Préprocessing** : les commentaires sont retirés, les directives pré-processeur sont remplacées par leur équivalent C. 2. **Compilation** : le code C d'une seule *translation unit* est converti en langage machine en un fichier objet ``.o``. 3. **Édition des liens** : aussi nommé *link*, les différents fichiers objets sont réunis en un seul exécutable. -Lorsqu'un seul fichier est fourni à GCC, les trois opérations sont effectuées en même temps mais ce n'est plus possible aussitôt que le programme est composé de plusieurs unités de translation (plusieurs fichiers C). Il est alors nécessaire de compiler manuellement chaque fichier source et d'en créer. +Lorsqu'un seul fichier est fourni à GCC, les trois opérations sont effectuées en même temps mais ce n'est plus possible aussitôt que le programme est composé de plusieurs unités de translation (plusieurs fichiers C). Il est alors nécessaire de compiler manuellement chaque fichier source et d'en créer. -La figure suivante résume les différentes étapes de GCC. Les pointillés indiquent à quel niveau les opérations peuvent s'arrêter. Il est dès lors possible de passer par des fichiers intermédiaires assembleur (``.s``) ou objets (``.o``) en utilisant la bonne commande. +La figure suivante résume les différentes étapes de GCC. Les pointillés indiquent à quel niveau les opérations peuvent s'arrêter. Il est dès lors possible de passer par des fichiers intermédiaires assembleur (``.s``) ou objets (``.o``) en utilisant la bonne commande. .. figure:: ../assets/figures/dist/toolchain/gcc.* -Notons que ces étapes existent quelque soit le compilateur ou le système d'exploitation. Nous retrouverons ces exactes mêmes étapes avec Microsoft Visual Studio mais le nom des commandes et les extensions des fichiers peuvent varier s'ils ne respectent pas la norme POSIX (et GNU). +Notons que ces étapes existent quelque soit le compilateur ou le système d'exploitation. Nous retrouverons ces exactes mêmes étapes avec Microsoft Visual Studio mais le nom des commandes et les extensions des fichiers peuvent varier s'ils ne respectent pas la norme POSIX (et GNU). -Notons que généralement, seul deux étapes de GCC sont utilisées : +Notons que généralement, seul deux étapes de GCC sont utilisées : 1. Compilation avec ``gcc -c ``, ceci génère automatiquement un fichier ``.o`` du même nom que le fichier d'entrée. -2. Édition des liens avec ``gcc ...``, ceci génère automatiquement un fichier exécutable ``a.out``. +2. Édition des liens avec ``gcc ...``, ceci génère automatiquement un fichier exécutable ``a.out``. Fichiers d'en-tête (*header*) ============================= @@ -133,7 +135,7 @@ Les fichiers d'en-tête (``.h``) sont des fichiers écrits en langage C mais qui - Des déclaration de types (``typedef``, ``struct``). - Des définitions pré-processeur (``#include``, ``#define``). -Nous l'avons vu dans le chapitre sur le pré-processeur, la directive ``#include`` ne fait qu'inclure le contenu du fichier cible à l'emplacement de la directive. Il est donc possible (mais fort déconseillé), d'avoir la situation suivante : +Nous l'avons vu dans le chapitre sur le pré-processeur, la directive ``#include`` ne fait qu'inclure le contenu du fichier cible à l'emplacement de la directive. Il est donc possible (mais fort déconseillé), d'avoir la situation suivante : .. code-block:: c @@ -142,32 +144,32 @@ Nous l'avons vu dans le chapitre sur le pré-processeur, la directive ``#include #include "foobar.def" } -Et le fichier ``foobar.def`` pourrait cotenir : +Et le fichier ``foobar.def`` pourrait cotenir : .. code-block:: c - // foobar.def - #ifdef FOO + // foobar.def + #ifdef FOO printf("hello foo!\n"); #else printf("hello bar!\n"); #endif -Vous noterez que l'extension de ``foobar`` n'est pas ``.h`` puisque le contenu n'est pas un fichier d'en-tête. ``.def`` ou n'importe quelle autre extension pourrait donc faire l'affaire ici. +Vous noterez que l'extension de ``foobar`` n'est pas ``.h`` puisque le contenu n'est pas un fichier d'en-tête. ``.def`` ou n'importe quelle autre extension pourrait donc faire l'affaire ici. -Dans cet exemple, le pré-processeur ne fait qu'inclure le contenu du fichier ``foobar.def`` à l'emplacement de la définition ``#include "foobar.def"``. Voyons le en détail : +Dans cet exemple, le pré-processeur ne fait qu'inclure le contenu du fichier ``foobar.def`` à l'emplacement de la définition ``#include "foobar.def"``. Voyons le en détail : -.. code-block:: c +.. code-block:: console $ cat << EOF > main.c → int main() { → #include "foobar.def" - → #include "foobar.def" + → #include "foobar.def" → } → EOF $ cat << EOF > foobar.def - → #ifdef FOO + → #ifdef FOO → printf("hello foo!\n"); → #else → printf("hello bar!\n"); @@ -177,12 +179,12 @@ Dans cet exemple, le pré-processeur ne fait qu'inclure le contenu du fichier `` $ gcc -E main.c | sed '/^#/ d' int main() { printf("hello bar\n"); - printf("hello bar\n"); + printf("hello bar\n"); } Lorsque l'on observe le résultat du pré-processeur, on s'aperçois que toutes les directives préprocesseur ont disparues et que la directive ``#include`` a été remplacée par de contenu de ``foobar.def``. Remarquons que le fichier est inclus deux fois, nous verrons plus loin comme éviter cela. -Nous avons vu au chapitre sur les `prototypes de fonctions `__ qu'il est possible de ne déclarer que la première ligne d'une fonction. Ce prototype permet au compilateur de savoir combien d'arguments est composé une fonction sans nécessairement disposer de l'implémentation de cette fonction. Aussi on trouve dans tous les fichiers d'en-tête des déclaration en amont (*forward declaration*). Dans le fichier d'en-tête ``stdio.h`` on trouvera la ligne : ``int printf( const char *restrict format, ... );``. +Nous avons vu au chapitre sur les `prototypes de fonctions `__ qu'il est possible de ne déclarer que la première ligne d'une fonction. Ce prototype permet au compilateur de savoir combien d'arguments est composé une fonction sans nécessairement disposer de l'implémentation de cette fonction. Aussi on trouve dans tous les fichiers d'en-tête des déclaration en amont (*forward declaration*). Dans le fichier d'en-tête ``stdio.h`` on trouvera la ligne : ``int printf( const char *restrict format, ... );``. .. code-block::c @@ -201,7 +203,7 @@ Un fichier d'en-tête contiendra donc tout le nécessaire utile à pouvoir utili Protection de réentrance ------------------------ -La protection de réentrence aussi nommée *header guards* est une solution au problème d'inclusion multiple. Si par exemple on défini dans un fichier d'en-tête un nouveau type et que l'on inclus ce fichier, mais que ce dernier est déjà inclu par une autre bibliothèque une erreur de compilation apparaîtera : +La protection de réentrence aussi nommée *header guards* est une solution au problème d'inclusion multiple. Si par exemple on défini dans un fichier d'en-tête un nouveau type et que l'on inclus ce fichier, mais que ce dernier est déjà inclu par une autre bibliothèque une erreur de compilation apparaîtera : .. code-block:: console @@ -242,7 +244,7 @@ La protection de réentrence aussi nommée *header guards* est une solution au p ^~~ ... -Dans cet exemple l'utilisateur ne sait pas forcément que ``bar.h`` est déjà inclus avec ``foo.h`` et le résultat après pré-processing est le suivant : +Dans cet exemple l'utilisateur ne sait pas forcément que ``bar.h`` est déjà inclus avec ``foo.h`` et le résultat après pré-processing est le suivant : .. code-block:: console @@ -260,11 +262,11 @@ Dans cet exemple l'utilisateur ne sait pas forcément que ``bar.h`` est déjà i foo(bar); } -On y retrouve la définition de ``Bar`` deux fois et donc, le compilateur génère une erreur. +On y retrouve la définition de ``Bar`` deux fois et donc, le compilateur génère une erreur. -Une solution à ce problème est d'ajouter des gardes d'inclusion multiple par exemple avec ceci: +Une solution à ce problème est d'ajouter des gardes d'inclusion multiple par exemple avec ceci: -.. code-block:: c +.. code-block:: c #ifndef BAR_H #define BAR_H @@ -275,11 +277,11 @@ Une solution à ce problème est d'ajouter des gardes d'inclusion multiple par e #endif // BAR_H -Si aucune définition du type ``#define BAR_H`` n'existe, alors le fichier ``bar.h`` n'a jamais été inclus auparavant et le contenu de la directive ``#ifndef BAR_H`` dans lequel on commence par définir ``BAR_H`` est exécuté. Lors d'une future inclusion de ``bar.h``, la valeur de ``BAR_H`` aura déjà été définie et le contenu de la directive ``#ifndef BAR_H`` ne sera jamais exécuté. +Si aucune définition du type ``#define BAR_H`` n'existe, alors le fichier ``bar.h`` n'a jamais été inclus auparavant et le contenu de la directive ``#ifndef BAR_H`` dans lequel on commence par définir ``BAR_H`` est exécuté. Lors d'une future inclusion de ``bar.h``, la valeur de ``BAR_H`` aura déjà été définie et le contenu de la directive ``#ifndef BAR_H`` ne sera jamais exécuté. Alternativement, il existe une solution **non standard** mais supportée par la plupart des compilateurs. Elle fait intervenir un pragma : -.. code-block:: c +.. code-block:: c #pragma once @@ -287,4 +289,4 @@ Alternativement, il existe une solution **non standard** mais supportée par la int b, a, r; } Bar; -Cette solution est équivalente à la méthode traditionnelle et présente plusieurs avantages. C'est tout d'abord une solution atomique qui ne nécessite pas un ``#endif`` à la fin du fichier. Il n'y a ensuite pas de conflit avec la règle SSOT car le nom du fichier ``bar.h`` n'apparaît pas dans le fichier ``BAR_H``. +Cette solution est équivalente à la méthode traditionnelle et présente plusieurs avantages. C'est tout d'abord une solution atomique qui ne nécessite pas un ``#endif`` à la fin du fichier. Il n'y a ensuite pas de conflit avec la règle SSOT car le nom du fichier ``bar.h`` n'apparaît pas dans le fichier ``BAR_H``. diff --git a/content/82-testing.rst b/content/82-testing.rst index 6a0196f..f9207e7 100644 --- a/content/82-testing.rst +++ b/content/82-testing.rst @@ -61,7 +61,7 @@ Considérons le programme suivant : A priori, c'est un programme tout à fait correct. Si l'utilisateur entre le bon mot de passe, il se voit octroyé des privilèges administrateurs. Testons ce programme : -.. code-block:: c +.. code-block:: console $ gcc u.c -fno-stack-protector $ ./a.out @@ -71,7 +71,7 @@ A priori, c'est un programme tout à fait correct. Si l'utilisateur entre le bon Très bien, maintenant testons avec un mauvais mot de passe : -.. code-block:: c +.. code-block:: console $ ./a.out Password: startrek diff --git a/content/85-data-structures.rst b/content/85-data-structures.rst index 6010c09..fa22997 100644 --- a/content/85-data-structures.rst +++ b/content/85-data-structures.rst @@ -1,54 +1,154 @@ -============================== -Structures de données avancées -============================== +===================== +Structures de données +===================== + +Types de données abstraits +========================== + +Un `type de donnée abstrait `__ (**ADT** pour Abstract Data Type) cache généralement une structure dont le contenu n'est pas connu de l'utilisateur final. Ceci est rendu possible par le standard (C99 §6.2.5) par l'usage de types incomplets. + +Pour mémoire, un type incomplet décrit un objet dont on ne connaît pas sa taille en mémoire. + +L'exemple suivant déclare un nouveau type structure qui n'est alors pas (encore) connu dans le fichier courant : + +.. code-block:: c + + typedef struct Unknown *Known; + + int main() { + Known foo; // Autorisé, le type est incomplet + + foo + 1; // Impossible car la taille de foo est inconnue. + foo->key; // Impossible car le type est incomplet. + } + +De façon générale, les types abstraits sont utilisés dans l'écriture de bibliothèques logicielles lorsqu'il est important que l'utilisateur final ne puisse pas compromettre le contenu du type et en forcant cet utilisateur à ne passer que par des fonctions d'accès. + +Prenons le cas du fichier `foobar.c` lequel décrit une structure ``struct Foo`` et un type ``Foo``. Notez que le type peut être déclaré avant la structure. ``Foo`` restera abstrait jusqu'à la déclaration complète de la structure ``struct Foo`` permettant de connaître sa taille. Ce fichier contient également trois fonctions : + +- ``init`` permet d'initialiser la structure ; +- ``get`` permet de récupérer la valeur contenue dans ``Foo`` ; +- ``set`` permet d'assigner une valeur à ``Foo``. + +En plus, il existe un compteur d'accès ``count`` qui s'incrémente lorsque l'on assigne une valeur et se décrémente lorsque l'on récupère une valeur. + +.. code-block:: c + + #include + + typedef struct Foo Foo; + + struct Foo { + int value; + int count; + }; + + void init(Foo** foo) { + *foo = malloc(sizeof(Foo)); // Allocation dynamique + (*foo)->count = (*foo)->value = 0; + } + + int get(Foo* foo) { + foo->count--; + return foo->value; + } + + void set(Foo* foo, int value) { + foo->count++; + foo->value = value; + } + +Evidemment, on ne souhaite pas qu'un petit malin compromette ce compteur en écrivant maladroitement : + +.. code-block:: c + + foo->count = 42; // Hacked this ! + +Pour s'en protéger on a recours à la compilation séparée (voir chapitre TranslationUnits__) dans laquelle le programme est découpé en plusieurs fichiers. Le fichier ``foobar.h`` contiendra tout ce qui doit être connu du programme principal, à savoir les prototypes des fonctions, et le type abstrait : + +.. code-block:: c + + #pragma once + + typedef struct Foo Foo; + + void init(Foo** foo); + int get(Foo* foo); + void set(Foo* foo, int value); + +Ce fichier sera inclu dans le programme principal ``main.c`` : + +.. code-block:: c + + #include "foobar.h" + #include + + int main() { + Foo *foo; + + init(&foo); + set(foo, 23); + printf("%d\n", get(foo)); + } + +En résumé, un type abstrait impose l'ulisation de fonctions intermédiaires pour modifier le type. Dans la grande majorité des cas, ces types représentent des structures qui contiennent des informations internes qui ne sont pas destinées à être modifiées par l'utilisateur final. Tableau dynamique ================= -Un tableau dynamique aussi appelé *vecteur* est, comme son nom l'indique, alloué dynamiquement dans le *heap* en fonction des besoins. Vous vous rappelez que le *heap* grossit à chaque appel de ``malloc`` et diminue à chaque appel de ``free``. +Un tableau dynamique aussi appelé *vecteur* est, comme son nom l'indique, alloué dynamiquement dans le *heap* en fonction des besoins. Vous vous rappelez que le *heap* grossit à chaque appel de ``malloc`` et diminue à chaque appel de ``free``. -Un tableau dynamique est souvent spécifié par un facteur de croissance (rien à voir avec les hormones). Lorsque le tableau est plein et que l'on souhaite rajouter un nouvel élément, le tableau est réalloué dans un autre espace mémoire plus grand avec la fonction ``realloc``. Cette dernière n'est rien d'autre qu'un ``malloc`` suivi d'un ``memcopy`` suivi d'un ``free``. Un nouvel espace mémoire est réservé, les données sont copiées du premier espace vers le nouveau, et enfin le premier espace est libéré. Voici un exemple : +Un tableau dynamique est souvent spécifié par un facteur de croissance (rien à voir avec les hormones). Lorsque le tableau est plein et que l'on souhaite rajouter un nouvel élément, le tableau est réalloué dans un autre espace mémoire plus grand avec la fonction ``realloc``. Cette dernière n'est rien d'autre qu'un ``malloc`` suivi d'un ``memcopy`` suivi d'un ``free``. Un nouvel espace mémoire est réservé, les données sont copiées du premier espace vers le nouveau, et enfin le premier espace est libéré. Voici un exemple : .. code-block:: c - char *buffer = malloc(3); // Alloue un espace de trois chars - buffer[0] = 'h'; + // Alloue un espace de trois chars + char *buffer = malloc(3); + + // Rempli le buffer + buffer[0] = 'h'; buffer[1] = 'e'; - buffer[2] = 'l'; // Maintenant le buffer est plein... - *buffer = realloc(5); // Réalloue avec un espace de cinq chars - buffer[3] = 'l'; - buffer[4] = 'o'; // Maintenant le buffer est à nouveau plein... + buffer[2] = 'l'; // Le buffer est plein... + + // Augmente dynamiquemenmt la taille du buffer à 5 chars + *buffer = realloc(5); + + // Continue de remplir le buffer + buffer[3] = 'l'; + buffer[4] = 'o'; // Le buffer est à nouveau plein... + + // Libère l'espace mémoire utilisé free(buffer); -La taille du nouvel espace mémoire est plus grande d'un facteur donné que l'ancien espace. Selon les langages de programmation et les compilateurs, ces facteurs sont compris entre 3/2 et 2. C'est à dire que la taille du tableau prendra les tailles de 1, 2, 4, 8, 16, 32, etc. +La taille du nouvel espace mémoire est plus grande d'un facteur donné que l'ancien espace. Selon les langages de programmation et les compilateurs, ces facteurs sont compris entre 3/2 et 2. C'est à dire que la taille du tableau prendra les tailles de 1, 2, 4, 8, 16, 32, etc. Lorsque le nombre d'éléments du tableau devient inférieur du facteur de croissance à la taille effective du tableau, il est possible de faire l'opération inverse, c'est-à-dire réduire la taille allouée. En pratique cette opération est rarement implémentée, car peu efficace (c.f. `cette `__ réponse sur stackoverflow). -Anatomie +Anatomie -------- -Un tableau est représenté en mémoire comme un contenu séquentiel qui possède un début et une fin. On appelle son début la "tête" ou *head* et la fin du tableau sa "queue" ou *tail*. Selon que l'on souhaite ajouter des éléments au début ou à la fin du tableau la complexité n'est pas la même. +Un tableau dynamique est représenté en mémoire comme un contenu séquentiel qui possède un début et une fin. On appelle son début la **tête** ou *head* et la fin du tableau sa **queue** ou *tail*. Selon que l'on souhaite ajouter des éléments au début ou à la fin du tableau la complexité n'est pas la même. -Nous définirons par la suite le vocabulaire suivant: +Nous définirons par la suite le vocabulaire suivant: ============================================== =============== -Action Terme technique + Action Terme technique ============================================== =============== -Ajout d'un élément à la tête du tableau `unshift` -Ajout d'un élément à la queue du tableau `push` -Suppression d'un élément à la tête du tableau `shift` -Suppression d'un élément à la queue du tableau `pop` +Ajout d'un élément à la tête du tableau `unshift` +Ajout d'un élément à la queue du tableau `push` +Suppression d'un élément à la tête du tableau `shift` +Suppression d'un élément à la queue du tableau `pop` ============================================== =============== -Nous comprenons rapidement qu'il est plus compliqué d'ajouter ou de supprimer un élément depuis la tête du tableau, car il est nécessaire ensuite de déplacer chaque élément (l'élément 0 devient l'élément 1, l'élément 1 devient l'élément 2...). +Nous comprenons rapidement qu'il est plus compliqué d'ajouter ou de supprimer un élément depuis la tête du tableau, car il est nécessaire ensuite de déplacer chaque élément (l'élément 0 devient l'élément 1, l'élément 1 devient l'élément 2...). Un tableau dynamique peut être représenté par la figure suivante : .. figure:: ../assets/figures/dist/data-structure/dyn-array.* -Un espace mémoire est réservé dynamiquement sur le tas. Comme ``malloc`` ne retourne pas la taille de l'espace mémoire alloué mais juste un pointeur sur cet espace, il est nécessaire de conserver dans une variable la capacité du tableau. Notons qu'un tableau de 10 ``int32_t`` représentera un espace mémoire de 4x10 bytes, soit 40 bytes. La mémoire ainsi réservée par ``malloc`` n'est généralement pas vide mais elle contient des valeurs, vestige d'une ancienne allocation mémoire d'un d'autre programme depuis que l'ordinateur a été allumé. Pour connaître le nombre d'éléments effectifs du tableau il faut également le mémoriser. Enfin, le pointeur sur l'espace mémoire est aussi mémorisé. +Un espace mémoire est réservé dynamiquement sur le tas. Comme ``malloc`` ne retourne pas la taille de l'espace mémoire alloué mais juste un pointeur sur cet espace, il est nécessaire de conserver dans une variable la capacité du tableau. Notons qu'un tableau de 10 ``int32_t`` représentera un espace mémoire de 4x10 bytes, soit 40 bytes. La mémoire ainsi réservée par ``malloc`` n'est généralement pas vide mais elle contient des valeurs, vestige d'une ancienne allocation mémoire d'un d'autre programme depuis que l'ordinateur a été allumé. Pour connaître le nombre d'éléments effectifs du tableau il faut également le mémoriser. Enfin, le pointeur sur l'espace mémoire est aussi mémorisé. -Les composants de cette structure de donnée sont donc : +Les composants de cette structure de donnée sont donc : - Un entier non signé ``size_t`` représentant la capacité totale du tableau dynamique à un instant T. - Un entier non signé ``size_t`` représentant le nombre d'éléments effectivement dans le tableau. @@ -64,7 +164,7 @@ L'opération ``pop`` retire l'élément de la fin du tableau. Le nombre d'élém if (elements <= 0) exit(EXIT_FAILURE); int value = data[--elements]; -L'opération ``push`` ajoute un élément à la fin du tableau. +L'opération ``push`` ajoute un élément à la fin du tableau. .. figure:: ../assets/figures/dist/data-structure/dyn-array-push.* @@ -104,7 +204,7 @@ Enfin, l'opération ``unshift`` ajoute un élément depuis le début du tableau data[k] = data[k - 1]; data[0] = value; -Dans le cas ou le nombre d'éléments atteint la capacité maximum du tableau, il est nécessaire de réallouer l'espace mémoire avec ``realloc``. Généralement on se contente de doubler l'espace alloué. +Dans le cas ou le nombre d'éléments atteint la capacité maximum du tableau, il est nécessaire de réallouer l'espace mémoire avec ``realloc``. Généralement on se contente de doubler l'espace alloué. .. code-block:: c @@ -112,28 +212,134 @@ Dans le cas ou le nombre d'éléments atteint la capacité maximum du tableau, i data = realloc(data, capacity *= 2); } -Piles ou LIFO (*Last In First Out*) -=================================== +Buffer circulaire +================== -Une pile est une structure de donnée similaire à un tableau dynamique dans laquelle il n'est possible que : +Un tampon circulaire est généralement d'une taille fixe et possède deux pointeurs. L'un pointant sur le dernier élément (*tail*) et l'un sur le premier élément (*head*). -- d'ajouter un élément (*push*) -- retirer un élément (*pop*) -- obtenir le dernier élément (*peek*) -- tester si la pile est vide (*is_empty*) -- tester si la pile est pleine avec (*is_full*) +Lorsqu'un élément est supprimé du buffer, le pointeur de fin est incrémenté. Lorsqu'un élément est ajouté, le pointeur de début est incrémenté. -Queues ou FIFO (*First In First Out*) -===================================== +Pour permettre la circulation, les indices sont calculés modulo la taille du buffer. + +Il est possible de représenter schématiquement ce buffer comme un cercle et ses deux pointeurs : + +.. figure:: ../assets/figures/dist/data-structure/ring.* + +Le nombre d'éléments dans le buffer est la différence entre le pointeur de tête et le pointeur de queue, modulo la taille du buffer. Néanmoins, l'opérateur ``%`` en C ne fonctionne que sur des nombres positifs et ne retourne pas le résidu positif le plus petit. En sommes, ``-2 % 5`` devrait donner ``3``, ce qui est le cas en Python, mais en C, en C++ ou en PHP la valeur retournée est ``-2``. Le modulo vrai, mathématiquement correct peut être calculé ainsi : + +.. code-block:: c + + ((A % M) + M) % M + +Les indices sont bouclés sur la taille du buffer, l'élément suivant est donc défini par : + +.. code-block:: c -Une queue est similaire à un tableau dynamique dans laquelle il n'est possible que : + (i + 1) % SIZE -- ajouter un élément à la queue (*push*) aussi nommé *enqueue* -- supprimer un élément au début de la queue (*shift*) aussi nommé *dequeue* +Voici une implémentation possible du buffer circulaire : + +.. code-block:: c + + #define SIZE 16 + #define MOD(A, M) (((A % M) + M) % M) + #define NEXT(A) (((A) + 1) % SIZE) + + typedef struct Ring { + int buffer[SIZE]; + int head; + int tail; + } Ring; + + void init(Ring *ring) { + ring->head = ring->tail = 0; + } + + int count(Ring *ring) { + return MOD(ring->head - ring->tail, size); + } + + bool is_full(Ring *ring) { + return count(ring) == SIZE - 1; + } + + bool is_empty(Ring *ring) { + return ring->tail == ring->head; + } + + int* enqueue(Ring *ring, int value) { + if (is_full(ring)) return NULL; + ring->buffer[ring->head] = value; + int *el = &ring->buffer[ring->head]; + ring->head = NEXT(ring->head); + return el; + } + + int* dequeue(Ring *ring) { + if (is_empty(ring)) return NULL; + int *el = &ring->buffer[ring->tail]; + ring->tail = NEXT(ring->tail); + return el; + } Listes chaînées =============== +On s'aperçois vite avec les tableaux, que certaines opérations sont coûteuses. Ajouter ou supprimer un élément à la fin du tableau coûte :math:`O(1)` amorti, mais ajouter ou supprimer un élément à l'intérieur du tableau coûte :math:`O(n)` du fait qu'il est nécessaire de déplacer tous les éléments qui suivent l'élément concerné. + +Une possible solution à ce problème serait de pouvoir s'affranchir du lien entre les éléments et leurs positions en mémoire relative les uns aux autres. + +Pour illustrer cette idée, imaginons un tableau statique dans lequel chaque élément est décrit par la structure suivante : + +.. code-block:: c + + struct Element { + int value; + int index_next_element; + }; + + struct Element elements[100]; + +Considérons les dix premiers éléments de la séquence de nombre `A130826 `__ dans un tableau statique. Ensuite répartissons ces valeurs aléatoirement dans notre tableau `elements` déclaré plus haut entre les indices 0 et 19. + +.. figure:: ../assets/figures/dist/data-structure/static-linked-list.* + +On observe sur la figure ci-dessus que les éléments n'ont plus besoin de se suivre en mémoire car il est possible facilement de chercher l'élément suivant de la liste avec cette relation : + +.. code-block:: c + + struct Element current = elements[4]; + struct Element next = elements[el.index_next_element] + +De même, insérer une nouvelle valeur `13` après la valeur `42` est très facile: + +.. code-block:: c + + // Recherche de l'élément contenant la valeur 42 + struct Element el = elements[0]; + while (el.value != 42 && el.index_next_element != -1) { + el = elements[el.index_next_element]; + } + if (el.value != 42) abort(); + + // Création d'un nouvel élément + struct Element new = (Element){ + .value = 13, + .index_next_element = el.index_next_element + }; + + // Insertion de l'élément quelque part dans le tableau + el.index_next_element = 34; // Rien encore à cet emplacement + elements[el.index_next_element] = new; + +Cette solution d'utiliser un lien vers l'élément suivant et s'appelle liste chaînée. Chaque élément dispose d'un lien vers l'élément suivant situé quelque part en mémoire. Les opérations d'insertion et de suppression au milieu de la chaîne sont maintenant effectuées en :math:`O(1)` contre :math:`O(n)` pour un tableau standard. En revanche l'espace nécessaire pour stocker ce tableau est doublé puisqu'il faut associer à chaque valeur le lien vers l'élément suivant. + +D'autre part, la solution proposée n'est pas optimale : + +- L'élément 0 est un cas particulier qu'il faut traiter différemment. Le premier élément de la liste doit toujours être positionné à l'indice 0 du tableau. Insérer un nouvel élément en début de tableau demande de déplacer cet élément ailleurs en mémoire. +- Le nombre d'éléments total est limité par la capacité effective du tableau, ici :math:`100 / 2 = 50` éléments. +- Supprimer un élément dans le tableau laisse une place mémoire vide. Il devient alors difficile de savoir où sont les emplacement mémoire disponibles + Une liste chaînée est une structure de données permettant de lier des éléments structurés entre eux. La liste est caractérisée par : - un élément de tête (*head*), @@ -148,12 +354,12 @@ Les listes chaînées réduisent la complexité liée à la manipulation d'élé Ce surcoût est souvent part du compromis entre la complexité d'exécution du code et la mémoire utilisée par ce programme. -+----------------------+----------------------------------------------------------------+ -| Structure de donnée | Pire cas | ++----------------------+--------------+--------------+-------------------+--------------+ +| Structure de donnée | Pire cas | | | | | +--------------+--------------+----------------------------------+ -| | Insertion | Suppression | Recherche | +| | Insertion | Suppression | Recherche | | | +--------------+--------------+-------------------+--------------+ -| | | | Trié | Pas trié | +| | | | Trié | Pas trié | +======================+==============+==============+===================+==============+ | Tableau, pile, queue | :math:`O(n)` | :math:`O(n)` | :math:`O(log(n))` | :math:`O(n)` | +----------------------+--------------+--------------+-------------------+--------------+ @@ -171,7 +377,7 @@ qu'il n'est alors pas indispensable que les éléments se suivent dans l'ordre. Il est indispensable de bien identifier le dernier élément de la liste grâce à son pointeur associé à la valeur ``NULL``. -.. figure:: ../assets/figures/dist/recursive-data-structure/list.* +.. figure:: ../assets/figures/dist/data-structure/list.* .. code-block:: c @@ -251,7 +457,7 @@ le nombre d'éléments jusqu'à ce que le pointeur *next* soit égal à ``NULL`` Attention, cette technique ne fonctionne pas dans tous les cas, spécialement lorsqu'il y a des boucles dans la liste chaînée. Prenons l'exemple suivant : -.. figure:: ../assets/figures/dist/recursive-data-structure/loop.* +.. figure:: ../assets/figures/dist/data-structure/loop.* La liste se terminant par une boucle, il n'y aura jamais d'élément de fin et le nombre d'éléments calculé sera infini. Or, cette liste a un nombre fixe d'éléments. Comment donc les compter ? @@ -260,7 +466,7 @@ Il existe un algorithme nommé détection de cycle de Robert W. Floyd aussi appe .. index:: Floyd -.. figure:: ../assets/figures/dist/recursive-data-structure/floyd.* +.. figure:: ../assets/figures/dist/data-structure/floyd.* .. code-block:: c @@ -348,6 +554,37 @@ On sait qu'une recherche idéale s'effectue en :math:`O(log(n))`, mais que la so Liste doublement chaînée ======================== +Liste chaînée XOR +----------------- + +L'inconvénient d'une liste doublement chaînée est le surcoût nécessaire au stockage d'un élément. Chaque élément contient en effet deux pointeurs sur l'élément précédent (*prev*) et suivant (*next*). + +.. code-block:: text + + ... A B C D E ... + –> next –> next –> next –> + <– prev <– prev <– prev <– + +Cette liste chaînée particulière compresse les deux pointeurs en un seul en utilisant l'opération XOR (dénotée ⊕). + +.. code-block:: text + + ... A B C D E ... + <–> A⊕C <-> B⊕D <-> C⊕E <-> + +Lorsque la liste est traversée de gauche à droite, il est possible de facilement reconstuire le pointeur de l'élément suivant à partir de l'adresse de l'élément précédent. + +Les inconvénients de cette structure sont : + +- Difficultés de débogage +- Complexité de mise en oeuvre + +L'avantage principal étant le gain de place en mémoire. + + +Liste chaînée déroulée (Unrolled linked list) +============================================= + Arbre binaire de recherche ========================== @@ -355,7 +592,7 @@ L'objectif de cette section n'est pas d'entrer dans les détails des `arbres bin L'arbre binaire, n'est rien d'autre qu'une liste chaînée comportant deux enfants un ``left`` et un ``right``: -.. figure:: ../assets/figures/dist/recursive-data-structure/binary-tree.* +.. figure:: ../assets/figures/dist/data-structure/binary-tree.* Arbre binaire équilibré @@ -401,61 +638,423 @@ L'insertion et la suppression d'éléments dans un arbre binaire fait appel à d Heap ==== -La structure de donnée ``heap`` aussi nommée tas ne doit pas être confondue avec le tas utilisé en allocation dynamique. Il s'agit d'une forme particulière de l'arbre binaire dit "presque complet", dans lequel la différence de niveau entre les feuilles n'excède pas 1. C'est à dire que toutes les feuilles sont à une distance identique de la racine plus ou moins 1. +La structure de donnée ``heap`` aussi nommée tas ne doit pas être confondue avec le tas utilisé en allocation dynamique. Il s'agit d'une forme particulière de l'arbre binaire dit "presque complet", dans lequel la différence de niveau entre les feuilles n'excède pas 1. C'est à dire que toutes les feuilles sont à une distance identique de la racine plus ou moins 1. -Un tas peut aisément être représenté sous forme de tableau en utilisant la règle suivante : +Un tas peut aisément être représenté sous forme de tableau en utilisant la règle suivante : -================ ====================== ========================== -Cible Début 0 Début 1 -================ ====================== ========================== -Enfant de gauche :math:`2*k + 1` :math:`2 * k` -Enfant de droite :math:`2*k + 2` :math:`2 * k + 1` -Parent :math:`floor(k-1) / 2` :math:`floor(k) / 2` -================ ====================== ========================== +================ ====================== ====================== +Cible Début à 0 Début à 1 +================ ====================== ====================== +Enfant de gauche :math:`2*k + 1` :math:`2 * k` +Enfant de droite :math:`2*k + 2` :math:`2 * k + 1` +Parent :math:`floor(k-1) / 2` :math:`floor(k) / 2` +================ ====================== ====================== + +.. figure:: ../assets/figures/dist/data-structure/heap.* + +Max-heap +-------- -.. figure:: ../assets/figures/dist/recursive-data-structure/heap.* Tableau de Hachage ================== -Les tableaux de hachage (*Hash Table*) sont une structure particulière combinant une liste chaînée avec un tableau statique ou dynamique. +Les tableaux de hachage (*Hash Table*) sont une structure particulière dans laquelle une fonction dite de *hachage* est utilisée pour transformer les entrées en des indices d'un tableau. + +L'objectif est de stocker des chaînes de caractères correspondant a des noms simples ici utilisés pour l'exemple. Une possible répartition serait la suivante : + +.. figure:: ../assets/figures/dist/data-structure/hash-linear.* + +Si l'on cherche l'indice correspondant à ``Ada``, il convient de pouvoir calculer la valeur de l'indice correspondant à partir de la valeur de la chaîne de caractère. Pour calculer cet indice aussi appelé *hash*, il existe une infinité de méthodes. Dans ce exemple considérons une méthode simple. Chaque lettre est identifiée par sa valeur ASCII et la somme de toutes les valeurs ASCII est calculée. Le modulo 10 est ensuite calculé sur cette somme pour obtenir une valeur entre 0 et 9. Ainsi nous avons les calculs suivants : + +.. code-block:: console + + Nom Valeurs ASCII Somme Modulo 10 + --- -------------- ----- --------- + Mia -> {77, 105, 97} -> 279 -> 4 + Tim -> {84, 105, 109} -> 298 -> 1 + Bea -> {66, 101, 97} -> 264 -> 0 + Zoe -> {90, 111, 101} -> 302 -> 5 + Jan -> {74, 97, 110} -> 281 -> 6 + Ada -> {65, 100, 97} -> 262 -> 9 + Leo -> {76, 101, 111} -> 288 -> 2 + Sam -> {83, 97, 109} -> 289 -> 3 + Lou -> {76, 111, 117} -> 304 -> 7 + Max -> {77, 97, 120} -> 294 -> 8 + Ted -> {84, 101, 100} -> 285 -> 10 + +Pour trouver l'indice de ``"Mia"`` il suffit donc d'appeler la fonction suivante : + +.. code-block:: c + + int hash_str(char *s) { + int sum = 0; + while (*s != '\0') sum += s++; + return sum % 10; + } + +L'assertion suivante est donc vraie : + +.. code-block:: c + + assert(strcmp(table[hash_str("Mia")], "Mia") == 0); + +Rechercher ``"Mia"`` et obtenir ``"Mia"`` n'est certainement pas l'exemple le plus utile. Néanmoins il est possible d'encoder plus qu'une chaîne de caractère et utiliser plutôt une structure de donnée : + +.. code-block:: c + + struct Person { + char name[3 + 1 /* '\0' */]; + struct { + int month; + int day; + int year; + } born; + enum { + JOB_ASTRONOMER, + JOB_INVENTOR, + JOB_ACTRESS, + JOB_LOGICIAN, + JOB_BIOLOGIST + } job; + char country_code; // For example 41 for Switzerland + }; + +Dans ce cas, le calcul du hash se ferait sur la première clé d'un élément : + +.. code-block:: c + + int hash_person(struct Person person) { + int sum = 0; + while (*person.name != '\0') sum += s++; + return sum % 10; + } + +L'accès à une personne à partir de la clé se résoud donc en ``O(1)`` car il n'y a aucune itération ou recherche à effectuer. + +Cette `vidéo `__ YouTube explique bien le fonctionnement des tableaux de hachage. + +Collisions +---------- + +Lorsque la fonction de hachage est mal choisie, un certain nombre de collision peuvent apparaître. Si l'on souhaite par exemple ajouter les personnes suivantes : + +.. code-block:: text + + Sue -> {83, 117, 101} -> 301 -> 4 + Len -> {76, 101, 110} -> 287 -> 1 + +On voit que les positions ``4`` et ``1`` sont déjà occupées par Mia et Tim. + +Une stratégie de résolution s'appelle `Open adressing `__. Parmi les possibilités de cette stratégie le *linear probing* consiste à vérifier si la position du tableau est déjà occupée et en cas de collision, chercher la prochaine place disponible dans le tableau : + +.. code-block:: c + + Person people[10] = {0} + + // Add Mia + Person mia = {.name="Mia", .born={.day=1,.month=4,.year=1991}}; + int hash = hash_person(mia); + while (people[hash].name[0] != '\0') hash++; + people[hash] = mia; + +Récupérer une valeur dans le tableau demande une comparaison supplémentaire : + +.. code-block:: c + + char key[] = "Mia"; + int hash = hash_str(key) + while (strcmp(people[hash], key) != 0) hash++; + Person person = people[hash]; + +Lorsque le nombre de collision est négligeable par rapport à la table de hachage la recherche d'un élément est toujours en moyenne égale à :math:`O(1)` mais lorsque le nombre de collision est prépondérant, la complexité se rapproche de celle de la recherche linéaire :math:`O(n)` et on perd tout avantage à cette structure de donnée. + +Dans le cas extrême, pour garantir un accès unitaire pour tous les noms de trois lettres, il faudrait un tableau de hachage d'une taille :math:`26^3 = 17576` personnes. L'emprunte mémoire peut être considérablement réduite en stockant non pas une structure ``struct Person`` mais plutôt l'adresse vers cette structure : + +.. code-block:: c + + struct Person *people[26 * 26 * 26] = { NULL }; + +Dans ce cas exagéré, la fonction de hachage pourrait être la suivante : + +.. code-block:: c + + int hash_name(char name[4]) { + int base = 26; + return + (name[0] - 'A') * 1 + + (name[1] - 'a') * 26 + + (name[2] - 'a') * 26 * 26; + } + +Facteur de charge +----------------- + +Le facteur de charge d'une table de hachage est donné par la relation : + +.. math:: + + \text{Facteur de charge} = \frac{\text{Nombre total d'éléments}}{\text{Taille de la table}} + +Plus ce facteur de charge est élevé, dans le cas du *linear probing*, moins bon sera performance de la table de hachage. + +Certains algorithmes permettent de redimensionner dynamiquement la table de hachage pour conserver un facteur de charge le plus faible possible. + +Chaînage +-------- + +Le chaînage ou *chaining* est une autre méthode pour mieux gérer les collisions. La table de hachage est couplée à une liste chaînée. .. figure:: ../assets/figures/dist/data-structure/hash-table.* -Imaginons que nous souhaitions mémoriser une liste de 2000 étudiants. Rechercher le nom d'un étudiant dans cette liste reviendrait à parcourir les -2000 entrées dans le cas le plus défavorable. La complexité de recherche est donc de O(log n) pour un tableau statique et de O(n) dans une liste chaînée. En organisant les données différemment, il est possible de réduire cette complexité à O(1) dans le cas ou la taille du tableau de hachage est égal au nombre d'étudiants. +Fonction de hachage +------------------- + +Nous avons vu plus haut une fonction de hachage calculant le modulo sur la somme des caractères ASCII d'une chaîne de caractères. Nous avons également vu que cette fonction de hachage est source de nombreuses collisions. Les chaînes ``"Rea"`` ou ``"Rae"`` auront les même *hash* puisqu'ils contiennent les même lettres. De même une fonction de hachage qui ne réparti pas bien les éléments dans la table de hachage sera mauvaise. On sait par exemple que les voyelles sont nombreuses dans les mots et qu'il n'y en a que six et que la probabilité que nos noms de trois lettres contiennent une voyelle en leur milieu est très élevée. + +L'idée générale des fonctions de hachage est de répartir **uniformément** les clés sur les indices de la table de hachage. L'approche la plus courante est de mélanger les bits de notre clé dans un processus reproductible. + +Une idée **mauvaise** et **à ne pas retenir** pourrait être d'utiliser le caractère pseudo-aléatoire de ``rand`` pour hacher nos noms : + +.. code-block:: c + + #include + #include + + int hash(char *str, int mod) { + int h = 0; + while(*str != '\0') { + srand(h + *str++); + h = rand(); + } + return h % mod; + } + + int main() { + char *names[] = { + "Bea", "Tim", "Len", "Sam", "Ada", "Mia", + "Sue", "Zoe", "Rae", "Lou", "Max", "Tod" + }; + for (int i = 0; i < sizeof(names) / sizeof(*names); i++) + printf("%s : %d\n", names[i], hash(names[i], 10)); + } + +Cette approche nous donne une assez bonne répartition : + +.. code-block:: console + + $ ./a.out + Bea : 2 + Tim : 3 + Len : 0 + Sam : 3 + Ada : 4 + Mia : 3 + Sue : 6 + Zoe : 5 + Rae : 8 + Lou : 0 + Max : 3 + Tod : 1 + +Dans la pratique, on utilisera volontier des fonctions de hachage utilisés en cryptographies tels que `MD5 `__ ou `SHA`. Considérons par exemple la première partie du poème Chanson de Pierre Corneille : + +.. code-block:: console + + $ cat chanson.txt + Si je perds bien des maîtresses, + J'en fais encor plus souvent, + Et mes voeux et mes promesses + Ne sont que feintes caresses, + Et mes voeux et mes promesses + Ne sont jamais que du vent. -Pour constituer un tableau de hachage, il convient de calculer le *hash* d'une entrée à l'aide d'une fonction de hachage. Dans le cas le plus simple imaginons la solution suivante : + $ md5sum chanson.txt + 699bfc5c3fd42a06e99797bfa635f410 chanson.txt + +Le *hash* de ce texte est exprimé en hexadécimal ( ``0x699bfc5c3fd42a06e99797bfa635f410``). Converti en décimal ``140378864046454182829995736237591622672`` il peut être réduit en utilsant le modulo. Voici un exemple en C : .. code-block:: c - int hash(char *str) { - int hash = 0; - char c; - while((c = str++) != '\0') - hash ^= c; - return hash; - } + #include + #include + #include + #include + + int hash(char* str, int mod) { + // Compute MD5 + unsigned int output[4]; + MD5_CTX md5; + MD5_Init(&md5); + MD5_Update(&md5, str, strlen(str)); + MD5_Final((char*)output, &md5); + + // 128-bits --> 32-bits + unsigned int h = 0; + for (int i = 0; i < sizeof(output)/sizeof(*output); i++) { + h ^= output[i]; + } + + // 32-bits --> mod + return h % mod; + } + + int main() { + char *text[] = { + "La poule ou l'oeuf?", + "Les pommes sont cuites!", + "Aussi lentement que possible", + "La poule ou l'oeuf.", + "La poule ou l'oeuf!", + "Aussi vite que nécessaire", + "Il ne faut pas lâcher la proie pour l’ombre.", + "Le mieux est l'ennemi du bien", + }; + + for (int i = 0; i < sizeof(text) / sizeof(*text); i++) + printf("% 2d. %s\n", hash(text[i], 10), text[i]); + } -Cette fonction calcul le OU Exclusif entre chaque caractère : +.. code-block:: console + + $ gcc hash.c -lcrypto + $ ./a.out + 4. La poule ou l'oeuf? + 3. Les pommes sont cuites! + 7. Aussi lentement que possible + 2. La poule ou l'oeuf. + 5. La poule ou l'oeuf! + 6. Aussi vite que nécessaire + 8. Il ne faut pas lâcher la proie pour l’ombre. + 1. Le mieux est l'ennemi du bien + +On peut constater qu'ici les indices sont bien répartis et que la fonction de hachage choisie semble uniforme. + +Piles ou LIFO (*Last In First Out*) +=================================== + +Une pile est une structure de donnée très similaire à un tableau dynamique mais dans laquelle les opérations sont limitées. Par exemple, il n'est possible que : + +- d'ajouter un élément (*push*) ; +- retirer un élément (*pop*) ; +- obtenir le dernier élément ajouté (*peek*) ; +- tester si la pile est vide (*is_empty*) ; +- tester si la pile est pleine avec (*is_full*). + +Une utilisation possible de pile sur des entiers serait la suivante : + +.. code-block:: c + + #include "stack.h" + + int main() { + Stack stack; + stack_init(&stack); + + stack_push(42); + assert(stack_peek() == 42); + + stack_push(23); + assert(!stack_is_empty()); + + assert(stack_pop() == 23); + assert(stack_pop() == 42); + + assert(stack_is_empty()); + } + +Les piles peuvent être implémentées avec des tableaux dynamiques ou des listes chaînées (voir plus bas). + +Queues ou FIFO (*First In First Out*) +===================================== + +Les queues sont aussi des structures très similaires à des tableaux dynamiques mais elle ne permettent que les opérations suivantes : + +- ajouter un élément à la queue (*push*) aussi nommé *enqueue* ; +- supprimer un élément au début de la queue (*shift*) aussi nommé *dequeue* ; +- tester si la queue est vide (*is_empty*) ; +- tester si la queue est pleine avec (*is_full*). + +Les queues sont souvent utilisées lorsque des processus séquentiels ou parallèles s'échangent des tâches à traiter : + +.. code-block:: + + #include "queue.h" + #include + + void get_work(Queue *queue) { + while (!feof(stdin)) { + int n; + if (scanf("%d", &n) == 1) + queue_enqueue(n); + scanf("%*[^\n]%[\n]"); + } + } + + void process_work(Queue *queue) { + while (!is_empty(queue)) { + int n = queue_dequeue(queue); + printf("%d est %s\n", n, n % 2 ? "impair" : "pair"; + } + } + + int main() { + Queue* queue; + + queue_init(&queue); + get_work(queue); + process_work(queue); + queue_free(queue); + } + + + +Performances +============ + +Les différentes structures de données ne sont pas toutes équivalentes en termes de performances. Il convient, selon l'application, d'opter pour la structure la plus adaptée, et par conséquent il est important de pouvoir comparer les différentes structures de données pour choisir la plus appropriée. Est-ce que les données doivent être maintenues triées ? Est-ce que la structure de donnée est utilisée comme une pile ou un tas ? Quelle est la structure de donnée avec le moin d'*overhead* pour les opérations de ``push`` ou ``unshift`` ? + +L'indexation (*indexing*) est l'accès à une certaine valeur du tableau par exemple avec ``a[k]``. Dans un tableau statique et dynamique l'accès se fait par pointeur depuis le début du tableau soit : ``*((char*)a + sizeof(a[0]) * k)`` qui est équivalant à ``*(a + k)``. L'indexation par arithmétique de pointeur n'est pas possible avec les listes chaînées dont il faut parcourir chaque élément pour découvrir l'adresse du prochain élément : + +.. code-block:: c + + int get(List *list) { + List *el = list->head; + for(int i = 0; i < k; i++) + el = el.next; + return el.value; + } + +L'indexation d'une liste chaînée prends dans le cas le plus défavorable :math:`O(n)`. + +Les arbres binaires ont une structure qui permet naturellement la dichotomique. Chercher l'élément 5 prend 4 opérations : ``12 -> 4 -> 6 -> 5``. L'indexation est ainsi possible en :math:`O(log n)`. .. code-block:: text - ┌─┬─┬─┬─┬─┬─┬─┬─┐ - 'H' == 72 == │0│1│0│0│1│0│0│0│ - ├─┼─┼─┼─┼─┼─┼─┼─┤ - 'E' == 69 == │0│1│0│0│0│1│0│1│ - ├─┼─┼─┼─┼─┼─┼─┼─┤ - 'L' == 76 == │0│1│0│0│1│1│0│0│ - ├─┼─┼─┼─┼─┼─┼─┼─┤ - 'L' == 76 == │0│1│0│0│1│1│0│0│ - ├─┼─┼─┼─┼─┼─┼─┼─┤ - 'O' == 79 == │0│1│0│0│1│1│1│1│ - └─┴─┴─┴─┴─┴─┴─┴─┘ - XOR ─────────────────────── - ┌─┬─┬─┬─┬─┬─┬─┬─┐ - │0│1│0│0│0│0│1│0│ == 66 - └─┴─┴─┴─┴─┴─┴─┴─┘ - -On obtient 66, qui est la valeur hachée de cette chaîne de caractère. Si nous disposons d'une table de hashage de taille 100, il suffit donc d'insérer à la position 66 de la table de hashage un pointeur vers une liste chaînée comportant cette chaîne de caractère. Si la fonction de hachage est bonne, les entrées vont se répartir équitablement dans les 100 entrées de la table de hachage et les listes chaînées seront chacune 100x plus petites. On divise ainsi par 100 la complexité de la recherche. Bien entendu dans le cas ou la fonction de hachage retourne une valeur plus grande que 100, il convient de calculer le modulo 100. + 12 + | + ----+---- + / \ + 4 12 + -- -- + / \ / \ + 2 6 10 14 + / \ / \ / \ / \ + 1 3 5 7 9 11 13 15 + +Le tableau suivant résume les performances obtenues pour les différentes structures de données que nous avons vu dans ce chapitre : + +============= ========== =========== ======== ========== ======== ========= + Action Tableau Liste Buffer Arbre Hash Map +------------- ----------------------- -------- ---------- -------- --------- +` ` Statique Dynamique chaînée circulaire +============= ========== =========== ======== ========== ======== ========= +Indexing 1 1 n 1 log n 1 +Unshift/Shift n n 1 1 log n n +Push/Pop 1 1 amorti 1 1 log n 1 +Insert/Delete n n 1 n log n n +Search n n n n log n 1 +Sort n log n n log n n log n n log n 1 *n/a* +============= ========== =========== ======== ========== ======== ========= + diff --git a/content/90-advanced-topics.rst b/content/90-advanced-topics.rst index 89756b0..15dd289 100644 --- a/content/90-advanced-topics.rst +++ b/content/90-advanced-topics.rst @@ -228,4 +228,178 @@ On voit immédiatement que la partie entière vaut 2, donc 90% de 3.14 donnera u inline int16_t q12_mul(int16_t a, int16_t b) { return q_mul(a, b, 12); - } \ No newline at end of file + } + +Mémoire partagée +================ + +Nous le verrons plus loin au chapitre sur la MMU, mais la mémoire d'un processus mémoire (programme) ne peut pas être accedée par un autre programme. Le système d'exploitation l'en empêche. + +Lorsque l'on souhaite communiquer entre plusieurs programmes, il est possible d'utiliser différentes méthodes : + +- les flux (fichiers, stdin, stdout...) +- la mémoire partagée +- les sockets + +Vous avez déjà vu les flux au chapitre précédant, et les sockets ne font pas partie de ce cours d'introduction. + +Notons que la mémoire partagée est un mécanisme propre à chaque système d'exploitation. Sous POSIX elle est normalisée et donc un programme compatible POSIX et utilisant la mémoire partagée pourra fonctionner sous Linux, WSL ou macOS, mais pas sous Windows. + +C'est principalement l'appel système ``mmap`` qui est utilisé. Il permet de mapper ou démapper des fichiers ou des périphériques dans la mémoire. + +.. code-block:: c + + void *mmap( + void *addr, + size_t length, // Taille en byte de l'espace mémoire + int prot, // Protection d'accès (lecture, écriture, exécution) + int flags, // Attributs (partagé, privé, anonyme...) + int fd, + off_t offset + ); + + +Voici un exemple permettant de réserver un espace partagé en écriture et en lecture entre deux processus : + +.. code-block:: c + + #include + #include + #include + + void* create_shared_memory(size_t size) { + // Accessible en lecture et écriture + int protection = PROT_READ | PROT_WRITE; + + // D'autres processus peuvent accéder à cet espace + // lequel est anonyme + // so only this process and its children will be able to use it: + int visibility = MAP_SHARED | MAP_ANONYMOUS; + + // The remaining parameters to `mmap()` are not important for this use case, + // but the manpage for `mmap` explains their purpose. + return mmap(NULL, size, protection, visibility, -1, 0); + } + +File memory mapping +------------------- + +Traditionnellement lorsque l'on souhaite travailler sur un fichier, il convient de l'ouvrir avec ``fopen`` et de lire son contenu. Lorsque cela est nécessaire, ce fichier est copié en mémoire : + +.. code-block:: c + + FILE *fp = fopen("foo", "r"); + fseek(fp, 0, SEEK_END); + int filesize = ftell(fp); + fseek(fp, 0, SEEK_SET); + char *file = malloc(filesize); + fread(file, filesize, sizeof(char), fp); + fclose(fp); + +Cette copie n'est pas nécessairement nécessaire. Une approche **POSIX**, qui n'est donc pas couverte par le standard **C99** consiste à lier le fichier dans un espace mémoire partagé. + +Ceci nécessite l'utilisation de fonctions bas niveau. + +.. code-block:: c + + #include + #include + #include + + int main() { + int fd = open("foo.txt", O_RDWR, 0600); + char *addr = mmap(NULL, 100, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); + printf("Espace mappé à %p\n", addr); + printf("Premiers caractères du fichiers : %.*s...\n", 20, addr); + } + +Les avantages de cette méthode sont : + +- pas nécessaire de copier l'intégralité du fichier en mémoire ; +- possibilité de partager le même fichier ouvert entre plusieurs processus ; +- possibilité laissée au système d'exploitation d'utiliser la RAM ou non si les ressources mémoires deviennent tendues. + +Collecteur de déchets (*garbage collector*) +=========================================== + +Le C est un langage primitif qui ne gère pas automatiquement la libération des ressources allouées dynamiquement. L'exemple suivant est évocateur : + +.. code-block:: c + + int* get_number() { + int *num = malloc(sizeof(int)); + *num = rand(); + } + + int main() { + for (int i = 0; i < 100; i++) { + printf("%d\n", *get_number()); + } + } + +La fonction ``get_number`` alloue dynamiquement un espace de la taille d'un entier et lui assigne une valeur aléatoire. Dans le programme principal, l'adresse retournée est déréférencée pour être affichée sur la sortie standard. + +A la fin de l'exécution de la boucle for, une centaine d'espaces mémoire sont maintenant dans les `limbes `__. Comme le pointeur retourné n'a jamais été mémorisé, il n'est plus possible de libérer cet espace mémoire avec ``free``. + +On dit que le programme à une `fuite mémoire `__. En admettant que ce programme reste résidant en mémoire, il peut arriver un moment où le programme peut aller jusqu'à utiliser toute la RAM disponible. Dans ce cas, il est probable que ``malloc`` retourne ``NULL`` et qu'une erreur de segmentaiton apparaisse lors du ``printf``. + +Allons plus loin dans notre exemple et considérons le code suivant : + +.. code-block:: c + + #include + #include + + int foo(int *new_value) { + static int *values[10] = { NULL }; + static int count = 0; + + if (rand() % 5 && count < sizeof(values) / sizeof(*values) - 1) { + values[count++] = new_value; + } + + if (count > 0) + printf("Foo aime %d\n", *values[rand() % count]); + } + + int bar(int *new_value) { + static int *values[10] = { NULL }; + static int count = 0; + + if (rand() % 5 && count < sizeof(values) / sizeof(*values) - 1) { + values[count++] = new_value; + } + + if (count > 0) + printf("Bar aime %d\n", *values[rand() % count]); + } + + int* get_number() { + int *number = malloc(sizeof(int)); + *number = rand() % 1000; + return number; + } + + int main() { + int experiment_iterations = 10; + for (int i = 0; i < experiment_iterations; i++) { + int *num = get_number(); + foo(num); + bar(num); + #if 0 // ... + free(num) ?? + #endif + }; + } + +La fonction ``get_number`` alloue dynamiquement un espace mémoire et assigne un nombre aléatoire. Les fonctions ``foo`` et ``bar`` reçoivent en paramètre un pointeur sur un entier. Chacune à le choix de mémoriser ce pointeur et de clamer sur ``stdout`` qu'elle aime un des nombre mémorisés. + +Au niveau du ``#if 0`` dans la fonction ``main``, il est impossible de savoir si l'adresse pointée par ``num`` est encore utilisée ou non. Il se peut que ``foo`` et ``bar`` utilisent cet espace mémoire, comme il se peut qu'aucun des deux ne l'utilise. + +Comment peut-on savoir si il est possible de libérer ou non ``num`` ? + +Une solution courament utilsée en C++ s'appelle un *smart pointer*. Il s'agit d'un pointeur qui contient en plus de l'adresse de la valeur, le nombre de références utilisées. De cette manière il est possible en tout temps de savoir si le pointeur est référencé quelque part. Dans le cas ou le nombre de référence tombe à zéro, il est possible de libérer la ressource. + +Dans un certain nombre de langage de programmation comme Python ou Java, il existe un mécanisme automatique nommé *Garbage Collector* et qui, périodiquement, fait un tour de toutes les allocations dynamique pour savoir si elle sont encore référencées ou non. Le cas échéant, le *gc* décide libérer la ressource mémoire. De cette manière il n'est plus nécessaire de faire la chasse aux ressources allouées. + +En revanche en C, il n'existe aucun mécanisme aussi sophistiqués alors prenez garde à bien libérer les ressources utilisée et à éviter d'écrire des fonctions qui allouent du contenu mémoire dynamiquement. \ No newline at end of file diff --git a/index.rst b/index.rst index 13e0b4e..d2cfa37 100644 --- a/index.rst +++ b/index.rst @@ -1,4 +1,6 @@ +.. raw:: latex + \frontmatter ===================== @@ -17,7 +19,8 @@ Le contenu de ce cours est calqué sur les fiches d'unités de cours et de modul - Unité **Informatique 1** (:ref:`Info1 `) - Unité **Informatique 2** (:ref:`Info2 `) -La version actuelle est |release| générée le |today|. +La version actuelle : |release| +Date : |today| .. raw:: latex