Efficacité du retour prématuré dans une fonction

97

C'est une situation que je rencontre fréquemment en tant que programmeur inexpérimenté et que je me demande en particulier pour un projet ambitieux et intensif en vitesse que j'essaie d'optimiser. Pour les principaux langages de type C (C, objC, C ++, Java, C #, etc.) et leurs compilateurs habituels, ces deux fonctions fonctionneront-elles tout aussi efficacement? Y a-t-il une différence dans le code compilé?

void foo1(bool flag)
{
    if (flag)
    {
        //Do stuff
        return;
    }

    //Do different stuff
}

void foo2(bool flag)
{
    if (flag)
    {
        //Do stuff
    }
    else
    {
        //Do different stuff
    }
}

Fondamentalement, y a-t-il jamais eu un bonus / une pénalité d'efficacité directe lorsque vous breakêtes en avance ou en returnavance? Comment le stackframe est-il impliqué? Existe-t-il des cas spéciaux optimisés? Y a-t-il des facteurs (comme l'inlining ou la taille de "Do stuff") qui pourraient affecter cela de manière significative?

Je suis toujours partisan d'une meilleure lisibilité sur des optimisations mineures (je vois beaucoup foo1 avec la validation des paramètres), mais cela revient si fréquemment que j'aimerais mettre de côté toute inquiétude une fois pour toutes.

Et je suis conscient des pièges de l'optimisation prématurée ... euh, ce sont des souvenirs douloureux.

EDIT: J'ai accepté une réponse, mais la réponse d'EJP explique assez succinctement pourquoi l'utilisation de a returnest pratiquement négligeable (en assemblage, le returncrée une 'branche' à la fin de la fonction, ce qui est extrêmement rapide. La branche modifie le registre du PC et peut également affecter le cache et le pipeline, ce qui est assez minuscule.) Pour ce cas en particulier, cela ne fait littéralement aucune différence car les if/elseet les returncréent la même branche à la fin de la fonction.

Philip Guin
la source
22
Je ne pense pas que ce genre de choses aura un impact notable sur les performances. Écrivez simplement un petit test et voyez-vous. Imo, la première variante est meilleure car vous n'obtenez pas d'imbrication inutile, ce qui améliore la lisibilité
SirVaulterScoff
10
@SirVaulterScott, à moins que les deux cas ne soient symétriques d'une manière ou d'une autre, auquel cas vous voudriez faire ressortir la symétrie en les mettant au même niveau d'indentation.
luqui
3
SirVaulterScoff: +1 pour réduire la nidification inutile
fjdumont
11
Lisibilité >>> Micro optimisations. Faites-le de la manière qui a le plus de sens pour le wetware qui le maintiendra. Au niveau du code machine, ces deux structures sont identiques lorsqu'elles sont introduites dans un compilateur même assez stupide. Un compilateur optimisant effacera tout semblant d'avantage de vitesse entre les deux.
SplinterReality
12
N'optimisez pas votre projet «intensif en vitesse» en vous souciant de ce genre de choses. Profil de votre application pour savoir où elle est réellement lente - si elle est réellement trop lente lorsque vous avez fini de la faire fonctionner. Vous ne pouvez presque certainement pas deviner ce qui le ralentit réellement.
blueshift

Réponses:

92

Il n'y a aucune différence:

=====> cat test_return.cpp
extern void something();
extern void something2();

void test(bool b)
{
    if(b)
    {
        something();
    }
    else
        something2();
}
=====> cat test_return2.cpp
extern void something();
extern void something2();

void test(bool b)
{
    if(b)
    {
        something();
        return;
    }
    something2();
}
=====> rm -f test_return.s test_return2.s
=====> g++ -S test_return.cpp 
=====> g++ -S test_return2.cpp 
=====> diff test_return.s test_return2.s
=====> rm -f test_return.s test_return2.s
=====> clang++ -S test_return.cpp 
=====> clang++ -S test_return2.cpp 
=====> diff test_return.s test_return2.s
=====> 

Cela ne signifie aucune différence dans le code généré, même sans optimisation dans deux compilateurs

