Tout sur les adresses

publicité
INF145 : quatrième cours
Tout sur les adresses
La mémoire
La mémoire symbolique d’une machine est un vecteur d’octets. Chaque octet possède un
numéro dans l’intervalle [ 0, quantité de mémoire ]. Tout programme, peu importe la langue de
programmation, se sert uniquement de cette mémoire symbolique.
La mémoire physique d’une machine dont la géographie, le maintien et le comportement sont d’une
grande complexité, est toujours atteinte avec le contrôleur d’adresse au service de tout programme dont
le système d’exploitation(OS)
Une adresse

Une adresse est une valeur qui possède une position mémoire et un type (et pouvoir ainsi
considérer son contenu). C’est la conjonction d’un numéro d’octet de départ dans la
mémoire et d’un contenu de type T.

L’adresse d’un contenu de type T possède le type T*

Une adresse permet des expressions utiles puisque de sa valeur et dépendant de T on
pourra soit
1. atteindre son contenu en lecture comme en écriture (ssi sizeof(T) est défini)
2. se déplacer de cette position vers d’autres adresses en décalage d’elle(ssi sizeof(T)
est défini, se fait de façon honnête essentiellement dans un tableau)
3. déclencher une fonction à partir d’une adresse de fonction

une adresse de type void* peut exister, elle a l’avantage de pouvoir conserver la position d’un
objet peu importe son type T, mais on perd alors la capacité d’accès au contenu.
Les pointeurs
On donne le nom générique de pointeur à toute variable faite pour garder une adresse.
Pour un type quelconque T, la déclaration d’une variable de type T* est possible. Ce pointeur
est apte à garder une adresse dont le contenu est un T. Ainsi, avec la déclaration T * ptr
(sizeof(T) existe, T n’est ni void ni de type fonction), l’expression *ptr donnera accès au contenu
de type T à la valeur de ptr.
Remarquez dans une déclaration T*… l’apparition du troisième et dernier opérateur de type du
C , après T…[ pour le tableau et T…( pour la fonction.
Au contraire des deux opérateurs précédents, l’opérateur * ne génère pas d’incompatibilité
avec un autre opérateur.
Lorsqu’un pointeur est déclaré, celui-ci n’est pas initialisé automatiquement à une adresse
correcte. L’utilisation d’un pointeur non initialisé est une erreur grave et malheureusement
fréquente en C. Le compilateur ne prend aucune responsabilité sur l’utilisation du contenu d’un
pointeur. Pour aider le programmeur, la norme ANSI offre le symbole NULL – équivalent
normalement à la valeur 0 -- assignable à tout type de pointeur pour indiquer qu’il n’adresse rien –
mais rien de plus ne vous aidera –
On peut déclarer
1. une variable, pointeur de type T* ( apte à conserver l’adresse d’un objet de type T)
2. des tableaux T*…[ ] de pointeurs T*
3. une fonction qui pourra retourner un T* ou qui pourra avoir un T* en paramètre formel.
Précision importante
1. une adresse n’est jamais à priori l’équivalent d’une simple position dans la mémoire.
2. La taille en octets des adresses sizeof(T*) dépend du modèle de compilation choisi avec
le compilateur utilisé. Avec la taille des mémoires actuelles sur les ordinateurs
personnels, toutes les adresses sont normalement gardées sur quatre octets. En
ingénierie, la programmation sur de toutes petites (micro)plate-formes se fait encore
avec des adresses plus petites.
Comment obtenir des adresses dans un programme
1.
Avec l’opérateur & : L’expression obtenue de l’opérateur & en préfixe à une variable de
type T est évaluée comme une adresse de type T*. Cet opérateur est utilisé avant
tout lors du passage de paramètres par référence à une fonction.
Note très
technique…. la classe d’allocation register et l’opérateur & sont incompatibles.
2. À l’aide du nom d’un tableau : Dans une expression, le nom d’un tableau contenant des
objets de type T est automatiquement évalué comme une adresse de type T*
correspondant à l’adresse du premier élément du tableau. – ce qui explique qu’un tableau
utilisé en paramètre effectif puisse être modifié à travers le paramètre formel dans la fonction –
3. À l’aide du nom d’une fonction : Dans une expression, le nom d’une fonction donne
l’adresse du premier octet de son code. Le type de cette adresse est associé à la
déclaration de la fonction. Le nom d’une fonction pourra donc servir de paramètre
effectif et cette fonction pourra être déclenchée avec son
paramètre formel
correspondant.
4. Par transtypage : Le transtypage (typecasting) d’une adresse correcte donne une
adresse différente. La position n’est pas changée mais le contenu est différent. La forme
la plus correcte devrait se faire en transitant par void*. Peu importe le type de l’adresse
addr, l’expression (T1*)(void*) addr sera de type T1* . Aucun compilateur ne
donne de limites à ces transtypages. Tout transtypage d’adresse est potentiellement
dangereux et le programmeur en est totalement responsable, donc un transtypage
d’adresse DOIT TOUJOURS être abondamment commenté.
5. Par allocation programmée : En Run Time du programme, la fonction classique malloc
permet la demande au système d’exploitation d’un bloc d’octets de la taille donnée en
paramètre. La fonction malloc retourne en cas de succès l’adresse (void*) du premier
octet du bloc d’octets offert alors par l’OS dans le heap de la machine (c’est la partie de la
mémoire non-utilisée lors de la demande) ou la constante symbolique NULL en cas d’échec.
Ces blocs d’octets n’ont pas de classe d’allocation, de « storage class » qui définit
normalement la durée de vie et l’accessibilité de toute mémoire obtenue
automatiquement en compilation (Compile time) pour le programme. Ces blocs de
mémoire obtenus dynamiquement (synonyme d’allocation programmée) sont tous à la
charge du programmeur qui devra les libérer explicitement avec la fonction free une fois
leur usage complété. La perte par le programmeur d’une adresse obtenue en allocation
programmée amène une perte très réelle de mémoire « dite memory leak » de la
machine hôte pendant toute l’exécution du programme. Ces bugs sont pervers et
amènent implacablement au plantage de l’application.
6. Par adressage absolu : Un entier peut être considéré comme une adresse. Par exemple,
l’expression (int*)(void *) 1600 permet d’avoir accès à un entier à l’octet numéro
1600 en mémoire. Cette pratique n’est rencontrée qu’en programmation sur de microplateformes , elle est absolument NON PORTABLE et interdite dans notre cours.
7. Par arithmétique des adresses : À l’aide de tout opérateur d’ addition ou de
soustraction des entiers (+ - ++ -- += -= ), on peut obtenir une nouvelle adresse. Une
section plus bas décrit toute l’arithmétique des adresses.
L’indirection, comment atteindre le contenu d’une adresse
Dans une expression, l’opérateur d’indirection * en préfixe à une valeur adresse sera évaluée et
permet d’accéder au contenu de cette adresse (à une seule exception naturelle décrite au point 3).
Ce qui peut être par exemple :
1. Une addresse addr de type double* ou int* qui est l’adresse d’une valeur réelle ou
entière. L’expression *addr permet d’atteindre son contenu en lecture comme en
écriture. L’expression sizeof(*addr) est correcte et donne la taille en octets du
contenu.
2. Une adresse addr de type double( * )(double) par exemple est l’adresse d’une
fonction d’une variable réelle (mat 145). L’expression addr(2.56) ou (*addr)(8.65)
permet de déclencher l’exécution de la fonction et la valeur retournée sera réelle.
Remarquez que l’utilisation de l’opérateur * est alors superflue et que seules les fonctions ont ce
privilège. Dans ce cas, l’expression sizeof(*addr) ne s’applique pas puisque la
déclaration d’une fonction ne suffit pas à connaître le nombre d’octets de son code.
3. Une adresse de type void* où seule la position mémoire est importante et le type du
contenu volontairement éludé. Le besoin d’adresses génériques -- peu importe alors le type
du contenu -- a fait naître ce type spécial. On les utilise pour la comparaison (memcmp)
ou la copie (memcpy) de blocs d’octets de toutes tailles ou pour obtenir (malloc) ou
libérer (free) de la mémoire en allocation programmée. Ni l’indirection, ni l’arithmétique
des adresses ne s’appliquent à une adresse void * .
L’arithmétique des adresses
Les opérations arithmétiques permises sur une adresse tiennent compte de la taille de son
contenu. Ainsi, on ne pourra pas effectuer d’opération sur une adresse dont le contenu n’admet
pas l’opérateur sizeof -- adresses de fonctions et adresses génériques (void*) --.
1. La somme d’une adresse et d’un entier donne une adresse : Avec une adresse addr de
type T* et un entier positif n, (addr + n) est une adresse de type T* et sa position est
avancée de n * sizeof(T) octets dans la mémoire par rapport à addr.
2. La différence d’une adresse et d’un entier donne une adresse : Avec une adresse addr
de type T* et un entier positif n, (addr - n) est une adresse de type T* et sa position
est reculée de n * sizeof(T) octets dans la mémoire par rapport à addr.
3. La différence de deux adresses du même type T* donne un entier : La valeur obtenue
correspond au nombre d’objets de type T entre les deux adresses. Attention, ne
confondez pas ici nombre d’objets et nombre d’octets.
L’usage portable de l’arithmétique des adresses n’a de sens que sur des adresses à
l’intérieur du même tableau.
Le passage par référence (par adresse)
La programmation moderne, structurée et modulaire, encourage la création de fonctions et
l’absence de variables globales. Cette pratique amène avec elle l’utilisation massive de la
paramétrisation.
En C, tout passage en paramètre (du paramètre effectif au paramètre formel) se fait par copie.
Cette pratique est efficace pour les types de base. Pour un tableau ou un objet structuré dont
la taille est souvent imposante, le gaspillage de temps et de mémoire rend cette méthode
inacceptable C’est l’adresse d’un tel objet plus massif qui sera toujours passée à une fonction
dans un paramètre formel pointeur (on devra alors spécifier si le contenu sera constant ou non). Cette
pratique ne nécessite la copie que de quelques octets et empêche si nécessaire toute
modification des données originales.
C’est une erreur sémantique d’oublier la présence du const dans la déclaration du paramètre
formel pointeur si le contenu à l’adresse donnée en paramètre effectif ne doit pas être modifié.
Le mot réservé const et la déclaration des pointeurs
La présence de const à la déclaration d’un pointeur est particulièrement intéressante et
amène une agréable
1. Voudrez-vous signifier par ce const que le contenu – obtenu par indirection de sa valeur-ne puisse être modifié mais que le pointeur lui-même reste sujet à l’assignation et puisse
être modifié.( adresse modifiable, contenu invariable)
2. Voudrez-vous que la valeur donnée au pointeur avec une initialisation obligatoire reste
invariante et que le contenu à cette adresse puisse être modifié – reste sujet à
l’assignation— ( adresse invariable, contenu modifiable)
3. Et pourquoi pas les deux : adresse invariable, contenu invariable.
Le C vous laissera choisir celui des trois dont vous aurez besoin.
Ainsi les délarations

const T * pt1 ; donne un pointeur de la forme #1

T * const pt2 = adr ; (adr est une adresse valide) donne un pointeur de la forme #2

const T * const pt3 = adr ; donne un pointeur de la forme #3
Supplément en allocation programmée
Utiliser malloc, une fonction (ANSI) qui offre au client d’obtenir en exécution du programme l’adresse du
premier octet d’un groupe de taille définie d’octets contigus (cette valeur entière sera le paramètre de la
fonction) actuellement libres dans la mémoire (heap) de l’appareil sur lequel s’exécute le programme.
Cette fonction implémente une demande directe à l’O.S. (operating system ou système d’exploitation) qui
doit essayer de la satisfaire. Si la demande est possible et le bloc d’octets réservé (plus le paramètre est
grand et moins vous avez de chance), l’O.S. vous assure que ce bloc d’octets sera réservé au programme
qui en a fait la demande jusqu’à ce qu’une autre instruction directe du programme (appel de la fonction
free avec comme paramètre l’adresse obtenue précédemment ) ne les libère ou que le programme se
termine.
Ces blocs dynamiques d’octects n’ont pas de classe d’allocation ( le storage class obtenu en compile time
comme auto, register , static ou extern) comme les déclarations de variables ou de fonctions dont vous
connaissez déjà l’existence.
Le maintien d’un bloc obtenu dynamiquement est tout à la charge du programmeur.
Le résultat d’une allocation programmée sera :

NULL(symbole de stdlib) si l’OS est incapable de fournir le bloc du nombre d’octets demandé.

Une adresse -- void * -- différente de 0 en cas de succès. C’est au demandeur de transtyper
« typecast » l’adresse reçue (bien qu’ANSI accepte l’assignation directe d’une valeur void*,
certains compilateurs trop sérieux grognent encore en émettant un warning) et de la garder
précieusement puisque si vous la perdez, ce bloc devient inutilisable pour vous comme pour l’OS
– beau gaspillage --
Avantages et désavantages de l’allocation programmée :

La mémoire est utilisée avec efficacité, bien souvent un programme complexe a des besoins très
différents en mémoire dépendant des conditions initiales d’une exécution donnée – c’est
normalement le cas des simulations numériques --.

Elle est nécessaire à la construction de structures de données permettant d’accumuler du data en
cours de traitement en utilisant toujours juste la mémoire suffisante.

Ces appels à l’OS sont lents et peu sûrs puisque l’état d’utilisation du Ram de la machine dicte
aussitôt le résultat de la demande!! Beaucoup de programmes où la fiabilité est essentielle –
médical, transport aérien…. – apprécient très peu ou refusent à priori l’allocation programmée.
On peut aussi obtenir des blocs de mémoire grâce aux fonctions calloc et realloc de stdlib que je ne
décris même pas ici vu leur moindre importance.
Plusieurs exemples de la méthode pour un gros tableau
La fonction malloc s’utilise normalement sous la forme :
malloc(nombre d’éléments * sizeof(element))
La fonction free s’utilise normalement sous la forme :
free( adresse obtenue précédemment d’un malloc)
/*=====================================================*/
Imaginons maintenant que vous avez besoin d’une très grande matrice de réels,
par exemple une de (3500 X 1200).
/*=====================================================*/
D’abord la forme statique (sans allocation dynamique) que vous connaissez, le compilateur gère autant
l’apparition que la disparition du tableau, sans aucune capacité de modification d’espace sans recompiler
le projet.
/* ce sera un bloc unique, un monstre de 33 600 000 octets */
double tab [3500][1200];
/*pour atteindre un réel de cette matrice, aucune difficulté */
tab[217][38] = 34.89 ;
//sa durée de vie terminée, le compilateur se charge seul d’utiliser cette mémoire à d’autres fins.
/*=====================================================*/
Première forme d’allocation programmée, qu’on peut qualifier de semi-dynamique. Le premier tableau de
3500 adresses est statique (obtenu en compile time).
/* un tableau de double * , pour (3500 x 4) bytes */
double * tab [3500];
int i;
/* puis les blocs obtenus dynamiquement sont indépendants et contrôlés*/
for(i=0;i<3500;++i) {
/* ce typecast n’est pas obligatoire*/
tab[i] = (double *) malloc(1200* sizeof(double));
assert(tab[i]); // contrôle de base ultra-simple
}
/* mais pour atteindre un réel , rien n’est changé */
tab[2419][807] = 34.89 ;
/* usage terminé, les 3500 tableaux doivent être libérés mais tab sera géré par le compilateur */
for(i=0;i<3500;++i) { free(tab[i]);}
/*=====================================================*/
Deuxième forme, tous les tableaux sont dynamiques, le compilateur ne prend en charge que 4 octets.
int i;
double ** tab ;
tab = (double **) malloc( 3500 * sizeof(double*));
if ( tab != NULL) {
for(i=0;i<3500;++i) {
assert(tab[i] = malloc(1200* sizeof(double)) );
}
}
/*pour atteindre un réel de la matrice, aucune différence */
tab[2215][378] = 34.89;
/*après usage, les 3500 tableaux de réels doivent être libérés*/
for(i=0;i<3500;++i) { free(tab[i]);}
/* et aussi le tableau de pointeurs, ici l’ordre est important */
free(tab);
/*=====================================================*/
En conclusion, vous ne verrez aucune différence à l’usage d’un tableau qu’il soit obtenu en allocation
programmée ou statiquement lors de la compilation avec un storage class défini.
C’est uniquement avant et après usage que se situent les différences.
En fait lorsque l’on comprend bien le sens du malloc et du free, l’allocation programmée n’a plus de secret
mais elle garde ses dangers.
Et pourquoi une fonction retounerait-elle une adresse?
Question importante dont les réponses sont traditionnelles :
1. La fonction est faite pour bien construire grâce aux valeurs des paramètres effectifs du
client un nouvel objet dynamique. La fonction retourne NULL si la mémoire nécessaire
n’a pu être obtenue ou une adresse différente de NULL en cas de succès. Cette adresse
devra obligatoirement être récupérée et précieusement conservée par le client de la
fonction devenu propriétaire du nouvel objet (peu importe sa nature) jusqu’à sa libération
explicite dont il sera seul responsable.
On donne le nom de « handle » à ces adresses et de constructeur à ces fonctions. La
réalisation complète de leur existence de constructeurs viendra dans toutes les langues
de la programmation Objet C++, Java, C# ….
Historiquement ces fonctions d‘initiation d’objets sont importantes, ce sont elles qui ont
initié la modularité de construction et de maintien des représentations complexes de
l’informatique moderne. Toute idée d’encapsulation du data et de son traitement indirect
par des fonctions d’information ou de mutation vient d’abord des constructeurs en C.
2. La fonction est faite pour chercher selon des critères précis à partir d’une adresse
fournie en paramètre par le client (dans l’analoque d’un tableau en fait). La fonction
retourne en cas de succès de la recherche une adresse déjà propriété du client. Ces
fonctions ne génèrent pas de nouveaux blocs et leur usage est moins dangereux que
celles de la forme précédente.
Supplément sur les strings
Un type (char) et une convention sur l’usage de leurs tableaux
On les appelle traditionnellement « strings » en informatique ou chaînes de caractères qui sont
essentielles à la représentation de la langue écrite des humains que nous sommes.
Avec des chaînes de caractères, toute la culture écrite peut être conservée, éditée ou analysée avec la
programmation d’une machine électronique.
LES CHAR
Le C a le mot réservé char qui fait référence à un petit groupe d’entiers ( oui, avec + - * / %, et toutes les
opérations normales des entiers) dont la taille sizeof(char) est donné à un octet par la norme ANSI.
Mais le but visé avec ce type, c’est d’obtenir un système d’étiquetage – de numérotation – des caractères
utilisés dans l’écriture d’une langue naturelle comme l’anglais, l’italien ou le danois.
Certaines langues écrites comme le chinois auquel ne suffit pas 255 caractères différents, ont pris plus de
temps à être considérées par les informaticiens qui se servent aujourd’hui d’un type défini sur 2 octets, le
wchar (pour wide char) que nous ne traiterons pas dans notre cours.
Les chars acceptent donc des littérales particulières comme ‘c’ ‘A’ ou ‘ ’ et ‘\n’ nécessaires à la
construction ou la séparation des mots. Ils possèdent un format d’affichage ( %c ) bien à eux ( %c pour
comme un caractère)
ANSI a prévu la librairie ctype toute faite pour caractériser un caractère comme membre ou non d’un
groupe de signes particuliers d’une forme d’écriture ( ouvrez ctype.h et servez-vous de l’aide d’un compilateur)
LES STRINGS
1. Ce sont au départ des littérales que vous connaissez comme " Salut Robert, on se voit mardi " qui
sont correctes en C.
2. Les strings possèdent aussi un format d’affichage ( %s ) bien à eux.
3. Chaque caractère d’une string sera gardée dans un objet de type char.
4. Les strings ne forment pas un type, mais une convention d’usage des tableaux de char.
5. À partir de l’adresse d’un char, tous les caractères jusqu’à celui dont la valeur est 0 (‘\0’ égale 0 et
de facture ancienne) seront considérés comme appartenant à la même string. Une string
correspondra donc à l’adresse d’un char et à tous les octets suivants, jusqu’à celui de valeur 0 .
6. On peut comprendre pourquoi sizeof("allo") vaut vraiment 5, quatre octets pour les lettres plus un
octet pour le 0.
7. Un tableau de char – un vrai tableau à toujours une taille en C – peut contenir une ou plusieurs
string, mais attention à ne pas déborder du tableau.
8. À la déclaration, un tableau de char peut être initialisé d’une string littérale.
9. Les string sont traditionnellement traversées avec l’arithmétique des adresses (d’où leur intérêt
dans le cours d’aujourd’hui)
10. Une fonction faite pour recevoir une string aura donc un paramètre formel char* ou const char* .
Dans le traitement, la fonction considère tous les caractères suivants l’adresse reçue jusqu’au
char* de contenu 0 inclusivement comme faisant partie de la string. Si le client n’a pas la
décence d’assurer que la string est toute entière dans un tableau de char lui appartenant….c’est
lui qui courra tous les risques.
Encore une fois, ANSI a prévu une librairie toute prête (string) pour obtenir un traitement de base sur les
chaines de caractères
strlen : obtenir la taille d’une string
strcpy : copier une string source dans une string destination
strcat : concaténer deux strings
strchr : chercher un caractère dans une string (strrchr aussi)
strcmp : comparer deux strings(ordre lexico)
strstr : chercher une string dans une autre
strtok : atomiser une string composée de plusieurs éléments.
Et quelques exercices :
1. Regardez les déclarations puis codez strlen,strcpy, strcat,strchr,strcmp de string.h
2. Une fonction lettres qui recevant deux strings – destination et source – copie toutes
les lettres de la "string" source dans la destination. Elle retourne le nombre de
caractères copiés (ces lettres de la source sont limitées à celles reconnues par isalpha).
3. La fonction phrase qui recevant une "string" retourne VRAI si elle ne contient que des
lettres et des espaces.
4. Une fonction qui recevant une string, retourne la somme des codes Ascii de ses
caractères
5. Une fonction qui recopie dans une "string" obtenue en allocation programmée tous les
caractères de la "string" source mais les caractères de source y sont inversés. Elle
retourne l’adresse de la nouvelle string.
char* str_renverse(const char* src);
6. La fonction nb_voyelles qui recevant une "string" retourne le nombre de voyelles
qu’elle contient.
7. Une fonction qui recevant deux "string" compte le nombre de caractères de la première qui
apparaissent dans la deuxième.
8. Une fonction qui recevant une "string" retourne l’adresse de la plus longue suite de chiffres qu’elle
contient. Elle retourne NULL si on ne trouve aucun chiffre dans cette "string".
const char* cherche_chiffres( const char * s) ;
9. La fonction nb_blocs qui recevant une "string" retourne le nombre de blocs de
caractères séparés par des espaces (elle permet de compter le nombre de mots dans
une phrase).
10. Une fonction qui recevant une string, retourne le nombre de points qu’elle vaut au Scrabble. Les
valeurs données plus bas sont correctes pour les minuscules comme pour les majuscules.
 1 point : AELIMORSTU
 2 points : DG
 3 points : BCNP
 4 points : FHVWY
 5 points : K
 6 points :JX
 7 points : QZ
 tous les autres caractères ont valeur 0
Téléchargement