Ce document a pour objectif de présenter comment les éléments constituant un programme C sont conçus et interprétés par ce langage. Présentant parfois un coté abstrait, nécessaire pour mettre en évidence l'unité et la cohérence du langage, et ne présentant que peu d'exemples de code, ce document s'adresse plutôt à des lecteurs qui ont un début d'expérience pratique du langage C pour les aider à en dégager la logique et le fonctionnement.

Il ne s'agit donc pas ici de présenter une initiation au langage C s'adressant à des débutants.

Nous nous intéresserons d'abord aux grandeurs manipulées par un programme C que nous classerons en valeurs et objets. Nous en décrirons les caractéristiques fondamentales en C puis l'interprétation que le langage fait de ces grandeurs lorsqu'elles interviennent dans une expression (contextes). Nous décrirons plus en détail les grandeurs présentant une particularité sémantique (booléens, adresses) ou structurelle (tableaux) avant d'évoquer la composition du code d'un programme C.

Votre avis et vos suggestions sur ce tutoriel m'intéressent ! Alors après votre lecture, n'hésitez pas : Commentez

1-09-10 : Révision du paragraphe V-A

- Titre : Concepts en C

- Auteur : Patrick Gonord

- Parution : 1-07-08

- Mise à jour : 1-09-10

- Licence : Ce document est issu de http://www.developpez.com et reste la propriété exclusive de son auteur. La copie, modification et/ou distribution par quelque moyen que ce soit est soumise à l'obtention préalable de l'autorisation de l'auteur.

Sommaire

- I. Valeurs

- I-A. Type

- I-B. Conversions, Cast

- II. Objets

- II-A. Caractéristiques des objets

- II-A-1. Type

- II-A-2. Adresse

- II-A-3. Valeur d'un objet

- II-B. Vie et mort des objets

- II-C. Accès à un objet

- III. Contexte d'évaluation des grandeurs

- III-A. Contexte des opérateurs

- III-A-1. Opérateurs donnant pour résultat un objet

- III-A-2. Opérateurs qui ont une opérande dans un contexte d'objet

- III-A-3. L'opérateur sizeof

- III-B. Contexte des fonctions

- IV. Grandeurs à caractère booléen

- IV-A. Opérateurs entre booléens

- IV-B. Représentation des valeurs booléennes

- V. Grandeurs de type adresse

- V-A. Type adresse

- V-B. Opérateurs sur le type adresse

- V-B-1. Les opérateurs unaires sizeof, * et ->

- V-B-2. Les opérateurs + et - entre une adresse et un entier

- V-B-3. L'opérateur []

- V-B-4. L'opérateur - entre deux adresses

- V-B-5. Les opérateurs de comparaison

- V-C. Type void *

- V-D. Objets pointeurs

- VI. Objets tableaux

- VI-A. Type tableau

- VI-B. Opérateurs définis pour les tableaux

- VI-C. Valeur d'un objet tableau

- VI-D. Fonctions et tableaux

- VII. Pointeurs sur fonctions

- VIII. Déclarateurs complexes

- IX. Programme C

- IX-A. Structure d'un programme C

- IX-B. Nature du code exécutable

- IX-B-1. Instructions de contrôle

- IX-B-2. Expressions

- X. Conclusion

I - Valeurs

Une valeur est une grandeur intrinsèquement constante . Elle a un caractère abstrait en ce sens qu'elle n'a pas besoin d'un support matériel pour exister.

Exemples :
2 , 3.1416 , Vrai, Rouge, ...

Ces grandeurs sont de nature différente : on a ici des valeurs du genre entier, réel, booléen, couleur. Selon leur nature, certaines opérations seront possibles et d'autres seront interdites ou non définies. Ainsi, il semble naturel de donner un sens à 2+3 mais que dire de 2+Rouge ? Même si les valeurs doivent être représentées en machine par un code, ceci ne doit pas faire oublier leur caractère abstrait ni leur signification.

Note : Le terme de valeur est utilisé ici dans un sens usuel.
Le résultat de l'évaluation d'une expression est souvent qualifié de "valeur de l'expression". En C, le résultat de l'évaluation d'une expression est une grandeur, valeur ou objet. Le terme "value" utilisé par la norme ne correspond pas à la définition du terme valeur de ce texte, mais plutôt à celle du terme "grandeur".

I-A - Type

En C, une valeur possède un type qui précise à quel ensemble appartient la valeur.

Le type caractérise les propriétés de cet ensemble, c'est à dire :

1. les valeurs que peuvent prendre les éléments de cet ensemble.

2. les opérations qui peuvent être effectuées avec les valeurs de cet ensemble.

3. le codage binaire permettant la représentation des valeurs de cet ensemble en machine. L'opérateur sizeof nous donne le nombre de bytes utilisés pour la représentation de ces valeurs.

On peut donc considérer une valeur comme une constante d'un type donné (à différentier d'un objet constant).

I-B - Conversions, Cast

Il est parfois nécessaire de transformer une valeur V_1 d'un type donné T_1 en une valeur V_2 d'un autre type T_2 . Cette opération est appelée transtypage ou cast. Elle impose que soit définie une règle de transformation des valeurs de type T_1 en des valeurs de type T_2 . Si une telle règle n'existe pas, le transtypage est impossible.

Cette opération de transtypage est parfois effectuée automatiquement, et plus ou moins discrètement selon le cas, par le compilateur ou peut être demandée par le programmeur, lorsqu'elle est possible, en utilisant l'opérateur de cast ().

(T_2 ) V_1

- Cette transformation peut conduire à une différence radicale entre la représentation binaire de V_1 et de V_2 et n'est donc pas une simple interprétation du codage binaire de V_1 selon la représentation binaire utilisée par les valeurs de type T_2 .

- La transformation ne conserve pas nécessairement la "valeur" de la grandeur : par exemple, la transformation de la valeur 258 de type int en une valeur de type unsigned char (sur 8 bits) donnera pour cette derniere la valeur 2.

II - Objets

Un objet est un conteneur d'information. Il a donc un caractère très concret puisqu'il est matérialisé par une zone mémoire permettant de stocker cette information. Cette information est une valeur .

L'information peut être structurée lorsque l'objet est construit à partir d'autres objets (objet du type agrégats : tableaux et structures). L'information associée à l'objet est alors l'ensemble des informations stockées par les objets qui le constituent.

