Capitulo 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 mayoria de las construcciones de control de flujo en ejemplos anteriores; aqui completaremos el conjunto, y sere­mos mas precisos acerca de las discutidas con anterioridad.

3.1 Proposiciones y bloques

Una expresion como x = 0 o i++ o printf(...) se convierte en una proposi­cion cuando va seguida de un punto y coma ;, como en

x = 0;
i++;
printf(...);

En C, el punto y coma ; es un terminador de proposicion, 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 proposicion compuesta o bloque, de modo que son sintacticamente equi­valentes a una proposicion sencilla. Las llaves que encierran las proposiciones de una funcion son un ejemplo obvio; otros ejemplos son las llaves alrededor de proposiciones multiples despues de un if, else, while o for. (Pueden declararse variables dentro de cualquier bloque; esto se expondra en el capitulo 4). No hay punto y coma despues de la llave derecha que termina un bloque.

3.2 If-Else

La proposicion if-else se utiliza para expresar decisiones. Formalmente, la sintaxis es

if (expresion)
    proposicion1
else
   proposicion2

donde la parte del else es optativa. La expresion se evalua; si es verdadera (esto es, si la expresion tiene un valor diferente de cero), la proposicion1 se ejecuta. Si es falsa (expresion es cero) y si existe una parte de else, se ejecuta en su lugar la proposicion2.

Puesto que un if simplemente prueba el valor numerico de una expresion, son posibles ciertas abreviaciones de codigo. Lo mas obvio es escribir

if (expresion)

en lugar de

