[ES] Rendimientos Linq vs For vs Foreach separando Strings

Hace algún tiempo en una entrevista de trabajo me propusieron una kata bastante interesante, consistía en que dado un archivo con un listado de personajes, separarlos en dos grupos, el patrón por el que debían ser separados era que aquellos denominados Villanos contendrían la letra d en su nombre.

Entre otras cosas querían evaluar el tratamiento de errores, y la efectividad de la separación por ejemplo. Bien, pues en esta segunda parte me encontré con algo realmente curioso, que lo que para alguien cómo yo acostumbrado a trabajar con linq lo más rápido de codificar suponía una deficiencia de rendimiento muy elevada desde un número bastante bajo de registros.

El planteamiento es el siguiente, con linq usaba un where y extraía todos los elementos que contenían la letra d de la siguiente manera, de forma que en una sola línea de código tenía todo el trabajo realizado:

List<String> villains = roles.Where(x => x.ToUpper().Contains('D')).ToList();

El tiempo de ejecución para unos 1100 registros salía así: Tiempo de ejecución con Linq: 00:00:00.0060463

Sin duda es un tiempo ínfimo, que a pocos haría dudar de que hay formas más efectivas, una de esas personas era mi entrevistador, realizó el trabajo que yo debía haber realizado y me mostró que tanto con un bucle for cómo con un foreach el rendimiento era sensiblemente mejor, y más cuanto más grande fuera el archivo de datos.

El código de los dos bucles es muy similar:

//Código bucle for
for (int i = 0; i < roles.Count; i++)
{
if (roles[i].ToUpper().Contains("D"))
{
villains.Add(roles[i]);
}
}
//Código bucle foreach
foreach (string name in roles)
if (name.ToUpper().Contains("D"))
{
villains.Add(name);
}

La salida de estos dos bucles es sensiblemente mejor al de linq (he ajustado el funcionamiento porque hablando con Luis Guerrero (@GuerreroTook) me evidenció que estaba siendo injusto con Linq porque en sólo una pasada de los bucles conseguía héroes y villanos y sin embargo en linq tenía dos where: uno para los héroes y otro para los villanos, así que en los 3 casos sólo extraemos los villanos). Pero aún así la salida comparativa para 1100 registros es la siguiente:

Tiempo de ejecución con Linq: 00:00:00.0060463
Tiempo de ejecución con un bucle for: 00:00:00.0025016
Tiempo de ejecución con un bucle foreach: 00:00:00.0025100
Pulsa una tecla para finalizar.
view raw 1100 hosted with ❤ by GitHub

Y la diferencia se vuelve más evidente cuando nos lanzamos a unos 50000 registros:

Tiempo de ejecución con Linq: 00:00:00.0902724
Tiempo de ejecución con un bucle for: 00:00:00.0530886
Tiempo de ejecución con un bucle foreach: 00:00:00.0518326
Pulsa una tecla para finalizar.
view raw 5000 hosted with ❤ by GitHub

Y forzando la máquina con dos millones de registros:

Tiempo de ejecución con Linq: 00:00:01.1483824
Tiempo de ejecución con un bucle for: 00:00:01.0601247
Tiempo de ejecución con un bucle foreach: 00:00:01.0607591
Pulsa una tecla para finalizar.

¿Y esto que significa? Que no siempre lo más fácil de programar es lo más rápido y que si evaluamos la solución cómo la pedían en la kata en la que había que separar el listado inicial en dos en los que tenemos héroes y villanos, mientras los tiempos de los bucles for y foreach seguirían siendo siempre los mismos el timing para la ejecución con Linq pasaría a duplicarse. Los frameworks son nuestros gratos compañeros de viaje pero se deben usar siempre con cuidado y coherencia, sin olvidar que puede haber soluciones con bucles simples que pueden ser más efectivas.

PD: He intentado realizar también la comparación con expresiones regulares pero no he conseguido componer el patrón de búsqueda porque algunos de los nombres eran compuestos y no podía usar el espacio cómo separador. Si a alguien se le ocurre un patrón, el código está subido a github para todos aquellos que quieran probarlo

Actualización: Justo recién publicado este post di con la opción para realizarlo con una expresión regular. La preparación de los datos no entra en la evaluación de tiempo del bucle:

//Volcamos la lista en una cadena y cómo existen nombres compuestos los separamos con doble coma. (Tiene truco)
cadRegExp = string.Join(",,", roles);
cadRegExp = ",," + cadRegExp + ",,";

Acepto que el tema de la doble coma es un poco extraño, pero también que la expresión regular dado mi nivel no es del todo sencilla, veamos, quiero buscar todos los nombres (simples o compuestos) que contengan la letra d. Por tanto, necesito el espacio para separar los nombres y otro carácter cómo separador para la expresión regular. ¿Y porqué no usar sólo una coma? Bien, pues no puedo usar sólo una coma, porque la primera coma pertenece hace de cierre de la palabra y la segunda coma hace de apertura de la segunda, si pruebas el código en github “,Angel Dust,Dirk Anger,” esta cadena me reconoce con mi expresión regular sólo el primero de los elementos, cuando el segundo también debería cumplirla, de modo que mi expresión regular la dejé así: “,[^,]*(d)[^,]*,” que traducido a texto sería-> una coma, 0 o más de cualquier carácter salvo la coma, obligatoria una d, 0 o más de cualquier carácter salvo la coma y una coma de cierre.

string pattern = @",[^,]*(d)[^,]*,";
List<String> villains = new List<string>();
Match match;
Regex intAll = new Regex(pattern, RegexOptions.IgnoreCase);
match = intAll.Match(cadRegExp);
int matches = 0;
while (match.Success)
{
matches++;
villains.Add(match.Value);
// Do nothing with the match except get the next match.
match = match.NextMatch();
}
view raw RegExp.cs hosted with ❤ by GitHub

Todo esto está muy bien, pero ¿no venías hablando de rendimiento? sí, efectivamente y aqui tenemos las comparativas.

//Para 1100:
Tiempo de ejecución con Linq: 00:00:00.0042489
Tiempo de ejecución con un bucle for: 00:00:00.0020423
Tiempo de ejecución con un bucle foreach: 00:00:00.0020911
Tiempo de ejecución con expresiones regulares: 00:00:00.0076936
Pulsa una tecla para finalizar.
//Para 50000
Tiempo de ejecución con Linq: 00:00:00.0466792
Tiempo de ejecución con un bucle for: 00:00:00.0386129
Tiempo de ejecución con un bucle foreach: 00:00:00.0244721
Tiempo de ejecución con expresiones regulares: 00:00:00.1088181
Pulsa una tecla para finalizar.
//Para 2 millones de registros
Tiempo de ejecución con Linq: 00:00:01.1146778
Tiempo de ejecución con un bucle for: 00:00:01.0618862
Tiempo de ejecución con un bucle foreach: 00:00:01.0559424
Tiempo de ejecución con expresiones regulares: 00:00:04.4571104
Pulsa una tecla para finalizar.

mmmmm… sinceramente, los resultados arrojados por las expresiones regulares sólo me dicen una cosa -> que creo que no las estoy usando todo lo bien que debería, que encontré la forma de hacerlo pero que al no haber pulido la idea la ejecución se ha vuelto pobre y necesita varias vueltas más. Pero seguiré trabajando en ello.

Comments are closed here.