Affichage des articles dont le libellé est prog. Afficher tous les articles
Affichage des articles dont le libellé est prog. Afficher tous les articles

vendredi 19 mars 2010

When can code duplication be better?

Follow-up to a conversation on reddit:

C and C++ make it hard to read a register for its side effects (main article)

my comment:

"It seems to me that when one is concerned about these issues, and checks the compiler's output, one is also able to write the desired machine code.
So why not just use inline assembly in the first place?"

A redittor:

"It doesn't hurt to write portable code even in this context. You may have to run your code on a PPC instead of an ARM, but targeting the same peripheral."

It happens that I worked a lot this week on the Linux kernel; I tried to add support for a development board we bought, and it happens it is ARM-based.

The thing to know is that ARM is not just one processor, it is a large family of processors, perhaps as big as the m68k familly. As a result, there's a large collection of processor-specific source code files in the kernel.

There could be more, actually. It's kind of a tautology, but within a family the difference between two processors are minor with regard to what they have in common. Often it boils down to some extra built-in peripheral support.

As a consequence, and it looks like a good thing at first glance, the Linux maintainers choose to put the sharable code into common files, and use various tricks for the parts that differ.

Except that it is squared by the number of boards that may use a chip, because different boards are often "wired" differently.

In the particular, in the kernel part I was working in, they handled that not only with the usual #ifdef conditional compilation tricks, but also with include file search path order.

Conditional compilation is brittle, because a single typo can accidently select the wrong code; it is also harder to read. The include search path thing is even worth; one has to read an horribly long compilation command in order to get the actual inclusion order. When you see that some compilation options (including the -I ones, include search path specifications) are duplicated, probably because the makefile stuff is not totally under control, you start to think that something is wrong.

The bottom line is that, in order to avoid duplicate code, we end-up with a build recipe that can give one headaches very quickly. 

It seems to me that the total energy spent in the DRY effort overweights the cost of just duplicating the code. This rather particular and extreme case, but it shows that the DRY principle isn't always appropriate.


dimanche 31 mai 2009

Types

Les articles se succèdent rapidement à mesure que le développement de Lama avance...
Parfois une petite avancée se fait au prix de quelques heures de deboguage, parfois ça fonctionne du premier coup, comme par miracle. Dans les deux cas, ce serait l'occasion d'un cri guttural (à la manière du genre de musique que j'écoute) de victoire, si je n'avais pas de voisins.
Cela tombe bien, la dernière avancée concerne un aspect dont j'avais dit que je le développerais plus tard dans le message précédent: les types.

Dans Lama, il y aura quatre types primitifs: entier, réel, booléen et chaîne. Des librairies permettront d'ajouter d'autres types; ce seront essentiellement des types opaques ou abstraits comme les flux ("streams").
Le programmeur peut créer à partir des types de base ces propres types selon le mécanisme classique d'agrégation qui donne les "structures" que l'on voit, avec diverses syntaxes, dans la plupart des langages.
Ces structures sont d'un point de vue mathématique des produits cartésiens d'ensembles. Par exemple, un mathématicien dirait d'une structure "Point" constituée de deux champs pour les coordonnées X et Y, qu'elle est une paire de réels, autrement dit l'ensemble résultant du produit cartésien de R par R (R étant l'ensemble des réels).
Mais en programmation, les types sont un peu plus que cela: Le même produit cartésien de R par R peut aussi bien être utilisé pour représenter un point, un vecteur ou un complexe. Et il n'est pas question d'ajouter un vecteur à un point par exemple. Le type en programmation porte une information supplémentaire ayant trait à la nature de l'entité. En mathématiques, on n'ajoute pas non plus un vecteur à un point car l'opération + : Point, Vecteur -> Point n'est normalement pas définie.
Pourtant, aussi bien l'ensemble des points que l'ensemble des vecteurs sont tout deux des sous-ensembles de l'ensemble des paires d'entier. L'interdiction demeure parce que Point ou Vecteur ne sont pas vraiment des ensembles; ce sont plutôt des structures algébriques, qui combinent ensembles et opérations sur les éléments de ces ensembles.
Les types sont en fait des structures algébriques: ils sont constitués d'une partie structurelle (définie par le mot-clef "struct" en C, "type" en Lama) et d'une partie "opérationnelle", qui est l'ensemble des fonctions qui s'appliquent à ces éléments. La distinction entre élément (d'ensemble) et opérateur des mathématiques rejoint la distinction informatique entre programme et donnée.

Pour clore ce chapitre, remarquons qu'assez étrangement, le potentiel anti-bogue des systèmes de types semble sous-exploités. Je me souviens d'un merveilleux exemple donné dans un tutoriel du langage Anubis, qui illustre cela. Le thème est le calcul de grandeurs électriques; la puissance électrique, qui se mesure en Watts, est le produit de la tension (en Volts) par l'intensité (en Ampères), autrement dit P=U x I. L'exemple montrait comment utiliser les types pour éviter les courts-circuits (je transcris en Lama):

type Watts Real

type Volts Real

type Amps Real

proc calculerAmperage Volts u, Watts p -> Amps i
set i : p/u
end

let tensionGen : 12 as Volts

let puissanceGen : 1 as Volts

let amperageGen : calculerAmperage(tensionGen, puissanceGen
)

Le fin mot de l'histoire est que, en utilisant des types spécifiques pour les volts et les ampères plutôt que de simple réels comme on le fait souvent, une interversion entre les deux paramètres de calculAmperage ne fera pas d'étincelles lorsque l'on mettra le tout sous tension. Cela a l'air d'être trop simpliste pour être efficace? Et pourtant (anglais - crash d'une sonde martienne due à une confusion entre système métrique et unités anglo-saxones)

En Lama, le mot-clef "type" définit à la fois une structure et un nom de type - une combinaison du typedef et du struct de C:

type Complex [ Real: realOf , Real: imaOf ]

