Me parece que a la gente no le gusta mucho una goto
declaración, así que sentí la necesidad de aclarar esto un poco.
Creo que las 'emociones' que las personas tienen sobre el goto
futuro se reducen a la comprensión del código y (conceptos erróneos) sobre las posibles implicaciones de rendimiento. Antes de responder la pregunta, por lo tanto, primero entraré en algunos de los detalles sobre cómo se compila.
Como todos sabemos, C # se compila en IL, que luego se compila en ensamblador utilizando un compilador SSA. Daré un poco de información sobre cómo funciona todo esto, y luego trataré de responder la pregunta en sí.
De C # a IL
Primero necesitamos una pieza de código C #. Comencemos simple:
foreach (var item in array)
{
// ...
break;
// ...
}
Lo haré paso a paso para darle una buena idea de lo que sucede debajo del capó.
Primera traducción: desde foreach
al for
bucle equivalente (Nota: estoy usando una matriz aquí, porque no quiero entrar en detalles de IDisposable, en cuyo caso también tendría que usar un IEnumerable):
for (int i=0; i<array.Length; ++i)
{
var item = array[i];
// ...
break;
// ...
}
Segunda traducción: el for
y break
se traduce a un equivalente más fácil:
int i=0;
while (i < array.Length)
{
var item = array[i];
// ...
break;
// ...
++i;
}
Y tercera traducción (este es el equivalente del código IL): cambiamos break
y while
en una rama:
int i=0; // for initialization
startLoop:
if (i >= array.Length) // for condition
{
goto exitLoop;
}
var item = array[i];
// ...
goto exitLoop; // break
// ...
++i; // for post-expression
goto startLoop;
Si bien el compilador hace estas cosas en un solo paso, le brinda información sobre el proceso. El código IL que evoluciona del programa C # es la traducción literal del último código C #. Puede verlo usted mismo aquí: https://dotnetfiddle.net/QaiLRz (haga clic en 'ver IL')
Ahora, una cosa que has observado aquí es que durante el proceso, el código se vuelve más complejo. La forma más fácil de observar esto es por el hecho de que necesitábamos más y más código para lograr lo mismo. También podría argumentar que foreach
, for
, while
y break
son realmente cortos-manos para goto
, lo cual es cierto en parte.
De IL a ensamblador
El compilador .NET JIT es un compilador SSA. No entraré en todos los detalles del formulario SSA aquí y cómo crear un compilador de optimización, es demasiado, pero puedo dar una comprensión básica sobre lo que sucederá. Para una comprensión más profunda, es mejor comenzar a leer sobre la optimización de compiladores (me gusta este libro para una breve introducción: http://ssabook.gforge.inria.fr/latest/book.pdf ) y LLVM (llvm.org) .
Cada compilador de optimización se basa en el hecho de que el código es fácil y sigue patrones predecibles . En el caso de los bucles FOR, utilizamos la teoría de gráficos para analizar ramas y luego optimizamos cosas como cycli en nuestras ramas (por ejemplo, ramas hacia atrás).
Sin embargo, ahora tenemos ramas hacia adelante para implementar nuestros bucles. Como habrás adivinado, este es en realidad uno de los primeros pasos que el JIT solucionará, como este:
int i=0; // for initialization
if (i >= array.Length) // for condition
{
goto endOfLoop;
}
startLoop:
var item = array[i];
// ...
goto endOfLoop; // break
// ...
++i; // for post-expression
if (i >= array.Length) // for condition
{
goto startLoop;
}
endOfLoop:
// ...
Como puede ver, ahora tenemos una rama hacia atrás, que es nuestro pequeño bucle. Lo único que sigue siendo desagradable aquí es la rama con la que terminamos debido a nuestra break
declaración. En algunos casos, podemos mover esto de la misma manera, pero en otros está ahí para quedarse.
Entonces, ¿por qué el compilador hace esto? Bueno, si podemos desenrollar el ciclo, podríamos ser capaces de vectorizarlo. Incluso podríamos ser capaces de probar que solo se agregan constantes, lo que significa que todo nuestro circuito podría desaparecer en el aire. Para resumir: al hacer que los patrones sean predecibles (al hacer que las ramas sean predecibles), podemos probar que ciertas condiciones se mantienen en nuestro bucle, lo que significa que podemos hacer magia durante la optimización JIT.
Sin embargo, las ramas tienden a romper esos patrones agradables y predecibles, que es algo optimizadores, por lo tanto, un disgusto. Romper, continuar, ir a, todos tienen la intención de romper estos patrones predecibles y, por lo tanto, no son realmente "agradables".
También debe darse cuenta en este punto de que un simple foreach
es más predecible que un montón de goto
declaraciones que van por todas partes. En términos de (1) legibilidad y (2) desde una perspectiva optimizadora, es la mejor solución.
Otra cosa que vale la pena mencionar es que es muy relevante para optimizar los compiladores para asignar registros a las variables (un proceso llamado asignación de registros ). Como sabrás, solo hay un número finito de registros en tu CPU y son, con mucho, las piezas de memoria más rápidas en tu hardware. Las variables utilizadas en el código que se encuentra en el bucle más interno tienen más probabilidades de obtener un registro asignado, mientras que las variables fuera de su bucle son menos importantes (porque este código probablemente se golpea menos).
Ayuda, demasiada complejidad ... ¿qué debo hacer?
La conclusión es que siempre debe usar las construcciones de lenguaje que tiene a su disposición, que generalmente (implícitamente) crearán patrones predecibles para su compilador. Trate de evitar las ramas extrañas si es posible (en concreto: break
, continue
, goto
o return
en el medio de la nada).
La buena noticia aquí es que estos patrones predecibles son fáciles de leer (para humanos) y fáciles de detectar (para compiladores).
Uno de esos patrones se llama SESE, que significa Single Entry Single Exit.
Y ahora llegamos a la verdadera pregunta.
Imagina que tienes algo como esto:
// a is a variable.
for (int i=0; i<100; ++i)
{
for (int j=0; j<100; ++j)
{
// ...
if (i*j > a)
{
// break everything
}
}
}
La forma más fácil de hacer de este un patrón predecible es simplemente eliminar por if
completo:
int i, j;
for (i=0; i<100 && i*j <= a; ++i)
{
for (j=0; j<100 && i*j <= a; ++j)
{
// ...
}
}
En otros casos, también puede dividir el método en 2 métodos:
// Outer loop in method 1:
for (i=0; i<100 && processInner(i); ++i)
{
}
private bool processInner(int i)
{
int j;
for (j=0; j<100 && i*j <= a; ++j)
{
// ...
}
return i*j<=a;
}
Variables temporales? ¿Bueno, malo o feo?
Incluso podría decidir devolver un booleano desde dentro del ciclo (pero personalmente prefiero el formulario SESE porque así es como lo verá el compilador y creo que es más limpio de leer).
Algunas personas piensan que es más limpio usar una variable temporal y proponen una solución como esta:
bool more = true;
for (int i=0; i<100; ++i)
{
for (int j=0; j<100; ++j)
{
// ...
if (i*j > a) { more = false; break; } // yuck.
// ...
}
if (!more) { break; } // yuck.
// ...
}
// ...
Personalmente me opongo a este enfoque. Mire nuevamente cómo se compila el código. Ahora piense en lo que esto hará con estos patrones agradables y predecibles. ¿Obtener la imagen?
Bien, déjame explicarlo. Lo que sucederá es que:
- El compilador escribirá todo como ramas.
- Como paso de optimización, el compilador realizará un análisis de flujo de datos en un intento de eliminar la
more
variable extraña que solo se usa en el flujo de control.
- Si tiene éxito, la variable
more
será eliminada del programa y solo quedarán ramas. Estas ramas se optimizarán, por lo que obtendrá solo una rama del bucle interno.
- Si no tiene éxito, la variable
more
definitivamente se usa en el bucle más interno, por lo que si el compilador no la optimiza, tiene una alta probabilidad de ser asignada a un registro (que consume memoria de registro valiosa).
Entonces, para resumir: el optimizador en su compilador tendrá muchos problemas para descubrir que more
solo se usa para el flujo de control, y en el mejor de los casos, lo traducirá a una sola rama fuera del exterior para lazo.
En otras palabras, el mejor de los casos es que terminará con el equivalente de esto:
for (int i=0; i<100; ++i)
{
for (int j=0; j<100; ++j)
{
// ...
if (i*j > a) { goto exitLoop; } // perhaps add a comment
// ...
}
// ...
}
exitLoop:
// ...
Mi opinión personal sobre esto es bastante simple: si esto es lo que pretendíamos desde el principio, hagamos el mundo más fácil tanto para el compilador como para la legibilidad, y escribamos eso de inmediato.
tl; dr:
Línea de fondo:
- Use una condición simple en su ciclo for si es posible. Apéguese a las construcciones de lenguaje de alto nivel que tenga a su disposición tanto como sea posible.
- Si todo falla y te quedas con uno
goto
o bool more
, prefiere el primero.