Contre-intuitivité

Créé le 2010-11-29 et modifié le 2010-12-10 par André Gillibert

L'univers grouille de standards de faits tellement universels qu'ils échappent souvent à la conscience. Ceux qui nous intéressent aujourd'hui sont les standards pour les langages de programmation, dont beaucoup sont hérités des mathématiques. Ainsi, un nombre en chiffres arabes est habituellement, s'il n'a ni préfixe ni suffixe, interprété comme une constante décimale. L'opérateur '+' est un opérateur binaire effectuant une addition, et de priorité inférieure à '*'. Les operateurs 'and', 'or' et 'not' obéissent aux règles de la logique. Une fonction appliquée à une expression, opère sur l'expression évaluée.

Malheureusement, comme ces standards ne sont pas formalisés, il sont parfois outrepassés, peut-être parce que le créateur du langage s'imagine que cela sera à l'origine d'un langage plus propre et donc plus "intuitif". En réalité, ces notions sont ancrées tellement profondément dans l'esprit des programmeurs, tellement répétés dans tous les autres langages que ces éléments contre-intuitifs deviennent une source fréquente de bogues. Avec la pratique, on s'habitue à un tel élément, mais, dès que l'on s'écarte quelques semaines ou mois du langage, pour pratiquer quelques autres systèmes, on est condamné à retomber dans les pièges les plus évidents lorsque l'on en revient au langage fautif.

Quoi de plus frustant que de s'apercevoir qu'un bogue ne vient que d'une bizarrerie syntaxe n'existant nulle part ailleurs que dans le langage employé ?

Déréférencement automatique des constantes

La syntaxe assembleur AT&T, celle de GNU as, est une horreur sans nom. Alors qu'Intel avait défini une syntaxe parfaitement claire et intuitive, des imbéciles se sont imaginés qu'ils pouvaient la rendre plus claire, régulière et "compatible" avec l'assembleur d'autres machines, en transformant:

mov eax,[ebx + 4*ecx + 16]

En

mov 16(%ebx,%ecx,4), %eax

Les changements par rapport à la syntaxe Intel sont:

On se fait très vite à toutes ces règles un peu bizarres, sauf la dernière. Après tout, index[pointeur] n'a jamais eu qu'une seule signification en C ou en assembleur Intel, et on lui donne la même syntaxe et signification en assembleur AT&T.

Mais, le fait que:

mov 3, %eax

Soit en réalité:

mov eax, [ds:3]

Est une abomination ! À chaque fois que je fais un programme en assembleur AT&T, la cause d'un SIGSEGV n'est pas un bogue du code que je crus écrire, mais est liée à cette syntaxe ridicule !

Pour mettre 3 dans eax, il faut écrire:

mov $3, %eax

Franchement, imaginez la même syntaxe en C:

int x;
x =  42; /* SIGSEGV: 42 signifie en réalité (*((int*)42)) */
x = &42; /* Okay, stocke 42 dans x */

Opérateurs logiques en Basic

Pourquoi le Basic (VB.NET n'est pas du Basic, c'est du Visual Fred) n'a-t-il pas d'opérateurs AND, OR et NOT logiques ? Des imbéciles se sont imaginés qu'en statuant que TRUE == -1 et FALSE == 0, les opérateurs travaillant sur les bits individuels auraient le même effet que des opérateurs logiques. Je suppose qu'ils s'imaginaient qu'il est désirable de réduire le nombre d'opérateurs pour faciliter l'apprentissage du langage. Ironie du sort, il devient quasiment impossible de s'adapter à cette logique contre-intuitive !

En effet, il est tout à fait habituel de programmer ainsi:

If condition1 Then Something Else AnotherThing

Où condition1 peut renvoyer zéro en cas d'échec de la condition, et tout autre valeur en cas de succès. Tout marche bien jusqu'au jour ou on décide d'inverser la condition, alors qu'on avait oublié le fait qu'elle pouvait renvoyer autre chose que True ou False.

If Not condition1 Then AnotherThing Else Something