Ceci définit le type "Complex" qui est structurellement une paire de nombres réels. Dans l'exemple précédent sur les unités de mesure, la syntaxe était différente parce que les types définis étaient de simples "variantes" d'un type déjà existant. Il est possible que j'utilise plutôt un mot-clef spécifique pour cela (par exemple "subtype") par la suite, car dans ce cas le type défini, du fait de sa "parenté" directe avec un autre type, pourrait bénéficier de quelques privilèges.
Les symboles "realOf" et "imaOf" sont deux identificateurs de champs. Ce sont des quasi-fonctions définies automatiquement. "quasi fonctions" parce qu'on les utilise syntaxiquement comme des fonctions, mais qu'il est permis de leur affecter une valeur. "realOf" et "imaOf" sont définies comme prenant en paramètre un Complex et retournent un Real. Voyons leur usage concrètement sur la procédure d'addition de deux complexes:

proc + infix Complex a, Complex b -> Complex z
set z : [ realOf a + realOf b , imaOf a + imaOf b ] as Complex
end

Les crochets utilisés dans la définition des types sont également utilisés pour sa contrepartie, la formation des t-uples (ici une simple paire).
La partie "as Complex" est une syntaxe qui permet de doter une expression d'un type particulier. Ici, elle est utilisée pour donner le type "Complex" à notre paire d'entiers. Le compilateur n'acceptera de faire une telle opération que si le type original de l'expression à la même structure que le type que l'on veut lui donner.

Ce mot-clef "as" permet, vous l'aurez compris, de "transtyper" une expression, autrement dit de court-circuiter (en partie) la vérification des types. Sans cette syntaxe, il serait impossible de créer une entité de type Complex (plus généralement, il n'aurait pas été possible de créer des variables dotées d'un type défini par l'utilisateur). Il existe certainement des alternatives, mais toute brutale qu'elle soit, cette méthode est sans doute la plus élégante.
Notamment parce qu'elle permet de résoudre le problème de "l'initialisation oubliée" dont je parlais dans un précédent message. Le fait que le compilateur vérifie la compatibilité structurelle, contrairement au transtypage de C, permet d'intercepter ce genre d'erreur: si on ajoute un champ à la définition du type, toutes les opérations de transtypage deviennent incorrectes. Le programme ne se compilera pas tant qu'on aura pas ajouté ce champ dans tous les transtypages. Ce n'est certes pas parfait car la permutation de deux champs identiques passera entre les mailles du filet, mais c'est un progrès notable.


vendredi 29 mai 2009

Initialisations

Dans cet article, un programmeur Java se plaint des pointeurs ou références nuls à cause des exceptions qu'ils causent, et imagine un monde sans eux.

Il soulève en fait deux problèmes: l'un est l'existence des pointeurs nuls. Comme quelqu'un l'indique en commentaire, son "inventeur" lui-même reconnaît que c'était une terrible erreur. En ce qui me concerne, étant donné que le langage que je crée s'adresse à des presque non-programmeurs, je n'introduirai la notion même de pointeur qu'en dernier et ultime ressort.

Le second problème soulevé est plus intéressant. Pour résumer, l'auteur peste contre les pointeurs nuls parce qu'ils provoquent des exceptions dans des fonctions qui "oublient" de tester ce cas. Et de proposer quelque moyen de spécifier une valeur par défaut pour les valeurs qui ne seraient pas explicitement initialisées, afin d'éviter les pointeurs "vers rien" (nuls).

Or, là est le vrai problème: "on" a oublié d'initialiser une variable. Que ce genre d'erreur passe au travers des mailles du compilateur n'est pas propre à Java. un GCC par exemple ne m'a pas toujours paru irréprochable sur ce point. Dans la version que j'utilise au travail pour de la compilation croisée, qui n'est certe pas la plus récente (2.95), il peut laisser passer ce type d'erreur, ou au contraire voir ce genre d'erreur là où il n'y en a pas (en particulier quand une structure switch...case est impliquée). On peut estimer qu'il s'agit de "faiblesses" de GCC, mais il y a un cas où l'absence d'avertissement quant à l'usage d'une variable non-initialisée est plus profonde: ce sont les variables-membre de classes. J'ai plus d'une fois, au cours d'un développement, oublié d'ajouter dans le constructeur d'une classe l'initialisation d'une nouvelle variable membre.
La détection de ce genre d'erreur par un compilateur est sans doute une tâche difficile, particulièrement si le langage s'y prête mal. J'ai de plus l'intuition que c'est un problème insoluble dans le cas général, car certainement équivalent au problème de l'arrêt.

Pour Lama, j'avais pris presque dès le départ la décision d'imposer l'initialisation à la déclaration, ce qui paraissait résoudre le problème. Mais en écrivant un commentaire dans ce sens pour cet article, un petit doute que j'avais au sujet de cet solution a en quelque sorte grandit:
Si j'impose à mes programmeurs de fournir une valeur à la déclaration, et que cela les ennuie parce la situation s'y prête mal, ils vont utiliser une valeur "par défaut" (version "manuelle" de ce que suggère l'article). Cela pourrait déboucher à des situations similaires de bogue où on utilise une variable à laquelle on a pas réellement affecté une valeur pertinente. D'un côté, cette solution rend dans tous les cas la valeur de la variable non-aléatoire, ce qui est un progrès réel car le caractère aléatoire d'une valeur non initialisée est ce qui rend parfois très difficile le déboguage. Mais d'un autre côté, le programmeur n'est toujours pas averti de son erreur.

C'est donc exactement l'inverse qu'il faut faire: ne pas autoriser l'affectation d'une valeur par défaut à la déclaration d'une variable, mais vérifier qu'une variable est affectée avant tout usage.
J'ai indiqué tout à l'heure que cette vérification était probablement impossible dans le cas général. Cela n'exclut pas qu'il soit possible de le faire dans certains cas particuliers, et il faudra prendre garde à ce que Lama se situe dans ce cas particulier. Au pire, on pourra tolérer de fausses alertes: si un compilateur n'arrive pas à prouver qu'une variable est bien initialisée avant son usage, c'est a fortiori difficile pour un humain. Une fausse alerte n'est donc pas une si mauvaise chose dans ce cas, et le programmeur peut toujours facilement y remédier.

Cela rejoint un autre dilemme de conception que j'ai dû résoudre ces derniers jours (et c'est sans doute pour cela que cet article a attiré mon attention en premier lieu). Lama a une petite particularité en ce qui concerne les définitions de procédures. Par exemple:

