29

Siempre he visto las funciones recursivas con malos ojos, y las he rechazado optando por otras opciones.

¿Por qué iba a hacer esto?:

foo(0);
function foo(cont){
  console.log(cont);
  cont++;
  if(cont<50){
    foo(cont);
  }
}

Pudiendo hacer esto:

foo();
function foo(){
  for(let cont=0;cont<50;cont++){
    console.log(cont);
  }
}

He buscado información, y he encontrado que las funciones recursivas pueden servir para optimizar Tail Call Optimization Recursion functional javascript

A lo cual no le encuentro mucho sentido, si en los snippets anteriores simulamos un bucle mayor por ejemplo: cont < 99999. La función recursiva da error antes de llegar al final mientras que el for la completa.

Y en cambio si simulamos un bucle infinito, el for no lo realiza mientras que la función recursiva sí lo intenta y de nuevo da error.

x3k
  • 3,732
  • 10
  • 38
  • 7
    Si usas intérpretes de JavaScript anteriores a ES2015, los mismos no están optimizados para usar [Tail Recursion](https://en.wikipedia.org/wiki/Tail_call), sin embargo a partir de ES2015 (ES6) la optimización de las llamadas de cola son parte del lenguaje. El problema ahora está en que no todos los motores de los navegadores han implementado dicha optimización. Chrome lo tiene en proyecto, y me parece que solo Safari para iOS ha actualizado su motor para la optimización. Es por ello que vas encontrar problemas a la hora de intentar llamadas recursivas, aunque sean de cola. Saludos – Mauricio Contreras Jul 11 '19 at 08:27
  • Al revés. Las recursiones se optimizan usando TCO y requieren engines recientes, optimizados para TCO. Ya decir si usar una función recursiva es buena o mala práctica, es independiente del lenguaje: las funciones recursivas pueden ser la solución al problema adecuado de implementación, limitado por lo que dé el contexto de ejecución (en el caso de JS, te va a dar stackOverflow si la recursión tiene muchos niveles) – Alfabravo Jul 15 '19 at 18:44
  • Que ingrata es la vida, en los albores de la programación no existía la recursividad y cuando se crearon los primeros lenguajes donde podías aplicarla todos querían programas en ellos para conocer como aplicarla ;)). – fwBasic Jul 19 '19 at 05:15

2 Answers2

19

Decir que es mala practica es exagerado pero si puedes evitarlo, mejor.

Me explico,

El uso de funciones recursiva llenan el call stack mas rapido y muy probablemente lanzen el error Maximum call stack size exceeded de no ser controlado.

Otra desventaja de las funciones recursivas es que hara el call stack muy largo lo que dificultaria el debuggin porque tendrias que buscar entre todos los registros en cual iteracion fallo, algo que no ayuda mucho para resolver un problema. Por eso el uso de una funcion recursiva debe de estar limitado a recursiones no muy prolongadas.

El tamaño maximo del stack es especifico por navegador. Por ejemplo chrome puede aceptar 21k mientras que opera 32k.

Para evitar eso estan los bucles while, for y foreach que no guardan en el call stack cada linea de codigo ejecutado por iteracion por lo que no tienen limite de ejecucion ya que no llenan el stack y tambien hacen facil identificar un error ya que el stack es muy reducido en comparacion con la recursion.


Actualizacion 1:

¿Puedo deducir que es una mala practica?

Si te interesa reducir el tamaño del stack se pudiera decir que si. Si puedes evitar recursion, mejor.

¿Es mejor evitarlas o tienen un uso especifico para lo cual son ideales?

Los bucles pueden resolver un problema igual que como lo haria la recursion y con menos costos por lo que es mejor un bucle que una recursion.

¿setInterval al ser un bucle entra en el grupo de while, for, foreach y no guarda el stack?

setInterval no es un bucle, sino que guarda la referencia de la funcion en una tarea que se ejecuta cuando el bucle principal de javascript esta disponible y no agrega en el stack porque al final se utiliza la misma referencia de la funcion.