II-A - Caractéristiques des objets

II-A-1 - Type

Un objet possède un type qui caractérise :

1. La structure interne de l'objet.

2. L'information qu'il contient et comment y accéder.

3. La place nécessaire au stockage de cette information, donc la place que l'objet occupe en mémoire. L'opérateur sizeof nous donne le nombre de bytes occupés en mémoire par l'objet.

4. Les opérations qui peuvent être effectuées avec cet objet.

Il n'est pas possible de modifier le type d'un objet : Le type de l'objet est fixé à sa création

II-A-2 - Adresse

Un objet possède une adresse : C'est une valeur qui repère l'emplacement de l'objet en mémoire. Le codage binaire utilisé pour ce repérage ne nous concerne pas.

- Pour obtenir l'adresse d'un objet, on dispose de l'opérateur unaire & : l'adresse de l'objet obj est &obj

- Pour obtenir l'objet situé à une adresse donnée, on utilise l'opérateur unaire * : l'objet à l'adresse adr est *adr (et par conséquent *&Objet est identique à Objet).

- Ceci implique qu'il existe des valeurs d'un type particulier décrivant l'adresse des objets. Elles seront dites du type "adresse d'un objet de type ..."

Il n'est pas possible de modifier l'adresse d'un objet : elle est fixée au moment de sa création. En d'autres termes, on ne peut pas "déplacer" un objet.

L'opérateur sizeof et l'opérateur unaire & sont les seuls opérateurs définis pour tous les objets, quel que soit leur type. Les objets du genre tableau ne possèdent que ces deux opérateurs. Les objets de type struct possèdent également l'opérateur d'assignation simple =.

II-A-3 - Valeur d'un objet

Il faut pouvoir accéder à l'information contenue dans un objet. Cet accès se fait par la propriété qu'il est convenu d'appeler la valeur de l'objet .

Deux cas sont possibles :

1. La valeur de l'objet est directement l'information stockée par l'objet. C'est le cas de tous les objets à l'exception unique des objets du genre tableau.

- On peut (si l'objet n'est pas déclaré constant const ) modifier la valeur de l'objet, donc l'information qu'il stocke en utilisant, par exemple, l'opérateur d'assignation =.

- Si l'information est de type T, alors la valeur de l'objet est de type T et l'objet lui-même est dit de type T.

2. La valeur de l'objet est l'adresse où se trouve placée l'information stockée par l'objet. C'est le cas des seuls objets du genre tableau.

- L'adresse de l'objet étant, on l'a vu, fixée à sa création et n'étant pas modifiable, on ne peut modifier la valeur d'un objet tableau.

- Le type de l'objet est alors différent du type de sa valeur : l'objet est du type "tableau de ..." , sa valeur est du type "adresse de..."

En résumé, un objet est caractérisé par :
- son type (non modifiable)
- sa valeur (modifiable si l'objet n'est pas un objet constant ou un tableau)
- son adresse (non modifiable)

II-B - Vie et mort des objets

Un objet existe lorsqu'une zone mémoire lui est allouée (pour lui permettre de stocker son information) et il cesse d'exister lorsqu'elle lui est reprise.
Le programmeur doit être sûr que l'objet qu'il utilise est "vivant". Il doit donc savoir quand l'objet a été créé et quand il a été détruit. Sa durée d'existence est liée au mode de création utilisé.

Les modes possibles de création d'un objet sont :

1. Par allocation mémoire statique

- L'objet est déclaré hors de toute fonction (ou est spécifié static ).

- Il est créé avant même de commencer l'exécution du programme (c'est à dire avant l'invocation de la fonction main).

- Son information est initialisée par défaut par un code où tous les bits de la mémoire allouée à l'objet sont à 0, mais il est possible d'initialiser l'objet au moment de sa création à une autre valeur.

- L'objet est repéré par un nom (identificateur).

- L'objet est détruit automatiquement à la fin du programme.

2. Par allocation mémoire automatique

- L'objet (non static ) est déclaré dans une fonction.

- Il est créé automatiquement dans une zone mémoire spéciale appelée pile (stack).

- Aucune initialisation de la mémoire allouée n'est faite par défaut, mais il est possible d'initialiser l'objet au moment de sa création.

- L'objet est repéré par un identificateur.

- Il est automatiquement détruit lorsque l'exécution du bloc { } où il est défini est terminée. C'est donc un objet "temporaire".

3. Par allocation mémoire dynamique

- Cette méthode permet de créer un bloc d'objets identiques (bloc qui peut ne comporter qu'un seul objet). La structure du bloc d'objets et les méthodes d'accès à leur information sont celles des tableaux et on parlera souvent de "tableau en allocation dynamique".

- Ils sont créés sur la demande explicite du programmeur dans une zone mémoire appelée tas (heap) par l'appel à une fonction de la bibliothèque (comme malloc).

- Dans ce cas, le bloc d'objets n'est pas repéré par un identificateur mais (comme pour les tableaux) par l'adresse du début du bloc d'objets.

- Il ne sera détruit que sur la demande explicite du programmeur, par appel à une fonction de la bibliothèque (free). Il est de la responsabilité du programmeur de détruire (une et une seule fois !) les objets devenus inutiles ainsi créés afin de rendre disponible l'espace mémoire qu'ils occupaient.

Lors de la création d'un objet, il est nécessaire de préciser la place mémoire nécessaire au stockage de l'information de l'objet. Dans les deux premières méthodes, cette information sera fournie en spécifiant, à sa création, le type de l'objet (ce qui précisera également les opérations qui lui seront applicables) et dans la dernière, elle sera fournie à la fonction d'allocation (souvent par l'intermédiaire de l'opérateur sizeof ).

II-C - Accès à un objet

Il n'existe fondamentalement qu'un mécanisme pour accéder à un objet : savoir où il est placé en mémoire, donc connaître son adresse. Toutefois, du point de vue du programmeur, deux possibilités sont offertes :

1. On connaît l'adresse de l'objet :

Pour accéder à un objet dont on connaît l'adresse, on utilise l' opérateur unaire *

2. Un nom (identificateur) a été donné à l'objet au moment de sa création (donc en allocation statique ou automatique).

