[Atom] [Mail] [Twitter]
Liens : C · git · hacks · divers · cabale · à propos +

Le code et ses raisons : goto en C

Le code et ses raisons
>. Le code et ses raisons : typedef en C

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.

[ tag:blog.huoc.org,2009:posts/goto ]
Nhat Minh Lê (rz0).
Le 10.09 2009 à 13:06.
(Modifié le 05.09 2010 à 13:45.)

[>] Commentaires[Atom]

# asmanur
09.09.09, 20:36.

L’article est intéressante et présente un autre avis que les avis communément trouvés sur internet sur le sujet. Ceci dit, la partie sur la récursion terminale est un peu vague ; je vois pas trop ce que le goto apporte ici par rapport à un simple 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 ?)
[ tag:blog.huoc.org,2009-09-09:1252521384.23737 ]

# 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 goto serait 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 switch sur 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.

[ tag:blog.huoc.org,2009-09-09:1252525018.23737 ]

# Sprank
10.09.09, 02:01.

Il y a aussi quand on a un switch dans une boucle et qu'on veuille sortir de la boucle que goto est utile. (En PHP on peut faire ça élégament avec un break 2; :-° )

Sinon quelques fautes :
 - "Je ne pourrai par" -> "Je ne pourrai pas"
 - "on se défaire en un saut" -> "on peut se défaire en un saut"
 - "des notions de plus bas niveau tels" -> "... telles"
[ tag:blog.huoc.org,2009-09-10:1252540889.24696 ]

# delroth
10.09.09, 12:31.

Très bon article, je n'hésiterai pas à le citer dans les futurs trolls/débats sur l'utilité de goto en C :) .
[ tag:blog.huoc.org,2009-09-10:1252578718.24696 ]

# rz0
10.09.09, 13:05.

Sprank > Pour l’histoire du switch dans 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.

[ tag:blog.huoc.org,2009-09-10:1252580720.24696 ]

# 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 boucle for (;;) et un switch à 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 un break;, soit un goto 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.

[ tag:blog.huoc.org,2009-09-10:1252589059.24696 ]

# rz0
10.09.09, 16:13.

Moui, bah la boucle avec un switch c’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 switch mais 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 du switch).

Bref, dans tous les cas, ouais, ces modèles existent.

[ tag:blog.huoc.org,2009-09-10:1252592015.26698 ]

# Mtzgrmstr
10.09.09, 21:07.

Bon article.
[ tag:blog.huoc.org,2009-09-10:1252609678.4587 ]

# Cygal
15.09.09, 10:21.

Ouais. Je suis toujours interdit de goto en cours mais ça donne un peu envie.

Au fait c’est supposé faire quoi antif() ?

[ tag:blog.huoc.org,2009-09-15:1253002914.17766 ]

# rz0
15.09.09, 19:24.

antif c’est « anti-f » donc ça fait l’inverse de la fonction f quoi. :p
[ tag:blog.huoc.org,2009-09-15:1253035494.17766 ]

# 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.

[ tag:blog.huoc.org,2009-10-24:1256420610.25953 ]

# Zenol
25.10.09, 18:22.

*pardon, je voulais écrire C++, et non C ~.~
[ tag:blog.huoc.org,2009-10-25:1256491366.29353 ]

# 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.

[ tag:blog.huoc.org,2009-10-25:1256497826.29353 ]

# mist
18.03.10, 14:58.

Excellent ! Moi à qui on avait toujours dit que goto c’est le Mal, ça fait du bien d’être libéré d’un coup là. :p
[ tag:blog.huoc.org,2010-03-18:comments/1268920721.32740 ]

# victor
05.09.10, 12:42.

« notre ptit Get the Facts avant d’embrailler »
embrayer, mon cher rz0, embrayer ;)
Article passionnant que je n'ai dû googler que 3sec pour retrouver.
[ tag:blog.huoc.org,2010-09-05:comments/1283683334.22329 ]

# webreac
24.11.10, 16:27.

Il me parait utile de citer le lien http://en.wikipedia.org/wiki/Structured_programming et de rappeler que l'article de Dijkstra faisait partie d'une suite d'échanges avec Knuth dans lequel Knuth a gagné. Comme tu l'expliques très bien, le goto est très utile comme ersatz de mécanisme d'exception, il peut être aussi très utile pour le code qui est généré automatiquement (par exemple pour des automates). Je suis contre les règles stupides et j'essaie de mettre des goto chaque fois que cela peux rendre mon code plus lisible. En 15 ans de carrière (dont la moitié en pur développement), j'ai réussi à en placer 3. Avant de travailler, le goto était beaucoup plus fréquents dans mon code (notamment en basic), mais en dehors des cas d'école, le goto est très rarement utile. Ma principale conclusion est que ces discours sur le goto sont stériles.
[ tag:blog.huoc.org,2010-11-24:comments/1290612440.26250 ]

# 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 :

  • appliquer le goto comme gestion d’« exceptions locales » ;
  • et faire peu de fonctions, de sorte à avoir beaucoup de gestion d’erreurs locale à faire.

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.

[ tag:blog.huoc.org,2010-11-24:comments/1290630208.27660 ]

Nouveau commentaire