Dani
la source
59
Ou mieux: il existe au moins une version d'un certain compilateur qui génère le même code pour les deux versions.
UncleZeiv
11
@UncleZeiv - la plupart sinon tous les compilateurs traduiront la source en un modèle de graphe de flux d'exécution. Il est difficile d'imaginer une implémentation sensée qui donnerait des graphiques de flux significativement différents pour ces deux exemples. La seule différence que vous pourriez voir est que les deux choses à faire sont permutées - et même cela peut bien être annulé dans de nombreuses implémentations pour optimiser la prédiction de branche ou pour un autre problème où la plate-forme détermine l'ordre préféré.
Steve314
6
@ Steve314, bien sûr, j'étais juste pinailleur :)
UncleZeiv
@UncleZeiv: testé sur clang aussi et même résultat
Dani
Je ne comprends pas. Il semble clair que ce something()sera toujours exécuté. Dans la question initiale, OP a Do stuffet Do diffferent stuffselon le drapeau. Je ne suis pas sûr que le code généré sera le même.
Luc M
65

La réponse courte est, aucune différence. Rendez-vous service et arrêtez de vous en préoccuper. Le compilateur d'optimisation est presque toujours plus intelligent que vous.

Concentrez-vous sur la lisibilité et la maintenabilité.

Si vous voulez voir ce qui se passe, créez-les avec des optimisations et regardez la sortie de l'assembleur.

décalage vers le bleu
la source
8
@Philip: Et faites aussi une faveur à tous les autres et arrêtez de vous en préoccuper. Le code que vous écrivez sera également lu et maintenu par d'autres (et même si vous écrivez qui ne sera jamais lu par d'autres, vous développez toujours des habitudes qui influenceront d'autres codes que vous écrivez et qui seront lus par d'autres). Écrivez toujours du code pour être aussi facile à comprendre que possible.
hlovdal
8
Les optimiseurs ne sont pas plus intelligents que vous !!! Ils ne sont que plus rapides pour décider où l'impact n'a pas trop d'importance. Là où cela compte vraiment, vous optimiserez certainement mieux que le compilateur avec une certaine expérience.
johannes
10
@johannes Permettez-moi de ne pas être d'accord. Le compilateur ne changera pas votre algorithme pour un meilleur algorithme, mais il fait un travail incroyable pour réorganiser les instructions pour obtenir une efficacité maximale du pipeline et d'autres choses pas si triviales pour les boucles (fission, fusion, etc.) que même un programmeur expérimenté ne peut pas décider quoi de mieux a priori à moins d'avoir une connaissance intime de l'architecture du CPU.
fortran
3
@johannes - pour cette question, vous pouvez supposer que oui. De plus, en général, vous pouvez parfois être en mesure d'optimiser mieux que le compilateur dans quelques cas particuliers, mais cela demande un peu de connaissances spécialisées de nos jours - le cas normal est que l'optimiseur applique la plupart des optimisations auxquelles vous pouvez penser et le fait systématiquement, pas seulement dans quelques cas particuliers. WRT cette question, le compilateur construira probablement exactement le même diagramme de flux d'exécution pour les deux formulaires. Choisir un meilleur algorithme est un travail humain, mais l'optimisation au niveau du code est presque toujours une perte de temps.
Steve314
4
Je suis d'accord et en désaccord avec cela. Il y a des cas où le compilateur ne peut pas savoir que quelque chose est équivalent à quelque chose d'autre. Saviez-vous que c'est souvent beaucoup plus rapide à fairex = <some number> que les if(<would've changed>) x = <some number>branches non utilisées ne peuvent vraiment faire mal. D'un autre côté, à moins que ce ne soit dans la boucle principale d'une opération extrêmement intensive, je ne m'en soucierais pas non plus.
user606723
28

Réponses intéressantes: Bien que je sois d'accord avec tous (jusqu'à présent), il y a des connotations possibles à cette question qui sont jusqu'à présent complètement ignorées.

Si l'exemple simple ci-dessus est étendu avec l'allocation de ressources, puis la vérification des erreurs avec une libération potentielle de ressources, l'image peut changer.

Considérez l' approche naïve que les débutants pourraient adopter:

int func(..some parameters...) {
  res_a a = allocate_resource_a();
  if (!a) {
    return 1;
  }
  res_b b = allocate_resource_b();
  if (!b) {
    free_resource_a(a);
    return 2;
  }
  res_c c = allocate_resource_c();
  if (!c) {
    free_resource_b(b);
    free_resource_a(a);
    return 3;
  }

  do_work();

  free_resource_c(c);
  free_resource_b(b);
  free_resource_a(a);

  return 0;
}

