Les points qui suivent ici sont en vrac, sans véritablement de hiérarchie.
Vocabulaire
Comparaisons
“Inférieur” veut dire “inférieur ou égal”. Chose analogue pour “supérieur”.
Signe et zéro
“Positif” veut dire “positif ou nul”. Il en est de
même pour “négatif” qui veut dire “négatif ou nul”.
Ceci est cohérent car le nombre 0
est à la fois positif et
négatif. Utiliser “strictement” dans les cas précédents pour
exclure 0
qui est donc là par défaut.
Évaluation
Il est impropre de dire qu’une expression “renvoie” une valeur. Il faut plutôt dire qu’une expression “s’évalue” en une valeur.
Transmission d’une valeur
Qu’une fonction “transmette une valeur” ne veut pas dire qu’elle “l’affiche sur la sortie standard”. Cela veut dire soit qu’elle la renvoie soit qu’elle l’écrit dans un de ses paramètres (par passage par adresse), au choix.
Renvoyer une valeur
Une fonction ne “retourne” pas une valeur. Une fonction “renvoie” une valeur.
Occurrences
Une occurrence d’une valeur x
dans un tableau
tab
est un indice i
tel que l’entrée
tab[i]
est égale à x
. Ainsi, dans le tableau
tab = 3 | 1 | 1 | 4 | 3 | 3 | 1
, les indices
0
, 4
et 5
sont des occurrences de
3
.
Portée de variable
Dans le code suivant
if (...) {
int truc;
...
}
if (truc) ...
l’identifiant truc
de la ligne 4 ne désigne pas la
variable déclarée à la ligne 2. Au mieux, il n’existe pas de variable
truc
et le compilateur râle. Au pire, il existe une autre
variable truc
de portée plus englobante et le compilateur
ne signale rien.
Fonctions
Paramètres
Quand il est écrit dans un énoncé qu’il est possible “d’ajouter d’autres paramètres” aux fonctions demandées, c’est qu’il faut en général faut le faire.
Par exemple, toute fonction qui prend en paramètre un tableau doit (dans la très grande majorité des cas) prendre aussi sa taille.
Respect des spécifications
Ne pas utiliser de printf
ou autres choses non demandées
explicitement par les spécifications des fonctions. Si une fonction fait
plus que ce qui est demandé, elle sera moins utilisable avec d’autres,
donc moins générique et moins réutilisable.
Paramètres constants
Ne pas oublier pas de placer des const
dans les
paramètres là où c’est nécessaire, pour les paramètres qui sont des
adresses dont les valeurs pointées ne sont utilisées qu’en lecture.
Tableaux
Test de fin de parcours d’un tableau
Un test tab[i] == NULL
pour savoir si on a terminé le
traitement dans un tableau est bien entendu totalement erroné. Ceci
provient d’une confusion entre tableaux et listes. Concernant les
tableaux, il faut connaître leur taille pour savoir quand arrêter le
traitement.
Tableaux statiques dans les fonctions
Il ne faut jamais renvoyer de tableau statique qui est déclaré comme variable locale d’une fonction. En effet, comme toute variable locale, celle-ci est dans la pile et elle est évacuée en sortant de la fonction.
Il existe une exception à ce propos concernant les variables locales d’un type structurées qui peuvent être renvoyées mais ceci est déconseillé pour des raisons d’efficacité.
Tableaux statiques de tailles non constantes dans les fonctions
Il n’est pas autorisé en C ANSI de déclarer des tableaux statiques comme variables locales de fonctions qui ont une taille non connue à la compilation. Par exemple,
void f() {
int tab[128];
...
}
est autorisé car 128
est bien sûr une constante et, lors
de la compilation, la taille de tab
est ainsi connue. En
revanche,
void g(int k) {
int tab[k];
...
}
est interdit car la valeur de k
est inconnue lors de la
compilation (c’est une variable, elle peut par exemple dépendre d’une
valeur entrée par l’utilisateur lors de l’exécution) et ainsi la taille
de tab
n’est pas prévisible et donc pas connue à la
compilation.
Ces tableaux statiques dont la taille dépend d’une variable sont connus sous le nom de variable-length arrays ou VLA. Bien qu’autorisés dans la norme C17, ils sont parfois considérés comme des bombes à retardement. Leur utilisation est à proscrire dans ce module, chaque variable doit posséder une taille calculable dès la compilation.
Création d’un nouveau tableau
Quand il est demandé qu’une fonction “créé un nouveau tableau” c’est qu’il faut réaliser une allocation dynamique dans la fonction en question.
Recherche de dernière occurrence dans un tableau
Quand il faut chercher la dernière occurrence d’un élément dans un tableau, le plus simple est de le parcourir de la droite vers la gauche. C’est dommage de le parcourir dans le sens naturel et de conserver une variable sur l’indice de la dernière apparition.
Pour communiquer le résultat, il faut faire un passage par adresse. Le retour de la fonction renseigne sur la présence ou non de la valeur recherchée. Ainsi, un bon prototype est
int dernier_indice_valeur(int *tab, int taille, int x, int *res);
où tab
est le tableau dans lequel la recherche
s’effectue, taille
sa taille, x
l’élément
recherché et res
un pointeur vers un entier où le résultat
sera écrit.
Modularisation
Graphes d’inclusions
Les graphes d’inclusion suivent des conventions bien précises à respecter : on ne met pas les extensions des fichiers car ce ne sont pas des fichiers qui sont mentionnées mais des modules.
Les sommets du graphe sont donc les noms de modules, avec deux types
de flèches : les inclusions pleines depuis les .h
et les
inclusions étendues depuis les .c
.
Tout .c
inclut le .h
de même nom s’il ce
dernier existe.
Inclusions et transitivité
Les inclusions rendues redondantes par transitivité ne sont pas un problème, bien au contraire.
Par exemple, si A
inclut B
et
C
, et B
inclut C
, il est faux de
dire qu’il n’est pas nécessaire que A
inclue
C
. En effet, avec une telle configuration, le jour où nous
apportons des modifications et que B
n’a plus besoin
d’inclure C
, la dépendance de A
à
C
est cassée.
Dépendances structurelles
Dans les Makefile
tout X.o
doit dépendre de
X.c
et de X.h
.
Makefile
Le Makefile
permet entre autre de ne pas écrire à la
main toutes les instructions de compilation nécessaire à la compilation
d’un projet en évitant les compilations inutiles. Pour cela, il est
nécessaire d’indiquer les dépendances étendus entre les modules.
Il permet d’éviter d’oublier les options de compilation en ne les indiquant que dans un fichier.
Compilation séparée
Quand un module est compilé, seul son .c
doit figurer
dans l’instruction de compilation :
gcc -Wall -std=c17 -c -o M.o M.c
.
Il ne faut surtout pas y ajouter les .c
des modules dont
il dépendrait, on se retrouverait avec des fonctions définies plusieurs
fois dans les .o
générés.
Gestion des erreurs
Capture des valeurs problématiques des arguments
Ne pas oublier les pré-assertions et donc les appels à
assert
. Il faut imaginer tous les valeurs problématiques
des arguments d’une fonction.
Ceci inclut les tests par rapport à NULL
des paramètres
qui sont des pointeurs notamment quand ils sont voués à être
déréférencés dans la suite.
Position des pré-assertions
Les assert
ne sont à mettre qu’au tout début du bloc
d’instructions d’une fonction (et donc juste après les déclarations de
variables locales).
Ce n’est pas totalement faux de mettre des assert
après,
sauf que nous les utilisons dans le cadre de ce cours pour mettre en
place des pré-assertions. En fait, si le besoin de mettre un
assert
dans le bloc d’instruction se fait ressentir, c’est
qu’il faudrait plutôt utiliser un renvoi de code d’erreur.
Pré-assertions pour restreindre les valeurs des arguments
Sur une spécification d’une fonction dans laquelle on restreint
volontairement les valeurs que pourront prendre ses paramètres, il faut
souvent vérifier les cas problématiques par des pré-assertions. Par
exemple, si on demande une fonction repeter
paramétrée par
une chaîne de caractères non vide str
et un entier positif
k
et qui répète l’affichage de str
k
fois, on écrira
int repeter(char *str, int k) {
/* Declaration des variables. */
/* Obligatoire pour eviter les seg. fault a cause des
* dereferenciations qui vont venir. */
(str != NULL);
assert
/* On s'assure que str est justement non vide. */
(str[0] != '\0');
assert
/* Et finalement on s'assure que k est positif. */
(k >= 0);
assert
/* Corps de la fonction. */
}
Même si la fonction pourrait marcher dans le cas où str
serait la chaîne vide, on l’interdit car la spécification le
demande.
Tests des allocations dynamiques
Ne pas oublier pas de tester les retours des malloc
. Si
une allocation s’est mal passée, il faut en tenir compte (renvoi d’une
valeur spéciale ou interruption de l’exécution).
Communiquer une erreur
Dans les fonctions à gestion d’erreur, ne pas quitter l’exécution par
un exit(1)
ou bien par un exit(EXIT_FAILURE)
.
Il faut plutôt renvoyer une valeur spéciale conformément au schéma de
gestion d’erreur. Une fonction autre que le main
ne doit
pas avoir la responsabilité de faire stopper l’exécution (sauf rares
exceptions).
Choix du code d’erreur
Quand nous avons une fonction qui renvoie une valeur spéciale en cas
d’erreur, il faut s’assurer du fait que cette valeur ne peut jamais être
renvoyée en cas de non erreur. Par exemple, si nous avons une fonction
de recherche d’occurrence dans un tableau qui renvoie l’occurrence si
l’élément est trouvé et 0
sinon serait fausse :
0
pourrait être une bonne réponse. Ici, il faudrait donc
renvoyer par exemple une valeur strictement négative.
Retour du scanf
Toujours tester la valeur de retour d’un scanf
. Si tout
s’est bien passé, il doit renvoyer le nombre de lectures correctement
réalisées.
Opérations bit à bit
Sur le xor
Si nous calculons le xor
entre deux suites de bits, par
exemple
a = 001111000
b = 010110010
-----------------
a ^ b = 011001010
nous pouvons observer que dans le résultat nous avons un bit à
0
si et seulement si les deux bits des deux opérandes sont
les mêmes en la position considérée (et, donc, de manière équivalente,
nous avons un 1
si et seulement si les deux bits des
opérandes sont différents). Ainsi, pour obtenir la distance de Hamming
(rappel : il s’agit du nombre de bits en mêmes position mais différents)
entre deux suites de bits, il suffit de compter le nombre de bits à
1
du xor
des deux valeurs. Nous obtenons donc
la fonction
int hamming(unsigned long long a, unsigned long long b) {
return nb_1(a ^ b);
}
où nb_1
est une fonction qui renvoie le nombre de bits à
1
de son argument.
Accès à un bit d’un entier
Un entier de 64 bits (et ceci est valable pour les autres tailles) ne
peut pas se gérer comme un tableau. En effet, quand x
est
un entier, il n’est pas possible de lire son bit d’indice i
par x[i]
puisque x
n’est pas un tableau. Il
faut procéder comme vu en cours, en utilisant les opérateurs bit à bit
&
et >>
de la bonne façon.
Divers
Utilisation de sizeof
Il est interdit de passer à sizeof
autre chose qu’un
type. L’opérateur sizeof
ne doit prendre qu’un type comme
opérande.
Déréférenciations et types
L’opérateur de déréférenciation *
enlève une étoile au
type de la variable sur lequel il s’applique.
Par exemple, si x
est une variable de type
char ***
, alors *x
est de type
char **
et **x
est de type
char *
.
Ainsi, quand nous avons une fonction qui doit renvoyer un tableau à
deux dimensions d’entiers (donc de type de retour int **
),
si nous déclarons une variable res
de type
int **
vouée à recueillir le résultat, il faudra au final
faire un return res;
et non pas un
return **res;
.
Confusion
entre opérateur de déréférencement *
et d’adresse
&
.
Dans une expression, l’opérateur de déréférencement permet d’obtenir
la valeur située à une adresse donnée. Dans l’expression
*exp
, si exp
s’évalue en une adresse de type
T
, alors *exp
est une valeur de type
T
, et peut servir de valeur gauche ou droite.
L’opérateur d’adresse permet d’obtenir l’adresse d’un objet en
mémoire. Dans l’expression &exp
, si exp
désigne une valeur se situant dans la mémoire (variable, tableau, membre
de structure…) et &exp
s’évalue alors en son adresse,
et n’est jamais une valeur gauche.
Dans une déclaration de variable (ou de paramètre), l’étoile indique
que la variable est de type adresse de T
.
Valeur d’une expression
Quand on demande la valeur d’une expression, ce n’est pas la valeur des variables impliquées qui importe, c’est la valeur de l’expression toute entière. Par exemple, dans
= b = 2; a
nous avons à faire à une instruction dont l’expression sous-jacente a
pour valeur 2
. Comme effet secondaire lors de son
évaluation (qui est donc la transformation de l’expression toute entière
en sa valeur finale), elle affecte 2
à b
et
2
à a
.
Alias pour les types structurés
Il faut utiliser l’alias pour les typedef struct
si et
seulement le type déclaré est récursif (ou mutuellement récursif avec
d’autres types).
Valeurs non signées
Pour forcer une valeur à être positive, ajouter
unsigned
. Ainsi, pour déclarer un type censé représenter
des couleurs selon leurs composantes RGB comprises entre 0
et 255
, une bonne solution est
typedef struct {
unsigned char rouge;
unsigned char vert;
unsigned char bleu;
} Couleur;
Longueur d’une chaîne de caractères
N’utiliser jamais strlen
(ou presque). En tout cas
certainement pas dans des fonctions qui parcourent des chaînes de
caractères. Par exemple, en considérant que str
est une
chaîne de caractères (et donc de type char *
),
for (int i = 0; i < strlen(str); ++i) {
...
}
ce très mauvais code va recalculer la longueur de str
pour tester la condition de continuation du for
à chaque
tour de boucle. On obtient donc une complexité au minimum quadratique en
la longueur de la chaîne. Il faut plutôt exploiter le fait qu’une chaîne
de caractères se termine par l’octet 0
. Ainsi, on écrira
plutôt
for (int i = 0; str[i] != '\0'; ++i) {
...
}
Bien qu’une chaîne de caractère soit un tableau de caractères, comme elle est pourvue d’un marqueur de fin contrairement à ces derniers, il est rarement utile de connaître sa longueur avant de la parcourir.
ASCII
Sans connaître la table ASCII, il est bon de retenir quelques points :
- il n’y a pas de type caractère en
C
, le typechar
est un type d’entier sur 1 octet. Et cette taille suffit pour stocker un caractère de la table ASCII (0-127). - la notation entre apostrophe désigne un entier (par exemple
'4'
) sur 1 octet dont la traduction en ASCII est le caractère entre apostrophe (ici4
) - les lettres de
a
àz
sont représentés par des entiers consécutifs (idem deA
àZ
et les chiffres0
à9
). On peut donc vérifier si un entierv
désigne une lettre minuscule :'a' <= v && v <= 'z'
Fichier
Un nom de fichier est une chaine de caractères et est donc stocké
dans un tableau de char
. Un fichier dont l’ouverture a
réussi doit être fermé avec fclose
. Une fois ouvert, il
faut utiliser les fonctions fgetc/fgets/fscanf/fread
et
fputc/fputs/fprintf/fwrite
pour lire et écrire dessus. Ces
fonctions sont à gestions d’erreurs, permettant par exemple de savoir si
la fin du fichier est atteinte.