Teniendo ya una familiaridad con los conceptos esenciales de C, la ocasión es propicia para empezar a formalizar el concepto de las funciones en C. Esto permitirá que los programas que queramos elaborar en C no solo tomen un aspecto más profesional, sino que puedan ser mas portátiles a un conjunto más grande de los compiladores C que hay en existencia así como los entornos de desarrollo IDE para programar en lenguaje C.
Habrá quienes se pregunten: ¿realmente queremos entrar en tanto detalle? Si se quiere ir más allá de la simple elaboración de páginas Web sencillas usando convenciones de marcado de hipertexto HTML y hojas de estilos en cascada CSS así como programación para la Web con lenguajes en-línea como JavaScript, la respuesta es afirmativa, en virtud de un hecho de la mayor importancia: el lenguaje C fue el punto de partida para la creación del lenguaje Java, la plataforma usada para programación Web con fines serios (tales como la creación de servidores Web capaces de servir a corporaciones transnacionales como Amazon, así como la creación de bases de datos de gran calibre habilitadas para la Web para ser usadas por corporaciones). Usar un lenguaje añejo como BASIC para este tipo de aplicaciones está fuera de consideración. Y usar lenguajes assembler es impráctico por el tamaño de los proyectos. En realidad, fuera de alternativas como C (y sus variantes) así como Java, las opciones son limitadas.
El lenguaje C es un lenguaje basado en el concepto de las funciones. Entendiendo bien esto se comprende la naturaleza de C como un lenguaje estructurado. Y es sobre lo que estaremos trabajando.
En matemáticas, al usar funciones estamos acostumbrados a que dichas funciones nos regresen algún valor. Tómese como ejemplo la función raíz() para la extracción de la raíz cuadrada de un número entero que posee raíz cuadrada exacta:
raiz(81)
Esperamos desde luego que la función nos regrese el entero 9, o sea:
raiz(81) = 9
Y si aplicamos la función a un número que no tiene raíz cuadrada exacta, por ejemplo 5.25, esperamos que nos regrese un número con una parte decimal, o sea:
raiz(5.25) = 2.291287847477920003294023596864
Todas las funciones en C, siendo funciones, regresan algún valor, y aún si no regresan ningún valor de ningún tipo (int, float, etc.) de cualquier modo se puede (y se deber) dejar en claro que no regresan nada declarándolas como funciones de tipo void. Un ejemplo de una función que no regresa absolutamente nada sería el de una función cuyo objetivo es imprimir algo en la pantalla, como la siguiente:
imprimir_alreves(char *hilera)
int t;
for(t=strlen(hilera)-1; t>-1; t--) printf("%c", hilera[t]);
}
Una vez que la hilera ha sido puesta en la pantalla al revés, no le queda ya nada por hacer, así que tiene que regresar al lugar desde donde fue llamada. Muchos compiladores aceptarán una función como la anterior aunque posiblemente enviarán una advertencia diciéndole al programador que la función imprimir_alreves() debería regresar algo. La manera de eliminar el mensaje de advertencia es, desde luego, especificando el tipo regresado por la función, que en este caso es nulo, corrigiéndola de la manera siguiente:
void imprimir_alreves(char *hilera)
int t;
for(t=strlen(hilera)-1; t>-1; t--) printf("%c", hilera[t]);
}
Esto nos proporciona el esqueleto fundamental de la sintaxis de todas las funciones en C:
tipo funcion(lista de parámetros)
{
cuerpo de la función
}
El tipo nos especifica el tipo de valor que la función regresará usando el enunciado return, pudiendo ser de cualquier tipo válido.
Si no se especifica un tipo de retorno, en forma predeterminada (default) se supone que la función regresa un entero.
Los programas de ejemplo que hemos visto en las entradas anteriores en los cuales no se especificó ningún tipo de retorno hicieron esta suposición implícita de que cuando el tipo estaba ausente, y al no haber incluso un enunciado usando return, al tipo de retorno se le tomó como un entero nulo. La lista de declaración de parámetros que le son pasados a la función es una lista de tipos y nombres separados con comas que recibirán los valores de los argumentos que le son pasados a la función cuando la función es invocada. Puede ser que una función carezca de parámetros, y de ser así la lista de parámetros estará vacía. Sin embargo, aunque no haya parámetros, de cualquier modo se requiere poner los paréntesis después del nombre de una función, es la manera que tiene C de distinguir una función.
Otra cosa que hay que entender acerca de la lista de declaración de los parámetros de una función es que a diferencia de lo que ocurre con las declaraciones de variables en las cuales se pueden declarar al mismo tiempo varias variables especificándolas todas como de un mismo tipo usando una lista de nombres de variables separadas con comas:
float altura, anchura, profundidad;
en el caso de las funciones todos los parámetros de una función deben incluír tanto el tipo como el nombre de la variable. De este modo, la lista de declaración de parámetros toma el siguiente aspecto:
funcion(tipo 1 nombre1, tipo2 nombre2, ..., tipo nombreN)
A continuación se tiene una declaración correcta de parámetros de una función g():
g(int A, int B, int C, float D, float E, double F)
entanto que la siguiente es incorrecta porque cada parámetro tiene necesariamente que incluír su propio tipo:
g(int A, B, C, float D, E, double F)
Volviendo al enunciado return, se puede decir que tiene dos aplicaciones importantes. La primera consiste en ocasionar una salida inmediata de una función en el momento en que es encontrado, y causará que la ejecución del programa regrese al código que invoca a la función en cuanto es encontrado. Y la segunda aplicación, desde luego, es su uso para regresar un valor de cierto tipo de dato. Cuando no se usa un enunciado return dentro de una función, la función no regresará ningún valor al terminar de ejecutarse la función.
Se puede usar un enunciado return sin asociar ningún valor al mismo, en cuyo caso es usado para terminar con la ejecución de una función, como en el siguiente programa con el cual se imprime el resultado de un número elevado a un entero positivo; y si el exponente es negativo entonces el enunciado return causará una salida fuera de la función antes de que el corchete de cierre “}” sea encontrado:
void potencia(int base, int exponente)
{
int i;
if(exponente<0)
return; /* salir si el exponente es negativo */
i = 1;
for(; exponente; exponente--) i = i * base;
printf("La potenciacion es igual a: %d ", i);
}
Obsérvese que en el fragmento anterior el tipo de la función es especificado como void.
Para regresar un valor de una función, es necesario usar el enunciado return seguido a continuación del valor que es regresado. En el siguiente ejemplo, la función regresa el valor máximo de sus dos argumentos:
maximo(int x, int y)
{
int temporal;
if(x>y) temporal = x;
else temporal = b;
return temporal;
}
En el fragmento anterior no se especificó tipo de retorno para la función porque la función regresa un valor de tipo entero (int), y este es el tipo predeterminado de retorno de cualquier función si no se especifica en forma explícita en la definición de la función. Aunque lo anterior es correcto y aceptado, la siguiente versión es más correcta aún:
int maximo(int x, int y)
{
int temporal;
if(x>y) temporal = x;
else temporal = b;
return temporal;
}
Dentro de una función podemos usar no solo un enunciado return sino varios, lo cual es usado para simplificar y hacer más eficientes algunos algoritmos, como en la siguiente versión de la función maximo():
maximo(int x, int y)
{
if(x>y) return x;
else return y;
}
Obsérvese que esto último requirió menos líneas de código que la versión anterior que solo usa un enunciado return.
Veamos otro ejemplo de una función que utiliza dos enunciados return, una función para encontrar una subhilera dentro de una hilera mayor, la cual llamaremos encontrar_subhilera(). Esta función nos regresará ya sea el índice de inicio de una subhilera que haya dentro de una hilera, o regresará un 1 negativo (-1) si no se encuentra la subhilera buscada dentro de la hilera mayor:
encontrar_subhilera(char *sub, char *hilera)
{
int t;
char *p1 , *p2;
/* obtener el punto de inicio */
for(t=0; hilera[t]; t++) {
p1 = &hilera[t];
p2 = sub;
/* avanzar mientras sean iguales */
while(*p2 && *p2==*p1) {
p1++;
p2++;
}
/* si hemos llegado al final de */
/* la subhilera, entonces se ha */
/* encontrado un par igual */
if(!p2) return t;
}
return -1;
}
Si invocamos esta función con una hilera que contiene “Me llamo Armando” y una subhilera que contiene “mo Arm”, entonces la función regresará 5.
Como puede verse en los ejemplos que hemos visto, el uso en un fragmento de código que contenga más de un enunciado return puede producir funciones más sencillas y más eficientes. Sin embargo, no hay que pasarse de listo y abusar de esta opción, ya que un exceso de enunciados return en un fragmento de código pueden conducir a una desestructuración de la función y obscurecer su significado. La guía a seguir es que el uso múltiple de enunciados return debe ser usado únicamente cuando pueda contribuír en forma significativa a la eficiencia y rapidez de la función.
Todas las funciones, excepto aquellas que son declaradas ser de tipo void, regresan un valor, el cual es especificado explícitamente con un enunciado return. Esto a su vez implica que una función puede ser usada como un operando en cualquier expresión C válida, por ejemplo:
raiz( maximo(float x) )
Por el mismo tenor, cada una de las siguientes expresiones es válida en C:
P = absoluto(p);
if( maximo(a,b) > 64) printf("es mayor que");
for(caracter = getchar(); isdigit(caracter;) ..... ;
Sin embargo, una función no puede ser el objetivo (blanco) final de una asignación, lo cual implica que algo como lo siguiente:
raiz(x) = 64;
constituye un error.
En la elaboración de programas las funciones que escribimos generalmente serán de tres tipos. El primer tipo de función es simplemente de carácter computacional, diseñada específicamente para recibir uno o más argumentos y regresar un valor o un resultado basados en las operaciones efectuadas por la función; o sea algo semejante a lo que hace una función clásica, tales como la función log() usada para determinar el logaritmo de un número, o la función trigonométrica tan() usada para determinar la tangente de cierto ángulo. El segundo tipo de función manipula información y regresa un resultado que simplemente indica el éxito o el fallo resultante de tal manipulación. Un ejemplo de tal función es fopen() usada para abrir un archivo ubicado en un disco duro o cualquier otro dispositivo de memoria, si la operación de apertura exitosa entonces se regresa un puntero hacia el inicio del archivo que ha sido abierto, y si no tiene éxito (por ejemplo en caso de no existir el archivo) se indica que ha ocurrido un error. Y el último tipo de función simple y sencillamente no produce ningún valor de retorno. Como algo curioso, y por razones históricas poco claras, hay funciones que no regresan algún resultado posteriormente manipulable y que de cualquier manera regresan algo. Tómese por ejemplo la función printf(). Esta función simple y sencillamente imprime algo en la pantalla, no esperemos que nos regrese nada una vez que haya efectuado su tarea. Y sin embargo, nos regresa el número de caracteres que se han impreso, lo cual nos permite escribir un programa como el siguiente:
#include <stdio.h>
main() {
int numero;
numero = printf("Esta es una hilera de prueba\n");
printf("El número de caracteres impresos fue %d", numero);
}
Si se compila y se ejecuta un programa como el que se ha mostrado, se obtendrá (en una segunda línea) el mensaje “El numero de caracteres impresos fue 29”. Sin embargo, es inusual encontrar programas que utilicen este tipo de información proporcionada por funciones de las cuales ordinariamente no se espera que regresen nada. Una interrogante frecuente es ésta: ¿No se requiere asignar el valor regresado por ésta función X a alguna variable, en virtud de que se está regresando un valor? La respuesta es no, si no hay una asignación que use el valor regresado por una función el valor de retorno es simplemente descartado. Tómese por ejemplo la siguiente función (se ha puesto la función main() después de la función producto() para que pueda ser compilable en la gran mayoría de los compiladores C):
#include <stdio.h>
producto(int a, int b) { return a*b; } main() { int x, y, z; x = 5; y = 8; z = producto(x, y); /* 1 */ printf("%d", producto(x, y)); /* 2 */ producto(x, y); /* 3 */ }
El valor de retorno de la función producto() es asignado a la variable z en la línea # 1, mientras que en la línea # 2 el valor de retorno no es asignado. Y por último, el valor de retorno en la línea # 3 se pierde porque no es asignado a otra variable ni es usado como parte de alguna expresión.
Ya se aclaró que cuando no se declara el tipo de una función, o sea el tipo de dato que nos regresa la función, C automáticamente y en forma predeterminada supone que se está regresando un valor de tipo int. Para muchas funciones, esta predeterminación resulta satisfactoria, y es precisamente lo que nos permitió en las entradas anteriores poder proporcionar muchos ejemplos de código C omitiendo declarar el tipo de la función puesta en el programa. Sin embargo, hay ocasiones en las cuales la función no regresará un cierto tipo de dato, y en tales casos es requerimiento indispensable declarar el tipo de la función, lo cual es un proceso de dos pasos. El primer paso consiste en que a la función se le declare ser de cierto tipo, se le debe dar en forma explícita un especificador de tipo. Y en el segundo paso, se le tiene que decir de antemano al compilador el tipo de la función antes de que la función sea invocada; es la única manera en la cual un compilador C bien diseñado puede generar el código correcto para funciones que regresan valores que no son de tipo int (enteros). Y esto nos lleva a algo que habíamos dejado como algo pendiente en las entradas anteriores al elaborar programas que no solo contienen una función main() sino otras funciones.
Una cosa que posiblemente habrá causado confusión, y en lo cual se tuvo la precaución de estar proporcionando un recordatorio cuando los ejemplos mostrados lo ameritaban, es que para que ciertos programas que contienen varias funciones puedan ser compilados, la función main() tiene que ser puesta no al principio del código sino al final. Y la cosa vá más allá; si alguna de las otras funciones invoca otras funciones entonces las funciones invocadas también tienen que ser puestas con anterioridad. O sea que se tenía que especificar cada función en cierto orden.
Nos preguntamos ahora: ¿no habrá alguna manera un poco más elegante que nos permita poner a la función main() al principio de un programa, y definir posteriormente las funciones usadas sin poner tanta atención al orden en el cual las vamos definiendo? La respuesta es afirmativa, y tal cosa se puede lograr con la declaracion de prototipos de funciones.
No hay que confundir la declaración del prototipo de una función con la función en sí conteniendo todo el código que va a ser ejecutado.
Todas las funciones pueden (y deben) ser declaradas para regresar cualquier tipo de dato válido en C. El método de declaración es semejante al método usado en la declaración de variables: el especificador de tipo precede al nombre de la función, y le informa al compilador qué tipo de dato nos regresará la función. Este tipo de información es importante e incluso crítica si un programa ha de ejecutarse correctamente, porque hemos visto ya cómo distintos tipos de datos tienen representaciones internas medidas en tamaños diferentes (en bytes). Antes de que una función regrese un tipo de dato que no es un entero, su tipo debe ser conocido de antemano por el resto del programa, y es fácil entender la razón detrás de esto. En C, a menos de que se le indique lo contrario al compilador, se supondrá que una función regresa un valor entero. Si el programa invoca una función que regresa un valor de tipo distinto antes de llegar a la declaración de dicha función, el compilador puede generar el código incorrecto para la invocación de la función. Y la mejor manera de informarle al compilador acerca del tipo de retorno de una función es usando un prototipo de función, el cual lleva a cabo dos tareas importantes: primero, identifica el tipo de retorno de la función para que el compilador pueda generar el código correcto para el tipo de dato regresado por la función; y en segundo lugar especifica el tipo y el número de argumentos usados por la función. El prototipo de una función toma la forma:
tipo nombre(lista de parámetros)
El prototipo es usualmente puesto en el tope de un programa (al principio), o en un archivo de cabecera, y tiene que ser incluído antes de que se haga un llamado a la función.
Los prototipos de función no formaban parte original del estándard C de Kernighan y Ritchie (conocido simplemente como el estándard K&R). Estos fueron añadidos posteriormente por el comité de estándares ANSI (American National Standards Institute), con la intención de que proporcionaran un chequeo riguroso de tipos semejante al que encontramos en otros lenguajes tales como Pascal además de informarle al compilador el tipo de retorno de una función.
Cuando se usan los prototipos de función, le permiten al compilador encontrar y reportar cualquier tipo de conversiones ilegales entre los tipos de argumentos usados para invocar una función y la definición de los tipos de sus parámetros, a la vez que le permiten a muchos compiladores C reportar cuándo una función está siendo invocada con muy pocos o con demasiados argumentos.
A continuación tenemos un programa en el que se utiliza un prototipo de función. Obsérvese que, aunque el programa consta de dos funciones, la función principal puede ir puesta al principio.
#include <stdio.h>
float suma(float x, float y); /* el prototipo de la funcion */
main()
{
float primero, segundo;
primero = 2731.589;
segundo = 514.32;
printf("%f", suma(primero, segundo));
}
float suma(float x, float y) /* se regresa un double */
{
return x+y;
}
El prototipo de la función, puesto al principio, le dice al compilador que suma() regresará un tipo de dato de tipo flotante, lo cual le permite al compilador generar correctamente el código para las llamadas a la suma(). Si removemos el prototipo de la función que aparece al principio, puede generar resultados incorrectos, o inclusive puede que el código no sea compilado.
He aquí otro programa usado para calcular el área de un círculo a partir de su radio:
#include <stdio.h>
#include <stdlib.h>
float area(float radio); /* prototipo de la funcion */
main()
{
float r;
printf("Dame el radio: ");
scanf("%f", &r);
printf("El área del círculo es: %f\n", area(r));
}
float area(float radio)
{
return 3.1416 * ( (radio *radio) );
}
En este ejemplo, la función area() regresa un flotante, de modo que hay que advertirle de antemano al compilador, con el prototipo de la función antes de que se lleve a cabo la invocación de la función. Lo importante en todo caso es que cuando una función regresa un valor que no es un entero (¡y el cual puede ser incluso un puntero!) el compilador debe ser avisado previamente de ello, y la forma de lograrlo es usando prototipos de función.
Al declarar prototipos de funciones, podemos hacerlo haciendo dos o más declaraciones en una misma línea si se trata de funciones que son del mismo tipo, como en el siguiente ejemplo:
float funcion1(void), funcion2(void);
en el que ambas funciones son de tipo float.
Ahora que ya tenemos conocimiento sobre la importancia de los prototipos, en los programas restantes estaremos incluyendo prototipos aún en el caso de que la función simplemente esté regresando un entero.
Veamos ahora el caso de las funciones que regresan punteros. Como el lector ya debe saberlo por la lectura de la entrada previa, los punteros no son enteros ni enteros sin signo, son domicilios de la memoria RAM para ciertos tipos de datos. Cuando se lleva a cabo aritmética de punteros mediante las operaciones de incremento y decremento, y si se trata de un puntero de tipo entero, contendrá un valor que es dos veces mayor (o más, dependiendo de la versión de C que se esté utilizando) que su valor previo. Cada vez que un puntero es incrementado, el puntero apuntará hacia el siguiente dato de su tipo, y puesto que cada tipo de dato (int, float, double, etc.) puede ser de longitud diferente, el compilador debe saber cuál es el tipo de dato hacia el cual está apuntando el puntero, para poder hacerlo apuntar correctamente hacia el siguiente elemento de dato. Por lo tanto, una función que nos regresa un puntero tiene que ser declarada como tal. A modo de ejemplo, he aquí una función que regresa un puntero hacia una hilera en el lugar en el cual se encuentra un caracter igual:
char *par(char c, char *hilera) { int conteo; conteo = 0; /* buscar un par o el terminador nulo */ while(c!=hilera[conteo] && hilera[conteo]!='\0') conteo++; /* si hay un par, regresar puntero a la localidad, */ /* de lo contrario regresar un puntero nulo */ if(hilera[conteo]) return(&hilera[conteo]); else return (char *) '\0'; }
La función pareja() tratará de regresar un puntero hacia el lugar en una hilera de caracteres en donde se encuentra el primer caracter que sea igual. Si no se encuentra un caracter igual después de haberse recorrido toda la hilera, se regresará un puntero hacia el terminador nulo. Podemos poner a prueba lo anterior invocando la función en el siguiente programa:
#include <stdio.h>
#include <conio.h>
char *par(char c, char *hilera); main() { char hilera[80], *p, ch; printf("dame una hilera y un caracter: "); gets(hilera); ch = getche(); p = par(ch, hilera); if(p) /* hay un par */ printf("%s ", p); else printf("no se hallo un par"); } /* Regresar un puntero hacia el primer caracter */ /* en la hilera que sea igual a c. */ char *par(char c, char *hilera) { int conteo; conteo = 0; /* buscar un par o el terminador nulo */ while(c!=hilera[conteo] && hilera[conteo]!='\0') conteo++; /* si hay un par, regresar puntero a la localidad, */ /* de lo contrario regresar un puntero nulo */ if(hilera[conteo]) return(&hilera[conteo]); else return (char *) '\0'; }
En el programa, si el caracter se encuentra en la hilera proporcionada por el usuario, el programa imprime la hilera a partir del caracter encontrado. De lo contrario imprime “no se hallo un par”. Si el usuario mete la hilera “castillo de chapultepec”, y si especifica el caracter “h”, entonces se imprimirá la subhilera “hapultepec”.
El siguiente programa se basa en una función imprimir_vertical() que toma una hilera proporcionada por el usuario imprimiéndola verticalmente (de arriba hacia abajo, caracter por caracter). La función no regresa ningún valor, y por ello debe ser declarada tanto en su prototipo como en su definición como una función de tipo void:
#include <stdio.h>
void imprimir_vertical(char *hilera); /* prototipo de la funcion */
main()
{
imprimir_vertical("hola!");
}
void imprimir_vertical(char *hilera)
{
while(*hilera)
printf("%c\n", *hilera++);
}
Además de informarle al compilador el tipo de retorno de una función, un prototipo también evita que una función pueda ser invocada con un tipo incorrecto en cada uno de los argumentos o con un número incorrecto de argumentos. Aunque C puede convertir automáticamente el tipo de un argumento hacia el tipo de parámetro que está recibiendo, hay conversiones de tipo que son ilegales (y los compiladores bien diseñados emitirán una advertencia o simplemente se negarán a compilar el programa). Cuando se proporciona el prototipo de una función cualquier conversión ilegal de tipo será encontrada y se emitirá un mensaje de error.
Puesto que los prototipos no fueron parte de la versión original de C (la versión K&R), hay una situación peculiar cuando se tiene que hacer el prototipo de una función que no toma argumentos. Esto se debe a que el estandard ANSI C asienta que cuando no se incluyen parámetros en el prototipo de una función no se especifica tampoco nada acerca del tipo o del número de los parámetros de la función; esto fue necesario para permitir que programas escritos con el “viejo estilo C” pudieran ser compilados los compiladores más modernos. Sin embargo, si queremos mantener una formalidad en nuestro estilo, ¿cómo podemos decirle al compilador que una función no toma ningún parámetro? La respuesta es que para poder lograr tal cosa se usa la palabra clave void dentro de la lista de parámetros. Un ejemplo es el siguiente programa:
#include <stdio.h>
void saludo(void);
main()
{
saludo();
}
void saludo(void)
{
printf("Hola!");
}
Obsérvese que la función saludo() es de tipo void porque no regresa nada. Pero tampoco toma ningún argumento de entrada, lo cual se indica con void puesto dentro de la lista de argumentos. En el programa, el prototipo le informa explícitamente al compilador que la función no toma argumentos, y puesto que la lista de parámetros de una función debe coincidir con su prototipo, el void debe ser incluído también en la declaración de saludo(). Con el prototipo declarado, la mayoría de los compiladores se negarán a compilar una invocación a la función de una manera como la siguiente:
saludo("Hola!");
Es muy posible que si en la declaración del prototipo se deja fuera void de la especificación de la lista de parámetros, algunos compiladores no reporten ningún error. En los ejemplos que hemos estado viendo, breves por su naturaleza didáctica, no es difícil encontrar los yerros. Pero en la elaboración de programas grandes que incluyen cientos de funciones complejas y miles de líneas de código, sobran los programadores que se han arrepentido de no haber elaborado correctamente sus programas desde un principio.
¿Y qué de la función principal main(), a la cual no se le ha especificado ningún argumento en todos los programas que hemos visto hasta ahora. Aunque no es necesario, para mantener un estilo consistente y formal (además de darle a nuestros programas un aspecto profesional) la función principal cuando carece de argumentos debería ser declarada como:
main(void)
Y se ha dicho “cuando carece de argumentos”, porque resulta que inclusive a la función principal main() se le puede especificar una lista de argumentos.
Una declaración más formal aún para la función principal main() que hemos estado usando en los ejemplos previos consistiría en asignarle un tipo de retorno nulo:
void main(void)
Y de hecho, la función principal main() en vez de regresar un tipo nulo puede regresar un tipo como int que puede ser procesado por el sistema operativo (UNIX, XENIX, DOS, Linux, etc.) dependiendo del valor del entero regresado.
Como ya se señaló, los prototipos fueron agregados a C con la llegada del estandard ANSI C en el cual nos hemos estado basando, antes de dicho estandard no era posible efectuar el prototipo de ninguna función, lo único que se podía especificar era el valor de retorno. Sin embargo, a causa de que no se especificaba nada acerca de los parámetros de una función, las funciones podían ser invocadas con tipos incorrectos o usando un número incorrecto de argumentos sin recibirse ninguna advertencia del compilador. De este modo, una función declarada como:
funcion()
podía ser invocada por igual de las siguientes maneras:
float funcion()
funcion(int a)
funcion(char c, double P)
El estandard ANSI C tenía que ser compatible “hacia abajo” (downward compatible) con el viejo estándard K&R para que los programas viejos pudieran ser compilados por compiladores más modernos (había mucho código C elaborado por progamadores de sistemas y creadores de sistemas operativos), y esta es la razón por la cual el prototipo parcial carente de argumentos podía seguir siendo aceptado; aunque de cualquier modo la recomendación es que para programas nuevos se recurra al estándard más reciente.
Ahora veremos el tema de las reglas de alcance (scope rules) del lenguaje C. Estas son las reglas que determinan si un fragmento de código puede tener conocimiento de o acceso a otra porción de código o datos. En C cada función es un bloque discreto de código. El código (incluyendo datos internos) de una función es privado a esa función y no puede ser accesado por ningún enunciado en ninguna otra función excepto mediante una invocación a esta función. No es posible, por ejemplo, usar un enunciado de salto incondicional goto para saltar desde una función hacia el interior de otra. El código que comprende el cuerpo de una función está oculto del resto del programa, y a menos de que se usen variables globales o datos globales, no puede afectar ni ser afectado por otras partes del programa. Puesto en otros términos, el código y los datos definidos dentro de una función no pueden interactuar con el código y los datos definidos dentro de otra función porque las dos funciones tienen alcances diferentes.
Repasando algo que hemos visto con anterioridad, existen tres tipos de variables: variables locales, parámetros formales y variables globales. Las reglas de alcance determinan cómo estas variables pueden ser accesadas desde otras partes del programa.
Lo más importante a recordar de las variables locales es que existen únicamente cuando se está ejecutando el bloque de código en el cual fueron declaradas, una variable local es creada al entrar en el bloque de código y es destruída al salir del bloque de código, y en este sentido su vida es breve. El bloque de código más común en el cual son declaradas variables locales es la función, como en el caso de las siguientes dos funciones:
void primera(void)
{
int a;
a = 35;
}
void segunda(void)
{
int a;
int 42;
}
La variable de a de tipo int fue declarada dos veces, la primera vez en la función primera() y la segunda vez en la función segunda(). La a en la primera función no tiene ninguna relación con la a usada en la segunda función, se trata de variables totalmente diferentes, cada a es conocida únicamente por el código que está puesto dentro del mismo bloque que la declaración de la variable. Esto lo podemos corroborar con el siguiente programa:
#include <stdio.h>
void funcion(void);
main(void)
{
int x;
x = 7;
printf("x en main() es %d\n", x);
funcion();
printf("x en main() aun es %d\n", x);
}
void funcion(void)
{
int x;
x = 25;
printf("la x en funcion() es %d\n", x);
}
Una curiosidad en C es la palabra reservada auto que puede ser usada para declarar variables locales. Sin embargo, puesto que todas las variables que no son globales son tomadas de manera predeterminada (default) como variables auto, casi nunca es utilizada.
Puesto que las variables locales son creadas y destruídas con cada entrada y salida de un bloque en el cual son declaradas, su contenido se pierde una vez que salimos del bloque, lo cual es importante a recordar cuando se hace la invocación a una función. Cuando se invoca una función sus variables locales son creadas, y al regresar de la función son destruídas, lo cual significa que no pueden retener sus valores entre las invocaciones hechas.
La naturaleza dinámica de las variables locales radica en el hecho de que el almacenamiento de las variables locales es adjudicado en una pila (stack).
A diferencia de las variables locales, las variables globales que son creadas declarándolas fuera de cualquier función son conocidas a través de todo el programa y pueden ser usadas en cualquier parte del programa, su alcance es global.
Obsérvese que en el siguiente programa la variable conteo es declarada fuera de todas las funciones, puesta fuera incluso de la función principal main(), lo cual la hace una variable global:
#include <stdio.h>
int conteo; /* variable global */
void funcion1(void), funcion2(void); /* declaración de dos prototipos */
main()
{
conteo = 85;
funcion1();
}
void funcion1(void)
{
funcion2();
printf("el valor de conteo es %d", conteo);
}
void funcion2(void)
{
int conteo; /* variable local */
for(conteo=1; conteo<12; conteo++)
printf(".");
}
Este programa imprimirá:
...........el valor de conteo es 85
Inspeccionando el programa, debe ser claro que aunque ni main() ni funcion1() han declarado (internamente, dentro de sus bloques de código) a la variable conteo, ambas la pueden usar, al ser una variable global. Sin embargo, funcion2() ha declarado internamente una variable local usando el mismo nombre conteo. ¿Cuál de las dos variables usará? Cuando funcion2() use la variable conteo, usará únicamente su variable local, no la variable global. Esto siempre hay que tener en consideración: si una variable local y una variable global tienen el mismo nombre, cualquier referencia al nombre de la variable dentro de una función en donde la variable local es declarada se referirán a la variable local y no habrá efecto alguno sobre la variable global. Sin embargo, es preferible tratar de no usar en un programa muchas variables globales y locales con el mismo nombre, porque esto puede conducir a confusiones y hacer extremadamente difícil el poder encontrar y depurar los errores que haya en un programa a causa de estas confusiones.
Una variable global se puede declarar en cualquier parte de un programa, pero tiene que aparecer declarada antes de que sea utilizada por vez primera en un programa C. A diferencia de lo que ocurre con las variables locales que son almacenadas temporalmente en una pila (stack) en constante movimiento y actualización, el almacenamiento de las variables globales se lleva a cabo en una región fija de la memoria que permanecerá intacta durante la ejecución del programa, y este es precisamente el pequeño inconveniente que presentan incluso en estos tiempos en los que las memorias RAM andan en el orden de los gigabytes. Las variables globales son útiles cuando los mismos datos son accesados y usados por muchas funciones dentro de un programa, sin embargo consumen memoria todo el tiempo que el programa se está ejecutando y no solo cuando son necesitadas. Usando una variable global en un lugar en donde una variable local sea suficiente hará que la función sea menos general porque dependerá de algo que tiene que ser definido fuera del bloque de código de la función. Y usando un gran número de variables globales puede conducir a errores en el programa debido a efectos no previstos en la manipulación de muchas variables globales. Esto último era evidente en el lenguaje BASIC, en donde todas las variables son globales y no es posible declarar variables locales. Un programa mayúsculo en el desarrollo de programas grandes ocurre cuando se altera accidentalmente el valor de una variable global porque fue usada en algún otro lado del programa, y lo mismo puede ocurrir si se usan demasiadas variables globales.
En un lenguaje estructurado, una de sus principales fortalezas es la separación compartamentalizada de datos y código. En el lenguaje C tal compartamentalización se logra con el uso de funciones y variable locales. El siguiente fragmento es un ejemplo de código de tipo general:
producto(float a, float b)
{
return(a*b);
}
Compárese con el siguiente fragmento que hace exactamente lo mismo:
float a, b;
producto()
{
return(a*b);
}
Ambas versiones regresan el producto de las variables a y b. Sin embargo, y téngase esto presente, que mientras que la primera versión que es una versión generalizada o parametrizada puede ser usada para regresar el producto de dos números cualesquiera, la segunda versión que es una versión específica únicamente puede encontrar el producto de dos variables globales sobre las cuales el programador tiene que mantener la pista de las operaciones que se hayan efectuado previamente sobre dichas variables.
Trataremos ahora sobre la manera en la cual C maneja los parámetros y argumentos de una función. Hay dos maneras en las cuales le podemos pasar los argumentos a una función. La primera manera es con una llamada por valor (call by value), y este método (que es el que usan los matemáticos cuando están resolviendo un problema a mano) inserta el valor de un argumento directamente en el parámetro formal de la función; y la segunda manera es con una llamada por referencia en la cual el domicilio (y no el valor) de un argumento es pasado hacia el parámetro. Dentro de la función el domicilio proporcionado es usado para accesar el argumento actual usado en la invocación, lo cual a su vez implica que los cambios que se le hagan al parámetro afectarán la variable usada para invocar la rutina.
La invocación por valor es usada para pasar argumentos, y esto significa que no se pueden alterar las variables usadas para invocar la función. Considérese el siguiente programa:
#include <stdio.h>
cuadrado (int x);
main(void)
{
int A = 8;
printf("%d %d", cuadrado(A), A);
}
cuadrado(int x)
{
x = x * x;
return(x);
}
En el programa, el valor del argumento para la función cuadrado() es copiado hacia el parámetro x. Cuando se lleva a cabo la asignación x.=.x*x, lo único que se modifica es la variable local x. La variable A, usada para invocar cuadrado(), aún tendrá el valor 8, y por lo tanto la salida producida será “64 8”. Es la copia del valor de un argumento (y no el valor en sí) lo que le es pasado a la función, lo que ocurra dentro de la función no tendrá efecto alguno sobre la variable usada en la invocación.
Es posible crear una invocación por referencia pasando un puntero al argumento de una función. Puesto que esto ocasionará que el domicilio del argumento sea pasado a la función, en este caso será posible alterar el valor del argumento que se encuenta fuera de la función. Los punteros son pasados a las funciones al igual que cualquier otro valor, declarando desde luego los parámetros como de tipo puntero. El ejemplo más sencillo de esto es una función como <b>intercambio()</b> definida de la siguiente manera:
void intercambio(int *x, int *y)
{
int temporal;
temporal = *x; /* guardar el valor en el domicilio x */
*x = *y; /* poner y en x */
*y = temporal; /* poner x en y */
}
El operador de indirección * es usado para accesar la variable hacia la cual apunta su operando, y por lo tanto los contenidos de las variables usadas para llamar la función serán intercambiados. Es importante recordar que al usar una función como intercambio() (o cualquier otra función que usa parámetros de punteros) la función tiene que ser invocada usando los domicilios de los argumentos, o sea con un enunciado como:
intercambio(&x, &y);
El siguiente programa usa la función intercambio() que se ha definido arriba:
#include <stdio.h>
void intercambio(int *x, int *y);
main(void)
{
int x, y;
x = 5;
y = 40;
printf("valores iniciales de x y y: %d %d \n", x, y);
intercambio(&x, &y);
printf("valores intercambiados de x y y: %d %d \n", x, y);
}
Con el programa, a la variable x se le asigna el valor 5 y a la variable y se le asigna el valor 40. La función intercambio() es invocada usando los domicilios de x y y, usando el operador unario & para obtener los domicilios de las variables. De este modo son los domicilios de x y y y no sus valores los que son pasados a la función intercambio().
No solo podemos pasar a una función datos de varios tipos como int y float e inclusive punteror, podemos pasarle arrays enteros de cualquier tamaño. Sin embargo, cuando le pasamos un array a una función, lo único que le estamos pasando es el domicilio del array, y no una copia completa del mismo. Al invocar una función con el nombre de un array, lo que se le pasa a la función es un puntero al primer elemento del array. Recuérdese que en C un nombre de array al que no se le especifica un índice es un puntero hacia el primer elemento del array. Se sobreentiende que la declaración de parámetros tiene que ser de un tipo compatible de punteros.
Hay tres maneras de declarar un parámetro que va a recibir un puntero. La primera consiste en declararlo como un array del mismo tipo y tamaño que lo que será usado para invocar la función, como se muestra en el siguiente programa que imprime los números del cero al nueve:
#include <stdio.h>
void mostrar(int numero[10]);
main(void)
{
int t[10], i;
for(i=0; i<10; ++i) t[i]=i;
mostrar(t);
}
void mostrar(int numero[10])
{
int i;
for(i=0; i<10; i++) printf("%d ", numero[i]);
}
Aunque el parámetro numero es declarado como un array de enteros que consta de diez elementos, el compilador lo convertirá automáticamente en un puntero de tipo entero, lo cual es requerido porque ningún parámetro puede recibir el array completo. Puesto que únicamente se está pasando un puntero hacia el array, debe de haber ahí un parámetro de puntero para recibirlo.
En el ejemplo que se acaba de ver, se conoce de antemano el tamaño del array. Pero en la segunda manera que hay para declarar un parámetro que va a recibir un puntero, especificamos un array sin tamaño fijo de la siguiente manera:
void mostrar(int numero[])
{
int i;
for(i=0; i<10; i++) printf("%d ", numero[i]);
}
En esta segunda manera el array numero es declarado como un array de tipo entero de tamaño desconocido (no hay nada especificado entre los paréntesis rectangulares). El tamaño que tenga el array es irrelevante en este tipo de declaración porque C no lleva a cabo chequeo de límites en arrays. El programa completo implementando la segunda manera es el siguiente:
#include <stdio.h>
void mostrar(int numero[]);
main(void)
{
int t[10], i;
for(i=0; i<10; ++i) t[i]=i;
mostrar(t);
}
void mostrar(int numero[])
{
int i;
for(i=0; i<10; i++) printf("%d ", numero[i]);
}
La tercera manera en que el array numero es declarado, que es la manera usada con mayor frecuencia por los programadores profesionales, es mediante un puntero, como se muestra abajo:
#include <stdio.h>
void mostrar(int *numero);
main(void)
{
int t[10], i;
for(i=0; i<10; ++i) t[i]=i;
mostrar(t);
}
void mostrar(int *numero)
{
int i;
for(i=0; i<10; i++) printf("%d ", numero[i]);
}
En rigor de verdad, los tres métodos usados para declarar un parámetro tipo array producen el mismo resultado: un puntero.
Sin embargo, un elemento de un array (a diferencia del array completo) cuando es usado como un argumento es tratado como cualquier otra variable sencilla. El mismo programa que hemos visto arriba en tres versiones puede ser escrito de la siguiente manera sin pasar el array completo:
#include <stdio.h>
void mostrar(int numero);
main(void)
{
int t[10], i;
for(i=0; i<10; ++i) t[i]=i;
for(i=0; i<10; i++) mostrar(t[i]);
}
void mostrar(int numero)
{
printf("%d ", numero);
}
Hasta aquí hemos estado usando la función main() sin pasarle argumento alguno usando si mucho void en su lista de argumentos, considerándola simple y sencillamente como el punto de entrada hacia un programa ejecutable que tenga un nombre de archivo como PROGRAMA.EXE que será invocable desde una línea de comandos. ¿Pero entonces qué significado se le puede dar a un argumento que se le vaya a pasar a la función principal de un programa C? Sorprendentemente, y en una ventana de líneas de comandos como la que solía encontrarse en el sistema operativo DOS y muchos otros sistemas operativos del ayer como CP/M, las posibilidades ofrecidas son enormes.
Supóngase que usando el lenguaje C hemos creado una utilería tipo UNIX como la utilería llamada GREP (Global Regular Expression Processor, o bien Global Regular Expression Parser) que puede buscar una cierta hilera de texto dentro de uno o más archivos situados en cierto sub-directorio usando la siguiente sintaxis en la línea de comandos DOS:
GREP [Opciones] Hilera Archivo(s)
siendo “Hilera” la expresión que vamos a buscar (por ejemplo, printf() o #include) y siendo “Archivo” la ruta DOS del directorio en el cual se va a llevar a cabo la búsqueda.
La utilería GREP elaborada inicialmente en lenguaje C y guardada en un dispositivo permanente de memoria con un nombre como GREP.C una vez compilada por nosotros sería un archivo ejecutable GREP.EXE. Al ser invocada simplemente usando su nombre GREP (en los sistemas operativos tipo DOS no era necesario especificar la extensión al tratarse de archivos ejecutables) puede actuar sobre un archivo o sobre todos los archivos que haya dentro de un sub-directorio usando los caracteres comodín o wildcard (para esto se usa la notación *.*) como en el siguiente ejemplo en donde sin usar [Opciones] recurrimos a GREP para poder encontrar la expresión “gotoxy” entre un alud de archivos ubicados en el directorio C:\BC4\INCLUDE y en donde estamos pidiéndole a GREP la localización del archivo de cabecera (o los archivos de cabecera) tipo #include que contenga(n) el prototipo de la función gotoxy()), aclarándose que lo que aparece en letras amarillas es lo que escribe el usuario y lo que aparece en letras blancas es la respuesta que GREP dá al usuario (se hará mención sin entrar en detalles que lo que muestra GREP en la tercera línea es la línea completa desde el principio hasta el final del archivo de cabecera en donde se ubica la expresión buscada):
C:>GREP
gotoxy C:\BC4\INCLUDE\*.*
File C:\BC4\INCLUDE\CONIO.H void _Cdecl gotoxy (in __x, int __y); C:> |
En castellano, la utilería GREP nos dice en la segunda línea que el prototipo de la funcion gotoxy() se encuentra en el archivo de cabecera CONIO.H. Obsérvese que en la última línea el control es regresado al sistema operativo.
Como un segundo ejemplo, queremos encontrar con GREP el prototipo de la función <b>atof()</b> en el archivo de cabecera STDLIB.H para saber qué tipo de dato nos regresa al procesar lo que se le dá como argumento:
C:>GREP
atof C:\BC4\INCLUDE\STDLIB.H
File C:\BC4\INCLUDE\STDLIB.H double _Cdecl atof (const char *__s); C:> |
Lo obtenido de GREP nos dice que la función atof() nos regresa un double.
Por último, si queremos saber en cuántos archivos de cabecera se encuentra declarada la función atof() y queremos conocer también los números de línea dentro de estos archivos de cabecera en donde se encuentran las declaraciones, usamos una opción como la siguiente:
C:>GREP
-n+ atof C:\BC4\INCLUDE\*.*
File C:\BC4\INCLUDE\MATH.H 89 double _Cdecl atof (const char *__s); File C:\BC4\INCLUDE\STDLIB.H 74 double _Cdecl atof (const char *__s); C:> |
Si vamos a escribir en C una utilería GREP que logre tales proezas, el programa debe ser capaz no solo de empezar a ejecutarse al ser invocado, sino que debe ser capaz también de tomar varios argumentos que sean especificados en la misma línea de comandos en donde es invocado el programa. En pocas palabras, debe ser posible pasarle argumentos de los conocidos como argumentos de línea de comandos a la función main() de alguna manera. Con esta finalidad, hay por lo menos dos argumentos especiales construídos en la función principal main(). Estos son argc (abreviatura simbólica que significa “argument count”) y argv (abreviatura simbólica que significa “argument values”), usados para recibir argumentos de línea de comandos. No es requisito indispensable que se les dé tales nombres, pero se les dá más por tradición que por necesidad. Y en muchos sistemas hay un tercer argumento env (abreviatura simbólica que significa “environment”), usado para poder tener acceso a los parámetros del entorno proporcionado por el sistema operativo DOS en el momento en el que el programa se empiece a ejecutar. Estos son los únicos argumentos que main() puede tener. El parámetro argc contiene el número de argumentos que hay en la línea de comandos, y por lo tanto es un entero. Siempre será por lo menos 1 porque en una línea de comandos el nombre del programa (por ejemplO, GREP) califica como el primer argumento. Por otro lado, el parámetro argv es un puntero hacia un array de hileras. Cada elemento en este array apunta hacia un argumento de líneas de comandos. Obsérvese que, necesariamente, todos los argumentos en una línea de comandos son hileras, y cualesquier números que haya tendrán que ser convertidos por el programa al formato apropiado.
A continuación se tiene un programa que usa argumentos de líneas de comandos (IMPORTANTE: este tipo de programas no se pueden correr desde los entornos IDE, es necesario compilar el programa hacia un archivo ejecutable y correrlo abriendo primero una ventana tipo DOS y escribiendo en la línea el nombre del programa ejecutable así como el argumento o argumentos que le serán pasados):
#include <stdio.h>
#include <process.h>
main(int argc, char *argv[])
{
if(argc!=2) {
printf("olvidaste escribir tu nombre\n");
exit(0);
}
printf("Hola, %s!", argv[1]);
}
Obsérvese que en el programa solo se utilizan los primeros dos argumentos de main(). Obsérvese también que se hizo una #inclusión del archivo de cabecera PROCESS.H en el cual se encuentra la declaración del prototipo de la función exit(). Si al programa ejecutable compilado se le dá un nombre como UTILERIA.EXE y se echa a andar escribiendo en la línea de comandos UTILERIA ARMANDO, el orden de sucesos será como el que se muestra a continuación:
C:>UTILERIA
ARMANDO
Hola, ARMANDO! C:> |
En la ventana DOS, cada argumento de línea de comando tiene que estar separado del otro por un espacio en blanco o por un tabulador (tab); las comas, semicolons, puntos, etcétera no cuentan como separadores. Esto significa que la línea:
primero, segundo, tercero, y cuarto
consta de cinco hileras mientras que:
primero;segundo;tercero y cuarto
consta de tres hileras, tomando en cuenta que las comas y los semicolones en la ventana DOS no son separadores legales. Si en una ventana DOS se desea pasarle a un programa ejecutable un argumento que contiene espacios en blanco, se le debe poner entre comillas dobles, por ejemplo:
"aqui solo hay un argumento"
El parámetro argv debe ser declarado en forma apropiada, y aunque muchos programadores usan la siguiente declaración:
char **argv;
la manera más usual es la que fue utilizada en el programa dado arriba:
char *argv[];
En esta declaración los paréntesis rectangulares vacíos asientan que se trata de un array de tamaño indeterminado), y podemos accesar cada uno de los argumentos individuales mediante el indexado de argv.argv[0] apunta hacia la primera hilera que es el nombre del programa ejecutable, argv[1] apunta hacia el primer argumento dado en la línea de comandos al programa ejecutable, argv[2] apunta hacia el tercer argumento, y así sucesivamente. En el primer ejemplo dado arriba usando la utilería GREP:
argv[0] apunta hacia GREP
argv[1] apunta hacia gotoxy
argv[2] apunta hacia C:\BC\INCLUDE\*.*
Dadas las especificaciones de lo que debe hacer un cierto programa, el programador de sistemas, sin recurrir en lo absoluto a conceptos sofisticados como la programación-orientada-a-objetos, puede escribir el código fuente para elaborar una utilería como GREP. Y de hecho, así fue como se elaboró GREP de amplio uso en los sistemas operativos UNIX, se elaboró no usando lenguajes ensambladores que varían de una máquina a otra, sino usando el lenguaje C que es lo más cercano que puede haber a un lenguaje de máquina pero sin depender tanto del tipo de procesador CPU que use la máquina. Y aún se pueden obtener archivos fuente de muchas utilerías UNIX como GREP escritos en C, y para tener disponibles estas utilerías como archivos ejecutables en otra computadora como una computadora con el sistema operativo OS/2 instalado en ella o la computadora Lisa de Apple solo se tiene que poseer un compilador C para cada máquina en cuestión, y compilar el archivo fuente en C de la utilería UNIX con cambios mínimos. Desde un principio la portabilidad de C siempre fue una prioridad de los diseñadores de C.
¿Y qué de las computadoras más modernas cuyo funcionamiento se basa no en una ventana tipo DOS de líneas de comandos sino en una interfaz visual tipo Windows con operaciones de arrastre de objetos mediante un Mouse o bien operaciones en una pantalla táctil como las que se llevan a cabo en una tableta? Aunque para la elaboración de los programas ejecutables en estas interfaces visuales se puede recurrir a atajos y simplificaciones como el entorno Visual Basic, también se pueden elaborar programas C para las interfaces visuales tipo Windows. Pero eso ya es otra historia posterior.