Tabla de Contenidos

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 sere­mos 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 proposi­ció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 separa­dor, 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 equi­valentes 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 encon­trar 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 verdade­ra, se ejecuta la proposición asociada con ella, y esto termina toda la cadena. Co­mo 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 ca­sos 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 bi­naria 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 po­sició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 en­cuentra 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 me­dio v[mid] en cada paso; esto es un else-if natural.

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 con­trol 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 co­mo 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 algu­na. 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 termi­nar 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á.

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 ex­presió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 analo­gí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 componen­tes del for son expresiones arbitrarias, sus ciclos no están restringidos a progresio­nes 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 ac­tividades, 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:

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 ordena­miento - 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 or­denamientos 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 gra­dual 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 eva­lú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 para­lelo. Esto se ilustra en la función reverse(s), que invierte a la cadena s en el mis­mo 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 declara­ciones, 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 expre­sió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;

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 ejecu­ta 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 equiva­lente 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 de­be 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.

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 pro­porciona 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 inme­diatamente.

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 propo­sició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 lu­gar. La más común es abandonar el procesamiento en alguna estructura profun­damente 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 inter­no. 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 erro­res 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ús­queda 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ódi­go 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