UT03 - Uso de estructuras de control

Conexión legislativa

En esta Unidad de Trabajo nos centraremos en PROG-CE-03.a, PROG-CE-03.b, PROG-CE-03.c, PROG-CE-03.e, PROG-CE-03.f, PROG-CE-03.g:


Introducción

Un programa no tiene por qué ejecutar siempre la misma secuencia de instrucciones, ni tiene por qué ejecutar todas las instrucciones. Hasta ahora, la ejecución de nuestros programas ha sido estrictamente secuencial, instrucción a instrucción, desde la primera línea del main hasta la última.

"Cualquier algoritmo computable puede expresarse combinando únicamente tres estructuras de control básicas: Secuencia, Selección e Iteración" (Böhm-Jacopini)

En esta unidad nos centraremos en las estructuras de control. Estas nos permiten alterar el flujo secuencial por defecto. Gracias a ellas, los programas pueden tomar decisiones en función de determinadas condiciones, o repetir bloques de código un número determinado de veces.

Las estructuras de control se dividen fundamentalmente en 2 grupos:

  1. Estructuras de selección (o condicionales): evalúan una expresión booleana y deciden qué camino o rama del código se debe ejecutar.
  2. Estructuras de iteración (de repetición o bucles): ejecutan un bloque de instrucciones repetidamente mientras se cumpla una condición dada.

Cuando se introducen este tipo de estructuras, el programa deja de ejecutarse de manera secuencial y se crean diferentes flujos de ejecución: cada estructura de selección ramifica las posibles rutas o alternativas que el programa puede seguir, y cada estructura de iteración hace que un conjunto de acciones se realicen de manera repetida un determinado número de veces. Esto complica mucho la lectura del programa y empiezan a aparecer errores semánticos.

Los errores semánticos son aquellos que se producen cuando un programa es sintácticamente correcto (el compilador no muestra ningún fallo y genera el código ejecutable), pero no hace lo que el programador pretendía. A diferencia de los errores de compilación, el compilador es incapaz de detectarlos porque las reglas del lenguaje no se han violado: el fallo reside en la lógica del algoritmo.

Al introducir estructuras de selección e iteración, la multiplicidad de flujos de ejecución incrementa la complejidad lógica del programa y favorece la aparición de este tipo de errores. Es por ello que necesitamos herramientas para facilitar su detección. Estas son las herramientas de depuración.

Las herramientas de depuración (debugging tools) permiten inspeccionar la ejecución paso a paso de un programa, examinar el estado interno de la memoria RAM en tiempo real y analizar cómo el flujo de control toma una determinada rama o realiza una iteración.


Estructuras de selección

Antes de hablar propiamente de las estructuras de selección conviene repasar lo que son las expresiones lógicas, ya que son uno de los componentes fundamentales de las tomas de decisiones en un programa.

Una expresión lógica es aquella que devuelve como resultado uno de dos valores: true o false. Estas expresiones se construyen mediante operadores relacionales (==, !=, <, >, <=, >=) y operadores lógicos (&&, ||, !).

Las estructuras de selección utilizan el resultado de estas expresiones para seleccionar qué código se ejecuta:

La sentencia if: condicional simple

Es la estructura de selección más básica. Permite ejecutar un bloque de código únicamente si se cumple una condición determinada. Para ello, evalúa una expresión lógica y, si el resultado de dicha expresión es true, ejecuta el bloque de instrucciones:

1. if (condición)  {
2.     bloque de instrucciones
3. 	   ...
4. }
5. ...
  1. Todas las instrucciones que haya antes del if se ejecutarán con normalidad.
  2. Cuando llega el turno a if, se evalúa la condición. El resultado debe ser true o false. De otra manera, el compilador detectará un error sintáctico.
  3. Si el resultado de la evaluación es true, se ejecutará el bloque que contiene if. Este bloque puede albergar cualquier tipo de sentencia, incluído otro if. Tras completar su ejecución, continúa por la instrucción inmediatamente después de la estructura if (línea 5).
  4. Si, por el contrario, la evaluación es false, no se ejecuta el bloque de instrucciones y salta a la instrucción inmediatamente después de la estructura if (línea 5).

