Heritage

Le C a connu plusieurs grandes versions qui ont altéré son allure générale

Version UNIX v3 (1972)

La premiere version du C utilisée en production dans l’écriture de certaines routines d’UNIX et dans ses utilitaires peut être consultée ici

https://www.tuhs.org/cgi-bin/utree.pl?file=V2/c/nc0 🌍⤴

Cette version est plus ancienne que celle documentée dans le livre K&R C premiere version. On remarque que le préprocesseur n’existe pas. Les structures commencent à être implémentées. En voici une copie Unix V2 .

Le mot clé extern sert à dire “n’alloue pas de mémoire pour cette variable : elle est définie ailleurs”. Lorsqu’on définit un pointeur, on le déclare ainsi int p[] au lieu de int *p. On ne déclare pas le type de retour d’une fonction, elles retournent toutes int. Lorsque la valeur de retour n’a pas d’importance un returne un entier arbitraire (21, 22, …).

Le mot clé auto est utilisé pour les variables locales, on utilise le mot clé int est utilisé après une déclaration extern quand on veut spécifier un pointeur sur int.

Les variables externes sont déclarées à la fin du permier fichier source. Quand on souhaite les initialiser on met juste la valeur précédée d’un espace après la variable qu’on veut initialiser. La systexe de declaration+initialisation en une seule fois est un ajout ultérieur.

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 nécessaires. En B lorsqu’on deeclare des variables on précise la classe de stockage souhaitée, par exemple auto a, b, c; ou encore extern printf, variable, variable2;. Le C accepte cette syntaxe : lorsque seule la classe de stockage est précisée, le type int est déduit par défaut, et lorsque seul le type est précisé, la classe de stockage est supposée auto pour les variables internes aux fonctions, et extern autrement.

extern toto_e; // de type "int" implicite
int toto_e_bis; // classe de stockage "extern" implicite

fonction(argument1, argument2) // type de retour "int" implicite
char argument2;  // argument1 est de type "int" implicitement
{
  auto toto_s; // type "int" implicite
  int toto_s_bis; // classe de stockage "auto" implicite

  return 0;
}

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é.

L’absence de préprocesseur et donc de prototypes explique pourquoi la syntaxe de la déclaration des fonctions était aussi brève : on ecrivait simplement extern nom_de_la_fonction.

Une forme intermédiaire déclarait que int nom_de_la_fonction(); était bien une fonction retournant int. Et par ailleurs lorsque le compilateur rencontre une fonction qui n’a pas été déclarée pour la première fois, il suppose que la fonction est de la forme extern int fonction().

Un programme tel que

main() {
  printf("Bonjour a tous !");
}

était donc valide (noter l’absence d’#include et de déclaration extern pour printf) car printf est bien une fonction qui retourne int, prend un nombre variable d’arguments et que l’editeur de lien saura trouver ensuite car le compilateur ajoute l’option -lc (lie le programme avec la librairie “c”) pour nous.

Dénominateur commun

Le C se veut universaliste pour permettre de propager le systeme UNIX sur un maximum de machines, 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, quotient, reste;

  quotient = a / b;
  reste = a % b; 
  // deux instructions separees en C
  // la ou la plupart des machine peut le faire en une
}

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.2, v); // catastrophe
  float r2 = trio_somme(1.f, 3.2f, (float)v); // correct
}

float somme(a, b, c)
float a, b, c;
{
  return a + b + c;
}

Avec somme(1, 3.2, 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 est important de noter ci dessus est que la partie décrivant les arguments attendus () après (*fn[]) est vide dans la forme des déclaration de “style C”.

Le resultat est un tableau de trois pointeurs de fonctions qu’on peut 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 🌍⤴

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;
}

https://www.tuhs.org/cgi-bin/utree.pl?file=V3/c/c03.c 🌍⤴

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.

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 bien distinctes pour le processeur : 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
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