proc square Int x -> Int y
set y : x*x
end

Ceci définit une procédure nommée "square" qui prend un entier en paramètre et retourne un entier (qui est la valeur du paramètre au carré, pour ceux qui auraient un doute). La particularité est que la variable qui sera retournée (y) est déclarée en première ligne, à l'instar de la variable paramètre x. Cette méthode évite l'usage d'un mot-clef "return", ce qui incite à utiliser un style "stricturé" (c'est-à-dire, une programmation strictement structurée). Soit dit en passant, j'utilise couramment un style moins strict - en particulier pour ce qui concerne l'unicité du point de sortie - et donc je n'exclus pas l'existence à terme d'un équivalent de "return" dans Lama. Mais je pense aussi qu'il manque aux langages classiques une structure de contrôle qui permettrait de répondre aux cas où la programmation stricturée semble compliquer les choses.

Dans cette conception, le problème de l'initialisation se pose pour la variable de retour: il faut s'assurer que l'on n'atteint pas la fin de la procédure sans avoir affecté au moins une fois la variable; de même, il faut vérifier qu'elle n'est pas passée en paramètre à une autre procédure avant son affectation.
Un autre problème qu'au départ je n'avais pas anticipé est que si la valeur retournée est une grosse "entité" (j'évite le terme "objet" car Lama n'est pas un langage à objet; les entités dont je parle sont l'équivalent des structures de C), il faut que cette structure soit allouée quelque part. Le compilateur pourrait le faire automatiquement; mais cela pourrait se révéler dans beaucoup de cas superflu (on peut imaginer d'y remédier par une optimisation, mais cela coûte quelque complexité dans le compilateur). De plus, il lui faudrait initialiser la structure à des valeurs par défaut, une opération qui risque également d'être redondante avec les actions du programmeur, et ne résout en fait pas grand chose: dans l'hypothèse où le compilateur fait tout automatiquement, la valeur initiale par défaut serait probablement zéro, qui peut constituer un casus belli pour certaines procédures ou fonctions.

La tactique de la non initialisation préventive exposée plus haut s'applique parfaitement à ce cas: la variable de retour est simplement déclarée, et le compilateur devra vérifier qu'elle n'est pas utilisée avant d'avoir été affectée, à l'instar des variables locales.

Reste le problème d'initialisation de variable-membre de C++ évoqué plus haut, qui se transpose en problème d'initialisation de champ de structure en C. Sachant que les structures de données dans Lama sont équivalentes à celles de C (ou de Pascal, etc.), propose-t-il une solution?

La réponse est oui. Mais pour exposer cette solution il me faudrait décrire un peu le système de types de Lama. Cet article est suffisamment long pour aujourd'hui, donc j'aborderais le sujet prochainement.

dimanche 24 mai 2009

Premiers pas

Lama, le langage de programmation sur lequel je travaille actuellement, vient d'exécuter ces premières instructions.

En soit, ces premiers mots n'ont rien d'extraordinaire, puisqu'il a additionné 1+1 et a affiché le résultat. Le source correspondant est:

bind + infix high Int x, Int y -> Int z  : add

bind print Int x -> () : print

print 1+1

Les deux premières lignes sont de "bas niveau", puisqu'elles ont pour but de déclarer les symboles "+" et "print" comme des procédures implémentées directement par des fonctions C ("add" et "print").

Les mot-clefs "infix" et "high" qui suivent "+" sur la première ligne permettent respectivement d'utiliser la notation infixe, et de donner une précédence supérieure pour ce symbole. La déclaration des procédures est assez proche de ce modèle, et en particulier ces deux mot-clefs seront utilisables.