Ce qui précède représenterait une version extrême du style de retour prématuré. Remarquez comment le code devient très répétitif et non maintenable au fil du temps lorsque sa complexité augmente. De nos jours, les gens peuvent utiliser la gestion des exceptions pour les attraper.

int func(..some parameters...) {
  res_a a;
  res_b b;
  res_c c;

  try {
    a = allocate_resource_a(); # throws ExceptionResA
    b = allocate_resource_b(); # throws ExceptionResB
    c = allocate_resource_c(); # throws ExceptionResC
    do_work();
  }  
  catch (ExceptionBase e) {
    # Could use type of e here to distinguish and
    # use different catch phrases here
    # class ExceptionBase must be base class of ExceptionResA/B/C
    if (c) free_resource_c(c);
    if (b) free_resource_b(b);
    if (a) free_resource_a(a);
    throw e
  }
  return 0;
}

Philip a suggéré, après avoir regardé l'exemple goto ci-dessous, d'utiliser un interrupteur / boîtier sans rupture à l'intérieur du bloc catch ci-dessus. On pourrait basculer (typeof (e)) puis passer à travers les free_resourcex()appels, mais ce n'est pas trivial et nécessite une considération de conception . Et rappelez-vous qu'un interrupteur / boîtier sans pause est exactement comme le goto avec les étiquettes en guirlande ci-dessous ...

Comme l'a souligné Mark B, en C ++, il est considéré comme un bon style de suivre le principe d' acquisition de ressources est l'initialisation , RAII en bref. L'essentiel du concept est d'utiliser l'instanciation d'objets pour acquérir des ressources. Les ressources sont alors automatiquement libérées dès que les objets sont hors de portée et que leurs destructeurs sont appelés. Pour les ressources interdépendantes, un soin particulier doit être pris pour garantir le bon ordre de désallocation et pour concevoir les types d'objets de telle sorte que les données requises soient disponibles pour tous les destructeurs.

Ou dans les jours pré-exception pourrait faire:

int func(..some parameters...) {
  res_a a = allocate_resource_a();
  res_b b = allocate_resource_b();
  res_c c = allocate_resource_c();
  if (a && b && c) {   
    do_work();
  }  
  if (c) free_resource_c(c);
  if (b) free_resource_b(b);
  if (a) free_resource_a(a);

  return 0;
}

Mais cet exemple trop simplifié présente plusieurs inconvénients: Il ne peut être utilisé que si les ressources allouées ne dépendent pas les unes des autres (par exemple, il ne peut pas être utilisé pour allouer de la mémoire, puis ouvrir un descripteur de fichier, puis lire les données du descripteur dans la mémoire ), et il ne fournit pas de codes d'erreur individuels et distinctifs comme valeurs de retour.

Pour garder le code rapide (!), Compact et facilement lisible et extensible, Linus Torvalds a imposé un style différent pour le code du noyau qui traite des ressources, même en utilisant le tristement célèbre goto d'une manière absolument logique :

int func(..some parameters...) {
  res_a a;
  res_b b;
  res_c c;

  a = allocate_resource_a() || goto error_a;
  b = allocate_resource_b() || goto error_b;
  c = allocate_resource_c() || goto error_c;

  do_work();

error_c:
  free_resource_c(c);
error_b:
  free_resource_b(b);
error_a:
  free_resource_a(a);

  return 0;
}

L'essentiel de la discussion sur les listes de diffusion du noyau est que la plupart des fonctionnalités du langage qui sont "préférées" par rapport à l'instruction goto sont des gotos implicites, comme les énormes if / else en forme d'arbre, les gestionnaires d'exceptions, les instructions loop / break / continue, etc. Et les goto dans l'exemple ci-dessus sont considérés comme ok, car ils ne sautent que sur une petite distance, ont des étiquettes claires et libèrent le code de tout autre encombrement pour garder une trace des conditions d'erreur. Cette question a également été abordée ici sur stackoverflow .

