Programación de Sistemas Embebidos
Facultad de Informática
Universidad Nacional del Comahue
El lenguaje de programación C fue creado en 1972 por investigadores de Bell Telephone Laboratories, con el objetivo de reescribir un sistema operativo, el UNIX, en un lenguaje de alto nivel, para poder adaptarlo (es decir, portarlo) a diferentes arquitecturas. Por este motivo sus creadores se propusieron metas de diseño especiales, tales como:
Poder utilizar todos los recursos del hardware ().
Obtener código generado eficiente en uso de memoria y en tiempo de ejecución (programas pequeños y veloces).
Compilador portable (implementable en cualquier arquitectura).
Actualmente existen implementaciones de C para todas las arquitecturas y sistemas operativos, y es el lenguaje más utilizado para la programación de sistemas. Por su gran eficiencia resulta ideal para la programación de sistemas operativos, drivers de dispositivos, herramientas de programación. El 95% del sistema operativo UNIX está escrito en C, así como gran parte de los modernos sistemas y ambientes operativos, y los programas de administración o aplicación que corren sobre ellos.
C es un lenguaje compilado. Sintácticamente, presenta similitudes formales con C++, con Java, y aun con Pascal, pero las diferencias en otros aspectos son muy importantes. A pesar de permitir operaciones de bajo nivel, tiene las estructuras de control, y permite la estructuración de datos, propias de los lenguajes procedurales de alto nivel.
El lenguaje C no es un lenguaje orientado a objetos, sino que adhiere al paradigma tradicional de programación imperativa o procedural. No soporta la orientación a objetos propiamente dicha, al no proporcionar herramientas fundamentales, como la herencia. Sin embargo, algunas características del lenguaje permiten que un proyecto de programación se beneficie, de todas maneras, con la aplicación de algunos principios de la orientación a objetos, tales como el ocultamiento de información y el encapsulamiento de responsabilidades. El lenguaje C++, orientado a objetos, no es una versión más avanzada del lenguaje o un compilador de C con más capacidades, sino que se trata de un lenguaje completamente diferente.
Un programa en C es, por lo general, más sintético que en otros lenguajes procedurales; la idea central que atraviesa todo el lenguaje es la minimalidad. La definición del lenguaje consta de muy pocos elementos, y tiene muy pocas palabras reservadas.
Como rasgo distintivo, en C no existen, rigurosamente hablando, funciones o procedimientos de uso general del programador. Por ejemplo, no tiene funciones de entrada/salida; la definición del lenguaje apenas alcanza a las estructuras de control y los operadores. La idea de definir un lenguaje sin funciones es, por un lado, hacer posible que el compilador sea pequeño, fácil de escribir e inmediatamente portable; y por otro, permitir que sea el usuario quien defina sus propias funciones cuando el problema de programación a resolver tenga requerimientos especiales. El usuario puede escribir sus propios procedimientos (llamados funciones aunque no devuelvan valores). Aunque existe la noción de bloque de sentencias (sentencias encerradas entre llaves), el lenguaje se dice no estructurado en bloques porque no pueden definirse funciones dentro de otras.
El lenguaje entrega completamente el control de la máquina subyacente al programador, no realizando controles en tiempo de ejecución. Es decir, no verifica condiciones de error comunes como overflow de variables, errores de entrada/salida, o consistencia de argumentos en llamadas a funciones. Ofrece una gran libertad sintáctica al programador. No es fuertemente tipado. Cuando es necesario, se realizan conversiones automáticas de tipo en las asignaciones, a veces con efectos laterales inconvenientes si no se tiene precaución. Una función que recibe determinados parámetros formales puede ser invocada con argumentos reales de otro tipo.
Se ha dicho que estas características posibilitan la realización de proyectos complejos con más facilidad que otros lenguajes como Pascal o Ada, más estrictos; aunque al mismo tiempo, así resulta más difícil detectar errores de programación en tiempo de compilación. Como resultado, es frecuente que el principiante, y aun el experto, cometan errores de programación que no se hacen evidentes enseguida, ocasionando problemas y costos de desarrollo. En este sentido, según los partidarios de la tipificación estricta, C no es un buen lenguaje. Gran parte del esfuerzo de desarrollo del estándar ANSI se dedicó a dotar al C de elementos para mejorar esta deficiencia.
Una característica especial del lenguaje C es que el pasaje de argumentos a funciones se realiza siempre por valor. ¿Qué ocurre cuando una función debe modificar datos que recibe como argumentos? La única salida es pasarle –por valor– la dirección del dato a modificar. Las consecuencias de este hecho son más fuertes de lo que parece a primera vista, ya que surge la necesidad de todo un conjunto de técnicas de manejo de punteros que no siempre son bien comprendidas por los programadores poco experimentados, y abre la puerta a sutiles y escurridizos errores de programación. Quizás este punto, junto con el de la ausencia de comprobaciones en tiempo de ejecución, sean los que le dan al C fama de .
Los tipos de datos no tienen un tamaño determinado por la definición del lenguaje, sino que diferentes implementaciones pueden adoptar diferentes convenciones. Paradójicamente, esta característica obedece al objetivo de lograr la portabilidad de los programas en C. El programador está obligado a no hacer ninguna suposición sobre los tamaños de los objetos de datos, ya que lo contrario haría al software dependiente de una arquitectura determinada ().
Pese a no estar formalmente definidas funciones de entrada/salida en el lenguaje, se ha establecido un conjunto mínimo de funciones, llamado la Biblioteca Standard del lenguaje C, que todos los compiladores proveen, a veces con agregados. La filosofía de la Biblioteca Standard es la portabilidad, es decir, casi no incluye funciones que sean específicas de un sistema operativo determinado. Aquellas que sí incluye están orientadas a la programación de sistemas, y a veces no resultan suficientes para el programador de aplicaciones. No provee, por ejemplo, la capacidad de manejo de archivos indexados, ni funciones de entrada/salida interactiva por consola que sean seguras (). Estas deficiencias se remedian utilizando bibliotecas de funciones (creadas por el usuario u obtenidas de otros programadores).
Las funciones de la Biblioteca Standard no tienen ningún privilegio sobre las del usuario y sus nombres no son palabras reservadas; el usuario puede reemplazarlas por sus propias funciones simplemente dándoles el mismo nombre.
La primera definición oficial del lenguaje fue dada en 1978 por Brian Kernighan y Dennis Ritchie (Fig. [fig:kandr]) en su libro El lenguaje de programación C. Este lenguaje fue llamado C K&R, por las iniciales de sus autores. En 1983 se creó el comité ANSI para el lenguaje, que en 1988 estableció el estándar ANSI C, con algunas reformas sobre el C K&R. Simultáneamente, Kernighan y Ritchie publicaron la segunda edición de su libro, describiendo la mayor parte de las características del ANSI C.
Algunas nuevas características de C99 son:
Matrices de tamaño variable
Soporte de números complejos
Tipos y de al menos 64 bits
Familia de funciones
Comentarios al estilo de C++ prefijando las líneas con la secuencia .
Familia de funciones
Tipo boolean
Las herramientas esenciales de un ambiente de desarrollo, además de cualquier editor de textos, son el compilador, el vinculador, linkeditor o linker, y el bibliotecario. A estas herramientas básicas se agregan otras, útiles para automatizar la compilación de proyectos extensos, almacenar y recuperar versiones de programas fuente, comprobar sintaxis en forma previa a la compilación, etc. Según el ambiente operativo y producto de software de que se trate, estas herramientas pueden ser comandos de línea independientes, con salidas de texto simples, o encontrarse integradas en una interfaz de usuario uniforme, en modo texto o modo gráfico.
Cuando encontramos varias de estas herramientas integradas en una sola aplicación, decimos que se trata de un IDE (Integrated Development Environment) o ambiente de desarrollo integrado. Un IDE oculta el ciclo de compilación al usuario, con la intención de simplificar el proceso de desarrollo. Sin embargo, conviene conocer qué función se cumple, y qué producto se espera, en cada fase del ciclo de compilación, para poder interpretar las diferentes situaciones de error y poder corregirlas.
El compilador acepta un archivo fuente, posiblemente relacionado con otros (una unidad de traducción), y genera con él un módulo objeto. Este módulo objeto contiene porciones de código ejecutable mezclado con referencias, aún no resueltas, a variables o funciones cuya definición no figura en los fuentes de entrada. Estas referencias quedan en forma simbólica en el módulo objeto hasta que se resuelvan en un paso posterior.
Si ocurren errores en esta fase, se deberán a problemas de sintaxis (el código escrito por el programador no respeta la definición del lenguaje).
El vinculador recibe como entrada un conjunto de módulos objeto y busca resolver, vincular, o enlazar, las referencias simbólicas en ellos, buscando la definición de las variables o funciones faltantes en los mismos objetos o en bibliotecas. Éstas pueden ser la Biblioteca Standard, u otras provistas por el usuario. Cuando el linker encuentra la definición de un objeto buscado (es decir, de una variable o función), la copia en el archivo resultante de salida (la resuelve). El objetivo del linker es resolver todas las referencias pendientes para producir un programa ejecutable.
Si ocurren errores en esta fase, se deberán a que existen variables o funciones cuya definición no ha sido dada (no se encuentran en las unidades de traducción procesadas, ni en ninguna biblioteca conocida por el linker).
El bibliotecario es un programa administrador de módulos objeto. Su función es reunir módulos objeto en archivos llamados bibliotecas, y luego permitir la extracción, borrado, reemplazo y agregado de módulos. El conjunto de módulos en una biblioteca se completa con una tabla de información sobre sus contenidos para que el linker pueda encontrar rápidamente aquellos módulos donde se ha definido una variable o función, y así extraerlos durante el proceso de linkedición.
El bibliotecario es utilizado por el usuario cuando desea mantener sus propias bibliotecas. La creación de bibliotecas propias del usuario ahorra tiempo de compilación y permite la distribución de software sin revelar la forma en que se han escrito los fuentes y protegiéndolo de modificaciones.
Una vez que el código ha sido compilado y vinculado, obtenemos un programa ejecutable. Los errores que pueden producirse en la ejecución ya no corresponden a problemas de compilación, sino que se deben a aspectos de diseño del programa que deben ser corregidos por el programador.
El clásico ejemplo de todas las introducciones al lenguaje C es un programa llamado .
#include <stdio.h>
/* El primer programa! */
main()
{
printf("Hola, gente!\n");
}
Este programa minimal comienza con una directiva de preprocesador que indica incluir en la unidad de traducción al archivo de cabecera o header stdio.h. Éste contiene, entre otras cosas, la declaración (o prototipo) de la función de salida de caracteres printf(), perteneciente a la Biblioteca Standard. Los prototipos se incluyen para advertir al compilador de los tipos de las funciones y de sus argumentos.
Entre los pares de caracteres especiales /* y */se puede insertar cualquier cantidad de líneas de comentarios.
La función main() es el cuerpo principal del programa (es por donde comenzará la ejecución). Todas las funciones en C están delimitadas por un par de llaves. Terminada la ejecución de main(), terminará el programa.
La función imprimirá la cadena entre comillas, que es una constante string terminada por un carácter de nueva línea (la secuencia especial ).
Para ver el primer ejemplo en C en funcionamiento:
Copiar el programa con cualquier editor de textos y guardarlo en un archivo llamado en el directorio de trabajo del usuario.
Sin cambiar de directorio, invocar al compilador ejecutando el comando . Por defecto, el compilador gcc invocará al vinculador ld para generar el ejecutable a partir del archivo objeto intermedio generado.
Ejecutar el programa con el comando .
Notar el punto y barra del principio al ejecutar el programa. El punto y barra le indican al shell que debe buscar el programa en el directorio activo.
Como antes, copiar el programa, o usar el mismo archivo fuente de hace un momento.
Sin cambiar de directorio, ejecutar el comando en una consola o terminal.
Ejecutar el programa con el comando .
La diferencia es que en el primer caso invocamos directamente al compilador , mientras que en el segundo caso utilizamos la herramienta , que nos asiste en la compilación de proyectos. En el ejemplo, le decimos al compilador que procese el archivo fuente , y que el ejecutable de salida (opción , de output) reciba el nombre .
Cuando damos un comando como , utilizamos el comando make para compilar y vincular el programa hola.c. El comando make contiene la inteligencia para:
buscar, en el directorio activo, archivos fuente llamados hola.*;
determinar (a partir de la extensión) que el hallado se trata de un programa en C;
ver que no existe en el directorio activo un programa ejecutable llamado hola, o que, si existe, su fecha de última modificación es anterior a la del fuente;
razonar que, por lo tanto, es necesario compilar el fuente hola.c para producir el ejecutable hola;
e invocar con una cantidad de opciones por defecto al compilador gcc, y renombrar la salida con el nombre hola. Éste será el ejecutable que deseamos producir.
Si se invoca al comando make una segunda vez, éste comprobará, en base a las fechas de modificación de los archivos fuente y ejecutable, que no es necesaria la compilación (ya que el ejecutable es posterior al fuente). Si editamos el fuente para cambiar algo en el programa, invocar nuevamente a make ahora repetirá la compilación (porque ahora el fuente es posterior al ejecutable).
En casos sencillos, el comando make puede hacer en forma autónoma todo el trabajo descripto anteriormente. Sin embargo, cuando se construyen proyectos más complejos, formados por muchos fuentes con interdependencias complejas, o cuando se requieren diferentes opciones del proceso de compilación (o aun diferentes compiladores), entonces es conveniente escribir una guía de actividades para make. Esta guía se guarda en un archivo llamado makefile o Makefile. Al ser invocado, el programa make buscará un archivo con alguno de esos nombres en el directorio actual, y si lo encuentra, ejecutará las tareas que se indican allí.
Un makefile tiene una sintaxis específica y sumamente compleja, pero suele ser suficiente con conocer unas pocas reglas de escritura para escribir makefiles que resulten útiles.
Los objetos que se desea construir se llaman goals o metas. Se indican con rótulos al principio del renglón y seguidos por dos puntos.
Las dependencias de cada goal se escriben inmediatamente después de los dos puntos. Las dependencias son los archivos cuya fecha make comprobará para saber si debe reconstruir un goal o no.
Las acciones que se debe llevar a cabo para cada goal, en caso de que deba ser reconstruido, se indican a continuación, sin renglones intermedios, e indentadas.
Un goal, sus dependencias y las acciones asociadas, se separan de las demás con un renglón en blanco.
Así, por ejemplo, el siguiente makefile es equivalente al conocimiento que make aplicó para construir el programa de nuestro primer ejemplo:
hola: hola.c
gcc hola.c -o hola
Se recomienda consultar el manual del comando make, ya que es muy útil para el desarrollador C dominar el lenguaje de escritura de makefiles. En particular, es valioso comprender que make es versátil y puede utilizarse para automatizar muchas otras tareas, no solamente las de desarrollo de programas.
Luego de la compilación y vinculación, el programa ejecutable queda contenido en un archivo. Al ser invocado, el sistema operativo lo carga en memoria, y allí se despliega en una cantidad de secciones de diferentes tamaños y con distintas funciones.
Los sistemas operativos modernos, salvo raras excepciones, administran la memoria física usando sistemas de memoria virtual. Cada sistema de memoria virtual funciona de modo diferente. La manera como se distribuyen realmente las secciones de un programa en la memoria física depende fuertemente de la forma de administración de memoria del sistema operativo para el cual ha sido compilado y vinculado. Sin embargo, el siguiente modelo puede servir de referencia para ilustrar algunas particularidades y problemas que irán surgiendo con el estudio del lenguaje.
El programa cargado en memoria (Fig. [fig:mapa]) se dividirá en cuatro regiones: código o texto, datos estáticos, heap (o región de datos dinámicos), y stack (o pila).
El tamaño de las regiones de código y de datos estáticos está determinado al momento de compilación y es inamovible. Las otras dos regiones quedan en un bloque cuyo tamaño inicial es ajustado por el sistema operativo al momento de la carga, pero puede variar durante la ejecución. Este bloque es compartido entre ambas regiones. Una de ellas, la de datos dinámicos, o heap, crece (hacia direcciones de memoria más altas); la otra, la pila del programa, o stack, crece (en sentido opuesto).
La región de texto contendrá el código del programa, es decir, la versión ejecutable de las instrucciones que escribió el programador, traducidas por el compilador al lenguaje de la máquina. En general, el programa fuente C se compondrá de funciones, que serán replicadas a nivel de máquina por subrutinas en el lenguaje del procesador subyacente. Algunas instrucciones C resultarán en última instancia en invocaciones a funciones del sistema (por ejemplo, cuando necesitamos escribir en un archivo).
La región de datos estáticos es un lugar de almacenamiento para datos del programa que quedan definidos al momento de la compilación. Se trata de datos cuya vida o instanciación no depende de la invocación de las funciones. Son las variables estáticas, definidas en el cuerpo del programa que es común a todas las funciones. A su vez, esta zona se divide en dos: la de datos estáticos inicializados explícitamente por el programa (zona a veces llamada bss por motivos históricos) y la zona de datos estáticos sin inicializar (a veces llamada data), que será llenada con ceros binarios al momento de la carga del programa.
El stack, o pila, aloja las variables locales de las funciones a medida que esas funciones son invocadas. Cada función que declare variables locales obtendrá espacio de almacenamiento para esas variables en el stack. Al terminar la función, como el ámbito de sus variables desaparece, esas variables son desalojadas, y el espacio que ocupaban vuelve a quedar disponible.
Un programa C puede utilizar estructuras de datos dinámicas, como listas o árboles, que vayan creciendo al agregárseles elementos. El programa puede memoria cada vez que necesite alojar un nuevo elemento de estas estructuras dinámicas, o para crear buffers temporarios para cualquier uso que sea necesario. El heap es la zona de donde el programa obtiene esos trozos de memoria, solicitada en forma dinámica al sistema operativo. El límite del heap se irá desplazando hacia las direcciones superiores.
Este modelo será útil en varias ocasiones para explicar algunas cuestiones especiales del lenguaje C.
Analicemos en qué lugares quedarán alojados los elementos del programa siguiente.
int a = 1;
int b;
int fun()
{
int c;
c = a + b;
}
main()
{
fun();
}
Las variables y corresponden a la zona de datos estáticos. La variable está inicializada con 1, pero como no está inicializada, recibirá un valor 0.
La variable aparecerá en el stack cuando se ejecute la función .
Las instrucciones de máquina procedentes de la compilación del programa (funciones y ) serán almacenadas en la región de texto.
¿Qué nombres son adecuados para los archivos fuente C?
Describa las etapas del ciclo de compilación.
¿Cuál sería el resultado de:
Editar un archivo fuente?
Ejecutar un archivo fuente?
Editar un archivo objeto?
Compilar un archivo objeto?
Editar una biblioteca?
¿Qué pasaría si un programa en C no contuviera una función ? Haga la prueba modificando hola.c.
Edite el programa hola.c y modifíquelo según las pautas que siguen. Interprete los errores de compilación. Identifique en qué etapa del ciclo de compilación ocurren los errores. Si resulta un programa ejecutable, observe qué hace el programa y por qué.
Quite los paréntesis de .
Quite la llave izquierda de .
Quite las comillas izquierdas.
Quite los caracteres .
Agregue al final de la cadena los caracteres .
Agregue al final de la cadena los caracteres .
Quite las comillas derechas.
Quite el signo punto y coma.
Quite la llave derecha de .
Agregue un punto y coma en cualquier lugar del texto.
Agregue una coma o un dígito en cualquier lugar del texto.
Reemplace la palabra por , manteniendo los paréntesis.
Elimine la apertura o cierre de los comentarios.
El compilador C tiene un componente auxiliar llamado preprocesador, que actúa en la primera etapa del proceso de compilación. Su misión es buscar, en el texto del programa fuente entregado al compilador, ciertas directivas que le indican realizar alguna tarea a nivel de texto. Por ejemplo, inclusión de otros archivos, o sustitución de ciertas cadenas de caracteres (símbolos o macros) por otras.
El preprocesador cumple estas directivas en forma similar a como podrían ser hechas interactivamente por el usuario, utilizando los comandos de un editor de texto ( o ), pero en forma automática. Una vez cumplidas todas estas directivas, el preprocesador entrega el texto resultante al resto de las etapas de compilación, que terminarán dando por resultado un módulo objeto. Un archivo fuente, junto con todos los archivos que incluya, es llamado una unidad de traducción.
El preprocesador sirve para eliminar redundancia y aumentar la expresividad de los programas en C, facilitando su mantenimiento. Si una variable o función se utiliza en varios archivos fuente, es posible aislar su declaración, colocándola en un único archivo aparte que será incluido al tiempo de compilación en los demás fuentes. Esto facilita toda modificación de elementos comunes en los fuentes de un proyecto. Por otro lado, si una misma constante o expresión aparece repetidas veces en un texto, y es posible que su valor deba cambiarse más adelante, es muy conveniente definir esa constante con un símbolo y especificar su valor sólo una vez, mediante un símbolo o macro.
Las directivas del preprocesador no pertenecen al lenguaje C en un sentido estricto. El preprocesador no comprende ningún aspecto sintáctico ni semántico de C. Las macros definidas en un programa C no son variables ni funciones, sino simplemente cadenas de texto que el preprocesador deberá sustituir por otras. Las directivas pueden aparecer en cualquier lugar del programa, pero sus efectos se ponen en vigor recién a partir del punto del programa en que aparecen, y hasta el final de la unidad de traducción. Es decir, un símbolo o macro puede utilizarse sólo después de la aparición de la directiva que la define, y no antes. Tampoco puede utilizarse en una unidad de traducción diferente, salvo que vuelva a ser definida en ella (los símbolos de preprocesador no se entre unidades de traducción).
Una de las funciones del preprocesador es sustituir símbolos, o cadenas de texto dadas, por otras Habitualmente se llama símbolos a aquellas cadenas que son directamente sustituibles por una expresión, reservándose el nombre de macros para aquellos símbolos cuya expansión es parametrizable (es decir, llevan argumentos formales y reales como en el caso de las funciones). La cadena de expansión puede ser cualquiera, no necesariamente un elemento sintácticamente válido de C.
La Fig. [fig:direct] muestra el programa ejemplo hola.c escrito usando directivas de inclusión de archivos, símbolos y macros, y las sucesivas transformaciones que hará el preprocesador.
El texto del programa una vez preprocesado quedará idéntico al del programa hola.c anteriormente presentado, y listo para ingresar a la etapa de compilación propiamente dicha.
Las directivas para incluir archivos suelen darse al principio de los programas, ya que en general se desea que su efecto alcance a todo el archivo fuente. Por esta razón los archivos preparados para ser incluidos se denominan headers o archivos de cabecera. La implementación de la Biblioteca Standard que viene con un compilador posee sus propios headers, uno por cada módulo de la biblioteca, que declaran funciones y variables de uso general. Estos headers contienen texto legible por humanos, y están en algún subdirectorio predeterminado (llamado /usr/include en UNIX, y dependiendo del compilador en otros sistemas operativos). El usuario puede escribir sus propios headers, y no necesita ubicarlos en el directorio reservado del compilador; puede almacenarlos en el directorio activo durante la compilación.
En el párrafo anterior, nótese que decimos declarar funciones, y no definirlas; la diferencia es importante y se verá en profundidad más adelante. Recordemos por el momento que en los headers de la Biblioteca Standard no aparecen definiciones -es decir, textos- de funciones, sino solamente declaraciones o prototipos, que sirven para anunciar al compilador detalles como los tipos y cantidad de los argumentos de las funciones.
No se considera buena práctica de programación colocar la definición de una función de uso frecuente en un header. Esto obligaría a recompilar siempre la función cada vez que se la utilizara. Por el contrario, lo ideal sería compilarla una única vez, produciendo un módulo objeto (y posiblemente incorporándolo a una biblioteca). Esto ahorraría el tiempo correspondiente a su compilación, ocupando sólo el necesario para la vinculación.
Si el programa dice:
a = 2 * 3.14159 * 20.299322;
Es mucho más claro poner, en su lugar:
#define PI 3.14159
#define RADIO 20.299322
a = 2 * PI * RADIO;
Con las siguientes directivas:
#include <stdio.h>
#include "aux.h"
#define MAXITEM 100
#define DOBLE(X) 2*X
Se incluye el header de Biblioteca Standard stdio.h
, que contiene declaraciones necesarias para poder utilizar funciones de entrada/salida standard (hacia consola y hacia archivos).
Se incluye un header aux.h
escrito por el usuario. Al indicar el nombre del header entre ángulos, como en la línea anterior, especificamos que la búsqueda debe hacerse en los directorios reservados del compilador. Al indicarlo entre comillas, nos referimos al directorio actual.
Se define un símbolo MAXITEM equivalente a la constante numérica 100.
Se define una macro DOBLE(X) que deberá sustituirse por la cadena 2*(argumento de la llamada a la macro).
De esta manera, podemos escribir sentencias tales como:
a=MAXITEM;
b=DOBLE(45);
El texto luego de la etapa de preprocesamiento y antes de la compilación propiamente dicha será
a=100;
b=2*45;
Es importante comprender que, aunque sintácticamente parecido, el uso de una macro no es una llamada a función; los argumentos de una macro no se evalúan en tiempo de ejecución antes de la llamada, sino que se sustituyen textualmente en el cuerpo de la macro. Así, si ponemos
#define DOBLE(X) 2*X
b=DOBLE(40+5);
el resultado será b=2*40+5
; y no b=2*45
, ni b=2*(40+5)
, que presumiblemente es lo que desea el programador.
Este problema puede solucionarse redefiniendo la macro así:
#define DOBLE(X) 2*(X)
b=DOBLE(40+5);
Ahora la expansión de la macro será la deseada. En general, es saludable rodear las apariciones de los argumentos de las macros entre paréntesis, para obligar a su evaluación al tiempo de ejecución con la precedencia debida, y evitar efectos laterales.
Una característica interesante del preprocesador es que permite la compilación condicional de segmentos de la unidad de traducción, en base a valores de símbolos. Una directiva condicional es aquella que comprueba si un símbolo dado ha sido definido, o si su definición coincide con cierta cadena. El texto del programa que figura entre la directiva y su end
será considerado sólo si la comprobación resulta exitosa. Los símbolos o macros pueden ser definidos al tiempo de la compilación, sin alterar el texto del programa, permitiendo así una parametrización del programa en forma separada de su escritura.
Con las directivas condicionales:
Definimos una macro CARTEL
que equivaldrá a invocar a una función , pero sólo si el símbolo DEBUG
ha sido definido. En otro caso, equivaldrá a la cadena vacía.
#ifdef DEBUG
#define CARTEL(x) imprimir(x)
#else
#define CARTEL(x)
#endif
El segmento siguiente muestra un caso con lógica inversa pero equivalente al ejemplo anterior.
#ifndef DEBUG
#define CARTEL(x)
#else
#define CARTEL(x) imprimir(x)
#endif
En el caso siguiente, se incluirá uno u otro header dependiendo del valor del símbolo SISTEMA
. Tanto DEBUG
como SISTEMA
pueden tomar valores al momento de compilación, si se dan como argumentos para el compilador. De esta manera se puede modificar el comportamiento del programa sin necesidad de editarlo.
#if SISTEMA==MS_DOS
#include "header1.h"
#elif SISTEMA==UNIX
#include "header2.h"
#endif
A veces puede resultar interesante, para depurar un programa, observar cómo queda el archivo intermedio generado por el preprocesador después de todas las sustituciones, inclusiones, etc. La mayoría de los compiladores cuentan con una opción que permite generar este archivo intermedio y detener allí la compilación, para poder estudiarlo.
Otra opción relacionada con el preprocesador que suelen ofrecer los compiladores es aquella que permite definir, al tiempo de la compilación y sin modificar los fuentes, símbolos que se pondrán a la vista del preprocesador. Así, la estructura final de un programa puede depender de decisiones tomadas al tiempo de compilación. Esto permite, por ejemplo, aumentar la portabilidad de los programas, o generar múltiples versiones de un sistema sin diseminar el conocimiento reunido en los módulos fuente que lo componen.
Finalmente, aunque el compilador tiene un directorio default donde buscar los archivos de inclusión, es posible agregar otros directorios para cada compilación con argumentos especiales si es necesario.
Dé ejemplos de directivas de preprocesador:
Para incluir un archivo proporcionado por el compilador.
Para incluir un archivo confeccionado por el usuario.
Para definir una constante numérica.
Para compilar un segmento de programa bajo la condición de estar definida una constante.
Idem bajo la condición de ser no definida.
Idem bajo la condición de que un símbolo valga una cierta constante.
Idem bajo la condición de que dos símbolos sean equivalentes.
Proponga un método para incluir un conjunto de archivos en un módulo fuente con una sola directiva de preprocesador.
¿Cuál es el ámbito de una definición de preprocesador? Si defino un símbolo A en un fuente y lo compilo creando un módulo objeto algo.o, ¿puedo utilizar A desde otro fuente, sin declararlo, a condición de linkeditarlo con algo.o?
¿Qué pasa si defino dos veces el mismo símbolo en un mismo fuente?
Un cierto header A es incluido por otros headers B, C y D. El fuente E necesita incluir a B, C y D. Proponga un método para poder hacerlo sin obligar al preprocesador a leer el header A más de una vez.
Edite el programa hello.c del ejemplo del capítulo 1 reemplazando la cadena Hola, mundo! n
por un símbolo definido a nivel de preprocesador.
Edite el programa hello.c incluyendo la compilación condicional de la instrucción de impresión printf() sujeta a que esté definido un símbolo de preprocesador llamado IMPRIMIR. Compile y pruebe a) sin definir el símbolo IMPRIMIR, b) definiéndolo con una directiva de preprocesador, c) definiéndolo con una opción del compilador. ¿En qué casos es necesario recompilar el programa?
Escriba una macro que imprima su argumento usando la función printf(). Aplíquela para reescribir hello.c de modo que funcione igual que antes.
¿Cuál es el resultado de preprocesar las líneas que siguen? Es decir, ¿qué recibe exactamente el compilador luego del preprocesado?
#define ALFA 8
#define BETA 2*ALFA
#define PROMEDIO(x,y) (x+y)/2
a=ALFA*BETA;
b=5;
c=PROMEDIO(a,b);
¿Qué está mal en los ejemplos que siguen?
#define PRECIO 27.5
PRECIO=27.7;
#define 3.14 PI
#define doble(x) 2*x;
alfa=doble(6)+5;
Investigue la función de los símbolos predefinidos __STDC__
, __FILE__
y __LINE__
.
En general, las expresiones en C se construyen conectando, mediante operadores, diversos elementos, tales como identificadores de variables, constantes e invocaciones de funciones. Cada uno de estos elementos tiene un valor al tiempo de ejecución, y debe ocupar -al menos temporariamente, mientras se calcula el resultado de la expresión- un lugar en memoria. Al evaluar cada expresión, el compilador crea, para alojar cada subexpresión de las que la constituyen, objetos de datos, que pueden pensarse como espacio de memoria reservado temporariamente para contener valores. Al completar el cálculo de la expresión, el resultado nuevamente debe ser alojado en un objeto de datos propio. Estos espacios de memoria son de diferentes (cantidades de bits) de acuerdo al tipo de dato de la subexpresión.
Así, las expresiones y subexpresiones en C asumen siempre un tipo de datos: alguno de los tipos básicos del lenguaje, o uno definido por el usuario. Una expresión, según las necesidades, puede convertirse de un tipo a otro. El compilador hace esto a veces en forma automática. Otras veces, el programador fuerza una conversión de tipo para producir un determinado resultado.
Los tipos básicos son:
char
(un elemento del tamaño de un byte)
int
(un número entero con signo)
long
(un entero largo)
float
(un número en punto flotante)
double
(un número en punto flotante, doble precisión)
Cuando declaramos una variable o forzamos una conversión de tipo, utilizamos una especificación de tipo. Ejemplos de declaración de variables:
char a;
int alfa,beta;
float x1,x2;
Los tipos enteros (char
, int
y long
) admiten los modificadores signed
(con signo) y unsigned
(sin signo). Un objeto de datos unsigned utiliza todos sus bits para representar magnitud; un signed utiliza un bit para signo, en representación complemento a dos.
El modificador signed
sirve sobre todo para explicitar el signo de los chars. El default para un int
es signed; el default para char
puede ser signed o unsigned, dependiendo del compilador.
unsigned int edad;
signed char beta;
Un int puede afectarse con el modificador short
(corto).
short i;
unsigned short k;
Cuando en una declaración aparece sólo el modificador unsigned o short, y no el tipo, se asume int. El tipo entero se supone el tipo básico manejable por el procesador, y es el tipo por omisión en varias otras situaciones. Por ejemplo, cuando no se especifica el tipo del valor devuelto por una función.
El modificador long puede aplicarse también a float y a double. Los tipos resultantes pueden tener más precisión que los no modificados.
long float e;
long double pi;
El lenguaje C no define el tamaño de los objetos de datos de un tipo determinado. Es decir, un entero puede ocupar 16 bits en una implementación del compilador, 32 en otra, o aun 64. Un long
puede tener, o no, más bits que un int
. Un short
puede ser, o no, más corto que un int
. Según K&R, lo único seguro es que “un short
no es mayor que un int
, que a su vez no es mayor que long
”.
Por supuesto, distintos tamaños en bits implican diferentes rangos de valores. Si deseamos portar un programa, hecho bajo una implementación del compilador, a otra, no es posible asegurar a priori el rango que tomará un tipo de datos. La fuente ideal para conocer los rangos de los diferentes tipos, en una implementación determinada, es –además del manual del compilador– el header limits.h
de la Biblioteca Standard. Debe recordarse que cualquier suposición que hagamos sobre el rango o tamaño de un objeto de datos afecta la portabilidad de un programa en C.
Las siguientes líneas son parte de un archivo limits.h
para una implementación en particular:
/* Minimum and maximum values a 'signed short int' can hold. */
#define SHRT_MIN (-32768)
#define SHRT_MAX 32767
/* Maximum value an 'unsigned short int' can hold. (Minimum is 0.) */
#define USHRT_MAX 65535
/* Minimum and maximum values a 'signed int' can hold. */
#define INT_MIN (-INT_MAX - 1)
#define INT_MAX 2147483647
/* Maximum value an 'unsigned int' can hold. (Minimum is 0.) */
#ifdef __STDC__
#define UINT_MAX 4294967295U
#else
#define UINT_MAX 4294967295
#endif
/* Minimum and maximum values a 'signed long int' can hold. */
#ifdef __alpha__
#define LONG_MAX 9223372036854775807L
#else
#define LONG_MAX 2147483647L
#endif
#define LONG_MIN (-LONG_MAX - 1L)
/* Maximum value an 'unsigned long int' can hold. (Minimum is 0.) */
# ifdef __alpha__
#define ULONG_MAX 18446744073709551615UL
#else
#ifdef __STDC__
#define ULONG_MAX 4294967295UL
#else
#define ULONG_MAX 4294967295L
#endif
#endif
Cuando una operación sobre una variable provoca overflow, no se obtiene ninguna indicación de error. El valor sufre truncamiento a la cantidad de bits que pueda alojar la variable.
Así, en una implementación donde los ints son de 16 bits, si tenemos en una variable entera el máximo valor positivo:
int a;
a=32767; /* a=0111111111111111 binario */
a=a+1;
Al calcular el nuevo valor de a
, aparece un 1 en el bit más significativo, lo cual, según la representación de los enteros, lo transforma en un negativo (el menor negativo que soporta el tipo de datos, -32768).
Si el int es sin signo:
unsigned a;
a=65535; /* máximo valor de unsigned int */
a=a+1;
el incremento de a
provoca overflow, y el nuevo valor de a
se trunca a 16 bits, volviendo así a 0.
Siempre se puede saber el tamaño en bits de un tipo de datos aplicando el operador sizeof()
a una variable o a la especificación de tipo.
En una expresión en C pueden aparecer componentes de diferentes tipos. Durante la evaluación de una expresión cuyas subexpresiones sean de tipos diferentes, deberá tener lugar una conversión, ya sea implícita o explícita, para llevar ambos operandos a un tipo de datos común con el que se pueda operar. La forma en que el compilador resuelve las conversiones implícitas a veces provoca algunas sorpresas.
Para empezar, una asignación de una expresión de un tipo dado a una variable de un tipo (en el sentido del tamaño en bits de cada objeto de datos), no sólo es permitida en C, sino que la conversión se hace en forma automática y generalmente sin ningún mensaje de tiempo de compilación ni de ejecución. Por ejemplo,
int a;
float b;
...
a=b;
En esta asignación tenemos miembros de diferentes tamaños. El resultado en a
será el truncamiento del valor entero de b
a la cantidad de bits que permita un int. Es decir, se tomará la parte entera de b
, y de ese valor se copiarán en el objeto de datos de a
tantos bits como quepan en un int, tomándose los menos significativos.
Si el valor de b
es, por ejemplo, 20.5, a
valdrá finalmente 20, lo que es similar a aplicar una función implícitamente, y no demasiado incongruente. Pero si la parte entera de b
excede el rango de un entero (por ejemplo si b=99232.5
en una plataforma donde los enteros son de 16 bits), el resultado en a
no tendrá lógica aparente. En el primer caso, los bits menos significativos de b
que en a
conservan el valor de b
; en el segundo caso, no.
En la sentencia:
a=19.27 * b;
a
contendrá los sizeof(int)
bits menos significativos del resultado de evaluar la expresión de la derecha, truncada sin decimales.
Por otra parte, se tienen las reglas de promoción automática de expresiones. Enunciadas en forma aproximada (luego las daremos con más precisión), estas reglas dicen que el compilador hará estrictamente las conversiones necesarias para llevar todos los operandos al tipo del entre ellos. El resultado de evaluar una operación aritmética será del tipo del de sus operandos.
A veces, esto no es lo que se desea.
Dada la sentencia:
a=3/2;
se tiene que tanto la constante 3 como la constante 2 son vistas por el compilador como ints; el resultado de la división será también un entero (la parte entera de 3/2, o sea 1).
Aun más llamativo es el hecho de que si declaramos previamente:
float a;
el resultado es casi el mismo: a
contendrá finalmente el valor float 1.0, porque el problema de truncamiento se produce ya en la evaluación del miembro derecho de la asignación.
En el ejemplo anterior, ¿cómo recuperar el valor correcto, con parte decimal, de la división? Declarar a la variable a
como float es necesario, pero no suficiente. Para que la expresión del miembro derecho sea float es necesario que al menos uno de sus operandos sea float. Hay dos formas de lograr esto; la primera es escribir cualquiera de las subexpresiones como constante en punto flotante (con punto decimal, o en notación exponencial):
a=3./2;
La segunda consiste en forzar explícitamente una conversión de tipo, con un importante operador llamado cast, de la siguiente manera.
a=(float)3/2;
El operador cast es la aclaración, entre paréntesis, del tipo al cual queremos convertir la expresión (en este caso, la subexpresión 3). Da lo mismo aplicarlo a cualquiera de las constantes. Sin embargo, lo siguiente no funcionará:
a=(float)(3/2);
Aquí el operador cast se aplica a la expresión ya evaluada como entero, con lo que volvemos a tener un valor 1.0, float, en a
.
Son aplicadas por el compilador en el orden que se da más abajo (tomado de K&R, 2a. ed.). Ésta es una lista muy detallada de las comprobaciones y conversiones que tienen lugar. Para la mayoría de los propósitos prácticos, basta tener en cuenta la regla de llevar ambos operandos al tipo del de ellos.
Entendemos por el acto de llevar los objetos de tipo char
, enum
y campos de bits a int; o, si los bits de un int no alcanzan a representarlo, a unsigned int.
Nótese que no existen en C tipos boolean
ni string
. Más adelante veremos cómo manejar datos de estas clases.
El tipo char, pese a su nombre, no está restringido a la representación de caracteres. Por el contrario, un char tiene entidad aritmética. Almacena una cantidad numérica y puede intervenir en operaciones matemáticas. En determinadas circunstancias, y sin perder estas propiedades, puede ser interpretado como un carácter (el carácter cuyo código ASCII contiene).
En general, en C es conveniente habituarse a pensar en los datos separando la representación (la forma como se almacena un objeto) de la presentación (la forma como se visualiza). Un mismo patrón de bits almacenado en un objeto de datos puede ser visto como un número decimal, un carácter, un número hexadecimal, octal, etc. La verdadera naturaleza del dato es la representación de máquina, el patrón de bits almacenado en el objeto de datos.
Con el objeto de facilitar la práctica, describimos aquí la función de Biblioteca Standard printf()
.
La función de salida printf()
lleva un número variable de argumentos.
Su primer argumento siempre es una cadena o constante string, la cadena de formato, conteniendo texto que será impreso, más, opcionalmente, especificaciones de conversión.
Las especificaciones de conversión comienzan con un signo %. Todo otro conjunto de caracteres en la cadena de formato será impreso textualmente.
Cada especificación de conversión determina la manera en que se imprimirán los restantes argumentos de la función.
Deben existir tantas especificaciones de conversión como argumentos luego de la cadena de formato.
Un mismo argumento de un tipo dado puede ser impreso o presentado de diferentes maneras según la especificación de conversión que le corresponda en la cadena de formato (de aquí la importancia de separar representación de presentación)
Las especificaciones de conversión pueden estar afectadas por varios modificadores opcionales que determinan, por ejemplo, el ancho del campo sobre el cual se escribirá el argumento, la cantidad de decimales de un número, etc.
Las principales especificaciones de conversión están dadas en el Cuadro [tab:conv].
Este programa escribe algunos valores con dos especificaciones de formato distintas.
main() {
int i,j;
for(i=65, j=1; i<70; i++, j++)
printf("vuelta no. %d: i=%d, i=%c\n",j,i,i);
}
Salida del programa:
vuelta no. 1: i=65, i=A
vuelta no. 2: i=66, i=B
vuelta no. 3: i=67, i=C
vuelta no. 4: i=68, i=D
vuelta no. 5: i=69, i=E
El programa siguiente escribe el mismo valor en doble precisión pero con diferentes modificadores del campo correspondiente, para incluir una cierta cantidad de decimales o alinear la impresión.
main() {
double d;
d=3.141519/2.71728182;
printf("d=%lf\n",d);
printf("d=%20lf\n",d);
printf("d=%20.10lf\n",d);
printf("d=%20.5lf\n",d);
printf("d=%.10lf\n",d);
}
Salida del programa:
d=1.156126
d= 1.156126
d= 1.1561255726
d= 1.15613
d=1.1561255726
¿Cuáles de entre estas declaraciones contienen errores?
2
integer a;
short i,j,k;
long float (h);
double long d3;
unsigned float n;
char 2j;
int MY;
float ancho, alto, long;
bool i;
Dé declaraciones de variables con tipos de datos adecuados para almacenar:
La edad de una persona.
Un número de DNI.
La distancia, en Km, entre dos puntos cualesquiera del globo terrestre.
El precio de un artículo doméstico.
El valor de la constante PI expresada con 20 decimales.
Prepare un programa con variables conteniendo los valores máximos de cada tipo entero, para comprobar el resultado de incrementarlas en una unidad. Imprima los valores de cada variable antes y después del incremento. Incluya unsigneds.
Lo mismo, pero dando a las variables los valores mínimos posibles, e imprimiéndolas antes y después de decrementarlas en una unidad.
Averigüe los tamaños de todos los tipos básicos en su sistema aplicando el operador sizeof()
.
Si se asigna la expresión (3 − 5) a un unsigned short
, ¿cuál es el resultado? ¿Depende de qué formato de conversión utilicemos para imprimirlo?
¿Qué hace falta corregir para que la variable r
contenga la división exacta de a
y b
?
int a, b;
float r;
a = 5;
b = 2;
r = a / b;
¿Qué resultado puede esperarse del siguiente fragmento de código?
int a, b, c, d;
a = 1;
b = 2;
c = a / b;
d = a / c;
¿Cuál es el resultado del siguiente fragmento de código? Anticipe su respuesta en base a lo dicho en esta unidad y luego confírmela mediante un programa.
printf("%d\n", 20/3);
printf("%f\n", 20/3);
printf("%f\n", 20/3.);
printf("%d\n", 10%3);
printf("%d\n", 3.1416);
printf("%f\n", (double)20/3);
printf("%f\n", (int)3.1416);
printf("%d\n", (int)3.1416);
Escribir un programa que multiplique e imprima 100000 * 100000. ¿De qué tamaño son los ints en su sistema?
Convertir una moneda a otra sabiendo el valor de cambio. Dar el valor a dos decimales.
Escriba y corra un programa que permita saber si los chars en su sistema son signed o unsigned.
Escriba y corra un programa que asigne el valor 255 a un char, a un unsigned char y a un signed char, y muestre los valores almacenados. Repita la experiencia con el valor −1 y luego con $'\377'$. Explicar el resultado.
Copiar y compilar el siguiente programa. Explicar el resultado.
main() {
double x;
int i;
i = 1400;
x = i; /* conversion de int a double */
printf("x = %10.6le\ti = %d\n",x,i);
x = 14.999;
i = x; /* conversion de double a int */
printf("x = %10.6le\ti = %d\n",x,i);
x = 1.0e+60;
i = x;
printf("x = %10.6le\ti = %d\n",x,i);
}
Escriba un programa que analice la variable v
conteniendo el valor 347 y produzca la salida:
3 centenas
4 decenas
7 unidades
(y, por supuesto, salidas acordes si v
toma otros valores).
Sumando los dígitos de un entero escrito en notación decimal se puede averiguar si es divisible por 3 (se constata si la suma de los dígitos lo es). ¿Esto vale para números escritos en otras bases? ¿Cómo se puede averiguar esto?
Indicar el resultado final de los siguientes cálculos
int a; float b = 12.2; a = b;
int a, b; a = 9; b = 2; a /= b;
long a, b; a = 9; b = 2; a /= b;
float a; int b, c; b = 9; c = 2; a = b/c;
float a; int b, c; b = 9; c = 2; a = (float)(b/c);
float a; int b, c; b = 9; c = 2; a = (float)b/c;
short a, b, c; b = -2; c = 3; a = b * c;
short a, b, c; b = -2; c = 3; a = (unsigned)b * c;
Aplicar operador cast donde sea necesario para obtener resultados apropiados:
int a; long b; float c;
a = 1; b = 2; c = a / b;
long a;
int b,c;
b = 1000; c = 1000;
a = b * c;
¿Cuál es la salida de este programa? ¿Cuál es la explicación?
#include <stdio.h>
int main(){
printf("%f\n", (float)333334126.98);
printf("%f\n", (float)333334125.31);
}
Las constantes numéricas en un programa C pueden escribirse en varias bases. Además, la forma de escribirlas puede modificar el tamaño de los objetos de datos donde se almacenan.
10, -1
son constantes decimales.
010, -012
son constantes octales por comenzar en .
0x10, -0x1B, 0Xbf01
son constantes hexadecimales por comenzar en o .
’A’
es una constante de carácter. En una computadora que sigue la convención del código ASCII, equivale al decimal , hexadecimal , etc.
Una constante entera puede indicarse como long agregando una letra L mayúscula o minúscula: 0L, 43l
. Si bien numéricamente son equivalentes a 0 y a 43 int, el compilador, al encontrarlas, manejará constantes de tamaño long (construirá objetos de datos sobre la cantidad de bits correspondientes a un long). Esto puede ser importante en ciertas ocasiones: por ejemplo, al invocar a funciones con argumentos formales long, usando argumentos reales que caben en un entero.
Para hacer más claro el propósito de una constante positiva, o para forzar la promoción de una expresión, puede notársela como unsigned. Esto tiene que ver con las reglas de promoción expresadas en el capítulo 3. Constantes unsigned son, por ejemplo, y .
Las constantes en punto flotante se caracterizan por llevar un punto decimal o un carácter ’e’ (que indica que está en notación exponencial). Así son constantes en punto flotante. La constante se interpreta como el número multiplicado por 1023. La constante es igual a .
El texto comprendido entre comillas dobles en un programa C es interpretado por el compilador como una constante string, con propiedades similares a las de un arreglo de caracteres.
El proceso de compilación, al identificarse una constante string, es como sigue:
El compilador reserva una zona de memoria en la imagen del programa que está construyendo, del tamaño del string más uno.
Se copian los caracteres entre comillas en esa zona, agregando al final un byte conteniendo un cero (’’
).
Se reemplaza la referencia a la constante string en el texto del programa por la dirección donde quedó almacenada.
La cadena registrada por el compilador será almacenada al momento de ejecución en la zona del programa correspondiente a datos estáticos inicializados (zona a veces llamada ). Así, una constante string equivale a, o se evalúa a, una dirección de memoria: la dirección donde está almacenado su primer carácter.
El carácter ’’
impuesto por el compilador al final de la secuencia de caracteres señaliza el fin de la cadena, y tiene la importante misión de funcionar como protocolo o señal de terminación para aquellas funciones de la Biblioteca Standard que manejan strings (copia de cadenas, búsqueda de caracteres, comparación de cadenas, etc.). Debido a esta representación interna, algunas veces se las ve mencionadas con el nombre de cadenas ASCIIZ (caracteres ASCII seguidos de cero).
Gracias a esta representación con el cero final, las cadenas ASCIIZ en C no tienen una longitud máxima. El final de la cadena simplemente ocurre donde aparezca un carácter cero.
¿Hay diferencias entre ’’
, ’0’
y 0
? Muchas.
La primera constante, ’’
, es un entero. Su valor aritmético es 0. Todos los bits del objeto de datos donde está representada son 0.
La segunda, ’0’
, es una constante de carácter. Ocupa un objeto de datos de 8 bits de tamaño. Su valor es 48 decimal en aquellas computadoras cuyo juego de caracteres esté basado en ASCII, pero puede ser diferente en otras.
La tercera, 0
, es una constante string, y se evalúa a una dirección. Es decir, en cualquier expresión donde figure, su valor aritmético es una dirección de memoria dentro del espacio de direcciones del programa. Ocupa un objeto de datos del tamaño de una dirección (frecuentemente 16 o 32 bits), además del espacio de memoria ubicado a partir de esa dirección y ocupado por los caracteres del string. Ese espacio de memoria está ocupado por un byte igual a ’0’
(el primer y único carácter del string), que como hemos visto equivale a 48 decimal en computadoras que adoptan ASCII, y a continuación viene un byte ’’
, o sea, 0 (señal de fin del string).
Si tenemos las declaraciones y asignaciones siguientes, donde las tres variables declaradas son char:
char a,b,c;
a='\0';
b='0';
c="0";
La primera asignación es perfectamente válida y equivale a a=0
.
La segunda asignación también es correcta y equivale a b=48
en computadores basados en ASCII.
La tercera asignación será rechazada por el compilador, generándose un error de . Los objetos a ambos lados del signo igual son de diferente naturaleza: a la izquierda tenemos algo que puede ser directamente usado como un dato (una constante o una variable); a la derecha, algo que, indirectamente, referencia a un dato (una dirección). Se dice que la variable y la constante string tienen diferente nivel de indirección.
El gráfico muestra el resultado de asignar algunas constantes relacionadas con el problema anterior, suponiendo una arquitectura donde los enteros y las direcciones de memoria son de 16 bits. Las tres primeras asignaciones dejan en a
valores aritméticos 0, 48 y 0.
Las dos últimas asignaciones dejan en la dirección de una cadena almacenada en memoria. Las cadenas apuntadas son las que están representadas en el diagrama.
La primera cadena contendrá el código ASCII del carácter 0 y un cero binario señalizando el fin del string. La segunda contendrá un cero binario (expresado por la constante de carácter ’’
) y un cero binario fin de string.
Las constantes de carácter son una forma expresiva y portable de especificar constantes numéricas. Internamente, durante la compilación y ejecución del programa, el compilador las entiende como valores numéricos sobre ocho bits. Así, es perfectamente lícito escribir expresiones como ’A’ + 1
(que equivale a 66
, o a 0x42
, o a la constante de carácter ’B’
).
Algunos caracteres especiales tienen una grafía especial:
carácter ’backspace’, ASCII 8
tabulador, ASCII 9
fin de línea, ASCII 10 (UNIX) o secuencia 13,10 (DOS)
retorno de carro, ASCII 13
Una forma alternativa de escribir constantes de carácter es mediante su código ASCII: ’33’, ’1B’
Aquí representamos el carácter cuyo código ASCII es 27 decimal, en dos bases. La barra invertida (backslash) muestra que el contenido de las comillas simples debe ser interpretado como el código del carácter. Si el carácter siguiente al backslash es x o X, el código está en hexadecimal; si no, está en octal. Para representar el carácter backslash, sin su significado como modificador de secuencias de otros caracteres, lo escribimos dos veces seguidas.
Estas constantes de carácter pueden ser también escritas respectivamente como las constantes numéricas , o , ya que son aritméticamente equivalentes; pero con las comillas simples indicamos que el programador a estas constantes como caracteres, lo que puede agregar expresividad a un segmento de programa.
Por ejemplo, 0 es claramente una constante numérica; pero si escribimos ’’
(que es numéricamente equivalente), ponemos en evidencia que estamos pensando en el carácter cuyo código ASCII es 0. El carácter ’’
(ASCII 0) es distinto de ’0’
(ASCII 48). La expresión de las constantes de carácter mediante backslash y algún otro contenido se llama una secuencia de escape.
Todas estas notaciones para las constantes de carácter pueden intervenir en la escritura de constantes string. El mecanismo de reconocimiento de constantes de caracteres dentro de strings asegura que todo el juego de caracteres de la máquina pueda ser expresado dentro de una constante string, aun cuando no sea imprimible o no pueda producirse con el teclado. Cuando el compilador se encuentre analizando una constante string asignará un significado especial al carácter barra invertida o backslash (). La aparición de un backslash permite referirse a los caracteres por su código en el sistema de la máquina (por lo común, el ASCII).
Como una alternativa más legible y expresiva a la definición de constantes de preprocesador, se pueden definir grupos de constantes reunidas por una declaración. Una declaración de constantes enumeradas hace que las constantes tomen valores consecutivos de una secuencia.
Si no se especifica el primer inicializador, vale 0. Si alguno se especifica, la inicialización de los restantes continúa la secuencia. Las constantes de una enumeración no necesitan tener valores distintos, pero todos los nombres en las diferentes declaraciones enum
de un programa deben ser diferentes.
Aquí los valores de las constantes son ENE = 1, FEB = 2, MAR = 3, etc.
enum meses {
ENE = 1, FEB, MAR, ABR, MAY, JUN,
JUL,AGO, SEP, OCT, NOV, DIC
};
Aquí los valores asumidos son respectivamente 0, 1, 2, 5, 6, y nuevamente 1 y 2.
enum varios { ALFA, BETA, GAMMA, DELTA = 5,IOTA, PI = 1, RHO };
[tc-constantes-preg] Elija dos constantes correctamente escritas: y . y . y . Todas las anteriores.
Una constante de carácter correctamente escrita entre las siguientes es: . A
. . . Todas las anteriores.
La constante de carácter en un sistema basado en ASCII tiene el valor decimal 0. 48. “0”.
La constante de carácter en un sistema basado en ASCII tiene el valor decimal 0. 48. “0”.
La constante string A 103
se leerá una vez impresa como AA
. A103
. A\\103
. A\103
.
La cadena es Una constante decimal. Una constante hexadecimal. Una constante string. Una constante de carácter. Ninguna de las anteriores.
Indicar si las siguientes constantes están bien formadas, y en caso afirmativo indicar su tipo y dar su valor decimal.
4
’C’
’0’
’0xAB’
70
1A
0xABL
070
’010’
0xaB
080
0x10
’0xAB’
0XFUL
’30’
-40L
015L
x41
’B’
’’
’AB’
322U
Indicar qué caracteres componen las constantes string siguientes:
ABC
03B41
¿Cómo se imprimirán estas constantes string?
BA
0BA
BACD
¿Qué imprime esta sentencia? Pista: nada no es la respuesta correcta.
printf("0\r1\r2\r3\r4\r5\r6\r7\r8\r9\r");
Escribir una macro que devuelva el valor numérico de un carácter correspondiente a un dígito en base decimal.
Idem donde el carácter es un dígito hexadecimal entre A y F.
Las variables tienen diferentes propiedades según que sean declaradas dentro o fuera de las funciones, y según ciertos modificadores utilizados al declararlas. Entre las propiedades de las variables distinguimos:
Alcance (desde dónde es visible una variable)
Vida (cuándo se crea y cuándo desaparece)
Clase de almacenamiento (dónde y cómo se aloja la información que contiene)
Liga o linkage (en qué forma puede ser manipulada por el linker)
Las reglas que determinan, a partir de la declaración de una variable, cuáles serán sus propiedades, son bastante complejas. Estas reglas son tan interdependientes, que necesariamente la discusión de las propiedades de las variables será algo reiterativa.
Una declaración puede aparecer, o bien dentro de una función, o bien fuera de todas ellas. En el primer caso, hablamos de una variable local; en el segundo, se trata de una variable externa, o global, y las diferencias entre ambas son muchas e importantes. Por supuesto, la primera consecuencia del lugar de declaración es el alcance, o ámbito de visibilidad de la variable: una variable local es visible sólo desde dentro de la función donde es declarada. Una variable externa puede ser usada desde cualquier función de la unidad de traducción, siendo suficiente que la declaración se encuentre antes que el uso.
La variable m
declarada al principio es externa, y puede ser vista desde fun1()
y fun2()
. Sin embargo, fun1()
declara su propia variable m local, y toda operación con m
dentro de fun1()
se referirá a esta última. Por otro lado, la variable n
es también externa, pero es visible sólo por fun2()
, porque todo uso de las variables debe estar precedido por su declaración. Si apareciera una referencia a la variable n
en fun1()
, se dispararía un error de compilación.
int m;
int fun1()
{
int m;
m=1;
...
}
int n;
int fun2()
{
m=1;
...
}
Una variable externa se crea al momento de carga del programa, y perdura durante toda la ejecución del mismo. Una variable local se crea y se destruye a cada invocación de la función donde esté declarada (excepción: las locales estáticas).
Cada vez que fun2()
asigna el resultado de fun1()
a j
, está utilizando el mismo objeto de datos de la misma variable j
, porque ésta es externa; pero cada invocación de fun1()
crea un nuevo objeto de datos para la variable k
, el cual se destruye al terminar esta función.
int j;
int fun1()
{
int k;
...
}
int fun2()
{
j=fun1();
}
La diferencia del siguiente ejemplo con el anterior es que ahora k
es declarada con el modificador static. Esto hace que k
tenga las mismas propiedades de vida que una variable externa. A cada invocación de fun1()
, ésta utiliza el mismo objeto de datos, sin modificarlo, para la variable k
. Si lee su valor, encontrará el contenido que pueda haberle quedado de la invocación anterior. Si le asigna un valor, la invocación siguiente de fun1()
encontrará ese valor en k
.
Este ejemplo muestra que alcance y vida no son propiedades equivalentes en C.
int j;
int fun1()
{
static int k;
...
}
int fun2()
{
j=fun1();
}
La propiedad que diferencia ambas instancias de k
es la clase de almacenamiento; en el primer caso, k
es local y automática; en el segundo, k
es local pero estática.
Dependiendo de cómo son almacenados los contenidos de las variables (es decir, en qué lugar del mapa de memoria del programa se mantienen los objetos de datos), éstas pueden tener varias clases de almacenamiento. Una variable externa tiene clase de almacenamiento estática. Una variable local tiene -salvo indicación contraria- clase de almacenamiento automática. Una tercera clase de almacenamiento es la llamada registro. La clase de almacenamiento determina, como se vio recién, la vida de las variables.
Las variables estáticas comienzan su vida al tiempo de carga del programa, es decir, aun antes de que se inicie la ejecución de la función . Existen durante todo el tiempo de ejecución del programa. Son inicializadas con ceros binarios, salvo que exista otra inicialización explícita. Son las variables externas y las locales declaradas .
Esta clase abarca exclusivamente las variables, declaradas localmente a una función, que no sean declaradas . El objeto de datos de una variable automática inicia su existencia al entrar el control a la función donde está declarada, y muere al terminar la función. No son inicializadas implícitamente, es decir, contienen basura salvo que se las inicialice explícitamente.
Una variable registro no ocupará memoria, sino que será mantenida en un registro del procesador.
En el segmento de programa siguiente:
int m;
int fun()
{
int j;
register int k;
static int l;
...
}
La variable m
, por ser externa, tiene clase de almacenamiento estática.
Las variables j
, k
y l
son locales, pero sólo j
es automática.
La variable l
es estática (tiene propiedades de vida similares a las de m
).
Por su parte k
es de tipo registro, lo que quiere decir que el compilador, siempre que resulte posible, mantendrá sus contenidos en algún registro de CPU de tamaño adecuado.
Una declaración register
debe tomarse solamente como una recomendación hecha por el programador al compilador, ya que no hay garantías de que, al tiempo de ejecución, resulte posible utilizar un registro para esa variable. Más aún, el mismo programa, compilado y corrido en diferentes arquitecturas, podrá utilizar diferentes cantidades de registros para sus variables.
Una variable tendrá un tiempo de acceso muy inferior al de una variable en memoria, porque el acceso a un registro de CPU es mucho más rápido. La motivación de la clase de almacenamiento registro se hace evidente en el Cuadro [tab:tiempos] (tomado de Systems Performance: Enterprise and the Cloud, Brendan Gregg, Prentice Hall, 2014).
En general resulta interesante que las variables más frecuentemente accedidas sean las declaradas como ; típicamente, los índices de arrays, variables de control de lazos, etc. Sin embargo, la declaración es quizás algo anacrónica, ya que los compiladores modernos ejecutan una serie de optimizaciones que frecuentemente utilizan registros para mantener las variables, aun cuando no haya indicación alguna por parte del programador.
La clase de almacenamiento automática es la natural para las variables locales; ¿cuál es la idea de declarar variables locales que sean estáticas? Generalmente se desea aprovechar la capacidad de de las variables estáticas, utilizando el valor al momento de la última invocación para producir uno nuevo.
La inicialización (implícita o explícita) de una variable estática se produce una única vez, al momento de carga del programa. Por el contrario, la inicialización (explícita) de una variable automática se hace al crear cada instancia de la misma (al momento de la entrada del control a la función).
Usando variables estáticas, una función puede, por ejemplo, contar la cantidad de veces que ha sido llamada. En el código siguiente, el ciclo while se ejecuta 50 veces y la variable asume cada vez un valor dependiente del anterior, pese a ser local a la función .
int veces()
{
static int vez=0;
return ++vez;
}
int fun()
{
while(veces() <= 50) {
...
}
}
De acuerdo a su clase de almacenamiento, las variables aparecen en diferentes regiones del mapa de memoria del programa en ejecución.
Las variables locales (automáticas) se disponen en la pila o stack del programa. Debido a la forma de administración de esta zona de la memoria, existen solamente hasta la finalización de la función.
Las variables estáticas (las externas, y las locales cuando son declaradas static
) se alojan en la zona de datos estáticos. Esta zona no cambia de tamaño ni pierde sus contenidos, y queda inicializada al momento de carga del programa.
A medida que una función invoca a otras, las variables locales van apareciendo en el stack, y a medida que las funciones terminan, el stack se va desalojando en orden inverso a como aparecieron las variables. Cada función, al recibir el control, toma parte del stack, con los contenidos que hubieran quedado allí de ejecuciones previas, para alojar allí sus variables. A esto se debe que el programa las vea inicializadas con basura.
Con el código de la Fig. [fig:stack1], el estado del stack en momentos sucesivos será:
Antes de entrar a fun1()
se tiene el stack vacío.
Al entrar a fun1()
se disponen sus variables locales en el stack, en orden de aparición.
Al entrar a fun2()
se dispone en el stack su variable local.
Al salir de fun2()
y volver a fun1()
se desaloja la variable local de fun2()
.
Al salir de fun1()
se desmantela el stack completamente y se vuelve al estado inicial.
Con el código de la Fig. [fig:stack2], que invoca a dos funciones secuencialmente, el estado del stack en momentos sucesivos será:
Antes de entrar a fun1()
se tiene el stack vacío.
Al entrar a fun1()
se disponen sus variables locales en el stack, en orden de aparición.
Al terminar fun1()
se desmantela el stack.
Al entrar en fun2()
se dispone en el stack su variable local, cuyo objeto de datos tendrá basura debida al valor de a dejado por fun1()
.
Al salir de fun2()
se desmantela su stack completamente y se vuelve al estado inicial.
La liga es la propiedad que determina si las variables y funciones definidas en una unidad de traducción serán o no visibles por el linker. Una vez que un conjunto de unidades de traducción pasa exitosamente la compilación, tenemos un conjunto de módulos objeto. Cada módulo objeto puede contener, en forma simbólica, pendiente de resolución, referencias a variables o funciones definidas en otros módulos. La propiedad de las variables y funciones que permite que el linker encuentre la definición de un objeto para aparearlo con su referencia es la liga externa. Tienen liga externa por defecto las variables externas y las funciones, de modo que todas éstas pueden ser referenciadas desde otras unidades de traducción.
El concepto de liga externa es importante cuando el proyecto de desarrollo abarca varias unidades de traducción que deben dar lugar a un ejecutable. Aprovechando la propiedad de liga externa de las funciones, se puede ubicar cada definición de función, o un conjunto de ellas, en un archivo separado. Esto suele facilitar el mantenimiento y aportar claridad a la estructura de un proyecto de desarrollo.
La excepción a la regla de liga externa se produce cuando las variables externas o funciones son declaradas con el modificador static. Este modificador cambia el tipo de los objetos a liga interna. Un objeto que normalmente sería de liga externa, declarado como static, pasa a ser visible únicamente dentro de la unidad de traducción donde ha sido declarado.
Esta particularidad permite realizar, en cierta medida, ocultamiento de información. Si una unidad de traducción utiliza variables externas o funciones de su uso privado, que no deben hacerse visibles desde afuera, puede declarárselas static, con lo cual se harán inaccesibles a toda otra unidad de traducción. El caso típico se presenta cuando se desea hacer opacas las funciones que implementan un tipo de datos abstracto, haciéndolas de liga interna mientras que las funciones públicas (las de interfaz) se dejan con liga externa.
Finalmente, las variables locales, al ser visibles únicamente dentro de su función, se dice que no tienen liga (el linker nunca llega a operar con ellas).
En el ejemplo dado en el Cuadro [tab:ejliga], fun1()
, fun2()
y fun3()
están definidas en unas unidades de traducción distintas de la de main()
. El fuente alfa.c es capaz de dar origen a un programa ejecutable (porque contiene el punto de entrada al programa), pero solamente si al momento de linkedición se logra que el linker resuelva las referencias pendientes a fun1()
y a fun2()
(que no están definidas en alfa.c). Por motivos similares, las referencias en gamma necesitan de las definiciones en beta al momento de linkedición.
En la práctica logramos esto de varias maneras.
O bien, con:
gcc alfa.c beta.c gamma.c -o alfa
que significa ;
o bien con:
gcc -c alfa.c
gcc -c beta.c
gcc -c gamma.c
gcc alfa.o beta.o gamma.o -o alfa
que es la misma tarea pero distribuida en etapas separadas;
o bien preparando un archivo makefile indicando este modo de construcción e invocar a make.
l|l|l alfa.c & beta.c & gamma.c
main() fun1(); fun2();
&
int fun1() ...
&
int fun2() ... fun3(); .... int fun3() ...
El ejemplo del Cuadro [tab:ejliga2] es casi idéntico al anterior, salvo que la función fun3()
ahora está declarada static
, y por este motivo no podrá ser vista por el linker para resolver la referencia pendiente de fun2()
en lambda.c. La función fun3()
tiene liga interna. Las tres unidades de traducción jamás podrán satisfacer la compilación.
l|l|l iota.c & kappa.c & lambda.c
main() fun1(); fun2();
&
int fun1() ... static int fun3() ...
&
int fun2() ... fun3(); ...
Una declaración consiste en la mención de un objeto (variable o función) antes de su uso.
Una definición consiste en una sentencia de creación de dicha variable o función, que a partir de la ejecución de esa sentencia comienza a ser una entidad viva del programa.
Normalmente una declaración de variable (de la forma especificacion_de_tipo identificador
) funciona también como definición de la variable. Es decir, no sólo queda advertido el compilador de cuál es el tipo del objeto que se va a utilizar, sino que también se crea el espacio de memoria (el objeto de datos) que va a alojar la información asociada.
Son declaraciones que además funcionan como definiciones:
int a;
float b, c;
int fun()
{
...
}
Cuando la declaración de una variable cualquiera aparece precedida del modificador extern
, ésta indica el nombre y tipo asociado, pero no habilita al compilador para crear el objeto de datos; se trata de una variable cuya definición puede ser encontrada más adelante, o aun en otra unidad de traducción. La declaración extern
tan sólo enuncia el tipo y nombre de la variable para que el compilador los tenga en cuenta.
Las declaraciones siguientes no crean objetos de datos y por lo tanto no son definiciones.
extern int a;
extern float b, c;
int fun();
Una variable externa es visible desde todas las funciones de la unidad de traducción, y además puede ser utilizada desde otras. Esto se debe a la propiedad de liga externa de las variables externas: son visibles al linker como candidatos para resolver referencias pendientes.
El requisito para poder utilizar una variable definida en otra unidad de traducción es declararla con el modificador extern en aquella unidad de traducción donde se va a utilizar.
En el Cuadro [tab:ejliga3], el texto delta.c
es una unidad de traducción que declara dos variables externas y dos funciones, pero hace opacas a la variable n
y a la función fun2()
con el modificador static.
La función fun1()
puede utilizar a todas ellas por estar dentro de la misma unidad de traducción, pero fun3()
, que está en otra, sólo puede referenciar a m
y a fun1()
, que son de liga externa. Para ello debe declarar a m
como extern
, o de lo contrario no superará la compilación ().
Si, además, eta.c
declarara una variable extern int n
, con la intención de referirse a la variable n
definida en delta.c
, la referencia no podría ser resuelta a causa de la condición de liga interna de n
.
Los usos de funciones (como fun1()
en eta.c
) pueden aparecer sin declaración previa, pero en este caso el compilador asumirá tipos de datos default para los argumentos y para el tipo del valor devuelto por la función (int en todos los casos).
l|l delta.c & eta.c
int m; static int n; int fun1() n=fun2(); ... static int fun2() ...
&
extern int m; int fun3() m=fun1();
El modificador const
indica que una variable no será modificada. Una variable const
solamente puede inicializarse al momento de carga del programa (y debería hacerse así, ya que no hay otra manera de asignarle un valor).
const int a=12; /* se declara un entero constante, con inicializacion */
a++; /* el compilador no aprobara esta sentencia */
El modificador const
también permite expresar, en el prototipo de una función, que un argumento no podrá ser modificado por la función, aun cuando sea pasado por referencia.
Los compiladores modernos aplican una cantidad de pasos de optimización cuando ven instrucciones aparentemente redundantes o sin efectos, porque su desplazamiento o eliminación puede implicar ventajas en tiempo de ejecución o espacio de almacenamiento. Esto es especialmente así si las instrucciones sospechosas se encuentran dentro de ciclos. El modificador volatile sirve para advertir al compilador de que una variable será modificada asincrónicamente con la ejecución del programa (por ejemplo, por efecto de una rutina de atención de interrupciones) y por lo tanto el optimizador no puede inferir correctamente su utilidad dentro del programa. Esto evitará que el compilador aplique la lógica de optimización a las instrucciones que involucran a esta variable.
El ciclo while
del Cuadro [tab:ciclo] podría ser reescrito por un optimizador, extrayendo del ciclo la asignación a=beta
en el entendimiento de que beta
no cambiará en ninguno de los pasos del ciclo.
l|l|l Sin optimizar & Optimizado & Optimización inhibida
while(!fin) a = beta; b = fun(a);
&
a = beta; while(!fin) b = fun(a);
&
volatile int beta; while(!fin) a = beta; b = fun(a);
Sin embargo, si esperamos que la variable beta cambie por acción de algún agente externo a la rutina en cuestión, con la declaración previa volatile int beta
el compilador se abstendrá de optimizar las líneas donde intervenga beta
.
Copie, compile y ejecute el siguiente programa. Posteriormente agregue un modificador static sobre la variable a y repita la experiencia.
int fun()
{
int a;
a = a + 1;
return a;
}
main()
{
printf("%d\n", fun());
printf("%d\n", fun());
}
¿Qué imprime este programa?
int alfa;
int fun()
{
int alfa;
alfa = 1;
return alfa;
}
main()
{
alfa = 2;
printf("%d\n",fun());
printf("%d\n",alfa);
}
¿Qué imprime este programa?
int alfa;
int fun(int alfa)
{
alfa = 1;
return alfa;
}
main()
{
alfa = 2;
printf("%d\n",fun(alfa));
printf("%d\n",alfa);
}
Copie y compile, juntas, las unidades de traducción que se indican abajo. ¿Qué hace falta para que la compilación sea exitosa?
p4cm|p5cm fuente1.c & fuente2.c
int a; int fun1(int x) return 2 * x;
&
main() a = 1; printf(“d”, fun1(a));
¿Qué ocurre si un fuente intenta modificar una variable externa, declarada en otra unidad de traducción como const
? Prepare, compile y ejecute un ejemplo.
¿Qué resultado puede esperarse de la compilación de estos fuentes?
p4cm|p5cm|p4cm header.h & fuente1.c &fuente2.c
#include <stdio.h> #define VALOR 6
&
#include “header.h” main() static int c; printf("
&
#include “header.h” int fun(int x) return VALOR * x;
Denotemos esquemáticamente que un módulo objeto prueba.o
contiene un objeto de datos x
y una función fun()
, ambos de liga externa, de esta manera:
prueba.o |
---|
x |
fun() |
Si se tiene un conjunto de archivos y unidades de traducción que se compilarán para formar los respectivos módulos objeto, ¿cómo se aplicaría la notación anterior al conjunto de módulos objeto resultantes? Hacer el diagrama para los casos que aparecen en el Cuadro [tab:fuentes]. ¿Hay colisión de nombres? ¿Hay referencias que el linker no pueda resolver? Cada grupo de fuentes, ¿puede producir un ejecutable?
Un conjunto de programas debe modelar eventos relativos a un aeropuerto. Se necesita preparar una implementación de las estructuras de datos y funciones del aeropuerto, para ser usada por los demás programas. Especifique las variables y funciones (en pseudocódigo) que podrán satisfacer los siguientes requerimientos. Preste atención a las declaraciones extern y static.
El aeropuerto tendrá cinco pistas.
Se mantendrá un contador de la cantidad total de aviones en el aeropuerto y uno de la cantidad total de aviones en el aire.
Para cada pista se mantendrá la cantidad de aviones esperando permiso para despegar de ella y la cantidad de aviones esperando permiso para aterrizar en ella.
Habrá una función para modelar el aterrizaje y otra para modelar el despegue por una pista dada (decrementando o incrementando convenientemente la cantidad de aviones en una pista dada, en tierra y en el aire).
Habrá una función para consultar, y otra para establecer, la cantidad de aviones esperando aterrizar o despegar por cada pista.
Habrá una función para consultar la cantidad de aviones en tierra y otra para consultar la cantidad de aviones en el aire.
No deberá ser posible que un programa modifique el estado de las estructuras de datos sino a través de las funciones dichas.
¿Cuáles pueden ser los al programa que sean capaces de cambiar el valor de una variable volatile
?
m0.2cm|m3cm|m3.5cm|m3.5cm|m3cm & hdr1.h & fuente1.c & fuente2.c & fuente3.c
a) &
#define UNO 1 #define DOS 2 extern int a;
&
#include “hdr1.h” main() int b; b = fun1(a);
&
#include “hdr1.h” int fun1(int x) return x+fun2(x); static int fun2(int x) return x + DOS;
&
#include “hdr1.h” int a;
b) &
extern int c; extern int fun1(int p), fun2(int p);
&
#include “hdr1.h” int fun1(int x) return fun2(x)+1;
&
int a, b, c=1; int fun2(int x) return x-1;
&
main() int d; d = fun1(3);
c) &
&
int fun1(int x) return x+1; int fun2(int x) return x+2;
&
int fun3(int x) return x+3; static int fun2(int x) return x+4;
&
main() int a; a = fun1(a);
d) &
&
int fun1(int x) extern int b; x = b-fun2(x);
&
static int a = 1; static int b = 1; int fun2(int x) return x-a;
&
int b; main() b = 2; printf(" fun1(3));
El C tiene un conjunto de operadores muy rico, incluyendo algunos operadores que es difícil encontrar en otros lenguajes de programación. Comenzamos por los operadores aritméticos.
+, −, *, / (operaciones aritméticas usuales)
++, −− (incremento y decremento)
% (operador módulo)
No existe operador de exponenciación en C. En cambio, está implementada una función pow()
en la Biblioteca Standard.
No existe operador de división entera, opuesto a la división entre números reales, como en Pascal. Sin embargo, la división entre enteros da un entero: el resultado se trunca debido a las reglas de evaluación de expresiones.
Aquí a
y b
reciben respectivamente el cociente y el resto de dividir un VALOR por 256. Imprimiéndolos podemos ver cómo una CPU, en la cual un unsigned short int
tuviera un tamaño de 16 bits, almacenaría ese VALOR sobre dos bytes.
unsigned char a,b;
a=VALOR / 256;
b=VALOR % 256;
La siguiente división de j
por k
es entera. La variable c
valdrá 1.
float c;
int j,k;
j=3;
k=2;
c=j/k;
Los operadores de incremento y decremento (++ y −−) equivalen a las sentencias del tipo a = a+1
o b = b-1
. Suman o restan una unidad a su argumento, que debe ser un tipo entero. Se comportan diferentemente según que se apliquen en forma prefija o sufija.
l|l|l Sentencias & a & b
a=5; b=a++;
& 6 & 5
a=5; b=++a;
& 6 & 6
a=3; b=a–;
& 2 & 3
a=3; b=–a;
& 2 & 2
Aplicados como prefijos, el valor devuelto por la expresión es el valor incrementado o decrementado.
Aplicados como sufijos, el incremento o decremento se realiza como efecto lateral, pero el valor devuelto por la expresión es el anterior al incremento o decremento.
Existe una forma de abreviar la notación en las expresiones del tipo a = a*b
y a = a+b
. Podemos escribir, respectivamente:
a *= b;
a += b;
Esto se aplica a todos los operadores aritméticos y de bits.
==
(igualdad)
<, >, <=, >=
!=
(distinto de)
La sentencia k=1;
, claramente, asigna 1 a la variable ; pero además, si se encuentra en un contexto donde sea evaluada, la expresión k=1
vale 1. Como consecuencia, su valor lógico es TRUE
, independientemente del valor que tuviera k. Por lo tanto, la sentencia if(k=1) k=2;
siempre asignará el valor 2 a . Por el contrario, la expresión k=0
es siempre FALSE
.
La sentencia:
if(k = 1)
...
es sintácticamente válida, pero en lugar de comparar k
con 1, asigna el valor 1 a la variable k
.
Si el programador desea obtener el valor lógico de la comparación de k
con 1, debe utilizar el operador de relación ==
, y la expresión correcta es if(k == 1)
.
!, &&, || (not, and, or)
La siguiente es una expresión lógica ().
a==b || !(c < 2)
Al no existir tipo booleano en C, los valores lógicos se equiparan a los aritméticos.
Una expresión formada con un operador ||
, &&
o !
arroja un valor aritmético 1 si es V, y un valor 0 si es F.
Toda expresión cuyo valor aritmético sea diferente de 0 es .
Toda expresión que dé 0 es .
a - b
, que es una expresión aritmética, es también una expresión lógica. Será F cuando a sea igual a b.
a
como expresión lógica será V sólo cuando a sea distinto de 0 .
a = 8
es una expresión de asignación. Su valor aritmético es el valor asignado, por lo cual, como expresión lógica, es V, ya que 8 es distinto de 0.
Generalmente los compiladores definen dos símbolos o macros, FALSE
y TRUE
, en el header stdlib.h
, y a veces también en otros. Su definición suele ser la siguiente:
#define FALSE 0
#define TRUE !FALSE
Es conveniente utilizar estos símbolos para agregar expresividad a los programas.
(negación de bits)
&, |
(and, or de bits)
^
(or de bits exclusivo)
>>, <<
(desplazamiento de bits a derecha y a izquierda)
El desplazamiento de bits es con pérdida, en el sentido de que:
Un desplazamiento a la izquierda en un bit equivale a una multiplicación por 2; un desplazamiento a la derecha en un bit equivale a una división por 2.
Si el bit menos significativo es 1 (si el número es impar), al desplazar a la derecha ese bit se pierde (la división no es exacta).
Si el bit más significativo es 1 (si el número es igual o mayor que la mitad de su rango posible), al desplazar a la izquierda ese bit se pierde (la multiplicación da overflow).
El operador de negación de bits es unario y provoca el complemento a uno de su operando. Los operadores and, or y or exclusivo de bits son binarios.
unsigned char a;
a = ~255; /* a <-- 0 */
a = 0xf0 ^ 255; /* a <-- 0x0f */
a = 0xf0 & 0x0f; /* a <-- 0x00 */
a = 0xf0 | 0x0f; /* a <-- 0xff */
Los operadores >>
y <<
desplazan los bits de un objeto de tipo char
, int
o long
, una cantidad dada de posiciones.
unsigned char a,b,c;
a = 1 << 4;
b = 2 >> 1;
c <<= 2;
En el primer caso, el 1 se desplaza 4 bits a la izquierda; a
vale finalmente 16.
En el segundo caso, el bit 1 de la constante 2, a uno, se desplaza un lugar a la derecha; b
vale 1.
En el tercero, c
se desplaza dos bits a la izquierda.
Distinguimos como especiales los operadores de asignación e inicialización, que son operaciones diferentes aunque desafortunadamente son representadas por el mismo signo, y el operador ternario.
=
(operador de asignación)
=
(operador de inicialización)
?: (operador ternario)
Una inicialización es la operación que permite asignar un valor inicial a una variable en el momento de su definición:
int a=1;
Una asignación es la operación que permite asignar un valor a una variable que ha sido definida anteriormente:
int a;
a=1;
El operador ternario comprueba el valor lógico de una expresión, y, según este valor, se evalúa a una u otra de las restantes expresiones. Supongamos tener la expresión siguiente.
a = (expresión_1) ? expresión_2 : expresión_3;
Entonces, si expresión_1
es V, el ternario se evaluará a expresión_2
, y ese valor será asignado a la variable a
. Si expresión_1
es F, a
quedará con el valor expresión_3
.
La expresión
c = b + (a < 0) ? -a : a;
asigna a c
el valor de b
más el valor absoluto de a
.
En una expresión compleja, formada por varias subexpresiones conectadas por operadores, es peligroso hacer depender el resultado del orden de evaluación de las subexpresiones. Si los operadores en una subexpresión tienen la misma precedencia, la evaluación se hace de izquierda a derecha, pero en caso contrario el orden de evaluación no queda definido. Por ejemplo, en la expresión w*x/++y + z/y
se puede contar con que primeramente se ejecutará w*x
y sólo entonces w*x/++y
, porque los operadores de multiplicación y división son de la misma precedencia. Sin embargo, no se puede asegurar que w*x/++y
sea evaluado antes o después de z/y
, lo que hace que el resultado de esta expresión sea indefinido en C, ya que no sabemos cuál será el valor de y
que intervendrá en el cómputo de z/y
.
La solución es secuencializar la ejecución, dividiendo las expresiones en sentencias:
a = w * x / ++y;
b = a + z / y;
El Cuadro [tab:preced] ilustra las relaciones de precedencia entre los diferentes operadores. La tabla está ordenada con los operadores de mayor precedencia a la cabeza. Los que se encuentran en el mismo renglón tienen la misma precedencia.
[tc-operadores-preg] El operador en C significa exponenciación en base 10. exponenciación en base e. or exclusivo de bits. or lógico exclusivo.
Luego de ejecutar las sentencias
c = 1;
a = c++;
las variables y valen respectivamente: 1 y 1. 1 y 2. 2 y 2. 2 y 1. ninguna de las anteriores.
Luego de ejecutar las sentencias
c = 1;
a = ++c;
las variables y valen respectivamente: 1 y 1. 1 y 2. 2 y 2. 2 y 1. ninguna de las anteriores.
Luego de ejecutar las sentencias
c = 1;
a = --c;
a += c++;
las variables y valen respectivamente 1 y 1. 1 y 2. 2 y 2. 2 y 1. ninguna de las anteriores.
La sentencia puede escribirse también
Luego de ejecutar las sentencias
a = 1;
b = 2;
if(a == b)
b = a;
las variables y valen respectivamente 1 y 1. 1 y 2. 2 y 2. ninguno de los anteriores.
Luego de ejecutar las sentencias
a = 1;
b = 2;
if(a = b)
b = a;
las variables y valen respectivamente 1 y 1. 1 y 2. 2 y 2. ninguno de los anteriores.
Luego de ejecutar las sentencias
a = 1;
b = 0;
if(a = b)
b = a;
las variables y valen respectivamente 0 y 0. 0 y 2. 2 y 2. ninguno de los anteriores.
¿Cuál de las reglas no es válida? Toda expresión cuyo valor aritmético es 0 tiene valor lógico falso. Toda expresión cuyo valor lógico es falso tiene valor aritmético 0. Toda expresión cuyo valor aritmético es 1 tiene valor lógico verdadero. Toda expresión cuyo valor lógico es verdadero tiene valor aritmético 1.
Indicar cuál de las expresiones tiene valor lógico falso:
¿Cuál es el valor de la expresión c = 20
? Depende del valor de c. 20.
¿Cuál es el valor de la expresión c == 20
? Depende del valor de c. 20.
La operación equivale a: dividir a por 7. dividir a por 8. tomar el resto de dividir a por 7. tomar el resto de dividir por 8. restarle 8 a . restarle 7 a .
La operación equivale a: dividir a por dos. dividir a por cuatro. multiplicar a por dos. multiplicar a por cuatro.
Dada la declaración la operación tiene como resultado 0. 1. 2. 255. 127.
La expresión vale a, si a es igual a b. b, si c es distinto de d. c, si c es igual a d. d, si a es distinto de b.
La sentencia imprime: 1. 2. 3. 4.
¿Qué valor lógico tienen las expresiones siguientes?
TRUE && FALSE
TRUE || FALSE
0 && 1
0 || 1
(c > 2) ? (b < 5) : (2 != a)
(b == c) ? 2 : FALSE;
c == a;
C = A;
0 || TRUE
TRUE || 2-(1+1)
TRUE && !FALSE
!(TRUE && !FALSE)
x == y > 2
Escriba una macro IDEM(x,y)
que devuelva el valor lógico TRUE
si x
e y
son iguales, y FALSE
en caso contrario. Escriba NIDEM(x,y)
que devuelva TRUE
si las expresiones x
e y
son diferentes y FALSE
si son iguales.
Escriba una macro PAR(x)
que diga si un entero es par. Muestre una versión usando el operador , una usando el operador >>
, una usando el operador &
y una usando el operador |
.
Escriba macros MIN(x,y)
y MAX(x,y)
que devuelvan el menor y el mayor elemento entre x
e y
. Usando las anteriores, escriba macros MIN3(x,y,z)
y MAX3(x,y,z)
que devuelvan el menor y el mayor elemento entre tres expresiones.
¿Cuál es el significado aritmético de la expresión 1<<x
para diferentes valores de x
= 0, 1, 2... ?
Utilice el resultado anterior para escribir una macro DOSALA(x)
que calcule 2 elevado a la x-ésima potencia.
¿A qué otra expresión es igual a<b || a<c && c<d
?
a<b || (a<c && c<d)
(a<b || a<c) && c<d
Reescribir utilizando abreviaturas:
a = a + 1;
b = b * 2;
b = b - 1;
c = c - 2;
e = e & 0x0F;
a = a + 1;
b = b + a;
a = a - 1;
c = c * a;
¿Qué escribirá este programa?
main()
{
int a = 1;
int b;
b = a || 12;
printf("%d\n",b);
}
Las estructuras de control de C no presentan, en conjunto, grandes diferencias con las del resto de los lenguajes estructurados. En los esquemas siguientes, donde figura una sentencia puede reemplazarse por varias sentencias encerradas entre llaves (un bloque).
Como en la casi totalidad de los lenguajes de programación, la estructura alternativa permite ejecutar un bloque de una o más sentencias cuando la expresión condicional es V, y opcionalmente otro bloque cuando dicha expresión es F. Formas típicas de la estructura alternativa son las siguientes.
if(expresión)
sentencia;
if(expresión) {
sentencias
...
}
if(expresión)
sentencia;
else
sentencia;
El formato en C es libre, y las llaves sólo son necesarias cuando las sentencias de ejecución condicional son más de una.
if(a == 8) c++;
Las condiciones lógicas se construyen en base a operadores de relación y lógicos.
if(c >= 2 || fun(b) < 0)
La cláusula else
indica el bloque que se ejecutará en el caso negativo de la condición.
if(d)
c++;
else
c += 2;
En las estructuras anidadas, la cláusula else
se aparea con el if
más interno, salvo que las llaves expresen otra cosa.
En el Cuadro [tab:indent], las llaves del Listado 1 muestran cómo están asociadas las estructuras. En el Listado 2, se han quitado, con lo cual la semántica cambia, pero se mantiene la misma indentación, lo que puede dar lugar a confusión. El Listado 2 en realidad es equivalente al Listado 3, que utiliza llaves aunque en forma redundante, y además tiene la indentación correcta.
l|l|l Listado 1 & Listado 2 & Listado 3
if(c >= 2) if(d) c++; else c += 2;
&
if(c >= 2) if(d) c++; else c += 2;
&
if(c >= 2) if(d) c++; else c += 2;
El lenguaje C dispone de las estructuras adecuadas para procesar conjuntos de 0 o más datos (estructura while
) y 1 o más datos (estructura do-while
).
Ambas estructuras ejecutan su cuerpo de sentencias mientras la expresión resulte verdadera, pero en un lazo while
, la comprobación de la expresión se hace al principio de cada ciclo. En cambio, en el lazo do-while
, se hace al final.
while(expresión)
sentencia;
do {
sentencias;
} while(expresión);
La iteración es un caso particular de lazo while
donde necesitamos que un bloque de sentencias se repita una cantidad previamente conocida de veces. Estos casos implican la inicialización de variables de control, el incremento o decremento de las mismas, y la comprobación por valor límite. Estas tareas administrativas se pueden hacer más cómoda y expresivamente con un lazo for
.
El esquema es:
for(inicialización; condición_mientras; incremento)
sentencia;
Donde:
inicialización es una o más sentencias, separadas por comas, que se ejecutan una única vez al entrar al lazo.
condición_mientras es una expresión lógica, que se comprueba al principio de cada iteración. Mientras resulte verdadera, se continúa ejecutando el cuerpo.
incremento es una o más sentencias, separadas por comas, que se realizan al final de cada ejecución del cuerpo de la iteración.
La estructura for es equivalente al siguiente lazo while:
inicialización;
while(condición_mientras) {
sentencia;
incremento;
}
Aunque el uso más común de las sentencias de incremento es hacer avanzar o retroceder un contador de la cantidad de iteraciones, nada impide que se utilice esa sección para cualquier otro fin.
Cualesquiera de las secciones inicialización, condición_mientras o incremento pueden estar vacías. En particular, la sentencia:
for( ; ; )
es un lazo infinito.
El siguiente lazo acumula los números 1 a 10 sobre la variable a
:
for(i=1; i<=10; i++)
a += i;
Si se quiere asegurar que la variable a
tiene un valor inicial cero, se puede escribir:
for(i=1, a=0; i<=10; i++)
a += i;
Aprovechando la propiedad del corto circuito en las expresiones lógicas, se puede introducir el cuerpo del lazo for
en la comprobación (aunque no es recomendable si complica la lectura):
for(i=1, a=0; i<=10 && a+=i; i++);
Nótese que el cuerpo de este último for es la sentencia nula. A propósito: es un error muy común utilizar un signo “;” de más, así:
for(i=1; i<=10; i++);
a += i;
Esta estructura llevará la variable i
desde 1 hasta 10 sin ejecutar ningún otro trabajo (lo que se repite es la sentencia nula) y después incrementará a
, una sola vez, en el valor de la última iteración de i
.
Una clásica estructura de control para un proceso consumidor de objetos:
a=leercaracter();
while( a != '\033' ) {
procesar(a);
a = leercaracter();
}
La propiedad de que toda asignación en C tiene un valor como expresión (el valor asignado) permite reescribir la estructura de control anterior como:
while( (a=leercaracter()) != '\033' )
procesar(a);
Las expresiones conectadas por los operadores lógicos se evalúan de izquierda a derecha, y la evaluación se detiene apenas alcanza a determinarse el valor de verdad de la expresión (propiedad ). Así, si suponemos que procesar() siempre devuelve un valor distinto de cero, la sentencia siguiente equivale a los lazos anteriores.
while((a=leercaracter()) != '\033' && procesar(a));
Otra versión, utilizando la estructura do-while, podría ser:
do {
if((a=leercaracter()) != '\033')
procesar(a);
} while(a != '\033');
Si utilizamos for
, que es esencialmente un while:
for( ; (a=leercaracter()) != '\033'; ) procesar(a);
Aquí dejamos vacías las secciones de inicialización y de incremento.
También, pero algo menos claro:
for( ; (a=leercaracter()) != '\033'; procesar(a) );
La estructura de selección (switch) es una estructura alternativa múltiple. Dadas varias alternativas, la estructura de selección desvía el control al segmento de programa correspondiente. La sintaxis de la estructura switch
es como sigue:
switch(expresión_entera) {
case expresión_constante1:
sentencias;
break;
case expresión_constante2:
sentencias;
break;
default:
sentencias;
}
Al entrar al switch, se comprueba el valor de la expresión entera, y si coincide con alguna de las constantes propuestas en los rótulos case
, se deriva el control directamente allí. La sección default
no es obligatoria. Sirve para derivar allí todos los casos que no se contemplen explícitamente. En las expresiones_constantes no se permite la aparición de variables ni funciones. Un ejemplo válido con expresiones_constantes sería:
#define ARRIBA 10
#define ABAJO 8
switch(valor(tecla)) {
case 127+ARRIBA:
arriba();
break;
case 127+ABAJO:
abajo();
break;
}
La sentencia break
es necesaria aquí porque, al contrario que en Pascal, el control no se detiene al llegar al siguiente rótulo.
Esta estructura recibe como entrada las variables m
y a
(mes y año) y da como salida d
(la cantidad de días del mes).
switch(m) {
case 2:
d=28 + bisiesto(a) ? 1 : 0;
break;
case 4:
case 6:
case 9:
case 11:
d= 30;
break;
default:
d= 31;
}
Si m
vale 4, 6, 9, u 11, la variable d
recibe el valor 30. Al no haber un break
intermedio, el control cae hasta la asignación d = 30
.
La estructura switch
tiene algunas limitaciones con respecto a sus análogos en otros lenguajes. A saber, no se puede comparar la expresión de selección con expresiones no constantes, ni utilizar rangos (el concepto de rango no está definido en C).
Hay varias sentencias de transferencia incondicional de control en C. Algunas tienen aplicación exclusivamente como modificadoras del control dentro de estructuras, como break
y continue
.
Utilizada dentro de un lazo while
, do-while
o for
, hace que el control salte directamente a la comprobación de la condición de iteración. Así:
for(i=0; i<100; i++) {
if(no_procesar(i))
continue;
procesar(i);
}
En este lazo, si la función no_procesar()
devuelve valor distinto de cero, no se ejecuta el resto del lazo (la función procesar()
, y otras, si las hubiera, hasta la llave final del lazo). Se comprueba la validez de la expresión i<100
, y si corresponde se inicia una nueva iteración.
La sentencia break
, por el contrario, hace que el control abandone definitivamente el lazo:
while(expresión) {
if(ya_no_procesar())
break;
procesar();
}
seguir();
Cuando la función ya_no_procesar()
dé distinto de cero, el control saltará a la función seguir()
, terminando la ejecución de la estructura repetitiva.
Un rótulo es un nombre, seguido del carácter “:”, que se asocia a un segmento de un programa. La sentencia goto
transfiere el control a la instrucción siguiente a un rótulo. Aunque no promueve la programación estructurada, y se sabe que su abuso es perjudicial, goto
es útil para resolver algunas situaciones. Por ejemplo: anidamiento de lazos con salida forzada.
for(i=0; i<10; i++) {
for(j=0; j<50; j++) {
if(ya_no_procesar(i,j))
goto final;
procesar(i,j);
}
}
final: imprimir(i,j);
Aquí se podría implementar una estrategia estructurada, en base a break
, pero el control quedaría retenido en el lazo exterior y se requeriría más lógica para resolver este problema. Se complicaría la legibilidad del programa innecesariamente.
Los rótulos a los que puede dirigirse un goto
tienen un espacio de nombres propio. Es decir, no hay peligro de conflicto entre un rótulo y una variable del mismo nombre. Además, el ámbito de un rótulo es local a la función (una sentencia goto
sólo puede acceder a los rótulos dentro del texto de la función donde aparece).
Permite devolver un valor a la función llamadora. Implica una transferencia de control incondicional hasta el punto de llamada de la función que se esté ejecutando.
Hay errores de programación típicos, relacionados con estructuras de control en C, que vale la pena enumerar:
Terminar el encabezado de las estructuras de control con un punto y coma extra.
Olvidar la sentencia break
separando casos de un switch
.
Confundir el significado de un lazo do-while
tomando la condición de mientras como si fuera una condición de hasta (por analogía con repeat
de Pascal).
[tc-control-preg] ¿Qué resultado final tiene la variable ?
a = 1;
if(a == 1)
a = 2;
1. 2. Ninguna de las anteriores.
¿Qué resultado final tiene la variable ?
a = 1;
if(3)
a = 2;
1. 2. Ninguna de las anteriores.
¿Qué resultado final tiene la variable ?
a = 1;
if(b == 2)
a = 2;
1. 2. Depende del valor de b. Ninguna de las anteriores.
¿Qué resultado final tiene la variable ?
a = 1;
if(b == 2);
a = 2;
1. 2. Depende del valor de b. Ninguna de las anteriores.
¿Qué resultado final tiene la variable ?
a = 1;
if(b = 0)
a=2;
1. 2. Depende del valor de b. Ninguna de las anteriores.
¿Qué resultado final tiene la variable ?
b = 3;
if(b == 1)
a=2;
else
if(b == 2)
a=3;
else
a=4;
2. 3. 4. No está definido.
¿Qué resultado final tiene la variable si inicialmente a, c y d valen 1?
switch(c) {
case 1: a = a+d;
break;
case 2: a = a-d;
break;
}
1. 2. 3.
¿Qué resultado final tiene la variable si inicialmente a, c y d valen 1?
switch(c) {
case 1: a = a+d;
case 2: a = a-d;
}
1. 2. 3.
¿Qué resultado final tiene la variable si inicialmente b, c y d valen 1?
switch(c) {
case 1: b = b+d;
case 2: b = b-d;
default: b = 0;
}
0 1 2 3
¿Qué resultado final tiene la variable si inicialmente b, c y d valen 1?
switch(c) {
case 1: b = b+d;
break;
case 2: b = b-d;
break;
default: b = 0;
}
0 1 2 3
¿Qué resultado final tiene la variable si inicialmente b, c y d valen 3?
switch(c) {
case 1: b = b+d;
break;
case 2: b = b-d;
break;
default: b = 0;
}
0 1 2 3
¿Qué resultado final tiene la variable ?
c = 1;
for(i=0; i<5; i++);
for(j=0; j<2; j++)
c++;
1 2 3 6 13
¿Cuántas X imprimen estas líneas?
c = 3;
do {
printf("X");
} while(c--);
1 2 3 4
¿Cuántas X imprimen estas líneas?
c = 3;
do {
printf("X");
} while(--c);
1 2 3 4
¿Cuántas X imprimen estas líneas?
c = 3;
while(c--)
printf("X");
1 2 3 4
¿Cuántas X imprimen estas líneas?
c = 3;
while(--c)
printf("X");
1 2 3 4
¿Cuántas X imprimen estas líneas?
c = 3;
while(--c);
printf("X");
1 2 3 4
Reescribir estas sentencias usando while en vez de for:
for(i=0; i<=10; i++)
a = i;
for( ; j<100; j+=2) {
a = j;
b = j * 2;
}
for( ; ; )
a++;
Si la función quedanDatos()
devuelve el valor lógico que sugiere su nombre, ¿cuál es la estructura preferible?
while(quedanDatos()) {
procesar();
}
do {
procesar();
} while(quedanDatos());
¿Cuál es el error de programación en estos ejemplos?
for(i = 0; i < 10; i++);
a = i - 50L;
while(i < 100) {
procesar(i);
a = a + i;
}
¿Cuál es el valor de x a la salida de los lazos siguientes?
for(x = 0; x<100; x++);
for(x = 32; x<55; x += 3);
for(x = 10;x>0; x--);
¿Cuántas X escriben estas líneas?
for (x = 0; x < 10; x++)
for (y = 5; y > 0; y--)
escribir("X");
Escribir sentencias que impriman la tabla de multiplicar para un entero dado.
Imprimir la tabla de los diez primeros números primos (sólo divisibles por sí mismos y por la unidad).
Escribir las sentencias para calcular el factorial de un entero.
Una unidad de traducción en C contiene un conjunto de funciones. Si entre ellas existe una con el nombre especial main, entonces esa unidad de traducción puede dar origen a un programa ejecutable, y el comienzo de la función main será el punto de entrada al programa.
Los tipos de datos de los parámetros recibidos y del resultado que devuelve la función quedan especificados en su cabecera. El valor devuelto se expresa mediante return:
Una función que recibe un int y un long, y devuelve un int.
int fun1(int alfa, long beta)
{
...
}
Una función que recibe dos doubles y devuelve un double.
double sumar(double x, double y)
{
...
return x+y;
}
El caso especial de una función que no desea devolver ningún valor se especifica con el modificador void, y en tal caso un return, si lo hay, no debe tener argumento. Los paréntesis son necesarios aunque la función no lleve parámetros, y en ese caso es recomendable indicarlo con un parámetro void:
Una función que recibe un int, y no devuelve ningún valor.
void procesar(int k)
{
...
}
Una función que devuelve un int, y no recibe argumentos.
int hora(void)
{
...
}
Una función puede ser declarada de un tipo cualquiera y sin embargo no contar con una instrucción return. En ese caso su valor de retorno queda indeterminado. Además, la función que llama a otra puede utilizar o ignorar el valor devuelto, a voluntad, sin provocar errores.
En el caso siguiente se recoge basura en la variable a
, ya que fun2
no devuelve ningún valor pese a ser declarada como de tipo entero.
int fun2(int x)
{
...
return;
}
...
a=fun2(1);
El cuerpo de la función, y en general cualquier cuerpo de instrucciones entre llaves, es considerado un bloque. Las variables locales son aquellas declaradas dentro del cuerpo de una función, y su declaración debe aparecer antes de cualquier sentencia ejecutable. Es legal ubicar la declaración de variables como la primera sección dentro de cualquier bloque, aun cuando ya se hayan incluido sentencias ejecutables. Sin embargo, no es legal declarar funciones dentro de funciones.
La variable v
declarada dentro del bloque interno opaca a la declarada al principio de la función.
int fun3()
{
int j,k,v;
for(i=0; i<10; i++) {
double v;
...
}
En general, como ocurre con las variables, el uso de una función debe estar precedido por su declaración. Sin embargo, el compilador trata el caso de las funciones con un poco más de flexibilidad. Un uso de variable sin declaración es ilegal, mientras que un uso de función sin definición obliga al compilador a suponer ciertos hechos, pero permite proseguir la compilación.
La suposición que hará el compilador, en la primera instancia en que se utilice una función y en ausencia de una definición previa, es que el resultado y los parámetros de la función son del tipo más que pueda representarlos. Esto vale tanto para las funciones escritas por el usuario como para las mismas funciones de la Biblioteca Standard.
Así, si se intenta calcular e5:
main()
{
double a;
a=exp(5);
}
Nada permite al compilador suponer que la función exp() debe devolver algo distinto de un entero (el hecho de que se esté asignando su valor a un double no es informativo, dada la conversión automática de expresiones que hace el C). Además, el argumento 5 puede tomarse a primera vista como int, pudiendo ser que en la definición real de la función se haya especificado como double, o alguna otra elección de tipo.
En cualquier caso, esto es problemático, porque la comunicación de parámetros entre funciones, normalmente, se hace mediante el stack del programa, donde los objetos se almacenan como sucesiones de bytes. La función llamada intentará recuperar del stack los bytes necesarios para los objetos que necesita, mientras que la función que llama le ha dejado en el mismo stack menos información de la esperada. El programa compilará correctamente pero los datos pasados a y desde la función serán truncamientos de los valores deseados.
Otro problema, relacionado con el anterior, es el que ocurre si permitimos que el compilador construya esa declaración provisoria y luego, en la misma unidad de traducción, damos la definición de la función, y ésta no concuerda con la imaginada por el compilador. La compilación abortará con error de . La forma de advertir al compilador de los tipos correctos antes del uso de la función es, o bien, definirla (proporcionando su fuente), o incluir su prototipo:
double exp(double x); /* prototipo de exp() */
main()
{
double a;
a=exp(5);
}
En el caso particular de las funciones de Biblioteca Standard, cada grupo de funciones cuenta con su header conteniendo estas declaraciones, que podemos utilizar para ahorrarnos tipeo. Para las matemáticas, utilizamos el header math.h:
#include <math.h>
main()
{
double a;
a=exp(5);
}
Un problema más serio que el de la redeclaración de funciones es cuando una función es compilada en una unidad de traducción separada A y luego se la utiliza, desde una función en otra unidad de traducción B, pero con una declaración incorrecta, ya sea porque se ha suministrado un prototipo erróneo o porque no se ha suministrado ningún prototipo explícito. y el implícito, que puede inferir el compilador, no es el correcto. En este caso la compilación y la linkedición tendrán lugar sin errores, pero la conducta al momento de ejecución depende de la diferencia entre ambos prototipos, el real y el inferido.
Las funciones en C pueden ser recursivas, es decir, pueden invocarse a sí mismas directa o indirectamente. Siguiendo el principio de que las estructuras de programación deben replicar la estructura de los datos, la recursividad de funciones es una manera ideal de tratar las estructuras de datos recursivas, como árboles, listas, etc.
int factorial(int x)
{
if(x==0)
return 1;
return x * factorial(x-1);
}
Escribir una función que reciba tres argumentos enteros y devuelva un entero, su suma.
Escribir una función que reciba dos argumentos enteros y devuelva un long, su producto.
Escribir una función que reciba dos argumentos enteros a y b, y utilice a las dos anteriores para calcular:
(a * b + b * 5 + 2) * (a + b + 1)
Escribir un programa que utilice la función anterior para realizar el cálculo con a=7 y b=3.
¿Qué está mal en estos ejemplos?
int f1(int x, int y);
{
int z;
z = x - y;
return z;
}
void f2(int k)
{
return k + 3;
}
int f3(long k)
{
return (k < 0) ? -1 : 1;
}
printf("%d\n",f3(8));
Escribir una función que reciba dos argumentos, uno de tipo int y el otro de tipo char. La función debe repetir la impresión del char tantas veces como lo diga el otro argumento. Escribir un programa para probar la función.
Como casi todos los lenguajes, el C provee varias maneras de estructurar, o combinar, variables simples en alguna forma de agregación. Las variables estructuradas permiten manejar un conjunto de datos como una sola entidad.
La declaración
tipo nombre[cant_elementos];
define un bloque llamado de objetos consecutivos de tipo , lo que habitualmente recibe el nombre de vector, array o arreglo. Sus elementos se acceden indexando el bloque con expresiones enteras entre corchetes.
Declaraciones y uso de arreglos.
int dias[12];
dias[0] = 31;
enero = dias[0];
febrero = dias[1];
a = dias[6 * b - 1];
double saldo[10];
for(i=0; i<10; i++)
saldo[i] = entradas[i] - salidas[i];
Al ser declarados, los arreglos pueden recibir una inicialización, que es una lista de valores del tipo correspondiente, indicados entre llaves. Esta inicialización puede ser completa o incompleta. Si se omite la dimensión del arreglo, el compilador la infiere por la cantidad de valores de inicialización.
Inicializaciones completas e incompletas.
/* inicialización completa */
int dias[12] = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
/* inicialización incompleta */
double saldo[10] = { 150.40, 170.20 };
long altura[] = { 3600, 3400, 3200, 6950 }; /* se infiere "long altura[4]" */
Los siguientes son errores de programación muy comunes al tratar con arreglos, y lamentablemente el lenguaje C no provee ayuda para prevenirlos:
Indexación fuera de límites
La dimensión dada en la declaración del arreglo dice cuántos elementos tiene. Esto no quiere decir que exista un elemento del arreglo con ese índice (porque se numeran a partir de 0).
La última instrucción equivale a acceder a un elemento fuera de los límites del arreglo. Este programa, aunque erróneo, compilará correctamente.
int tabla[5];
tabla [5] = 1; /* ¡Error! el último elemento es tabla[4] */
Asignación de arreglos
Es frecuente confundir las operaciones de inicialización y de asignación. La inicialización sólo es válida en el momento de la declaración: no es legal asignar un arreglo. La asignación debe forzosamente hacerse elemento por elemento.
Notar la diferencia sintáctica entre inicialización y asignación.
/* Inicializacion */
int tabla[5] = { 1, 3, 2, 3, 4 }; /* correcto */
/* Asignacion */
int tabla[5];
tabla[] = { 1, 3, 2, 3, 4 }; /* incorrecto */
La última sentencia no es compilable. Debe reemplazarse por:
tabla[0] = 1;
tabla[1] = 3; ...etc
En C se pueden simular matrices y arreglos de más dimensiones creando arreglos cuyos elementos son arreglos. La declaración:
int matriz[3][4];
expresa un arreglo de tres posiciones cuyos elementos son arreglos de cuatro posiciones. Una declaración con inicialización podría escribirse así:
int matriz[3][4] = {
{1, 2, 5, 7},
{3, 0, 0, 1},
{2, 8, 5, 4}};
y correspondería a una matriz de tres filas por cuatro columnas. La primera dimensión de un arreglo multidimensional puede ser inferida:
int matriz[][4] = {
{1, 2, 5, 7},
{3, 0, 0, 1},
{2, 8, 5, 4}};
El recorrido de toda una matriz implica necesariamente un lazo doble, a dos variables:
for(i=0; i<3; i++)
for(j=0; j<4; j++)
a[i][j] = i + j;
Las estructuras de C permiten agrupar una cantidad de variables simples, de tipos, en general, diferentes. Las estructuras y uniones aportan la ventaja de que es posible manipular este conjunto de variables como un todo.
Es posible inicializar estructuras, asignar conjuntos de constantes a las estructuras, asignar estructuras entre sí, pasarlas como argumentos reales a funciones, y devolverlas como valor de salida de funciones. En particular, ésta viene a ser la única manera de que una función devuelva más de un dato.
Las declaraciones siguientes no definen variables, con espacio de almacenamiento, sino que simplemente enuncian un nuevo tipo que puede usarse en nuevas declaraciones de variables.
struct persona {
long DNI;
char nombre[40];
int edad;
};
struct cliente {
int num_cliente;
struct persona p;
double saldo;
};
El nombre o tag dado a la estructura es el nombre del nuevo tipo. En las instrucciones siguientes se utilizan los tags definidos anteriormente y se acceden a los diferentes miembros de las estructuras.
struct cliente c1, c2;
c1.num_cliente = 1001;
c1.p.DNI = 14233326; /* acceso anidado */
c1.p.edad=40;
c2 = c1; /* copia de estructuras */
struct persona p1 = {17698735, "Juan Perez", 30};
c2.p = p1;
Una declaración con inicialización completa:
struct cliente c3 = {
1002,
{17698735, "Juan Perez", 30},
150.25 };
Es legal declarar una variable junto con la enunciación de su tipo, con o sin el tag asociado y con o sin inicialización.
struct complejo { double real, imag; } c;
struct { double real, imag; } c;
struct complejo {
double real, imag;
} c = { 20.5, -7.3 };
Una función que recibe y devuelve estructuras:
struct punto {
int x, y;
};
struct punto puntomedio(struct punto p1, struct punto p2)
{
struct punto z;
z.x = (p1.x + p2.x) / 2;
z.y = (p1.y + p2.y) / 2;
return z;
}
En una estructura, el compilador calcula la dirección de inicio de cada uno de los miembros dentro de la estructura sumando los tamaños de los elementos de datos. Una unión es un caso especial de estructura donde todos los miembros en el mismo lugar de origen de la estructura.
union intchar {
int i;
char a[sizeof(int)];
};
Este ejemplo de unión contiene dos miembros: un entero y un arreglo de tantos caracteres como bytes contiene un en la arquitectura destino. Ambos miembros, por la propiedad fundamental de los , quedan en memoria. El resultado es que podemos asignar un campo por un nombre y acceder por el otro.
En este caso particular, podemos conocer qué valores recibe cada byte de los que forman un .
union intchar k;
k.i = 30541;
b = k.a[2];
Se pueden definir estructuras donde los miembros son agrupaciones de bits. Esta construcción es especialmente útil en programación de sistemas donde se necesita la máxima compactación de las estructuras de datos. Cada miembro de un campo de bits es un que lleva explícitamente un indicando la cantidad de bits que contiene.
struct disp {
unsigned int encendido : 1;
unsigned int
online : 1,
estado : 4;
};
Imaginemos, en base a la declaración anterior, un dispositivo mapeado en memoria con el cual comunicarnos en base a un protocolo, también imaginario. Implementamos con un campo de bits un registro de control , que permite encenderlo o apagarlo, consultar su disponibilidad ( o no), y hacer una lectura de un valor de de cuatro bits (que entonces puede tomar valores entre 0 y 15). Todo el registro de comunicación cabe en un byte.
Podríamos encender nuestro dispositivo imaginario, esperar a que se ponga online, tomar el promedio de diez lecturas de estado y apagarlo, con las instrucciones siguientes. Como se ve, no hay diferencia sintáctica de acceso con las estructuras.
struct disp {
unsigned int encendido : 1;
unsigned int
online : 1,
estado : 4;
};
struct disp d;
d.encendido = 1;
while(!d.online);
for(p=0, i=0; i<10; i++)
p += d.estado;
p /= 10;
d.encendido = 0;
Escribir una declaración con inicialización de un arreglo de diez elementos , todos inicialmente iguales a 2.25.
Escribir las sentencias para copiar un arreglo de cinco en otro.
Escribir las sentencias para obtener el producto escalar de dos vectores.
Escribir una función que devuelva la posición donde se halla el menor elemento de un arreglo de .
Dado un vector de diez elementos, escribir todos los promedios de cuatro elementos consecutivos.
Declarar una estructura punto conteniendo coordenadas x e y de tipo . Dar ejemplos de inicialización y de asignación.
Declarar una estructura segmento conteniendo dos estructuras punto. Dar ejemplos de inicialización y de asignación. Dar una función que calcule su longitud. Dar una función que reciba un segmento S y un punto p, y devuelva si p ∈ S.
¿Cuál es el error en estas sentencias de inicialización?
struct alfa {
int a, b;
};
alfa = { 10, 25 };
struct alfa {
int a, b;
};
alfa d = { 10, 25 };
union dato {
char dato_a[4];
long dato_n;
} xdato = { "ABC", 1000 };
El tema de esta unidad es el más complejo del lenguaje C y por este motivo se han separado los contenidos en dos partes (llamadas 10 y 10b).
La memoria del computador está organizada como un vector o arreglo unidimensional. Los índices en este arreglo son las direcciones de memoria. Este arreglo puede accederse indexando a cada byte individualmente, y en particular a cada estructura de datos del programa, mediante su dirección de comienzo.
Para manipular direcciones se utilizan en C variables especiales llamadas apuntadores o punteros, que son aquellas capaces de contener direcciones. En la declaración de un apuntador se especifica el tipo de los objetos de datos cuya dirección contendrá.
La notación:
char *p;
es la declaración de una variable puntero a carácter. El contenido de la variable p
puede ser, en principio, cualquiera dentro del rango de direcciones de la máquina subyacente al programa. Una vez habiendo recibido un valor, se dice que la variable p
apunta a algún objeto en memoria.
Esquemáticamente representamos la situación de una variable que contiene una dirección (y por lo tanto ) según el diagrama siguiente (mas_información). La posición 1 de la memoria aloja un puntero que actualmente apunta a la posición 5.
El tema de apuntadores (o punteros) y direcciones es crucial en la programación en C, y parece ser el origen más frecuente de errores. Programas con mala lógica de acceso a memoria pueden ser declarados válidos por el compilador: su compilación puede ser exitosa y sin embargo ser completamente erróneos en ejecución. Esta es una de las críticas más frecuentes al lenguaje C, aunque en rigor de verdad, el problema no es del lenguaje, sino del programador con una mala comprensión de las cuestiones del lenguaje relacionadas con memoria.
Es fundamental, para no cometer estos errores, comprender en profundidad los conceptos de direcciones y punteros, así como la sintaxis de las declaraciones de punteros, para asegurarnos de que escribimos lo que se pretende lograr.
Para manipular punteros se hacen necesarios dos operadores especiales:
El operador dirección devuelve la dirección de un objeto. La construcción siguiente:
p = &a;
puede leerse: .
El operador de indirección, o de dereferenciación, surte el efecto contrario: accede al objeto apuntado por una dirección. La construcción
a = *p;
puede leerse como .
Para obtener el efecto lógicamente esperado, en las expresiones anteriores p
deberá ser un puntero, capaz de recibir y entregar una dirección.
En general, si el contenido de la variable p
es igual a la dirección de a
, es decir, &a
, la expresión de dereferenciación *p
puede aparecer en cualquier contexto en el que apareciera a
. En particular, es legal asignar indirectamente a través de un apuntador.
Las instrucciones
int a, *p;
p = &a;
*p = 1;
equivalen a
a = 1;
Son operaciones legales asignar punteros entre sí, sumar algebraicamente un entero a un puntero y restar dos punteros. Las consecuencias de cada operación se esquematizan en las figuras siguientes.
Luego de asignar un puntero a otro, ambos apuntan al mismo objeto. Cualquier modificación al objeto apuntado por uno se refleja al accederlo mediante el otro puntero.
La suma algebraica de una dirección más un entero es nuevamente una dirección. El sentido de la operación es desplazar el punto de llegada del apuntador (hacia arriba o hacia abajo en memoria) en tantas unidades como diga el entero, con la particularidad de que el resultado final es dependiente del tamaño del objeto apuntado. Esto es en general lo que desea el programador al aplicar un incremento a un apuntador.
Es decir que sumar (o restar) una unidad a un puntero, lo incrementa (decrementa) en tantos bytes como mida el objeto al cual apunta. Por ejemplo, para punteros a carácter, la instrucción p++
incrementa el valor del puntero en 1, que es el sizeof()
de los chars
; pero si p
es un puntero a long
, en una arquitectura donde los longs
miden cuatro bytes, p++
incrementa el valor de p
en 4 (y p
queda apuntando en la memoria).
El sentido de una resta de punteros (o, equivalentemente, de una diferencia de direcciones) es obtener el tamaño del área de memoria comprendida entre los objetos apuntados por ambos punteros. La resta tendrá sentido únicamente si se hace entre variables que apuntan a objetos del mismo tipo.
Nuevamente se aplica la lógica del punto anterior: el resultado obtenido quedará expresado en unidades del tamaño del objeto apuntado. Es decir, si una diferencia entre punteros a long da 3, debe entenderse el resultado como equivalente a 3 longs, y por lo tanto a 3*sizeof(long) bytes.
Una consecuencia de que sea posible sumar enteros a punteros es que se puede simular el recorrido de un arreglo mediante el incremento sucesivo de un puntero. La operación de acceder a un elemento del arreglo es equivalente a obtener el objeto apuntado por el puntero. Las sentencias:
int *p;
int a[10];
p = &a[0];
Habilitan al programador para acceder a cada elemento del arreglo a mediante aritmética sobre el puntero p. Como el nombre de un arreglo se evalúa a su dirección inicial, la última sentencia también puede escribirse simplemente así:
p = a;
Supongamos dadas las definiciones siguientes:
int alfa[] = { 2, 4, 6, 7, 4, 2, 3, 1 };
int *p, *q;
int b;
Con estas definiciones, veamos algunas manipulaciones de arreglos y punteros.
p = alfa; /* el nombre de un arreglo
equivale a su direccion */
*p = 3; /* equivalente a alfa[0] = 3 */
*(p+2) = 4; /* equivalente a alfa[2] = 4 */
b = *p; /* equiv. a b = alfa[0] */
*(p+3) = *(p+6); /* sobreescribe el 7 con el 3 */
q = alfa + 2; /* apunta al tercer elemento */
printf("%d\n",*q); /* imprime 4 */
printf ("%d\n",q - p); /* imprime 2 */
p += q; /* ERROR - la suma de punteros
no esta definida */
Los dos segmentos de código siguientes hacen exactamente la misma tarea pero de maneras diferentes.
Un caso típico del uso de punteros ocurre cuando se necesita trabajar con cadenas de texto, a veces llamadas strings, o constantes string.
Como se vio en la sección [sec:constantesstring], las constantes string son todas aquellas secuencias de caracteres (eventualmente la secuencia vacía) en el texto del programa, que aparecen entre comillas. La representación interna de las constantes string durante la compilación, con el cero final, y con la referencia a la constante reemplazada por su dirección, se trasladará al programa compilado y aparecerá en la memoria, en la zona de datos estáticos, al momento de ejecución del programa.
Como la constante string es reemplazada por la dirección de su primer carácter, la asignación o inicialización de una constante string a un puntero es legal, y almacena en el puntero dicha dirección. En la inicialización de punteros, las constantes string son un caso especialmente frecuente.
Un puntero a carácter inicializado con una constante string.
char *s = "Lenguaje C";
La inicialización de s
y la asignación de t
cargan a ambas variables con las direcciones del primer carácter, o direcciones iniciales, de las cadenas respectivas.
char *s = "Esta es una cadena";
char *t;
t = "Esta es otra cadena";
Representamos en el diagrama el carácter 0 final (que no es imprimible) con el símbolo . La expresión en C de este carácter es simplemente 0 (un entero) o ’’
(una constante carácter cuyo código ASCII es cero).
La función de Biblioteca Standard printf()
permite imprimir una cadena con el especificador de conversión .
Las líneas siguientes;
char *s = "Cadena de prueba";
char *t;
t = s + 7;
printf("%s\n", s);
printf("%s\n", t);
O bien, equivalentemente:
char *s = "Cadena de prueba";
printf("%s\n", s);
printf("%s\n", s + 7);
imprimen:
Cadena de prueba
de prueba
Una función que recorre una cadena ASCIIZ buscando un carácter y devuelve la primera dirección donde se lo halló, o bien el puntero nulo (NULL).
char *donde(char *p, char c)
{
for( ; *p != 0; p++)
if(*p == c)
return p;
return NULL;
}
main()
{
char *cadena = "Buscando exactamente esto";
char *s;
s = donde(cadena, 'e');
if(s != NULL)
printf("%s\n", s);
}
El ejemplo de uso imprime:
exactamente esto
En C, donde todo pasaje de parámetros a funciones se realiza por valor, los punteros brindan una manera de entregar a las funciones referencias a objetos.
Modificación de un objeto externo a una función. La función f2()
debe poner a cero una variable entera que es externa a ella, por lo cual el argumento formal h
debe ser la dirección de un entero.
void f2(int *h)
{
*h = 0;
}
int f1()
{
int j,k;
int *p;
p = &j;
f2(p); /* le pasamos una direccion */
f2(&k); /* y tambien aqui */
}
Uso incorrecto de argumentos pasados por valor.
void swap(int x, int y) /* incorrecta */
{
int temp;
temp = x;
x = y;
y= temp;
}
La función swap()
, que podría ser usada por un algoritmo de ordenamiento para intercambiar los valores de dos variables, está incorrectamente escrita, ya que los valores que intercambia son los de sus argumentos, que vienen a estar al nivel de variables locales. El uso de la función swap() no tendrá efecto en el exterior de la misma. La versión correcta debe escribirse con pasaje por referencia:
void swap(int *x, int *y) /* correcta */
{
int temp;
temp = *x;
*x = *y;
*y = temp;
}
La invocación de swap()
debe hacerse con las direcciones de los objetos a intercambiar:
int a, b;
swap(&a, &b);
En las funciones que reciben direcciones, los argumentos formales pueden tener cualquiera de dos notaciones: como punteros, o como arreglos. No importa qué objeto sea exactamente el argumento real (arreglo o puntero): en ambos casos, la función únicamente recibe una dirección y no sabe, ni le importa, cuál es la naturaleza real del objeto exterior a ella.
La función que busca un carácter en una cadena, vista más arriba, puede escribirse correctamente así, cambiando el tipo del argumento formal. El uso es exactamente el mismo que antes, sin cambios en la función que llama.
char *donde(char p[], char c)
{
int i;
for(i=0 ; p[i] != 0; i++)
if(p[i] == c)
return p+i;
return NULL;
}
Nótese que dentro del cuerpo de la función podemos seguir utilizando la notación de punteros si lo deseamos, aun con la declaración del argumento formal como arreglo.
char *donde(char p[], char c) /* el argumento p es un arreglo */
{
for( ; *p != 0; p++) /* pero se le puede aplicar el */
if(*p == c) /* operador de dereferenciacion */
return p;
return NULL;
}
Del mismo modo, si quisiéramos, podríamos representar los argumentos como punteros y manipular los datos con indexación. Todo esto se debe, por un lado, a que las notaciones *p
y p[]
, para argumentos formales, expresan únicamente que el argumento es una dirección; y por otro lado, a la equivalencia entre las formas de acceso mediante apuntadores y mediante índices de arreglos.
¡Esto no quiere decir que punteros y arreglos sean lo mismo! Véanse las observaciones en la próxima unidad.
Dado el programa siguiente, ¿a dónde apunta k1
?
main()
{
int k;
int *k1;
}
Dado el programa siguiente, ¿a dónde apunta m1
?
int *m1;
main()
{
...
}
¿Cuánto espacio de almacenamiento ocupa un arreglo de diez enteros? ¿Cuánto espacio de almacenamiento ocupa un puntero a entero?
Declarar variables long
llamadas a, b y c, y punteros a long
llamados p, q y r; y dar las sentencias en C para realizar las operaciones siguientes. Para cada caso, esquematizar el estado final de la memoria.
Cargar p con la dirección de a. Si ahora escribimos *p = 1000
, ¿qué ocurre?
Cargar r con el contenido de p. Si ahora escribimos *r = 1000
, ¿qué ocurre?
Cargar q con la dirección de b, y usar q para almacenar una constante 4L
en el espacio de b.
Cargar en c la suma de a y b, pero sin escribir la expresión a + b.
Almacenar en c la suma de a y b pero haciendo todos los accesos a las variables en forma indirecta.
Compilar y ejecutar:
main()
{
char *a = "Ejemplo";
printf("%s\n",a);
}
main()
{
char *a;
printf("%s\n",a);
}
main()
{
char *a = "Ejemplo";
char *p;
p = a;
printf("%s\n", p);
}
¿Qué imprimirán estas sentencias?
char *s = ABCDEFG;
¿Son correctas estas sentencias? Bosqueje un diagrama del estado final de la memoria para aquellas que lo sean.
char *a = Uno;
char *a, b; a = Uno; b = Dos;
char *a,*b ; a = Uno; b = a;
char *a,*b ; a = Uno; b = *a;
char *a,*b ; a = Uno; *b = a;
char *a = Dos; *a = ’T’;
char *a = Dos; a = T;
char *a = Dos; *(a + 1) = ’i’; *(a + 2) = ’a’;
char *a, *b ; b = a;
Escribir funciones para:
Calcular la longitud de una cadena.
Dado un carácter determinado y una cadena, devolver la primera posición de la cadena en la que se lo encuentre, o bien −1 si no se halla.
Buscar una subcadena en otra, devolviendo un puntero a la posición donde se la halle.
Escribir una función para reemplazar en una cadena todas las ocurrencias de un carácter dado por otro, suponiendo:
Que no interesa conservar la cadena original, sino que se reemplazarán los caracteres sobre la misma cadena.
Que se pretende obtener una segunda copia, modificada, de la cadena original, sin destruirla.
Escribir funciones para:
Rellenar una cadena con un carácter dado, hasta que se encuentre el 0 final, o hasta alcanzar n iteraciones.
Pasar una cadena a mayúsculas o minúsculas.
Reescriba dos de las funciones escritas en 8 y dos de las escritas en 10 usando la notación opuesta (cambiando punteros por arreglos).
El utilizar un puntero sin inicializar parece estar entre las primeras causas de errores en C.
Si bien sintácticamente correctas, las líneas del ejemplo presentan un problema muy común en C. Declaran un puntero a entero, p
, y almacenan un valor entero en la dirección apuntada por el mismo.
/* Incorrecto */
int *p;
*p = 8;
Como p
es un puntero, su contenido será interpretado como una dirección de memoria. El problema es que el contenido de p
es impredecible:
Si la variable p
es local, su clase de almacenamiento es auto y por lo tanto contiene basura, salvo inicialización explícita.
Si p
es externa, será inicializada a 0.
En el primer caso, esa dirección es aleatoria. En el segundo caso, será cero
, que en la mayoría de los sistemas operativos conocidos es una dirección inaccesible para los procesos no privilegiados.
En cualquier caso, el programa compilará pero se encontrará con problemas de ejecución. Lo que falta es hacer que p
apunte a alguna zona válida.
Direcciones válidas, que pueden ser manipuladas mediante punteros, son las de los objetos conocidos por el programa: variables, estructuras, arreglos, funciones, bloques de memoria asignados dinámicamente, son todos objetos cuya dirección ha sido obtenida legítimamente, ya sea al momento de carga o al momento de ejecución.
La solución al problema de los punteros sin inicializar es asegurarse, siempre, de que los punteros apuntan a lugares válidos del programa, asignándoles direcciones de objetos conocidos.
/* Correcto */
int a;
int *p;
p = &a;
*p = 8;
Es imprescindible comprender rigurosamente la diferencia entre arreglos y punteros. Aunque son intercambiables en algunos contextos, suponer que son lo mismo lleva a graves errores. Es frecuente confundirlos, y esta confusión es explicable a partir de algunos hechos que se enumeran a continuación. No hay que dejarse engañar por ellos.
Ambos se evalúan a direcciones.
El nombre de un arreglo equivale a una dirección, y usar un puntero equivale a usar la dirección que contiene. Es decir, tienen usos similares.
char formato[] = "%d %d\n";
printf(formato, 5, -1);
char *formato = "%d %d\n";
printf(formato, 5, -1);
Aquí usamos como equivalentes a un arreglo y a un puntero, porque de cualquiera de las dos maneras estamos expresando una dirección en la invocación a printf()
.
Como argumentos formales, son equivalentes.
En una función, un argumento formal que sea una dirección puede ser declarado como puntero o como arreglo, intercambiablemente. Convirtamos los ejemplos de más arriba a funciones:
int fun(char *s, int x, int y)
{
printf(s, x, y);
}
int fun(char s[], int x, int y)
{
printf(s, x, y);
}
Ambas formas son válidas, porque lo único que estamos expresando es que un argumento es una dirección; y, como se ha dicho, tanto punteros como nombres de arreglos los representan. Además, cualquiera de las funciones escritas puede usarse en cualquiera de las dos maneras siguientes, pasando punteros o arreglos en la llamada:
char formato[] = "%d %d\n";
fun(formato, 5, -1);
char *formato = "%d %d\n";
fun(formato, 5, -1);
Comparten operadores.
Se pueden aplicar los mismos operadores de acceso a ambos; a saber, se puede dereferenciar un arreglo (igual que un puntero) para acceder a un elemento y se puede indexar un puntero (como un arreglo) para acceder a una posición dentro del espacio al que apunta.
char cadena[] = "abcdefghijkl";
char c;
c = *(cadena + 4); /* c = 'e' */
char *cadena = "abcdefghijkl";
char c;
c = cadena[4]; /* c = 'e' */
En muchas formas, entonces, punteros y arreglos pueden ser intercambiados porque son dos maneras de acceder a direcciones de memoria, pero un arreglo no es un puntero, y ninguno de ellos es una dirección (aunque las representan), porque:
Un arreglo tiene memoria asignada para todos sus elementos (desde la carga del programa, para arreglos globales o static
, y desde la entrada a la función donde se lo declara, para los arreglos locales).
Un puntero, en cambio, solamente contiene una dirección, que puede ser o no válida en el sentido de apuntar o no a un objeto existente en el espacio direccionable por el programa. La validez de la dirección contenida en un puntero es responsabilidad del programador.
Una variable de un tipo básico cualquiera contiene un dato que puede ser directamente utilizado en una expresión para hacer cálculos. En cambio, un puntero que apunte a esa variable contiene su dirección; es una referencia al dato, y necesita ser dereferenciado para acceder al dato. La variable y su puntero tienen diferente nivel de indirección.
Un char
, un int
, un long
, un double
, una estructura de un tipo definido por el usuario, tienen un mismo nivel de indirección, que podríamos llamar . Un puntero, una dirección, un nombre de arreglo, tienen un . La Fig. [fig:indir] esquematiza situaciones donde los niveles de indirección son 0, 1, y 2.
Aplicar el operador &
(dirección de) a algo aumenta su nivel de indirección. Aplicar el operador *
(dereferenciación) lo decrementa.
Este tipo de análisis es sumamente útil para prevenir errores de programación. Conviene utilizarlo para dar una segunda mirada crítica a las expresiones que escribimos.
char *s = "cadena";
char *t;
char u;
t = s + 2; /* CORRECTO */
u = s; /* INCORRECTO */
u = *s; /* CORRECTO */
t = &u; /* CORRECTO */
La asignación t = s + 2
es correcta porque la suma de una dirección más un entero está definida y devuelve una dirección; con lo cual la expresión mantiene el mismo nivel de indirección en ambos miembros (puntero = dirección).
En cambio, la asignación u = s
intenta asignar una dirección (la dirección contenida en s
) a un char. No se respeta el mismo nivel de indirección en ambos miembros de la asignación (puntero = char), de modo que ésta es incorrecta y será rechazada por el compilador.
En las dos últimas asignaciones se usan los operadores de dirección y de indirección para hacer consistentes los niveles de indirección de ambos miembros:
u = *s;
char = lo apuntado por(puntero a char);
char = char;
t = &u;
puntero a char = direccion de(char);
direccion de char = direccion de char;
Una construcción especialmente útil es la de arreglos de punteros a carácter. Esta construcción permite expresar una lista de rótulos y navegar por ellos con la indexación natural de los arreglos.
Aquí el tipo de los elementos del arreglo mes es puntero a carácter. Cada elemento se inicializa en la declaración a una constante string.
char *mes[] = { "Ene","Feb","Mar","Abr","May","Jun",
"Jul","Ago","Sep","Oct","Nov","Dic" };
printf("Mes: %s\n",mes[6]);
En el caso particular de estructuras o uniones referenciadas por punteros, la notación para acceder a sus miembros cambia ligeramente, reemplazando el operador por .
Accedemos a los miembros de la estructura persona
en forma directa:
struct persona p;
p.DNI = 14233326;
p.edad = 40;
En forma indirecta:
struct persona p, *pp;
pp = &p;
pp->DNI = 14233326;
pp->edad = 40;
Las estructuras de datos recursivas se expresan efectivamente creando miembros que sean punteros a estructuras del mismo tipo.
struct itemlista {
double dato;
struct itemlista *proximoitem;
}
struct nodoarbol {
int valor;
struct nodoarbol *hijoizquierdo;
struct nodoarbol *hermanoderecho;
}
En cambio, no es legal la composición de estructuras dentro de sí mismas:
struct itemlista { /* INCORRECTO */
double dato;
struct itemlista proximoitem;
}
Aunque la construcción de tipos definidos por el usuario no es una característica directamente ligada a los punteros o a las variables estructuradas, es un momento apropiado para introducirla. El lenguaje C admite la generación de nuevos nombres para tipos (en particular, los tipos estructurados) mediante la primitiva typedef
.
Las declaraciones del ejemplo anterior se podrían reescribir más claramente de la forma que sigue.
typedef struct nodoarbol {
int valor;
struct nodoarbol *hijoizquierdo;
struct nodoarbol *hermanoderecho;
}nodo;
typedef struct nodoarbol *nodop;
Entonces, el tipo de un argumento de una función podría quedar expresado sintéticamente como nodop
:
nodop crearnodo(nodop padre);
La construcción con typedef
no es indispensable, pero aporta claridad al estilo de programación, y, bien manejada, promueve la portabilidad.
Se ha visto la necesidad de que los punteros apunten a direcciones válidas. ¿Qué hacer cuando la lógica del programa pide la creación de estructuras de datos en forma dinámica? Los punteros son muy convenientes para manejarlas, pero se debe asegurar que apunten a zonas de memoria legítimamente obtenidas por el programa.
En C se tiene como herramientas básicas de gestión dinámica de memoria a las funciones malloc()
y free()
. Con malloc()
pedimos una cantidad de bytes contiguos que serán tomados del heap. La función malloc()
devuelve la dirección del bloque de memoria asignado. Esta dirección debe reservarse en un puntero para uso futuro y para liberarla con free()
.
En lugar de hacer que p
apunte a un objeto existente al momento de compilación, solicitamos tanta memoria como sea necesaria para alojar un entero y ponemos a p
apuntando allí. Luego podemos hacer la asignación. Luego del uso se debe liberar la zona apuntada por p
.
/* Correcto */
int *p;
p = malloc(sizeof(int));
*p = 8;
free(p);
Mejoramos el ejemplo anterior verificando que devuelva memoria válida.
/* ¡Mejor! */
int *p;
if(p = (int *)malloc(sizeof(int))) {
*p = 8;
free(p);
}
Aplicamos el operador cast al resultado de para que ambos miembros de la asignación sean consistentes.
La propiedad de poder aplicar indexación a los punteros hace que, virtualmente, el C sea capaz de proporcionarnos arreglos dimensionables en tiempo de ejecución. En efecto:
double *tabla;
tabla = malloc(k);
tabla[50] = 15.25;
Estas líneas son virtualmente equivalentes a un arreglo de k elementos double
, donde k, por supuesto, puede ser calculado en tiempo de ejecución.
Una variante de malloc()
es la función calloc()
, que solicita una cantidad dada de elementos de memoria, de un tamaño también dado, y además garantiza que todo el bloque de memoria concedido esté inicializado con ceros binarios.
float *lista;
int i;
lista = calloc(k, sizeof(float));
for(i=0; i<k; i++)
lista[i] = fun(i);
Así como se pueden tomar las direcciones de los elementos de datos, es posible manipular las direcciones iniciales de los segmentos de código representados por las funciones de un programa C, mediante punteros a funciones. Esta característica es sumamente poderosa.
La declaración de un puntero a función tiene una sintaxis algo complicada: debe indicar el tipo devuelto por la función y los tipos de los argumentos. El nombre del puntero, con el signo de incluido, debe estar entre paréntesis en la declaración.
Definición de un puntero llamado p
, a una función que recibe dos enteros y devuelve un entero:
int (*p)(int x, int y);
o también:
int (*p)(int, int);
Los paréntesis alrededor de *p
son importantes: sin ellos, se define una función que devuelve un puntero a entero, que no es lo que se pretende.
Asignación del puntero p
:
int fun(int x, int y)
{
...
}
p = fun;
Uso del puntero p
para invocar a la función fun
cuya dirección tiene asignada:
a = (*p)(k1, 20 - k2);
La Biblioteca Standard contiene una función, qsort()
, que realiza el ordenamiento de una tabla de datos mediante el método de Quicksort. Para que pueda ser completamente flexible (para poder ordenar datos de cualquier naturaleza y según cualquier criterio), la función acepta a su vez una función provista por el usuario, que determina el orden de dos elementos.
Es responsabilidad del usuario, entonces, definir cuándo un elemento es mayor que el otro, a través de esta función de comparación. La función de ordenamiento recibe un puntero a la función de comparación y la invoca repetidamente.
La función de comparación sólo debe aceptar p1
y p2
, dos punteros a un par de datos, y seguir el protocolo siguiente:
Si | Devolver |
---|---|
*p1 < *p2 | un número menor que cero |
*p1 = = * p2 | cero |
*p1 < *p2 | un número mayor que cero |
La declaración de argumentos formales como void *
expresa que esos argumentos pueden tratarse de direcciones de objetos de cualquier tipo.
#include <stdlib.h>
struct p {
...
char nombre[40];
double salario;
...
} lista[100];
int cmpSalario(const void *p1, const void *p2)
{
return p1->salario - p2->salario;
}
int cmpNombre(const void *p1, const void *p2)
{
return strncmp(p1->nombre, p2->nombre, 40);
}
Con estas definiciones, la tabla lista se puede ordenar por uno u otro campo de la estructura con las sentencias:
qsort(lista, 100, sizeof(struct p),cmpSalario);
qsort(lista, 100, sizeof(struct p),cmpNombre);
La indirección mediante punteros puede ser doble, triple, etc. Los punteros dobles tienen aplicación en el manejo de conjuntos de strings o matrices bidimensionales.
Como en el caso de punteros a funciones, esto brinda una gran potencia, pero a costa de complicar enormemente la notación y la programación, por lo que se recomienda no abordar el tema en un curso introductorio y, sólo una vez dominadas las técnicas y conceptos básicos de punteros, referirse a una fuente como el libro de Kernighan y Ritchie, 2ª edición.
Un ejemplo de uso de punteros dobles.
En estas sentencias, el puntero q
recibe la dirección de a
; pero p
recibe la dirección de q
. Luego, p
contiene de a
.
Similarmente, la expresión *p
significa ; pero entonces **p
significa .
int **p; /* un puntero doble */
int *q;
int a;
q = &a; /* q recibe la direccion de a */
p = &q; /* p recibe la direccion de q */
**p = 8; /* la variable a recibe un valor 8 */
Para facilitar la práctica damos la descripción de otra función de Biblioteca Standard.
La función gets()
pide al usuario una cadena terminada por ENTER
. Recibe como argumento un espacio de memoria (expresado por una dirección) donde copiará los caracteres tipeados. El fin de la cadena recibirá automáticamente un cero final para hacerla compatible con las funciones de tratamiento de strings de la Biblioteca Standard.
La función gets()
debe recibir la dirección de un área de memoria legal para el programa y donde no haya riesgo de sobreescribir contenidos. Es importante comprender la teoría dada en este capítulo para evitar el uso de un puntero no inicializado, una situación de error frecuente cuando se utilizan gets()
y funciones similares.
Creamos un área de memoria utilizable por gets() simplemente definiendo un arreglo de caracteres del tamaño deseado.
main()
{
char arreglo[100];
gets(arreglo);
}
El puntero del siguiente ejemplo no está inicializado. Por ser una variable local, contiene basura, y por lo tanto apunta a un lugar impredecible.
main()
{
char *s;
gets(s); /* ¡¡¡Mal!!! */
}
Podemos corregir el ejemplo anterior proveyendo espacio legítimo a donde apunte s
, reservándolo estáticamente mediante la declaración del arreglo.
main()
{
char arreglo[100];
char *s;
s = arreglo;
gets(s); /* ¡Ahora sí! */
}
En el siguiente ejemplo cambiamos la asignación estática del ejemplo anterior, usando ahora asignación dinámica y liberación de memoria. En un sentido estricto, en este caso particular no es necesaria la liberación, porque la terminación del programa devuelve todas las estructuras creadas dinámicamente; pero es útil habituarse a la disciplina de aparear cada invocación de malloc()
con el correspondiente free()
.
main()
{
char *s;
s = malloc(100); /* ¡Ahora también! */
gets(s);
printf("ingresado: %s\n",s);
free(s);
}
¿Qué objetos se declaran en las sentencias siguientes? El primero, por ejemplo, es un apuntador a función que recibe un entero y devuelve un double.
double (*nu)(int kappa);
int (*xi)(int *rho);
long phi();
int *chi;
int pi[3];
long *beta[3];
int *(gamma[3]);
int (*delta)[3];
void (*eta[5])(int *rho);
int *mu(long delta);
Construir una función que reciba un arreglo de punteros a string A y un string B, y busque a B en el array A, devolviendo su índice en el array, o bien −1 si no se halla.
Construir una función que imprima una cadena en forma inversa. Muestre una versión iterativa y una recursiva.
Construir un programa que lea una sucesión de palabras y las busque en un pequeño diccionario. Al finalizar debe imprimir la cuenta de ocurrencias de cada palabra en el diccionario.
Construir un programa que lea una sucesión de palabras y las almacene en un arreglo de punteros a carácter.
Ordenar lexicográficamente el arreglo de punteros del ejercicio 4.
El concepto de Entrada/Salida en C replica el de su ambiente nativo, el sistema operativo UNIX, donde todos los archivos son vistos como una sucesión de bytes, prescindiendo completamente de su contenido, organización o forma de acceso. Además, en UNIX los dispositivos de entrada o salida llevan asociados archivos lógicos, que son puntos de entrada implementados en software a dichos dispositivos. Toda la comunicación entre un programa y el mundo externo, ya sean archivos físicos o lógicos, se hace mediante las mismas funciones comunes. Para la programación en C, la abstracción de un archivo es simplemente un flujo de bytes o stream, que se maneja con operaciones primitivas comunes y con independencia de cuál sea el origen y su destino de ese flujo.
Si bien el C no contiene palabras reservadas de entrada/salida (E/S), la Biblioteca Standard sí provee un rico conjunto de funciones de E/S, tan amplio que suele provocar confusión en quienes se aproximan por primera vez al lenguaje. Primero veremos un resumen de las funciones de E/S standard y luego nos concentraremos en la E/S sobre archivos. Aunque la información dada aquí es suficiente para intentar la creación de programas simples, la E/S es un tema notablemente complejo, y es aconsejable tener a mano el manual de las funciones C de nuestro sistema. Para mejor orientarnos, agregamos un mapa de esta unidad en el Cuadro [tab:mapaes].
|c|c|c|c|
E/S standard ([sec:esstandard])&
De caracteres ([subsec:esstandardcaract])& & POSIX ([subsec:esarchivosposix])
De líneas ([subsec:esstandardlineas])& De caracteres ([subsubsec:esarchivosansiccaract])& De acceso directo ([subsec:esarchivosansicdirect])&
Con formato ([subsec:esstandardformato])& De líneas ([subsubsec:esarchivosansiclineas])& &
Sobre strings ([subsec:esstandardstrings])& Con formato ([subsubsec:esarchivosansicformato])& &
Los programas C reciben tres canales de comunicación con el ambiente abiertos antes de comenzar su ejecución. El uso típico de estos canales de comunicación es la lectura del teclado y la impresión sobre pantalla, aunque, si el sistema operativo lo soporta, la existencia de estos canales también hace posibles las poderosas técnicas de la redirección y el piping.
La Biblioteca Standard provee funciones mínimas para estos usos, quedando fuera de consideración algunas características indispensables en programas de producción, como seguridad, validación de datos, o la posibilidad de organizar la salida en pantalla. Por ejemplo, no hay una forma canónica de borrar la pantalla en C, ya que ésta es una función que depende fuertemente de la plataforma y del ambiente donde se ejecute el programa. Las características faltantes en la E/S standard se compensan recurriendo a bibliotecas de terceras partes.
Las funciones de E/S standard de caracteres son y . Las funciones y leen de teclado e imprimen, respectivamente, un carácter por vez.
Un programa que copia la entrada en la salida, carácter a carácter. Puede usarse, con redirección, para crear o copiar archivos, como un clon del comando de UNIX.
#include <stdio.h>
main()
{
int a;
while((a = getchar()) != EOF)
putchar(a);
}
Las funciones y leen de teclado e imprimen, respectivamente, líneas de caracteres terminadas por la señal de fin de línea . La función debe recibir como argumento la dirección de un buffer o zona de memoria donde depositar los caracteres provenientes de entrada standard. Éstos pueden ser tipeados por el usuario, o, gracias a la redirección, provenir de archivos, o ser resultado de la ejecución –eventualmente concurrente– de otros programas.
Es un error muy frecuente ofrecer a un puntero no inicializado. La función ha sido descripta en la unidad sobre apuntadores y direcciones (ver Sección [sec:lafunciongets]).
El mismo programa, pero orientado a copiar un stream de texto línea por línea. La constante está definida en y es el tamaño del buffer de estas funciones. Se puede sugerir esta elección para el buffer del programa, salvo que haya motivos para proporcionar otro tamaño.
#include <stdio.h>
main()
{
char area[BUFSIZ];
while(gets(area) != NULL)
puts(area);
}
La función elimina el final con que termina la línea antes de almacenarla en su buffer. La función lo repone.
Las funciones y permiten imprimir e ingresar, respectivamente, conjuntos de datos en formato legible, descriptos por cadenas de formato. Las cadenas se componen de especificaciones de conversión y son simétricamente las mismas para ambas funciones. La función y las cadenas de formato han sido descriptas en la unidad correspondiente a tipos de datos (ver sección [sec:lafuncionprintf]).
Inversamente a , la función buscará en la entrada standard patrones de caracteres que estén de acuerdo con las especificaciones de conversión. Generará representaciones internas para los datos leídos, y los almacenará en variables. Para esto debe recibir las direcciones de dichas variables donde almacenar los elementos detectados en la entrada standard. Es un error frecuente ofrecerle, como argumentos, las variables, y no las referencias a las mismas.
Leer de entrada standard un valor entero y un , e imprimirlos. Controlar que efectivamente se haya logrado leer ambos valores antes de imprimir las variables.
main()
{
int a, long b;
if(scanf("%d %ld", &a, &b) != 2)
exit(1);
printf("a=%d, b=%ld\n", a, b);
}
El uso de es con frecuencia problemático. La función consumirá toda la entrada posible, pero se detendrá al encontrar un error (una entrada que no corresponda a lo descripto por la especificación de conversión) y dejará el resto de la entrada sin procesar, en el buffer de entrada. Si luego otra función de entrada intenta leer, se encontrará con esta entrada no consumida, lo cual puede dar origen a problemas de ejecución difíciles de diagnosticar. El error parecerá producirse en una instrucción posterior a la invocación de culpable.
Por esta razón suele ser difícil combinar el uso de con el de otras funciones de entrada/salida. Además, no hay manera directa de validar que la entrada quede en el rango del tipo de datos destino.
El uso más recomendable de es cuando se la utiliza para leer, mediante redirección, un flujo generado automáticamente por otro programa (y que, por lo tanto, tiene una gramática rigurosa y conocida).
La misma lógica de las funciones de E/S con formato sirve para que otras funciones lean variables con formato de un string, o impriman variables formateadas sobre una cadena. El efecto de sobre su cadena argumento es el mismo que tendría sobre salida standard. Por su parte la función lee de un string en memoria, conteniendo datos en formato legible, y los recoge en representación binaria en variables, lo mismo que si los hubiera leído de entrada standard.
main()
{
char area[1024];
int a; long b;
printf("1: %d %ld\n", -6534, 1273632);
sprintf(area, "%d %ld", -6534, 1273632);
printf("2: %s\n",area);
sscanf(area, "%d %ld", &a, &b);
printf("3: %d %ld\n", a, b);
}
El resultado debería ser:
1: -6534 1273632
2: -6534 1273632
3: -6534 1273632
Diferentes sistemas operativos tienen diferentes sistemas de archivos y diferentes modelos de archivos. Los sistemas de archivos son los conjuntos de funciones particulares que cada sistema ofrece para acceder a los archivos y a la estructura de directorios que soporta. Los modelos de archivos son aquellas convenciones de formato u organización que son particulares de un sistema operativo o plataforma.
Por ejemplo, tanto DOS como UNIX soportan la noción de archivo de texto, pero con algunas diferencias a nivel de modelo de archivos. En ambos, un archivo de texto es una secuencia de líneas de texto, donde cada línea es una secuencia de caracteres terminado en fin de línea; pero en DOS, la convención de fin de línea es un par de caracteres (CR, LF) (equivalentes a ASCII 13 y 10, respectivamente) mientras que para UNIX, un fin de línea equivale solamente a LF (ASCII 10). Además DOS, a diferencia de UNIX, almacena un carácter especial (EOF o ASCII 26) al final de los archivos de texto para señalar el fin del archivo.
Por otro lado, diferentes sistemas de archivo proveen diferentes vistas sobre diferentes implementaciones. Un sistema operativo puede soportar o no la noción de directorio, o la de links múltiples; o puede fijar determinadas condiciones sobre los nombres de archivos, todo esto en función de la organización íntima de sus estructuras de datos.
Siendo uno de sus objetivos de diseño el favorecer la producción de programas portables, el lenguaje C contempla la forma de resolver estos problemas de manera fácil para los programadores. Las funciones de entrada/salida sobre archivos de la Biblioteca Standard están divididas en dos grandes regiones: el conjunto de funciones del C standard, también llamadas funciones de entrada/salida bufferizada, definidas por ANSI, y las funciones POSIX, también llamadas funciones de entrada/salida de bajo nivel.
Las funciones ANSI C tienen el objetivo de ocultar a los programas las particularidades de la plataforma, haciéndolos plenamente portables a pesar de las diferencias conceptuales y de implementación de entrada/salida entre los diferentes sistemas operativos. Estas funciones resuelven, por ejemplo, el clásico problema de las diferentes convenciones sobre los delimitadores de un archivo de texto. Es decir, están orientadas a resolver los problemas de incompatibilidad inherentes al modelo de archivos. En cambio, el esfuerzo de estandarización de POSIX apunta a establecer (aunque no solamente en lo referente a los archivos) una interfaz uniforme entre compilador y sistema operativo, proveyendo primitivas de acceso a los archivos con un comportamiento claramente determinado, independientemente de cuál sea el sistema operativo subyacente. Así, las funciones POSIX resuelven problemas de consistencia entre diferentes sistemas de archivos.
Las funciones del ANSI C son las más comúnmente utilizadas por el programador, pero se apoyan en funcionalidad suministrada por las funciones POSIX (de nivel más bajo), que también están disponibles y son las recomendadas cuando las restricciones del problema exceden a las funciones ANSI. La característica fundamental de las funciones ANSI es la entrada/salida bufferizada. Por ejemplo, una operación de escritura solicitada por una instrucción del programa no se efectiviza inmediatamente sino que se realiza sobre un buffer intermedio, administrado por las funciones de Biblioteca Standard y con su política propia de flushing o descarga al dispositivo.
Las funciones de entrada/salida bufferizada reciben argumentos a imprimir y los van depositando en un buffer o zona de memoria intermedia. Cuando el buffer se llena, o cuando aparece un carácter de fin de línea, el buffer se descarga al dispositivo, escribiéndose los contenidos del buffer en pantalla, disco, etc. En cambio, las funciones POSIX hacen E/S directa a los dispositivos (o al menos, al sistema de E/S del sistema operativo) y por esto son las preferidas para la programación de drivers, servidores, etc., donde la performance y otros detalles finos deban ser controlados más directamente por el programa.
Las funciones ANSI realizan todas las operaciones sobre archivos por medio de una estructura o bloque de control cuyo tipo en C se llama . Esta estructura está definida en el header y contiene, entre otras cosas, punteros a buffers para escritura y lectura. La primera operación necesaria es la apertura del archivo, que construye una estructura , la inicializa con valores adecuados y devuelve un apuntador a la misma (es decir, un valor de tipo ). El apuntador servirá para referenciar esa estructura durante todo el trabajo con el archivo y hasta que deba ser cerrado.
En la apertura del archivo corresponde indicar el modo de acceso (la clase de operaciones que se van a hacer sobre él). Como algunos sistemas operativos (notoriamente, el DOS) distinguen entre archivos de texto y binarios, el ANSI C provee dos formas de apertura, para indicar cómo se va a tratar el archivo. Cuando un archivo se abre en modo de texto, durante las operaciones de lectura y escritura se aplicarán las conversiones de fines de línea y de fin de archivo propias de la plataforma. Para los archivos abiertos en modo binario, no se aplicarán conversiones.
En sistemas conformes a POSIX, como UNIX, no hay realmente diferencia entre los dos modos de apertura. Si se desea especificar una apertura de archivo en modo binario (para asegurar la portabilidad) se añade una b a la especificación de modo (por ejemplo, como en wb+
). En el Cuadro [tab:modos] se resumen las especificaciones de modos de acceso en apertura de archivos.
c|l Modo & Operación
“r”
& Abre un archivo que ya existe para lectura.
& La lectura se realiza al inicio del archivo.
“w”
& Se crea un nuevo archivo para escribir.
& Si el archivo ya existe, se inicializa y se sobreescribe.
“a”
& Abre un archivo que ya existe, para agregar información al final.
& Sólo se puede escribir a partir del final del archivo.
“r+”
& Abre un archivo que ya existe, para actualizarlo
& (tanto para lectura como para escritura).
“w+”
& Crea un nuevo archivo para actualizarlo (lectura y escritura);
& si ya existe, lo sobreescribe.
“a+”
& Abre un archivo para añadir información al final.
& Si no existe, lo crea.
Como en la entrada/salida standard, para manejar archivos tenemos funciones para E/S de caracteres, de líneas y con formato.
Las funciones que leen y escriben sobre archivos un carácter por vez son y . Ejemplo que copia un archivo:
#include <stdio.h>
main()
{
FILE *fp1, *fp2;
int a;
if(((fp1 = fopen("ejemplo.txt","r")) == NULL) ||
((fp2 = fopen("copia.txt","w")) == NULL))
exit(1);
while((a = fgetc(fp1)) != EOF)
fputc(a, fp2);
fclose(fp1);
fclose(fp2);
}
Hay otras funciones dentro de esta categoría, como , que devuelve un carácter al flujo de donde se leyó.
Mismo ejemplo anterior, en base a líneas, usando las funciones y . La declaración de variables y las sentencias de apertura y cierre de archivos son idénticas al caso anterior. Enunciamos solamente el lazo principal.
char area[BUFSIZ];
...
while(fgets(area, BUFSIZ, fp1) != NULL)
fputs(area, fp2);
Existen funciones y , casi idénticas a y , pero donde se especifica el stream de entrada o de salida. Las funciones y pueden verse como el caso particular de las primeras donde el stream es stdout o stdin, respectivamente.
Leer dos variables de un archivo y escribirlas, en el mismo formato, en un segundo archivo.
#include <stdio.h>
main()
{
FILE *fp1, *fp2;
int a; long b;
if(((fp1 = fopen("ejemplo.txt","r")) == NULL) ||
((fp2 = fopen("copia.txt","w")) == NULL))
exit(1);
if(fscanf(fp1, "%d %ld", &a, &b) != 2)
exit(1);
fprintf(fp2, "%d %ld\n", a, b);
fclose(fp1);
fclose(fp2);
}
Un conjunto muy útil de funciones ANSI permite el acceso directo, aleatorio, o random sobre archivos, saltando por encima del modelo de E/S secuencial que domina al resto de las funciones. Las funciones básicas de acceso directo son y , que leen bloques de un tamaño dado y en una cantidad dada.
El acceso directo a un archivo se logra manteniendo un dato en su estructura , llamado el puntero de lectura/escritura, correspondiente al punto dentro del archivo donde se efectuará la próxima lectura o escritura cuando el programa lo requiera. Cada lectura o escritura de una cierta cantidad de bytes avanza este puntero en esa cantidad de bytes, dejando el puntero listo para la siguiente operación a continuación de la última.
Sin embargo, el valor del puntero de lectura/escritura puede consultarse o modificarse arbitrariamente, con funciones específicas. Cambiando explícitamente el valor de este dato, podemos hacerlo apuntar a cualquier dirección dada, u offset, dentro del archivo. Si hacemos que el puntero de escritura sea igual al tamaño en bytes del archivo, la próxima escritura se hará a partir del final del archivo. Si hacemos que sea igual a 0, la próxima lectura (o escritura) se hará a partir del comienzo del archivo. Del mismo modo se puede modificar un trozo de información, de cualquier tamaño dado, en cualquier lugar del archivo.
Las funciones ANSI de acceso directo utilizan el puntero de lectura/escritura. Son ideales para lectura y escritura directa de estructuras de datos, ya sea que se trate de elementos individuales u organizados en arreglos. Al poder posicionarse el puntero de lectura o escritura en zonas arbitrarias de los archivos, se logra la capacidad de E/S por registros con acceso aleatorio.
En el ejemplo suponemos que se han grabado en el archivo varios registros cuyo formato está representado por la estructura de la variable . La función posiciona el puntero de lectura en el offset (que debe ser un ), significando que necesitamos acceder al registro lógico número 10 del archivo. A continuación se leen tantos bytes como mide un elemento de datos.
Cambiando el tercer argumento de podemos leer en un solo acceso un vector completo de estas estructuras en lugar de un elemento individualmente. Luego de cambiar un valor del registro se lo vuelve a grabar, esta vez en un offset distinto (correspondiente al registro lógico 5).
struct registro {
int dato1;
long dato2;
} datos;
...
fseek(fp, 10L * sizeof(struct registro), SEEK_SET);
fread(&datos, sizeof(struct registro), 1, fp);
datos.dato1 = 1;
fseek(fp, 5L * sizeof(struct registro), SEEK_SET);
fwrite(&datos, sizeof(struct registro), 1, fp);
Para posicionar el puntero de lectura/escritura en un offset determinado, existe la función . El origen del desplazamiento se expresa con las constantes , y .
La constante indica que el posicionamiento solicitado debe entenderse como absoluto a partir del principio del archivo. La constante indica posicionamiento a partir del offset actual, y , a partir del fin del archivo. El offset proporcionado puede ser negativo.
Una restricción importante de la E/S en ANSI C es que no se pueden mezclar instrucciones de entrada y de salida sin que intervenga una operación intermedia de posicionamiento. Es decir, una sucesión de invocaciones a puede ser seguida de uno o más , pero únicamente luego de un entre ambas. La operación de posicionamiento resincroniza ambos punteros y su ausencia puede hacer que se recupere basura.
Este posicionamiento puede ser nulo, como por ejemplo en que no varía en absoluto la posición de los punteros, pero realiza la sincronización buscada.
Podemos resumir lo visto hasta aquí con los prototipos de las funciones ANSI de E/S en el Cuadro [tab:resumenesansicarchivos].
l|l|l & E/S Standard & E/S sobre archivos
&
int getchar();
&
int fgetc(FILE *stream);
&
int putchar(int c)
&
int fputc(int c, FILE *stream);
&
char *gets(char *s);
&
char *fgets(char *s, int n, FILE *stream);
&
int puts(const char *s);
&
int fputs(const char *s, FILE *stream);
&
int printf(const char *format, ...);
&
int fprintf(FILE *stream, const char *format, ...);
&
int scanf(const char *format, ...);
&
int fscanf(FILE *stream, const char *format, ...);
&
int sprintf(char *s, const char *format, ...);
&
&
int sscanf(char *s, const char *format, ...);
&
& &
size_t fread(void *ptr, size_t size, size_t nobj, FILE *stream);
& &
size_t fwrite(const void *ptr, size_t size, size_t nobj, FILE *stream);
Las funciones POSIX son la interfaz directa del programa con las llamadas al sistema, o system calls. Los programas que utilicen funciones POSIX deben incluir los headers de Biblioteca Standard y .
Las funciones POSIX que operan sobre archivos lo hacen a través de descriptores de archivos. Estos pertenecen a una tabla de archivos abiertos que tiene cada proceso o programa en ejecución y se corresponden con estructuras de datos del sistema operativo para manejar la escritura y la lectura en los archivos propiamente dichos.
La tabla de archivos abiertos de un proceso se inicia con tres archivos abiertos (correspondientes a los streams de entrada standard, salida standard y salida standard de errores), que reciben los descriptores números 0, 1 y 2 respectivamente. Estos archivos son automáticamente provistos por el ambiente donde se ejecuta el proceso, y al comenzar a ejecutarse los encuentra ya abiertos.
Además de los tres descriptores standard abiertos por defecto, los programas abren otros archivos usando la función . Los archivos que se abran subsiguientemente irán ocupando los siguientes descriptores de archivo libres en esta tabla, y por lo tanto reciben números de descriptor de 3 en adelante. Cada nueva operación de apertura exitosa de un archivo devuelve un nuevo descriptor. Este número de descriptor se utiliza durante el resto de la actividad sobre el archivo.
Tanto las funciones ANSI C para archivos como las funciones POSIX de archivos manejan referencias obtenidas mediante la apertura y utilizadas durante toda la relación del programa con el archivo, pero las referencias son diferentes. La referencia al bloque de control utilizada por las funciones ANSI es de tipo , mientras que el descriptor de archivo POSIX es un . Por este motivo no se pueden mezclar las llamadas a funciones de uno y otro grupo.
Sin embargo, sí es cierto que las estructuras de tipo , referenciadas por un , como se dijo antes, se apoyan en funcionalidad aportada por funciones POSIX, y por lo tanto contienen un descriptor de archivo. Si se tiene un archivo abierto mediante una función POSIX, es posible, dado su descriptor, obtener directamente el stream bufferizado correspondiente para manipularlo con funciones ANSI. Esto se logra con la función .
Las funciones de E/S POSIX permiten controlar varios aspectos de la entrada/salida sobre dispositivos o archivos. Existe un conjunto de flags, u opciones, que modifican la conducta de cada operación. Los flags se expresan como nombres simbólicos para constantes de bits, y se especifican en la apertura del archivo como segundo argumento de la función . Todas las combinaciones válidas de flags pueden expresarse como un de bits (operador ) que acumula las constantes de bits correspondientes. Resumimos los flags más importantes existentes para este segundo argumento de en el Cuadro [tab:flags].
l|l
O_RDONLY
& El archivo se abre para lectura solamente
O_RDWR
& Se abre para escritura solamente
O_APPEND
& El archivo puede ser leído o agregársele contenido
O_CREAT
& Si el archivo no existe, se lo crea
O_EXCL
& Si ya existe, se vuelve con indicación de error
O_WRONLY
& Se abre para escritura solamente
O_TRUNC
& Si existe, se destruye antes de crearlo
Este programa copia un archivo sobre otro llamado , usando funciones POSIX y aplicadas a los descriptores obtenidos con .
Abre el primer archivo en modo de sólo lectura con el flag, u opción, .
Como necesita escribir sobre el segundo, utiliza el flag .
Además, para el segundo archivo, especifica otros flags que van agregados al primero, y que son (si no existe, se lo crea) y (si ya existe, se borran todos sus contenidos).
#include <unistd.h>
#include <fcntl.h>
main()
{
char area[1024];
int fd1, fd2, bytes;
if((fd1 = open("ejemplo.txt", O_RDONLY)) < 0)
exit(1);
if((fd2 = open("copia.txt", O_WRONLY|O_CREAT|O_TRUNC, 0660)) < 0)
exit(1);
while(bytes = read(fd1, area, sizeof(area)))
write(fd2, area, bytes);
close(fd1);
close(fd2);
}
El tercer argumento de tiene sentido sólo al crear un archivo. Sirve para especificar los permisos con los que será creado, siempre según el concepto de UNIX de permisos de lectura, escritura y ejecución, distribuidos en clases de usuarios. Para los sistemas operativos que no cuentan con estas nociones, el tercer argumento simplemente se ignora, pero se mantiene en la interfaz POSIX para asegurar la portabilidad de los programas. Nótese que las funciones ANSI C no permiten la especificación de estos permisos de creación.
Como para los flags, hay algunas constantes de bits útiles, que se detallan en el Cuadro [tab:permisos].
l|l|l|l & Dueño & Grupo & Otros
Lectura &
S_IRUSR
&
S_IRGRP
&
S_IROTH
Escritura &
S_IWUSR
&
S_IWGRP
&
S_IWOTH
Ejecución &
S_IXUSR
&
S_IXGRP
&
S_IXOTH
Los tres permisos &
S_IRWXU
&
S_IRWXG
&
S_IRWXO
Si el archivo no existe, se lo crea; se abre para lectura y escritura y con todos los permisos para el creador, pero sólo con permiso de lectura para el grupo del dueño. El resto de los usuarios no tiene ningún permiso sobre el archivo.
int fd = open("prueba.dat", O_RDWR|O_CREAT, S_IRWXU|S_IRGRP);
Para posicionar el puntero de lectura/escritura en un offset determinado, existe la función . El origen del desplazamiento se expresa, como en las funciones ANSI C de acceso directo, con las constantes , y (ver [subsubsec:posicionamiento]).
Repetimos el ejemplo dado para las funciones ANSI donde se lee el registro lógico número 10 y tras una modificación se lo copia en el registro lógico 5, esta vez con funciones POSIX.
lseek(fd, 10L * sizeof(struct registro), SEEK_SET);
read(fd, &datos, sizeof(struct registro));
datos.dato1 = 1;
lseek(fd, 5L * sizeof(struct registro), SEEK_SET);
write(fd, &datos, sizeof(struct registro));
Escribir una función que copie la entrada en la salida pero eliminando las vocales.
Escribir una función que reemplace los caracteres no imprimibles por caracteres punto.
Construir un programa que imprima su entrada invertida, carácter por carácter.
Construir un programa que imprima su entrada invertida, línea por línea.
Construir un programa que cuente la cantidad de palabras de un archivo, separadas por blancos, tabuladores o fin de línea.
Construir un programa que cuente la cantidad de caracteres y de líneas de un archivo.
Construir un programa que permita eliminar de un archivo las líneas que contengan una cadena dada.
Escribir una función que reciba como argumento dos enteros y devuelva un string de formato conteniendo una máscara de formato apropiada para imprimir un número en punto flotante. Por ejemplo, si se le dan como argumentos 7 y 2, deberá devolver el string . Aplicar la función para imprimir números en punto flotante.
Escribir sobre un archivo una variable con valor 1 y una variable con valor 2. Hacerlo primero con funciones de E/S con formato, y luego con funciones de acceso directo. Examinar en cada caso el resultado visualizando el archivo y opcionalmente con un comando como .
(*) Defina una estructura básica simple para un registro, a su gusto. Puede ser un registro de información personal, bibliográfica, etc. Construya funciones para leer estos datos del teclado e imprimirlos en pantalla. Luego, usando funciones ANSI C, construya funciones para leer y escribir una de estas estructuras en un archivo, dado un número de registro lógico determinado.
(*) Repita el ejercicio anterior reemplazando las funciones ANSI C por funciones POSIX.
Construya programas que utilicen las funciones anteriores, ANSI C o POSIX, y ofrezcan un menú de operaciones de administración: cargar un dato en el archivo, imprimir los datos contenidos en una posición determinada, listar la base generada completa, eliminar un registro, etc.
Construir una función que pida por teclado datos personales (nombre, edad, ...) y los almacene en una estructura. Construir una función que imprima los valores recogidos por la función anterior.
Construir un programa que lea una cantidad de datos en lote y luego los imprima utilizando las funciones del ejercicio anterior. Generar un archivo de datos usando redirección.
Realizar el mismo programa del punto anterior pero efectuando toda la E/S sobre archivos. El programa deberá poder leer el archivo de datos del punto anterior.
Construir un programa que organice la salida de la función anterior para obtener un clon del comando de UNIX.
Construir un programa que lea el listado de un directorio en formato largo (la salida del comando ) y devuelva la cantidad total de bytes ocupados.
Construya programas que utilicen las funciones ANSI C o POSIX de los ejercicios anteriores marcados con un asterisco, y que ofrezcan un menú de operaciones de administración: cargar un dato en el archivo, imprimir los datos contenidos en una posición determinada, listar la base generada completa, eliminar un registro, etc.
Entendemos por comunicación con el ambiente todas aquellas formas posibles de intercambiar datos entre el programa y el entorno, ya sea el sistema operativo, el shell del usuario, u otro programa que lo haya lanzado. Una necesidad evidente de comunicación será recibir parámetros, argumentos u opciones de trabajo. Otras necesidades serán generar archivos con resultados, o comunicar una condición de error a la entidad que puso en marcha el programa.
Esta forma de comunicación en realidad no es específica del C sino que está implementada (hoy, en prácticamente todos los sistemas operativos) por el shell de usuario. Todos los programas en ejecución (o procesos) nacen con tres canales de comunicación abiertos: entrada standard, salida standard y salida standard de errores. Cuando el shell lanza un programa, por default le conecta estos tres canales con los dispositivos lógicos teclado, pantalla y pantalla respectivamente. El resultado es que el programa puede recibir caracteres por teclado e imprimir cadenas por pantalla haciendo uso de las funciones de entrada/salida corrientes.
Ahora bien, si el usuario indica al shell, en el momento de lanzar el programa, que desea reconectar alguno de estos canales con otros dispositivos lógicos o archivos, tenemos un fenómeno de redirección, que permite que el programa, sin cambio alguno, utilice las mismas funciones de entrada/salida para leer y generar archivos o comunicarse con dispositivos diferentes.
Los procesos reciben tres canales de comunicación abiertos por donde relacionarse con el ambiente. Mediante redirección se pueden crear archivos con el producto de su salida, o alimentarlos con el contenido de archivos preexistentes.
Otra alternativa es el piping, o entubamiento, que permite, con un solo comando de shell, el lanzamiento (en forma concurrente, si lo soporta el sistema operativo) de dos o más procesos con sus entradas y salidas interconectadas para funcionar acopladas. El shell se apoya en el sistema operativo para construir un pipe, o tubería temporaria, para conducir el flujo de datos entre los procesos que se comunican.
El C adhiere a las convenciones de redirección y piping permitiendo manejar separadamente estos canales con sus funciones de Biblioteca Standard. Poder realizar piping entre procesos permite separar arquitecturalmente las funciones de un programa muy complejo, facilitando el desarrollo, aumentando la mantenibilidad y fomentando la reutilización de los programas escritos sin costo adicional de diseño o programación.
Los procesos pueden comunicarse a través de pipes o tuberías. El sistema operativo UNIX hace uso extensivo de esta capacidad proveyendo una gran cantidad de comandos sencillos que, combinados mediante piping, permiten crear poderosas herramientas sin necesidad de programación.
Para poder aprovechar estas capacidades solamente se requiere un protocolo común entre los programas que se comunicarán. Un medio para lograrlo, en aquellos programas que no son naturalmente cooperativos, es a veces construir adaptadores a nivel de shell. Estos son scripts generalmente sencillos que transforman un formato de datos en otro, facilitando la flexibilidad que no da el C por tratarse de un lenguaje compilado.
Los scripts, siendo interpretados, pueden ejecutarse directamente sin compilación. Pueden modificarse y probarse más rápidamente que los programas compilables, y la programación suele ser más flexible y poderosa. El costo asociado con el scripting es una menor velocidad de ejecución, lo que propone un estudio de cada caso, para optar entre scripting o programación ad hoc. Ambientes como el moderno UNIX ofrecen numerosas herramientas y varios intérpretes de lenguajes de scripting, cada cual con mayores ventajas en un área determinada. Herramientas que es útil conocer son , , , , etc. El shell de usuario es normalmente una buena elección para scripting de tareas simples, poseyendo un lenguaje completo con manejo de variables, estructuras de control, arreglos, etc. Sin embargo, otros como awk, Perl o Python tienen mejores capacidades de manejo de cadenas, esencial para el trabajo que describimos, además de una sintaxis sumamente sintética y poderosa.
El shell, responsable de recibir las órdenes del usuario para lanzar nuevos procesos, mantiene áreas de memoria reservadas para variables de ambiente que son accesibles a los nuevos procesos. Estas variables son simplemente pares (nombre, valor) de cadenas asociadas.
Las variables de ambiente se pueden establecer y consultar con comandos de shell, desde la línea de comandos o desde un script; y lo mismo con funciones de Biblioteca Standard C desde un programa compilado. Los programas pueden consultar una variable de ambiente y decidir el curso de ejecución en función de su contenido; y pueden establecer sus valores para los procesos hijos que originen.
Las variables de ambiente son una forma flexible de configurar el comportamiento de los programas. Las funciones de manejo de variables de ambiente son y (POSIX). Ver también y (BSD 4.3).
Estos comandos a nivel de shell colocan una variable y su valor en el ambiente. El comando la hace visible a los procesos hijos.
$ DIR=/usr/local/programa
$ export DIR
Para leer la variable desde un programa C:
char directorio[50];
strcpy(directorio, getenv("DIR"));
Un programa puede recibir argumentos al momento de ejecución, dados en la línea de comandos. El protocolo para recibir argumentos se ha diseñado para ser lo más general posible. Cada argumento en la línea de comandos es una cadena, independientemente del tipo de los datos, y se accede desde el programa como un puntero a carácter. Es responsabilidad del programa hacer las conversiones a los tipos esperados.
Los argumentos son recibidos por la función , con las siguientes convenciones:
espera dos parámetros, un entero y un arreglo de punteros a carácter.
El primer parámetro representa la cantidad total de argumentos en la línea de comandos, incluido el nombre del programa.
Los elementos del segundo parámetro son punteros a cadenas, terminadas en , representando cada argumento recibido (incluyendo el nombre del programa).
El primer parámetro representa la cantidad total de argumentos en la línea de comandos, incluido el nombre del programa.
main(int argc, char *argv[])
{
if(argc != 3)
printf("Debe dar nombre y edad del usuario\n");
else
printf("Nombre: %s Edad: %d\n", argv[1], atoi(argv[2]));
}
Este programa se invocaría como:
$ programa Alicia 26
Nombre: Alicia Edad: 26
Cada programa ha sido lanzado por algún otro, por lo común el shell del usuario. El programa puede seguir diferentes caminos de ejecución, encontrar errores, condiciones en las cuales es imposible proseguir, etc. Al momento de finalización del programa, puede ser interesante que el programa que le dio origen recoja alguna indicación de este estado final. El C tiene la capacidad (porque la tiene el sistema operativo) de devolver un entero, cuyo significado queda completamente librado al programador. El programa originador debe interpretar este código de retorno, que es una convención entre ambos programas. Es costumbre, aunque para nada obligatoria, devolver un 0 en caso de terminación exitosa, y números diferentes de cero para diferentes casos de terminación con error, al estilo de los protocolos de las funciones de Biblioteca Standard.
Esta característica es especialmente útil en el contexto de un script donde necesitamos determinar si se debe proseguir la ejecución en base al estado retornado por un programa invocado. La función para terminar el programa devolviendo una señal de estado es . Si no se dan argumentos, el valor devuelto queda indefinido.
main(int argc, char *argv[])
{
if(argc < 3) {
printf("Insuficientes argumentos\n");
exit(1);
}
procesar(argv[1],argv[2]);
exit(0);
}
Es muy común encontrar comandos del sistema operativo que aceptan un conjunto, a veces muy vasto, de opciones. Las opciones, si están presentes, se reconocen por comenzar con guiones, y deben ser los primeros argumentos dados al programa.
La convención usual en UNIX de expresar las opciones con un signo guión y letras, y opcionalmente argumentos numéricos, ha llevado a definir funciones de Biblioteca Standard para manejar conjuntos de opciones.
El siguiente fragmento de programa supone que tenemos escritas las funciones y , y que con una opción dada al momento de ejecución queremos decidir cuál de ellas va a ser utilizada.
#include <getopt.h>
#include <unistd.h>
extern char *optarg;
extern int optind, opterr, optopt;
int debug;
main(int argc, char *argv[])
{
char *optstring="RrTtV:v:";
int c;
opterr=0;
while((c=getopt(argc, argv, optstring)) != EOF)
switch(c) {
case 'v':
case 'V':
debug=atoi(optarg);
printf("Nivel de debugging: %d\n",debug);
break;
case ':':
printf("Falta valor numérico\n");
exit(1);
break;
case 'R':
case 'r':
printf("Recibiendo\n");
recibir(argv[optind]);
break;
case 'T':
case 't':
printf("Transmitiendo\n");
transmitir(argv[optind]);
break;
case '?':
printf("Mal argumento\n");
break;
}
}
El programa podría usarse tanto para transmitir como para recibir archivos, observando un nivel de salida de debugging conveniente. Podría invocarse como:
$ transferir -v 2 -T archivo.txt
La función es quien va recogiendo las opciones vistas en la línea de comandos y devolviéndolas como caracteres separados. La variable string contiene las opciones válidas. Para aquellas opciones (como en el ejemplo) que pueden asumir un modificador numérico, se ubica un símbolo a continuación en el string . El valor para la opción numérica se recibe en la variable .
Si ocurre un error sintáctico en el procesamiento de las opciones, la rutina devuelve el carácter y emite un mensaje de error por salida de errores standard. Si no se desea emitir este mensaje, se hace . Las funciones y obtienen el nombre del archivo del arreglo de argumentos , indexándolo con la variable , que queda apuntando al siguiente elemento en la línea de comandos.
Escribir un programa que imprima una secuencia de números consecutivos donde el valor inicial, el valor final y el incremento son dados como argumentos.
Mismo ejercicio pero donde los parámetros son pasados como variables de ambiente.
Mismo ejercicio pero donde los parámetros son pasados como opciones.
Programar una calculadora capaz de resolver cálculos simples como los siguientes:
$ casio 3 + 5
8
$ casio 20 * 6
120
$ casio 5 / 3
1
Agregar la capacidad de fijar precisión (cantidad de decimales) como una opción:
$ casio -d2 5 / 3
1.66
Manteniendo la capacidad anterior, agregar la posibilidad de leer una variable de ambiente que establezca la precisión default. Si no se da la precisión como opción, se tomará la establecida por la variable de ambiente, pero si se la especifica, ésta será la adoptada. Si no hay definida una precisión se tomará 0. Ejemplo:
$ casio 10 / 7
1
$ PRECISION_CASIO=5
$ export PRECISION_CASIO
$ casio 10 / 7
1.42857
$ casio -d2 10 / 7
1.42
Retomar ejercicios de programación de prácticas anteriores, agregándoles opciones. Por ejemplo, el programa para eliminar líneas de un archivo (Ejercicios [sec:tc-esstandard-ej]) admite una opción para elegir líneas conteniendo o no conteniendo una cadena. El programa que cuenta palabras de un archivo (misma práctica) puede recibir opciones o variables de ambiente especificando cuáles serán los separadores entre palabras.
La Biblioteca Standard no forma parte del lenguaje C, estrictamente hablando, pero todos los compiladores contienen una implementación de ella, a veces con agregados o pequeñas variantes. Desde la oficialización del ANSI C, los contenidos de la Biblioteca Standard se han estabilizado y se puede contar con el mismo conjunto de funciones en todas las plataformas.
Las funciones de la Biblioteca Standard están agrupadas en varias categorías, cada una representada en un header, según el Cuadro [tab:catbs]. Una implementación de C puede, sin embargo, aportar muchísimos otros headers, más específicos.
Para poder utilizar cualquiera de las funciones de cada categoría, es necesario incluir en el fuente el header asociado con la categoría. Esto no implica incluir los textos de las funciones en la unidad de traducción, sino simplemente incorporar los prototipos de las funciones de la biblioteca. Es decir, incluir un header de Biblioteca Standard no define las funciones que se van a usar, sino que las declara. La resolución de las referencias a las funciones o variables de Biblioteca Standard quedan pendientes hasta la linkedición.
Ya hemos visitado la mayoría de las funciones de la categoría de funciones de E/S. Nos quedan por explorar algunas otras de importancia. Con esta información no pretendemos reemplazar al manual del compilador, sino orientar a los primeros pasos en el uso de la Biblioteca Standard.
No existiendo un tipo de datos string en C, se lo implementa como un arreglo de caracteres, dado por su dirección inicial, y terminado en el carácter especial . Todas las funciones de strings de Biblioteca Standard, declaradas en el header , hacen uso de este protocolo de fin de string. Muchas de ellas han sido implementada en las prácticas de capítulos anteriores.
En el Cuadro [tab:strings] aparecen las más utilizadas. Es importante consultar también el manual de las funciones , , , , .
En C es posible definir funciones con una lista de argumentos de una longitud variable, es decir, funciones que reciban una cantidad arbitraria de parámetros reales. La función de Biblioteca Standard es un claro ejemplo de esta clase de funciones, y el usuario puede definir las propias. Para esto se prepara un encabezado de la función con los parámetros reales fijos que se desee y se indican los restantes, variables, mediante puntos suspensivos. Se recuperan los demás con macros especiales definidas en este header.
Lamentablemente las macros declaradas en el header no permiten la creación de funciones sin argumentos fijos. Existe otro paquete de argumentos variables, definido en varargs.h, que sí lo permite; pero que no está comprendido en el estándar ANSI C y que no es compatible con .
#include <stdarg.h>
int sumar(int cuantos, ...)
{
va_list ap;
int suma=0;
va_start(ap, cuantos);
for(i=0; i<cuantos; i++)
suma += va_arg(ap, int);
va_end(ap);
return suma;
}
Que se utilizaría como:
main()
{
printf("Resultado 1: %d\n", sumar(3, 4, 5, 6));
printf("Resultado 2: %d\n", sumar(2, 100, 2336));
}
Esta zona de la Biblioteca, declarada en los headers y , provee indispensables herramientas de debugging. La variable externa es un entero que toma un valor de acuerdo a condiciones de error provocadas por cualquiera de las funciones de la Biblioteca Standard, y de acuerdo a una catalogación de errores que depende de la función. Si una función ANSI C devuelve un valor indicador de error (como NULL donde debería devolver un puntero, o negativo donde debería devolver un positivo), la variable contendrá más explicación sobre el motivo del error. Se consulta con las funciones o . La función admite una cadena arbitraria para indicar, por ejemplo, el lugar del programa donde se produce el error. Imprimirá esta cadena más la descripción del problema.
#include <errno.h>
if(open("noexiste", O_RDONLY) < 0)
perror("Error en apertura");
La macro sirve para detener la ejecución cuando se alcanza un estado imposible para la lógica del programa. Para usarla adecuadamente es necesario identificar invariantes en el programa (condiciones que jamás deban ser falsas). Si al evaluarse la macro resulta que su condición argumento es falsa, aborta el programa indicando nombre del archivo fuente y línea donde estaba originalmente la llamada. Un programa en producción no debería fallar debido a .
#include <assert.h>
...
assert(restantes >= 0);
Existen dos tipos de datos definidos en el header para manejar datos de fechas. Por un lado, se tiene el tipo , que contiene los siguientes elementos que describen un momento en el tiempo:
struct tm {
int tm_sec, /* segundos 0..59 */
tm_min, /* minutos 0..59 */
tm_hour, /* horas 0..23 */
tm_mday, /* día del mes 1..31 */
tm_mon, /* meses desde enero 0..11 */
tm_year, /* años desde 1900 */
tm_wday, /* días desde el domingo 0..6 */
tm_yday, /* días desde enero 0..365 */
tm_isdst; /* flag de ahorro diurno de luz */
};
Por otro lado, existe un segundo formato de representación interna de fechas, , que es simplemente un entero conteniendo la cantidad de segundos desde el principio de la era UNIX (), acaecido el 1/1/1970 a la hora 0 UTC. Este formato es el usado por la función que da la hora actual. Este formato entero puede convertirse a y viceversa con funciones definidas en esta zona de la Biblioteca Standard, como y .
El contenido de una estructura se puede imprimir en una gran variedad de formatos, con la función , que acepta una cantidad de especificaciones al estilo de . Las funciones y son más sencillas. Devuelven una cadena conteniendo una fecha en formato normalizado (como el que aparece en los mensajes de correo electrónico). La primera recibe un puntero a ; la segunda, un puntero a .
time_t t;
struct tm *stm;
t = time(NULL); /* recoge la hora actual */
printf("%s\n",ctime(&t)); /* la imprime en formato standard */
char area[100];
stm = gmtime(&t); /* convierte t a struct tm */
strftime(area,sizeof(area),"%A %b %d %H",stm); /* prepara string según formato del usuario */
printf("%s\n",area); /* lo imprime */
El header declara las habituales funciones aritméticas avanzadas, trigonométricas y logarítmicas. Todas ellas se presentan en tres versiones, que reciben y devuelven diferentes tipos de datos, y cuyos nombres están estructurados de acuerdo a dichos tipos de datos:
La versión básica recibe argumentos , y devuelve un valor . Por ejemplo, .
Una versión de precisión limitada, donde se agrega una al nombre de la función, que recibe argumentos , y devuelve un valor . Por ejemplo, .
Una versión de precisión extendida, donde se agrega una al nombre de la función, que recibe argumentos , y devuelve un valor . Por ejemplo, .
En el Cuadro [tab:trig] se enumeran las versiones básicas. Aunque sólo se ejemplifica para sin(x), todas las funciones matemáticas de la Biblioteca Standard presentan las tres versiones. Como siempre, se recomienda consultar los manuales de estas funciones.
El header agrupa las declaraciones de varias funciones no relacionadas entre sí, y que sirven a varios fines. Solamente las nombramos, y encarecemos la lectura del manual.
Funciones de conversión: las funciones , , , , , , toman cadenas representando números y generan los elementos de datos del tipo correspondiente.
Se pueden generar números aleatorios con y .
Aquí también se declaran las funciones de asignación de memoria como , , , .
Para manejar datos en memoria con eficiencia se puede recurrir a y , que ordenan una tabla y realizan búsqueda binaria en la tabla ordenada.
El header contiene declaraciones de macros para averiguar la pertenencia de un carácter a determinados conjuntos (Cuadro [tab:macros]). Son todas booleanas, salvo las últimas, que devuelven ints.
Utilizar la función de cantidad variable de argumentos definida más arriba para obtener los promedios de los 2, 3, ..., n primeros elementos de un arreglo.
Construir una función de lista variable de argumentos que efectúe la concatenación de una cantidad arbitraria de cadenas en una zona de memoria provista por la función que llama.
Construir una función de cantidad variable de argumentos que sirva para imprimir, con un formato especificado, mensajes de debugging, conteniendo nombres y valores de variables.
Construir un programa que separe la entrada standard en palabras, usando las macros de clasificación de caracteres. Debe considerar como delimitadores a los caracteres espacio, tabulador, signos de puntuación, etc.
Dadas dos fechas y horas del día, calcular su diferencia. Utilizar las funciones de Biblioteca Standard para convertir a tipos de datos convenientes e imprimir la diferencia en años, meses, días, horas, etc.
Generar fechas al azar dentro de un período de tiempo dado.
Construir un paquete de administración de archivos en formato CSV, escribiendo:
Una función de inicialización que lea la cabecera del archivo, reconozca los nombres de campos y construya una lista con ellos; y prepare una tabla de registros en memoria, vacía.
Una función que lea la siguiente línea del archivo y asigne los valores a un nuevo registro en la tabla de registros en memoria.
Una función que ordene alfabéticamente la tabla de registros en memoria según un campo, dado por su nombre.
Una modificación a la función anterior que permita diferenciar entre campos alfanuméricos y numéricos según el nombre del campo (por ejemplo, si el nombre del campo comienza con , determinar que se trata de un campo numérico), y ordene la tabla aplicando la comparación adecuada entre registros.
Una función que busque una cadena en un campo alfanumérico y devuelva el índice del registro donde se la encuentre.
Una función que sume los contenidos en un campo numérico.
Una función que reciba un nombre de campo y un parámetro numérico o alfabético, y devuelva el índice del primer registro donde se halle un valor mayor o igual que el parámetro para ese campo, asumiendo que la tabla de registros está ordenada.
Una función que grabe el archivo con las modificaciones a los registros que hubieran ocurrido.
Una vez probadas las funciones, escriba un programa que pueda funcionar como utilitario de línea de comandos para que el usuario administre sus archivos CSV.
Dedique algo de tiempo a considerar la mejor estructura de datos posible de acuerdo a las funciones que debe contener el paquete (por ejemplo, considere si es preferible un arreglo bidimensional definido estáticamente, un arreglo lineal definido estáticamente de punteros a espacio asignado dinámicamente, una lista asignada dinámicamente de punteros a espacio asignado dinámicamente, etc.).