Le compilateur alloue la zone mémoire nécessaire à la création de l'objet et connaît donc son emplacement en mémoire : il fera alors lui-même la correspondance entre l'identificateur et l'emplacement en mémoire de l'objet.

L'adresse d'un objet n'est valide que durant la durée de vie de l'objet. C'est pourquoi, par exemple, il ne faut jamais retourner l'adresse d'un objet local à une fonction (donc en allocation automatique).

III - Contexte d'évaluation des grandeurs

Le contexte d'évaluation d'une grandeur précise si la grandeur attendue à cet endroit est une valeur ou un objet . On parlera alors de contexte de valeur ou de contexte d'objet.

On ne peut pas placer une valeur dans un contexte d'objet

Si on place un objet dans un contexte de valeur, ceci sera interprété comme la valeur de l'objet

III-A - Contexte des opérateurs

A chaque opérande d'un opérateur correspond un contexte. Le résultat de l'opération peut être un objet ou une valeur selon le cas.

La plupart des opérateurs ont leur(s) opérande(s) dans un contexte de valeur et donne une valeur pour résultat. Les cas particuliers sont énumérés ci-dessous.

III-A-1 - Opérateurs donnant pour résultat un objet

Ce sont les deux opérateurs unaires * et . (point) ainsi que les opérateurs [] et ->.

- l'opérateur unaire * , permettant d'obtenir l'objet placé à une adresse donnée.
L'opérande est dans un contexte de valeur.

*Valeur => Objet

- l'opérateur unaire . (point) de sélection d'un champ d'une structure ou d'une union si l'opérande est un objet.

Objet.id => Objet

Si l'opérande est une valeur, le résultat est une valeur: Valeur.id => Valeur

Exemple

struct s {int a ;};
struct s st;
struct s f(void);

Alors :  st.a est un objet
f().a est une valeur

Les deux opérateurs suivants sont en fait dérivés des deux opérateurs précédents mais permettent une écriture plus agréable :

- l'opérateur de sélection d'un objet dans un tableau []. L'écriture a[b] est équivalente à *(a+b) (ce qui fait qu'on peut écrire indifféremment a[b] ou b[a])
Les opérandes sont dans un contexte de valeur.

Valeur[Valeur] => Objet

- l'opérateur unaire -> de sélection d'un champ d'une structure ou d'une union à partir de l'adresse de la structure ou de l'union. L'écriture a->b est équivalente à (*a).b
L'opérande est dans un contexte de valeur.

Valeur->id => Objet

III-A-2 - Opérateurs qui ont une opérande dans un contexte d'objet

Ce sont les 4 opérateurs & . (point) ++ -- ainsi que les opérateurs d'assignation simple ou composée =, +=, etc.

- l'opérateur unaire & permettant d'obtenir l'adresse d'un objet.
Le résultat est une valeur.

&Objet => Valeur

- l'opérateur unaire . (point) de sélection d'un champ d'une structure ou d'une union (voir aussi III-A-1).
Le résultat est alors un objet.

Objet.id => Objet

- les opérateurs d'incrémentation ++ et de décrémentation –-
Le résultat est une valeur.

Objet++ => Valeur

- les opérateurs d'assignation ( =, +=, etc.) pour leur opérande de gauche. L'opérande de droite est dans un contexte de valeur.
Le résultat est une valeur.

Objet = valeur => Valeur

Exemple

Supposons que ObjEntier et ObjEntier1 soient deux objets.
Il est possible d'écrire :
(les parenthèses sont mises pour la clarté mais  sont ici superflues)

ObjEntier = (ObjEntier1 = UnEntier) ;

Le terme entre parenthèses s'évalue comme une valeur et ObjEntier est un objet
et on a bien objet = valeur.

Mais il est impossible de mettre les parenthèses comme ceci :

(ObjEntier = ObjEntier1) = UnEntier2 ;

Le terme entre parenthèses s'évalue comme une valeur et on a  valeur = valeur
ce qui viole le contexte de l'opérateur =

III-A-3 - L'opérateur sizeof

L'opérateur sizeof est une exception. Le contexte de l'opérande est un contexte d'objet. Toutefois, l'opérande peut être un type (entre parenthèses, comme l'opérateur de cast) ou une valeur. Le résultat est une valeur égale au nombre de bytes occupés en mémoire par un objet qui aurait ce type ou le même type que celui de son opérande.

sizeof Objet => Valeur

sizeof (Type) => Valeur

sizeof Valeur => Valeur

III-B - Contexte des fonctions

Il y a deux aspects dans le cas des fonctions : l'appel à une fonction et la fonction elle-même.

- Lors d'un appel à une fonction, tous les arguments sont dans un contexte de valeur. Le résultat de l'appel à une fonction, autrement dit l'expression constituée par l'appel d'une fonction est une valeur . Cet appel ne peut figurer que dans un contexte de valeur.

fonction(Valeur,Valeur,.....) => Valeur

- Les paramètres d'une fonction, qui figurent dans la déclaration de la fonction, représentent des objets locaux à la fonction (en allocation automatique) initialisés par les valeurs correspondantes figurant dans l'appel de la fonction.

- Le contexte de l'expression suivant l'instruction return est un contexte de valeur.

Il n'y a aucune exception à ces règles.

Exemple

Il est impossible d'écrire

f(---)= ...
mais
*f(---)=...
est possible (à la condition que la fonction f renvoie une adresse).

IV - Grandeurs à caractère booléen

Une grandeur de type booléen peut prendre comme valeurs Vrai ou Faux. En C ISO90, le type booléen, bien que de la première importance, est resté "incomplet". On doit néanmoins le considérer comme un type de données particulier puisqu'il possède une gamme d'opérateurs et une sémantique qui lui sont propres.
Cet "oubli" a été réparé en C99 avec l'introduction du type _Bool .

L'importance du type booléen vient de ce qu'il est utilisé par les instructions de contrôle du programme :

if(exp_bool)...

do(exp_bool)...

do... while(exp_bool)

for (...; exp_bool ;...)...

ainsi que dans l'expression conditionnelle obtenue avec l'opérateur ternaire ?:

exp_bool ? expa : expb

Les valeurs de type booléen sont produites comme résultat des opérateurs de comparaison.

IV-A - Opérateurs entre booléens

Les opérateurs suivants prennent pour opérande(s) des valeurs booléennes et fournissent une valeur booléenne :

- ET booléen : opérateur &&

- OU booléen : opérateur ||

- NON booléen : opérateur unaire !

A noter que les expressions formées à partir de l'un des deux premiers opérateurs utilisent une procédure d'évaluation particulière : leur opérande de gauche est toujours évalué en premier. Si le résultat obtenu permet de déterminer le résultat final de l'expression, l'opérande de droite n'est pas évalué.

IV-B - Représentation des valeurs booléennes

Si les booléens sont utilisés correctement, comme un type de donnée particulier, leur représentation interne peut être largement occultée.

Les valeurs booléennes sont assimilées à des entiers, Faux étant représenté par la valeur 0 (toute valeur différente de 0 sera considérée en tant que booléen comme étant Vrai). Les expressions fournissant une valeur booléenne comme résultat donneront 1 pour la valeur Vrai.
La conséquence de cette assimilation est qu'il est possible de mélanger des expressions arithmétiques et booléennes. Ces expressions sont peu lisibles (par défaut d'abstraction car elles reposent sur la représentation interne des booléens), n'ont pas grand sens et constituent un vieux jargon de programmeur C. Elles peuvent toujours être remplacées par des expressions respectant la sémantique des booléens.
Ce mélange des genres est donc fortement déconseillé.