Conférer une précédence haute à "+" n'est en réalité pas correct, car c'est la multiplication qui devrait l'avoir pour reproduire la précédence naturelle entre l'addition et la multiplication. Mais il me permet de montrer quelque chose sur la dernière ligne: les parenthèses autour des arguments ne sont pas obligatoires. Sans la précédence supérieure pour '+' toutefois, il faudrait quand même mettre des parenthèses (car sinon le compilateur comprend (print 1)+1, ce qui n'a pas de sens).

Un motif de satisfaction supplémentaire est qu'en fait, une grande partie du système est déjà là: l'analyse syntaxique "comprend" les définitions de procédures, de variables, de types et les expressions; la vérification de types est en grande partie opérationnelle; la mécanique pour compiler cela est présente, et la machine virtuelle qui doit exécuter ce qui a été compilé est en place. Mais ils ne sont pas pour autant terminés, et encore moins débogués.

D'où vient le nom du langage? Je n'ai pas d'affection particulière pour les lamas. C'est au départ un acronyme, "Library Assembly and MAsh-up" (assemblage et mixage de bibliothèques), que j'ai trouvé par la suite pas si terrible. Je l'ai gardé à cause de sa proximité avec le mot anglais lame ( "boîteux" - lame descent d'ailleur du vieil-anglais lama) m'amuse. Je lui est aussi trouvé une nouvelle signification: "Lame Attempt to Making an Acronym" (tentative boîteuse de faire un acronyme), qui m'amuse tout autant.

Pour résumer, j'ai choisi un nom délibéremment vide de sens voire un peu comique, par opposition à un nom ronflant, bouffi de sens ou qui cherche à impressionner. Ce choix est, finalement, plutôt cohérent avec la philosophie générale du langage.

PS: J'avais en tête l'image d'un animal assez peu charismatique, mais quelques recherches sur le sujet m'ont détrompé.

jeudi 14 mai 2009

Lua

Le bricoleur pur et dur a toujours son couteau suisse dans la poche; le programmeur pur et dur a toujours son langage favori sous la main.

Ce genre de langage-couteau-suisse se distingue par certaines caractéristiques:
* la gestion de la mémoire y est automatique,
* c'est un langage interprété,
* de nombreuses librairies sont disponibles,
* sa syntaxe est simple,

C'est un langage avant tout pragmatique: les créateurs ne recherchent pas la pureté absolue (pur objet ou pur fonctionnel), ou la performance absolue, mais plutôt une ergonomie optimale. Oui, l'ergonomie ne s'applique pas seulement aux postes de travail, aux appareils electro-ménagers ou aux gadgets électroniques.

Pour ma part, je recherche une qualité supplémentaire: que le langage soit tout-terrain. Il doit fonctionner aussi bien sous Linux que Windows, mais aussi sur les systèmes embarqués tels que ceux sur lesquels je travaille (à base de processeurs 32bits tournant à100-200MHz, disposant de seulement quelques Mo de RAM et faisant tourner un système Linux léger).

Cela impose indirectement certaines contraintes de performance. Cela met en fait hors course la plupart des candidats, à commencer par Python, Ruby et TCL. En fait, il ne reste quasiment que Lua, qui est le seul langage interprété à concurrencer Forth en matière de performances. Il est certes nettement plus gros que lui, mais il n'y a guère qu'un autre système Forth pour concurrencer Forth sur ce point. En contrepartie, Lua offre la gestion de mémoire automatique et une syntaxe plus naturelle.

En fait, Lua est assez proche du langage "orienté utilisateur" que je cherche à créer. Pour commencer, il a toute ma reconnaissance pour ne pas être encore un de ces langages orientés objet. Il est difficile d'en trouver un qui ne l'est pas ces temps-ci, comme si cela était une condition sine qua non. Le seul pas en direction de l'orienté-objet est une facilité de syntaxe dont l'utilité se justifie d'elle-même: si elle s'avère pratique pour le programme que vous écrivez, c'est que le style orienté-objet est certainement le plus adapté. Si vous ne savez que penser en termes d'orienté-objet, et bien, vous devriez envisager de vous faire trépaner, mais vous pouvez construire un système de classes etc, ou adopter l'un de ceux créé par d'autres.

Ensuite, l'attention qui a été prêtée à la facilité d'intégration des librairies C avec le language. C'est un point important parce que ce type d'intégration est parfois difficile car s'y cumulent les défauts de la librairie et du langage-hôte. Lua simplifie les choses dans ce domaine en présentant une interface compréhensible et compacte. Cela incite à faire ces intégrations, et plus de librairies intégrées signifie plus de fonctionnalités disponibles pour le langage. C'est l'une des clefs du succès de Python.
A cela il faut ajouter que Lua donne le choix entre une intégration par librairie dynamique ou statique de manière transparente. L'un permet que l'interpréteur garde une taille raisonnable, et l'autre peut être plus pratique, plus compact, mais aussi permet le fonctionnement sur des plateformes qui ne savent pas gérer les librairies dynamiques.

Enfin, Lua est doté d'un ensemble équilibré et efficace de caractéristiques: clôtures, syntaxe avec juste ce qu'il faut de facilités, coroutines, et les metatables.


Lorsque j'ai commencé à avoir une idée claire du langage que je voulais, j'ai fait quelques recherches: l'expérience laisse à penser que tout ce que vous pouvez imaginer en matière de logiciel existe quelque part sur la toile. Pour le coup, il ne s'agissait pas d'écrire un interpréteur simple comme Forth, car ce que je veux implique de l'analyse de syntaxe, de la gestion automatique de mémoire, et de la vérification de type. Mes recherches ont été infructueuses; Lua, que je connaissais déjà est ce qui s'en rapproche le plus.
Je suis en train d'implémenter très lentement l'interpréteur pour mon langage; la difficulté de cette implémentation est à la auteur de mes craintes. Pourtant, je continue cette implémentation plutôt que d'adopter Lua car celui-ci a, à mes yeux, des défauts importants.

Ceux qui connaissent Lua auront remarqué qu'une des différences d'avec ce que je souhaite est la vérification de types; Lua est un langage dynamiquement typé alors que je veux un langage fortement et statiquement typé, une différence qui reste aujourd'hui le sujet de guerres de clocher virulentes. L'un et l'autre ont leurs avantages et leurs inconvénients. La raison de ma préférence pour le typage statique tient probablement au fait que je suis, le jour, programmeur pour système embarqué, et que le typage statique constitue une barrière de plus contre les bogues.
Le typage dynamique de Lua, couplé avec son principe de fonctionnement basé sur les tables, amène un inconvénient majeur: une simple faute de frappe peut amener un disfonctionnement incompréhensible. C'est l'assurance de perdre bêtement de temps à autres une heure ou deux pour une simple faute de frappe. Cela peut être à la limite acceptable quand on est prévenu de ce problème; après tout, quiconque c'est fait "avoir" deux ou trois fois par un point-virgule mal placé ou un "break" manquant en C apprend à être vigilant sur ces pièges.
Un autre problème chez Lua est la qualité des librairies. Un symptôme assez représentatif est l'usage à tort et à travers de chaines de caractères au lieu de codes numériques, notamment pour les codes d'erreur. On peut même le voir dans les fonctions intégrées du langage: la fonction type() renvoie la chaine "string", "table", etc. selon le type de l'argument passé. Outre le problème de la faute de frappe, c'est selon toute vraissemblance inefficace: si l'égalité peut être déduite efficacement en comparant les adresses des chaines (moyennant une optimisation de compilation que Lua fait certainement, sans quoi cela devient franchement stupide), si les adresses sont différentes le système doit en passer par une comparaison du contenu des chaînes (direct ou indirect - en tout cas c'est moins efficace que de comparer deux entiers).
Dans les librairies qui sont des contributions externes, j'ai vu tantôt des fonctions qui retournaient des chaines en guise d'erreur, tantôt un message d'erreur et un code d'erreur. Dans un autre registre, dans une autre librairie on peut voir un usage arbitraire des" metatables"; par arbitraire j' entend que cet usage n'est en rien justifié ou justifiable. Pour terminer, il faut noter que ce n'est que récemment que Lua c'est inspiré de Python, et offre une distribution incluant un éventail relativement convainquant de librairies (où j'ai d'ailleurs vu ces problèmes).
Globalement, le problème est que les auteurs de Lua semblent ne s'occuper que du langage et se désinteresser de la qualité des librairies. Or, cette qualité égale en importance les caractéristiques propres du langage. Il faudrait pour Lua que quelqu'un (ou un groupe) s'occupe de définir les règles de bonne conception des interfaces pour les librairies, évalue la qualité de leur implémentation et de leur documentation, choisisse entre plusieurs librairies qui offrent les mêmes fonctionnalités. On peut trouver quelque chose qui va dans ce sens sur le Wiki de Lua, mais cela reste très embryonnaire.
Pour conclure, il faut qu'une entité régisse les librairies. "Regir" a pour racine latine rex ("roi"). Sur d'autres projets Open Source, on se plaint parfois du caractère autoritaire de l'équipe de développement en charge du projet, car elle refuse tel ajout ou telle modification. Mais je pense que l'inverse, une équipe beni oui-oui qui fait ce qui suggéré sans discernement, est bien pire.

samedi 7 mars 2009

Erreurs

Programmer ne consiste pas seulement a faire réaliser un tâche particulière à un ordinateur: cela consiste également à prévoir et traiter les circonstances particulières qui peuvent se produire lors de cette tâche, autrement dit gérer les cas d'erreur.
La gestion d'erreur est vécue comme un corvée par la plupart des programmeurs. Une première raison est que cela peut représenter beaucoup travail sans qu'on ait l'impression que la valeur ajoutée au programme augmente: une bonne part de ce travail consiste en effet à gérer des cas dont on a l'impression qu'ils ne se produiront jamais. A tel point que cela pose parfois de sérieuses difficultés: comprendre pourquoi le système renvoie une erreur obscure et décider de la réponse à y donner peut nécessiter beaucoup de recherche et de réflexion. De plus, la gestion d'erreur "défigure" en quelque sorte un programme: si on compare un programme avec une gestion d'erreur minimale et ce même programme avec une gestion plus rigoureuse, on constate qu'il a facilement doublé de volume, et que le programme original est "noyé" dans le code dédié à la gestion des erreurs; la lisibilité s'en trouve grandement réduite.

Toutefois, les programmeurs savent aussi que c'est un mal nécessaire. Un programme qui ne gère pas correctement les erreurs "se plante", avec en prime des pertes de données pour l'utilisateur.
L'impression que tout ce code supplémentaire ne sert pas à grand chose est fausse, parce qu'on pense souvent qu'une erreur qui a peu de chances de se produire ne se produira jamais. Un joueur de Loto n'a qu'une chance très faible de gagner le gros lot, et pourtant il y a régulièrement des gagnants, car il y a beaucoup de joueurs. De même, on peut penser a priori que les chances sont quasi-nulles pour qu'un certain scénario d'erreur se produise; mais si le programme est utilisé quotidiennement par des milliers d'utilisateurs, vous aurez des centaines de gens qui viendront se plaindre sur votre forum.
La gestion d'erreur fait partie de l'intelligence du logiciel, car il s'agit bien de doter le programme d'un comportement sensé quand il rencontre des circonstances inhabituelles; c'est aussi en partie ce qui rend la tâche difficile au programmeur. Un programme qui gère mal les erreurs a l'air de se comporter de manière stupide, et laisse bien souvent l'utilisateur seul avec son problème. Un programme qui gère bien les erreurs donnera, s'il ne peut résoudre le problème lui-même, un diagnostic précis et des éléments de résolution.
Les programmes qui doivent se connecter à un serveur affichent typiquement un message laconique "Impossible de se connecter au serveur" et proposent à l'utilisateur d'essayer à nouveau de se connecter. C'est un cas de gestion médiocre d'erreur. Les programmes avec une bonne gestion d'erreur vérifient si la machine visée peut être contactée, si le client est bien connecté sur le réseau, et si les paramètres réseau sont corrects. Ses diagnostics permettent à l'utilisateur de résoudre le problème plus rapidement; on lui épargne au moins la tâche de faire lui-même ces diagnostics manuellement, et qui peuvent du reste être faux et aiguiller sur une fausse piste (confusion entre adresse IP du client et du serveur, par exemple).

La gestion d'erreur est donc pénible et difficile, mais elle est d'une importance majeure. Une gestion d'erreur éronnée est un comble que les programmeurs ne savent pas toujours éviter. Elle mérite donc qu'on y réfléchisse, notamment afin de définir une stratégie saine. Avec en perspective des possibilités de soutien de cette stratégie par le langage de programmation.

Deux techniques bien connues existent:

  • La technique des codes d'erreur,
  • La technique des exceptions.
La technique des codes d'erreur est la plus basique et la plus ancienne. Quand une erreur se produit, la fonction retourne une valeur particulière et le programme appelant doit effectuer des actions supplémentaires pour connaître la cause précise de l'erreur.
Ce mode de gestion a plusieurs inconvénients: elle nécessite de dédier une des valeurs que peut retourner la fonction pour le signalement de l'erreur, et cette valeur peut varier d'une fonction à une autre suivant les contraintes (parfois c'est 0, d'autre fois c'est -1, parfois c'est une constante symbolique comme EOF, etc.), ce qui est source d'erreur. Un autre inconvénient est que le code d'erreur étant en quelque sorte encodé dans la valeur de retour, la distinction entre la logique de traitement d'erreur et la logique de l'application s'en trouve brouillée. Les langages qui permettent aux fonctions de retourner plusieurs valeurs améliorent un peu les choses, mais cela reste globalement insatisfaisant. Enfin, un reproche qui est souvent fait à cette méthode est qu'il faut faire "redescendre" les codes d'erreur de l'appelé à l'appelant, puis à son appelant, etc.

La technique des exceptions évite ces inconvénients. Avec les exceptions, une fonction qui échoue peut lancer une sorte de signal d'erreur qui peut être intercepté et traité par l'appelant, mais peut aussi être ignoré, auquel cas le signal sera traité par la dernière fonction parente qui aura fait en sorte d'intercepter ce signal. Ce qui permet à une fonction de ne traiter que les erreurs qui "l'intéresse". Cela donne parfois un code plus léger par rapport à la première méthode.
Cependant, il n'y a pas de magie, le code de gestion d'erreur que l'on trouvait dans la première méthode doit nécessairement se retrouver quelque part avec cette méthode. On le retrouve généralement au niveau de la fonction parente qui intercepte les exceptions éventuellement émises par les fonctions qu'elle appelle elle-même; ce code est "délocalisé" en quelque sorte, pour le regrouper avec d'autres gestions d'erreur. Cette délocalisation est un point qui peut être reproché à la technique des exceptions: la gestion des erreurs est déléguée d'une fonction à une autre, ce qui peut constituer une entorse au principe d'encapsulation: une fonction d'un niveau N peut se retrouver à gérer des exceptions du niveau N-2 si le niveau N-1 "oublie" de les intercepter. Cela constitue à mon sens à diluer la responsabilité de la gestion d'erreur. Ce qui se confirme dans les faits, car les concepteurs de Java ont pris des décisions qui ont provoqué une petite polémique, dans le but d'éradiquer ou du moins de limiter les cas de programmes "terminés" sans autre forme de procès par une exception qui est "remontée" jusqu'à la fonction principale parce qu'aucune fonction de la chaine d'appel n'a daigné l'intercepter et la traiter. J'exagère un peu ici: les programmes ne se plantaient pas vraiment: ils consignaient ladite exception dans leur journal d'évènements, et informaient l'utilisateur qu'ils étaient au regret de mettre fin à leur exécution...

La décision polémique que j'ai évoqué paraît pourtant être une bonne idée : que les exceptions émises par une fonction soit déclarées, ce qui permet au compilateur d'offrir un service supplémentaire de vérification. Il est parfaitement justifié d'imposer que les caractéristiques de comportement en erreur fassent partie de l'interface de la fonction au même titre que le type et le nombre de ces arguments. On peut même se laisser aller à dire que c'est le symétrique "en sortie" (avec la valeur éventuellement retournée) des paramètres "en entrée".

Cela impose que pour chaque appel de fonction, il faut intercepter les exceptions. Les concepteurs de Java ont rendu cela obligatoire en générant une erreur de compilation si cela n'est pas fait. Le problème est qu'au final, on en revient de facto quasiment à la même forme de code qu'avec la première technique, pour laquelle un appel de fonction est souvent suivi d'un switch... case sur le code d'erreur retourné. Autrement dit, les concepteurs de Java ont pour ainsi dire adopté la position des détracteurs des exceptions (nota - lien vers un wiki, le contenu peut se trouver modifié), et ont décidé de renoncer à l'un de ces avantages majeurs qui était exactement contraire à un principe (pourtant) de base: le traitement local et au plus proche de la source de l'erreur.
Valider une donnée que l'on reçoit du monde extérieur comme première et indispensable étape du traitement de cette donnée par l'application est une méthode qui est généralement suivie par les programmeurs. Sans quoi, il faut partout dans l'application vérifier que la donnée est valide, et courir le risque d'oublier cette vérification sur un cas obscur ou simplement lors d'une modification anodine. On vérifie que la donnée répond à un certain "format", et la fonction chargée de la vérification retourne une structure ou un objet correspond à la donnée. Dans les langages à typage statique, la donnée acquiert du même un coup un type. Ces informations de type permettent d'éliminer les tests "internes" redondants.
Ne pas traiter une erreur rapidement et au contraire déléguer sont traitement à "quelqu'un" d'autre est très similaire à ne pas vérifier les données entrantes: on multiple les gestionnaires pour être sûr d'intercepter toutes les erreurs, mais un jour on ne voit pas un cas obscur ou on fait un faux-pas sur une modification qui parait triviale, et des exceptions non-interceptées commencent à terminer le programme de manière inattendue.

Le mécanisme des exceptions est une technique populaire, et on peut se demander pourquoi Java est le seul langage à revenir ainsi sur ces exceptions. Les programmeurs Java sont-ils simplement médiocres? Cela me fait beaucoup penser au cas de la gestion manuelle de la mémoire. S'il est effectivement possible de gérer manuellement et correctement la mémoire à force de rigueur et de choix judicieux, en revanche dans certains cas la difficulté de cette gestion peut excéder nos capacités. On commet une erreur bête ou subtile, et la fuite mémoire tant redoutée apparaît. La mise sur pied d'une bonne gestion mémoire peut donc consommer énormément d'énergie, à la fois à la conception et à l'entretien.
Les programmeurs ne sont bien sûr pas des gens parfaits. Pour peu qu'un langage devienne populaire et largement utilisé comme Java, il attire des programmeurs de tous horizons et de tout niveau. Il ne faut pas s'attendre à ce que le programmeur moyen soit un programmeur d'élite. Beaucoup de langages prennent bien cela en considération, mais ce faisant ils attirent un éventail encore plus large de programmeurs. C'est une curieuse variante de la loi de Hofstadter: Le programmeur moyen est toujours un peu plus médiocre que ce que l'on pense, même en prenant en compte cette loi.
Il ne faudrait pas en conclure qu'il faut concevoir les langages (ou les logiciels, d'un manière générale) comme si l'utilisateur était complètement débile et incompétent, mais bien qu'il faut prévoir des dispositions particulières autour du langage (tutoriels, documentation, formation, ...) pour que le programmeur (ou l'utilisateur) moyen ait les compétences attendues. On peut donc concevoir un logiciel (ou un langage de programmation) avec un certain utilisateur-type en tête, mais il faut être conscient du décalage potentiel entre celui-ci et l'utilisateur réel, et que ce décalage se concrétise par un effort supplémentaire pour l'utilisateur et le concepteur en termes de formation.
On pourrait aussi arguer que la gestion d'exception dans Java est faible comme le sous-entend à peine l'article Wikipedia français sur le sujet. En particulier, que le "vrai" système de gestion des situations d'erreur, qui généralise les exceptions, est celui-ci de Lisp est un argument fréquent. Pour être honnête, je ne suis pas sûr d'avoir tout compris de son fonctionnement, et j'ai quelques méfiances vis-à-vis de systèmes qui résistent à la compréhension. La cause de mon incompréhension est que ce système plus complexe est encore pire pour ce qui est de suivre le déroulement du programme dans les cas d'erreur.

Les exceptions coupaient également le noeud gordien de "l'anti-motif de la flèche": dans la programmation structuré stricte ("stricturée"), la gestion d'erreur conduit à des imbrications de tests qui peuvent être tellement difficiles à suivre qu'on considère souvent que c'est une exception à la règle de la programmation stricturée. Par exemple, une fonction de copie de fichier en programmation stricturée donne ceci (pseudo-code; désolé, blogger s'obstine à supprimer mes indentations):
CopierFichier(String Src, String Dst)
HSrc=openFile(Src)
if(HSrc!=0)
{
HDst=createFile(Dst)
if(HDst!=0)
{
CopyContent(Src,Dst)
closeFile(HDst)
Error=NO_ERROR
}
else
{
closeFile(HSrc)
Error=ERROR_DST
}
}
else
{
Error=ERROR_SRC
}
return Error

