Pointeurs en C
Un pointeur est l’adresse d’un objet ou d’une fonction .
Un objet du point de vue du C designe une zone de mémoire à laquelle on peut accéder via son adresse. Une fonction est egalement adressable mais il ne s’agit pas d’un objet.
Voila quelques exemples d’éléments non adressables en C :
#define def
// on ne peut pas prendre l'adresse d'une variable de preprocesseur
enum mon_enum{
en1,
en2,
}
// on ne peut prendre ni l'adresse de mon_enum, ni de en1 ou en2
struct {
champ1:1;
champ2:3;
}
// champ1 est adressable mais pas champ2.
register int ent;
// on ne peut pas adresser une variable avec une type de
// stockage << register >>, qui n'est donc pas un objet.
Voila certains éléments adressables
int ent; // une variable non register
void fun() {
}
// une fonction
"toto" // une chaine de caractère s'évalue
// à l'adresse de son premier caractère
int tab[] = {1, 2, 3};
// tab est asdressable, ainsi que les élements consécutifs
// qui le constituent
struct stru {
int a;
char b;
bool oui1:1;
bool non:8;
int oui2:0;
} stru;
// stru est adressable,
// ainsi que a, b, oui1 et oui2. non est pas adressable.
inline void fun() {
}
// prendre l'adresse d'une fonction inlinée rend le
// spécificateur caduc et empeche le compilateur d'élider
// la fonction qui est donc adressable, sans toutefois
// être un objet
Demander au programme l’adresse d’un objet ou d’une fonction empêche de nombreuses optimisations basées sur l’elision de variables ou de fonctions. Faire un cas abusif de pointeurs dans son programe aura un effet nefaste sur la performance.
Les premiers ordinateurs disposaient de mémoire tambours ou a bande magnétique. L’accès à cette mémoire se faisait de manière sérielle et non aléatoire. Voir la mémoire d’un ordinateur comme une unique bande magnétique peut être pratique pour acquérir l’intuition de la notion d’un pointeur, surtout aujourd’hui où la mémoire d’un processeur est éparpillée entre registres, caches de niveau 1, 2 et 3, et mémoire vive, généralement agencées en feuillets de matrices de nombres, cette dernière pouvant être virtualisée, accessible directement, ou par l’intermédiaire d’un système d’exploitation.
Le processeur peut stocker dans ses registres et veut stocker une valeur “quelque part” de manière rapide et y accéder rapidement, ce quelque part peut être vu de manière simplifiée comme une bande magnétique virtuelle où l’adressage se fait de manière ordinale, la première adresse se trouvant à l’adresse zéro (0), et n’importe quelle autre valeur se trouvant à la distance correspondante à son ordinal dans l’ensemble des nombres entiers finis, la taille de cet ensemble étant limitée à la taille de la bande.
Un pointeur est un objet qui occupe de la place en mémoire donc on le déclare comme d’autres objets en précisant son type, en ajoutant une étoile pour signaler qu’il s’agit d’un pointeur puis en lui donnant un nom, et éventuellement en l’initialisant :
int * ptr = 0;
On donne à un pointeur généralement l’ordinalité
d’un autre objet, ou au lieu d’ordinalité on dit
généralement son “addresse”.
Pour obtenir l’adresse d’un objet on utilise
l’opérateur &
(un esperluette).
Déclarer un objet sur la pile lui attribue
automatiquement et de manière transparente pour nous
une adresse sur la pile. On peut égalemennt considérer
que la
machine virtuelle du C
nous fournit cette adresse.
On verra comment obtenir des pointeurs de manière
explicite en dehors de cette machine.
int a;
int *b = &a;
Le type d’un pointeur peut être de n’importe quel objet valide en C, y compris d’autre pointeurs : pour déclarer un pointeur de pointeur on s’y prend ainsi :
int a; // un entier non initialisé
int *b; // pointeur vers int indéterminé
int ** c = &b; // pointeur vers un pointeur de int
int tab[3] = {1, 5, 10}; // un tableau de 3 entiers
int (*d)[3] = &tab; // pointeur vers tableau de 3 entiers
int *e[3] = {&a, &tab[0], &((*d)[1])}; // tableau de 3 pointeurs vers des entiers, attention a la priorité des opérateurs !
Pointeurs vers des fonctions
Un pointeur vers une fonction suit une notation particulière. On le déclare ainsi
int fun1(int a, char b) {
return 1;
}
int fun2(int a, char b) {
return a + b;
}
char fun3(int a, int b) {
return a * b;
}
int main() {
int (pf*)(int, char); /// pointeur vers une fonction retournant un `int` et prennant en paramètre un int et un char
pf = fun1;
printf("%d\n", pf(10, (char) 2)); /// 1
pf = fun2;
printf("%d\n", pf(10, (char) 2)); /// 12
///pf = fun3; /// interdit
}
Un pointeur de fonction déclaré comme pointant
sur une fonction retournant un type donné
et prenant des paramètres avec des types donnés
ne peut pointer que sur des fonctions
qui correspondent à cette
signature
. Attention utiliser des pointeurs de
fonctions rend plus difficile les optimisations
consistant à inliner (applatir) une fonction
à son lieu d’appel. Afficher la valeur d’un
pointeur de fonction (avec printf ou au sein d’une
expression produisant un changement visible du
comportement du programme) interdira dentièrement
toute optimisation d’aplatissement. Tout comme
une variable déclarée register
ne pourra jamais
se faire pointer dessus par un pointeur, une fonction
déclarée
inline
ne pourra se faire
pointer dessus par un pointeur de fonction, ce qui
permet au compilateur d’effectuer des
optimisation d’applatissement agressives sur celle-ci.
États des pointeurs
Les pointeurs existent dans 3 états: valides, nuls, indeterminés (soit qu’ils soient non initialisés, “abandonnés” ou contiennent simplement une valeur ne correspondant à un objet valide pour quelque raison que ce soit).
Un pointeur nul est de type “pointeur sur void” et il a
une valeur de zero. Il s’écrit ainsi (void *)0
.
La macro NULL
dans la librairie <stdlib.h>
a cette valeur.
Un pointeur valide pointe sur un objet qui est dans sa durée de vie (lifetime). Un objet valide possède une addresse fournie par l’environnement où s’exécute le programme.
Un pointeur indéterminé ne réference pas un objet valide.
int *a; /* indéterminé */
int *b = 0; /* pointeur sur int déterminé à la valeur zéro */
void *d = 0; /* pointeur nul */
{
int c;
b = &c; /* b devient un pointeur valide après l'exécution de cette expression d'assignement */
}
/* b devient un pointeur indéterminé après l'exécution de la sortie du bloc */
int *e = malloc(4); /* soit valide soit nul*/
free(e); /* e devient indéterminé */
Attention: un programme déréférencant un pointeur indéterminé a un comportement indéterminé. Un programme qui déréférence un pointeur nul est généralement terminé par le système d’exploitation : il s’agit d’une erreur irrécupérable et terminale : bien que spectaculaire il s’agit d’un comportement moins risqué et pernicieux que de le laisser s’exécuter comme ce serait le cas dans le cas d’un pointeur indéterminé. Il faut garder en tête que déréférencer un pointeur est une chose dangeureuse.
Valeur interne
La représentation binaire et la taille en mémoire d’un pointeur est opaque et varie selon les architectures et il ne faut pas s’en soucier.
La norme C demande qu’on puisse les convertir vers une valeur entière et assigner une valeur entière à un pointeur.
C’est à dire le programme suivant sur une architecture 32 bits est valide.
int main() {
char * toto = 42;
int pointeur = toto;
*toto = 24;
printf("toto = %dn", *(*char)pointeur); // 24
}
En programmation système ou embarquée cela est utile pour obtenir des pointeurs sur des régions connues et documentées de l’architecture courante.
Pointeur et register
Il n’est pas possible de déclarer de pointeur sur
une variable de type register
. En sachant qu’aucun
autre pointeur ne pourra acceder ou modifier cette variable, des optimisations de compilateur deviennent possibles.
Pointeurs restrict
On peut
restreindre
depuis le
C99
la possibilité
de donner un alias à un pointeur avec
le mot clé restrict
.
Tout comme pour la classe de stockage register ce qualificateur ne modifie pas le comportement visible du programme mais permet certaines optimisations.
Nullabilité des pointeurs
On peut spécifier depuis le C11 qu’une fonction n’accepte
que des pointeurs pointant sur une région alouée de la mémoire,
il faut declarer le paramètre comme un tableau de taille
1, et précéder la taille du mot clé static
.
#include <stdlib.h>
/* Le pointeur doit pointer sur une adresse valide */
void fun(int param[static 1]) {
if(param == NULL) {
// vu que selon les règles du langage
// le comportement du programme si le
// pointeur param est nul est indéfini,
// le compilateur peut supprimer tout
// le code présent dans ce bloc, il
// est donc inutile voire dangeureux
// d'écrire un tel test
}
}
void null_ok(int * param) {
if(param == NULL) {
// le traitement à faire si pointeur nul
}
// ...
}
int main(void) {
int *toto;
int **tata = &toto;
fun(NULL); // avertissement du compilateur
fun((void*)0); // avertissement
fun((void*)42); // ok, pointeur sur void mais non nul
fun((int*)0); // ok, un pointeur nul mais sur int
fun(toto); // ok, un pointeur non initialisé est autorisé
fun(*tata); // ok, pointeur valide
null_ok(NULL); // ok
null_ok(*tata); // ok
}
On voit donc que [static N]
peut aider à documenter
l’intention d’une fonction mais n’est pas une garantie
contre un mauvais usage. Ce n’est qu’une indication
du compilateur qui n’ajoute aucun coût au runtime, mais
passer un pointeur nul à une telle fonction causera
un
comportement indéfini
alors que ce n’est
pas le cas dans une fonction acceptant juste un pointeur :
il sera donc tâche au code qui appelle une telle fonction
de faire un “NULL check” avant d’apeller la fonction.
Un pointeur devrait correspondre exactement au type pointé
Alors que les entiers sont tolérants vis à vis des valeurs
qu’ils acceptent en les convertissant silencieusement
si besoin : un long int
peut accueillir un entier
“plus petit” tel que signed char
ou un int
, un pointeur
devrait strictement correspondre au type pointé : cela est
nécessaire pour l’arithmétique des pointeurs et que
les valeurs dans un tableau soient placées au bon offset.
En sorte :
int main() {
long int li = 't'; // ok : une chaine de caracteres est de type "int", converti implicitement en "long int"
signed char sc = 1234; // avertissement la conversion de 1234 en signed char peut entrainer une perte de données
long long int * lli = &li; // avertissement : l'arithmetique de pointeurs aura un pas différent du type pointé
}
Parité des pointeurs
Le processeur essaye toujours de
placer les variables à des valeurs qui sont des multiples
de deux. C’est un dire que la valeur d’un pointeur sera
toujours un nombre pair, sauf si il s’agit d’un pointeur
vers char
. Le pointeur
vers char
est le seul pointeur dont l’adresse peut être impaire.
Une valeur paire aura toujours le bit de poids faible à
zéro, donc on peut imaginer que pour optimiser la place
un ordinateur ne stocke la valeur du dernier bit d’un pointeur
que si il s’agit d’un pointeur vers char
. En règle
générale on peut considérer la valeur d’un pointeur comme
étant opaque et utiliser la simplification de la considérér
comme étant une valeur entre 0 et la taille de la mémoire
comme étant une simplification pour aider à l’enseignement du C.
Astuces
- Toujours initialiser ses pointeurs.
- Eviter de pointer sur des valeurs d’une portée et d’une durée de vie differente des leurs.
- Si on ne peut l’éviter, leur assigner explicitement la valeur 0, ou NULL, si stdlib a été incluse, pour éviter une source de comportement indéterminé.
Un pointeur peut prendre l’addresse d’une variable scalaire,
d’un tableau (et d’une chaîne de caractères), d’une
structure, d’une fonction, mais pas d’une variable de
type register ou d’une fonction déclarée
inline
.
Une fois un pointeur déclaré, il ne pourra pas changer de type, sauf cast. On peut créer autant de pointeurs sur autant de types que l’on veut.
Un pointeur void quand a lui peut changer de type. Par definition on ne peut creer de variable de type void, et donc un pointeur void ne peut correspondre a un objet : on peut par contre le cast ou le transferer vers tout autre pointeur, void ou non.
Finalement le pointeur sur void de valeur 0
void * pointeur = 0
est appellé le pointeur nul,
il est utilisé par de nombreuses fonctions de la
librairie standart du C et déréférencer un tel pointeur
cause l’arrêt du programme. Il est interdit de déréférencer
un pointeur sur un type void
en règle générale, et
sa valeur étant zero elle na validera pas une
expression conditionelle. La librairie standart du C
définit NULL
dans stdlib.h
comme étant un pointeur
de ce type.
Parler de pointeurs de pointeurs, pointeurs de fonctions, cdecl.org
Extensions non standard
Nullabilité des pointeurs en Clang
Clang
permet de spécifier si un pointeur peut
être nul _Nullable
, jamais nul _Nonnull
ou si pour des raions de maintenabilité
ou d’opacité du code cela est
impossible à dire _Null_unspecified
.1
Dans les librairies MacOS, il est courant de voir du code
ainsi, qui ne compilera qu’avec clang
.
// passer un pointeur nul à cette fonction
// provoque un comportement indéterminé
int fetch(int * _Nonnull ptr) { return *ptr; }
// 'ptr' peut être nul.
int fetch_or_zero(int * _Nullable ptr) {
return ptr ? *ptr : 0;
}
// Un pointer nulable sur des pointeurs non nullable sur des
// caractères constants.
const char *join_strings(
const char * _Nonnull * _Nullable strings, unsigned n
);
Il existe une notation standart depuis le C11 pour signifier qu’on se souhaite pas qu’un pointeur soit non nul.
Pointeurs near, far et huge
A l’epoque où les processeurs 16 bits étaient la norme, il était possible de spécifier la représentation interne du pointeur selon les opérations qu’on souhaitait faire dessus.
Plus récemment alors que les pointeurs de 16 octets comment à apparaître certains compilateurs autorisent un qualificateur far sur les pointeurs.