exemple

3+ (j>0)
Mais que peut signifier 3+Vrai ou 3+Faux ??
Ceci peut être écrit beaucoup plus logiquement avec
l'opérateur d'expression conditionnelle :
j>0 ? 4 : 3

De même, là où est attendue une valeur booléenne, placez une expression dont le résultat est une valeur booléenne.

exemple

- Si i est un nombre, écrivez     if(i == 0)...
de préférence à                 if(!i)...
qui exprime implicitement que i a un caractère booléen.

- Si b a un sens booléen, ecrivez if(!b)
de préférence à                 if(b == 0)...

En C90, pour déclarer un objet de type booléen, il faudra lui donner en pratique un type entier et une fonction retournant un booléen devra retourner un type entier.

exemple

typedef enum {Faux,Vrai} Bool;

Toutefois, cette définition n'est pas totalement satisfaisante
car elle ne permet pas l'évaluation correcte des expressions
(redondantes) UnBool==Vrai et UnBool!=Vrai si UnBool  est différent de 0 ou 1

V - Grandeurs de type adresse

On a introduit, en décrivant les caractéristiques d'un objet, un type particulier de valeur qui représente l'adresse d'un objet. L'importance de ce type vient de ce que l'adresse permet d'accéder à l'objet.

Une seule constante de ce type, NULL, est définie et permet de représenter "l'adresse d'aucun objet".

On ne confondra pas le type adresse avec un type entier : la grandeur représentée est d'une nature différente et le sens des opérateurs est différent.

V-A - Type adresse

La notion d'adresse est plus riche que le simple repérage d'une position mémoire : le type de l'objet intervient dans la définition d'un type "adresse". Les raisons de ceci sont les suivantes :

- Ceci permet de savoir quel objet est concerné par cette adresse. En effet, si un objet se situe à une et une seule position en mémoire, l'inverse n'est pas vrai : à une position mémoire donnée, on peut trouver plusieurs objets de type différent : c'est le cas si l'objet est un aggrégat (tableau ou structure) ou une union. Par exemple, si on a un tableau, l'adresse du tableau repère la même position mémoire que l'adresse du premier élément du tableau. Ce sont deux objets de type différent situés à une même position en mémoire et leur adresse sera en C d'un type différent.

- Ceci permet de connaître le type du résultat de l'évaluation d'une expression comportant des adresses (par exemple *Ta est de type T)

- Certains opérateurs sur les adresses utilisent implicitement la taille de l'objet adressé, donc doivent connaître son type.

- L'exception à ce principe concerne le type void *.

Déclaration d'un type "adresse"

Le type Ta "adresse d'un objet de type T " sera noté en C en cohérence avec la signification de l'opérateur unaire * :

On écrira que l'objet à l'adresse de type Ta, (*Ta), est de type T : T (*Ta) ou plus simplement :

T * Ta

- Si on préfère associer le signe * au type T, T * Ta , on lira " Ta est un type adresse de T ".

- Si on préfère voir le signe * associé au type Ta, T * Ta , on lira "l'objet à l'adresse de type Ta est de type T ".

On retrouvera ce souci de cohérence entre déclarations de type et action des opérateurs dans toutes les déclarations en C ce qui explique et justifie le caractère parfois ésotérique de la déclaration des types.
Voir à ce sujet les déclarateurs complexes

V-B - Opérateurs sur le type adresse

Le type "adresse d'objet" a son jeu propre d'opérateurs ayant un sens bien particulier. Certains de ces opérateurs binaires (à deux opérandes) ont la particularité d'associer deux valeurs de types radicalement différents (adresse et entier), ce qui est une exception en C, pour fournir une valeur de type adresse.

V-B-1 - Les opérateurs unaires sizeof, * et ->

Ces opérateurs ont été déjà décrits et on se reportera au chapitre III-A

V-B-2 - Les opérateurs + et - entre une adresse et un entier

Ces opérateurs sont utilisés lorsqu'on a un ensemble d'objets d'un même type T placés consécutivement en mémoire (autrement dit un tableau d'objets T).

Si A est l'adresse d'un objet de type T (donc A est de type T*) et n un entier :

- A+n permet d'obtenir l'adresse du nieme objet de type T qui serait situé après (si n>0) ou avant (si n <0) l'objet d'adresse A

- A-n permet d'obtenir l'adresse du nième objet de type T qui serait situé avant (si n>0) ou après (si n <0) l'objet d'adresse A

Ces opérateurs ont besoin de connaître la taille de l'objet adressé, donc son type.

V-B-3 - L'opérateur []

Cet opérateur est dérivé de l' opérateur unaire * et de l'opérateur + sur les adresses et permet une écriture et une lecture plus naturelle. Cet opérateur a donc besoin de connaître la taille de l'objet adressé, donc son type.

L'un des deux opérandes doit être une adresse A et l'autre un entier n.

A[n] ou n[A], écriture peu courante, sont tous deux équivalents à *(A+n)

V-B-4 - L'opérateur - entre deux adresses

L'opérateur – est également défini entre deux adresses A et B d'un même type T* :

Si B == A+n , alors n == B-A