Pour cette petite procédure, vérifier simplement et rapidement que le code est correct - et c'est rappelons-le ce pourquoi on utilise la programmation stricturée et non des GOTO - n'est pas tout à fait évident. Ceux qui ne sont pas d'accord ont tort d'ils n'ont pas vu que la procédure ne refermait pas le fichier source ;-)
Les exceptions permettent, d'une manière discutable il est vrai, d'éviter ces imbrications car les fonctions qui n'interceptent pas les exceptions des fonctions qu'elles appellent se voient terminées. En s'y prennant bien, on peut n'avoir localement qu'un seul chemin d'execution, tandis que dans notre exemple il y en a de multiples suivant les cas de figure, ce qui rend la vérification de notre exemple un peu difficile.
On peut avoir recours à diverses astuces pour éviter ces imbrication; mon exemple à cet égard est un peu forcé. Mais il n'existe pas dans la plupart des langages de mécanisme générique pour "casser" l'execution d'une procédure et passer à son épilogue (la partie un peu avant la fin faisant le nettoyage avant de quitter la fonction). Les seuls moyens à disposition sont de faire un "return" au beau milieu de la procédure ou d'avoir recours à un "goto".

L'examen de ces deux méthodes permet de tirer quelques conclusions.
D'abord que quelque soit la méthode utilisée, il n'y a pas de miracles: il faudra toujours à un moment ou un autre examiner l'erreur et y apporter la réponse ad hoc, ce qui implique la mise en place de structures équivalentes à un switch..case.
Ensuite que faire abstraction du principe qu'il faut vérifier et traiter une erreur le plus tôt possible est dangereux. Ignorer même temporairement une erreur la possibilité une opportunité de création de bogue, menace qui pèsera sur la maintenance ou l'évolution du programme.

