Eficiencia de retorno prematuro en una función


97

Esta es una situación con la que me encuentro con frecuencia como programador sin experiencia y me pregunto sobre todo para un proyecto mío ambicioso y de alta velocidad que estoy tratando de optimizar. Para los principales lenguajes similares a C (C, objC, C ++, Java, C #, etc.) y sus compiladores habituales, ¿estas dos funciones se ejecutarán con la misma eficacia? ¿Hay alguna diferencia en el código compilado?

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

    //Do different stuff
}

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

Básicamente, ¿existe alguna vez una bonificación / penalización directa por eficiencia cuando se breakinicia o se returnadelanta? ¿Cómo está involucrado el stackframe? ¿Hay casos especiales optimizados? ¿Hay algún factor (como la inserción o el tamaño de "Hacer cosas") que pueda afectar esto de manera significativa?

Siempre soy un defensor de la legibilidad mejorada sobre las optimizaciones menores (veo mucho foo1 con la validación de parámetros), pero esto surge con tanta frecuencia que me gustaría dejar de lado todas las preocupaciones de una vez por todas.

Y soy consciente de las trampas de la optimización prematura ... uf, esos son algunos recuerdos dolorosos.

EDITAR: Acepté una respuesta, pero la respuesta de EJP explica de manera bastante sucinta por qué el uso de a returnes prácticamente insignificante (en el ensamblaje, returncrea una 'rama' al final de la función, que es extremadamente rápido. La rama altera el registro de la PC y también puede afectar la caché y la canalización, lo cual es bastante minúsculo). Para este caso en particular, literalmente no hace ninguna diferencia porque tanto el if/elsecomo el returncrean la misma rama hasta el final de la función.


22
No creo que este tipo de cosas tengan un impacto notable en el rendimiento. Solo escribe una pequeña prueba y mírate a ti mismo. Imo, la primera variante es mejor ya que no obtiene un anidamiento innecesario que mejora la lectura
SirVaulterScoff

10
@SirVaulterScott, a menos que los dos casos sean simétricos de alguna manera, en cuyo caso querrá resaltar la simetría colocándolos en el mismo nivel de sangría.
luqui

3
SirVaulterScoff: +1 para reducir la anidación innecesaria
fjdumont

11
Legibilidad >>> Micro optimizaciones. Hágalo de la forma que tenga más sentido para el software que lo mantendrá. A nivel de código de máquina, estas dos estructuras son idénticas cuando se introducen incluso en un compilador bastante tonto. Un compilador de optimización borrará cualquier apariencia de ventaja de velocidad entre los dos.
SplinterReality

12
No optimice su proyecto de "velocidad intensiva" preocupándose por cosas como esta. Cree un perfil de su aplicación para averiguar dónde es realmente lenta, si en realidad es demasiado lenta cuando haya terminado de hacerla funcionar. Es casi seguro que no puede adivinar qué es lo que realmente lo está frenando.
blueshift

Respuestas:


92

No hay ninguna diferencia:

=====> 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
=====> 

Lo que significa que no hay diferencia en el código generado en absoluto, incluso sin optimización en dos compiladores


59
O mejor: hay al menos una versión de cierto compilador que genera el mismo código para las dos versiones.
UncleZeiv

11
@UncleZeiv: la mayoría, si no todos los compiladores, traducirán la fuente a un modelo de diagrama de flujo de ejecución. Es difícil imaginar una implementación sensata que proporcione gráficos de flujo significativamente diferentes para esos dos ejemplos. La única diferencia que puede ver es que las dos cosas diferentes se intercambian, e incluso eso puede deshacerse en muchas implementaciones para optimizar la predicción de rama o para algún otro problema en el que la plataforma determina el orden preferido.
Steve 314

6
@ Steve314, claro, solo estaba quisquilloso :)
UncleZeiv

@UncleZeiv: probado en clang también y el mismo resultado
Dani

No entiendo. Parece claro que something()siempre se ejecutará. En la pregunta original, OP tiene Do stuffy Do diffferent stuffdepende de la bandera. No estoy seguro de que el código generado sea el mismo.
Luc M

65

La respuesta corta es, no hay diferencia. Hazte un favor y deja de preocuparte por esto. El compilador de optimización es casi siempre más inteligente que usted.

