Site WWW de Laurent Bloch
Slogan du site

ISSN 2271-3905
Cliquez ici si vous voulez visiter mon autre site, orienté vers des sujets moins techniques.

Pour recevoir (au plus une fois par semaine) les nouveautés de ce site, indiquez ici votre adresse électronique :

Que penser du langage C ?
Une conversation avec Robert Ehrlich
Article mis en ligne le 7 novembre 2017
dernière modification le 26 août 2022

par Laurent Bloch

Après la mise en ligne de ma communication sur la conversion à Unix, une conversation animée s’est engagée avec Robert Ehrlich à propos du langage C, dont il est, bien plus que moi, un connaisseur dans les moindres détails. En voici la transcription. Les propos de Robert sont précédés du signal RE, les miens de LB.

Inélégances de C

LB

Certains traits du langage C me sont restés inexplicables jusqu’à ce que je suive un cours d’assembleur VAX, descendant de leur ancêtre commun, l’assembleur PDP. J’ai compris alors d’où venaient ces modes d’adressage biscornus et ces syntaxes à coucher dehors, justifiées certes par la capacité exiguë des mémoires disponibles à l’époque, mais de nature à décourager l’apprenti. L’obscurité peut être un moyen de défense de techniciens soucieux de se mettre à l’abri des critiques.

Sans trop vouloir entrer dans la sempiternelle querelle des langages de programmation, je retiendrai deux défauts du langage C, dus clairement à un souci d’efficacité mal compris : l’emploi du signe = pour signifier l’opération d’affectation, et l’usage du caractère NUL pour marquer la fin d’une chaîne de caractères.

Depuis la publication de cet article, une nouvelle pièce est venue s’ajouter au dossier :
C Is Not a Low-level Language

À l’époque où nous étions tous débutants, la distinction entre l’égalité et l’affectation fut une affaire importante. En venant de Fortran, les idées sur la question étaient pour le moins confuses [1], et il en résultait des erreurs cocasses ; après avoir fait de l’assistance aux utilisateurs pour leurs programmes Fortran je parle d’expérience. Avec Algol, Pascal, LSE ou Ada, qui notaient l’égalité = et l’affectation := (sans parler de Scheme qui écrit set! ), c’était l’arrivée d’une distinction syntaxique claire, qui permettait de remettre les choses en place, c’était une avancée intellectuelle dans la voie d’une vraie réflexion sur les programmes.

Les auteurs de C en ont jugé autrement : ils notent l’affectation = et l’égalité ==, avec comme argument le fait qu’en C on écrit plus souvent des affectations que des égalités, et que cela économise des frappes au clavier. Ça c’est de l’ingénierie de haut niveau ! Dans un article du bulletin 1024 de la SIF [2], une jeune doctorante explique comment, lors d’une présentation de l’informatique à des collégiens à l’occasion de la fête de la Science, une collégienne lui a fait observer que l’expression i = i+1 qu’elle remarquait dans le texte d’un programme était fausse. Cette collégienne avait raison, et l’explication forcément controuvée qu’elle aura reçue l’aura peut-être écartée de l’informatique, parce qu’elle aura eu l’impression d’une escroquerie intellectuelle. Sa question montrait qu’elle écoutait et comprenait ce que lui disaient les professeurs, et là des idées durement acquises étaient balayées sans raison valable.

Pour la critique de l’usage du caractère NUL pour marquer la fin d’une chaîne de caractères je peux m’appuyer sur un renfort solide, l’article de Poul-Henning Kamp The Most Expensive One-byte Mistake [3]. Rappelons que ce choix malencontreux (au lieu de représenter une chaîne comme un doublet {longueur, adresse}) contribue aux erreurs de débordement de zone mémoire, encore aujourd’hui la faille favorite des pirates informatiques. Poul-Henning Kamp énumère dans son article les coûts induits par ce choix : coût des piratages réussis, coût des mesures de sécurité pour s’en prémunir, coût de développement de compilateurs, coût des pertes de performance imputables aux mesures de sécurité supplémentaires…

Exemple de code source : scheduler du noyau Linux v0.01

Le scheduler est l’élément principal du système d’exploitation, il distribue le temps de processeur aux différents processus en concurrence pour pouvoir s’exécuter.

Les codes de ces systèmes sont aujourd’hui facilement disponibles en ligne, ici [4] et là [5] par exemple. Par souci d’équité (d’œcuménisme ?) entre les obédiences on n’aura garde d’omettre BSD [6], ici la version historique 4BSD.