La méthode de gestion d'erreur à privilégier est donc pour moi celle des codes d'erreurs. Un langage de programmation peut apporter son assistance en y apportant quelques éléments tirés pour certain de l'expérience avec les exceptions.
Le premier élément est l'utilisation d'une syntaxe spécifique pour l'examen des cas d'erreur, une structure similaire à un switch...case mais qui permettra dans un premier temps de "démêler" valeur de retour et signalement de l'erreur; de plus étant spécialisée, elle sera syntaxiquement plus "légère" que son modèle.
Mais surtout elle permettra la mise au point d'un dispositif de vérification de la vérification des erreurs (sic) par le compilateur, à la manière des "checked exceptions" de Java. Il serait possible de faire en sorte que le compilateur déduise du code les erreurs que peut retourner une procédure dans notre cas, mais demander leur déclaration manuelle est sans doute plus simple (pas seulement pour le compilateur) et meilleur.
Enfin, éviter le "motif de la flèche" demande la mise en place d'une construction permettant la définition d'un bloc de code, simplement pour pouvoir interrompre l'execution de ce bloc en cas d'erreur.


dimanche 18 janvier 2009

Basic

Le langage Basic a été créé au milieu des années 60 pour permettre aux étudiants non-scientifiques de programmer un ordinateur. Autrement dit, un langage de programmation pour les non-programmeurs.