ut03-condicional-simple.png300

Un ejemplo con sintaxis de Java podría ser el siguiente:

int edad = 18;
if (edad >= 18) {
	System.out.println("Edad: " + edad);
}

Bloque de instrucciones y ámbito de una variable

Un bloque de instrucciones es un conjunto de 0 o más sentencias agrupadas entre llaves {} que la JVM trata como una única unidad de ejecución. Dentro de un bloque de instrucciones podemos definir otros bloques de instrucciones.

Las variables declaradas dentro de un bloque de instrucciones sólo pueden utilizarse en dicho bloque. A esto se lo conoce como ámbito o alcance (scope) de una variable. Hasta ahora, todas nuestras variables han estado declaradas en main. A estas se las conoce como variables locales. Las variables declaradas en un bloque de instrucciones reciben el nombre de variables de bloque. El funcionamiento de ambas es idéntico, la única distinción es su ámbito.

int edad = 18;
if (edad >= 18) {
	int variableDentro = 0;
	System.out.println("Edad: " + edad);
	System.out.println(variableDentro);
}
System.out.println(edad);
System.out.println(variableDentro);

Sentencia if-else: condicional doble

Existe otra versión de la sentencia if denominada if-else, el cual nos permite especificar dos bloques de instrucciones. Cuál de los dos bloques se ejecute depende de la evaluación de la expresión lógica. Si el resultado es true, se ejecutará el primer bloque. Si el resultado es false, se ejecutará el segundo bloque.

1. if (condición) {
2.     bloque de instrucciones A
3. } else {
4.     bloque de instrucciones B
5. }
6. ...
  1. Todas las instrucciones que haya antes del if-else se ejecutarán con normalidad.
  2. Cuando llega el turno a if-else, se evalúa la condición. El resultado debe ser true o false. De otra manera, el compilador detectará un error sintáctico.
  3. Si el resultado de la evaluación es true, se ejecuta el bloque A (línea). Tras completar su ejecución, continúa por la instrucción inmediatamente después de la estructura if-else (línea 6).
  4. Si, por el contrario, la evaluación es false, se omite el bloque A y se ejecuta el bloque B (línea 4). Tras completar su ejecución, continúa por la instrucción inmediatamente después de la estructura if-else (línea 6).

ut03-condicional-doble.png400

Un ejemplo con sintaxis de Java podría ser el siguiente:

int edad = 18;
if (edad >= 18) {
	System.out.println("Mayor de edad");
} else {
	System.out.println("Menor de edad");
}
System.out.println("Es lo que hay");
Equivalencia con el operador ternario

En la unidad anterior vimos cómo funciona el operador ternario. En realidad, el operador ternario es un atajo sintáctico de la estructura condicional doble cuando el único objetivo de ambos bloques es asignar o devolver un valor en función de la condición evaluada.

variable = condición ? valor1 : valor2;

es equivalente a

if (condición) {
	variable = valor1;
} else {
	variable = valor2;
}

Sentencia if-else if-else: condicional múltiple

Cuando necesitamos evaluar más de dos alternativas mutuamente excluyentes, podemos encadenar varias sentencias else if. Esta estructura permite comprobar múltiples condiciones lógicas de manera secuencial, de arriba a abajo, ejecutando únicamente el bloque de instrucciones correspondiente a la primera condición que devuelva true.

1.  if (condición A) {
2.      bloque de instrucciones A
3.  } else if (condición B) {
4.      bloque de instrucciones B
5.  } else if (condición C) {
6.      bloque de instrucciones C
7.  } else {
8.      bloque de instrucciones D
9.  }
10. ...
  1. Todas las instrucciones que haya antes de la estructura se ejecutarán con normalidad.
  2. Al llegar a la sentencia if, se evalúa la condición A. Si el resultado es true, se ejecuta el bloque A (línea 2) e, inmediatamente al terminar, el flujo salta a la instrucción posterior a toda la estructura (línea 10). El resto de condiciones no se llegarán a evaluar.
  3. Si la condición A es false, se ignora el bloque A y el programa pasa a evaluar la condición B. Si resulta ser true, se ejecuta el bloque B (línea 4) y el flujo salta directamente a la línea 10.
  4. Si la condición B también es false, se evalúa la condición C, y así sucesivamente con tantas ramas else if como se hayan definido.
  5. Si todas las condiciones previas devuelven false, se ejecuta el bloque D (línea 8) contenido dentro de la rama else final. Tras completarse, el programa continúa por la línea 10.