Avec l’aide de Patrick Cegielski [7] (p. 196) on observe que la variable c représente la priorité dynamique de la tâche [8] considérée. Le scheduler parcourt la table des tâches, et parmi celles qui sont dans l’état TASK_RUNNING, c’est-à-dire disponibles pour s’exécuter, il sélectionne celle qui a la priorité dynamique la plus élevée et lui donne la main : switch_to(next); (switch_to est un code assembleur introduit par un #define que l’on pourra lire dans le fichier include/linux/sched.h à la fin de l’article).

Bien que pour un texte C ce snippet (fragment de code) soit relativement propre, il illustre la raison de mes réticences à l’égard de ce langage. Et lorsque Peter Salus [9] (p. 77) écrit que C est un langage différent des autres langages de programmation, plus proche d’un langage humain comme l’anglais, on peut se demander s’il a un jour écrit une ligne de programme, en C ou autre chose.

Discussion avec Robert Ehrlich

RE

Peut-on avoir plus de précision sur les raisons de cette réticence pour ce snippet de C ?

Je note au passage qu’il y a en préambule la mention v0.01, ce qui tendrait à signifier qu’il s’agit d’une toute première version. Ça me semble en contradiction avec la présence de void qui n’existait pas dans les premières versions de C. De même les sélecteurs de champs alarm, signal, counter dans la structure task_struct n’on pas de préfixe « discriminant » pour les distinguer d’autres sélecteurs de champ homonymes dans d’autres structures, ce qui était nécessaire dans les premières versions de C et ce qui se retrouve dans nombre de structure définies dans les .h sous /usr/include, bien que la nécessité ait disparu depuis.

LB

Il s’agit là de la version princeps de Linux, soit 1991, C était déjà dans sa maturité.

Mes réticences ? Je préfère les langages expressifs aux notations cryptiques. Je sais que ce n’est pas ton cas, mais j’ai assez connu d’adolescents prolongés heureux de faire des choses que personne ne pouvait comprendre. Il suffit de consulter les sites de publication de failles de sécurité pour constater les dégâts qui en résultent.

RE

Je ne vois toujours pas en quoi ce snippet illustre ces réticences, en quoi est-il plus cryptique qu’en un quelconque autre langage ?

LB

Comme je l’ai écrit, je dois concéder que ce code C est relativement propre. Restent le symbole d’affectation, et la ligne 25, dont la syntaxe ne m’a été expliquée par aucun des auteurs suivants : MM. Kernighan, Ritchie, Harbison, Steele Jr, Braquelaire et Lazard.

Pourrais-tu m’expliquer la seconde ligne ci-dessous :

RE

Là ce n’est pas le langage qui est cryptique, mais l’auteur du programme. Si j’avais eu à écrire ce bout de code, j’aurais écrit :

La règle étant que ce qui est conditionné par le if est l’unique instruction qui suit, si on veut en mettre plusieurs, les encadrer par {} transforme ce groupe d’instructions en une instruction unique.

L’auteur de ces lignes, plutôt que d’utiliser cette méthode standard, a préféré utiliser une autre construction du langage peu usitée et dont la nécessité ne s’impose pas ici.

La syntaxe de C définit deux notions : instruction (statement dans la langue des créateurs) et expression (pareil dans les deux langues). Une expression est comme on s’y attend une combinaison (récursive) d’objets élémentaires par un certain nombre d’opérateurs (parmi lesquels l’affectation =). Une instruction a de nombreuse variantes (boucles for, do, while, conditionnelle if, ...) mais entre autres une expression suivie d’un point-virgule est une instruction.

Parmi les opérateurs pouvant intervenir dans une expression, il en est un peu connu dont le symbole est la virgule. C’est un opérateur binaire, il calcule aussi bien ce qui est devant la virgule que ce qui est derrière et la valeur de l’expression est ce second résultat. C’est cet opérateur que l’auteur du snippet a utilisé pour faire de deux expressions une seule, qui suivie d’un point-virgule devient une instruction, qui est donc l’unique instruction conditionnée par le if précédent.

J’avoue que c’est particulièrement inélégant dans ce cas. Le principe de C comme de nombreux autres langages est que le calcul d’une expression, d’une part produit une valeur, d’autre par peut produire certains effets que d’aucuns qualifient « de bord ». Qualification que j’aurais tendance à rejeter dans la mesure ou elle tend à dire que ce n’est pas l’essentiel, alors que tout particulièrement ici le but recherché des deux expressions composantes est l’effet de l’opérateur = qui est de modifier c et next. L’opérateur , est là pour dire qu’on ne s’intéresse qu’à l’effet de la première expression, mais à la valeur de la deuxième. Faire suivre ensuite l’expression complète d’un ; dit ensuite que finalement, non, on ne s’intéresse pas à cette valeur, comme quoi un caractère plus loin on peut être d’un avis contraire.

On peut se poser la question de l’utilité de cet opérateur , et des cas où son emploi est justifié. La raison de son existence tient pour l’essentiel à celle de l’expression conditionnelle <expr1>?<expr2>:<expr3> dont la valeur est <expr2> si <expr1> est vraie, <expr3> sinon. Exactement comme pour le if, on peut avoir plusieurs expressions à calculer en place de <expr2> ou <expr3>, seule la dernière fournissant la valeur de l’expression conditionnelle, l’opérateur , est là pour ça. Je dois reconnaître qu’une inélégance du langage C est cette distinction entre expression et instruction, qui oblige à deux constructions différentes (if(...)... else ... et (...?...:...) et deux mécanismes différents ( { et } d’une part, , d’autre part) pour résoudre la contrainte de l’instruction ou expression unique. Inélégance qu’évitent habilement LISP (et ses dérivés) et Algol68. En particulier en Algol68 if ... then ... else ... fi et ( ... | ... | ... ) sont strictement équivalents.

Quel que soit le langage, il y a moyen d’écrire du code inélégant, voire cryptique. Ce n’est pas le langage qui est à incriminer dans ce cas.

Une inélégance du même type fort répandue en C est d’écrire une instruction dont le seul but est d’incrémenter la variable x comme x++; plutôt que ++x;. La définition de l’opérateur ++ postfixé est d’incrémenter ce qu’il postfixe en ayant pour résultat la valeur avant incrémentation. Ecrire ++x; revient à dire « incrémente x et oublie » alors que x++ revient à dire « incrémente x mais souviens-toi de ce qu’il valait avant, non, finalement oublie ».

Une autre inélégance à mon goût personnel, mais je sais que tout le monde ne sera pas d’accord, présente dans ce même « snippet » est le while(1) auquel je préfère for(;;). Pour moi la première forme consiste à dire « boucle tant que le vrai n’est pas faux » alors que la deuxième dit simplement « boucle », autrement dit la première forme est une forme pédante de la deuxième.

Une dernière encore : pourquoi traîner partout ce (*p) plutôt que de déclarer un struct task_struct *q; et d’y mettre *p aux bons moments. Lesquels sont ligne 7 en remplaçant if (*p) par if(q = *p) et ligne 22 en remplaçant if (!*--p) par if (!(q = *--p)). Ensuite tous les (*p) deviennent des q.

Dernière remarque : je m’interroge sur la ligne 27. C’est elle qui fait sortir de la boucle while (1). La boucle for qui précède semble visiblement rechercher parmi les struct task_struct celle qui a la plus forte valeur pour le champ counter, dont la position est mémorisée dans next. On pourrait s’attendre à ce qu’on sorte du while(1) si on a trouvé un tel maximum, mais ça ne correspond pas au test qui est fait. Si on ne trouve pas de maximum on sort avec c == -1 et next == 0, comme -1 n’est pas 0 il est considéré comme vrai et donc on sort du while(1) dans ce cas. Le seul cas ou on ne sort pas est celui ou on trouve un maximum mais qu’il se trouve être zéro. C’est peut-être impossible si le champ counter n’est jamais nul mais si c’est le cas le test ne sert à rien et on n’exécute jamais le for qui suit. A mon humble avis le test devrait être soit if (c != -1) soit if (next) puisque next est initialisé à 0 et que si on trouve un maximum on sort avec sa position du while (--i) qui ne peut être 0 vu ce while (--i).

LB

Une fois que tes explications m’eurent donné les noms des choses, je les ai effectivement trouvées dans les livres, notamment au paragraphe 7.15 de l’appendice A de K&R, « L’opérateur virgule ». Et j’ai ainsi compris que c’était même une bonne construction, qui permet de faire la même chose que la forme « do » des Lisp modernes, comme exposé par MM. Gabriel et Steele Jr.

Quant à l’auteur du programme dont nous parlions, ce ne peut être, à cette époque, que Linus Torvalds.

RE

Ben Linus Torvalds se passera de mes compliments. Indépendamment des inélégances, il semble qu’il y ait avec le test de la ligne 27 une vraie erreur de logique dans cette fonction.

LB

Ce qui me gêne dans cette histoire, ce n’est pas l’opérateur virgule en lui-même, c’est que ce soit si mal documenté, et que cela devienne du coup un caractère confus du langage. Certes, aucune grammaire n’empêchera jamais d’écrire des programmes cryptiques, faux, moches ou les trois à la fois. Quand trop de choses sont laissées au gré d’options par défaut, on finit par ne plus savoir très bien ce qui est écrit, parce qu’il ne suffit pas de pouvoir écrire, mais il faut aussi lire le code d’autrui.

RE

Les options par défaut sont le pain béni des concurrents d’obfuscated C. Un bon exemple qui revient souvent est d’écrire i; au lieu de int i; ce qui marche parce que le type peut être omis dans une déclaration et le type par défaut est int.

Le nécessité de pouvoir lire le code d’autrui est mon principal argument contre C++ et de façon plus générale l’orienté objet. Même si on peut leur trouver des tas de vertus, le fait est que l’intention du programmeur est rarement évidente au vu du seul code dans ce type de langage. Si le programmeur ne met pas la dose de commentaires adéquate, c’est incompréhensible.

Ca me rappelle un dialogue entre un de mes collègues enseignant en informatique avec un étudiant dont le programme ne marchait pas :

 Monsieur je ne comprends pas, mon programme ne marche pas et je ne vois pas pourquoi
 Ah mais moi je vois tout de suite pourquoi il ne marche pas, c’est parce qu’il n’ y a pas de commentaires.
 Vous rigolez ?
 Mais non, pas du tout, mettez de commentaires et vous verrez, ça va marcher.
  ??

L’étudiant s’exécute et bien entendu trouve son erreur en la commentant.

LB

Les « affectations condensées » me gênent aussi :

Bon, là aussi il y a des circonstances où c’est élégant...

RE

Et même indispensable. Cette construction a l’avantage de mettre l’accent sur le fait que le résultat est aussi l’un des deux opérandes, ce qui n’est pas un hasard. Il évite la bête erreur faute de frappe du doigt qui dérape et qui écrit j = k +1 au lieu de k = k+ 1. Par ailleurs, en C comme dans de nombreux autres langages, ce qui est à droite de l’affectation peut être une expression complexe qu’il est inutile de calculer 2 fois, voire même nuisible si ce calcul a un effet de bord qu’on ne veut pas répéter. On peut argumenter qu’un compilateur optimiseur évitera le recalcul, mais il ne le fera justement que s’il peut (se) prouver qu’il n’y a pas d’effet de bord. Et de toute façon je trouve inélégant d’écrire deux fois cette même expression.

Cette construction est l’équivalent en programmation itérative de ce qu’on fait fréquemment en programmation récursive : rappeler la fonction courante avec un argument qui est une fonction de celui qu’on a reçu en cette position, typiquement en LISP une fonction qui reçoit comme argument une liste L et qui la parcourt en se rappelant récursivement avec (CDR L).

A noter que cette construction est présente en Algol68 qui a influencé les auteurs de C. On trouve même dans les premiers Unix des progammes « Algolisés » à grand coups de #define (je crois que l’auteur est Bourne) :

Supplément : les en-têtes

Voici le fichier d’en-tête include/linux/sched.h, avec même un peu d’assembleur :

Post-scriptum : pourquoi je n’aime pas C

Finalement, cette conversation m’aura aidé à mieux comprendre pourquoi je n’aimais pas le langage C : toujours dans le souci d’économiser les frappes au clavier, ses auteurs ont substitué aux mots des langages « à la Algol » des signes cabalistiques, ce qui ne pose sans doute que peu de problèmes à ceux qui en usent quotidiennement, mais qui sont une torture pour les utilisateurs occasionnels tels que moi.

Plus grave et plus profond : les règles syntaxiques facultatives et les options par défaut, qui permettent d’écrire à peu près n’importe quoi, avec un résultat au petit bonheur la chance. Comme l’écrit Gérard Berry dans son livre L’hyperpuissance de l’Informatique, « C n’est pas un langage bien défini. Dans son standard, on voit en effet qu’il y a un grand nombre de comportements non spécifiés, voire indéfinis, qui ne peuvent donc pas être formalisés. »

Allez, tant qu’à passer pour un dinosaure, autant y aller carrément : vive Ada !

Depuis l’écriture de cet article, David Chisnall est venu apporter à mon moulin une eau de haute qualité, sur le blog de l’ACM, par son article C Is Not a Low-level Language - Your computer is not a fast PDP-11. Il y explique que depuis des décennies les architectes hardware s’évertuent à ce que leurs processeurs exhibent un modèle de calcul conforme au langage C, c’est-à-dire calqué sur les PDP-11 des années 1970, alors que le comportement réel des machines contemporaines n’a plus grand-chose à voir. Cela les conduit à porter leurs efforts sur le parallélisme au niveau de l’instruction (pipe-line, exécution out of order, superscalaire, exécution spéculative, etc.), ce qui est coûteux, notamment en termes énergétiques, alors que le temps est venu du parallélisme explicite. Ainsi, les développeurs de WhatsApp utilisent-ils le langage fonctionnel et parallèle Erlang, cependant que ceux de Facebook utilisent Haskell.