Concéntrese en la legibilidad y la facilidad de mantenimiento.

Si desea ver qué sucede, compárelos con optimizaciones y observe la salida del ensamblador.


8
@Philip: Y hazle un favor a todos los demás y deja de preocuparte por esto. El código que escriba será leído y mantenido por otros también (e incluso si escribe que nunca será leído por otros, desarrollará hábitos que influirán en otros códigos que escriba y que otros lean). Siempre escriba el código para que sea lo más fácil de entender posible.
hlovdal

8
¡Los optimizadores no son más inteligentes que tú! Solo son más rápidos para decidir dónde no importa mucho el impacto. Donde realmente importa, seguramente con algo de experiencia optimizará mejor que el compilador.
johannes

10
@johannes Déjame estar en desacuerdo. El compilador no cambiará su algoritmo por uno mejor, pero hace un trabajo increíble al reordenar las instrucciones para lograr la máxima eficiencia de la canalización y otras cosas no tan triviales para los bucles (fisión, fusión, etc.) que incluso un programador experimentado no puede decidir. lo que es mejor a priori a menos que tenga un conocimiento profundo de la arquitectura de la CPU.
fortran

3
@johannes: para esta pregunta, puede asumir que sí. Además, en general, es posible que ocasionalmente pueda optimizar mejor que el compilador en algunos casos especiales, pero eso requiere un poco de conocimiento especializado en estos días; el caso normal es que el optimizador aplica la mayoría de las optimizaciones que pueda pensar y lo hace. sistemáticamente, no solo en unos pocos casos especiales. WRT esta pregunta, el compilador probablemente construirá precisamente el mismo diagrama de flujo de ejecución para ambas formas. Elegir un algoritmo mejor es un trabajo humano, pero la optimización a nivel de código es casi siempre una pérdida de tiempo.
Steve 314

4
Estoy de acuerdo y en desacuerdo con esto. Hay casos en los que el compilador no puede saber que algo es equivalente a otra cosa. ¿Sabías que a menudo es mucho más rápido de lo x = <some number>que if(<would've changed>) x = <some number>las ramas innecesarias pueden doler? Por otro lado, a menos que esto esté dentro del ciclo principal de una operación extremadamente intensiva, tampoco me preocuparía por eso.
user606723

28

Respuestas interesantes: aunque estoy de acuerdo con todas ellas (hasta ahora), hay posibles connotaciones para esta pregunta que hasta ahora se han ignorado por completo.

Si el ejemplo simple anterior se amplía con la asignación de recursos y luego la verificación de errores con una posible liberación de recursos resultante, el panorama podría cambiar.

Considere el enfoque ingenuo que pueden adoptar los principiantes:

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

Lo anterior representaría una versión extrema del estilo de regresar prematuramente. Observe cómo el código se vuelve muy repetitivo y no se puede mantener con el tiempo cuando aumenta su complejidad. Hoy en día, la gente puede usar el manejo de excepciones para detectarlos.

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 sugirió, después de mirar el ejemplo de goto a continuación, usar un interruptor / caja sin interrupciones dentro del bloque de captura de arriba. Uno podría cambiar (typeof (e)) y luego pasar por free_resourcex()alto las llamadas, pero esto no es trivial y necesita consideración de diseño . Y recuerde que un interruptor / caja sin interrupciones es exactamente como el goto con etiquetas encadenadas debajo ...

Como señaló Mark B, en C ++ se considera de buen estilo seguir el principio de adquisición de recursos es inicialización , RAII en resumen. La esencia del concepto es utilizar la instanciación de objetos para adquirir recursos. Los recursos se liberan automáticamente tan pronto como los objetos salen del alcance y se llaman a sus destructores. Para los recursos interdependientes, se debe tener especial cuidado para asegurar el orden correcto de desasignación y diseñar los tipos de objetos de manera que los datos requeridos estén disponibles para todos los destructores.

O en días previos a la excepción podría hacer:

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

Pero este ejemplo demasiado simplificado tiene varios inconvenientes: se puede usar solo si los recursos asignados no dependen entre sí (por ejemplo, no se puede usar para asignar memoria, luego abrir un identificador de archivo y luego leer datos del identificador en la memoria ), y no proporciona códigos de error individuales distinguibles como valores de retorno.

Para mantener el código rápido (!), Compacto y fácilmente legible y extensible, Linus Torvalds impuso un estilo diferente para el código del kernel que se ocupa de los recursos, incluso usando el infame goto de una manera que tiene absolutamente sentido :

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

La esencia de la discusión sobre las listas de correo del kernel es que la mayoría de las características del lenguaje que son "preferidas" sobre la instrucción goto son gotos implícitos, tales como if / else enormes, en forma de árbol, manejadores de excepciones, declaraciones loop / break / continue, etc. Y los goto en el ejemplo anterior se consideran correctos, ya que están saltando solo una pequeña distancia, tienen etiquetas claras y liberan el código de otro desorden para realizar un seguimiento de las condiciones de error. Esta pregunta también se ha discutido aquí en stackoverflow .

Sin embargo, lo que falta en el último ejemplo es una buena forma de devolver un código de error. Estaba pensando en agregar un result_code++después de cada free_resource_x()llamada y devolver ese código, pero esto compensa algunas de las ganancias de velocidad del estilo de codificación anterior. Y es difícil devolver 0 en caso de éxito. Quizás soy poco imaginativo ;-)