if (expresion != 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 ambiguedad cuando un else se omite de una secuencia if anidada. Esto se resuelve al asociar el else con el if sin else anterior mas cercano. Por ejemplo, en

if (n > 0)
    if (a > b)
        z = a;
else
    z = b;

el else va con el if mas interno, como se muestra con el sangrado. Si eso no es lo que se desea, se deben utilizar llaves para forzar la asociacion correcta:

if (n > 0) {
    if (a > b)
        z = a;
}
else
    z = b;
4

La ambiguedad 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 inequivoca lo que se desea, pero el compilador no entiende el mensaje y asocia el else con el if mas interno. Puede ser dificil encon­trar esta clase de errores; es una buena idea utilizar llaves cuando hay varios if anidados.

A proposito, notese que hay un punto y coma ; despues de z = a en

if (a > b)
    z = a;
else
    z = b;

Esto se debe a que gramaticalmente al if le sigue una proposicion, y una expresion como "z = a;" siempre se termina con punto y coma ;.

3.3 Else-if

La construccion

if (expresion)
    proposicion
else if (expresion)
    proposicion
else if (expresion)
    proposicion
else if (expresion)
    proposicion
else
    proposicion

ocurre de modo tan frecuente que bien vale una pequena discusion aparte. Esta secuencia de proposiciones if es la forma mas general de escribir una decision multiple. Las expresiones se evaluan en orden; si cualquier expresion es verdade­ra, se ejecuta la proposicion asociada con ella, y esto termina toda la cadena. Co­mo siempre, el codigo para cada proposicion es una proposicion simple o un grupo dentro de llaves.

La parte del ultimo else maneja el caso “ninguno de los anteriores” o caso por omision cuando ninguna de las otras condiciones se satisface. En algunos ca­sos no hay una accion explicita para la omision; en ese caso el ultimo

else
    proposicion

puede omitirse, o puede utilizarse para deteccion de errores al atrapar una condicion “imposible” .

Para ilustrar una decision de tres vias, se muestra a continuacion una funcion de busqueda 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 funcion regresa la po­sicion (un numero entre 0 y n-1) si x ocurre en v, y -1 si no es asi.

La busqueda 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 busqueda 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 continua 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 decision 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.

  • Ejercicio 3-1. Nuestra busqueda binaria realiza dos pruebas dentro del ciclo, cuando una podria ser suficiente (al precio de mas pruebas en el exterior). Escriba una version con solo una prueba dentro del ciclo y mida la diferencia en tiempo de ejecucion. □

3.4 Switch

La proposicion switch es una decision multiple que prueba si una expresion coincide con uno de un numero de valores constantes enteros, y traslada el con­trol adecuadamente.

switch (expresion) {
    case exp-const: proposiciones
    case exp-const: proposiciones
    default: proposiciones
)

Cada case se etiqueta con uno o mas valores constantes enteros o expresiones constantes enteras. Si un case coincide con el valor de la expresion, la ejecucion comienza alli. Todas las expresiones case deben ser diferentes. El que esta etiquetado co­mo default se ejecuta si ninguno de los otros se satisface. El default es optativo; si no esta y ninguno de los casos coincide, no se toma accion algu­na. Las clausulas case y default pueden ocurrir en cualquier orden.

En el capitulo 1 se escribio un programa para contar las ocurrencias de cada digito, espacio en blanco y todos los demas caracteres, usando una secuencia de if ... else if ... else. Aqui esta el mismo programa con un switch:

#include <stdio.h>
main()    /* cuenta digitos, 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 proposicion break provoca una salida inmediata del switch. Puesto que los case sirven solo como etiquetas, despues de que se ejecuta el codigo para Uno, la ejecucion pasa al siguiente, a menos que se tome una accion especifica para terminar el switch. Las formas mas comunes de dejar un switch son break y return. Una proposicion break tambien se puede emplear para forzar una salida inmediata de los ciclos while, for y do, como se vera mas adelante en este capitulo.

Pasar a traves de los case es en parte bueno y en parte no. Por el lado positivo, esto permite conectar varios case a una accion simple, como con los digitos de este ejemplo. Pero eso tambien 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 practica muy solida y es susceptible a la desintegracion cuando se modifica el programa. Con la excepcion de etiquetas multiples para un calculo simple, lo anterior se debe utilizar con cautela y emplear comentarios.

Como formalidad, coloque un break despues del ultimo case (en este caso el default) aun si desde ek punto de vista logico resulta innecesario. Algun dia - cuando se agregue otro case al final - esta practica de programacion defensiva lo salvara.

  • Ejercicio 3-2. Escriba una funcion escape(s,t) que convierte caracteres como nueva linea y tabulacion en secuencias de escape visibles como \n y \t mientras copia la cadena t a s. Utilice un switch. Escriba tambien una funcion para la di­reccion inversa, convirtiendo secuencias de escape en caracteres reales. □

3.5 Ciclos - While y For

Ya hemos encontrado los ciclos while y for. En

while (expresion)
    proposicion

la expresion se evalua. Si es diferente de cero, se ejecuta la proposicion y se reevalua la expresion. Este ciclo continua hasta que la expresion se hace cero, punto en el cual se suspende la ejecucion para continuar despues de la proposicion.

La proposicion for

for (expr1; expr2; expr3)
    proposicion

es equivalente a

expr1;
while (expr2) {
    proposicion
    expr3;
}

excepto por el comportamiento de continue que se describe en la seccion 3.7.

Gramaticalmente, las tres componentes de un ciclo for son expresiones. Por lo comun, expr1 y expr3 son asignaciones o llamadas a funcion y expr2 es una ex­presion de relacion. Cualquiera de las tres partes se puede omitir, aunque deben permanecer los punto y coma ;. Si se omite expr1 o expr3, solo se desecha de la expansion. Si la prueba expr2 no esta presente, se toma como permanentemente verdadera, asi que

for (;;) {
    ...
}

es una iteracion “infinita”, que presumiblemente sera interrumpida por otros medios, como un break o un return.

El usar while o for es principalmente cuestion de preferencia personal. Por ejemplo, en

while ((c = getchar()) == ' ' || c == '\n' || c = '\t')
    ; /* ignora caracteres espaciadores */

no hay inicializacion o reinicializacion, por lo que el while es mas natural.

El for se prefiere cuando existe una inicializacion simple e incrementos, puesto que mantiene las proposiciones de control del ciclo juntas y visibles al principio del mismo. Esto es mas obvio en

for (i = 0; i < n; i++)
    ...

que es la forma caracteristica de procesar los primeros n elementos de un arreglo en C, lo analogo al ciclo DO de Fortran o al for de Pascal. Sin embargo, la analo­gia no es perfecta puesto que tanto el indice como el limite de un ciclo for en C pueden ser alterados desde dentro del ciclo, y la variable del Indice retiene su valor cuando por cualquier razon terminan las iteraciones. Puesto que las componen­tes del for son expresiones arbitrarias, sus ciclos no estan restringidos a progresio­nes aritmeticas. Por otra parte, considere que es un mal estilo incluir en las secciones de inicializacion e incremento operaciones no relacionadas con esas ac­tividades, que mas bien se reservan para acciones de control del ciclo.

Como un ejemplo mas amplio, aqui esta otra version de atoi para convertir una cadena a su equivalente numerico. Esta es ligeramente mas general que la del capitulo 2; trata tambien los espacios en blanco previos al numero, y los signos + o -. (El capitulo 4 muestra atof, que realiza la misma conversion para numeros 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 conviertela

Cada paso realiza su parte, y deja las cosas en forma clara para el siguiente. La totalidad del proceso termina con el primer caracter que no pueda ser parte de un numero.

#include <ctype.h>

/* atoi: convierte s a entero; version 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 estandar proporciona una funcion mas elaborada, strtol, para la conversion de cadenas a enteros largos; vease la seccion 5 del apendice B.

Las ventajas de mantener centralizado el control del ciclo son aun mas obvias cuando existen ciclos anidados. La siguiente funcion es una clasificacion Shell para ordenar un arreglo de enteros. La idea basica 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 mas simples). Esto tiende a eliminar rapidamente gran cantidad de desorden, asi 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 me­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 mas externo controla el espacio entre los elementos comparados, reduciendolo desde n/2 por un factor de dos en cada paso hasta que llega a cero. El ciclo intermedio recorre los elementos. El ciclo mas interno compara cada pareja de elementos que esta separada por el espacio gap e invierte a las que esten desordenadas. Puesto que gap finalmente se reduce a uno, todos los elementos se ordenan correctamente. Notese como la generalidad del for hace que el ciclo mas externo coincida con la forma de los otros, aun cuando no es una progresion aritmetica.

Un ultimo operador de C es la coma , que frecuentemente encuentra uso en la proposicion for. Una pareja de expresiones separadas por una coma se eva­lua de izquierda a derecha, y el tipo y valor del resultado son el tipo y valor del operando derecho. Asi, en una proposicion for es posible colocar expresiones multiples en las diferentes partes, por ejemplo, para procesar dos indices en para­lelo. Esto se ilustra en la funcion 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 funcion, las variables en declara­ciones, etc., no son operadores coma, y no garantizan evaluacion de izquierda a derecha.

Los operadores coma deberan utilizarse poco. Los usos mas adecuados son en construcciones fuertemente relacionadas una con la otra, como en el ciclo for de reverse, y en macros en donde un calculo de paso multiple debe ser una expre­sion simple. Una expresion coma podria tambien ser apropiada para el intercambio de elementos en reverse, donde el intercambio puede ser a traves de una operacion 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 funcion expand(sl,s2) que expande notacion abreviada como a-z, que viene en la cadena s1, en la lista equivalente completa abc...xyz en s2. Permita letras mayusculas y minusculas, asi como digitos, y este preparado para manejar casos como a-b-c y a-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 capitulo 1, los ciclos while y for verifican al principio la condicion de termino. En contraste, el tercer ciclo en C, el do-while, prueba al final despues de realizar cada paso a traves del cuerpo del ciclo, el cual se ejecu­ta siempre por lo menos una vez.

La sintaxis del do es

do
    proposicion
while (expresion);

La proposicion se ejecuta y despues se evalua la expresion. Si es verdadera, la proposicion se evalua de nuevo, y asi sucesivamente. Cuando la expresion se hace falsa, el ciclo termina. Excepto por el sentido de la prueba, el do-while es equiva­lente a la proposicion 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 funcion itoa, que convierte un numero a una cadena de caracteres (lo inverso de atoi). El trabajo es ligeramente mas complicado de lo que podria pensarse en un principio, debido a que los metodos faciles para generar digitos los generan en el orden incorrecto. Hemos elegido generar la cadena al reves y despues 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 digito */
    } 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 caracter en el arreglo s, aun si n es cero. Tambien empleamos llaves alrededor de la proposicion simple que hace el cuerpo del do-while, aunque son innecesarias, y asi el lector apresurado no confundira la seccion del while con el comienzo de un ciclo while.

  • Ejercicio 3-4. En una representacion de numeros en complemento a dos, nuestra version de itoa no maneja el numero negativo mas grande, esto es, el valor de n igual a -( 2^tamanopalabra-1). Explique por que. Modifiquelo para imprimir el valor correctamente, sin importar la maquina en que ejecute. □
  • Ejercicio 3-5. Escriba la funcion itob(n,s,b) que convierte al entero n en una re­presentacion de caracteres con base b dentro de la cadena s. En particular, itob(n,S,16) da formato a s como un entero hexadecimal en s. □
  • Ejercicio 3-6. Escriba una version de itoa que acepte tres argumentos en lugar de dos. El tercer argumento es un ancho minimo de campo; si es necesario, al numero converti­do debe agregarsele caracteres en blanco a la izquierda para hacerlo lo suficiente­mente 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 proposicion 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 mas interno que lo encierra termine inme­diatamente.

La siguiente funcion, trim, elimina espacios blancos, tabuladores y nuevas li­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 linea de mas 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 atras, buscando el primer caracter 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 debera verificar que este comportamiento es correcto, aun cuando la cadena este vacia o solo contiene espacios en blanco.

La proposicion continue esta relacionada con el break, pero se utiliza menos; provoca que inicie la siguiente iteracion 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­sicion continue se aplica solamente a ciclos, no a switch. Un continue dentro de un switch que esta a su vez en un ciclo, provoca la siguiente iteracion del ciclo.

Como un ejemplo, el siguiente fragmento procesa solo los elementos no negativos que estan 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 proposicion continue se emplea a menudo cuando la parte del ciclo que sigue es complicada, de modo que invertir la prueba y sangrar otro nivel podria anidar profundamente el programa.

3.8 Goto y Etiquetas

C proporciona la infinitamente abusable proposicion goto, y etiquetas para saltar hacia ellas. Formalmente, el goto nunca es necesario, y en la practica es casi siempre mas facil escribir codigo sin el. En este libro no se ha usado goto alguno.

Sin embargo, hay algunas situaciones donde los goto pueden encontrar un lu­gar. La mas comun es abandonar el procesamiento en alguna estructura profun­damente anidada, tal como salir de dos o mas ciclos a la vez. La proposicion break no se puede utilizar directamente, puesto que solo sale del ciclo mas inter­no. Asi:

    for ( ... )
        for ( ... ) {
            ...
            if (desastre)
                goto error;
        }
    ...
error:
    /* arregla el desorden /*

Esta organizacion es util si el codigo 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 proposicion de la misma funcion en la que esta el goto. El alcance de una etiqueta es toda la funcion.

Como otro ejemplo, considerese el problema de determinar si dos arreglos, a y b, tienen un elemento en comun. Una posibilidad es

    for (i = 0; i < n; i++)
        for (j = 0; j < m; j++)
            if (a[i] == b[j])
                goto encontrado;
    /* no se encontro ningun elemento comun */
    ...
encontrado:
    /* se encontro uno: a[i] == b[j] */
    ...

El codigo que involucra un goto siempre puede escribirse sin el, aunque tal vez al precio de algunas pruebas repetidas o variables extra. Por ejem plo, la bus­queda en los arreglos quedara

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 encontro uno: a[i-1] == b[j-1] */
    ...
else
    /* no se encontro ningun elemento comun */
    ...

Con pocas excepciones, como las citadas aqui, el codigo que se basa en proposiciones goto es generalmente mas dificil de entender y de mantener que el codi­go sin ellas. Aunque no somos dogmaticos acerca del asunto, se ve que las proposiciones goto deben ser utilizadas raramente, si acaso.

Continuar: Capitulo 4