8  Control de flujo

Hasta ahora hemos visto cómo almacenar datos y organizar código en funciones. En este capítulo aprenderemos a controlar el flujo de ejecución de un programa mediante estructuras condicionales y bucles.

8.1 Sentencia if

La sentencia if permite ejecutar código de forma condicional, con sintaxis similar a la de muchos otros lenguajes. Sin embargo, a diferencia de C, C++, JavaScript o Python, la condición debe ser siempre una expresión booleana: Rust no realiza conversiones implícitas entre números y valores booleanos.

En el siguiente ejemplo se comprueba si un número de puerto pertenece al rango de puertos de sistema (del 0 al 1023) o si se trata de un puerto de usuario:

const ULTIMO_PUERTO_SISTEMA: u32 = 1023;

fn main() {
    let puerto = 8080;

    if puerto <= ULTIMO_PUERTO_SISTEMA {
        println!("{} es puerto de sistema", puerto);
    } else {
        println!("{} es puerto de usuario", puerto);
    }
}

La condición aparece entre if y la llave de apertura. Si su valor es true, se ejecuta el primer bloque; en caso contrario, se ejecuta el bloque asociado a else.

8.1.1 if sin else

La cláusula else es opcional. Cuando no se especifica, el bloque de código asociado al if se ejecuta únicamente si la condición es verdadera; en caso contrario, no ocurre nada.

const TEMPERATURA_MAXIMA_CPU: u32 = 80;

fn main() {
    let temperatura_cpu = 72;

    if temperatura_cpu > TEMPERATURA_MAXIMA_CPU {
        println!("Aviso: temperatura excesiva");
    }

    println!("Monitorización completada");
}

En este ejemplo, el mensaje de advertencia solo se muestra cuando la temperatura de la CPU supera el umbral definido por la constante TEMPERATURA_MAXIMA_CPU. La última llamada a println! se ejecuta siempre, independientemente del resultado de la condición.

8.1.2 Cadenas ifelse if

Cuando existen varias alternativas, es posible encadenar varias condiciones mediante cláusulas else if. Las condiciones se evalúan en orden y se ejecuta únicamente el bloque asociado a la primera que resulte verdadera.

const TEMPERATURA_FRIA: u32 = 40;
const TEMPERATURA_NORMAL: u32 = 70;
const TEMPERATURA_CALIENTE: u32 = 85;

fn main() {
    let temperatura_cpu = 72;

    if temperatura_cpu < TEMPERATURA_FRIA {
        println!("CPU fría");
    } else if temperatura_cpu < TEMPERATURA_NORMAL {
        println!("Temperatura normal");
    } else if temperatura_cpu < TEMPERATURA_CALIENTE {
        println!("CPU caliente");
    } else {
        println!("Advertencia: temperatura excesiva");
    }
}

En este ejemplo, las condiciones se evalúan de arriba abajo. Una vez encontrada una condición verdadera, el resto de alternativas ya no se comprueban. Así, para una temperatura de 72 grados, se mostrará el mensaje "CPU caliente".

Las cadenas ifelse if son adecuadas para expresar un número reducido de alternativas. Más adelante veremos la construcción match, que suele resultar más clara cuando existen muchas posibilidades o cuando se desea clasificar valores discretos.

8.1.3 Evitando el anidamiento excesivo

Las sentencias if pueden anidarse sin ninguna restricción. Sin embargo, un exceso de anidamiento dificulta la lectura y el mantenimiento del código. Como regla práctica, uno o dos niveles de anidamiento suelen ser aceptables. Cuando un fragmento de código alcanza tres o más niveles, a menudo es una señal de que existe una forma más clara de expresar la misma lógica.

Muchos programadores siguen la recomendación informal Flat is better than nested (“mejor plano que anidado”). La idea consiste en mantener el flujo de ejecución lo más alineado posible hacia la izquierda, evitando niveles de indentación innecesarios.

Los dos ejemplos siguientes implementan exactamente la misma funcionalidad: clasificar la temperatura de una CPU. Para ello se consideran dos arquitecturas distintas: x86-64 (utilizada por los procesadores Intel y AMD actuales) y ARM, cada una con sus propios umbrales de temperatura.

El primero utiliza varias sentencias if anidadas.

const TEMP_ALTA_X64: u32 = 70;
const TEMP_M_ALTA_X64: u32 = 85;
const TEMP_CRITICA_X64: u32 = 95;

const TEMP_ALTA_ARM: u32 = 60;
const TEMP_M_ALTA_ARM: u32 = 75;
const TEMP_CRITICA_ARM: u32 = 85;