Maintenant, si condition1 == 2, AnotherThing est exécuté. Je suis sûr que le démon du Basic ricane à chaque fois que quelqu'un fait ça.

Avec de l'entrainement, on devient prudent et on peut évite ce genre de problème, mais, pourquoi faut-il que l'on en arrive là, alors qu'en C, langage réputé pour être difficile, on n'a jamais ce problème!

L'addition surchargée

Les langages dynamiques comme le Perl, le JavaScript, le python, offrent la possibilité de manipuler des expressions susceptibles de renvoyer dynamiquement un nombre ou une chaîne de caractères. Très souvent, la logique du programme est telle que ces différents types de valeurs ne sont pas mélangés, mais, lorsqu'elles le sont, des résultats contre-intuitifs sont renvoyés.

Prenons JavaScript comme exemple. La concaténation de chaînes de caractères est obtenu par l'opérateur '+'. Un autre opérateur aurait tout aussi bien fait l'affaire, mais ça semble correct puisque, de toute façon, on ne peut pas additionner deux chaînes de caractères.

L'opérateur '+' est tellement souvent utilisé pour la concaténation de chaînes de caractères que certains lecteurs de cette page Web pourraient même aller jusqu'à dire que la concaténation de chaînes de caractères est une forme d'addition, et pourraient sortir comme exemple une fonction d'accumulation, faisant la somme de tous les éléments d'une liste et renvoyant le résultat, aussi bien utile pour les chaînes de caractères que pour les nombres. Mais, cela ne prouve rien d'autre que la concaténation est un opérateur binaire associatif, et par le même raisonnement on en arriverait à dire que les opérateurs d'addition et de multiplication sont identiques pour les nombres! En fait, on voudrait bien passer l'opérateur de multiplication à une fonction d'accumulation. Donc, une fonction d'accumulation digne de ce nom, doit accepter une fonction/opérateur binaire en paramètre.

En JavaScript il existe des conversions implicites entre chaînes de caractère et nombres. En perl, ça va tellement loin, que l'on peut conceptuellement considérer "42" et 42 comme le même scalaire, ce qui libère l'esprit d'un fardeau colossal et permet de se concentrer sur la logique du programme.

Puisque les conversions sont implicites, il y a conceptuellement une certaine équivalence entre "42" et 42, et on s'attendrait à ce que les opérateurs respectent cette équivalence.

Malheureusement, en JavaScript, ce n'est pas le cas, de telle sorte que l'opérateur '+' n'est même pas associatif!

(42+42)+"42"	// -> "8442"
42+(42+"42")	// -> "424242"

// Différence de comportement entre + et -
42+"41"		// -> "4241"
42-"41"		// -> 1

Si une des deux opérandes de l'opérateur '+' est une chaîne de caractères, l'opérateur effectue la concaténation de chaînes.

Hereusement, dans l'immense majorité des cas on ne mélange pas les nombres et les chaînes de caractères, de telle sorte que l'on peut même ignorer ces règles. Mais, dans de rares cas, ça peut faire très mal. Rare est synonyme de découverte tardive et douloureuse quand il s'agit de bogues.

En en revenant à la fonction d'accumulation, supposez que l'on applique cette fonction à un mélange quelconque de nombres et de chaînes. Alors que l'on souhaiterait, à priori, obtenir une chaîne de caractères correspondant à la concaténation de toutes les valeurs, les résultats seront étonnants si plusieurs nombres se trouvent en début de liste.

accumulation(42, 42, "bonjour", 42, 42) // -> 84bonjour4242

En fait, en cas de mélange de nombres et chaînes, le JavaScript a une chance sur deux de faire l'opération que le programmeur voulait, et une chance sur deux de faire l'autre. À ce stade, il eût été bien plus sage de générer une erreur "type mismatch" comme le font python et ruby.

Le comportement de python et ruby évite que les pires bogues passent inaperçus, mais obligent le programmeur à différencier mentalement les chaînes de caractères des nombres, ce qui est assez fatiguant lorsque l'on code un petit script simple.

