Heritage
Influence d’UNIX
Le C a initialement été crée comme brique logicielle d’un ensemble servant à construire la troisième version du système d’exploitation UNIX. Il s’articule donc avec les programme as dont les spécificités influent le langage C lui même : nom de variables externes et labels limitées à 8 caractère, interdiction de nommer des variables
ou des labels commencant par des numéros
labels numérotés dits « de knuth ». Les descripteurs de fichiers stdin, stdout et stderr ouverts
par le système d’exploitation viennent de l’environnement UNIX, de même que les arguments d’entrée (argc, argv), la
présence d’une fonction d’entrée unique appellée main
et que celle ci retourne automatiquement la valeur 0
si aucune instruction return n’est présente,
qu’il est abusif de parler de protoype pour la fonction
main car elle en “accepte” plusieurs, qu’il est tout
de meme possible de l’appeller (pas en C++).
La notation concise des chiffres octaux facilite l’ecriture de routines liées au systeme de permission de l’acces aux fichiers d’UNIX.
Influence de l’ASCII
Lors de la création du C, l’ASCII n’était pas encore complet,
notamment les caractères @, ` et $ n’étaient
pas encore présents dans le jeu de caractères, si bien que
ces caractères ne sont pas présents dans la grammaire du C.
Là où l’ALGOL déclarait une grammaire où la plupart
des opérateurs sont des monogrammes, par exemple ≤ pour
“inférieur ou égal”, le C prend acte du fait que ce symbole
n’existe pas dans la table ASCII et lui préfère un
digramme <=.
Heritage du B
Pour faciliter la transition depuis le B, le C accepte la syntaxe du B avec peu de changements, les variables et fonctions sont considerées comme de type “int” par defaut, sans qu’il soit necessaire de le préciser.
Pour des variables definies sur la pile, il faut neanmoins preciser la classe de stockage voulue, qui est de type “auto” pour les variables internes de fonction et “extern” pour les variables externes aux fonctions.
extern toto_e; // de type "int" implicite
int toto_e_bis; // classe de stockage "extern" implicite
fonction(argument1, argument2)
long argument2;
{
auto toto_s; // type "int" implicite
int toto_s_bis; // classe de stockage "auto" implicite
return 0;
}
// "fonction" ci dessus retourne "int" implicitement
// l'"argument1" est type "int" implicitement
// "argument2" est de type "long" explicitement
Compatibilité IBM
IBM utilise l’encodage EBCDIC dans ses supercalculateurs
qui ne dispose pas de certains caractères indispensables
à sa synaxe, comme {, [ ou \. Les trigrammes
suivants ont été ajoutés au langage
| trigraphe | equivalent ASCII |
|---|---|
| ??( | [ |
| ??/ | \ |
| ??) | ] |
| ??' | ^ |
| ??< | { |
| ??! | | |
| ??> | } |
| ??- | ~ |
Dans les années 1990 des digraphes - plus lisibles et commodes à écrire - ont été rajoutés
| digraphe | equivalent ASCII |
|---|---|
| <: | [ |
| :> | ] |
| <% | { |
| %> | } |
| %: | # |
| %:%: | ## |
Les digraphes sont inclus dans la grammaire du langage comme des tokens et ne sont pas remplacés lorsque présents dans des chaines de caractère ou des commentaires, contrairement aux trigraphes.
Pour gérer la manipulation de casse de caractères,
des fonction “IBM-safe”, isupper(), isalpha(), etc.
sont ajoutées à la lirairie standard C.
Entiers signés
Il n’y avait pas de consensus entre fabricants
d’ordinateurs dans les années 70 quand a la représentation des entiers
signés, si bien qu’en cas de changement de bit de
signe, pour la représentation binaire du nombre
suivant 1000 0000 (ici un signed char) le C
accepte 3 valeurs comme étant correctes et ne garantit
donc pas la valeur que possède cette représentation
en mémoire : soit -0 (signe
et magnitude), -127 (complément à un) ou encore
-128 (complément à deux). Un dépassement
d’entier signé devient donc un comportement
indéterminé qui dans certaines architectures
rigoristes peut causer l’arrêt du programme.
Préprocesseur
La majorité des programmeurs systeme à la sortie du C utilisent l’assembleur et ont l’habitude d’employer des “macros” pour s’éviter de recopier du code répétitif ou pour s’adapter automatiquement à plusieurs types d’architectures, y gagnant ainsi en productivité. Le C devait donc proposer lui aussi un langage préprocesseur pour etre considéré comme un outil sérieux par les programmeurs systeme, et ce langage devait etre standardisé afin d’éviter le fractionnement en dialectes de préprocesseur incompatibles.
Il y a une tendance aujourd’hui, surtout dans la communauté C++ de décrier le préprocesseur et de vouloir le remplacer totalement par des éléments de langage nouveaux.
Dénominateur commun
Le C se veut universaliste et pouvoir être compilé vers tous les langages machines imaginables, et se plie pour cela au plus grand denominateur commun.
Si il existe des architectures qui signalent par exemple un depassement de capacité lors d’une addition ou d’une multiplication, toutes les architectures ne le font pas : ainsi rien ne permet dans le langage de detecter explicitement un depassement d’entier sinon d’observer un resultat incoherent apres de telles opérations, (typiquement un resulatat plus petit que l’un ou l’autre operande).
Pareillement si de nombreux assembleurs sont capables d’effectuer la division d’un nombre avec un autre et de stocker a la fois le resulatat et le reste de cette operation via une seule instruction, tous les assembleurs ne le permettent pas, ainsi il n’existe pas en C d’operateur ou d’instruction qui effectue une division et le resultat d’une division en une seule instruction : on doit donc écrire
main() { // style k&r
int a = 3, b = 2; // extension initialisation + declaration
int quotien,reste;
quotien = a / b;
reste = a % b; // en C il faut deux instructions,
// alors que dans la plupart des assembleurs,
// une seule instruction suffirait
}
Le dépassement de pile est également une notion assez abstraite au C et rien ne peut le prévenir sinon un arret brutal du programme.
Il n’est pas possible de cibler explicitement les diférents niveaux de cache du processeur
Le PDP
Meme si le C se veut universaliste, il impose toutefois que toute machine voulant adopter ce langage soit capable d’emuler le fonctionnement du PDP.
Il presuppose un CPU capable d’effectuer des branchements conditionnels, couplé à une memoire serielle qu’on peut accéder avec un offset numerique inconnu à la compilation et pouvant varier à l’execution, ce qui le rend fondamentalement impossible à convertir vers du code GPU.
Le traitement parallelisable, par lots ou asyncrone sont disponibles via des librairies et des appels de fonction, et non dans la syntaxe du langage.
Fonctions k&r
Avant le C23, on pouvait déclarer une fonction ainsi
void f(x) { // implicitement "int x"
}
Si on desire un type autre que int, on doit le
préciser.
func(p, c1, c2, c3) // retourne "int", p est un "int"
char c1, c2, *c3; // c1 et c2 sont de type char, c3 pointeur sur char
{
return 0;
}
Pour déclarer la fonction précédente on écrit
func();
Le compilateur ne sait donc pas quels arguments la fonction attend, donc il faut s’assurer au niveau de l’appel à la fonction correspondent strictement à ce à quoi la fonction s’attend.
float trio_somme();
main()
{
double v = 1.;
float r = trio_somme(1, 3., v); // catastrophe
float r2 = trio_somme(1.f, 3.f, (float)v); // correct
}
float somme(a, b, c)
float a, b, c;
{
return a + b + c;
}
Avec somme(1, 3., v); le premier argument
est de type int, le deuxieme de type double,
de meme pour le troisieme argument. Les variables
a, b et c dans la fonction auront des valeurs
completement arbitraires et incohérentes.
Par contraste le prototype doit comporter les types attendus par les paramètres
int func(int, char, char, char*);
Le nom de variables est optionnel mais assez utile pour documenter l’usage de la fonction.
double convertisseur(float taux, float offset, float* source, float* cible);
Une personne tierce n’ayant acces qu’au prototype de cette fonction aura une bonne idée de comment agencer les variables qu’il fournit a cette fonction.
Meme aujourd’hui malgré l’ajout des protoypes au langage, il
est possible que le compilateur vous avertisse si vous
appellez une fonction de la libaririe standard qui
attend un float en lui donnant un double.
Voir https://bugs.llvm.org/show_bug.cgi?id=25110 🌍⤴ est un exemple bug causé par le cohabitation de fonctions k&r et de prototypes.
Polymorphisme et variadiques anciennes
Ne pas déclarer le type des arguments attendus d’une fonction de la sorte
int foo();
permet de déclarer un tableau de fonctions “polymorphiques” de la sorte.
void foo(int i);
void bar(char *a, double b);
void baz(void);
int main()
{
void (*fn[])() = { foo, bar, baz };
fn[0](5);
fn[1]("abc", 1.0);
fn[2]();
}
Ce qui permet de mélanger plusieurs pointeurs de fonctions dans un même tableau et pouvoir les appeller sans nécessiter de cast. Voir https://stackoverflow.com/a/1631781 🌍⤴
Bien que la syntaxe de fonction indéterminée soit obsolète en C23,
il n’y a pas de syntaxe de remplacement qui permette d’avoir
un pointeur de fonction générique de la meme façon qu’il
existe un pointeur d’objet générique void*, jusqu’à l’adoption
de la proposition n3556 :
https://www.open-std.org/JTC1/SC22/WG14/www/docs/n3556.htm
🌍⤴
Un autre aspect des fonctions k&r est que toutes les fonctions sont variadiques par défaut.
Voici comment était implémenté printf() dans UNIX v3
printn(n,b) {
extern putchar;
auto a;
if(a=n/b) /* assignment, not test for equality */
printn(a, b); /* recursive */
putchar(n%b + '0');
}
printf(fmt,x1,x2,x3,x4,x5,x6,x7,x8,x9)
char fmt[]; {
extern printn, putchar, namsiz, ncpw;
char s[];
auto adx[], x, c, i[];
adx = &x1; /* argument pointer */
loop:
while((c = *fmt++) != '%') {
if(c == '\0')
return;
putchar(c);
}
x = *adx++;
switch (c = *fmt++) {
case 'd': /* decimal */
case 'o': /* octal */
if(x < 0) {
x = -x;
if(x<0) { /* - infinity */
if(c=='o')
printf("100000");
else
printf("-32767");
goto loop;
}
putchar('-');
}
printn(x, c=='o'?8:10);
goto loop;
case 's': /* string */
s = x;
while(c = *s++)
putchar(c);
goto loop;
case 'p':
s = x;
putchar('_');
c = namsiz;
while(c--)
if (*s)
putchar((*s++)&0177);
goto loop;
}
putchar('%');
fmt--;
adx--;
goto loop;
}
A noter que
extern printn, putchar, namsiz, ncpw; declare a la fois
une fonction definie en amont, une fonction definie
dans une autre unité de compilation, une variable et un symbole
inutilisé, tous de type int ou retournant int. Cela facilite
le travail du compilateur qui n’a ainsi pas besoin de faire
une seconde passe pour savoir que c’est à
l’éditeur de liens d’écrire l’adresse exacte des fonctions
ou variables en question.
En outre le préprocesseur et les fichiers .h contenant
les déclarations extern n’existait pas.
Une telle syntaxe ne se trouve pas ailleurs que dans les premieres versions d’UNIX et il n’est pas nécessaire de s’y attarder.
Le fait que le premier compilateur C était à une seule passe explique également la nécessité de déclarer en amont les fonctions qui s’appellent récursivement, ou qu’il fallait déclarer toutes les variables de chaque bloc en tete de celui ci afin que l’assembler sache immédiatement de combien modifier le compteur de stack.
Déclaration et initialisation
Pouvoir initialiser une variable en même temps que sa déclaration et un ajout ultérieur au C.
Lorsqu’on écrit int a = 42; il y a deux
instructions de preprocesseur : l’une augmente
(ou diminue selon l’architecture) le compteur
de stack et une autre y place la veleur 42.
Dans le code suivant
int a = 30;
goto saut;
{
int a = 42;
saut:;
a = 10;
}
ce qui se passe réellement est ce qui suit
int a = 30;
goto saut;
{
int a; // on augmente la stack
a = 42; // ... mais on saute cette instruction
saut:
printf("%d\n", a); // valeur indéterminée
a = 10; // on refere bien au "a" de notre bloc
printf("%d\n", a); // "10"
} // la stack est restaurée a la sortie du bloc
printf("%d\n", a); // "30"
Après le goto la stack est bien augmentée pour convenir
à toutes les variables déclarées en tête de bloc, mais
l’instruction d’assignation est sautée et a à l’intérieur
du bloc n’est pas initialisé.
La confusion est d’autant plus facile maintenant que le C moderne autorise de déclarer des variables n’importe où dans un bloc et peut laisser penser que la stack est augmentée dès l’instruction de déclaration, alors que cela a lieu en amont, à l’entrée du bloc.
La même remarque est valable avec les tables de saut switch/case
switch(val)
{
int a = 42;
case 1:
printf("%d", a); // valeur indéterminée
break;
case 2:
int b = 12;
case 3: // pas de break
b = 20;
case 4: // pas de break
printf("%d", b); // potentiellement non initialisé
break;
default:
break;
}
Encore une fois si val == 4, b ne vaut pas 12 mais aura une
valeur indéterminée. Par ailleurs dans aucun cas a est initialisé
dans ce bloc.
C’est pour éviter une source de confusion que le C++ interdit les deux constructions montrées ici.
Portée structure
Avec le C99, chaque structure a sa portée propre. C’est à dire que deux structures peuvent déclarer des champs avec des noms identiques
struct a{
int toto;
}
struct b{
int toto; // ok depuismle C99
}
Auparavent il y aurait eu conflit de nommage. Pour cette raison les structures “standard” du C ajoutent un prefixe à tous leurs champs pour réduire les probablitiés de conflits.
struct tm {
int tm_sec; /* Seconds. [0-60] 1 leap second */
int tm_min; /* Minutes. [0-59] */
int tm_hour; /* Hours. [0-23] */
...
}
La version moderne serait probablement
struct tm {
int sec; /* Seconds. [0-60] 1 leap second */
int min; /* Minutes. [0-59] */
int hour; /* Hours. [0-23] */
...
}
as: interdiction des label et variables a nombres, gestion de extern, retrocompatibilité (ABI fixe)
Ressources externes
- Genese du C par son créateur : https://www.nokia.com/bell-labs/about/dennis-m-ritchie/chist.html 🌍⤴
- Manuel turbo C : modern vs classic https://archive.org/details/bitsavers_borlandtur1987_10489658/page/n147/mode/2up?view=theater 🌍⤴