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