fn main() {
    let es_arm = true;
    let temp_cpu = 78;

    if es_arm {
        if temp_cpu < TEMP_ALTA_ARM {
            println!("temp normal");
        } else if temp_cpu < TEMP_M_ALTA_ARM {
            println!("CPU ALTA");
        } else if temp_cpu < TEMP_CRITICA_ARM {
            println!("CPU muy ALTA");
        } else {
            println!("temp crítica");
        }
    } else {
        if temp_cpu < TEMP_ALTA_X64 {
            println!("temp normal");
        } else if temp_cpu < TEMP_M_ALTA_X64 {
            println!("CPU ALTA");
        } else if temp_cpu < TEMP_CRITICA_X64 {
            println!("CPU muy ALTA");
        } else {
            println!("temp crítica");
        }
    }
}

El segundo expresa cada caso de forma independiente. Aunque ambas soluciones son correctas, preferimos la segunda por considerarla más fácil de leer y mantener.

const TEMP_ALTA_X64: u32 = 70;
const TEMP_M_ALTA_X64: u32 = 85;
const TEMP_CRITICA_X64: u32 = 95;

const TEMP_ALTA_ARM: u32 = 60;
const TEMP_M_ALTA_ARM: u32 = 75;
const TEMP_CRITICA_ARM: u32 = 85;

fn main() {
    let es_arm = true;
    let temp_cpu = 78;

    if es_arm && temp_cpu < TEMP_ALTA_ARM {
        println!("temp normal");
    }

    if es_arm && temp_cpu >= TEMP_ALTA_ARM && temp_cpu < TEMP_M_ALTA_ARM {
        println!("CPU ALTA");
    }

    if es_arm && temp_cpu >= TEMP_M_ALTA_ARM && temp_cpu < TEMP_CRITICA_ARM {
        println!("CPU muy ALTA");
    }

    if es_arm && temp_cpu >= TEMP_CRITICA_ARM {
        println!("temp crítica");
    }

    if !es_arm && temp_cpu < TEMP_ALTA_X64 {
        println!("temp normal");
    }

    if !es_arm && temp_cpu >= TEMP_ALTA_X64 && temp_cpu < TEMP_M_ALTA_X64 {
        println!("CPU ALTA");
    }

    if !es_arm && temp_cpu >= TEMP_M_ALTA_X64 && temp_cpu < TEMP_CRITICA_X64 {
        println!("CPU muy ALTA");
    }

    if !es_arm && temp_cpu >= TEMP_CRITICA_X64 {
        println!("temp crítica");
    }
}

Si en vez de dos niveles de anidamiento como en este ejemplo hubiera tres, cuatro o más, la ventaja del enfoque plano resultaría mucho más evidente.

Por otro lado, la segunda solución no es completamente equivalente a la primera. Al utilizar varias sentencias if independientes, todas las condiciones se evalúan, mientras que en una cadena ifelse if la evaluación se detiene en cuanto se encuentra una condición verdadera.

En este ejemplo la diferencia carece de importancia, ya que las condiciones son simples comparaciones. En general, salvo que existan requisitos específicos de rendimiento o de otro tipo, suele ser preferible la solución más clara y fácil de mantener.

8.1.4 if como expresión

En Rust, if no es únicamente una sentencia de control de flujo. También puede utilizarse como una expresión que produce un valor. Por ejemplo, el siguiente código asigna una cadena distinta según el tipo de puerto:

const ULTIMO_PUERTO_SISTEMA: u32 = 1023;

fn main() {
    let puerto = 8080;

    let categoria = if puerto <= ULTIMO_PUERTO_SISTEMA {
        "sistema"
    } else {
        "usuario"
    };

    println!("{} es puerto de {}", puerto, categoria);
}

Obsérvese que los bloques asociados a if y else no terminan en punto y coma. El valor de la última expresión de cada bloque es el valor producido por toda la construcción if.

Además, ambos bloques deben producir valores compatibles con el mismo tipo. Por ejemplo, no sería válido devolver una cadena en una rama y un número entero en la otra.

const ULTIMO_PUERTO_SISTEMA: u32 = 1023;

fn main() {
    let puerto = 8080;

    let categoria = if puerto <= ULTIMO_PUERTO_SISTEMA {
        "sistema" ;  // ¡MAL! Si acaba en ; ya no es una expresión
    } else {
        2;  // ¡MAL! El tipo es distinto al de la rama "then"
    };

    println!("{} es puerto de {}", puerto, categoria);
}

8.2 Bucles

Los bucles permiten ejecutar repetidamente un bloque de código. Rust proporciona tres construcciones para ello: loop, while y for, con propósitos diferentes. loop representa una repetición incondicional, while ejecuta el bloque mientras se cumpla una condición y for está pensado para recorrer los elementos de una secuencia. En la práctica, for suele ser la opción preferida cuando se desea iterar sobre una colección o un rango de valores.