Einer
  • 20,190
  • 2
  • 14
  • 36
  • Gracias por la respuesta, pero me vienen varias preguntas leyendo tu respuesta. ¿Puedo deducir que es una mala practica? o al menos si no esta bien controlada el numero de recursiones..? ¿Es mejor evitarlas o tienen un uso especifico para lo cual son ideales?¿`setInterval` al ser un bucle entra en el grupo de `while, for, foreach` y no guarda el stack? – x3k Jul 11 '19 at 12:40
  • @x3k_js verifica la respuesta actualizada. – Einer Jul 11 '19 at 14:57
  • 6
    como tal no puedes deducir que es una `mala` o `buena` practica usar recursividad. La `recursividad` en si es una forma de solucionar un problema. Puede que en ciertos casos se aplique mal, pues estará `mal aplicada`. Puede que la forma iterativa de un problema sea demasiado compleja para implementar, y la forma recursiva sea mas practica... – Jakala Jul 15 '19 at 12:45
14

TL;DR

No es mala práctica, es un recurso.
La pregunta en realidad debería haber sido si aplicar recursión es adecuado o no y acotar el contexto a un problema específico.

Un problema es adecuado para ser resuelto con recursión cuando el mismo puede modelarse como una función que se llama a si misma y en cada llamada se acerca un poco más a una condición de corte (donde deja de llamarse a sí misma, para devolver un resultado que es (o es parte) de la solución.

Estado de la función

En los dos casos que estás comparando hay una diferencia fundamental: El estado de la función.

En el caso de la recursión todas las variables (estado) de la función son preservadas cuando se hace la iteración. De esta forma cada vez una llamada termina, se vuelve un paso atrás y el estado es restaurado de forma automática.

En el caso del loop (for / while) el estado cambia con cada iteración, y si se quisiera preservar el estado vigente (los valores de las variables en ese momento) para volver atrás (backtracking) en un futuro, se debe usar alguna estructura auxiliar como un vector que haga de stack, y poner ahí objetos con las variables que necesitamos. Con lo que de alguna forma terminamos haciendo lo mismo que el algoritmo recursivo pero de forma más complicada.

El ejemplo expuesto es muy trivial, pero en este caso el estado viene a estar representado por la variable count, y no hay muchos motivos para recordar ese estado para este ejercicio mas que para saber cuando cortar.

Para resolver otros problemas como evaluar movidas en un tablero de ajedrez (un alfil que puede ir tanto para la derecha como para izquierda por ejemplo), preservar el estado en cada loop o iteración es importante.

También para resolver problemas de lógica donde se prueban todas las alternativas (finitas o hasta un nivel x de profundidad de llamadas) hasta dar con una que resuelve el problema (o determinar que no tiene solución).

Si bien esta pregunta se acota a javascript, otros lenguajes como Prolog tienen la recursión como parte central del lenguaje.

Por esto, la recursión es un recurso que es adecuado o no adecuado para un problema determinado, y no una buena o mala práctica a nivel general.

Ejemplo (Problema del barquero)

Para dar un ejemplo voy a usar un problema conocido:

"Un pastor tiene que cruzar un lobo, una cabra, y una planta de lechuga de un lado al otro de un río usando un bote / barcaza donde sólo entran el pastor y uno de los tres elementos a cruzar (lobo, cabra, o lechuga). La cuestión es qué, por motivos obvios, no pueden quedar solos el lobo con la cabra, ni la cabra con la lechuga. ¿Cómo puede hacer el pastor para cruzar a los 3 elementos?"

Tip: El pastor puede cruzar solo en el bote.

Para resolver este ejercicio necesitamos:

1) Modelar el estado del problema:

Puede servir un array de 4 elemntos (LOBO, CABRA, LECHUGA, PASTOR) donde cada elemento puede ser verdadero si está en la margen izquierda del río, y falso, si está en la margen derecha del mismo.

2) Determinar un estado inicial, que es este array con todos valores veraderos (Todos están en la margen izquierda).

3) Determinar un estado final, que es el mismo array con todos valores falsos (Todos han logrado cruzar a la margen derecha).

4) Partiendo del estado inicial, iterar todos los cruces posibles hasta dar con en estado final (la solución), o hasta que ya no queden cruces posibles para efectuar. Los cruces posibles son aquellos donde el elemento a cruzar está del mismo lado que el pastor, y que luego de cruzar nadie se pueda comer a nadie.

Nota: En este problema pueden producirse ciclos (lobo y pastor van y vienen de un lado al otro del río continuamente) por lo que hay tener un control extra para detectar estos ciclos y romperlos.

Es importante notar que, por lo menos para estas soluciones, se deben "recordar" los estados previos que van llevando a cada nuevo estado. Esto es porque luego de probar todos los cruces posibles desde un estado dado, si ninguno de ellos llegó a una solución, entonces ese estado debe descartarse y se debe continuar probando con el estado anterior.

