Hace unos días revisando un código PHP de una página web, me encontré con un par de líneas que me parecieron sustancialmente mejorables.
El código es algo muy común en páginas que ponen frases aleatorias de proverbios, refranes, citas y similares. Explico el código para que se entienda y el problema a resolver.
Las líneas en cuestión eran:
$citas=split("\n",obtener_citas());
$valor=$citas[rand(0,(count($citas)-2))];
Supongamos que tenemos un fichero llamado citas.txt con 6 citas tal que:
Cita1
Cita2
Cita3
Cita4
Cita5
Cita6
Este fichero es leído por la función obtener_citas() de la línea 1, que devuelve una cadena de texto con todo el contenido. Pero debemos notar, que cada línea es separada en la cadena de texto cuando se lee un carácter \n de salto de línea. Luego lo lógico sería pensar que cada frase se encuentra en una línea y que podemos diferenciarla porque al final de cada cita encontraremos un carácter \n.
Por lo que para separar la cadena devuelta por obtener_citas(), debemos trocearla por cada carácter \n que encontremos. En el código original esto se hace con la funcion split() de PHP, que es una función que trocea una cadena dada en el segundo argumento, según la expresión regular dada en el primer argumento. Es importante notar, que trocea según una expresión regular, ya que esto será discutido más adelante.
Por lo tanto, la función split() nos devuelve un array con las cadenas troceadas, que en código inicial se guarda en la variable $citas que contendrá en su interior algo como lo siguiente:
Array (
[0] => Cita1
[1] => Cita2
[2] => Cita3
[3] => Cita4
[4] => Cita5
[5] => Cita6
[6] => ) 1
Observamos que array o matriz ha guardado 7 posiciones en total (del 0 al 6) y que para nuestra sorpresa la posición 6 esta vacía. Esto se debe a que en nuestro fichero citas.txt, la última línea también termina en \n pero luego solo existe vacío, por lo que se añade una posición innecesaria que será lo que nos incomode principalmente en este código.
En la línea dos, lo que se pretende principalmente es obtener una frase aleatoriamente entre las posiciones de la matriz. Para ello se utiliza la función count() que nos devuelve el número de elementos de nuestra matriz, en nuestro caso 7 (del 0 al 6). Pero debido a que la última posición es vacía, debemos restar un elemento (nos quedan 6 elementos) y otra resta de elemento porque la matriz empieza en 0. Luego en realidad tenemos un valor de 5.
Este valor es utilizado por la función rand() que como primer argumento recibe el número que abre el rango y como segundo el número que cierra el rango de números aleatorios. Luego nos quedaría un rand() entre 0 y 5 para nuestra matriz de citas, con lo que obtendremos un valor aleatorio que puesto como índice en la matriz $citas nos devuelve la cita aleatoria que será guardada en la variable $valor.
Tal vez pienses que para dos lineas de código he escrito una extensa explicación, pero prefiero que gente menos experimentada capte bien todo el problema y se entere perfectamente.
Después de haber descrito el problema profundamente, pasamos a estilizar el código haciendo una serie de propuestas y viendo las ventajas y desventajas de cada una para quedarnos con la mejor posible (aunque puede depender del caso).
Como primera mejora, podemos utilizar la función mt_rand() que es mucho más rápida que rand() para calcular números aleatorios, nuestro código quedaría como:
$citas=split("\n",obtener_citas());
$valor=$citas[mt_rand(0,(count($citas)-2))];
Este pequeño cambio nos hará mejorar mucho nuestro código y su ejecución, pero siendo exigentes aún no es suficiente mejora.
El principal problema del código, es que nos encontramos con la función count() que puede ser muy costosa cuando tengamos muchos elementos (en nuestro caso citas). Imaginemos que tenemos un fichero con 5000 citas.
La función tendría que contar una matriz de 5000 posiciones, que aunque para los ordenadores modernos esto no supone apenas tiempo, si puede suponer tiempo para un servidor que recibe muchas peticiones de usuarios a la vez y debe procesar el código muchas veces.
Obviamente, si tus códigos no se aplican en casos tan extremos, no tiene sentido intentar estilizarlos tan extremante, pero nunca esta mal saber como hacerlo y tal vez si nos acostumbramos a programar así, el día que si tengamos una alta demanda de usuarios no tengamos que revisar el código de nuevo.
Por lo tanto, trataremos de evitar la función count() para contabilizar los elementos.
Aunque existe otra función equivalente llamada sizeof() que parece dar mejor rendimiento para hacer este propósito, también intentaremos evitarla.
Para evitar estas dos funciones pesadas utilizaremos la función array_rand() que nos devuelve índices aleatorios de una matriz. Pero nos surge el problema, de que podría devolvernos el índice de la posición vacía.
Para solucionar esto, una primera solución puede ser utilizar unset() que nos remueve una variable, que en nuestro caso sera una posición. Pero para ello, debemos utilizar count() para situarnos en la última posición y de nuevo otra vez, para eliminar la penúltima.
unset ($citas[count($citas)-1]);
unset ($citas[count($citas)-1]);
$valor=$citas[array_rand($citas)];
Esta solución no es muy buena, porque volvemos a utilizar count() y en mayor número de usos(dos veces), con lo que en este caso el remedio es peor que la enfermedad.
En un intento de salvar esta posible solución podríamos ahorrarnos un count() con el siguiente código:
$aux=count($citas);
unset($citas[$aux-1]);
unset($citas[$aux-2]);
$valor=$citas[array_rand($citas)];
Aún así esta solución utiliza el count() que queremos evitar por todos los medios.
Podríamos pensar, que como la función vista anteriormente split() reconoce expresiones regulares, podríamos utilizar “\n.” en lugar de “\n”. De este modo el carácter punto, que representa cualquier símbolo, conseguiría que no obtuviéramos ninguna posición vacía en la matriz. El único inconveniente, es que para el final de cada frase se cogería el primer carácter de la siguiente, para hacer el troceado, con lo que tendríamos que añadir un carácter extra al comenzar la frase y a partir de la primera. Algo muy incomodo y no muy limpio. Por lo que desechamos esta solución.
Otra posible solución, sería utilizar la función array_slice() que extrae una porción de la matriz y quedarnos con todas las posiciones útiles. Por ejemplo utilizando el siguiente código:
$num = array_slice($citas,0, -2);
valor=$citas[array_rand($num)];
De esta forma, evitaríamos el count() y utilizaríamos array_rand(), pero esta solución es incorrecta ya que array_slice() devuelve un valor mixed que es un pseudo-valor que puede aceptar diferentes tipos. Por lo que array_rand() necesita recibir un array y tendríamos que convertir el mixed a array. Esto se puede hacer con la función array() pero al hacer la conversión, creará un array de una sola posición, con lo que no nos servira de nada esta solución.
Después de tantos quebraderos de cabeza se nos puede ocurrir una solución rápida como la siguiente:
do
{
$valor = $citas[array_rand($citas)]
}while(empty($valor));
Pero no veo a ningún programador profesional haciendo códigos tan mediocres. Este código lo que hace es intentar obtener un valor aleatorio de las citas con array_rand() y en caso de que sea vacío, comprobándolo en cada iteración con la función empty() que determina si una variable es vacía, entonces intentará obtener otro nuevo valor, hasta que consiga uno que no sea vacío. Esto realmente funciona y aunque existe una probabilidad muy pequeña de que la posición sea vacía, no es un código digno y limpio.
Bien, a alguien se le podría ocurrir, que si queremos evitar el bucle, pues que simplemente si la posición es vacía, escogemos la posición anterior, como podemos apreciar en este código mediante un if ternario:
$i = array_rand($citas);
$valor = !empty($citas[$i]) ? $citas[$i] : $citas[$i-1];
El problema es que en el caso de que cayera en el valor vacío, estaríamos condicionando a que el último valor tuviera mas probabilidad de aparecer; en un millón, es poca probabilidad, pero y ¿en 10 posiciones?, la probabilidad estaría trucada.
Bueno, pues veamos otra posible solución.
En el sitio de PHP recomiendan utilizar la función explode() que hace exactamente lo mismo que split() cuando al trocear no necesitemos utilizar expresiones regulares. Como hemos visto, nuestra función split() no las necesita, ya que únicamente trocea por el carácter \n luego podemos utilizarla en su lugar ya que ganaremos algo de eficiencia.
$citas=explode("\n",obtener_citas());
$valor=$citas[mt_rand(0,(count($citas)-2))];
Pero esto, también nos volverá a obligar a utilizar count(). Por lo que tenemos que recolocar la segunda línea, para evitar el count().
Si pensamos detenidamente, nuestro problema principal es el elemento de la matriz que permanece vacía, por lo que debemos concentrarnos en él y tratar de solucionarlo para no tener tantos problemas en un futuro.
Para ello, podríamos evitar los espacios blancos al principio y al final de la cadena con trim() y aprovechar la potencia de explode(). Ello nos garantiza que podamos utilizar posteriormente array_random() con lo que obtenemos la solución definitiva y más optimizada.
$citas=explode("\n",trim(obtener_citas()));
$valor=$citas[array_rand($citas)];
Esta solución parece que de momento es la mejor solución que he encontrado, pero si alguna mente avezada que lee estas humildes líneas consigue una mejor solución siempre es bienvenida.
El proceso para obtener una buena solución ha sido largo y complicado, pero realmente ha merecido la pena, por lo que recomiendo a los lectores que se molesten en pensar un poco cuando escriben programas ya que hacerlo bien a la primera vez ahorrara mucho tiempo en el futuro.
Actualización: Un posible resultado para mostrarlo podría ser:
< ?
$citas=explode("\n",trim(file_get_contents('citas.txt')));
echo 'La cita de hoy:'.$citas[array_rand($citas)].'
';
?>
¿como quedaría entonces el código completo para mostrar ya la cita? si fuese alguien tan amable de poner tal cual quedaría..
@paco: he actualizado la entrada poniendo un ejemplo final como pedías.
otra solución seria escanear el array anteriormente creado con la función array_filter() y asi eliminar directamente los espacios vacíos.
luego se utiliza la función array_rand() para seleccionar aleatoriamente uno de los valores del array.
y por ultimo lo imprimimos.