Cet opérateur a besoin de connaître la taille de l'objet adressé, donc son type.

L'opérateur + entre adresses n'est pas défini.
Ainsi, si a, b et c sont du type T*, il est illégal d'écrire a+b-c (équivalent à (a+b)-c)
mais correct d'écrire a+(b-c) qui est une expression du type T*

V-B-5 - Les opérateurs de comparaison

Ce sont les opérateurs ==, !=, <=, <, > , >=

- Les deux premiers permettent de savoir si les adresses sont celles d'un même objet ou de deux objets différents.

- Les quatre derniers permettent de connaître la position relative en mémoire de deux objets. Cette information n'est pertinente que si ces objets appartiennent à une même structure de données ( tableau ou struct ).

V-C - Type void *

Le type "adresse d'objet" spécifie normalement le type de l'objet adressé. Il est quelquefois utile que cette information soit, au moins provisoirement, non précisée. Dans ce cas, le type adresse sera "adresse d'un type void " : void *.

Naturellement, comme le compilateur ne connait pas le type réel de l'objet, on ne peut utiliser l'opérateur unaire *, les opérateurs + , – portant sur les adresses ni l'opérateur []. On ne peut finalement pas faire grand chose avec une adresse de ce type, à part utiliser les opérateurs de comparaison, et il faudra, pour pouvoir utiliser les autres opérateurs, rétablir par un transtypage explicite le type de l'adresse en accord avec le véritable type de l'objet adressé (et surtout ne pas se tromper !).

On a avec le type void * un type d'adresse s'appliquant à tous les objets. On l'appelle quelquefois "type générique d'adresse" .
A noter que le type void ** n'est pas, lui, un type générique d'adresse : on connaît parfaitement le type de l'objet adressé ( void *)

A noter que le terme void * est assez maladroitement choisi puisqu'il ne s'agit pas du tout d'une adresse de "rien" (une adresse de "rien" est représentée par la valeur NULL), mais d'une adresse (valide) d'objet dont le type est non précisé ou est la valeur NULL.

V-D - Objets pointeurs

Les objets permettant de stocker ce type d'information sont appelés pointeurs.
Un pointeur est donc simplement un objet qui contient comme information une adresse d'objet de type T. Son type (et celui de sa valeur) est donc T*.

Outre les opérateurs s'appliquant aux adresses, les opérateurs suivants sont définis pour les objets pointeurs : & ++ -- ainsi que les opérateurs d'assignation =, +=, etc. Voir le chapitre III-A-2.

- On ne peut donner, de façon portable, à un pointeur une valeur entière à l'exception de 0, valeur qui se transtype automatiquement en la valeur NULL.
On préférera toujours assigner NULL à un pointeur plutot que 0

- On exprime quelquefois, pour simplifier, le type "adresse d'un objet de type T " comme le type "pointeur sur T " et la valeur de l'adresse d'un objet de type T comme une "constante pointeur sur T " (on fera la différence avec un objet pointeur déclaré const : " pointeur constant sur T ").

VI - Objets tableaux

Un objet tableau est un ensemble d'objets, tous du même type et placés consécutivement en mémoire. Céer un objet tableau revient à créer les objets qui le constituent. L'initialisation des objets dépend du mode de création du tableau (Voir Vie et mort des objets)

Il est possible de créer un objet tableau ne comportant qu'un objet, mais le comportement de cet objet tableau sera différent de celui de l'objet qu'il contient. On ne peut créer de tableau vide, c'est à dire ne comportant aucun objet.

Rappelons que les objets tableaux sont les seuls qui ont un type différent de celui de leur valeur. Voir Valeur d'un objet

VI-A - Type tableau

La notation [] permet d'obtenir les éléments d'un tableau. Pour écrire le type Tt d'un tableau de N objets de type T, en cohérence avec la signification de cet opérateur, on écrira que Tt [N] est un objet de type T :

T Tt [N]

Les objets de type T peuvent être éventuellement eux-mêmes des tableaux de M objets de type T'. Le tableau est alors à plusieurs dimensions. On écrira alors suivant le même principe que Tt [N] est un tableau d'objets T' :

T' (Tt [N] ) [M]

ou, comme les parenthèses sont ici inutiles,

T' Tt [N] [M]

ce qui pourra être interprété au choix comme :

- T' Tt [N] [M] , autrement dit "Tt est tableau de N tableaux de M objets T' "

- T' Tt [N] [M] soit "Tt [] est tableau de M objets T' "

- T' Tt [N] [M] soit "Tt [] [] est un objet T' "

VI-B - Opérateurs définis pour les tableaux

Les seuls opérateurs définis pour un tableau sont l' opérateur unaire & (adresse de) et sizeof . Il n'y a donc aucune possibilité d'effectuer des traitements sur un tableau (dans son ensemble).
Pour manipuler l'information contenue dans un tableau, il faut traiter individuellement les éléments du tableau avec les opérateurs qui leurs sont propres.