En el programa hecho con recursividad el estado y el camino quedan en call stack del programa, junto con la línea que se estaba ejecutando y el valor del subíndice del for que indica el elemento que se estaba cruzando.

En el programa hecho con while el estado, junto con el camino y el elemento que se estaba cruzando se guardan en un vector que funciona como un stack (LIFO) dentro del mismo programa. La línea del programa es irrelevante en esta versión dado que todo se ejecuta en un mismo loop y el estado a evaluar en cada iteración va cambiando ya sea generando uno nuevo (y almacenando el vigente) ó restaurando uno anteriormente almacenado en el vector.

El programa planteado con recursividad:

function cruzarElRio(estado,camino){
    logCamino(camino);
    logEstado("cruzando río -> ", estado);

    // Se controla que nadie se coma a nadie
    if(estadoValido(estado) === false){
        console.log("El estado es inválido.");
        return -1;
    }

    //  Que no hayamos caido en este estado anteriormente
    if(cicloDetectado(estado, camino) === true ){
        console.log("El estado ya fue evaluado (ciclo).");
        return -1;
    }

    // agregamos el estado a la copia local del camino.
    camino.push(estado);

    // checkear si este estado es el final
    if(cruzaron(estado) === true){
        console.log("Solución encontrada!!");
        return camino;
    }

    // Probar cruzando de a uno por vez
    for(let i=0; i<4; i++){
        if(puedeCruzar(i, estado)){
            let proxEstado = cruzar(i, estado);     
            let proxCamino = camino.map( (x) => x); // Hace una copia del array.
            let res = cruzarElRio(proxEstado, proxCamino);
            if(res !== -1) return res; 
        }
    }
    console.log("El camino no tiene solución. Se retorna al camino previo.");
    return -1;

}

