Blog

Control de flujo en Java: bucles, if y switch expressions

El if y los bucles de Java se parecen a los de C, con algunas reglas que atrapan bugs reales. El switch antiguo cae al siguiente caso por defecto, y las switch expressions lo corrigen y hacen que el compilador revise cada valor de un enum.

El control de flujo es la forma en que un programa decide qué ejecutar después y cuántas veces. El if, el while y el for de Java se ven casi iguales a los de C, pero algunas reglas cambian, y cada una existe para frenar un bug que de otro modo llegaría a producción. El cambio más grande está en switch, que ahora tiene dos formas muy distintas.

Este post cubre if y el operador ternario, la evaluación de cortocircuito de && y ||, todos los tipos de bucle, break y continue con etiquetas, la antigua sentencia switch y su bug de fall-through, y las switch expressions con -> y yield. Termina con una pequeña calculadora de calificaciones que usa todo eso. Cada programa de abajo se ejecutó en Java 25, y su salida está copiada de esa ejecución. Para ejecutar uno tú mismo, guárdalo como Main.java y ejecuta java Main.java.

if necesita un boolean de verdad

Un if en Java ejecuta su bloque cuando una condición es verdadera, y esa condición debe tener el tipo boolean. Esta es la forma completa de if / else if / else:

String describe(int celsius) {
    if (celsius < 0) {
        return "freezing";
    } else if (celsius < 20) {
        return "cool";
    } else {
        return "warm";
    }
}

void main() {
    IO.println(describe(-5));
    IO.println(describe(12));
    IO.println(describe(31));
}

Imprime:

freezing
cool
warm

Las comprobaciones corren de arriba hacia abajo, y gana la primera que sea verdadera. 12 no es menor que 0, así que se salta la primera rama. Sí es menor que 20, así que devuelve "cool" y el else nunca se ejecuta.

En C, cualquier número sirve como condición, y cero significa falso. Java no lo permite. El error de tipeo clásico de C, = donde querías ==, no pasa el compilador:

void main() {
    int x = 3;
    if (x = 5) {
        IO.println("x is five");
    }
}

La compilación falla con:

Main.java:3: error: incompatible types: int cannot be converted to boolean
    if (x = 5) {
          ^

x = 5 es una asignación, y su valor es el int 5. Un int no es un boolean, así que javac se detiene. En C esta línea compila, pone x en 5 y siempre entra en la rama.

Hay un caso que la regla no atrapa. Cuando la variable ya es un boolean, la asignación tiene tipo boolean, y compila:

void main() {
    boolean done = false;
    if (done = true) {
        IO.println("the branch ran, and done is now " + done);
    }
}

Imprime:

the branch ran, and done is now true

Esperábamos al menos una advertencia de lint aquí, y javac -Xlint:all no dio ninguna. La solución es no comparar booleanos. Escribe if (done) o if (!done), y no queda ningún = que teclear mal.

El operador ternario elige uno de dos valores

El operador ternario, condition ? a : b, es un if que produce un valor. Úsalo cuando las dos ramas son cortas y quieres asignar o devolver el resultado:

void main() {
    int items = 1;
    String label = items == 1 ? "item" : "items";
    IO.println(items + " " + label);

    items = 3;
    IO.println(items + " " + (items == 1 ? "item" : "items"));
}

Imprime:

1 item
3 items

La condición igual tiene que ser un boolean. Mantén los ternarios planos. Un ternario dentro de otro es válido, pero un if o una switch expression se lee mejor.

&& y || se detienen antes, & y | no

&& y || se saltan su lado derecho cuando el lado izquierdo ya decide la respuesta. Eso se llama evaluación de cortocircuito. & y | también funcionan con booleanos, pero siempre evalúan los dos lados. La diferencia se nota cuando el lado derecho hace algo:

boolean check(String name, boolean result) {
    IO.println("  checked " + name);
    return result;
}

void main() {
    IO.println("&&:");
    boolean a = check("left", false) && check("right", true);
    IO.println("&:");
    boolean b = check("left", false) & check("right", true);
    IO.println("||:");
    boolean c = check("left", true) || check("right", false);
    IO.println(a + " " + b + " " + c);
}

Imprime:

&&:
  checked left
&:
  checked left
  checked right
||:
  checked left
false false true

Con &&, en cuanto el lado izquierdo es falso, todo es falso, así que right nunca se comprueba. & da la misma respuesta pero ejecuta los dos. || se detiene apenas el lado izquierdo es verdadero.

El cortocircuito es lo que hace segura una comprobación de null seguida de una llamada a un método. Cambia && por & y la protección deja de proteger:

String name = null;

void main() {
    if (name != null && name.length() > 3) {
        IO.println("long name");
    }
    IO.println("the && version is fine");
    if (name != null & name.length() > 3) {
        IO.println("long name");
    }
}

Imprime y se detiene:

the && version is fine
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.length()" because "this.name" is null

La versión con & llama a name.length() aunque name != null era falso. En el código de todos los días, usa && y ||. Guarda & y | para operaciones de bits con enteros.

while, do-while y el for clásico

Java tiene tres bucles que se repiten mientras se cumple una condición, y se diferencian en cuándo revisan esa condición. Un while la revisa antes de cada vuelta, un do-while la revisa después, y un for pone el inicio, la comprobación y el paso en una sola línea:

void main() {
    int n = 3;
    while (n > 0) {
        IO.println("while: " + n);
        n--;
    }

    int tries = 10;
    do {
        IO.println("do-while ran with tries = " + tries);
        tries++;
    } while (tries < 5);

    for (int i = 0; i < 3; i++) {
        IO.println("for: i = " + i);
    }
}

Imprime:

while: 3
while: 2
while: 1
do-while ran with tries = 10
for: i = 0
for: i = 1
for: i = 2

El cuerpo del do-while se ejecutó una vez aunque tries < 5 era falso desde el principio. Eso es lo único que un do-while hace distinto. Encaja con “haz esto y después pregunta si hay que repetir”, como pedir una entrada hasta que sea válida.

La i del bucle for existe solo dentro del bucle. Úsala después de la llave de cierre y javac reporta cannot find symbol.

El for mejorado recorre cada elemento

El for mejorado, escrito for (var x : things), visita cada elemento de un array o de una colección en orden, sin ningún índice que manejar:

void main() {
    int[] scores = {72, 95, 88};
    int total = 0;
    for (int score : scores) {
        total += score;
    }
    IO.println("total: " + total);

    var names = List.of("Ana", "Ben", "Chen");
    for (var name : names) {
        IO.println("hello, " + name);
    }
}

Imprime:

total: 255
hello, Ana
hello, Ben
hello, Chen

Lee los dos puntos como “en”: para cada score en scores. Funciona con arrays y con cualquier cosa que implemente Iterable, lo que incluye List, Set y las demás colecciones. Cuando necesitas la posición además del valor, vuelve al for clásico.

Un bug off-by-one, y la solución

Un bug off-by-one es un bucle que se ejecuta una vez de más o una vez de menos. Con arrays, la causa habitual es <= donde necesitabas <:

void main() {
    String[] days = {"Mon", "Tue", "Wed"};
    for (int i = 0; i <= days.length; i++) {
        IO.println(i + ": " + days[i]);
    }
}

Imprime y se detiene:

0: Mon
1: Tue
2: Wed
Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: Index 3 out of bounds for length 3

Un array de longitud 3 tiene los índices 0, 1 y 2. La condición i <= days.length deja que i llegue a 3, y Java revisa cada acceso a un array, así que lanza una excepción en lugar de leer la memoria que haya después del final. C no lo revisa, así que el mismo bucle allí puede leer basura sin avisar.

La solución es <:

void main() {
    String[] days = {"Mon", "Tue", "Wed"};
    for (int i = 0; i < days.length; i++) {
        IO.println(i + ": " + days[i]);
    }
}

Imprime:

0: Mon
1: Tue
2: Wed

Empieza en 0 y detente antes de la longitud. Si no necesitas i, el for mejorado no puede equivocarse en esto, porque no tiene índice.

break, continue y break con etiqueta

break sale de un bucle de inmediato, y continue se salta el resto de la vuelta actual y empieza la siguiente. Los dos actúan sobre el bucle más interno, y eso es un problema cuando estás dos bucles adentro. Una etiqueta le da nombre a un bucle exterior para que break pueda salir de ese:

void main() {
    for (int i = 1; i <= 6; i++) {
        if (i % 2 == 0) {
            continue;
        }
        if (i == 5) {
            break;
        }
        IO.println("odd: " + i);
    }

    int[][] grid = {{1, 2, 3}, {4, 42, 6}, {7, 8, 9}};

    for (int[] row : grid) {
        for (int value : row) {
            if (value == 42) {
                IO.println("plain break: found 42");
                break;
            }
        }
        IO.println("plain break: still scanning after a row");
    }

    search:
    for (int r = 0; r < grid.length; r++) {
        for (int c = 0; c < grid[r].length; c++) {
            if (grid[r][c] == 42) {
                IO.println("labeled break: found 42 at row " + r + ", column " + c);
                break search;
            }
        }
    }
    IO.println("done");
}

Imprime:

odd: 1
odd: 3
plain break: still scanning after a row
plain break: found 42
plain break: still scanning after a row
plain break: still scanning after a row
labeled break: found 42 at row 1, column 1
done

El primer bucle se salta los números pares con continue y se detiene en 5 con break, así que solo se imprimen 1 y 3.

El break simple encontró el 42 en la fila del medio y salió solo del bucle interno. El bucle exterior siguió, imprimió su línea y recorrió la última fila para nada. Un break simple no puede llegar más lejos que un bucle.

search: etiqueta el bucle exterior, así que break search sale de los dos bucles a la vez. La siguiente línea que se ejecuta es la que está después del bucle exterior, que imprime done. continue también acepta una etiqueta: continue search saltaría a la siguiente fila.

Las etiquetas son raras en la práctica. Si necesitas una, muchas veces queda más limpio mover los bucles anidados a un método y hacer return cuando encuentras el valor.

La antigua sentencia switch cae al siguiente caso

La antigua sentencia switch salta al case que coincide y después sigue ejecutando cada línea de abajo, pasando por los casos siguientes, hasta que llega a un break. Eso se llama fall-through, y un break olvidado lo convierte en un bug:

@SuppressWarnings("fallthrough")
String dayType(int day) {
    String result = "";
    switch (day) {
        case 6:
            result += "Saturday ";
        case 7:
            result += "Sunday ";
            break;
        default:
            result += "weekday ";
    }
    return result;
}

void main() {
    IO.println("6 -> " + dayType(6).strip());
    IO.println("7 -> " + dayType(7).strip());
    IO.println("2 -> " + dayType(2).strip());
}

Imprime:

6 -> Saturday Sunday
7 -> Sunday
2 -> weekday

El día 6 coincidió con case 6, agregó "Saturday " y siguió de largo hacia case 7, donde agregó también "Sunday ". Nada lo detuvo hasta el break. El día 7 empezó más abajo, así que solo sumó "Sunday ".

La línea @SuppressWarnings("fallthrough") está ahí porque cada programa de esta serie se compila con javac -Xlint:all -Werror. Quítala y esa compilación atrapa el bug:

String dayType(int day) {
    String result = "";
    switch (day) {
        case 6:
            result += "Saturday ";
        case 7:
            result += "Sunday ";
            break;
        default:
            result += "weekday ";
    }
    return result;
}

void main() {
    IO.println(dayType(6));
}

La compilación falla con:

Main.java:6: warning: [fallthrough] possible fall-through into case
        case 7:
        ^
error: warnings found and -Werror specified

Esa advertencia viene apagada por defecto. Un javac Main.java simple no imprime nada, y tampoco java Main.java, que ejecutó la versión con el bug de arriba sin decir una palabra. Si mantienes código con switches antiguos, activa -Xlint:fallthrough.

A veces el fall-through es justo lo que quieres. case 6: case 7: sin nada en medio es la forma antigua de compartir un bloque entre dos etiquetas. El switch nuevo tiene una forma más limpia de decir eso.

Switch expressions: -> y sin fall-through

Un switch con -> ejecuta exactamente un caso y nunca cae en el siguiente. También puede ser una expresión: el switch completo produce un valor que puedes asignar o devolver. Las switch expressions quedaron como definitivas en Java 14. Con javac --release 13, javac las rechaza con switch expressions are not supported in -source 13.

enum Day { MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY }

String dayType(Day day) {
    return switch (day) {
        case SATURDAY, SUNDAY -> "weekend";
        case MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY -> "weekday";
    };
}

void main() {
    IO.println("SATURDAY -> " + dayType(Day.SATURDAY));
    IO.println("SUNDAY -> " + dayType(Day.SUNDAY));
    IO.println("TUESDAY -> " + dayType(Day.TUESDAY));
}

Imprime:

SATURDAY -> weekend
SUNDAY -> weekend
TUESDAY -> weekday

Compáralo con la versión antigua:

  • Sin break. Se ejecuta el lado derecho de ->, y el switch termina. No hay nada que olvidar.
  • Varias etiquetas en un caso. case SATURDAY, SUNDAY reemplaza la antigua pila case 6: case 7:.
  • Sale un valor. return switch (...) { ... }; devuelve lo que haya producido el caso elegido. Fíjate en el punto y coma después de la llave de cierre, porque el switch es parte de una sentencia return.
  • Sin default. Los casos nombran los siete días, y el compilador lo comprobó. Más sobre eso abajo.

No puedes mezclar los dos estilos. Un case 6: y un case 7 -> en el mismo switch fallan con different case kinds used in the switch.

La flecha también funciona en una sentencia switch, sin valor, cuando cada caso solo hace algo. Igual te quedas sin fall-through.

yield devuelve un valor desde un bloque

Cuando un caso necesita más de una expresión, dale un bloque entre llaves y termina el bloque con yield:

enum Day { MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY }

int openingHour(Day day) {
    return switch (day) {
        case SATURDAY -> 10;
        case SUNDAY -> {
            IO.println("  (Sunday: checking the holiday rota)");
            yield 12;
        }
        default -> 9;
    };
}

void main() {
    IO.println("MONDAY opens at " + openingHour(Day.MONDAY));
    IO.println("SUNDAY opens at " + openingHour(Day.SUNDAY));
}

Imprime:

MONDAY opens at 9
  (Sunday: checking the holiday rota)
SUNDAY opens at 12

yield le entrega un valor al switch, igual que return entrega uno desde un método. No puedes usar return para esto, porque return saldría del propio openingHour. Si dejas yield fuera del bloque, javac reporta switch rule completes without providing a value.

Aquí default está bien, porque “todos los demás días abre a las 9” es la regla real. La siguiente sección muestra cuándo default esconde un bug.

Explicado como si tuvieras diez años

Imagina el switch antiguo como un tobogán alto con una plataforma en cada nivel: un nivel para el sábado, uno debajo para el domingo, uno más abajo para los días de semana. Te subes en tu nivel. Después te deslizas, y sigues deslizándote por cada nivel de abajo, recogiendo lo que haya en cada uno, hasta que un break te atrapa como una red. Si olvidas la red, te deslizas hasta el fondo.

El switch nuevo es un pasillo de puertas separadas. Hay una puerta para el sábado, una para el domingo y una para los días de semana. Abres la que tiene tu nombre y entras a esa habitación. La habitación no tiene un agujero en el piso, así que no puedes terminar en la habitación de al lado.

La versión precisa

En una antigua sentencia switch, las etiquetas case son solo puntos de entrada a un único bloque de sentencias. El switch salta a la etiqueta que coincide y ejecuta las sentencias en orden desde ahí. Un break salta fuera del bloque. Sin él, la ejecución sigue con las sentencias de la etiqueta siguiente, porque para el compilador son solo las líneas que vienen.

En un switch con ->, cada caso es una regla separada. El lado derecho es una expresión, un bloque o un throw, y cuando termina, el control sale del switch. En una switch expression, además, cada regla debe producir un valor, directamente o con yield, o lanzar una excepción.

Dónde falla la analogía: puedes construir a propósito un tobogán con una plataforma compartida por dos niveles, y el switch nuevo cubre eso con case SATURDAY, SUNDAY, una puerta con dos nombres. Las puertas también tienen una regla que la analogía no muestra: en una switch expression, el compilador comprueba que haya una puerta para cada valor posible antes de que el programa se ejecute.

Switch sobre strings y enums

Sin patrones, un switch funciona con int, short, byte, char, sus clases envoltorio, String y enums. Un long no está en la lista: en Java 25, javac rechaza un switch sobre un long con primitive patterns are a preview feature and are disabled by default. Los strings se comparan con equals, así que el caso coincide por el texto, no por el objeto:

String run(String command) {
    return switch (command) {
        case "start", "go" -> "starting";
        case "stop" -> "stopping";
        default -> "unknown command: " + command;
    };
}

void main() {
    IO.println(run("go"));
    IO.println(run("stop"));
    IO.println(run("jump"));
    IO.println(run(null));
}

Imprime y se detiene:

starting
stopping
unknown command: jump
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.hashCode()" because "<local2>" is null

Las tres primeras llamadas se comportan como esperas. El null no llega a default. Lanza una excepción, y el mensaje revela cómo funciona un switch sobre strings. javac copia command en una variable oculta, llama a hashCode() sobre ella para elegir un caso candidato y después confirma la coincidencia con equals. La variable oculta no tiene nombre, así que el mensaje la llama <local2>. Si null es una entrada real, compruébalo antes del switch. La parte sobre sealed types y pattern matching muestra case null para los switches con patrones.

Una switch expression sobre un enum debe cubrir cada constante

Una switch expression sobre un enum sin default tiene que listar todas las constantes. Si falta una, el código no compila. Aquí se dejó fuera SUNDAY:

enum Day { MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY }

String dayType(Day day) {
    return switch (day) {
        case SATURDAY -> "weekend";
        case MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY -> "weekday";
    };
}

void main() {
    IO.println(dayType(Day.SUNDAY));
}

La compilación falla con:

Main.java:4: error: the switch expression does not cover all possible input values
    return switch (day) {
           ^

Una switch expression debe producir un valor para cada entrada, y el compilador conoce la lista completa de constantes de Day, así que ve que SUNDAY no tiene adónde ir. Por eso el dayType de antes no necesitaba default. También por eso conviene casi siempre dejar default fuera de una switch expression sobre un enum. Si el próximo año agregas una constante HOLIDAY, cada switch que la olvidó deja de compilar. Un default le daría a HOLIDAY la respuesta por defecto sin avisar.

Esta comprobación aplica a las switch expressions. Una sentencia switch al estilo antiguo sobre un enum, con -> o con :, todavía puede saltarse constantes, y un valor que falta simplemente no ejecuta nada. La parte sobre sealed types y pattern matching cubre los switches sobre tipos, donde la misma comprobación de exhaustividad hace más trabajo.

Un ejemplo completo: una calculadora de calificaciones

Una calculadora de calificaciones junta casi todo este post en un programa. Recorre una lista de puntajes, convierte cada uno en una letra con una switch expression, rechaza los puntajes fuera de 0 a 100 y cuenta cuántas veces vio cada letra:

char grade(int score) {
    if (score < 0 || score > 100) {
        throw new IllegalArgumentException("score out of range: " + score);
    }
    return switch (score / 10) {
        case 10, 9 -> 'A';
        case 8 -> 'B';
        case 7 -> 'C';
        case 6 -> 'D';
        default -> 'F';
    };
}

void main() {
    var scores = List.of(91, 78, 100, 59, 84, 67, 88, 73, 45, 95);
    var counts = new TreeMap<Character, Integer>();
    int passed = 0;

    for (int score : scores) {
        char letter = grade(score);
        counts.put(letter, counts.getOrDefault(letter, 0) + 1);
        if (letter != 'F') {
            passed++;
        }
    }

    for (var entry : counts.entrySet()) {
        IO.println(entry.getKey() + ": " + "#".repeat(entry.getValue()));
    }
    IO.println(passed + " of " + scores.size() + " passed");

    try {
        grade(101);
    } catch (IllegalArgumentException e) {
        IO.println("rejected: " + e.getMessage());
    }
}

Imprime:

A: ###
B: ##
C: ##
D: #
F: ##
8 of 10 passed
rejected: score out of range: 101

Ahora las piezas:

  • score / 10 es división entera, así que 91 se vuelve 9 y 78 se vuelve 7. Eso convierte 101 puntajes posibles en 11 casos.
  • case 10, 9 -> 'A' le da una A al 100 sin un if aparte.
  • default -> 'F' es correcto aquí. Un int tiene miles de millones de valores, y “todo lo que está por debajo de 60” es un caso general de verdad. La comprobación de rango encima del switch se asegura de que no llegue nada absurdo.
  • El bucle for cuenta en un TreeMap, que mantiene sus claves ordenadas. Por eso las letras se imprimen de la A a la F.
  • El segundo bucle for recorre el map y dibuja un # por puntaje con String.repeat.
  • grade(101) no pasa la comprobación de rango, lanza la excepción, y el catch imprime el mensaje. Sin la comprobación, 101 / 10 da 10, y el 101 se habría ganado una A sin avisar.

Recorrer un Map cuando importa el orden

Recorres un map con for (var entry : map.entrySet()), y el orden que obtienes depende de qué tipo de map es. Un HashMap devuelve las entradas en un orden basado en los códigos hash, que no es el orden en que las insertaste. Map.of(...) es peor para imprimir: su orden cambia de una ejecución de la JVM a otra, y cuatro ejecuciones del mismo programa imprimieron cuatro órdenes distintos cuando lo probamos. Cuando el orden importa, elige el map que promete uno. Un LinkedHashMap conserva el orden de inserción, y un TreeMap mantiene sus claves ordenadas:

void main() {
    var byInsertion = new LinkedHashMap<String, Integer>();
    byInsertion.put("pears", 3);
    byInsertion.put("apples", 5);
    byInsertion.put("figs", 1);

    for (var entry : byInsertion.entrySet()) {
        IO.println("inserted: " + entry.getKey() + " = " + entry.getValue());
    }

    var sorted = new TreeMap<>(byInsertion);
    for (var entry : sorted.entrySet()) {
        IO.println("sorted:   " + entry.getKey() + " = " + entry.getValue());
    }
}

Imprime:

inserted: pears = 3
inserted: apples = 5
inserted: figs = 1
sorted:   apples = 5
sorted:   figs = 1
sorted:   pears = 3

La parte sobre equals, hashCode y colecciones explica por qué un HashMap no tiene un orden útil.

Qué recordar

  • La condición de un if debe ser un boolean. if (x = 5) no compila, pero if (done = true) sí, así que escribe if (done).
  • && y || se saltan el lado derecho cuando el izquierdo decide la respuesta. & y | siempre ejecutan los dos lados.
  • Recorre arrays con i < array.length, no con <=, o usa el for mejorado y olvídate del índice.
  • Un break simple sale solo del bucle más interno. Etiqueta el bucle exterior para salir de los dos.
  • La antigua sentencia switch cae al siguiente caso si no hay break, y nada te avisa a menos que actives -Xlint:fallthrough.
  • Un switch con -> nunca cae al siguiente caso. Como expresión, devuelve un valor, usa yield dentro de un bloque y debe cubrir cada constante del enum, así que deja fuera default cuando el enum es la lista completa.
  • Usa un LinkedHashMap o un TreeMap cuando importa el orden en que recorres un map.

Prefiere el switch con ->: cada caso se ejecuta solo, y el compilador comprueba que no falte ninguno.

¿Qué tan útil te resultó este post?

¡Haz clic en un corazón para calificar!

Calificación promedio 0 / 5. Total de votos: 0

Todavía no hay votos. Sé el primero en calificar este post.