ut03-condicional-multiple.png500

Si creamos esta estructura en PSeInt, el pseudocódigo que se generará será algo así:

if (condiciónA) {
	bloque de instrucciones A
} else {
	if (condiciónB) {
		bloque de instrucciones B
	} else {
		if (condiciónC) {
			bloque de instrucciones C
		} else {
			bloque de instrucciones D
		}
	}
}

Esto es así porque, en realidad, la estructura condicional múltiple es una simplificación sintáctica que evita la acumulación excesiva de sangría y llaves de cierre al final del bloque. Cuando el compilador y la JVM se encuentran esta estructura, la "traducen" a una serie de estructuras condicionales dobles (if-else) anidadas dentro de la rama else del condicional anterior, tal y como acabamos de ejemplificar.

Un ejemplo con sintaxis de Java podría ser el siguiente:

double nota = 8.5;

if (nota < 0.0 || nota > 10) {
	System.out.println("Error: La nota debe estar en el rango [0.0, 10.0]");
} else if (nota < 5.0) {
	System.out.println("Suspenso");
} else if (nota < 7.0) {
	System.out.println("Aprobado");
} else if (nota < 9.0) {
	System.out.println("Notable");
} else if (nota <= 10.0) {
	System.out.println("Sobresaliente");
} else {
	System.out.println("Error inesperado");
}

System.out.println("Proceso de evaluación finalizado.");

Este ejemplo se traduciría al siguiente código en el momento de su ejecución:

double nota = 8.5;

if (nota < 0.0 || nota > 10) {
	System.out.println("Error: La nota debe estar en el rango [0.0, 10.0]");
} else {
	if (nota < 5.0) {
		System.out.println("Suspenso");
	} else {
		if (nota < 7.0) {
			System.out.println("Aprobado");
		} else {
			if (nota < 9.0) {
				System.out.println("Notable");
			} else {
				if (nota <= 10.0) {
					System.out.println("Sobresaliente");
				} else {
					System.out.println("Error inesperado");
				}
			}
		}
	}
}


System.out.println("Proceso de evaluación finalizado.");

Como puede apreciarse, es bastante más difícil de leer, estéticamente menos vistoso y propenso a errores al cerrar las llaves. Precisamente para evitar este anidamiento excesivo (conocido como "la pirámide del infierno"), existe la sintaxis if-else if-else.

La importancia del orden de evaluación

Dado que las condiciones se evalúan secuencialmente de arriba hacia abajo y el flujo abandona la estructura en la primera coincidencia (true), el orden de las condiciones es determinante. Colocar una condición demasiado general al principio puede hacer inalcanzables las condiciones posteriores, provocando un error semántico.

Opcionalidad del bloque else final

El bloque else del final es opcional. Sin embargo, su uso se considera una buena práctica de programación para controlar el caso por defecto o capturar situaciones no previstas en las condiciones anteriores.

Sentencia switch: condicional de selección múltiple

Hay ocasiones en las que utilizar estructuras if, if-else o if-else if-else puede producir un código poco legible y difícil de mantener. Suele suceder cuando queremos evaluar el valor de una única variable o expresión contra múltiples valores constantes posibles.

A diferencia de las estructuras if, que evalúan expresiones en las que se comprueba la pertenencia a un rango de valores, o una desigualdad, o una combinación de varias comparaciones mediante operadores lógicos, la sentencia switch tradicional realiza únicamente comprobaciones de igualdad exacta (==).

