Tak tak, aujourd’hui Ours répond ! Quelqu’un m’a demandé hier pourquoi
mon code contenait autant d’instructions goto, la malfamée. Comme
c’est une question qui revient souvent, je me suis dit que cela
pouvait intéresser notre audience moins expérimentée en C. Here
comes!
Ahem, commençons. goto, c’est un truc un peu vite fait obscur du C,
un peu comme les trucs Caml obscurs de bluestorm mais
quand même pas au même niveau de louchitude. goto, ça déchaîne les
jeunes comme les vieux, tout ça au final pour une pauvre
instruction qui sautille. Bref, goto ça transporte le flot
d’exécution d’un endroit à un autre d’une fonction. Ça, c’est la
description objective, notre ptit Get the Facts avant
d’embrayer sur la vraie question : pourquoi diable utilise-t-on
goto ? Je ne pourrai pas répondre pour « on » mais je peux répondre
pour moi !
goto est avant tout l’instrument du pauvre. Voilà qui est dit. La
raison principale derrière l’utilisation de goto est que celui-ci
permet d’émuler des flots d’exécution atypiques et indisponibles en
C. On parle souvent de gestion des erreurs, d’exceptions du pauvre ;
ce n’en est qu’un cas particulier. L’idée générale est celle que je
viens d’énoncer : goto permet d’écrire des programmes C avec des
structures non présentes directement dans le langage.
Lol le C, c’est pour les ptits joueurs, ya pas d’exceptions
Quelques exemples maintenant, avec tout d’abord le grand classique : la sortie de boucles imbriquées et la gestion des erreurs. Pourquoi ai-je donc regroupé ces deux cas qui apparaissent distincts ? Car ils modèlent tous deux un type de flot permis par les exceptions dans les langages qui en possèdent : on dispose de contextes imbriqués desquels on veut se défaire en un saut.α
α : Notez que je n’ai pas dit que les exceptions pouvaient être
émulées par des goto, juste qu’il y a des ressemblances dans le
flot obtenu.
Jetons un œil à deux bouts de code. La sortie de bloc, tout d’abord :
{
while (a) {
while (b) {
if (c)
goto end;
...
}
}
end:
...
}
Et la gestion des erreurs, dans sa forme modelant une pile :
{
if ((a = f(...)) == NULL)
goto bada;
if ((b = g(...)) == NULL)
goto badb;
...
return ...;
badb:
antif(a);
bada:
return ...;
}
Le premier exemple ne devrait demander aucun commentaire. Le second n’est pas aussi évident : il n’exhibe pas clairement l’idée de contextes imbriqués. Celle-ci est en vérité masquée par le court-circuitage introduit par les sauts. Nous aurions pu écrire :
{
if ((a = f(...)) != NULL) {
if ((b = g(...)) != NULL) {
...
} else {
antif(a);
goto bada;
}
} else {
bada:
return ...;
}
return ...;
}
OK, le tout s’est sensiblement enlaidi, et je vous rassure, personne
ne l’écrit ainsi (car en plus d’être laid, on effectue beaucoup plus
de sauts en cas d’erreur). L’avantage de cette forme, cependant, est
qu’elle modélise beaucoup plus précisément le comportement d’une
exception dans un langage de plus haut niveau : chaque bloc if
correspondrait à un bloc try (ou équivalent) avec une clause de
finalisation (contenue dans la partie else). Lorsqu’une condition
exceptionnelle se produit, les clauses de finalisation sont déroulées
les unes après les autres, dans l’ordre d’empilement des contextes
(représentés ici par les blocs).
À part remarquer que la gestion des erreurs, cas normal d’utilisation
des exceptions, n’est pas celui qui possède la correspondance la plus
naturelle avec l’utilisation de goto, nous n’avons plus grand chose
à dire ici. Passons donc à…
Plus tiré par les cheveux (encore) : appels récursifs terminaux
Si vous jetez un œil aux codes sources que j’écris, il arrive que
j’utilise des goto à la place de boucles classiques. Et oui,
sacrilège § L’explication ? ’Tention, c’est tordu : cela modélise des
appels récursifs terminaux simples.β
L’exemple typique est celui d’une fonction :
{
T x;
re:
if (...)
...
if (...)
...
if (...) {
x = f(x, ...);
goto re;
}
}
Alors oui, on pourrait bien sûr écrire tout ça sous forme d’une boucle ou carrément utiliser l’appel récursif et prier pour que le compilateur C optimise.γ
C’est en vérité une question d’emphase, et ces questions là sont
profondément subjectives. Une boucle mettrait moins l’accent sur
l’action et davantage sur sa répétition. Dans le cas où le cas
récursif est marginal, la construction s’en retrouve relativement
maladroite : on a alors le choix entre une condition de boucle obscure
et redondante, des marqueurs booléens dans tous les sens, ou des
instructions break et continue judicieusement placées. En
choisissant cette dernière solution, qui semble la moins
contraignante, on se retrouve avec un code dans ce genre-là :
{
T x;
for (;;) {
if (...)
...
if (...)
...
if (...) {
x = f(x, ...);
continue;
}
break;
}
}
Mieux ? Je ne crois pas, mais je n’irai pas non plus cracher sur celui qui écrit cela. :) Tout cela au fond pour une question de…
β : Là aussi, soyons fous mais pas trop, on ne peut pas émuler la
récursivité terminale dans le cas général avec goto.
γ : GCC le fait, m’enfin, d’autres compilateurs ne le font pas forcément, ce n’est pas une optimisation très orthodoxe en C comme elle peut l’être dans les langages fonctionnels, l’idiome étant en Cδ de tout convertir en style itératif.
δ : Mais rms a trop fait de Lisp, comme tout le monde sait. Lalala.
Lisibilité !
C’est là que les gens se fâchent, que les regards se clashent, que
la violence se lâche, wash wash. Hum, les uns disent de ne pas
utiliser goto, pour la clarté du code, et pour les mêmes raisons,
d’autres l’utilisent ! C’est la vie. Après tout, il y bien des gens
qui codent en style GNU en pensant se rendre lisibles. :)
La question de la lisibilité de goto tient principalement à sa
nature la plus primaire : la possibilité de passer librement d’une
portion de code à une autre. Les détracteurs de goto pointent du
doigt les utilisations de l’instruction qui engendrent des transitions
difficiles à suivre dans le flot. Ceux qui en défendent le gain visuel
évoquent la possibilité offerte par goto de déporter des blocs de
code d’un endroit à un autre. Cette astuce est principalement utilisée
pour la gestion des erreurs et permet ainsi de dégager du traitement
utile la gestion des cas de défaillance, rares en pratique et souvent
triviaux (l’action à entreprendre étant, la plupart du temps, de faire
remonter l’information telle qu’on la reçoit).
Pour conclure sur un dernier exemple, j’abuse souvent de cette propriété pour écrire des choses comme celles-ci :
{
T *p;
R *q;
p = malloc(sizeof *p);
q = malloc(sizeof *q);
if (p == NULL || q == NULL)
goto bad;
...
return ...;
bad:
free(q);
free(p);
return ...;
}
Mon style vous répugne ? N’hésitez pas à commenter. :)
Annexe : setjmp et ses copains
Si, de mon expérience, goto est généralement relativement bien reçu
dans la communauté des programmeurs C dans son ensemble (excepté dans
certains cercles, et dans l’enseignement, sans doute de peur de
pervertir les étudiantsε), il en est autrement de setjmp, le
« goto non local ».ζ
Il y a probablement plusieurs raisons à cela : la difficulté
d’utilisation (cf. restrictions énoncée par la norme, dont la
nécessité de déclarer toutes les variables locales manipulables après
le saut volatile), mais aussi les divers problèmes lié à la
conservation et la restauration des états : il faut pouvoir replacer
de manière sûre le contexte au point où il était au moment de l’appel
à setjmp. Cela implique la libération de mémoire et l’appel de
destructeurs pour les données allouées dans l’intervalle, mais
également des notions de plus bas niveau telles que la gestion des
signaux (cf. sigsetjmp(3)). En plus de ça, setjmp avec toute
cette complication engendre des performances moisies, en général.
setjmp est toutefois utilisé, parfois, pour implémenter des
mécanismes d’exceptions, notamment dans la kazlibη (pour le C) et
diverses implémentations portables de langages dynamiques
(l’interpréteur interactif d’OCaml, si je me rappelle bien, et
d’autres dont je ne me rappelle plus…).
ε : Il paraît que ce sont les éléments perturbateurs tels que moi qui tentent leurs petits camarades avec le côté obscur. :)
ζ : Notez que GCC possède également des goto non locaux et
indirects grâce aux pointeurs d’étiquettes. Je ne m’étendrai pas
à ce sujet (car je connais mal le GNU C). Plus d'informations dans
le manuel de GCC.
η : Je ne vois plus dans Google la page Web correspondant. Le projet est peut-être bien décédé…
Annexe : omg l’optimisation qu’elle est bonne
On entend dire parfois que goto c’est pas bon pour les
performances. Bon alors, dans l’absolu, hum, en fait il n’y a pas
d’absolu. On pourrait dire que remplacer des structures de contrôle
classiques par goto enlève de l’information sémantique, et bon, dans
l’absolu, c’est vrai. La question est de savoir si cette information
sémantique est pertinente d’une part, et si elle est utilisée, d’autre
part. Question pertinence, c’est, là encore, un peu subjectif : est-ce
que tel ou tel goto remplace simplement une combinaison intelligente
de structures de contrôle ou ne reflète-t-il un concept absent du C ?
C’est davantage une question philosophique, donc humaine, que
d’importance à la machine… car en vérité, la plupart des
compilateurs modernes ne tiennent absolument pas compte de ce genre de
choses, et le langage intermédiaire employé n’emploie que des
sauts. Ce sont ensuite les algorithmes d’analyse de flot (brrr,
théorie des graphes) qui ont à leur charge de différencier les blocs
et autres constructions utiles au compilateur.
# asmanur
09.09.09, 20:36.
return f(x, ...)? (En fait c’est le seul usage que je trouve vraiment pas orthodoxe, je pense que dans la vraie vie j’aurais été « wtf ? », on dirait vraiment un while du pauvre). Bon après c’est sûr que si tous les compilateurs n’ont pas le support de la tail-rec … (ICC l’a ?)# rz0
09.09.09, 21:36.
Bah ça apporte que c’est tail-recifié à la main quoi. :D
Mais hum, pour la question du support par les compilateurs, ICC et tout ça, en fait, c’est plus compliqué que ça. Fondamentalement, la convention d’appel que l’on a sous x86 (et probablement ailleurs, à cause des fonctions variadic, sauf si on fait deux conventions, une pour les fonctions à nombre arguments fixes et une pour les variadic) rend les tailcalls impossibles à faire dans le cas général. Parce que la convention dit que celui qui appelle est celui qui nettoie, donc en gros après chaque appel tu te coltines du code de nettoyage de la pile. :p Donc même les compilos qui le supportent ne le supporte que dans les cas favorables (où, par hasard, le code de nettoyage de la pile pour la fonction courante est le même que celui pour la fonction appelée). En ce sens, la plupart des compilos n’essaient probablement même pas (là je viens de faire un essai avec ICC, bah il ne fait rien dans le cas général).
En revanche, lorsque l’on a connaissance de toutes les fonctions mises en jeu (ce qui est le cas d’ICC qui sait effectuer des optimisations globales inter-modules), il est possible de transformer les appels terminaux en boucles. Et ça, GCC et ICC (et probablement d’autres compilateurs dotés de modules d’optimisation avancés) le font. Donc mon cas simple de
gotoserait pris en charge, très probablement.En revanche, PCC par exemple, qui est un compilateur (presque) dénué d’optimisations, ne prend pas bien ce genre de choses. Bien sûr, il ne faut pas se laisser dicter sa conduite par l’outil, mais à mon avis, si le C a survécu si longtemps, c’est aussi par son pragmatisme : il est « suffisamment proche » de la machine pour pouvoir écrire du code « suffisamment rapide », étant donné une implémentation (fictive) naïve. C’est cela que les gens abrègent souvent abusivement « le C est un langage de bas niveau lol, c’est pour ça, c’est rapide, tak tak ».
Bref, sinon, moi je fais ça plus pour des questions d’organisation du code. Quand tu as hum, un truc type analyseur de données (quelles qu’elles soient, textuelles ou pas) qui fait un gros
switchsur l’entrée pour déterminer sa prochaine action, une modélisation proche de ça, c’est l’automate à états finis… qui s’implémente bien par de la récursivité terminale ! Enfin, ça reste des automates du pauvre (c’est-à-dire des espèces de pseudo-automates à un seul état :-°), quand j’utilise ce genre de cheap tricks ; sinon, il y a des manières plus élégantes et plus idiomatiques de représenter tout ça.# Sprank
10.09.09, 02:01.
# delroth
10.09.09, 12:31.
# rz0
10.09.09, 13:05.
Sprank > Pour l’histoire du
switchdans une boucle, c’est du même ordre que les boucles imbriquées, et je n’ai pas trouvé nécessaire d’énumérer un cas de plus.Merci pour les corrections.
# bluestorm
10.09.09, 15:24.
J’ai bien aimé l’article.
Pour la tail-rec, dans le cas où tu peux avoir plusieurs actions entrelacées (les fonctions mutuellement tail-rec dans un langage fonctionnel), tu te retrouves soit avec plusieurs
goto, soit avec la bouclefor (;;)et unswitchà l’intérieur. Les deux sont un peu plus laids que la solution fonctionnelle parce que tu as forcément une instruction supplémentaire pour les cas de terminaison sans saut (soit unbreak;, soit ungoto end;).Je trouve que la solution par
switchévolue mieux en pratique : tu te retrouves naturellement à modéliser ton état courant par une valeur du programme, au lieu que ce soit juste une propriété implicite du flot de contrôle, et c’est utile quand tu te retrouves à factoriser et effectuer certain traitements dans des sous-fonctions dont le comportement dépend de l’état courant.Après, on peut aussi s’en sortir dans le cas GOTO (on sait toujours statiquement quel est l’état courant, donc on peut le préciser en paramètre à l’appel de l’hypothétique sous-fonction), et ça doit être plus performant dans le cas courant (vu qu’il ne demande pas d’écriture/lecture de l’état), mais j’ai l’impression que souvent quand on a le choix entre une solution implicite et une solution explicite (au niveau des données), la solution explicite survit plus longtemps.
# rz0
10.09.09, 16:13.
Moui, bah la boucle avec un
switchc’est le modèle classique de la machine à états ; mais comme je le disais à asmanur, c’est souvent réservé à des « vraies » machines à états, là je montre un truc plus cheap, clairement, mais bon.Sinon pour les machines à états ya aussi la solution « tableau de pointeurs de fonctions », avec chaque fonction qui retourne l’état suivant, d’une manière ou d’une autre. C’est relativement sexy aussi, et c’est typiquement la solution qui scale le mieux (niveau factorisation / organisation du code, je parle), selon moi.
Note que l’on peut faire ça en effectuant un appel dans chaque cas du
switchmais en pratique, c’est plus encombrant, et mixer les deux ne fait pas forcément très bon ménage (une partie en sous-fonctions, une partie inline dans le corps duswitch).Bref, dans tous les cas, ouais, ces modèles existent.
# Mtzgrmstr
10.09.09, 21:07.
# Cygal
15.09.09, 10:21.
Ouais. Je suis toujours interdit de
gotoen cours mais ça donne un peu envie.Au fait c’est supposé faire quoi
antif()?# rz0
15.09.09, 19:24.
antifc’est « anti-f » donc ça fait l’inverse de la fonctionfquoi. :p# rz0
24.10.09, 23:43.
Ya pas une implémentation des exceptions en C, tout dépend ce que l’on veut. Mais généralement, pour avoir un comportement qui ressemble à ce qu’on trouve en C++ ou ailleurs, on fait ça avec une pile de contextes et un setjmp(). On peut construire cette pile en chaînant des nœuds alloués localement d’ailleurs.
Mais sinon la gestion des erreurs par les codes de retour est la plus courante, oui.
# Zenol
25.10.09, 18:22.
# rz0
25.10.09, 20:10.
Zenol > Oups, il y avait un bug avec les URLs de site dans les commentaires qui a fait que ton dernier commentaire a disparu, désolé. :/ Pour l’instant, j’ai désactivé les URLs de sites en attendant…
Sinon pour les exceptions en C++ c’est pas implémenté directement comme ça ; ’fin, ça utilise des mécanismes de plus bas niveau, mais ça utilise les mêmes concepts oui : ya une pile de contextes stockées sur la pile système, à côté des variables locales et qui indique des choses comme le code qu’il est nécessaire d’appeler pour nettoyer quand on balance une exception, et un pointeur quelque part vers le premier tel bloc ; puis quand tu balances une exception, bah ça y saute, et voilà. Comme tu ferais avec
setjmp()/longjmp()mais en moins lourd/maladroit.# mist
18.03.10, 14:58.
# victor
05.09.10, 12:42.
# webreac
24.11.10, 16:27.
# rz0
24.11.10, 21:23.
Merci pour la précision concernant l’article de Dijkstra, je n’étais pas vraiment au courant. Les aspects historiques ont tendance à m’échapper.
Autrement, ce n’est pas parce que j’écris dessus que forcément je pense que c’est très utile ou très usité en pratique. C’est juste que c’est rigolo d’en parler. :p
À mon avis, pour avoir beaucoup de gotos dans un code, en pratique, il faudrait :
Si on a beaucoup de fonctions, comme, au final, c’est assez souvent le cas, ça a tendance à réduire l’usage du goto de manière assez drastique. Dans mon code, je constate qu’on en retrouve, très naturellement, surtout dans les fonctions d’allocations de ressources, qui concentrent, au final, la majeure partie du traitement d’erreurs liées à l’environnement. Les erreurs relevant de l’interface utilisateur sont souvent gérées par d’autres moyens, et pour les autres cas, il n’y a pas forcément de ressources à libérer. Mais c’est personnel, ça dépend du style et de l’expérience de chacun.
Je n’ai pas 15 ans de carrière derrière moi, et pour le peu que j’ai travaillé, je n’ai pas fait de C… ironiquement, c’est en Java que le goto m’a manqué : comme tu le mentiones, pour générer du code.