Capítulo 3: Control de Flujo
Las proposiciones de control de flujo de un lenguaje especifican el orden en que se realiza el procesamiento. Ya hemos visto la mayoría de las construcciones de control de flujo en ejemplos anteriores; aquí completaremos el conjunto, y seremos más precisos acerca de las discutidas con anterioridad.
3.1 Proposiciones y bloques
Una expresión como x = 0
ó i++
o printf(…)
se convierte en una proposición cuando va seguida de un punto y coma ;
, como en
x = 0; i++; printf(...);
En C, el punto y coma ;
es un terminador de proposición, en lugar de un separador, como lo es en un lenguaje tipo Pascal.
Las llaves {
y }
se emplean para agrupar declaraciones y proposiciones dentro de una proposición compuesta o bloque, de modo que son sintácticamente equivalentes a una proposición sencilla. Las llaves que encierran las proposiciones de una función son un ejemplo obvio; otros ejemplos son las llaves alrededor de proposiciones múltiples después de un if
, else
, while
o for
. (Pueden declararse variables dentro de cualquier bloque; esto se expondrá en el capítulo 4). No hay punto y coma después de la llave derecha que termina un bloque.
3.2 If-Else
La proposición if-else
se utiliza para expresar decisiones. Formalmente, la sintaxis es
if (expresión) proposición1 else proposición2
donde la parte del else
es optativa. La expresión se evalúa; si es verdadera (esto es, si la expresión tiene un valor diferente de cero), la proposición1 se ejecuta. Si es falsa (expresión es cero) y si existe una parte de else
, se ejecuta en su lugar la proposición2.
Puesto que un if
simplemente prueba el valor numérico de una expresión, son posibles ciertas abreviaciones de código. Lo más obvio es escribir
if (expresión)
en lugar de
if (expresión != 0)
Algunas veces esto es claro y natural; otras puede ser misterioso.
Debido a que la parte else
de un if-else
es optativa, existe una ambigüedad cuando un else
se omite de una secuencia if
anidada. Esto se resuelve al asociar el else
con el if
sin else
anterior más cercano. Por ejemplo, en
if (n > 0) if (a > b) z = a; else z = b;
el else
va con el if
más interno, como se muestra con el sangrado. Si eso no es lo que se desea, se deben utilizar llaves para forzar la asociación correcta:
if (n > 0) { if (a > b) z = a; } else z = b; 4
La ambigüedad es especialmente perniciosa en situaciones como esta:
if (n > 0) for (i = 0; i < n; i++) if (s[i] > 0) { printf("..."); return i; } else /* EQUIVOCADO */ printf("error -- n es negativo\n");
El sangrado muestra en forma inequívoca lo que se desea, pero el compilador no entiende el mensaje y asocia el else
con el if
más interno. Puede ser difícil encontrar esta clase de errores; es una buena idea utilizar llaves cuando hay varios if
anidados.
A propósito, nótese que hay un punto y coma ;
después de z = a
en
if (a > b) z = a; else z = b;
Esto se debe a que gramaticalmente al if
le sigue una proposición, y una expresión como “z = a;
” siempre se termina con punto y coma ;
.
3.3 Else-if
La construcción
if (expresión) proposición else if (expresión) proposición else if (expresión) proposición else if (expresión) proposición else proposición
ocurre de modo tan frecuente que bien vale una pequeña discusión aparte. Esta secuencia de proposiciones if
es la forma más general de escribir una decisión múltiple. Las expresiones se evalúan en orden; si cualquier expresión es verdadera, se ejecuta la proposición asociada con ella, y esto termina toda la cadena. Como siempre, el código para cada proposición es una proposición simple o un grupo dentro de llaves.
La parte del último else
maneja el caso “ninguno de los anteriores” o caso por omisión cuando ninguna de las otras condiciones se satisface. En algunos casos no hay una acción explícita para la omisión; en ese caso el último
else
proposición
puede omitirse, o puede utilizarse para detección de errores al atrapar una condición “imposible” .
Para ilustrar una decisión de tres vías, se muestra a continuación una función de búsqueda binaria que decide si un valor particular de x
se encuentra en el arreglo ordenado v
- Los elementos de v
deben estar en orden ascendente. La función regresa la posición (un número entre 0
y n-1
) si x
ocurre en v
, y -1
si no es así.
La búsqueda binaria primero compara el valor de entrada x
con el elemento medio del arreglo v
. Si x
es menor que el valor del medio, la búsqueda se enfoca sobre la mitad inferior de la tabla; de otra manera lo hace en la mitad superior, cualquier caso, el siguiente paso es comparar a x
con el elemento medio de la mitad seleccionada. Este proceso de dividir en dos continúa hasta que se encuentra el valor o ya no hay elementos.
/* binsearch: encuentra x en v[0] <= v[1] <= ... <= v[n-1] */ { int low, high, mid; low = 0; high = n - 1; while (low <= high) { mid = (low+high)/2; if (x < v[mid]) high = mid + 1; else if (x > v[mid]) low = mid + 1; else /* el elemento fue encontrado */ return mid; } return -1; /* no fue encontrado */ }
La decisión fundamental es si x
es menor que, mayor que o igual al elemento medio v[mid]
en cada paso; esto es un else-if
natural.
- Ejercicio 3-1. Nuestra búsqueda binaria realiza dos pruebas dentro del ciclo, cuando una podría ser suficiente (al precio de más pruebas en el exterior). Escriba una versión con sólo una prueba dentro del ciclo y mida la diferencia en tiempo de ejecución. □
3.4 Switch
La proposición switch
es una decisión múltiple que prueba si una expresión coincide con uno de un número de valores constantes enteros, y traslada el control adecuadamente.
switch (expresión) { case exp-const: proposiciones case exp-const: proposiciones default: proposiciones )
Cada case
se etiqueta con uno o más valores constantes enteros o expresiones constantes enteras. Si un case
coincide con el valor de la expresión, la ejecución comienza allí. Todas las expresiones case
deben ser diferentes. El que está etiquetado como default
se ejecuta sí ninguno de los otros se satisface. El default
es optativo; si no está y ninguno de los casos coincide, no se toma acción alguna. Las cláusulas case
y default
pueden ocurrir en cualquier orden.
En el capítulo 1 se escribió un programa para contar las ocurrencias de cada dígito, espacio en blanco y todos los demás caracteres, usando una secuencia de if … else if … else
. Aquí está el mismo programa con un switch
:
#include <stdio.h> main() /* cuenta dígitos, espacios blancos, y otros */ { int c, i, nwhite, nother, ndigit[10]; nwhite = nother = 0; for (i = 0; i < 10; i++) ndigit[i] = 0; while ((c = getchar()) != EOF) { switch (c) { case '0': case '1': case '2': case '3': case '4': case '5': case '6': case '7': case '8': case '9': ndigit[c-'0']++; break; case ' ': case '\n': case '\t': nwhite++; break; default: nother++; break; } } printf("digits ="); for (i = 0; i < 10; i++) printf(" %d", ndigit[i]); printf(", espacio en blanco = %d, otros = %d\n", nwhite, nother); return 0; }
La proposición break
provoca una salida inmediata del switch
. Puesto que los case
sirven sólo como etiquetas, después de que se ejecuta el código para Uno, la ejecución pasa al siguiente, a menos que se tome una acción específica para terminar el switch
. Las formas más comunes de dejar un switch
son break
y return
. Una proposición break
también se puede emplear para forzar una salida inmediata de los ciclos while
, for
y do
, como se verá más adelante en este capítulo.
Pasar a través de los case
es en parte bueno y en parte no. Por el lado positivo, esto permite conectar varios case
a una acción simple, como con los dígitos de este ejemplo. Pero eso también implica que cada case normalmente debe terminar con un break
para prevenir pasar al siguiente. Pasar de un case
a otro no es una práctica muy sólida y es susceptible a la desintegración cuando se modifica el programa. Con la excepción de etiquetas múltiples para un cálculo simple, lo anterior se debe utilizar con cautela y emplear comentarios.
Como formalidad, coloque un break
después del último case
(en este caso el default
) aun si desde ek punto de vista lógico resulta innecesario. Algún día - cuando se agregue otro case
al final - esta práctica de programación defensiva lo salvará.
- Ejercicio 3-2. Escriba una función
escape(s,t)
que convierte caracteres como nueva línea y tabulación en secuencias de escape visibles como\n
y\t
mientras copia la cadenat
as
. Utilice unswitch
. Escriba también una función para la dirección inversa, convirtiendo secuencias de escape en caracteres reales. □
3.5 Ciclos - While y For
Ya hemos encontrado los ciclos while
y for
. En
while (expresión) proposición
la expresión se evalúa. Si es diferente de cero, se ejecuta la proposición y se reevalúa la expresión. Este ciclo continúa hasta que la expresión se hace cero, punto en el cual se suspende la ejecución para continuar después de la proposición.
La proposición for
for (expr1; expr2; expr3) proposición
es equivalente a
expr1; while (expr2) { proposición expr3; }
excepto por el comportamiento de continue
que se describe en la sección 3.7.
Gramaticalmente, las tres componentes de un ciclo for
son expresiones. Por lo común, expr1 y expr3 son asignaciones o llamadas a función y expr2 es una expresión de relación. Cualquiera de las tres partes se puede omitir, aunque deben permanecer los punto y coma ;
. Si se omite expr1 o expr3, sólo se desecha de la expansión. Si la prueba expr2 no está presente, se toma como permanentemente verdadera, así que
for (;;) { ... }
es una iteración “infinita”, que presumiblemente será interrumpida por otros medios, como un break
o un return
.
El usar while
o for
es principalmente cuestión de preferencia personal. Por ejemplo, en
while ((c = getchar()) == ' ' || c == '\n' || c = '\t') ; /* ignora caracteres espaciadores */
no hay inicialización o reinicialización, por lo que el while
es más natural.
El for
se prefiere cuando existe una inicialización simple e incrementos, puesto que mantiene las proposiciones de control del ciclo juntas y visibles al principio del mismo. Esto es más obvio en
for (i = 0; i < n; i++) ...
que es la forma característica de procesar los primeros n elementos de un arreglo en C, lo análogo al ciclo DO
de Fortran o al for
de Pascal. Sin embargo, la analogía no es perfecta puesto que tanto el índice como el límite de un ciclo for
en C pueden ser alterados desde dentro del ciclo, y la variable del Índice retiene su valor cuando por cualquier razón terminan las iteraciones. Puesto que las componentes del for
son expresiones arbitrarias, sus ciclos no están restringidos a progresiones aritméticas. Por otra parte, considere que es un mal estilo incluir en las secciones de inicialización e incremento operaciones no relacionadas con esas actividades, que más bien se reservan para acciones de control del ciclo.
Como un ejemplo más amplio, aquí está otra versión de atoi
para convertir una cadena a su equivalente numérico. Esta es ligeramente más general que la del capítulo 2; trata también los espacios en blanco previos al número, y los signos +
o -
. (El capitulo 4 muestra atof
, que realiza la misma conversión para números de punto flotante).
La estructura del programa refleja la forma de la entrada:
- ignora espacios en blanco, si los hay
- toma el signo, si lo hay
- toma la parte entera y conviértela
Cada paso realiza su parte, y deja las cosas en forma clara para el siguiente. La totalidad del proceso termina con el primer carácter que no pueda ser parte de un número.
#include <ctype.h> /* atoi: convierte s a entero; versión 2 */ int atoi(char s[]) { int i, n, sign; for (i = 0; isspace(s[i]); i++) /* ignora espacio en blanco * / ; sign = (s[i] == '-') ? -1 : 1; if (s[i] == '+' || s[i] == '-') /* ignora el signo */ i++; for (n = 0; isdigit(s[i]); i++) n = 10 * n + (s[i] - '0'); return sign * n; }
La biblioteca estándar proporciona una función más elaborada, strtol
, para la conversión de cadenas a enteros largos; véase la sección 5 del apéndice B.
Las ventajas de mantener centralizado el control del ciclo son aún más obvias cuando existen ciclos anidados. La siguiente función es una clasificación Shell para ordenar un arreglo de enteros. La idea básica de este algoritmo de ordenamiento - inventado en 1959 por D.L. Shell - es que en las primeras etapas sean comparados elementos lejanos (en lugar de los adyacentes, como en los ordenamientos de intercambio más simples). Esto tiende a eliminar rápidamente gran cantidad de desorden, así que los estados posteriores tienen menos trabajo por hacer. El intervalo entre los elementos comparados disminuye en forma gradual hasta uno, punto en el que el ordenamiento viene a ser efectivamente un método adyacente de intercambio.
/* shellsort: ordena v[0]...v[n-1] en orden ascendente */ void shellsort(int v[], int n) { int gap, i, j, temp; for (gap = n/2; gap > 0; gap /= 2) for (i = gap; i < n; i++) for (j=i-gap; j>=0 && v[j]>v[j+gap]; j-=gap) { temp = v[j]; v[j] = v[j+gap]; v[j+gap] = temp; } }
Existen tres ciclos anidados. El más externo controla el espacio entre los elementos comparados, reduciéndolo desde n/2
por un factor de dos en cada paso hasta que llega a cero. El ciclo intermedio recorre los elementos. El ciclo más interno compara cada pareja de elementos que está separada por el espacio gap
e invierte a las que estén desordenadas. Puesto que gap
finalmente se reduce a uno, todos los elementos se ordenan correctamente. Nótese cómo la generalidad del for
hace que el ciclo más externo coincida con la forma de los otros, aun cuando no es una progresión aritmética.
Un último operador de C es la coma ,
que frecuentemente encuentra uso en la proposición for
. Una pareja de expresiones separadas por una coma se evalúa de izquierda a derecha, y el tipo y valor del resultado son el tipo y valor del operando derecho. Así, en una proposición for
es posible colocar expresiones múltiples en las diferentes partes, por ejemplo, para procesar dos índices en paralelo. Esto se ilustra en la función reverse(s)
, que invierte a la cadena s
en el mismo lugar.
#include <string.h> /* reverse: invierte la cadena s en el mismo lugar */ void reverse(char s[]) { int c, i, j; for (i = 0, j = strlen(s)-1; i < j; i++, j--) { c = s[i]; s[i] = s[j]; s[j] = c; } }
Las comas que separan a los argumentos de una función, las variables en declaraciones, etc., no son operadores coma, y no garantizan evaluación de izquierda a derecha.
Los operadores coma deberán utilizarse poco. Los usos más adecuados son en construcciones fuertemente relacionadas una con la otra, como en el ciclo for
de reverse
, y en macros en donde un cálculo de paso múltiple debe ser una expresión simple. Una expresión coma podría también ser apropiada para el intercambio de elementos en reverse
, donde el intercambio puede ser a través de una operación simple:
for (i = 0, j = strlen(s)-1; i < j; i++, j--) c = s[i], s[i] = s[j], s[j] = c;
- Ejercicio 3-3. Escriba la función
expand(sl,s2)
que expande notación abreviada comoa-z
, que viene en la cadenas1
, en la lista equivalente completaabc…xyz
ens2
. Permita letras mayúsculas y minúsculas, así como dígitos, y esté preparado para manejar casos comoa-b-c
ya-z0-9
y—a—z
. Haga que los guiones-
al inicio o al final se tomen literalmente. □
3.6 Ciclos - do-while
Como ya se expuso en el capítulo 1, los ciclos while
y for
verifican al principio la condición de término. En contraste, el tercer ciclo en C, el do-while
, prueba al final después de realizar cada paso a través del cuerpo del ciclo, el cual se ejecuta siempre por lo menos una vez.
La sintaxis del do es
do proposición while (expresión);
La proposición se ejecuta y después se evalúa la expresión. Si es verdadera, la proposición se evalúa de nuevo, y así sucesivamente. Cuando la expresión se hace falsa, el ciclo termina. Excepto por el sentido de la prueba, el do-while
es equivalente a la proposición repeat-until
de Pascal.
La experiencia demuestra que el do-while
es mucho menos utilizado que el while
y el for
. Aunque de cuando en cuando es valioso, como en la siguiente función itoa
, que convierte un número a una cadena de caracteres (lo inverso de atoi
). El trabajo es ligeramente más complicado de lo que podría pensarse en un
principio, debido a que los métodos fáciles para generar dígitos los generan en el orden incorrecto. Hemos elegido generar la cadena al revés y después invertirla.
/* itoa: convierte n a caracteres en s */ void itoa(int n, char s[]) { int i, sign; if ((sign = n) < 0) /* registra el signo */ n = -n; /* hace a n positivo */ i = 0; do { /* genera digitos en orden inverso */ s[i++] = n % 10 + '0'; /* obtiene siguiente dígito */ } while ((n /= 10) > 0); /* lo borra */ if (sign < 0) s[i++] = '-'; s[i] = '\0'; reverse(s); }
El do-while
es necesario, o al menos conveniente, puesto que por lo menos se debe instalar un carácter en el arreglo s
, aun si n
es cero. También empleamos llaves alrededor de la proposición simple que hace el cuerpo del do-while, aunque son innecesarias, y así el lector apresurado no confundirá la sección del while
con el comienzo de un ciclo while
.
- Ejercicio 3-4. En una representación de números en complemento a dos, nuestra versión de
itoa
no maneja el número negativo más grande, esto es, el valor den
igual a-( 2^tamañopalabra-1)
. Explique por qué. Modifíquelo para imprimir el valor correctamente, sin importar la máquina en que ejecute. □ - Ejercicio 3-5. Escriba la función
itob(n,s,b)
que convierte al enteron
en una representación de caracteres con baseb
dentro de la cadenas
. En particular,itob(n,S,16)
da formato as
como un entero hexadecimal ens
. □ - Ejercicio 3-6. Escriba una versión de
itoa
que acepte tres argumentos en lugar de dos. El tercer argumento es un ancho mínimo de campo; si es necesario, al número convertido debe agregársele caracteres en blanco a la izquierda para hacerlo lo suficientemente ancho. □
3.7 Break y Continue
Algunas veces es conveniente tener la posibilidad de abandonar un ciclo de otra manera que no sea probando al inicio o al final. La proposición break
proporciona una salida anticipada de un for
, while
y do
, tal como lo hace el switch
.
Un break
provoca que el ciclo o switch
más interno que lo encierra termine inmediatamente.
La siguiente función, trim
, elimina espacios blancos, tabuladores y nuevas líneas al final de una cadena, utilizando un break
para salir de un ciclo cuando se encuentra el no-blanco, no-tabulador o no-nueva línea de más a la derecha.
/* trim: elimina blancos, tabuladores y nueva linea al final */ int trim(char s[]) { int n; for (n = strlen(s)-1; n >= 0; n--) if (s[n] != ' ' && s[n] != '\t' && s[n] != '\n') break; s[n+1] = '\0'; return n; }
strlen
regresa la longitud de la cadena. El ciclo for
inicia al final y rastrea hacia atrás, buscando el primer carácter que no sea blanco o tabulador o nueva linea.
El ciclo se interrumpe cuando se encuentra alguno o cuando n se hace negativa (esto es, cuando se ha rastreado toda la cadena. Se deberá verificar que este comportamiento es correcto, aun cuando la cadena esté vacía o sólo contiene espacios en blanco.
La proposición continue
está relacionada con el break
, pero se utiliza menos; provoca que inicie la siguiente iteración del ciclo for
, while
o do
que la contiene. Dentro de while
y do
, esto significa que la parte de la prueba se ejecuta inmediatamente; en el for
, el control se traslada al paso de incremento. La proposición continué se aplica solamente a ciclos, no a switch
. Un continue
dentro de un switch
que está a su vez en un ciclo, provoca la siguiente iteración del ciclo.
Como un ejemplo, el siguiente fragmento procesa sólo los elementos no negativos que están en el arreglo a; los valores negativos son ignorados.
for (i = 0; i < n; i++) if (a[i] < 0) /* ignora elementos negativos */ continue; ... /* trabaja elementos positivos */
La proposición continue
se emplea a menudo cuando la parte del ciclo que sigue es complicada, de modo que invertir la prueba y sangrar otro nivel podría anidar profundamente el programa.
3.8 Goto y Etiquetas
C proporciona la infinitamente abusable proposición goto
, y etiquetas para saltar hacia ellas. Formalmente, el goto
nunca es necesario, y en la práctica es casi siempre más fácil escribir código sin él. En este libro no se ha usado goto
alguno.
Sin embargo, hay algunas situaciones donde los goto
pueden encontrar un lugar. La más común es abandonar el procesamiento en alguna estructura profundamente anidada, tal como salir de dos o más ciclos a la vez. La proposición break
no se puede utilizar directamente, puesto que sólo sale del ciclo más interno. Así:
for ( ... ) for ( ... ) { ... if (desastre) goto error; } ... error: /* arregla el desorden /*
Esta organización es útil si el código de manejo de error no es trivial y si los errores pueden ocurrir en varios lugares.
Una etiqueta tiene la misma forma que un nombre de variable y es seguida por dos puntos :
. Puede ser adherida a cualquier proposición de la misma función en la que está el goto
. El alcance de una etiqueta es toda la función.
Como otro ejemplo, considérese el problema de determinar si dos arreglos, a
y b
, tienen un elemento en común. Una posibilidad es
for (i = 0; i < n; i++) for (j = 0; j < m; j++) if (a[i] == b[j]) goto encontrado; /* no se encontró ningún elemento común */ ... encontrado: /* se encontró uno: a[i] == b[j] */ ...
El código que involucra un goto
siempre puede escribirse sin él, aunque tal vez al precio de algunas pruebas repetidas o variables extra. Por ejem plo, la búsqueda en los arreglos quedará
encontrado = 0; for (i = 0; i < n && !encontrado; i++) for (j = 0; j < m && !encontrado; j++) if (a[i] == b[j]) encontrado = 1; if (encontrado) /* Se encontró uno: a[i-1] == b[j-1] */ ... else /* no se encontró ningún elemento común */ ...
Con pocas excepciones, como las citadas aquí, el código que se basa en proposiciones goto
es generalmente más difícil de entender y de mantener que el código sin ellas. Aunque no somos dogmáticos acerca del asunto, se ve que las proposiciones goto
deben ser utilizadas raramente, si acaso.
Continuar: Capítulo 4