Hoy en la oficina, repasando el código de un compañero vino a mi cabeza la siguiente duda, cuando en C# tienes una función en la que tienes que modificar una cadena y devolverla, ¿qué es mejor? ¿Machacar la cadena existente, asignar a otra variable, o devolver directamente el resultado de la operación?
Para ello me he creado un pequeño proyecto de ejemplo, y con la ayuda del desensamblador del lenguaje IL (el lenguaje intermedio que se genera cuando compilamos algún lenguaje de .net como C#) he podido ver qué pasaba realmente. Los resultados no dejan de ser interesantes.
Antes de empezar, un par de apuntes.
- IL nos podría recordar a ensamblador, ya que las funciones están basadas en manejo de pila, colocando en la misma el resultado antes de retornar.
- Los valores se definen por índices, de tal manera que cuando hablamos de ldarg.0 estamos operando con el índice 0.
- El resultado de llamadas a funciones externas se obtiene también de la pila.
Vistos estos detalles, pasemos al programa en cuestión:
class Program { public static string MyFirstCustomFunction(string a) { a = a.Substring(4); return a; } public static string MySecondCustomFunction(string b) { return b.Substring(4); } public static string MyThirdCustomFunction(string c) { var result = c.Substring(4); return result; } static void Main(string[] args) { Console.WriteLine(MyFirstCustomFunction("Lorem ipsum dolor sit amet")); Console.WriteLine(MySecondCustomFunction("Lorem ipsum dolor sit amet")); Console.WriteLine(MyThirdCustomFunction("Lorem ipsum dolor sit amet")); } }
Vamos con la primera función, si cargamos ILdasm a partir de la consola de desarrollador de Visual Studio, podemos cargar el ejecutable, obteniendo esta imagen:
Vamos con el desensamblado de la primera función:
.method public hidebysig static string MyFirstCustomFunction(string a) cil managed { // Code size 16 (0x10) .maxstack 2 .locals init ([0] string CS$1$0000) IL_0000: nop IL_0001: ldarg.0 IL_0002: ldc.i4.4 IL_0003: callvirt instance string [mscorlib]System.String::Substring(int32) IL_0008: starg.s a IL_000a: ldarg.0 IL_000b: stloc.0 IL_000c: br.s IL_000e IL_000e: ldloc.0 IL_000f: ret } // end of method Program::MyFirstCustomFunction
Lo que está pasando es lo siguiente:
- Se define al principio del todo la variable de retorno, con el índice 0.
- Se carga en la pila el valor pasado por argumento.
- Se carga también de manera constante el entero de 4 bytes (i4) de valor 4.
- Se llama a la función substring, accediendo primero al ensamblado, y luego al espacio de nombres.
- Se obtiene el resultado de la pila (recordemos los apuntes iniciales), y se almacena en a
- Se vuelve a cargar en la pila el valor del argumento (que acabamos de machacar).
- Se almacena este valor en la variable de retorno (en la posición 0).
- Se realiza un salto a la siguiente línea (en este caso sin ninguna razón aparente).
- Se carga el elemento de la variable de retorno en la pila (para que la función que ha llamado a esta pueda acceder).
- Se retorna.
Por lo que podemos ver aquí, el hecho de almacenar el resultado en el argumento hace que carguemos el valor varias veces, lo cual no tiene por qué ser necesario.
Veamos qué sucede con la segunda función:
.method public hidebysig static string MySecondCustomFunction(string b) cil managed { // Code size 13 (0xd) .maxstack 2 .locals init ([0] string CS$1$0000) IL_0000: nop IL_0001: ldarg.0 IL_0002: ldc.i4.4 IL_0003: callvirt instance string [mscorlib]System.String::Substring(int32) IL_0008: stloc.0 IL_0009: br.s IL_000b IL_000b: ldloc.0 IL_000c: ret } // end of method Program::MySecondCustomFunction
Hasta la llamada 0003 es exactamente igual que el anterior (incluida la definición de la variable de retorno de la función), sin embargo pasa lo siguiente.
- Se fija, directamente desde la pila, el valor a la variable de retorno.
- Se vuelve a colocar dicho valor en la pila antes de volver a la función “padre”.
Por lo que se ve, esta sería una opción sin duda más eficiente, ya que evita una operación de lectura.
Veamos qué pasa en el último caso, usando una variable intermedia que NO es la que hemos pasado por argumento, ¿cual sería el comportamiento en este caso?
.method public hidebysig static string MyThirdCustomFunction(string c) cil managed { // Code size 15 (0xf) .maxstack 2 .locals init ([0] string result, [1] string CS$1$0000) IL_0000: nop IL_0001: ldarg.0 IL_0002: ldc.i4.4 IL_0003: callvirt instance string [mscorlib]System.String::Substring(int32) IL_0008: stloc.0 IL_0009: ldloc.0 IL_000a: stloc.1 IL_000b: br.s IL_000d IL_000d: ldloc.1 IL_000e: ret } // end of method Program::MyThirdCustomFunction
La primera diferencia es que antes de empezar la ejecución tenemos una segunda variable local definida como una cadena, de tipo string, e inicializada, al igual que la variable de retorno. El resto de las llamadas hasta 0003 es igual, y podemos ver que hace:
- Guardar el resultado en la variable de retorno.
- Cargar el resultado de la variable de retorno en la pila
- Mover de la pila a la variable result.
- Mover de la variable result a la pila una vez más.
Es decir, se vuelve a hacer una operación de doble lectura y escritura, exactamente igual que la anterios, salvo que en vez de acceder a los argumentos, se accede a variables locales. Queda pendiente averiguar qué operación sería más costosa.
En resumen, si devolvemos directamente el resultado de una operación sin usar variables intermedias ahorramos una operación de lectura y escritura en la pila.
Para complementar, esta entrada de Stack overflow habla sobre algunas cosas que he dejado en el tintero, como los nop y las br:
String es una clase especial, es inmutable. Es decir, que una vez creada la instancia no se puede modificar, por eso todos los métodos de la clase string devuelve siempre una nueva referencia con la cadena modificada. Esto es así por motivos de rendimiento.
Muy interesante el articulo.
Saludos!
Es curioso, siempre que busco optimizar strings me quedo en el típico string.Concat vs StringBuilder y no me había planteado qué pasa con las variables locales.
Para comprobar estas cosas yo prefiero compilar en modo Release, así el código está optimizado y te evitas encontrarte variables locales que no se utilizan (como el [1] string CS$1$0000 que aparece por todo).
Además de las optimizaciones que hagamos con el IL debemos tener en cuenta las que hace luego el JIT, así que para medir la eficiencia real al final no te queda más remedio que hacer la prueba de llamar al método unos cuantos miles o millones de veces, en modo Release, y medir cuánto tarda en cada uno de ellos unas cuantas veces.
Saludos!
Juanma
Interesante para aprender a optimizar las aplicaciones. Aunque, siempre hay que poner en una balanza cosas como optimización (que dada las velocidades de CPU tienen que ser operaciones muy repetitivas y pesadas para aprovechar las ventajas), tiempo de desarrollo, claridad de los códigos fuentes, puesto que ambas repercuten muy directamente en la cuenta de resultados (Que no es algo informático, pero hay que tener en cuenta siempre a la hora de desarrollar. :-) )
En Java pasa algo parecido (extraído del manual de Android):
«When extracting strings from a set of input data, try to return a substring of the original data, instead of creating a copy. You will create a new String object, but it will share the char[] with the data. (The trade-off being that if you’re only using a small part of the original input, you’ll be keeping it all around in memory anyway if you go this route.)»
(http://developer.android.com/training/articles/perf-tips.html)
;)