1. switch (expresión) {
2.     case valor1:
3.         bloque de instrucciones A
4.         break;
5.     case valor2:
6.         bloque de instrucciones B
7.         break;
8.     ...
9.     default:
10.        bloque de instrucciones por defecto
11. }
12. ...
  1. Todas las instrucciones que haya antes del switch se ejecutarán con normalidad.
  2. Al llegar a la sentencia switch, se evalúa la expresión situada entre paréntesis.
  3. La JVM busca la primera etiqueta case cuyo valor coincida exactamente con el resultado de dicha evaluación. Si la encuentra, el flujo salta directamente a las instrucciones de ese case.
  4. La sentencia break; interrumpe el flujo y hace que el programa abandone inmediatamente la estructura switch, saltando a la instrucción posterior (línea 12).
  5. Si ninguna etiqueta case coincide con el valor evaluado, el programa ejecutará el bloque contenido dentro de la etiqueta default: (línea 10).

Si no se pone el break, el flujo de ejecución continúa por el siguiente bloque de instrucciones independientemente de si la etiqueta de ese siguiente case coincide o no con la expresión evaluada. A este comportamiento se le conoce como el efecto fall-through (o caída a través de casos).

ut03-switch.png500

Un ejemplo usando la sintaxis de Java podría ser el siguiente:

byte diaSemana = 4;
switch (diaSemana) {
	case 1:
		System.out.println("Lunes");
		break;
	case 2:
		System.out.println("Martes");
		break;
	case 3:
		System.out.println("Miércoles");
		break;
	case 4:
		System.out.println("Jueves");
		break;
	case 5:
		System.out.println("Viernes");
		break;
	case 6:
		System.out.println("Sábado");
		break;
	case 7:
		System.out.println("Domingo");
		break;
	default:
		System.out.println("Día no válido");
}

Efecto fall-through

Si omitimos la sentencia break; al final de un case, el flujo de ejecución no abandona la estructura switch, sino que se "desliza" hacia las instrucciones del siguiente case y las ejecuta de manera secuencial, independientemente de que ese valor coincida o no con el case.

Este comportamiento se denomina efecto fall-through. Cuando se produce por despiste, constituye un error semántico habitual. Un ejemplo de ello:

byte diaSemana = 4;
switch (diaSemana) {
	case 1:
		System.out.println("Lunes");
	case 2:
		System.out.println("Martes");
	case 3:
		System.out.println("Miércoles");
	case 4:
		System.out.println("Jueves");
	case 5:
		System.out.println("Viernes");
	case 6:
		System.out.println("Sábado");
	case 7:
		System.out.println("Domingo");
	default:
		System.out.println("Día no válido");
}

En este caso, al no existir ninguna sentencia break;, el flujo de ejecución buscará el primer caso coincidente (case 4) e imprimirá "Jueves". Sin embargo, en lugar de salir de la estructura, "caerá" secuencialmente ejecutando los bloques de las etiquetas inferiores. Por lo tanto, la salida generada por consola será:

Jueves
Viernes
Sábado
Domingo
Día no válido

Uso intencionado del fall-through

Aunque suele ser una fuente común de errores semánticos, el efecto fall-through se puede aprovechar de forma deliberada cuando se desea que múltiples casos compartan exactamente el mismo bloque de instrucciones:

byte diaSemana = 4;

switch (diaSemana) {
    case 1:
    case 2:
    case 3:
    case 4:
    case 5:
        System.out.println("Día laborable");
        break;
    case 6:
    case 7:
        System.out.println("Fin de semana");
        break;
	default:
		System.out.println("Día no válido");
}

En este ejemplo, para cualquier valor de diaSemana entre 1 y 5, el flujo irá cayendo secuencialmente hasta ejecutar la línea de "Día laborable." y finalizar con el break;.

Tipos de datos permitidos

En la versión tradicional de la sentencia switch, la expresión entre paréntesis no admite cualquier tipo de dato. Únicamente acepta:

Tipos de datos no permitidos

Los tipos primitivos de coma flotante (float y double), el tipo entero de gran tamaño (long) y el tipo booleano (boolean) no son válidos en la sentencia switch tradicional. Su uso provocará un error de compilación.

