El lenguaje de programación C (Explicación de las transparencias) (C) Javier Miranda 1996-2004 Reservados todos los derechos Esta colección de notas es un material complementario de la colecci’on de transparencias. Forma parte del contenido del libro “Diseño de Software con C y UNIX (Volumen 1)”, J.Miranda (1996), con ISBN 84-87526-45-4. 18 de febrero de 2004 1 18 de febrero de 2004 Notas 1 4. 4.1. El lenguaje de programación C Historia de C El nacimiento de C está muy ligado al nacimiento de Unix en los laboratorios Bell. En 1968 Thompson creó un grupo para investigar alternativas al sistema operativo Multics y desarrolló Unix sobre un computador DEC PDP-7 (8k-18bit palabras de memoria, sin software disponible). Escribió la versión original de Unix mediante macros y un ensamblador cruzado (cargando el ejecutable mediante una cinta). De esta forma desarrolló: el núcleo primitivo de Unix, un editor básico, un ensamblador sencillo que generaba siempre el ejecutable en el fichero “a.out”, un interprete de comandos básico y los comandos básicos del S.O. (rm, cat, cp). A partir de este momento ya pudo escribir los programas directamente en Unix. En 1969 Thompson intenta utilizar FORTRAN para escribir el S.O., pero lo abandona rápidamente y crea el lenguaje B (simplificación del lenguaje BCPL). B es un lenguaje para programación de sistemas, pequeño, con descripción compacta, fácilmente traducible y cercano a la máquina. Sin embargo, Thompson comprobó que B no era adecuado para reescribir Unix y sus utilidades. En 1970 Thompson continuó el desarrollo de Unix sobre DEC PDP-11 (24k), y reescribió Unix en ensamblador del PDP-11 utilizando 12k para el núcleo de Unix y 12k como disco RAM. En 1971 Unix comenzó a tener usuarios (que programaban en ensamblador con rutinas de biblioteca). De esta forma Steve Johnson creó yacc. Este mismo año Thompson modificó B y creó el lenguaje NB (introduciendo básicamente los tipos de datos int, char, los punteros y los arrays). En 1972 introdujeron en el lenguaje un preprocesador y realizaron pequeñas modificaciones en la evaluación de las expresiones e introdujeron nuevos operadores. En 1973 se creó C, Thompson reescribió Unix en C e instaló C en otras arquitecturas (Honeywell 635, IBM 360/370) y Lesk desarrolló un módulo de E/S estándar. En 1978 Kernighan y Ritchie publicaron el libro The C programming language. (Kernighan se encargó de la exposición de C y Ritchie de la interfaz con Unix y el apéndice — manual de referencia técnica de C—) y Steve Johnson desarrolló pcc (un compilador de C fácilmente transportable). En 1979 Johnson crea lint adaptando pcc. En 1983 se crea el comite X2J11 para la creación del estándar de C. En 1989 C se convierte en estándar ISO/IEC 9899-1990 A partir de C se han desarrollado los siguientes lenguajes: Objetive C [Cox, 86], C++ [Stroustrup, 86], C Concurrente[Gehani, 89], C* [Thinking, 90], C Concurrente Tolerante a fallos [Cmelik, Gehani, Roome]. 18 de febrero de 2004 Notas 2 4.2. El compilador de C El compilador de C consta internamente de 4 programas: el preprocesador, el compilador, un ensamblador y un enlazador. El preprocesador se encarga de eliminar los comentarios del programa y de interpretar sus comandos. Tras el preprocesador se ejecuta el compilador, que genera un fichero equivalente en ensamblador. Este fichero se ensambla para generar el fichero objeto (.o). Finalmente el enlazador combina todos los ficheros objeto especificados en la lı́nea de comando (incluyendo los ficheros objeto de las bibliotecas) y genera el fichero ejecutable. Ejemplos: 1. Llamar al preprocesador, compilador, ensamblador, enlazador y generar el ejecutable en el fichero a.out. cc ejemplo.c 2. Llamar al preprocesador, compilador y ensamblador para generar los ficheros objeto asociados a los ficheros main.c, getline.c e index.c. A continuación llamar al enlazador pasandole los tres ficheros objeto para generar el fichero ejecutable myprog. cc main.c getline.c index.c -o myprog 3. Llamar al preprocesador, compilador y ensamblador para generar el fichero objeto asociado al fichero main.c. A continuación llamar al enlazador pasandole los tres ficheros objeto para generar el fichero ejecutable. cc main.c getline.o index.o 18 de febrero de 2004 Notas 3 4.3. El preprocesador El preprocesador de C es un procesador de macros simple que conceptualmente procesa el fichero fuente de un programa C antes de su compilación. Se controla mediante lı́neas de comandos (lı́neas del programa fuente C) que pueden ir en cualquier parte del fichero fuente y deben cumplir las siguientes reglas: 1. Deben comenzar comienzan con el carácter #. 2. Cada orden debe ocupar una lı́nea. 4.3.1. Macros El comando #define introduce una definición de macro. Una macro asocia un identificador a una cadena (string). El preprocesador sustituye las ocurrencias de dicho identificador por la cadena de reemplazo (sustituyendo los parámetros formales que aparezcan en el cuerpo de la macro por el valor de los parámetros proporcionado en el momento de la invocacion de la macro). El ámbito de las macros va desde el punto de su definición hasta el final del fichero. Las macros pueden declararse desde la lı́nea comandos (cuando se invoca el compilador). Por ejemplo: cc -DM68000=1 file.c . . . define la macro M68000 con el valor 1 antes de compilar el fichero file.c. El comando #undef borra la definición de una macro previamente definida. 18 de febrero de 2004 Notas 4 4.3.2. Compilación condicional La compilación condicional se utiliza para incorporar u omitir de forma selectiva una secuencia de sentencias. Por convenio los ficheros que se incluyen tienen el sufijo .h. Se utiliza para incluir sentencias de depuración de los programas C y para aumentar la portabilidad de los programas. 4.3.3. Inclusión de ficheros El comando #include hace que el contenido del fichero especificado se procese como si estuviese ubicado en el lugar del comando. Existen dos formas de nombrar el fichero, y difieren en donde está almacenado el fichero especificado. #include "fichero.h" /* Busca en el directorio actual */ #include <fichero.h> /* Busca el sitio estandar /usr/include */ Este comando se utiliza generalmente para acceder a grupos de macros. De esta forma, varios ficheros ven las mismas constantes. Sin embargo, debe tenerse especial cuidado con los posibles bucles y duplicaciones de macros. La forma correcta de evitar estos problemas consiste en utilizar el comando #ifndef de la siguiente forma: #ifndef MAX #define MAX 50 /* Resto del fichero de definiciones */ #endif 18 de febrero de 2004 Notas 5 4.4. Introducción a C Veamos con un ejemplo algunas de las caracterı́sticas del lenguaje C: 1. C es un lenguaje de formato libre (excepto los comandos del preprocesador). 2. Los comentarios pueden ponerse en cualquier sitio donde pueda ponerse un espacio en blanco. Comienzan mediante /* y terminan con */. 3. Es un lenguaje sensible a mayúsculas y minúsculas. Por convenio se suele programar utilizando MAYUSCULAS para las CONSTANTES y minúsculas para las variables. 4. Los identificadores de C estan formados por una secuencia de letras, digitos y . El primer carácter debe ser una letra o . 5. Las palabras reservadas de C son: PALABRAS CLAVE (en minuscula) ----------------------------auto double int struct break else long switch case enum register typedef char extern return union const float short unsigned continue for signed void default goto sizeof volatile do if static while - Algunas implementaciones tambien reservan fortran y asm. - const, signed y volatile son palabras nuevas de C-ANSI - enum y void son nuevas con respecto a la primera version de C. 6. Los programas C se estructuran mediante funciones. Los parámetros se pasan siempre por valor. La función main es necesaria en todo programa (ya que es la primera en ejecutarse) y puede tener parámetros (los veremos en la sección 4.13.5). 7. Las variables pueden inicializarse en la declaración. 8. La finalización del programa ocurre cuando: Se finaliza main. Se ejecuta una sentencia exit(). Ocurre una interrupción del programa (externa, interna). 18 de febrero de 2004 Notas 6 4.5. Tipos de datos C tiene cuatro tipos de datos básicos: carácter (char), entero (int), flotante de simple precisión (float) y flotante de doble precisión (double). Además tiene tres cualificadores que pueden aplicarse a los tipos básicos para ajustar la precisión de los tipos básicos: short, long y unsigned (aritmética módulo 2N , siendo N el número de bits). Los rangos están definidos por la implementación y son especificados en el fichero <limits.h>. Si se utiliza un cualificador (short, long, unsigned) y se omite el tipo, se presupone que la variable es de tipo int. long int factorial; unsigned int natural; signed char c; /* -127..127 */ short int dia,mes; 4.6. Constantes Constantes tipo carácter. ascii \’{A}’ octal ’\101’ hex ’\x41’ Constantes tipo string. Son una secuencia de caracteres entre comillas dobles. Las strings adyacentes son automáticamente concatenadas en una única string. C utiliza el carácter null (’\0’) como terminador de strings. Constantes tipo entero. El tipo de una constante entera depende de su forma, valor y sufijo1 . decimal octal (comienzan por 0) hex 1234 015 0x4f 12L 015L 0x4f3 12U 015U Constantes tipo float. Su sintaxis es: int.fracción [ E [+|-]int[F|L], significando los sufijos F y L float y long double respectivamente. Puede omitirse: • La parte entera o la parte fraccionaria (pero no ambas). • El punto decimal o el exponente (no ambos). 1 Si no tiene sufijo adopta el primero de los siguientes tipos: si es decimal adopta int, long int, unsigned long int; si es octal o hex adopta int, unsigned int, long int, unsigned long int. 18 de febrero de 2004 Notas 7 4.7. Operadores y expresiones Aritméticos: Debe tenerse en cuenta que la división entera trunca, y que el resto (operador módulo) adopta el mismo signo que el dividendo. Toda la aritmética entera se realiza al menos con rango int (short int es convertido automáticamente en int, lo que se denomina promoción integral). En expresiones mixtas se realiza conversión automática al mayor tamaño. Relacionales. Adoptan los valores 0 para falso y 1 para verdadero. Lógicos. Autoincremento, autodecremento. Existen cuatro posibilidades: ++variable; Preincremento; --variable: Predecremento variable++: Postincremento; variable--: Postdecremento Cuidado con los efectos laterales. Asignación. Existen dos tipos de asignación: 1. Asignación múltiple. Operador asociativo por la izquierda que realiza conversiones de tipo implı́citas. a=b=c+d ===> a=(b=(c+d)) 2. Asignación compuesta (a op= b). Puede utilizarse con: • Cualquiera de los operadores aritméticos. • Todos los operadores de nivel de bit excepto el complemento a 1. La asignación compuesta tiene las siguientes ventajas: a) Concisa. b) Se corresponde mejor con la forma en que piensa la gente. Decimos suma 2 a i o incrementa i en 2, no toma i, súmale 2 y vuelve a colocar el resultado en i. c) En expresiones complicadas hace el código más fácil de comprender, ya que el lector no necesita comprobar si dos expresiones complicadas son realmente la misma. Por ejemplo: yyval[yypv[p3+p4] + yypv[p1+p2]] += 2 d ) Puede ayudar al compilador a producir un código más eficiente. Expresión condicional: Evalúa la expresión e1. Si es cierto, evalúa la expresión e2 y toma el resultado como el valor de la expresión condicional; si es falso, evalúa la expresión e3 y toma este resultado como el valor de la expresión condicional. Operador coma: Evalúa la primera expresión (comenzando por la izquierda) y descarta el resultado; a continuación evalúa la siguiente expresión y toma este valor como el resultado de la expresión. Operador sizeof(): Operador unitario que devuelve un entero igual al tamaño en bytes de cualquier objeto. 18 de febrero de 2004 Notas 8 Precedencia de operadores (ver tabla). Puede obviarse si no se evitan los paréntesis. Conversión de tipos. Se puede forzar la conversión explı́cita del tipo çoaccionada”de una expresión mediante una construcción denominada cast. El operador cast tiene la misma precedencia que cualquier otro operador unitario2 . 4.7.1. Orden de evaluación de expresiones C no especifica en que orden se evalúan los operandos de un operador. Por ejemplo, en una sentencia como x = f ()+g(), f puede ser evaluado antes que g o viceversa. Entonces, si f o g alteran alguna variable externa, de la cual depende la otra función, x puede depender del orden de evaluación. Tampoco especifica el orden de evaluación de los argumentos de una función esta especificado. Asi, la sentencia: printf ("%d %d\n", ++n, f(n) ); puede producir diferentes resultados en distintas máquinas, según que n sea incrementado antes o despues de la llamada a f (). La solución consiste en escribir ++n; printf ("%d %d\n", n, f(n) ); 2 Existe un punto oscuro en la conversión de caracteres a enteros. El lenguaje no especifica si las variables de tipo char tienen signo o no. De esta forma, dependiendo de las caracterı́sticas de la arquitectura de cada máquina puede producir un entero negativo o no. 18 de febrero de 2004 Notas 9 4.8. Control de flujo Todas las sentencias de control de flujo de C estan asociadas a una única sentencia. Si se desea asociarlas a un bloque debe crearse un nuevo ámbito (mediante { }). Sentencias condicionales 1. IF: El resultado de evaluar la expresión logica es un entero donde cualquier valor diferente de 0 se interpreta como verdadero. Se asocia else al if más interno. 2. SWITCH: Todas las alternativas deben ser diferentes. Debe combinarse con la sentencia BREAK. Sentencias iterativas 1. WHILE. 2. DO-WHILE. 3. FOR. for (expr1; expr2; expr3) sentencia; === expr1; while (expr2) { sentencia; expr3; } Puede omitirse cualquiera de las expresiones. Sentencias de bifurcación 1. BREAK: Sale de la sentencia SWITCH, WHILE o FOR más interna. 2. CONTINUE: Salta a reevaluar la condición del bucle más interno. 3. GOTO: Salto incondicional. 18 de febrero de 2004 Notas 10 4.9. Funciones Un programa C consta de 1 o más funciones. Un programa completo C tiene una función denominada main(). C no permite definir funciones dentro de funciones. La finalización de la función ocurre cuando se llega al final de la función (}) o se ejecuta una sentencia return(). Los parámetros se pasan siempre por valor. Retornan por defecto un valor de tipo entero. Las funciones que no devuelven nada se definen de tipo void. Las funciones C pueden ser recursivas. 18 de febrero de 2004 Notas 11 4.10. Variables Las variables de C pueden ser: Externas: Son las que se definen fuera de cualquier función y, por tanto, son potencialmente utilizables por muchas funciones. El campo de validez de una variable externa abarca desde el punto de su declaración en un archivo fuente hasta el fin del archivo. Si se ha de hacer referencia a una variable externa antes de su definición o si está definida en un archivo fuente que no es aquel en que se usa, es obligatoria una declaración extern. Automáticas: Son las definidas dentro de las funciones. Estáticas: Las variables estáticas (static) pueden ser internas o externas: • Las variables estáticas internas son locales a una función en la misma forma que las automáticas pero, a diferencia de ellas, su existencia es permanente, en lugar de aparecer y desaparecer al activar la función. Esto significa que las variables estáticas internas proporcionan un medio de almacenamiento permanente y privado a una función. • Las variables estáticas externas son accesibles en el resto del archivo fuente en el que está declarada, pero no en otro. Por tanto, el almacenamiento estático externo proporciona un medio de almacenamiento permanente y privado a un archivo (de hecho, no habrá conflictos con los mismos nombres en otros archivos del mismo programa). Registro: Una declaración register avisa al compilador que la variable será muy usada. Cuando es posible se colocan en los registros de la máquina, lo que produce programas más cortos y rápidos. Existen algunas restricciones en las variables registro, que reflejan la realidad del hardware subyacente: • Pocas variables de cada función se pueden mantener en registros. • Sólo se permiten algunos tipos. • La palabra register se ignora si hay declaraciones excesivas o no permitidas. • No es posible tomar la dirección de una variable registro. Las variables de C pueden definirse en una forma estructurada en bloques. Las declaraciones de variables (incluyendo inicializaciones) se colocan despues de la llave de apertura que introduce cualquier sentencia compuesta, no solamente al comienzo de una función. Las variables declaradas ası́ se solapan con cualquier variable del mismo nombre en bloques externos, y permanecen hasta que se alcanza la llave de cierre. 18 de febrero de 2004 Notas 12 4.10.1. Reglas de inicialización de variables En ausencia de una inicializacion explı́cita, se garantiza que las variables externas y estáticas tendran inicialmente el valor cero. Las variables automáticas y registro tienen valores indefinidos (”basura”). Las variables externas y estáticas sólo pueden inicializarse mediante una expresión constante; la inicialización se realiza una sola vez, conceptualmente antes de que comience la ejecución del programa. Las variables automáticas y registro pueden inicializarse a valores no constantes (cualquier expresión, incluyendo llamadas a funciones), y se inicializan cada vez que se entra en la función o bloque. 18 de febrero de 2004 Notas 13 4.11. Entrada/salida La entrada/salida básica de caracteres se realiza mediante dos macros definidas en <stdio.h>. Son: putchar(char c) int getchar() La entrada/salida de strings se realiza mediante las funciones: gets(char *linea) puts(const char *linea) La entrada/salida formateada se realiza mediante las funciones: printf(char *formato, arg1, arg2, ...) scanf(char *formato, arg1, arg2, ...) La conversión de strings a tipos básicos y viceversa se realiza mediante las funciones: sprintf(char *s, const char *format, ...) sscanf(char *s, const char *format, ...) 18 de febrero de 2004 Notas 14 4.12. 4.12.1. Estructuras de datos Sinónimo (alias) C permite crear sinónimos de tipos de datos. Las principales razones para utilizar este tipo de declaraciones son: 1. Facilitar la documentación (y legibilidad) de un programa. 2. Asociar nombres más cortos a identificadores largos. 3. Parametrizar un programa contra problemas de portabilidad. Si se utiliza typedef con los tipos de datos que pueden ser dependientes de la instalación, sólo se tendrán que cambiar los typedef cuando se lleve el programa a otro computador. Una práctica común es emplear typedef para las cantidades enteras y luego hacer las elecciones apropiadas de short, int y long en cada computadora. 4. Utilizar algún programa que aproveche la informacion contenida en las declaraciones typedef para realizar comprobaciones de tipos en el programa (por ejemplo, lint). 4.12.2. Enumerado A menos que se le asignen valores explı́citos, el compilador asigna valores enteros constantes sucesivos comenzando por cero. La declaración de un enumerado tiene las siguientes ventajas frente a las declaraciones equivalentes mediante macros: 1. El compilador puede detectar errores en las expresiones y asignaciones. 2. El depurador puede imprimir el valor simbólico (en vez del valor entero). 18 de febrero de 2004 Notas 15 4.12.3. Array Declaración: Existen dos formas de declaración de arrays: 1. Explı́cita: cuando se especifica el número de elementos del array. 2. Implı́cita: cuando el número de elementos del array lo calcula el compilador. Los arrays multidimensionales se declaran mediante notación de vectores (no matricial) y se almacenan por filas. Acceso. El ı́ndice de acceso del array es una expresión entera. Es responsabilidad del programador garantizar que el valor del ı́ndice está dentro de los lı́mites del array. 18 de febrero de 2004 Notas 16 4.12.4. Estructura (Registro) Declaración. Una estructura es un conjunto de una o más variables, posiblemente de tipos diferentes, agrupadas bajo un mismo nombre para hacer más eficiente el manejo. Las estructuras se denominan registros en otros lenguajes; por ejemplo, en Pascal. • Opcionalmente puede seguir un nombre a la palabra clave struct; se lo denomina nombre de la estructura y se puede emplear en declaraciones posteriores como una abreviatura de la estructura. • La llave de cierre que termina la lista de miembros puede ir seguida de una lista de variables. • Si la declaración de una estructura no va seguida de la lista de variables, no se reserva memoria alguna; en este caso se está describiendo una plantilla de la estructura. • Se puede inicializar una estructura externa o estática añadiendo a su definición la lista de inicializadores de los componentes. • Las estructuras se pueden anidar. • Podemos declarar arrays de estructuras. Acceso. Para referenciar un miembro de una estructura en una expresión, se emplea la notación punto. nombre_de_la_estructura.miembro 4.12.5. Restricciones de las estructuras Las estructuras no pueden compararse; las únicas operaciones que se pueden realizar con una estructura son copiarlas o asignarlas como un todo, tomar su dirección (mediante &), y acceder a uno de sus miembros. 18 de febrero de 2004 Notas 17 4.12.6. Unión Una unión es una estructura donde todos los miembros tienen desplazamiento cero. La estructura es lo suficientemente grande como para contener el mayor de los miembros, y la alineación es la apropiada para todos los tipos de la unión. Es responsabilidad del programador recordar cual es el tipo que hay en la unión. Las uniones pueden aparecer dentro de estructuras y arreglos, o viceversa. La notación para acceder a un miembro de una unión dentro de una estructura (o viceversa) es idéntica a la empleada con estructuras anidadas. 4.12.7. Campos de bit La forma usual de manejar bits consiste en definir un conjunto de máscaras (en potencias de 2) que corresponden a las posiciones de los bits. El acceso a los bits se convierte en un juego de operaciones de desplazamiento, enmascaramiento y complementación. C posee capacidad para definir y acceder directamente a campos definidos en el interior de una palabra, en lugar de hacerlo mediante máscaras. Un campo es un conjunto de bits adyacentes dentro de un int. La sintaxis de la definición de los campos se basa en las estructuras. Los campos sin nombre (dos puntos y un tamaño solamente) sirven de relleno. 18 de febrero de 2004 Notas 18 4.13. Punteros Un puntero es una variable que contiene la dirección de otra variable. Los punteros se utilizan con abundancia en C, debido a que: A veces son la única manera de expresar un cálculo. Con ellos puede obtenerse un código más compacto y eficiente. C proporciona dos operadores especiales para punteros: El operador unitario * toma su operando como una dirección y accede a ella para obtener su contenido. El operador unitario & devuelve la dirección de un objeto. Sólo puede aplicarse a variables y a elementos de un arreglo; construcciones como &(x + 1) y &3 son ilegales. Tambien es ilegal obtener la dirección de una variable de tipo register. La declaración del puntero p1 se entiende como un nemotécnico; se quiere indicar que la combinación ∗p1 equivale a una variable de tipo int. C permite declarar punteros dobles (punteros a punteros). En general, un puntero se puede inicializar como cualquier otra variable, aunque normalmente los únicos valores significativos son cero (NULL) o una expresión en que aparezcan direcciones de objetos del tipo apropiado. Los punteros pueden aparecer en expresiones. Los operadores unitarios ∗ y & tienen mayor precedencia que los operadores aritméticos, por lo que al evaluar y = ∗p1 + 1 la expresión toma el valor del objeto al que apunta p1, se le suma 1, y el resultado se asigna a y. También pueden aparecer referencias a punteros en la parte izquierda de una asignación (∗p1 = 0; ∗p1+ = 1). La expresión (∗p1) + + necesita los paréntesis; sin ellos incrementarı́a el valor del puntero p1 y no el objeto al que apunta, ya que los operadores unitarios ∗ y ++ se evalúan de derecha a izquierda. Los punteros se pueden comparar entre ellos o con cero (NULL). Como los punteros son variables, se pueden manipular igual que cualquier otra variable (p2 = p1). Como los parámetros de las funciones se pasan siempre por valor, cuando queremos que la función devuelva valores mediante los parámetros tenemos que pasar punteros. 18 de febrero de 2004 Notas 19 4.13.1. Punteros y arrays En C existe una estrecha relación entre punteros y arrays, ya que cualquier operación que se pueda realizar mediante la indexación de un array se puede realizar también con punteros. La versión con estos puede ser más rápida pero, más difı́cil de entender. Si ptr apunta a un elemento particular de un array, entonces ptr − i apunta al elemento que está i elementos antes de ptr, y ptr + i apunta al elemento que está i elementos después. Si ptr apunta a a[0], entonces ∗(p1 + i) se refiere al contenido de a[i]. Esto es cierto independientemente del tipo de variables del array. La definición de sumar 1 a un puntero, y, por extensión, toda la aritmética de punteros establece que el incremento se adecúa al tamaño en memoria del objeto apuntado. En pa + i, i se multiplica por el tamaño de los objetos a los que apunta pa antes de ser sumado a p1. Cuando se pasa el nombre de un array a una función, se pasa la dirección del comienzo del array (un puntero). El paso de arrays multidimensionales a una función no requiere especificar la primera dimension (es irrelevante porque lo que se trasmite realmente es, como antes, un puntero). En el ejemplo de la transparencia, la última declaración de la derecha indica que el parámetro es un puntero a un array de 4 enteros. Los paréntesis son necesarios porque los corchetes [ ] tienen mayor precedencia que el operador ∗; sin paréntesis. La declaración int *tabla[4]; serı́a un array de 4 punteros a enteros. 4.13.2. Punteros y estructuras: notación − > Si p es un apuntador a una estructura, los miembros de la estructura se pueden referenciar mediante (*pd).miembro (hacen falta los paréntesis porque la precedencia del operador de miembro de estructuras es mayor que la de *). Sin embargo, como son tan frecuentes los apuntadores a estructuras se ha introducido en el lenguaje la notación abreviada − >, que es equivalente. 4.13.3. Punteros a funciones En C es posible definir un puntero a una función, que puede ser manipulado, pasado a funciones, colocado en arrays, etc. La declaración de un puntero a función tiene la siguiente sintaxis: tipo (*nombre_puntero)() Es necesaria la primera pareja de paréntesis. Sin ellos estarı́amos haciendo una declaración de una función que devuelve un puntero a un entero. La llamada se realiza mediante: (*nombre_puntero)(parametros) 18 de febrero de 2004 Notas 20 4.13.4. Resumen de aritmética de punteros En resumen, las operaciones aritméticas que podemos realizar con los punteros de C son las siguientes: Pueden inicializar como cualquier otra variable, aunque normalmente los únicos valores significativos son 0 (NULL) o una expresión en que aparezcan direcciones de objetos del tipo apropiado. Puede sumarse o restarse un entero a un puntero. Pueden compararse. Pueden restarse punteros. No se permite sumar, restar, multiplicar, dividir, rotar, enmascarar punteros, ni sumarles valores float o double. 18 de febrero de 2004 Notas 21 4.13.5. Parámetros de la lı́nea de comandos Cuando se invoca main al comienzo de la ejecución, se llama con dos parámetros. 1. El primero (llamado argc por convenio) es el número de argumentos en la lı́nea de comandos con que se invocó al programa. 2. El segundo (argv) es un puntero a un array de cadenas de caracteres que contienen los argumentos, uno por cadena. Por convenio, argv[0] es el nombre con el que se invoco el programa; por tanto, argc es como mı́nimo 1 (el primer argumento real es argv[1] y el último es argv[argc-1]). Debido a que argv un puntero a un array de punteros, existen varias maneras de manipular los parámetros. 18 de febrero de 2004 Notas 22 4.14. Tratamiento de ficheros Para manejar ficheros con formato en C necesitamos punteros a una estructura de datis definida en el fichero <stdio.h> denominada FILE. Las principales funciones C para tratamiento de ficheros son: 1. Apertura, cierre, renombrado y borrado: FILE *fopen(char *nombre, char *modo) El modo de apertura puede ser: • r: modo lectura. • w: modo escritura (crea el fichero si no existe y descarta su contenido en caso de que ya existiese). • a: modo añadir (si no existe lo crea). Los modos r+, w+ y a+ tienen el mismo significado que sus homólogos r, w, a, pero se permite tanto la lectura como la escritura. Si el modo incluye una b (rb, r+b), significa que es un fichero binario. En caso de error retorna un puntero NULL. int fclose(FILE *f) int rename(const char *antiguo nombre, const char *nuevo nombre) int remove(const char *nombre fichero) 2. Transferencia: int fgetc(FILE *f) int fputc(int c, FILE *f) char *fgets(char *linea, int max linea, FILE *f) int fputs(char *linea, FILE *fp) int fscanf(FILE *f, char *format, ....) int fprintf(FILE *f, char *format, ....) 3. Posicionamiento: int fseek(FILE *f, long despl, int posicion) long ftell(FILE *f) void rewind(FILE *f); 4. Funciones de error: int ferror(FILE *f) int feof(FILE *f) Existen tres punteros de tipo FILE que son estándar y están definidos en <stdio.h>: stdin, puntero a la entrada estándar, stdout, puntero a la salida estándar y stderr, puntero a la salida estándar de errores. Las funciones getchar() y putchar() son macros definidas en <stdio.h> de la siguiente forma: #define getchar() getc(stdin) #define putchar(x) putc(x,stdout) 18 de febrero de 2004 Notas 23 4.15. Errores frecuentes de los programadores de C En este último apartado se presentan los errores que comenten con más frecuencia los nuevos programadores de C. 18 de febrero de 2004 Notas 24 18 de febrero de 2004 Notas 25 18 de febrero de 2004 Notas 26 18 de febrero de 2004 Notas 27 4.16. Programación de estructuras de datos dinámicas con C La combinación de estructuras (registros) y punteros permiten crear estructuras cuya forma y tamaño no sea fija: estructuras de datos dinámicas. La unidad básica de las estructuras dinámicas es el nodo (registros que son enlazados mediante punteros para formar la estructura deseada). En términos generales las estructuras se pueden agrupar en tres clases: estructuras lineales, estructuras jerárquicas, y grafos3 . La declaración de un nodo se realiza mediante una declaración recursiva (una estructura que se autoreferencia —ver transparencia—). El miembro next es una variable de tipo puntero que contendrá la dirección de otro nodo. Para utilizar las estructuras dinámicas es necesario solicitar memoria (mediante malloc()) y liberar memoria (mediante free()). Para ello es necesario utilizar las rutinas definidas en el fichero stdio.h. En las siguientes transparencias veremos cómo podemos implementar en C las siguientes estructuras dinámicas: Pilas. Colas. Listas simplemente encadenadas. Listas doblemente encadenadas. Arboles. 3 Un grafo es una generalización de una estructura dinámica en la que cada nodo puede tener varios enlaces, y pueden existir bucles 18 de febrero de 2004 Notas 28 4.16.1. Pila (LIFO) Una pila es una estructura de datos que consta de dos operaciones: push() para insertar un elemento y pop() para extraer el último elemento insertado. Por esta razón las pilas también se denominan estructuras LIFO (del inglés Last In First Out). Las pilas pueden declararse mediante arrays. Esto tiene el inconveniente de que necesitamos reservar el espacio máximo necesario para ella. En la transparencia se presenta la implementación en C de una pila dinámica de datos de tipo entero. Utilizamos la variable global Top para apuntar a la cima de la pila. El algoritmo de las funciones push() y pop() es el siguiente: Push(): • Creamos un nuevo nodo. • Rellenamos la información del nuevo nodo. • Actualizamos el puntero de cima de pila al nuevo nodo. Pop(): • Si la pila está vacı́a retornamos un código de error. • Guardamos en una variable auxiliar la información del nodo que está en la cima de la pila. • Guardamos en un puntero auxiliar la dirección del nodo que está en la cima de la pila. • Actualizamos el puntero de cima de pila al siguiente nodo. • Liberamos el nodo que estaba en la cima de la pila. • Retornamos la información que contenı́a el nodo que hemos liberado. 18 de febrero de 2004 Notas 29 4.16.2. Cola Una cola es una estructura de datos que consta de dos operaciones: una para insertar un elemento al final de la cola y otra para para extraer el elemento que está al principio de la cola. Por esta razón las pilas también se denominan estructuras FIFO (del inglés First In First Out). En esta implementación utilizamos las variables globales principio y final para apuntar al primer y último elemento de la cola. El algoritmo de las funciones insertar() y extraer() es el siguiente: Insertar() • Creamos un nuevo nodo. • Rellenamos la información del nuevo nodo. • Si la cola está vacı́a actualizamos los punteros primero y siguiente al nuevo nodo. • Si la cola no está vacı́a insertamos el nuevo nodo al final de la cola. Extraer() • Si la cola está vacı́a retornamos un error. • Guardamos en variables auxiliares la información del primer elemento de la cola (el nodo que vamos a liberar) y la dirección del segundo elemento de la cola (que va a pasar a ser el nuevo primer elemento de la cola). • Liberamos la memoria del primer elemento. • Actualizamos el puntero al nuevo primer elemento de la cola. • Si la cola está vacı́a (el primer elemento es NULL), actualizamos el puntero al último elemento de la cola a NULL. • Retornamos la información del nodo que hemos extraido. 18 de febrero de 2004 Notas 30 4.16.3. Lista simplemente encadenada La implementación de una lista simplemente encadenada es similar a cola, pero se diferencia en que los elementos se insertan y extraen de forma ordenada. 18 de febrero de 2004 Notas 31 . Lista simplemente encadenada (cont.) La función de busqueda recibe un parámetro de entrada (el dato a buscar —n—) y retorna dos parámetros de salida: 1. El nombre de la función retorna un puntero al nodo donde se encontró el dato. 2. El parámetro de salida anterior es un puntero al nodo que hay antes (siguiendo el orden de la lista) del nodo donde se encontró el dato. De esta forma podemos utilizar la función buscar con dos fines: 1. Saber la información solicitada está en la lista (ya que la función retorna NULL cuando la clave no está en la lista). 2. Facilitar la extracción del nodo (ya que la función retorna las direcciones del nodo anterior al buscado y la dirección del nodo buscado, con lo que solamente necesitamos actualizar los punteros —observese la función Borrar en la transparencia—). Si anterior vale NULL significa que la clave se encontró en el primer nodo de la lista. 18 de febrero de 2004 Notas 32 4.16.4. Lista doblemente encadenada En una lista doblemente encadenada cada nodo tiene dos punteros: un puntero al siguiente nodo (en orden) de la lista, y un puntero al nodo anterior. Como puede apreciarse en la transparencia, la función de inserción es similar a la función de inserción en una lista simplemente encadenada (sólo hay que tener cuidado en la actualización del nuevo puntero). 18 de febrero de 2004 Notas 33 4.16.5. Arbol binario El arbol binario es probablemente la estructura dinámica más utilizada. Cada nodo de la estructura tiene un predecesor (padre o ancestro) y uno o dos sucesores (hijo o descendientes). La transparencia contiene el pseudocódigo de un algoritmo de recorrido in-order iterativo del arbol binario. El algoritmo consiste en comenzar profundizando en el arbol sólamente por la rama izquierda de los nodos a la vez que almacenamos en la pila la dirección de estos nodos (porque aún no los hemos procesado). Al llegar a lo más profundo del a’rbol por su rama más izquierda, en la cima de la pila tenemos el primer nodo que debemos procesar. Por lo tanto, extraemos el nodo de la pila, lo procesamos, e intentamos profundizar a partir de su hijo derecho. Si no tiene hijo derecho, sacamos otro nodo de la pila (el padre), lo procesamos e intentamos profundizar por su hijo derecho. Este proceso se repite hasta procesar el arbol completo. El algoritmo recursivo es quizás más fácil de entender. Es básicamente el mismo algorimo, lo que en este caso la pila se implementa de forma automática mediante las llamadas recursivas. 18 de febrero de 2004 Notas 34 . Arbol binario (cont.) La inserción en el arbol binario sigue el siguiente algoritmo: Si el arbol está vacı́o, la raiz es el nuevo nodo que ibamos a insertar. Si el arbol no está vacı́o, buscamos la posición en el árbol donde debemos insertar el nodo y actualizar el puntero correspondiente.