Entonces, sí, creo que hay una gran diferencia en la cuestión de codificar devoluciones prematuras o no. Pero también creo que es evidente solo en un código más complicado que es más difícil o imposible de reestructurar y optimizar para el compilador. Que suele ser el caso una vez que entra en juego la asignación de recursos.


1
Vaya, realmente interesante. Definitivamente puedo apreciar la imposibilidad de mantener el enfoque ingenuo. Sin embargo, ¿cómo mejoraría el manejo de excepciones en ese caso particular? ¿Como que catchcontiene una switchdeclaración sin interrupciones en el código de error?
Philip Guin

@Philip Se agregó un ejemplo básico de manejo de excepciones. Tenga en cuenta que solo el goto tiene una posibilidad de falla. Su cambio propuesto (tipo de (e)) ayudaría, pero no es trivial y necesita consideración de diseño . Y recuerde que un interruptor / caja sin interrupciones es exactamente como el goto con etiquetas encadenadas ;-)
cfi

+1 esta es la respuesta correcta para C / C ++ (o cualquier lenguaje que requiera la liberación manual de memoria). Personalmente, no me gusta la versión de etiquetas múltiples. En mi empresa anterior, siempre fue "goto fin" (era una empresa francesa). En fin, desasignaríamos cualquier memoria, y ese era el único uso de goto que pasaría la revisión del código.
Kip

1
Tenga en cuenta que en C ++ no haría ninguno de estos enfoques, pero usaría RAII para asegurarse de que los recursos se limpien correctamente.
Mark B

12

Aunque esta no es una gran respuesta, un compilador de producción será mucho mejor optimizando que usted. Favorecería la legibilidad y la capacidad de mantenimiento sobre este tipo de optimizaciones.


9

Para ser específico sobre esto, returnse compilará en una rama hasta el final del método, donde habrá una RETinstrucción o lo que sea. Si lo deja fuera, el final del bloque antes de elsese compilará en una rama hasta el final del elsebloque. Así que puede ver que en este caso específico no hay ninguna diferencia.


Te tengo. De hecho, creo que esto responde a mi pregunta de manera bastante sucinta; Supongo que es literalmente solo una adición de registro, que es bastante insignificante (a menos que tal vez estés haciendo programación de sistemas, e incluso entonces ...) Voy a darle una mención honorífica.
Philip Guin

@Philip ¿qué registro de adición? No hay ninguna instrucción adicional en la ruta.
Marqués de Lorne

Bueno, ambos tendrían adiciones de registro. Eso es todo lo que es una rama de ensamblaje, ¿no? ¿Una adición al contador de programas? Podría estar equivocado aquí.
Philip Guin

1
@Philip No, una rama de ensamblaje es una rama de ensamblaje. Afecta a la PC, por supuesto, pero podría ser al recargarla por completo, y también tiene efectos secundarios en el procesador, como la canalización, los cachés, etc.
Marqués de Lorne

4

Si realmente quiere saber si hay una diferencia en el código compilado para su compilador y sistema en particular, tendrá que compilar y mirar el ensamblado usted mismo.