Reglas de case y default

Algunas reglas importantes sobre los valores válidos en case son las siguientes:

Con respecto a default: es opcional y actúa como cajón de sastre cuando ningún caso coincide. Aunque la sintaxis permite colocarlo en cualquier posición dentro del bloque, la norma de estilo estandarizada exige situarlo siempre al final.

Evolución al switch moderno

La estructura switch ha sufrido muchos cambios dirigidos a reducir la cantidad de texto que necesita para implementarse (verbosidad), evitar los errores más comunes derivados de la omisión involuntaria del break; y permitir su uso no solo como una instrucción, sino también como una expresión capaz de devolver un valor directo..

Sintaxis con flecha (->) y eliminación del fall-through

La principal novedad sintáctica es la sustitución de los dos puntos (:) por el operador de flecha (->). Cuando se utiliza la sintaxis case valor ->, se aplican automáticamente las siguientes reglas:

  1. Ausencia de fall-through: no es necesario escribir la sentencia break;. La JVM ejecuta únicamente la instrucción o bloque asociado a la flecha y abandona la estructura inmediatamente.
  2. Sintaxis limpia: si la acción asociada a un case consiste en una única instrucción o expresión, no es necesario encerrarla entre llaves {}.
  3. Agrupación de valores separados por comas: se permite especificar múltiples constantes en una misma etiqueta case, eliminando la necesidad de apilar varios case consecutivos.
switch (expresión) {
    case valor1, valor2 -> instrucción_o_bloque_A;
    case valor3 -> instrucción_o_bloque_B;
    default -> instrucción_o_bloque_defecto;
}

Con la sintaxis moderna, el ejemplo anterior queda de la siguiente manera:

byte diaSemana = 4;

switch (diaSemana) {
    case 1, 2, 3, 4, 5 -> System.out.println("Laborable");
    case 6, 7 -> System.out.println("Fin de semana");
    default -> System.out.println("Día no válido");
}

Uso como expresión

En Java moderno, switch se evalúa como una expresión, permitiendo asignar el resultado que produce directamente a una variable.

byte diaSemana = 4;

String nombreDia = switch (diaSemana) {
    case 1  -> "Lunes";
    case 2  -> "Martes";
    case 3  -> "Miércoles";
    case 4  -> "Jueves";
    case 5  -> "Viernes";
    case 6  -> "Sábado";
    case 7  -> "Domingo";
    default -> "Día no válido";
}; // Requiere punto y coma al final por ser una sentencia de asignación

System.out.println("El día seleccionado es: " + nombreDia);
Exhaustividad

Cuando se utiliza como una expresión para devolver un valor, es obligatorio ser completamente exhaustivo. Esto es: hay que devolver un valor en todos los casos posibles. Por ello, el uso de la cláusula default es obligatorio.

Bloques multilínea y la palabra clave yield

Cuando la lógica asociada a un case -> requiere la ejecución de varias instrucciones antes de producir el valor final, se debe utilizar un bloque delimitado por llaves {}. En estos escenarios se utiliza la palabra clave yield, encargada de especificar el valor que devolverá esa rama concreta de la expresión switch:

byte mes = 2;
boolean esBisiesto = true;

int numeroDias = switch (mes) {
    case 1, 3, 5, 7, 8, 10, 12 -> 31;
    case 4, 6, 9, 11 -> 30;
    case 2 -> {
        System.out.println("Evaluando días para el mes de febrero...");
        if (esBisiesto) {
            yield 29; // Devuelve 29 como resultado de la expresión
        } else {
            yield 28; // Devuelve 28 como resultado de la expresión
        }
    }
    default -> {
        System.out.println("Error: Valor de mes fuera de rango.");
        yield 0;
    }
};

System.out.println("Número de días: " + numeroDias);

Estructuras de iteración

Las estructuras de iteración (también denominadas bucles) son mecanismos de control de flujo que permiten ejecutar un bloque de instrucciones de manera repetida mientras una determinada condición lógica continúe siendo verdadera (true). Cada una de las repeticiones individuales de este bloque recibe el nombre de iteración.