Comment le perl évite-t-il le pire tout en obligeant pas le programmeur à surveiller le type de chaque variable ? Très simplement, '+' correspond toujours à l'addition tandis que '.' sert à la concaténation:

 42  .  42	# -> 4242
"42" + "42"	# -> 84

Perl est un langage complexe, mais au moins cette fois, il respecte le principe "Do What I Mean", sauf dans les cas où la chaîne de caractère n'est pas une valeur numérique valide, au quel cas la conversion ne considère que les chiffres initiaux.

En lua, comme en Perl, les conversions sont implicites et l'operateur de concaténation est séparé, mais les conversions de chaîne vers nombre échouent en cas de format invalide de la chaîne.

Un problème similaire existe pour l'opérateur d'égalité de valeurs. Le perl distingue == (égalité numérique) de eq (égalité de chaînes), ce qui est très commode.

La priorité des opérateurs

Le Smalltalk est le seul langage que je conaisse à ne pas respecter les règles de priorité des opérateurs:

1 + 2 * 3	-> 9 en Smalltalk

Pourquoi cette syntaxe pose-t-elle un véritable problème au cerveau humain ? Parce que ça ressemble fichtrement à une expression familière à notre cerveau, mais, ce n'est qu'une apparence, puisque se cache derrière, un système de messages n'ayant aucun rapport avec les opérateurs binaires mathématiques. La syntaxe, certes non intuitive, (* (+ 1 2) 3) du Lisp/Scheme, semble d'abort étrange, mais s'apprend rapidement, et finalement ne pose aucun problème.

Et pourtant le Smalltalk est censé être un langage réfléchi, cohérent, régulier. Il est vrai que c'est bien plus logique, du point de vue de l'ordinateur, de traiter tous les opérateurs et fonctions de la même manière, mais pas du point de vue de l'esprit humain. De plus, le fait que 1+2 envoie le message "ajoute toi à 2" à 1, obligeant 1 à accéder aux tripes de 2 par une interface publique, est censé être intuitif, orienté objet, beaucoup plus logique que dans tous les autres langages, mais, en réalité, ne fait que montrer un abus horrible du concept du "tout objet". Pas étonnant qu'avec cette mentalité, sacrifiant le savoir humain acquis sur l'autel d'une cohérence interne douteuse, toutes les implémentations de Smalltalk qu'il m'ait été donné de tester sont lentes ou s'intègrent mal au système, ce qui explique aussi pourquoi ce langage n'a jamais dépassé le stade de jouet, et ne se retrouve dans prèsque aucun projet sérieux.

J'avoue n'avoir pas utilisé Smalltalk suffisamment longtemps pour estimer les conséquences de ce "bogue". D'une manière générale, je n'utilise que des langages dont l'implémentation est de qualité correcte.

L'assignement en C

En C, il m'arrive encore parfois d'utiliser accidentellement l'assignement "=" pour la comparaison de valeurs, normalement "==", en général après avoir pratiqué d'autres langages pendant un certain temps.

L'emploi de l'assignement au milieu d'expressions est très commode en C. L'opérateur == n'est pas non plus contre-intuitif pour la comparaison. Mais, par contre, "=" est tellement souvent utilisé pour la comparaison dans d'autres langages, dont les mathématiques, que "if a égal b", sonne vraiment comme une comparaison dans mon cerveau. Si le C avait choisi un autre opérateur pour l'assignement, qu'il fût ":=" ou "<-", ce type d'erreur disparaîtrait.

Je traite ce cas en dernier parce que ce n'est pas non plus l'élément le plus contre-intuitif qui soit. Après tout, l'opérateur "=" est utilisé pour l'assignement dans de très nombreux autres langages.

Conclusion

Je ne crois pas que les créateurs de langages aient tous vraiment compris que l'intuitivité d'un système est plus important que sa simplicité interne. Il est certainement moins gênant de faire une exception dans la grammaire d'un langage pour les opérateurs mathématiques que d'imposer l'idée que 1+2*3 égale 9.