Cependant, ce qui manque dans le dernier exemple est une bonne façon de renvoyer un code d'erreur. Je pensais ajouter unresult_code++ après chaque free_resource_x()appel et renvoyer ce code, mais cela compense certains des gains de vitesse du style de codage ci-dessus. Et il est difficile de renvoyer 0 en cas de succès. Peut-être que je suis juste sans imagination ;-)

Donc, oui, je pense qu'il y a une grande différence dans la question du codage des retours prématurés ou non. Mais je pense aussi que cela n'apparaît que dans un code plus compliqué qu'il est plus difficile ou impossible de restructurer et d'optimiser pour le compilateur. Ce qui est généralement le cas une fois que l'allocation des ressources entre en jeu.

cfi
la source
1
Wow, vraiment intéressant. Je peux certainement apprécier l'incertitude de l'approche naïve. Comment la gestion des exceptions améliorerait-elle ce cas particulier? Comme un catchcontenant une switchinstruction sans rupture sur le code d'erreur?
Philip Guin
@Philip Ajout d'un exemple de gestion d'exception de base. Notez que seul le goto a une possibilité de chute. Votre commutateur proposé (typeof (e)) aiderait, mais n'est pas trivial et nécessite une considération de conception . Et rappelez-vous qu'un interrupteur / boîtier sans pause est exactement comme le goto avec des étiquettes en guirlande ;-)
cfi
+1 c'est la bonne réponse pour C / C ++ (ou tout langage nécessitant une libération manuelle de la mémoire). Personnellement, je n'aime pas la version multi-étiquettes. Dans mon entreprise précédente, c'était toujours "goto fin" (c'était une entreprise française). En fin de compte, nous désallouerions toute mémoire, et c'était la seule utilisation de goto qui passait la révision du code.
Kip
1
Notez qu'en C ++ vous ne feriez aucune de ces approches, mais utiliseriez RAII pour vous assurer que les ressources sont nettoyées correctement.
Mark B
12

Même si ce n'est pas vraiment une réponse, un compilateur de production sera bien meilleur pour l'optimisation que vous. Je préférerais la lisibilité et la maintenabilité à ces types d'optimisations.

Lou
la source
9

Pour être précis à ce sujet, le returnsera compilé dans une branche jusqu'à la fin de la méthode, où il y aura une RETinstruction ou quoi que ce soit. Si vous l'omettez, la fin du bloc avant le elsesera compilée dans une branche jusqu'à la fin du elsebloc. Vous pouvez donc voir que dans ce cas précis, cela ne fait aucune différence.

Marquis de Lorne
la source
Je t'ai eu. Je pense en fait que cela répond à ma question assez succinctement; Je suppose que c'est littéralement juste un ajout de registre, ce qui est assez négligeable (à moins que vous ne fassiez peut-être de la programmation système, et même alors ...) Je vais lui donner une mention honorable.
Philip Guin
@Philip quel ajout de registre? Il n'y a aucune instruction supplémentaire dans le chemin.
Marquis of Lorne
Eh bien, les deux auraient des ajouts de registre. C'est tout ce qu'est une branche d'assemblage, n'est-ce pas? Un ajout au compteur de programmes? Je pourrais me tromper ici.
Philip Guin
1
@Philip Non, une branche d'assemblage est une branche d'assemblage. Cela affecte le PC bien sûr, mais cela pourrait être en le rechargeant complètement, et cela a également des effets secondaires dans le processeur, le pipeline, les caches, etc.
Marquis de Lorne
4

Si vous voulez vraiment savoir s'il existe une différence dans le code compilé pour votre compilateur et votre système particuliers, vous devrez compiler et examiner l'assembly vous-même.

Cependant, dans le grand schéma des choses, il est presque certain que le compilateur peut optimiser mieux que votre réglage fin, et même s'il ne le peut pas, il est très peu probable qu'il ait réellement une importance pour les performances de votre programme.

Au lieu de cela, écrivez le code de la manière la plus claire pour que les humains le lisent et le maintiennent, et laissez le compilateur faire ce qu'il fait le mieux: générer le meilleur assembly possible à partir de votre source.

Marque B
la source
4

Dans votre exemple, le retour est perceptible. Qu'arrive-t-il à la personne qui effectue le débogage lorsque le retour est une page ou deux au-dessus / en dessous où // des choses différentes se produisent? Beaucoup plus difficile à trouver / voir quand il y a plus de code.