8.2.1 El bucle loop

La construcción loop ejecuta repetidamente un bloque de código. A diferencia de otros bucles, no incorpora ninguna condición de terminación, por lo que continuará ejecutándose indefinidamente mientras no se abandone explícitamente mediante una instrucción break.

fn main() {
    let mut contador = 0;

    loop {
        contador += 1;
        print!("hola ");

        if contador == 5 {
            break;
        }
    }
    println!("Fin del bucle.");
}

En este ejemplo, en cada iteración se incrementa la variable contador y se muestra el texto hola en pantalla mediante la sentencia print!, que a diferencia de println!, no añade un salto de línea. Cuando el contador alcanza el valor 5, la instrucción break interrumpe la ejecución del bucle.

Si eliminásemos la instrucción break, tendríamos un bucle infinito que solo se detendría con una interrupción externa, por ejemplo pulsando Ctrl+c.

En el siguiente ejemplo vemos cómo, además de finalizar el bucle, break puede devolver un valor: al igual que loop, la construcción loop también una expresión y, mediante break, puede producir un valor.

fn main() {
    let mut contador = 0;

    let resultado = loop {
        contador += 1;

        if contador == 5 {
            break contador;
        }
    };

    println!("{}",resultado);
}

En este caso, cuando contador alcanza el valor 5, el bucle termina y la expresión loop produce dicho valor, que se asigna a la variable resultado.

8.2.2 El bucle while

La construcción while ejecuta repetidamente un bloque de código mientras una condición sea verdadera. Antes de cada iteración se evalúa la condición y, si resulta falsa, el bucle finaliza.

fn main() {
    let mut contador = 1;

    while contador <= 5 {
        println!("{}",contador);
        contador += 1;
    }
}

En este ejemplo, el cuerpo del bucle se ejecuta mientras la variable contador sea menor o igual que 5. En cada iteración se muestra su valor y se incrementa en una unidad. Cuando contador alcanza el valor 6, la condición deja de cumplirse y el bucle termina.

Conceptualmente, este bucle es completamente análogo a los anteriores con loop, pero expresando la condición de terminación directamente en la cabecera del bucle.

8.2.3 El bucle for

La construcción for permite recorrer secuencias de valores de forma sencilla y segura. Es el mecanismo habitual para iterar sobre colecciones y rangos.

Un programador procedente de lenguajes como C o Java tal vez haría algo así:

fn main() {
    let puertos = ["22/tcp", "80/tcp", "443/tcp"];

    for i in 0..puertos.len() {
        println!("{}", puertos[i]);
    }
}

Este código es correcto y produce el resultado esperado. Sin embargo, no es la forma habitual de iterar sobre una colección en Rust. Siempre que sea posible, se prefiere recorrer directamente sus elementos como en el siguiente ejemplo. En cada iteración, la variable nombre toma el valor de uno de los elementos.

fn main() {
    let puertos = ["22/tcp", "80/tcp", "443/tcp"];

    for puerto in puertos {
        println!("{}", puerto);
    }
}

También es posible utilizar for para recorrer un rango de valores enteros.

  • El operador .. crea un rango entre dos enteros, excluyendo el límite superior.

  • El operador ..= es similar, pero incluyendo el límite superior.

fn main() {
    for i in 1..5 {
        // De 1 a 4
        print!("{} ", i);
    }
    println!();

    for i in 1..=5 {
        // De 1 a 5
        print!("{} ", i);
    }
    println!();

    for i in (1..=5).rev() {
        // de 5 a 1
        print!("{} ", i);
    }
    println!();
}
  • El rango 1..5 contiene los valores 1, 2, 3 y 4. El extremo superior no forma parte del rango.

  • El rango 1..=5 contiene todos los valores entre 1 y 5, ambos inclusive.

  • El método rev() devuelve los elementos del rango en sentido descendente.

8.3 Resumen

En este capítulo hemos estudiado las estructuras de control de flujo que permiten decidir qué instrucciones ejecutar y repetir un conjunto de acciones.

Las ideas principales son las siguientes:

  • La sentencia if permite ejecutar código de forma condicional.
  • En Rust, if también es una expresión, por lo que puede devolver un valor.
  • Las cadenas ifelse if permiten expresar decisiones con múltiples alternativas.
  • El bucle loop repite un bloque de código indefinidamente hasta que se ejecuta un break.
  • El bucle while repite una acción mientras se cumpla una condición.
  • El bucle for es la forma más habitual de recorrer los elementos de una colección o los valores de un rango.
  • Las sentencias break y continue permiten modificar el comportamiento de los bucles cuando es necesario.

Estas estructuras constituyen la base para desarrollar algoritmos capaces de tomar decisiones y realizar tareas repetitivas de forma clara, segura y expresiva.