- L'opérateur & donne l'adresse du tableau (qui correspond à l'adresse du début du tableau).

Le type Ta de cette valeur est "adresse d'un tableau de N objets de type T ". On écrira donc que (*Ta) est un tableau de N objets de type T :

T (*Ta) [N]

Les parenthèses sont obligatoires ici : En leur absence, la priorité des opérateurs donnerait le groupement
T * (Ta [N]) ce qui définirait un tableau de pointeurs sur T.

- L'opérateur sizeof donne le nombre de bytes occupés en mémoire par le tableau. On a donc, puisque les objets sont consécutifs en mémoire, sizeof (Tt) qui est égal à N*sizeof (T).

Soit tab, déclaré par T tab [N].
- Alors sizeof tab est égal à N*sizeof (T).
- Mais si tab est dans un contexte de valeur, comme dans sizeof (tab+0), le résultat est égal à sizeof (T*).

(Voir Valeur d'un objet tableau )

VI-C - Valeur d'un objet tableau

Si un objet "tableau d'objets de type T" a été créé, la valeur de l'objet tableau est l'adresse du premier objet du tableau. Les opérateurs s'appliquant à la valeur d'un objet tableau sont donc les opérateurs s'appliquant aux adresses.

- Le type Tv de cette valeur étant "adresse d'un objet de type T " , on a :

T * Tv

Elle est donc différente du type Tt du tableau : T Tt [N] et du type Ta de l'adresse du tableau : T (*Ta) [N]).

La connaissance du contexte dans lequel est placé l'identificateur du tableau est donc primordiale : Dans un contexte d'objet, il est du type T [N] et dans un contexte de valeur du type T * .

- Si le type Tt du tableau est T' Tt [N] [M], alors le type Tv de sa valeur sera "adresse d'un tableau de M objets de type T' " :

T' (*Tv) [M]

- La valeur de l'objet tableau indique où se trouvent les éléments du tableau, mais pas leur nombre. Le nombre d'éléments du tableau est déterminé à sa création et si on en a besoin ultérieurement (ce qui est fréquent), il faudra prendre la précaution de le sauvegarder.

- La valeur d'un objet tableau est non modifiable, autrement dit, les opérateurs =, +=, ... ,++ et –- ne sont pas définis pour les tableaux et il est impossible d'écrire tableau = ...

(Voir Valeur d'un objet )

VI-D - Fonctions et tableaux

Puisque les arguments d'une fonction sont dans un contexte de valeur, on ne peut pas passer en argument d'une fonction un objet mais seulement la valeur d'un objet.

Dans le cas d'un objet tableau, l'argument sera donc la valeur du tableau soit l'adresse de son premier élément. Si il s'agit d'un tableau d'objets de type T, le paramètre correspondant de la fonction sera donc du type T*.
Le langage tolère également de déclarer le paramètre dans la liste des paramètres de la fonction sous la forme tableau : T[]. Le choix de T* montre bien que la variable locale à la fonction est un pointeur (ce qui est vrai dans les deux cas) alors que la notation tableau T[] insiste sur le fait que l'argument passé à la fonction est l'adresse du début d'un tableau et non pas celle d'un objet "simple".

exemple

T tab[N];
Fonc(tab); // tab est ici du type T*
....
Fonc(T* t) // ou Fonc(T t[])
{
// t est un pointeur local sur des objets du type T
....
}

La fonction ne peut connaître le nombre d'éléments du tableau sauf si celui-ci est passé en argument ou si il peut se déduire du contenu du tableau. Ce dernier cas est celui des chaînes de caractères où la valeur '\0' est présente comme sentinelle pour indiquer la fin du tableau.

Dans le cas des tableaux à plusieurs dimensions, le problème se complique : Si le tableau est déclaré comme :

T' Tab [N] [M]

le type Tv de sa valeur, qui doit être le type du paramètre correspondant dans la fonction, est

T' (*Tv) [M]

Le problème majeur est la présence de la valeur M dans la définition de ce type : Il est nécessaire que le compilateur connaisse cette valeur sinon, il ne connaît pas la taille des éléments du tableau et ne peut donc calculer leur position. Ceci est une énorme contrainte puisqu'une fonction, écrite pour une valeur donnée de M ne peut être utilisée pour une autre valeur (à moins d'utiliser des tableaux de longueur variable (VLA) du C99 ).

exemple

T tab[N][M];
Fonc(tab); // tab est ici du type T (*)[M]
....
Fonc(T (*t)[M])
{
// t est un pointeur local sur des objets du type T[M]
....
}

Finalement, les tableaux à plusieurs dimensions sont très contraignants pour les fonctions et on préfèrera souvent les concevoir comme un ensemble de tableaux à une dimension, très souples à utiliser, au prix d'une occupation mémoire augmentée et du code supplémentaire nécessaire à leur création. Pour créer un tableau à deux dimensions, on créera un tableau (à une dimension) de pointeurs, chaque pointeur contenant l'adresse d'un tableau (à une dimension) représentant une "ligne" du tableau à deux dimensions.
Le même principe est applicable pour un nombre de dimensions plus grand.

L'expression suivant un return étant dans un contexte de valeur, il n'est pas possible à une fonction de retourner un tableau mais uniquement la valeur du tableau, donc l'adresse de son premier élément.

On se souviendra qu'il est interdit de retourner l'adresse d'un objet local, tableau ou non, car, étant en allocation automatique, l'objet est détruit en sortie de la fonction et on renvoie dans ce cas l'adresse d'un objet "mort".

VII - Pointeurs sur fonctions

Si on se réfère à la définition des objets utilisée dans ce texte, les fonctions ne sont pas des objets. Par contre, il est possible de stocker les informations nécessaires à l'activation d'une fonction dans un objet appelé pointeur sur fonction. Le terme pointeur est utilisé ici dans un sens étendu par rapport à celui utilisé précédemment dans ce texte (puisqu'il ne contient pas l'adresse d'un objet) mais justifié par le fait que la syntaxe utilisée est analogue à celle des adresses d'objets.

Les pointeurs sur fonctions sont fréquemment utilisés pour passer une fonction comme argument d'une autre fonction. Cette dernière est alors souvent un cadre général de résolution d'une classe de problèmes et ceci permet d'ajuster son comportement à un problème précis (cas typique : la fonction standard de tri qsort).
On rencontre aussi ces objets associés en tableau. Une telle structure peut éviter alors un switch ou une collection de if imbriqués en les remplaçant par une simple indexation dans le tableau et facilite ainsi la maintenance du programme.

Le type Tf "fonction retournant un type T et ayant des arguments de types T_1 , T_2 , ...", s'écrira en accord avec la déclaration des fonctions :

T Tf ( T_1 , T_2 , ...)

Pour écrire un type Taf "adresse d'une fonction retournant un type T et ayant des arguments de types T_1 , T_2 , ...", on écrira donc qu'à l'adresse de type Taf , (*Taf ), on a une fonction :

T (*Taf ) ( T_1 , T_2 , ...)

exemple

/* Un pointeur F sur une fonction retournant un double et
ayant un double pour argument */
double (*F)(double);
/* Pour lui donner une valeur, par exemple la fonction sin
de la bibliothèque */
F = sin;
/* la fonction sin pourra alors être activée par */
sin(...);
/* ou par */
(*F)(...);
/* et même par */
F(...);
/* Cette dernière écriture est, à mon avis, à éviter car elle
porte à confusion en laissant penser qu'il existe une
fonction F quelque part dans le programme. */

On remarquera, sur l'exemple ci-dessus, que l'identificateur de la fonction (sin) est assimilé à un pointeur sur la fonction double sin( double )

VIII - Déclarateurs complexes

On a, pour la déclaration des types, à distinguer deux cas :

- Les types "élémentaires" ( char , int , float , enum , ..., struct , union )

- les types "complémentaires" : adresses (d'objets ou de fonctions), fonctions et tableaux

Un type de donnée peut être une combinaison plus ou moins compliquée associant des types "élémentaires" avec des types "complémentaires". Son écriture et son décodage n'est pas une chose très aisée. Rappelons l'écriture des types "complémentaires" telle qu'elle a été décrite aux chapitres précédents :

Type adresse Ta

Type tableau Tt

Type fonction Tf

T * Ta

T Tt []

T Tf ( T_1 , T_2 , ....)

Il reste à appliquer les mêmes principes qui ont conduit à cette syntaxe à des déclarations plus complexes.

Quelques exemples :

- Pour déclarer que Ttp est un tableau de pointeurs sur T, on écrira que

Ttp

est tableau de pointeurs sur T

Ttp []

est pointeur sur T

(*Ttp [] )

est T

T (*Ttp [] )

T *Ttp []

plus simplement

- Pour déclarer que Tpt est un pointeur sur un tableau de T, on écrira que :

Tpt

est pointeur sur tableau de T

(*Tpt )

est tableau de T

(*Tpt ) []

est T

T (*Tpt ) []

- Pour déclarer que T est un tableau de pointeurs sur fonction sans arguments retournant un pointeur sur int, on écrira que :

T

est tableau de pointeurs sur fonction sans arguments
retournant un pointeur sur int

T []

est pointeur sur fonction sans arguments
retournant un pointeur sur int

(*T [] )

est fonction sans arguments
retournant un pointeur sur int

(*T [] ) ( void )

est pointeur sur int

*(*T [] ) ( void )

est int

int *(*T [] ) ( void )

- Que signifie la déclaration suivante : char *(*(*F) (int)) [] ?

char *(*(*F) (int)) []

*(*(*F) ( int )) []

est char ;

(*(*F) ( int )) []

est pointeur sur char ;

(*(*F) ( int ))

est tableau de pointeurs sur char

*(*F) ( int )

idem

(*F) ( int )

est pointeur sur tableau de pointeurs sur char

(*F)

est fonction d'argument int retournant un pointeur
sur tableau de pointeurs sur char

*F

idem

F

est pointeur sur fonction d'argument int
retournant un pointeur sur tableau
de pointeurs sur char

IX - Programme C

IX-A - Structure d'un programme C

Un programme C est constitué :

- d'objets globaux, c'est à dire d'objets déclarés en dehors de toute fonction.

Ces objets sont en allocation statique . Ils sont potentiellement accessibles partout dans le programme. Ceci est leur avantage mais également leur inconvénient : On ne sait que très difficilement où ils sont utilisés et surtout où, quand et par qui ils sont modifiés. Ils sont utilisés parfois (et à tort) pour éviter de passer des paramètres aux fonctions. Pourtant, ceci est également un inconvénient puisque les fonctions cessent de ce fait d'être des entités autonomes : elles dépendent alors d'éléments qui leur sont extérieurs. La seule lecture du code de la fonction ne permet plus de déterminer son action.

Ces inconvénients sont majeurs pour la maintenance et la compréhension du code. On considère que l'utilisation d'objets globaux est à proscrire et n'est justifiée que dans des cas très particuliers.

- d'une collection de fonctions.

Une et une seule d'entre elles doit s'appeler main. Exécuter un programme C consiste à invoquer la fonction main. Dans une fonction, on peut appeler les autres fonctions, y compris la fonction où on se trouve (appel récursif), toutefois un appel à main, bien que non interdit, est à éviter absolument. Dans une fonction, il est interdit de définir une fonction : les fonctions se trouvent donc définies les unes à la suite des autres.

IX-B - Nature du code exécutable

Le code exécutable du programme (nous excluons de ce terme la définition et l'initialisation des objets globaux) se trouve intégralement dans les fonctions : il n'y a pas de code exécutable extérieur aux fonctions. Normalement, le déroulement de l'exécution d'un programme est séquentiel : après l'exécution d'un élément du code, on exécute l'élément du code écrit directement à la suite, mais on a des exceptions à ce processus : appel d'une fonction, sortie d'une fonction et surtout l'utilisation des instructions de contrôle qui permettent de modifier ce comportement.

Le code exécutable est construit à partir

- d'instructions de contrôle

- et d'expressions.

IX-B-1 - Instructions de contrôle

Les instructions de contrôle modifient le déroulement séquentiel normal du programme. Elles peuvent permettre :

- de n'exécuter un segment de code que si une condition est remplie :

if

if else

switch

- de répéter un segment de code tant qu'une condition est remplie (boucles) :

for

while

do while

- de réaliser inconditionnellement un saut directement à une autre partie du code :

goto

break

continue

Les instructions de saut inconditionnel brisent la structure du code et sont donc en général à éviter (sauf les break utilisés dans l'instruction switch dont la présence est le cas le plus fréquent et l'absence l'exception).

- de sortir d'une fonction :

return

IX-B-2 - Expressions

Tous les autres éléments du code exécutable sont des expressions terminées par un point-virgule.
L'exécution du programme n'est qu'une suite d'évaluation d'expressions, cette suite étant contrôlée par les instructions de contrôle.

- :

Au moment de l'exécution, ces expressions sont évaluées et l'évaluation fournit une valeur ou un objet selon son contexte . Pour que cette évaluation ait un effet, il faut qu'elle entraîne une modification de l'environnement propre du programme (modifie la valeur d'un objet ou lise la valeur d'un objet volatile) ou de l'environnement d'exécution du programme (par exemple, affiche quelque chose à la console) autrement dit qu'elle provoque une "action". Dans ce cas, on dit que l'évaluation provoque des effets de bords.

- :

L'interprétation des expressions est régie par la priorité et l'associativité des opérateurs. L'évaluation d'une expression est segmentée par des points de séquencement .

Les points de séquencement délimitent les expressions élémentaires de l'expression (une expression est élémentaire si elle ne comporte pas de points de séquencement interne). On évalue complètement chaque expression élémentaire avant d'évaluer la suivante et l'ordre d'évaluation des opérandes de l'expression élémentaire n'est pas spécifié.
Les effets de bords peuvent intervenir à tout moment pendant l'évaluation de l'expression élémentaire mais doivent avoir été accomplis à la fin de l'évaluation.

La plupart des expressions sont élémentaires. Les expressions construites avec

- les opérateurs booléens && et ||

- l'opérateur conditionnel ?:

- l'opérateur , (virgule) (à ne pas confondre avec la virgule séparant les arguments d'une fonction)

pour lesquelles un point de séquencement est situé après le premier opérande sont les exceptions. Par conséquent, pour ces opérateurs, l'opérande de gauche est toujours évalué en premier.

- :

Si la valeur d'une expression élémentaire peut dépendre de l'ordre d'évaluation de ses termes, l'expression est mal formée et conduit à un résultat indéfini. Une expression ayant une expression élémentaire mal formée est elle-même mal formée.

- Une expression élémentaire peut être mal formée si l'évaluation des opérandes entraîne l'appel de plus d'une fonction possédant des effets de bords.

- Elle est mal formée si deux opérandes (ou plus) font référence à un même objet alors que l'évaluation d'au moins l'un d'entre eux provoque un effet de bord sur cet objet.

Exemples d'expressions mal formées :

i = ++ i + 1
a[i++] = i
res = getchar()-getchar()

- On a évoqué précédemment les types de grandeurs booléens et adresses ainsi que leurs opérateurs. On n'a pas envisagé le cas des nombreux types numériques entiers et réels. Lorsqu'un opérateur arithmétique binaire (à deux opérandes) Op combine deux valeurs V_1 et V_2 de types différents T_1 et T_2 :

V_1 Op V_2

le compilateur utilise un type commun T, pour lequel cet opérateur est défini, et pour lequel il existe une règle de transformation de T_1 vers T et de T_2 vers T (transtypage) .

V_1 => T V_2 => T

Il effectue alors l'opération entre les deux valeurs obtenues par transtypage de V_1 et V_2 en le type T et obtient comme résultat une valeur de type T.

Le principe est simple, son application l'est moins. Le premier critère de choix pour T sera logiquement d'essayer de conserver les valeurs lors du transtypage.

1. Un opérande (au moins) est de type réel

On peut donner un rang de classement aux types réels, avec par rang décroissant long double (C99) , double , float . Alors :

- Si les deux types sont réels, le type T sera celui de T_1 ou T_2 qui a le rang le plus élevé. Les valeurs seront donc conservées.

- Si un des types est réel et l'autre entier, le type T sera le type réel. Les valeurs seront donc (au moins approximativement) conservées.

2. Les deux opérandes sont de type entier

La difficulté arrive lorsque les deux types sont entiers.
- Les types entiers possèdent un rang de classement, avec par rang décroissant, les types long long (en C99), long , int , short , signed char . Les types unsigned ont le même rang que le type signé correspondant.

- La première étape est la "promotion des petits entiers" : les entiers de rang inférieur à celui de int sont promus, en conservant leur valeur, en int (ou si ce n'est pas possible en unsigned int. Ce sera, par exemple, le cas d'un unsigned short si l'étendue des valeurs représentées par un short est égale à celle d'un int ).
Les deux opérandes ont maintenant un rang supérieur ou égal à celui de int .

- Le type de rang le plus élevé est le type candidat à être le type commun. Le type final T sera ce type candidat mais éventuellement dans sa version non signée.

1. Si les valeurs peuvent être exactement représentées dans le type candidat, le type final T est le type candidat.

C'est le cas si
- le type des deux opérandes sont tout deux signés
- le type des deux opérandes sont tout deux non signés
- les valeurs du type non signé peuvent être représentées par les valeurs (positives) du type signé (Le type signé est alors de rang plus élevé que le type non signé et est le type candidat).

2. Sinon, le type final T est la version non signée du type candidat.

Ce choix de la version non signée s'explique parce que le transtypage d'un entier signé en un entier non signé est parfaitement défini alors que l'inverse ne l'est pas.

Quelques exemples :

type 1

type 2

type final T

int

long

long

unsigned int

unsigned long

unsigned long

int

unsigned int

unsigned int

int

unsigned long

unsigned long

unsigned int

long

long (?)
ou unsigned long (?)

X - Conclusion

Les choix fondamentaux effectués par les concepteurs du langage C résultent (probablement) d'un compromis entre deux objectifs quelque peu contradictoires :

- avoir un langage capable de manipuler des données avec suffisamment d'abstraction pour permettre au programmeur d'ignorer largement les mécanismes sous-jacents impliqués dans l'interprétation et l'exécution de leur code;

- avoir un langage permettant également une programmation bas niveau adaptée à une machine donnée, ce qui impose une connaissance détaillée du fonctionnement de celle-ci, mais aussi du comportement du compilateur.

Les concepteurs du langage ont élaboré une trame conceptuelle, permettant de résoudre le premier point, en laissant au langage suffisamment de degrés de libertés pour répondre au second. C'est cette trame que le présent document avait pour but de mettre en évidence et l'importance accordée aux grandeurs (et aux expressions) dans ce texte se justifie par l'incidence qu'elles ont sur la conception du langage.

Les nombreux degrés de libertés apportent la souplesse nécessaire au langage mais le rendent délicat à utiliser dès qu'on veut obtenir un programme portable. Les termes "comportement indéfini", "comportement déterminé par l'implémentation", etc. sont extrêmement fréquents dans la norme. Des choses qui semblent parmi les plus élémentaires peuvent varier d'un système à l'autre, comme par exemple la gamme des valeurs représentables par un entier d'un type donné ainsi que leur représentation binaire (la norme accepte trois méthodes différentes pour représenter les nombres signés !).

Le langage C a la réputation d'être un langage compliqué. Cette réputation est fondée souvent sur de bien mauvaises raisons (syntaxe mal comprise, notion d'adresse et de pointeurs mal assimilée, confusions avec les tableaux, etc.). C'est plutôt finalement un langage avec une structure de base simple mais la souplesse dont on l'a doté crée des comportements délicats qu'il faut maîtriser.

Il me semble impossible de maîtriser ce langage sans comprendre ses méthodes de raisonnement, les mécanismes sous-jacents mis en oeuvre et l'interprétation du code source par le compilateur.

Ce document avait pour but de vous y aider.

Concepts en C

Cohérence, logique et fonctionnement du C

Auteur

Patrick Gonord