On peut dire que le langage a remplit sa mission, puisque (avec peut-être un petit coup de pouce de Bill)  il est devenu le langage d'apprentissage par excellence.

Le premier impératif de ce type de langage est d'être simple. Mais qu'est ce que la simplicité? La théorie a une définition interessante, car elle utilise justement la notion de programme pour quantifier la complexité d'une entité, d'un objet ou d'une donnée: c'est la taille minimale du programme qui produit la donnée ou une description de l'entité considérée. Seulement, on sait qu'un tout petit programme peut être long à s'executer et produire une donnée particulièrement complexe (on peut penser à un programme de décompression par exemple); ce qui amène à considérer le temps d'éxécution de ce programme, et à la distinction deux types de complexités: la complexité dite "aléatoire", qui est en quelque sorte liée à la quantité de données que porte l'objet, et la complexité logique, qui représente le degré d'élaboration de l'objet, c'est à dire en quelque sorte la quantité de travail qu'a nécessité sa construction.

La théorie parle d'un programme, donc d'un langage de programmation. Quel langage utilise-t-elle? C? Basic? Haskell? Celui de la machine de Turing? En fait, peu importe, il suffit d'utiliser toujours le même langage pour pouvoir faire des comparaisons. 

Puisque le source d'un programme est une donnée (textuelle), on peut appliquer cette définition aux programmes eux-mêmes. En particulier, on peut utiliser cette métrique pour un même programme écrit dans différents langages. On devrait mesurer ainsi la complexité relative de différents langages. Toutefois, cela revient plu ou moins à compter les lignes de code, autrement dit au fameux indice "SLOC" (Source Lines Of Code), dont on sait qu'il doit être utilisé avec prudence.

Compter les lignes que l'on doit écrire dans un langage pour obtenir un résultat simple comme afficher "Bonjour tout le monde" est quand même révélateur de certaines choses intéressantes. Cela sépare grosso modo les langages en deux catégories:

* les langages qui le font en une  ligne,

* les langages qui le font en plus de lignes.

La théorie et l'expérience concordent: les programmes de la seconde catégorie, qui consistent généralement en l'instruction "afficher 'bonjour'" entourée de lignes de code dont l'utilité est plus ou moins claire pour un non-initié, sont subjectivement et objectivement plus complexes.