En el for(let i=0; i<4; i++){ se trata de cruzar a cada elemento uno por vez y para cada cruce se genera un nuevo estado modificado que se usa para llamar a la función recursiva.

Cuando esto ocurre, los valores locales antes de la llamada (estado, camino, y el índice del for) son preservados en el call stack sin que tenga que hacer nada adicional.

Si resulta que la invocación no lleva a una solución, en algún momento la función vuelve a este punto, y los valores de estado, camino, y el índice for son restaurados automáticamente, e incluso la función retoma desde dentro del for en la linea siguiente a la llamada recursiva.

La solución planteada con un bucle: (Probablemente el código se pueda mejorar un poco)

function cruzarRioConLoops(estado, camino){
    let stack = [];
    let end = false;

    let quienActual = 0;

    while( end === false ){
        logCamino(camino);
        logEstado("cruzando río -> ", estado);

        if(estadoValido(estado) === false || cicloDetectado(estado, camino) === true ){
            console.log("El estado es inválido o existe un ciclo.");
            if(stack.length > 0){
                let stackItem = stack.pop();
                estado = stackItem.estado;
                camino = stackItem.camino;
                quienActual = stackItem.quienActual;
                quienActual++;
                continue;
            }else{
                end = true;
                continue;
            }
        }

        if(cruzaron(estado) === true){
            console.log("Solución encontrada!!");
            camino.push(estado);
            return camino;
        }

        if( quienActual < 4 ){
            if(puedeCruzar(quienActual, estado)){
                let proxEstado = cruzar(quienActual, estado);       
                let proxCamino = camino.map( (x) => x); // Hace una copia del array.
                proxCamino.push(estado);
                let stackItem = {
                    "estado": estado,
                    "camino": camino,
                    "quienActual": quienActual
                }
                stack.push(stackItem);
                estado = proxEstado;
                camino = proxCamino;
                quienActual = 0;
                continue;       
            } else {
                quienActual++;
                continue;
            }
        }else{
            if(stack.length > 0){
                let stackItem = stack.pop();
                estado = stackItem.estado;
                camino = stackItem.camino;
                quienActual = stackItem.quienActual;
                quienActual++;
                continue;
            }else{
                end = true;
                continue;
            }
        }   
    }
}

En esta versión todo el programa corre en un ciclo. quienActual representa el elemento a cruzar en la iteración. Aquellos elementos habilitados para cruzar generaran nuevos estados a testear. A diferencia de la versión anterior el estado vigente se reemplaza en cada iteración. El reemplazo puede deberse a:

  • Un cruce que generó un nuevo estado. En este caso el estado previo se guarda en el vector (stack) haciendo un push() antes del reemplazo.

  • Un camino sin salida que deriva en la restauración de un estado anterior haciendo un pop() y el avance del quienActual para intentar el cruce con el elemento siguiente.

El código completo:

/* Verifica que todos estén del otro lado del río.
 */
function cruzaron(estado){
 return estado.orillaIzq[LOBO] === false
        && estado.orillaIzq[CABRA] === false
        && estado.orillaIzq[LECHUGA] === false
        && estado.orillaIzq[PASTOR] === false
}

/*
 El lado actual es donde está el PASTOR que es quien cruza las cosas
 en la barca.
*/
function ladoActual(estado){
 return (estado.orillaIzq[PASTOR] === true) ? "I" : "D";
}

/*
 Solo determina si "quien" está del mismo lado del PASTOR de forma
 de estar habilitado a ser cruzado al otro lado
*/
function puedeCruzar(quien, estado){
 let lado = ladoActual(estado);
 if(lado === "I"){
  if(estado.orillaIzq[quien] === false) return false; 
 }else{
  if(estado.orillaIzq[quien] === true) return false;
 }
 return true;
}

/*
  Verifica que el estado no rompa alguna condición. Que la cabra 
  no se coma la lechuga, y que el lobo no se coma a la cabra.
*/
function estadoValido(estado){
 if( estado.orillaIzq[LOBO] === estado.orillaIzq[CABRA] 
     && estado.orillaIzq[PASTOR] !== estado.orillaIzq[LOBO] ){
  return false;
 }
 
 if( estado.orillaIzq[CABRA] === estado.orillaIzq[LECHUGA] 
     && estado.orillaIzq[PASTOR] !== estado.orillaIzq[CABRA] ){
  return false;
 }
 
 return true;
}

/*
 Permite comparar dos estados para saber si son iguales o no.
*/
function eqEstado(e1, e2){
 return e1.orillaIzq[LOBO] === e2.orillaIzq[LOBO]
        && e1.orillaIzq[CABRA] === e2.orillaIzq[CABRA]
     && e1.orillaIzq[LECHUGA] === e2.orillaIzq[LECHUGA]
     && e1.orillaIzq[PASTOR] === e2.orillaIzq[PASTOR];
}

/*
  Este problema permite que pueda entrarse en un ciclo (que el lobo no dede de ir y venir de
  un lado al otro por ejemplo). Al buscar el estado nuevo en el "historial" del camino, podemos
  detectar esto y romper el ciclo.
*/
function cicloDetectado(proxEstado, camino){
 for(let i=0; i<camino.length; i++){
  if(eqEstado(camino[i], proxEstado)){
   return true;
  }
 }
 return false;
}

/*
 Mueve a "quien" junto con el pastor al otro lado del río.
 Devuelve un nuevo estado (no altera el pasado por parámetro)
*/
function cruzar(quien, estado){
 let nuevaOrillaIzq = estado.orillaIzq.map( (x) => x );
 let nuevoEstado = {"orillaIzq": nuevaOrillaIzq};
 if( quien !== PASTOR ){
  nuevoEstado.orillaIzq[quien] = !nuevoEstado.orillaIzq[quien];
 }
 nuevoEstado.orillaIzq[PASTOR] = !nuevoEstado.orillaIzq[PASTOR];
 return nuevoEstado;
}

/*
 Muestra el Estado en la consola.
*/
function logEstado(msg, estado){
 let iz = "";
 let da = "";
 let quien = [" LOBO ", " CABRA ", " LECHUGA ", " PASTOR "];
 for(let i=0; i<4; i++){
  if(estado.orillaIzq[i] === true){
   iz += quien[i];
  }else{
   da += quien[i];
  }
 }
 console.log("Estado: " + msg + " " + iz + "/~~~/" + da);
}

/*
 Muestra un camino (lista de estados) en la consola
*/
function logCamino(camino){
 console.log("Camino:");
 for(let i=0; i<camino.length; i++){
  logEstado(""+i, camino[i]);
 }
}


function cruzarRioConLoops(estado, camino){
 let stack = [];
 let end = false;
 
 let quienActual = 0;
 
 while( end === false ){
  logCamino(camino);
  logEstado("cruzando río -> ", estado);
  
  if(estadoValido(estado) === false || cicloDetectado(estado, camino) === true ){
   console.log("El estado es inválido o existe un ciclo.");
   if(stack.length > 0){
    let stackItem = stack.pop();
    estado = stackItem.estado;
    camino = stackItem.camino;
    quienActual = stackItem.quienActual;
    quienActual++;
    continue;
   }else{
    end = true;
    continue;
   }
  }
  
  if(cruzaron(estado) === true){
   console.log("Solución encontrada!!");
   camino.push(estado);
   return camino;
  }
  
  if( quienActual < 4 ){
   if(puedeCruzar(quienActual, estado)){
    let proxEstado = cruzar(quienActual, estado);  
    let proxCamino = camino.map( (x) => x); // Hace una copia del array.
    proxCamino.push(estado);
    let stackItem = {
     "estado": estado,
     "camino": camino,
     "quienActual": quienActual
    }
    stack.push(stackItem);
    estado = proxEstado;
    camino = proxCamino;
    quienActual = 0;
    continue;  
   } else {
    quienActual++;
    continue;
   }
  }else{
   if(stack.length > 0){
    let stackItem = stack.pop();
    estado = stackItem.estado;
    camino = stackItem.camino;
    quienActual = stackItem.quienActual;
    quienActual++;
    continue;
   }else{
    end = true;
    continue;
   }
  } 
 }
}

/**
 La función recursiva. 
 Si el estado es inválido o si presenta ciclos, se retorna y retoma el camino anterior.
 De otro modo, se verifica si llegamos al estado final donde todos cruzaron al otro lado.
 Si todos cruzaron se devuelve el camino que nos llevó hasta acá.
 Si no se llegó se generan nuevos estados para cada uno de los posibles "quien" que pueden cruzar el río.
 Y se llama a la función recursiva con el nuevo estado y con una copia del camino que será la copia local de 
 nueva iteración. 
 La función invocada puede devolver -1 si no se llegó a un resultado, o un array con el camino que llevó a la 
 solución.
*/
function cruzarElRio(estado,camino){
 logCamino(camino);
 logEstado("cruzando río -> ", estado);
 
 if(estadoValido(estado) === false){
  console.log("El estado es inválido.");
  return -1;
 }
 
 if(cicloDetectado(estado, camino) === true ){
  console.log("El estado ya fue evaluado (ciclo).");
  return -1;
 }
 
 camino.push(estado);
 
 if(cruzaron(estado) === true){
  console.log("Solución encontrada!!");
  return camino;
 }
 
 for(let i=0; i<4; i++){
  if(puedeCruzar(i, estado)){
   let proxEstado = cruzar(i, estado);  
   let proxCamino = camino.map( (x) => x); // Hace una copia del array.
   let res = cruzarElRio(proxEstado, proxCamino);
   if(res !== -1) return res; 
  }
 }
 console.log("El camino no tiene solución. Se retorna al camino previo.");
 return -1;

}

let LOBO = 0, CABRA = 1, LECHUGA = 2, PASTOR = 3;

function recursivo(){
  let camino = [];

  let estado = {
    "orillaIzq" : [true, true, true, true],
  }
  
  let res = cruzarElRio(estado, camino);
  if(res === -1) {
    console.log("sin solución");
  } else {
    logCamino(res);
  }
}

function conWhile(){

  let camino = [];

  let estado = {
    "orillaIzq" : [true, true, true, true],
  }
  
  let res = cruzarRioConLoops(estado, camino);
  if(res === -1) {
    console.log("sin solución");
  } else {
    logCamino(res);
  }
}
button{
width: 200px;
margin: 10px;
}
<p>Como cruzar un lobo, una cabra, y una lechuga de un lado al otro del río sin que la cabra se quede sola con el lobo, ni que la lechuga se quede sola con la cabra y utilizando un bote/barcaza con dos lugares.</p>
<p>Resultado en log de la consola</p>
<button onclick="recursivo()">Recursivo</button><br>
<button onclick="conWhile()">Con While</button>
x3k
  • 3,732
  • 10
  • 38
Juan
  • 5,605
  • 1
  • 9
  • 15
  • Muchas gracias por la respuesta. Me imaginaba que el uso de la recursión tendría un uso especifico para el cual sería mas ideal que usar los loops, si bien creo entender lo que quieres decir con "preservar el estado", no lo tengo muy claro. ¿Podrías poner un ejemplo con recursión y otro sin? Para que quede mas claro. – x3k Jul 16 '19 at 17:40
  • Fijate la edición II. – Juan Jul 17 '19 at 23:04