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

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.

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