En Java existen dos categorías principales de bucles en función de cómo se controla la repetición:

  1. Bucles controlados por condición: la repetición depende exclusivamente del estado de una condición booleana (while y do-while).
  2. Bucles controlados por contador: la repetición está asociada al recorrido de un rango de valores o número prefijado de pasadas (for).

Bucles controlados por condición: while y do-while

La sentencia while: iteración con precondición

El bucle while evalúa una expresión lógica antes de ejecutar el cuerpo de instrucciones. Por esta razón, se le clasifica como una estructura de evaluación previa o precondición. Si la condición se evalúa inicialmente como false, el bloque del bucle no llegará a ejecutarse ni una sola vez.

1. while (condición) {
2.     bloque de instrucciones
3.     ...
4. }
5. ...
  1. Se llega a la instrucción while y se evalúa la condición entre paréntesis.
  2. Si el resultado es true, el flujo de ejecución entra en la estructura y ejecuta secuencialmente las sentencias contenidas en el bloque (líneas 2 y 3).
  3. Al alcanzar la llave de cierre }, el flujo no avanza a la línea 5, sino que salta automáticamente de vuelta a la línea 1 para reevaluar la condición.
  4. Este proceso se repite cíclicamente hasta que la condición se evalúe como false. En ese instante, el bucle finaliza y la ejecución continúa por la línea inmediatamente posterior a la estructura (línea 5).

ut03-while-pre.png300

Ejemplo con sintaxis de Java :

int contador = 1;

while (contador <= 5) {
	System.out.println("Iteración número: " + contador);
	contador++;
}

System.out.println("Fin del bucle while.");
Bucles infinitos

Para que un bucle while termine, debe existir al menos una instrucción dentro del bloque que modifique las variables de la condición para que esta llegue a ser false en algún momento. Si la condición nunca se vuelve false, el programa entra en un bucle infinito.

// ERROR SEMÁNTICO: Se olvidó modificar 'i', provocando un bucle infinito
int i = 0;
while (i < 10) {
    System.out.println("Procesando...");
    // Falta: i++;
}

La sentencia do-while: iteración con postcondición

A diferencia de while, el bucle do-while evalúa la expresión lógica al final del bloque de código. Debido a esta evaluación posterior (postcondición), se garantiza que el bloque de instrucciones se ejecutará al menos una vez, independientemente de si la condición es true o false desde el inicio.

1. do {
2.     bloque de instrucciones
3.     ...
4. } while (condición);
5. ...
  1. El flujo de ejecución entra directamente al bloque do sin realizar ninguna evaluación previa (línea 1).
  2. Se ejecutan todas las instrucciones contenidas dentro del bloque entre llaves (líneas 2 y 3).
  3. Al alcanzar la cláusula while (condición);, se evalúa la expresión lógica entre paréntesis.
  4. Si la condición se evalúa como true, el flujo vuelve al principio del bloque do (línea 1) para realizar una nueva iteración.
  5. Si la condición se evalúa como false, la estructura finaliza y el programa continúa en la instrucción posterior (línea 5).
Punto y coma obligatorio

A diferencia de otras estructuras de control, la sentencia do-while debe finalizar obligatoriamente con un punto y coma ; tras los paréntesis de la condición while(...). Omitirlo provocará un error de compilación.

ut03-while-post.png250

Ejemplo con sintaxis de Java :

int acumulador = 0;
int numeroUsuario;
do {
	numeroUsuario = sc.nextInt();
	acumulador += numeroUsuario;
} while (numeroUsuario != 0);
System.out.println("La suma total es: " + acumulador);

Comparativa while VS do-while

Criterio while do-while
Punto de evaluación Precondición (al inicio) Postcondición (al final)
Iteraciones mínimas 0 1
Sintaxis en Java while (condición) { ... } do { ... } while (condición);

Bucles controlados por contador: for

Salidas anticipadas: instrucciones de salto

Bucles anidados


Depuración de código

Puntos de interrupción: breakpoints

Control del flujo de ejecución

Inspección y evaluación en tiempo de ejecución

under_construction.webp