Sin embargo, en el gran esquema de las cosas, es casi seguro que el compilador puede optimizar mejor que su ajuste fino, e incluso si no puede, es muy poco probable que realmente importe para el rendimiento de su programa.

En su lugar, escriba el código de la manera más clara para que los humanos lo lean y mantengan, y deje que el compilador haga lo que mejor sabe hacer: generar el mejor ensamblado posible a partir de su fuente.


4

En su ejemplo, el retorno es notable. ¿Qué le sucede a la persona que realiza la depuración cuando la devolución es una página o dos arriba / abajo donde // ocurren cosas diferentes? Mucho más difícil de encontrar / ver cuando hay más código.

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

    //Do different stuff
}

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

Por supuesto, una función no debe tener más de una (o incluso dos) páginas. Pero el aspecto de depuración aún no se ha cubierto en ninguna de las otras respuestas. ¡Punto a favor!
cfi

3

Estoy totalmente de acuerdo con blueshift: legibilidad y mantenibilidad primero. Pero si está realmente preocupado (o simplemente quiere saber qué está haciendo su compilador, lo que definitivamente es una buena idea a largo plazo), debería buscarlo usted mismo.

Esto significará usar un descompilador o mirar la salida del compilador de bajo nivel (por ejemplo, lenguaje de ensamblaje). En C #, o cualquier lenguaje .Net, las herramientas documentadas aquí le darán lo que necesita.

Pero como usted mismo ha observado, probablemente se trate de una optimización prematura.


1

De Clean Code: un manual de artesanía de software ágil

Los argumentos de bandera son feos. Pasar un valor booleano a una función es una práctica realmente terrible. Inmediatamente complica la firma del método, proclamando en voz alta que esta función hace más de una cosa. ¡Hace una cosa si la bandera es verdadera y otra si la bandera es falsa!

foo(true);

en el código solo hará que el lector navegue a la función y pierda el tiempo leyendo foo (bandera booleana)

Una base de código mejor estructurada le dará una mejor oportunidad de optimizar el código.


Solo estoy usando esto como ejemplo. Lo que se pasa a la función podría ser un int, double, una clase, lo que sea, no está realmente en el corazón del problema.
Philip Guin

La pregunta que hizo es acerca de hacer un cambio dentro de su función, la mayoría de los casos, es un olor a código. Se puede lograr de muchas maneras y el lector no tiene que leer toda esa función, ¿qué significa foo (28)?
Yuan

0

Una escuela de pensamiento (no recuerdo al cabeza de huevo que la propuso en este momento) es que todas las funciones solo deben tener un punto de retorno desde un punto de vista estructural para que el código sea más fácil de leer y depurar. Eso, supongo, es más para programar el debate religioso.

Una razón técnica por la que puede querer controlar cuándo y cómo sale una función que rompe esta regla es cuando está codificando aplicaciones en tiempo real y desea asegurarse de que todas las rutas de control a través de la función tomen la misma cantidad de ciclos de reloj para completarse.


Uh, pensé que tenía que ver con la limpieza (especialmente al codificar en C).
Thomas Eding

no, no importa dónde dejes un método, siempre y cuando regreses, la pila se vuelve a bajar (eso es todo lo que se "limpia").
MartyTPS

-4

Me alegra que hayas mencionado esta pregunta. Siempre debe usar las sucursales en lugar de una devolución anticipada. ¿Por qué detenerse ahí? Combine todas sus funciones en una sola si puede (al menos tanto como pueda). Esto es factible si no hay recursividad. Al final, tendrá una función principal masiva, pero eso es lo que necesita / desea para este tipo de cosas. Luego, cambie el nombre de sus identificadores para que sean lo más cortos posible. De esa manera, cuando se ejecuta su código, se dedica menos tiempo a leer nombres. Siguiente hacer ...


3
Puedo decir que estás bromeando, ¡pero lo que da miedo es que algunas personas pueden tomar tu consejo en serio!
Daniel Pryden

De acuerdo con Daniel. Por mucho que me guste el cinismo, no debería utilizarse en documentación técnica, documentos técnicos y sitios de preguntas y respuestas como SO.
cfi

1
-1 para una respuesta cínica, no necesariamente reconocible por principiantes.
Johan Bezem
Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.