Une autre façon de comparer les langages plus qualitative que quantitative est de dénombrer le nombre de concepts utilisés de manière plus ou moins directe dans ces programmes. Basic par exemple, n'en utilise que deux: le concept d'instruction, et le concept de chaine de caractère. Un langage à objets un tant soit peu rigoureux utilise en plus la notion de classe (ou de méthode). Un langage purement fonctionnel utilisera une monade ou imposera de définir une fonction.

Du point de vue du non-programmeur ou du débutant, un langage appartenant à la première catégorie comme Basic semble préférable, parce qu'il permet d'atteindre un objectif de manière simple. Pourquoi s'encombrer de lignes ou de concepts sans rapport direct avec le problème à résoudre? Pourquoi le langage se jette-t-il en travers du chemin?

Il est évident que les langages sont rarement conçu pour être gratuitement complexes. Les complications qu'ils introduisent ont pour but de se conformer à un paradigme, qu'on estime profitable à moyen ou long terme de suivre. Les concepts imposés qui peuvent paraître a priori superflus sont en général des investissements visant à améliorer certaines qualités internes du programme, telles que la fiabilité ou la maintenabilité sur le long terme. 

Mais surtout, ces "complications" permettent d'aller plus loin avec le langage. La théorie dit que tous les langages qui sont capables de simuler une machine de Turing sont tous capables de faire la même chose. Et donc, le langage de la machine de Turing est aussi capable de réaliser les mêmes choses qu'un langage évolué comme Haskell, Lisp ou SmallTalk. On réalise immédiatement ce que cela vaut en pratique: il serait extrêmement difficile par exemple, de trier un tableau de chaînes de caractères avec une machine de Turing. Le même inconvénient frappe plus ou moins les langages simples comme Basic: la réalisation des fonctionnalités devient de plus en plus difficile à mesure que ces fonctions deviennent évoluées. Les langages simples "décrochent", tandis que les langages plus complexes peuvent suivre l'évolution des besoins de l'utilisateur. La comparaison des exemples de programmes "Hello, world" avec ceux réalisés pour un programme un peu plus élaboré comme "99 bouteilles de bière" esquisse un peu les limites de la simplicité à tout prix.

Ces qualités ne sont pas sans interêt pour le non-programmeur. Obtenir un programme qui marche dans les cas prévus est le premier objectif; mais il appréciera sans doute aussi que celui-ci ne plante pas lamentablement en cas d'erreur de saisie. Et il appréciera aussi de pouvoir le faire évoluer. Par définition, un non-programmeur n'a aucune idée de la façon d'obtenir ces qualités - voire à l'extrême, il n'en a pas toujours conscience a  priori. Enfin, le non-programmeur devient, par la force des choses, un peu programmeur et est logiquement amené à faire des programmes plus ambitieux à mesure que ces compétences s'améliorent. Un "guidage" du langage peut être un élément de satisfaction vis-à-vis du langage ("ce langage est super car il sûr et évolutif"), même s'il recent ce guidage comme une contrainte ou un obstacle.

Un exemple emblématique de cela est l'instruction GOTO de Basic, qui est incomparablement plus séduisante que la programmation structurée (voire "stricturée"), mais qui, pour le moins, à conduit plus d'un programme "fait à la maison" à l'effacement du fait qu'il n'était plus possible de le modifier sans engendrer une nuée de bogues. Dans le même registre mais de manière un peu plus subtile, certains estiment que les exceptions, un moyen relativement intuitif et séduisant pour résoudre le problème de la gestion d'erreur, sont des GOTO (à peine) déguisés.

Est-ce à dire que les langages "compliqués" seraient en réalité plus adaptés aux non-programmeurs? Il suffit de prendre un exemple extrême comme le langage Haskell, ou d'une autre manière, Forth, pour se convaincre du contraire. L'un est un langage d'universitaire, l'autre un langage de "vrai" programmeur. Ces langages semblent empreinter des voies très détournées pour atteindre l'objectif de faire un programme qui execute une tâche déterminée. Certes, ces langages "à forte personnalité" excellent à atteindre les objectifs secondaires pour lesquels ils sont construits (forte optimisation et simplicité de conception pour l'un, formalisme rigoureux pour l'autres), et ses objectifs secondaires sont sensés aider à atteindre le but principal et/ou maximiser une ou plusieurs des qualités évoquées plus haut.

Mais le revers de la médaille est que cette volonté d'excellence dans ces domaines secondaires obligent à sacrifier certaines aides directes à l'accomplissement du projet principal (syntaxe inhabituelle ou cryptographique, "surcharge" de concepts ou de connaissances prérequises), alors que l'utilisateur n'a pas forcément besoin d'une extrême fiabilité, optimisation ou évolutivité. Cette excellence est ce qu'on appelle de la "surqualité", une qualité ou niveau de qualité qui n'était pas requis initialement, et qui est en fait néfaste car elle entraîne toujours sous une forme ou une autre un coût qui n'est pas justifié (du point de vue du client ou de l'utilisateur final).

Un compromis entre une simplicité qui rend le langage accessible au premier abord et une complexité qui le rend utilisable sur le long terme est donc à trouver. La formule de ce subtil équilibre a déjà été en grande partie par le langage Lisp:

  1.  les clôtures ("closures"), des entités qui peuvent être difficiles à comprendre mais qui s'avèrent très utiles. Elles permettent notamment de construire un système orienté-objet si on le souhaite (voir CLOS).
  2. un système de "macros" qui tient la route (celui de C/C++ est à côté une mauvaise plaisanterie), et qui permet de rendre plus expressif le langage quand les choses se compliquent.   

Mais il a un défaut majeur: le manque de lisibité de sa syntaxe préfixe et sa célèbre surabondance de parenthèses. La lisibilité d'un langage est une qualité qu'on sous-estime ou sacrifie trop souvent. Un langage lisible permet de communiquer clairement le modus operandi d'un programme. On utilise souvent du "pseudo-code" à cette fin plutôt que d'utiliser le langage d'implémentation, et c'est à mon sens à porter au passif dudit langage. Un langage clair devient un langage de communication aussi avec les autres utilisateurs, plus approprié que la langue naturelle qui parfois trop floue ou ambigüe (pour ne pas paraphraser: "mieux vaut un petit programme qu'un long discours"), ce qui facilite l'apprentissage et permet la coopération.