void foo1(bool flag)
{
    if (flag)
    {
        //Do stuff
        return;
    }

    //Do different stuff
}

void foo2(bool flag)
{
    if (flag)
    {
        //Do stuff
    }
    else
    {
        //Do different stuff
    }
}
PCPGMR
la source
Bien entendu, une fonction ne doit pas dépasser une (voire deux) pages. Mais l'aspect du débogage n'a encore été abordé dans aucune des autres réponses. Point pris!
cfi
3

Je suis tout à fait d'accord avec blueshift: la lisibilité et la maintenabilité avant tout !. Mais si vous êtes vraiment inquiet (ou que vous voulez simplement savoir ce que fait votre compilateur, ce qui est certainement une bonne idée à long terme), vous devriez vous chercher.

Cela signifiera utiliser un décompilateur ou regarder la sortie du compilateur de bas niveau (par exemple, le langage d'assemblage). En C #, ou tout autre langage .Net, les outils documentés ici vous donneront ce dont vous avez besoin.

Mais comme vous l'avez vous-même observé, il s'agit probablement d'une optimisation prématurée.

M. Putty
la source
1

From Clean Code: Un manuel de l'artisanat logiciel agile

Les arguments de drapeau sont laids. Passer un booléen dans une fonction est une pratique vraiment terrible. Cela complique immédiatement la signature de la méthode, proclamant bruyamment que cette fonction fait plus d'une chose. Il fait une chose si le drapeau est vrai et une autre si le drapeau est faux!

foo(true);

dans le code, le lecteur devra simplement naviguer vers la fonction et perdre du temps à lire foo (indicateur booléen)

Une base de code mieux structurée vous donnera une meilleure opportunité d'optimiser le code.

Yuan
la source
J'utilise juste ceci comme exemple. Ce qui est passé dans la fonction pourrait être un int, un double, une classe, vous l'appelez, ce n'est pas vraiment au cœur du problème.
Philip Guin
La question que vous avez posée est de faire un changement à l'intérieur de votre fonction, la plupart du temps, c'est une odeur de code. Cela peut être réalisé de nombreuses façons et le lecteur n'a pas à lire toute cette fonction, disons que signifie foo (28)?
Yuan
0

Une école de pensée (je ne me souviens pas de l'egghead qui l'a proposé pour le moment) est que toutes les fonctions ne devraient avoir qu'un seul point de retour d'un point de vue structurel pour rendre le code plus facile à lire et à déboguer. Cela, je suppose, est plus pour la programmation du débat religieux.

Une raison technique pour laquelle vous souhaiterez peut-être contrôler quand et comment une fonction se termine et qui enfreint cette règle est lorsque vous codez des applications en temps réel et que vous voulez vous assurer que tous les chemins de contrôle à travers la fonction prennent le même nombre de cycles d'horloge pour se terminer.

MartyTPS
la source
Euh, je pensais que cela avait à voir avec le nettoyage (surtout lors du codage en C).
Thomas Eding
non, peu importe où vous laissez une méthode tant que vous retournez la pile est repoussée (c'est tout ce qui est "nettoyé").
MartyTPS du
-4

Je suis content que vous ayez soulevé cette question. Vous devez toujours utiliser les branches lors d'un retour anticipé. Pourquoi s'arrêter là? Fusionnez toutes vos fonctions en une si vous le pouvez (au moins autant que vous le pouvez). C'est faisable s'il n'y a pas de récursivité. En fin de compte, vous aurez une fonction principale massive, mais c'est ce dont vous avez besoin / que vous voulez pour ce genre de chose. Ensuite, renommez vos identifiants pour qu'ils soient aussi courts que possible. De cette façon, lorsque votre code est exécuté, moins de temps est passé à lire les noms. Ensuite, faites ...

Thomas Eding
la source
3
Je peux dire que vous plaisantez, mais ce qui est effrayant, c'est que certaines personnes pourraient prendre vos conseils au sérieux!
Daniel Pryden
D'accord avec Daniel. Autant j'aime le cynisme - il ne devrait pas être utilisé dans la documentation technique, les livres blancs et les sites de questions-réponses comme SO.
cfi
1
-1 pour une réponse cynique, pas forcément reconnaissable par les débutants.
Johan Bezem