96 votes

Efficacité du retour prématuré dans une fonction

Il s'agit d'une situation que je rencontre fréquemment en tant que programmeur inexpérimenté et je me pose des questions, notamment pour un de mes projets ambitieux et gourmand 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 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
    }
}

En fait, y a-t-il jamais un bonus/pénalité direct d'efficacité quand break ou return à un stade précoce ? Comment le cadre d'empilage est-il impliqué ? Existe-t-il des cas particuliers optimisés ? Existe-t-il des facteurs (comme l'inlining ou la taille du "Do stuff") qui pourraient avoir un impact significatif ?

Je suis toujours partisan d'une meilleure lisibilité plutôt que d'optimisations mineures (je vois souvent foo1 avec la validation des paramètres), mais cette question se pose 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... ugh, ce sont des souvenirs douloureux.

EDIT : J'ai accepté une réponse, mais la réponse d'EJP explique assez succinctement pourquoi l'utilisation d'une return est pratiquement négligeable (dans l'assemblage, le return crée une "branche" vers la fin de la fonction, ce qui est extrêmement rapide. Le branchement modifie le registre PC et peut également affecter le cache et le pipeline, ce qui est assez minuscule). Dans ce cas précis, cela ne fait littéralement aucune différence car les deux registres de la fonction if/else et le return créer la même branche jusqu'à la fin de la fonction.

92voto

Dani Points 13077

Il n'y a pas de différence du tout :

=====> 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 signifie qu'il n'y a aucune différence dans le code généré, même sans optimisation dans deux compilateurs.

65voto

blueshift Points 3281

La réponse courte est : aucune différence. Faites-vous une faveur et arrêtez de vous inquiéter à ce sujet. Le compilateur optimiseur est presque toujours plus intelligent que vous.

Concentrez-vous sur la lisibilité et la facilité de maintenance.

Si vous voulez voir ce qui se passe, construisez-les avec les optimisations activées et regardez la sortie de l'assembleur.

28voto

cfi Points 2775

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

Si l'on ajoute à l'exemple simple ci-dessus l'allocation de ressources, puis le contrôle d'erreurs et la libération potentielle de ressources qui en résulte, le tableau peut changer.

Considérez le approche naïve que les débutants pourraient prendre :

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;
}

L'exemple ci-dessus représente 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, on peut utiliser traitement 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 de capture ci-dessus. On pourrait commuter(typeof(e)) et ensuite tomber dans le bloc free_resourcex() appels mais c'est n'est pas anodin et nécessite une réflexion sur la conception . Et rappelez-vous qu'un interrupteur/case sans ruptures est exactement comme le goto avec des étiquettes en guirlande ci-dessous...

Comme Mark B l'a fait remarquer, en C++, il est considéré comme un bon style de suivre la règle du L'acquisition des ressources est l'initialisation principe, RAII en bref. L'essentiel du concept consiste à utiliser l'instanciation d'un objet pour acquérir des ressources. Les ressources sont ensuite automatiquement libérées dès que les objets sortent de leur champ d'application et que leurs destructeurs sont appelés. Pour les ressources interdépendantes, il convient de veiller à l'ordre correct de la libération et de concevoir les types d'objets de telle sorte que les données requises soient disponibles pour tous les destructeurs.

Ou dans les jours de 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 simpliste 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 pourrait pas être utilisé pour allouer de la mémoire, puis ouvrir un gestionnaire de fichier, puis lire les données du gestionnaire dans la mémoire), et il ne fournit pas de codes d'erreur individuels et distincts comme valeurs de retour.

Maintenir un code rapide ( !), compact, facilement lisible et extensible. Linus Torvalds a imposé un style différent pour le code du noyau qui traite des ressources, utilisant même le tristement célèbre goto d'une manière qui est tout à fait 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 caractéristiques du langage qui sont "préférées" à l'instruction goto sont des gotos implicites, comme les énormes if/else arborescents, les gestionnaires d'exceptions, les instructions de type boucle/break/continue, etc. Et les goto dans l'exemple ci-dessus sont considérés comme corrects, puisqu'ils ne sautent que sur une petite distance, ont des étiquettes claires, et libèrent le code d'autres encombrements pour garder la trace des conditions d'erreur. Cette question a également été discutée ici sur stackoverflow .

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

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 qu'elle n'est apparente 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 lorsque l'allocation des ressources entre en jeu.

12voto

Lou Points 1427

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

9voto

EJP Points 113412

Pour être précis, le return sera compilé en un branchement jusqu'à la fin de la méthode, où il y aura un RET l'instruction ou quoi que ce soit d'autre. Si vous l'omettez, la fin du bloc avant l'instruction else sera compilé dans un branchement à la fin de la branche else bloc. Vous pouvez donc voir que dans ce cas précis, cela ne fait aucune différence.

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X