Introducción: Ple - Curso de Programacion en C
Introducción: Ple - Curso de Programacion en C
Introducción: Ple - Curso de Programacion en C
1. INTRODUCCIÓN
1.1. ¿Qué es programar?
¿Programar es difícil?
¿Qué es un algoritmo?
Brevísima introducción a la ingeniería del software
Diagramas de flujo
Pseudocódigo
Reglas de estilo
Programación modular
Funciones y procedimientos
Paso de parámetros
Variables globales y locales. Efectos laterales
2. EL LENGUAJE C
2.1. Los lenguajes de programación
Sintaxis básica de C
Condicionales
Bucles
2.8. Apéndices
Estructuras (structs)
Uniones
Enumeraciones
Tipos definidos por el programador
¿Por qué hay tantas estructuras de datos?
4. ARCHIVOS
4.1. Introducción
Tipos de archivos en C
Flujos
Archivos y buffers
Funciones de apertura y cierre: fopen() y fclose()
Funciones de lectura y escritur: fread(), fwrite(), fgets(), fputs(), etc.
Funciones específicas para acceso directo: fseek() y ftell()
Diferencias entre archivos binarios y de texto
Manipulación de archivos y directorios
5. RECURSIVIDAD
Planteamiento de soluciones recursivas
Normas para aplicar correctamente la recursividad
Ventajas e inconvenientes de las soluciones recursivas
Divide y vencerás
Backtracking
6.5. Pilas
6.6. Colas
7. ASPECTOS AVANZADOS DE LA
PROGRAMACIÓN EN C
Compilación con archivos múltiples: problemas y soluciones
Creación de librerías
Espacios con nombre
El preprocesador
Tipos de almacenamiento
Argumentos en la línea de comandos
Manipulación de bits
Estándares C89 y C99
Caracterísiticas de bajo nivel de C
1 Introducción
¿Programar un ordenador es tan difícil?
Parece, en principio, una tarea imposible. Pero existe un lenguaje que sin duda compartimos:
el de las matemáticas. Según todos los indicios, los principios matemáticos son universales
y, por lo tanto, verdaderos tanto aquí como en la galaxia de Andrómeda. Así que nuestro
extraterrestre debe saber que dos y dos son cuatro, aunque a las ideas de “dos”, “cuatro” y
“más” las llame de otra forma.
Primero, le tendríamos que decir que necesita conocer cuáles son esos diez números, ¿no?
Eso ya se lo diré yo, que para eso soy el autor del hallazgo del extraterrestre. Cuando tenga
los diez números, deberá sumarlos y, el resultado de esa suma, dividirlo entre diez. Así que
esto es lo que le diríamos al extraterrestre, gritando mucho como cuando hablamos con un
extranjero en nuestro idioma:
Para escribir cualquier programa que resuelva un problema es necesario idear previamente
un algoritmo que resuelva ese problema. Sin algoritmo, no existiría el programa, porque un
programa no es más que un algoritmo traducido a un lenguaje comprensible por el
ordenador, esto es, un lenguaje de programación.
1. Inicio
4. Fin
Si se fija usted bien, este algoritmo cumple las tres condiciones enumeradas anteriormente
(precisión, definición y finitud) y resuelve el problema planteado. Lógicamente, al ordenador
no le podemos dar estas instrucciones tal y como las hemos escrito, sino que habrá que
expresarlo en un lenguaje de programación, pero esto es algo que trataremos en otros
posts.
Notación de algoritmos
Los algoritmos deben representarse con algún método que permita independizarlos del
lenguaje de programación que luego se vaya a utilizar. Así se podrán traducir más tarde a
cualquier lenguaje. En el ejemplo que acabamos de ver hemos especificado el algoritmo en
lenguaje español, pero existen otras formas de representar los algoritmos. Entre todas ellas,
destacaremos las siguientes:
1. Lenguaje español
2. Diagramas de flujo
3. Diagramas de Nassi-Schneiderman (NS)
4. Pseudocódigo
Dedicaremos muchos otros post a discutir las técnicas básicas de programación usando
pseudocódigo y, a veces, diagramas de flujo; pero, como adelanto, ahí va el algoritmo que
determina si un número N es par o impar, escrito en pseudocódigo. Es recomendable que le
eche un vistazo para intentar entenderlo y para familiarizarse con la notación en
pseudocódigo:
algoritmo par_impar
variables
N es entero
solución es cadena
inicio
leer (N)
escribir (solución)
fin
Muchos autores recomiendan escribir una primera versión del algoritmo en lenguaje
natural (en nuestro caso, en castellano), siempre que dicha primera versión cumpla dos
condiciones:
que la solución se exprese como una serie de instrucciones o pasos a seguir para
obtener una solución al problema
que las instrucciones haya que ejecutarlas de una en una, es decir, una instrucción cada
vez
Consideremos un problema sencillo: el cálculo del área y del perímetro de un rectángulo.
Evidentemente, tenemos que conocer su base y su altura, que designaremos con dos
variables de tipo real. Una primera aproximación, en lenguaje natural, podría ser:
1. Inicio
5. Fin
Describir un algoritmo de esta forma puede ser útil si el problema es complicado, ya que
puede ayudarnos a entenderlo mejor y a diseñar una solución adecuada. Pero esto sólo es
una primera versión que puede refinarse añadiendo cosas. Por ejemplo, ¿qué pasa si la base
o la altura son negativas o cero? En tal caso, no tiene sentido averiguar el área o el
perímetro. Podríamos considerar esta posibilidad en nuestro algoritmo para hacerlo más
completo:
1. Inicio
4. Si no:
5. Fin
Estos refinamientos son habituales en todos los algoritmos y tienen la finalidad de conseguir
una solución lo más general posible, es decir, que pueda funcionar con cualquier valor de
“base” y “altura”.
Diagramas de flujo
El diagrama de flujo es una de las técnicas de representación de algoritmos más antigua y
también más utilizada, al menos entre principiantes y para algoritmos sencillos. Con la
práctica comprobará que, cuando se trata de problemas complejos, los diagramas de flujo se
hacen demasiado grandes: es como conducir un camión por la zona antigua de su ciudad.
Los símbolos de las cajas están estandarizados y son muy variados. En la tabla siguiente
tiene los que utilizaremos en este blog. Ciertamente, alguien podrá objetar que me he
dejado algunos símbolos en el tintero, y llevará razón si lo hace. Pero cuando se trata de
introducir herramientas prefiero la simplicidad.
Veamos un ejemplo: la representación del algoritmo que calcula el área y el perímetro de un
rectángulo mediante un diagrama de flujo. Antes, tengamos en cuenta que:
los valores de “base” y “altura” los introducirá el usuario del programa a través del
teclado; así, el programa servirá para cualquier rectángulo
después se realizarán los cálculos necesarios
los resultados, “área” y “perímetro”, deben mostrarse en un dispositivo de salida (por
defecto, la pantalla) para que el usuario del programa vea cuál es la solución
Esta estructura en 3 pasos es muy típica de todos los algoritmos: primero hay una entrada
de datos, luego se hacen cálculos con esos datos, y por último se sacan los resultados.
El pseudocódigo utiliza ciertas palabras reservadas para representar las acciones del
programa. Estas palabras originalmente están en inglés (y se parecen mucho a las que luego
emplean los lenguajes de programación), pero por suerte para nosotros su traducción
española está muy extendida entre la comunidad hispanohablante.
inicio
leer (base)
leer (altura)
escribir (área)
escribir (perímetro)
fin
Pero, a lo largo de los años 70, el avance de la tecnología provocó que los ordenadores
tuvieran cada vez más capacidad de cálculo y, por lo tanto, que los programas fueran cada
vez más complejos. Llegó un momento en el que se hizo evidente que ningún ser humano
era capaz de hacer un programa tan complejo que aprovechase todas las posibilidades de
hardware de los ordenadores de esa época. A esto se le llamó la crisis del software, y
estancó la industria informática durante varios años.
El problema era que, hasta entonces, se programaba sin método ni planificación. A nadie se
le ocurriría, por ejemplo, construir un avión sin haber hecho antes, cuidadosamente, multitud
de cálculos, estudios, planos, diseños, esquemas, etc. Pues bien, un programa de ordenador
puede ser tan complejo, o más, que un avión o cualquier otro artefacto industrial, y, por lo
tanto, es necesario construirlo con los mismos procesos de ingeniería.
Surgió así el concepto de ingeniería del software. No me gustan las definiciones, pero a
veces son inevitables. Ahí va una:
Actualmente, los procesos de la ingeniería del software (que son muchos y variados) se
aplican en todas las empresas y organismos en los que se desarrolla software de forma
profesional y rigurosa, porque no hay otro modo de asegurar que el producto se va a
terminar dentro de los plazos y costes previstos, y que éste va a funcionar correctamente y
se va a ajustar a los niveles de calidad que el mercado exige.
El mundo real, por definición, es muy complejo. Cuando pretendemos traspasar una parte de
ese mundo a un ordenador es necesario extraer sólo los aspectos esenciales del problema,
es decir, lo que realmente afecta a esa parte del mundo, desechando todo lo demás. El
proceso de comprensión y simplificación del mundo real se denomina análisis del problema, y
la simplificación obtenida como resultado del análisis se llama modelo.
En este ejemplo, el modelo del tiro oblicuo es muy fácil de construir ya que se basa en
fórmulas matemáticas perfectamente conocidas. Necesitamos conocer algunos datos previos
para que el modelo funcione: la velocidad del proyectil, su masa y su ángulo de salida. Con
eso, nuestro programa podría calcular fácilmente la altura y la distancia que el proyectil
alcanzará. Sin embargo, las áreas de aplicación de la Informática van más allá de la Física,
por lo que la modelización suele ser bastante más difícil de hacer que en este problema.
Por ejemplo, en el programa de facturación de una empresa: ¿qué datos previos necesitamos
conocer? ¿Qué fórmulas o cálculos matemáticos debemos realizar con ellos? ¿Qué resultado
se espera del programa? Estas cuestiones deben quedar muy claras antes de la modelización
porque, de lo contrario, el modelo no será adecuado para resolver el problema y todo el
proceso de programación posterior dará como fruto un programa que no funciona o no hace
lo que se esperaba de él.
Para que el modelo sea acertado, por lo tanto, es necesario tener muy clara la naturaleza del
problema y de los datos que le afectan. A este respecto, es imprescindible establecer lo que
se denomina una especificación de requisitos, que no es más que una definición lo más
exacta posible del problema y su entorno. Sin una especificación detallada, es imposible
comprender adecuadamente el problema y, por lo tanto, también es imposible hacer bien el
análisis y construir un modelo que sea válido.
Diseño de soluciones
Una vez establecido el modelo del mundo real, y suponiendo que el problema sea
computable, es necesario decidir CÓMO se va a resolver el problema, es decir, crear una
estructura de hardware y software que lo resuelva.
Diseñar una solución para un modelo no es una tarea sencilla y sólo se aprende a hacerlo
con la práctica. Típicamente, el diseño se resuelve mediante la técnica del diseño
descendente (top-down), que consiste en dividir el problema en subproblemas más simples,
y estos a su vez en otros más simples, y así sucesivamente hasta llegar a problemas lo
bastante sencillos como para ser resueltos con facilidad.
Pruebas
Una vez que el programa está introducido en la memoria del ordenador, es necesario
depurar posibles errores. La experiencia demuestra que hasta el programa más sencillo
contiene errores y, por lo tanto, este es un paso de vital importancia.
Los errores más frecuentes son los sintácticos o de escritura, por habernos equivocado
durante la codificación. Para corregirlos, basta con localizar el error (que generalmente nos
marcará el propio ordenador) y subsanarlo.
Más peliagudos son los errores de análisis o diseño. Un error en fases tan tempranas dará
lugar a un programa que, aunque corre en la máquina, no hace lo que se esperaba de él y,
por lo tanto, no funciona. Estos errores obligan a revisar el análisis y el diseño y, en
consecuencia, a rehacer todo el trabajo de especificación, codificación y pruebas. La mejor
forma de evitarlos es realizar un análisis y un diseño concienzudos antes de lanzarnos a
teclear código como posesos.
Existen varias técnicas, relacionadas con los controles de calidad, para generar software libre
de errores y diseñar baterías de prueba que revisen los programas hasta el límite de lo
posible, pero que quede claro: ningún programa complejo está libre de errores al 100% por
más esfuerzos que se hayan invertido en ello.
Mantenimiento
Cuando el programa está en uso, y sobre todo si se trata de software comercial, suele ser
preciso realizar un mantenimiento. El mantenimiento puede ser de varios
tipos: correctivo (para enmendar errores que no se hubieran detectado en la fase de
pruebas), perfectivo (para mejorar el rendimiento o añadir más funciones)
o adaptativo (para adaptar el programa a otros entornos).
Cuando digo que “Tengo 78 años”, transmito una información que todo el mundo puede
comprender. La información viaja a bordo de los datos. El dato más crucial de la afirmación
anterior es el número entero “78″. Podemos sustituir el enunciado por uno más general, tal
como “Tengo X años”, donde X es cualquier número entero entre 1 y 120. En ese caso,
diremos que X es una variable de tipo entero que puede tomar valores entre 1 y 120.
Un tipo de datos es exactamente eso: una clase concreta de variables. Cada tipo de datos
exige que las variables que pertenecen a esa clase tomen sólo un conjunto de valores
posibles y, además, tiene asociado un conjunto de operaciones para válidas manipularlos.
Cada tipo de datos dispone de una representación interna diferente en el ordenador; por eso
es importante distinguir entre tipos de datos a la hora de programar.
Existen tipos de datos simples y tipos complejos. Entre los simples tenemos:
Números enteros
Números reales
Caracteres
Lógicos
Números enteros
Es probablemente el tipo más sencillo de entender. Los datos de tipo entero sólo pueden
tomar como valores:
Además, los enteros pueden ser con signo y sin signo. Si tienen signo, se admiten los
números negativos; si no lo tienen, los números sólo pueden ser positivos (sería más
correcto llamarlos números naturales).
Por lo tanto:
Si se utilizan 8 bits para codificar los números enteros, el rango de valores permitido irá
de 0 a 255 (sin signo) o de -128 a +127 (con signo).
Si se utilizan 16 bits para codificar los números enteros, el rango será de 0 a 65535 (sin
signo) o de -32768 a 32767 (sin signo).
Si se utilizan 32, 64, 128 bits o más, se pueden manejar números enteros mayores.
Números reales
El tipo de dato número real permite representar números con decimales. La cantidad de
decimales de un número real puede ser infinita, pero al ser el ordenador una máquina finita
es necesario establecer un número máximo de dígitos decimales significativos.
La notación científica es muy útil para representar números muy grandes economizando
esfuerzos. Por ejemplo, el número 129439000000000000000 tiene la siguiente
representación científica:
1,29439 x 1020
0,129439 x 1021
La notación científica es igualmente útil para números decimales muy pequeños. Por
ejemplo, el número 0,0000000000000000000259 tiene esta notación científica:
2,59 x 10-23
0,259 x 10-22
Internamente, el ordenador reserva varios bits para la mantisa y otros más para el
exponente. Como en el caso de los números reales, la magnitud de los números que el
ordenador pueda manejar estará directamente relacionada con el número de bits reservados
para su almacenamiento.
Overflow
Cuando se realizan operaciones con números (tanto enteros como reales), es posible que el
resultado de una de ellas dé lugar a un número fuera del rango máximo permitido. Por
ejemplo, si tenemos un dato de tipo entero sin signo de 8 bits cuyo valor sea 250 y le
sumamos 10, el resultado es 260, que sobrepasa el valor máximo (255).
En estos casos, estamos ante un caso extremo denominado overflow o desbordamiento. Los
ordenadores pueden reaccionar de forma diferente ante este problema, dependiendo del
sistema operativo y del lenguaje utilizado. Algunos lo detectan como un error de ejecución
del programa, mientras que otros lo ignoran, convirtiendo el número desbordado a un
número dentro del rango permitido pero que, obviamente, no será el resultado correcto de la
operación, por lo que el programa probablemente fallará.
Caracteres y cadenas
El tipo de dato carácter sirve para representar datos alfanuméricos. El conjunto de elementos
que puede representar está estandarizado según el código ASCII, que, como ya vimos,
consiste en una combinación de 8 bits asociada a un carácter alfanumérico concreto.
Las combinaciones de 8 bits dan lugar a un total de 255 valores distintos (desde 0000 0000
hasta 1111 1111), por lo que esa es la cantidad de caracteres diferentes que se pueden
utilizar. Entre los datos de tipo carácter válidos están:
Nótese que no es lo mismo el valor entero 3 que el carácter ’3′. Para distinguirlos, usaremos
siempre comillas para escribir los caracteres.
Los datos tipo carácter sólo pueden contener UN carácter. Una generalización del tipo
carácter es el tipo cadena de caracteres, utilizado para representar series de varios
caracteres. Éste, sin embargo, es un tipo de datos complejo y será estudiado más adelante
(en el tema 3). Sin embargo, las cadenas se utilizan tan a menudo que no podremos evitar
usarlas en algunos ejercicios incluso antes de estudiarlas a fondo.
Para distinguir una cadena de caracteres de los caracteres individuales, usaremos la misma
convención que en el lenguaje C: rodearemos las cadenas con comillas dobles (“) y a los
caracteres individuales con comillas simples (‘). Esta sintaxis, como el lógico, depende del
lenguaje de programación utilizado.
Datos lógicos
El tipo dato lógico, también llamado booleano, es un dato que sólo puede tomar un valor
entre dos posibles. Esos dos valores son:
Este tipo de datos se utiliza para representar alternativas del tipo sí/no. En algunos
lenguajes, el valor truefalse con el número 0. Es decir, los datos lógicos contienen
información binaria. Esto ya los hace bastante importantes, pero la mayor utilidad de los
datos lógicos viene por otro lado: son el resultado de todas las operaciones lógicas y
relacionales que se emplean continuamente en las instrucciones condicionales y en
los bucles. se representa con el número 1 y el valor
Como dijimos entonces, los tipos de datos se caracterizan por la clase de objeto que
representan y por las operaciones que se pueden hacer con ellos. Los datos que participan
en una operación se llaman operandos, y el símbolo de la operación se denomina operador.
Por ejemplo, en la operación entera 5 + 3, los datos 5 y 3 son los operandos y “+” es el
operador.
Podemos clasificar las operaciones básicas con datos simples en dos grandes grupos: las
operaciones aritméticas y las operaciones lógicas.
Operaciones aritméticas
Son análogas a las operaciones matemáticas convencionales, aunque cambian los símbolos.
Sólo se emplean con datos de tipo entero o real (aunque puede haber alguna excepción):
No todos los operadores existen en todos los lenguajes de programación. Por ejemplo, en
lenguaje Fortran no existe la división entera, en C no existe la exponenciación, y, en Pascal,
el operador “%” se escribe “mod”.
Señalemos que la división entera (div) se utiliza para dividir números enteros,
proporcionando a su vez como resultado otro número entero, es decir, sin decimales. La
operación módulo (%) sirve para calcular el resto de estas divisiones enteras.
El tipo del resultado de cada operación dependerá del tipo de los operandos. Por ejemplo, si
sumamos dos números enteros, el resultado será otro número entero. En cambio, si
sumamos dos números reales, el resultado será un número real. La suma de un número
entero con otro real no está permitida en muchos lenguajes, así que intentaremos evitarla.
Por último, decir que las operaciones “div” y “%” sólo se pueden hacer con números enteros,
no con reales, y que la operación “/” sólo se puede realizar con reales, no con enteros.
Aquí tenemos algunos ejemplos de operaciones aritméticas con números enteros y reales:
Nótese que el operador “–” también se usa para preceder a los números negativos, como en
el álgebra convencional.
Hay dos tipos de operadores que se utilizan en estas operaciones: los operadores de relación
y los operadores lógicos
Muchos lenguajes prefieren el símbolo “< >” para “distinto de”. En realidad, es un asunto de
notación que no tiene mayor importancia.
Los operadores de relación se pueden usar con todos los tipos de datos simples: entero, real,
carácter o lógico. El resultado será verdadero si la relación es cierta, o falso en caso
contrario.
B) Operadores lógicos. Los operadores lógicos son and (y), or (o) y not (no). Sólo se
pueden emplear con tipos de datos lógicos.
El operador and, que también podemos llamar y, da como resultado verdadero sólo si los dos
operandos son verdaderos:
El operador or (también nos vale o) da como resultado verdadero cuando al menos uno de
los dos operandos es verdadero:
El operador not (o no) es uno de los escasos operadores que sólo afectan a un operando
(operador monario), no a dos (operador binario). El resultado es la negación del valor del
operando, es decir, que le cambia el valor de verdadero a falso y viceversa:
La prioridad de cálculo respeta las reglas generales del álgebra. Así, por ejemplo, la división
y la multiplicación tienen más prioridad que la suma o la resta. Pero el resto de prioridades
pueden diferir de manera imporante de un lenguaje de programación a otro. Por ejemplo, las
siguientes son las prioridades en el lenguaje C:
La prioridad del cálculo se puede alterar usando paréntesis, como en álgebra. Los paréntesis
se pueden anidar tantos niveles como sean necesarios. Por supuesto, a igualdad de prioridad
entre dos operadores, la operación se calcula de izquierda a derecha, en el sentido de la
lectura de los operandos.
Es la misma diferencia que hay entre decir “Tengo 50 años” y decir “Tengo X años”. El
primero es un enunciado con un dato constante (50), que proporciona una determinada
información. El segundo es un enunciado con un dato variable (X), que puede tomar un valor
concreto dentro de un gran número de valores posibles y que, por tanto, puede dar lugar a
un montón de enunciados diferentes.
Identificadores
A los datos variables se les asigna un identificador alfanumérico, es decir, un nombre. Por lo
tanto, es necesario distinguir entre el identificador de una variable y su valor. Por ejemplo,
en la frase “Tengo X años”, la variable X puede contener el valor 50. En este caso, X es el
identificador y 50 el valor de la variable.
Los identificadores o nombres de variable deben cumplir ciertas reglas que, aunque varían de
un lenguaje a otro, podemos resumir en que:
Deben empezar por una letra y, en general, no contener símbolos especiales excepto el
subrayado (“_”)
No deben coincidir con alguna palabra reservada del lenguaje
He aquí algunos ejemplos de identificadores:
x: Es válido
5x: No es válido, porque no empieza por una letra
x5: Es válido
pepe: Es válido
-pepe: No es válido, porque no empieza por una letra
pepe_luis: Es válido
pepe!luis: No es válido, porque contiene caracteres especiales (!)
Las constantes también pueden tener un identificador, aunque no es estrictamente
obligatorio. En caso de tenerlo, ha de cumplir las mismas reglas que los identificadores de
variable.Declaración y asignación
Declaración de variables
Las variables tienen que pertenecer a un tipo de dato determinado, es decir, debemos indicar
explícitamente qué tipo de datos va a almacenar a lo largo del programa. Esto implica que,
en algún punto del programa (ya veremos dónde) hay que señalar cuál va a ser el
identificador de la variable, y qué tipo de datos va a almacernar. A esto se le llama declarar
la variable.
X es entero
Y es real
letra es carácter
X, Y y letra son los identificadores de variable. Es necesario declararlas porque, como vimos,
el ordenador maneja internamente cada variable de una forma diferente: en efecto, no es lo
mismo una variable entera de 8 bits sin signo que otra real en coma flotante. El ordenador
debe saber de antemano qué variables va a usar el programa y de qué tipo son para poder
asignarles la memoria necesaria.
Para adjudicar un valor a una variable, se emplea una sentencia de asignación, que tienen
esta forma:
X = 5
Y = 7.445
letra = 'J'
A partir de la asignación, pueden hacerse operaciones con las variables exactamente igual
que se harían con datos. Por ejemplo, la operación X + X daría como resultado 10. A lo largo
del programa, la misma variable X puede contener otros valores (siempre de tipo entero) y
utilizarse para otras operaciones. Por ejemplo:
X es entero
Y es entero
Z es entero
X = 8
Y = 2
Z = X div Y
X = 5
Y = X + Z
En cambio, las constantes no necesitan identificador, ya que son valores que nunca cambian.
Esto no significa que no se les pueda asociar un identificador para hacer el programa más
legible. En ese caso, sólo se les puede asignar valor una vez, ya que, por su propia
naturaleza, son invariables a lo largo del programa.
Expresiones
Una expresión es una combinación de constantes, variables, operadores y funciones. Es
decir, se trata de operaciones aritméticas o lógicas como las que vimos en el apartado 2.2
(página 12), pero en las que, además, pueden aparecer variables.
Por ejemplo:
(5 + X) div 2
En esta expresión, aparecen dos constantes (5 y 2), una variable (X) y dos operadores (+ y
div), además de los paréntesis, que sirven para alterar la prioridad de las operaciones.
Lógicamente, para resolver la expresión, es decir, para averiguar su resultado, debemos
conocer cuál es el valor de la variable X. Supongamos que la variable X tuviera el valor 7.
Entonces, el resultado de la expresión es 6. El cálculo del resultado de una expresión se
suele denominar evaluación de la expresión.
Otro ejemplo:
( – b + raiz(b^2 – 4 * a * c)) / (2 * a)
Esta expresión, más compleja, tiene tres variables (a, b y c), 4 operadores (–, +, ^ y *,
aunque algunos aparecen varias veces), 2 constantes (2 y 4, apareciendo el 2 dos veces) y
una función (raiz, que calcula la raiz cuadrada). Si el valor de las variables fuera a = 2, c = 3
y b = 4, al evaluar la expresión el resultado sería –0.5
La forma más habitual de encontrar una expresión es combinada con una sentencia de
asignación a una variable. Por ejemplo:
Y = (5 + X) div 2
En estos casos, la expresión (lo que hay a la derecha del signo “=”) se evalúa y su resultado
es asignado a la variable situada a la izquierda del “=”. En el ejemplo anterior, suponiendo
que la variable X valiera 7, la expresión (5 + X) div 2 tendría el valor 6, y, por lo tanto, ese
es el valor que se asignaría a la variable Y.
El pseudocódigo utiliza ciertas palabras reservadas para representar las acciones del
programa. Estas palabras originalmente están en inglés (y se parecen mucho a las que luego
emplean los lenguajes de programación), pero por suerte para nosotros su traducción
española está muy extendida entre la comunidad hispanohablante.
Las palabras reservadas del pseudocódigo son relativamente pocas, pero, como irá
comprobando el aprendiz de programador con la práctica, con un conjunto bastante reducido
de instrucciones, correctamente combinadas, podemos construir programas muy complejos.
A continuación presentamos una tabla-resumen con todas las palabras reservadas del
pseudocódigo. A partir de ahora, en sucesivos posts haremos uso de estas instrucciones.
Instrucción Significado
si_no
inicio
acciones-2
fin
según (expresión) hacer Instrucción condicional múltiple. Se utiliza cuando hay más de
dos condiciones posibles (verdadero o falso) . Se evalúa la
inicio expresión, que suele ser de tipo entero, y se busca un valor en la
lista valor1, valor2,… valorN que coincida con ella, realizándose
valor1: acciones-1
las acciones asociadas al valor coincidente.Si ningún valor de la
valor2: acciones-2 lista coincide con la expresión del “según”, se realizan las
acciones de la parte “si_no”.
…
valor3: acciones-N
si_no: acciones-si_no
fin
fin
fin
para variable desde expr- Bucle para. Se evalúa la expresión expr-ini, que debe ser de tipo
ini hasta expr-fin hacer entero, y se asigna ese valor a la variable. Dicha variable se
incrementa en una unidad en cada repetición de las acciones. Las
inicio
acciones se repiten hasta que la variable alcanza el valor expr-fin.
acciones
fin
Reglas de estilo
La escritura de un programa debe ser siempre lo más clara posible, ya se esté escribiendo en
pseudocódigo o en un lenguaje de programación real. La razón es evidente: los algoritmos
pueden llegar a ser muy complejos, y, si a su complejidad le añadimos una escritura sucia y
desordenada, se volverán ininteligibles.
Por esta razón, y ya desde el principio, debemos acostumbrarnos a respetar ciertas reglas
básicas de estilo. Cierto que cada programador puede luego desarrollar su estilo propio, pero
siempre dentro de un marco aceptado por la mayoría.
Partes de un algoritmo
Los algoritmos deberían tener siempre una estructura en tres partes:
1. Cabecera
2. Declaraciones
3. Acciones
Algunos lenguajes, C entre ellos, son lo bastante flexibles como para permitir saltarse a la
torera esta estructura, pero es una buena costumbre respetarla siempre:
Documentación
La documentación del programa comprende el conjunto de información interna y externa que
facilita su posterior mantenimiento.
Los símbolos que marcan las zonas de comentario dependen del lenguaje de programación,
como es lógico. Así, por ejemplo, en Pascal se escriben encerrados entre los símbolos (* y
*):
El lenguaje C, sin embargo, utiliza los símbolos /* y */ para marcar los comentarios.
Además, C++ permite emplear la doble barra ( / / ) para comentarios que ocupen sólo una
línea. Nosotros usaremos indistintamente estos dos métodos:
/* Esto es un comentario en C */
// Esto es un comentario en C++
He aquí un ejemplo de algoritmo comentado: un algoritmo que suma todos los números
naturales de 1 hasta 1000
algoritmo sumar1000
Fecha: 12-12-04 */
variables
N es entero
inicio
inicio
fin
escribir (suma)
fin
Observe que los comentarios aparecen a la derecha de las instrucciones, encerrados entre
llaves. A efectos de ejecución, se ignora todo lo que haya escrito entre los símbolos /* y */,
pero a efectos de documentación y mantenimiento, lo que haya escrito en los comentarios
puede ser importantísimo.
Estilo de escritura
En este blog encontrará muchos algoritmos. Si se fija en ellos, verá que todos siguen ciertas
convenciones en el uso de la tipografía, las sangrías, los espacios, etc. Escribir los algoritmos
cumpliendo estas reglas es una sana costumbre.
Sangrías
Las instrucciones que aparezcan debajo de “inicio” deben tener una sangría mayor que dicha
instrucción. Ésta sangría se mantendrá hasta la aparición del “fin” correspondiente. Esto es
particularmente importante cumplirlo si existen varios bloques inicio–fin anidados. Asimismo,
un algoritmo es más fácil de leer si los comentarios tienen todos la misma sangría.
Ejemplo: Escribir un algoritmo que determine, entre dos números A y B, cuál es el mayor o
si son iguales. Observe bien las sangrías de cada bloque de instrucciones, así como la
posición alineada de los comentarios.
algoritmo comparar
variables
inicio
leer (B)
fin
si (A > B) entonces
inicio // A es mayor
fin
si_no
inicio // B es mayor
fin
fin
fin
Ejemplo: Repetiremos el mismo ejemplo anterior, prescindiendo de los “inicio” y “fin” que no
sean necesarios. Fíjese en que el algoritmo es más corto y, por lo tanto, más fácil de leer y
entender.
algoritmo comparar
variables
inicio
leer (B)
si (A > B) entonces
fin
fin
Tipografía
En muchos textos, se resaltan las palabras clave del lenguaje de programación en negrita,
para distinguirlas de identificadores de variable, símbolos, etc. Muchos editores de texto
pensados para escribir programas con ellos también lo hacen, utilizando diversos colores
para distinguir los elementos entre sí. Esto aumenta la legibilidad del algoritmo, aunque
tiene sus detractores.
Espacios
Otro elemento que aumenta la legibilidad es espaciar suficientemente (pero no demasiado)
los distintos elementos de cada instrucción. Por ejemplo, esta instrucción ya es bastante
complicada y difícil de leer:
Pero se lee mucho mejor que esta otra, en la que se han suprimido los espacios (excepto los
imprescindibles):
si(a>b)y(c>d*raiz(k))entonces a=k+5.7*b
Al ordenador le dará igual si escribimos (a > b) o (a>b), pero a cualquier programador que
deba leer nuestro código le resultará mucho más cómoda la primera forma.
Por la misma razón, también es conveniente dejar líneas en blanco entre determinadas
instrucciones del algoritmo cuando se considere que mejora la legibilidad.
Identificadores
A la hora de elegir identificadores de variables (o de constantes) es muy importante utilizar
nombres que sean significativos, es decir, que den una idea de la información que almacena
esa variable. Por ejemplo, si en un programa de nóminas vamos a guardar en una variable la
edad de los empleados, es una buena ocurrencia llamar a esa variable “edad”, pero no
llamarla “X”, “A” o “cosa”.
Toda esta idea de significación de los identificadores es extensible a los nombres de los
algoritmos, de las funciones, de los procedimientos, de los archivos y, en general, de todos
los objetos relacionados con un programa.
En ciertos lenguajes existen convenciones más o menos rígidas para formar identificadores
largos. También pueden existir acuerdos (escritos o tácitos) en empresas y organizaciones,
así que uno debe amoldarse al sitio y al lenguaje. El objetivo es evitar que unos
programadores usen identificadores del tipoedad_de_los_empleados, mientras que otros
bauticen a esta variableedadDeLosEmpleados.
Por último, señalar que muchos lenguajes de programación distinguen entre mayúsculas y
minúsculas, es decir, que para ellos no es lo mismo el identificador “edad” que “Edad” o
“EDAD”. Es conveniente, por tanto, ir acostumbrándose a esta limitación. Nosotros
preferiremos usar identificadores en minúscula, por ser lo más habitual entre los
programadores de lenguaje C.
Allá por mayo de 1966, Böhm y Jacopin demostraron que se puede escribir
cualquier programa propio utilizando solo tres tipos de estructuras de control: la secuencial,
la selectiva (o condicional) y la repetitiva. A esto se le llama Teorema de la programación
estructurada, y define un programa propio como un programa que cumple tres
características:
Realmente, el trabajo de Dijkstra basado en este teorema fue revolucionario, porque lo que
venía a decir es que, para construir programas más potentes y en menos tiempo, lo que
había que hacer era simplificar las herramientas que se utilizaban para hacerlos, en lugar de
complicarlas más. Este regreso a la simplicidad, unido a las técnicas de ingeniería del
software, acabó con la crisis del software de los años 70.
Por lo tanto, los programas estructurados deben limitarse a usar tres estructuras:
Secuencial
Selectiva (o condicional)
Repetitiva
La estructura secuencial
La estructura secuencial no es ni más ni menos que aquélla en la que una acción sigue a otra
(en secuencia). Esta es la estructura algorítmica básica, en la que las instrucciones se
ejecutan una tras otra, en el mismo orden en el que fueron escritas.
La estructura secuencial, por lo tanto, es la más simple de las tres estructuras permitidas. A
continuación vemos su representación mediante pseudocódigo:
inicio
acción 1
acción 2
...
acción N
fin
Un ejemplo inofensivo
Vamos a escribir, como ejemplo, un algoritmo completamente secuencial que calcule la suma
de dos números, A y B. Recuerde que, generalmente, los algoritmos se dividen en tres
partes: entrada de datos, procesamiento de esos datos y salida de resultados.
algoritmo suma
variables
A, B, suma son enteros
inicio
leer (A)
leer (B)
suma = A + B
escribir (suma)
fin
Vimos en el post anterior que los programas estructurados utilizan únicamente tres
estructuras: secuencial, condicional y repetitiva. También vimos en qué consiste la
estructura secuencial.
Los algoritmos que usan únicamente estructuras secuenciales están muy limitados y no
tienen ninguna utilidad real. Esa utilidad aparece cuando existe la posibilidad de ejecutar una
de entre varias secuencias de instrucciones dependiendo de alguna condición asociada a los
datos del programa.
simples
dobles
múltiples
Condicional simple
La estructura condicional simple tiene esta representación:
si condición entonces
inicio
acciones
fin
La condición que aparece entre “si” y “entonces” es siempre una expresión lógica, es decir,
una expresión cuyo resultado es “verdadero” o “falso”. Si el resultado es verdadero,
entonces se ejecutan las acciones situadas entre “inicio” y “fin”. Si es falso, se saltan las
acciones y se prosigue por la siguiente instrucción (lo que haya debajo de “fin”)
Por ejemplo: recuperemos algoritmo del área y el perímetro del rectángulo para mostrar la
condicional simple en pseudocódigo:
algoritmo rectángulo
variables
base, altura, área, perímetro son reales
inicio
leer (base)
leer (altura)
si (área > 0) y (altura > 0) entonces
inicio
área = base * altura
perímetro = 2 * base + 2 * altura
escribir (área)
escribir (perímetro)
fin
si (área <= 0) o (altura <=0) entonces
inicio
escribir ('Los datos son incorrectos')
fin
fin
Observe que, en la primera instrucción condicional (si (área > 0) y (altura > 0) entonces) se
comprueba que los dos datos sean positivos; en caso de serlo, se procede al cálculo del área
y el perímetro mediante las acciones situadas entre inicio y fin. Más abajo hay otra
condicional (si (área <= 0) o (altura <=0) entonces) para el caso de que alguno de los datos
sea negativo o cero: en esta ocasión, se imprime en la pantalla un mensaje de error.
Condicional doble
La forma doble de la instrucción condicional es:
si condición entonces
inicio
acciones-1
fin
si_no
inicio
acciones-2
fin
Por ejemplo, podemos reescribir nuestro algoritmo del rectángulo usando una alternativa
doble:
algoritmo rectángulo
variables
base, altura, área, perímetro son reales
inicio
leer (base)
leer (altura)
si (área > 0) y (altura > 0) entonces
inicio
área = base * altura
perímetro = 2 * base + 2 * altura
escribir (área)
escribir (perímetro)
fin
si_no
inicio
escribir ('Los datos de entrada son incorrectos')
fin
fin
Lo más interesante de este algoritmo es compararlo con el anterior, ya que hace
exactamente lo mismo. ¡Siempre hay varias maneras de resolver el mismo problema! Pero
esta solución es un poco más sencilla, al ahorrarse la segunda condición, que va implícita en
el si_no.
Condicional múltiple
En algunas ocasiones nos encontraremos con selecciones en las que hay más de dos
alternativas (es decir, en las que no basta con los valores “verdadero” y “falso”). Siempre es
posible plasmar estas selecciones complejas usando varias estructuras si-entonces-si_no
anidadas, es decir, unas dentro de otras, pero, cuando el número de alternativas es grande,
esta solución puede plantear grandes problemas de escritura y legibilidad del algoritmo.
Sin embargo, hay que dejar clara una cosa: cualquier instrucción condicional múltiple puede
ser sustituida por un conjunto de instrucciones condicionales simples y dobles totalmente
equivalente.
La estructura condicional múltiple sirve, por tanto, para simplificar estos casos de
condiciones con muchas alternativas. Su sintaxis general es:
Por ejemplo, construyamos un algoritmo que escriba los nombres de los días de la semana
en función del valor de una variable entera llamada “día”. Su valor se introducirá por teclado.
Los valores posibles de la variable “día” serán del 1 al 7: cualquier otro valor debe producir
un error.
algoritmo día_semana
variables
día es entero
inicio
leer (día)
según (día) hacer
inicio
1: escribir('lunes')
2: escribir('martes')
3: escribir('miécoles')
4: escribir('jueves')
5: escribir('viernes')
6: escribir('sábado')
7: escribir('domingo')
si_no: escribir('Error: el día introducido no existe')
fin
fin
Hay dos cosas interesantes en este algoritmo. Primera, el uso de la instrucción selectiva
múltiple: la variable día, una vez leída, se compara con los siete valores posibles. Si vale 1,
se realizará la acción escribir(‘lunes’); si vale 2, se realiza escribir(‘martes’); y así
sucesivamente. Por último, si no coincide con ninguno de los siete valores, se ejecuta la
parte si_no. Es un buen ejercicio mental pensar en cómo se podría resolver el mismo
problema sin recurrir a la alternativa múltiple, es decir, utilizando sólo alternativas simples y
dobles.
El otro aspecto digno de destacarse no tiene nada que ver con la alternativa múltiple, sino
con la sintaxis general de pseudocódigo: no hemos empleado inicio y fin para marcar cada
bloque de instrucciones. Lo más correcto hubiera sido escribirlo así:
Anteriormente hemos visto cómo la metáfora del ordenador visto como un extraterrestre casi
idiota nos podía resultar útil para aprender los rudimentos de la programación.
Siguiendo con el mismo razonamiento, intentaremos ir un poco más allá. Pero, primero,
permítanme que formalicemos un poco lo que sabemos hasta ahora.
Por ejemplo, supongamos que queremos darle las instrucciones para que nos pida dos
números y nos diga cuál de los dos es más pequeño:
A menudo, estas listas de instrucciones que conocemos como algoritmos se entienden mejor
si se representan gráficamente con un ordinograma, ya saben, el viejo juego de “siga la
flecha”:
En Basic:
INPUT A
INPUT B
IF A > B THEN
PRINT "El mayor es A"
ELSE
PRINT "El mayor es B"
En C:
scanf("%i", &A);
scanf("%i", &B);
if (A > B)
printf("El mayor es A");
else
printf("El mayor es B");
En Pascal:
readln(A);
readln(B);
if (A > B) then
writeln('El mayor es A')
else
writeln('El mayor es B');
Algo que agobia mucho a los principiantes es que un programa nunca está del todo acabado.
Incluso este programa tan sencillo está incompleto. Y si no me creen, digan: ¿qué pasa si los
dos números son iguales?
Los ordenadores se diseñaron inicialmente para realizar tareas sencillas y repetitivas. El ser
humano es de lo más torpe acometiendo tareas repetitivas: pronto le falla la concentración y
comienza a tener descuidos. Los ordenadores programables, en cambio, pueden realizar la
misma tarea muchas veces por segundo durante años y nunca se aburren (o, al menos,
hasta hoy no se ha tenido constancia de ello)
Los bucles tienen que repetir un conjunto de instrucciones un número finito de veces. Si no,
nos encontraremos con un bucle infinito y el algoritmo no funcionará. En rigor, ni siquiera
será un algoritmo, ya que no cumplirá la condición de finitud.
Por ejemplo, vamos a escribir un algoritmo que muestre en la pantalla todos los números
enteros entre 1 y 100
algoritmo contar
variables
cont es entero
inicio
cont = 0
mientras (cont <= 100) hacer
inicio
cont = cont + 1
escribir (cont)
fin
fin
Aquí observamos el uso de un contador en la condición de salida de un bucle, un elemento
muy común en estas estructuras. Observe la evolución del algoritmo:
La condición de salida del bucle hace que éste se repita mientras cont valga menos de 101.
De este modo nos aseguramos de escribir todos los números hasta el 100.
Lo más problemático a la hora de diseñar un bucle es, por lo tanto, pensar bien su condición
de salida, porque si la condición de salida nunca se hiciera falsa, caeríamos en un bucle
infinito. Por lo tanto, la variable implicada en la condición de salida debe sufrir alguna
modificación en el interior del bucle; si no, la condición siempre sería verdadera. En nuestro
ejemplo, la variable contse modifica en el interior del bucle: por eso llega un momento,
después de 100 repeticiones, en el que la condición se hace falsa y el bucle termina.
El bucle “repetir”
El bucle de tipo “repetir” es muy similar al bucle “mientras”, con la salvedad de que la
condición de salida se evalúa al final del bucle, y no al principio, como a continuación
veremos. Todo bucle “repetir” puede escribirse como un bucle “mientras”, pero al revés no
siempre sucede.
repetir
inicio
acciones
fin
Cuando el ordenador encuentra un bucle de este tipo, ejecuta las acciones escritas
entre inicio y fin y, después, evalúa la condición, que debe ser de tipo lógico. Si el resultado
es falso, se vuelven a repetir las acciones. Si el resultado es verdadero, el bucle se repite. Si
es falso, se sale del bucle y se continúa ejecutando la siguiente instrucción. Existe, pues, una
diferencia fundamental con respecto al bucle “mientras”: la condición se evalúa al final. Por
lo tanto, las acciones del cuerpo de un bucle “repetir” se ejecutan al menos una vez, cuando
en un bucle “mientras” es posible que no se ejecuten ninguna (si la condición de salida es
falsa desde el principio)
Ejemplo: Escribir un algoritmo que escriba todos los números enteros entre 1 y 100, pero
esta vez utilizando un bucle “repetir” en lugar de un bucle “mientras”
algoritmo contar
variables
cont es entero
inicio
cont = 0
repetir
inicio
cont = cont + 1
escribir (cont)
fin
mientras que (cont <= 100)
fin
Observa que el algoritmo es básicamente el mismo que en el ejemplo anterior, pero hemos
cambiado el lugar de la condición de salida.
El bucle “para”
En muchas ocasiones se conoce de antemano el número de veces que se desean ejecutar las
acciones del cuerpo del bucle. Cuando el número de repeticiones es fijo, lo más cómodo es
usar un bucle “para”, aunque sería perfectamente posible sustituirlo por uno “mientras”.
La estructura “para” repite las acciones del bucle un número prefijado de veces e incrementa
automáticamente una variable contador en cada repetición. Su forma general es:
Ejemplo 1: Escribir un algoritmo que escriba todos los números enteros entre 1 y 100,
utilizando un bucle “para”
algoritmo contar
variables
cont es entero
inicio
para cont desde 1 hasta 100 hacer
inicio
escribir (cont)
fin
fin
De nuevo, lo más interesante es observar las diferencias de este algoritmo con los dos
ejemplos anteriores. Advierta que ahora no es necesario asignar un valor inicial de 0
a cont, ya que se hace implícitamente en el mismo bucle; y tampoco es necesario
incrementar el valor de cont en el cuerpo del bucle (cont = cont + 1), ya que de eso se
encarga el propio bucle “para”. Por último, no hay que escribir condición de salida, ya que el
bucle “para” se repite hasta quecont vale 100 (inclusive)
Ejemplo 2: Escribir un algoritmo que escriba todos los números enteros impares entre 1 y
100, utilizando un bucle “para”:
algoritmo contar
variables
cont es entero
inicio
para cont desde 1 hasta 100 inc 2 hacer
inicio
escribir (cont)
fin
fin
Este ejemplo, similar al anterior, sirve para ver el uso de la sintaxis anternativa del bucle
“para”. La variable cont se incrementará en 2 unidades en cada repetición del bucle.
Dábamos instrucciones al extraterrestre para que calculase el valor medio de diez números
que previamente nos debía solicitar a nosotros. Ahora tratemos de pedirle que haga algo un
poco más complicado: que, primero, me pregunte cuántos números le voy a dar, y que luego
me vaya pidiendo los números, para después calcular el valor medio de todos ellos.
Tendríamos así un programa básicamente igual al anterior, pero más general, ya que no
tiene por qué funcionar siempre con 10 números, sino que lo hará con cualquier cantidad de
ellos.
Paso 1: Preguntar al terrícola cuántos números piensa introducir (llamar a esta cantidad
A)
Paso 2: Usar la letra S para referirme a la suma de los números que voy a empezar a
pedir al terrícola enseguida. S valdrá 0 inicialmente.
Repetir A veces los pasos 3 y 4:
Paso 3: Pedir al terrícola un número (lo llamaré N)
Paso 4: Sumar N a los números que ya había sumado antes (S = S + N).
Paso 5: Calcular media = S / A
Paso 6: Comunicar al terrícola el resultado de mis cálculos (es decir, el valor de media)
Pasémosle al extraterrestre este conjunto ordenado de instrucciones y pidámosle que las
siga: estaremos ejecutando el programa.
Observe que ocurre algo curioso en los pasos 3 y 4: se repiten varias veces antes de
continuar en el paso 5. Esta estructura repetitiva es fundamental en programación y se
denomina muy imaginativamente bucle, lazo o iteración.
Esta lista de pasos (o programa) a veces se representa, para mejor comprensión, de manera
gráfica. Cada paso se dibuja dentro de un rectángulo y los rectángulos se unen entre sí con
flechas, que indican el orden de ejecución. Las condiciones o preguntas (como “¿ya hemos
repetido el número suficiente de veces?”) se representan con un rombo. El conjunto se
denomina ordinograma y verá que es muy fácil de interpretar si le dedica un momento de
atención:
Asociadas a los bucles se encuentran a menudo algunas variables auxiliares. Como siempre
se utilizan de la misma manera, las llamamos con un nombre propio (contador, acumulador,
etc.), pero hay que dejar claro que no son más que variables comunes, aunque se usan de
un modo especial.
CONTADORES
Un contador es una variable (casi siempre de tipo entero) cuyo valor se incrementa o
decrementa en cada repetición de un bucle. Es habitual llamar a esta variable “cont” (de
contador) o “i” (de índice).
Primero se inicializa antes de que comience el bucle. Es decir, se le da un valor inicial. Por
ejemplo:
cont = 5
Segundo, se modifica dentro del cuerpo del bucle. Lo más habitual es que se incremente su
valor en una unidad. Por ejemplo:
cont = cont + 1
Esto quiere decir que el valor de la variable “cont” se incrementa en una unidad y es
asignado de nuevo a la variable contador. Es decir, si cont valía 5 antes de esta instrucción,
cont valdrá 6 después.
cont = cont – 1
El incremento o decremento no tiene por qué ser de una unidad. La cantidad que haya que
incrementar o decrementar vendrá dada por la naturaleza del problema.
Tercero, se utiliza en la condición de salida del bucle. Normalmente, se compara con el valor
máximo (o mínimo) que debe alcanzar el contador para dejar de repetir las instrucciones del
bucle.
Ejemplo: Escribir un algoritmo que escriba la tabla de multiplicar hasta el 100 de un número
N introducido por el usuario
algoritmo tabla_multiplicar
variables
cont es entero
N es entero
inicio
leer (N)
cont = 1
mientras (cont <= 100) hacer
inicio
escribir (N * cont)
cont = cont + 1
fin
fin
El uso de contadores es casi obligado en bucles “mientras” y “repetir” que deben ejecutarse
un determinado número de veces. Recuerde que siempre hay que asignar al contador un
valor inicial para la primera ejecución del bucle (cont = 1 en nuestro ejemplo) e ir
incrementándolo (o decrementándolo, según el algoritmo) en cada repetición con una
instrucción del tipo cont = cont + 1 en el cuerpo del bucle. De lo contrario habremos escrito
un bucle infinito.
Por último, hay que prestar atención a la condición de salida, que debe estar asociada al
valor del contador en la última repetición del bucle (en nuestro caso, 100). Mucho cuidado
con el operador relacional (<, >, <=, >=, etc) que usemos, porque el bucle se puede
ejecutar más o menos veces de lo previsto
ACUMULADORES
Las variables acumuladoras tienen la misión de almacenar resultados sucesivos, es decir, de
acumular resultados, de ahí su nombre.
acum = 0
Por supuesto, el valor inicial puede cambiar, dependiendo de la naturaleza del problema. Más
tarde, en el cuerpo del bucle, la forma en la que nos la solemos encontrar es:
acum = acum + N
acum = acum + M
acum = acum + P
Ejemplo: Escribir un algoritmo que pida 10 números por el teclado y los sume, escribiendo el
resultado
algoritmo sumar10
variables
cont es entero
suma es entero
N es entero
inicio
suma = 0
para cont desde 1 hasta 10 hacer
inicio
leer (N)
suma = suma + N
fin
escribir (suma)
fin
En este algoritmo, cont es una variable contador típica de bucle. Se ha usado un bucle
“para”, que es lo más sencillo cuando conocemos previamente el número de repeticiones (10
en este caso). La variable Nsuma es el acumulador, donde se van sumando los diferentes
valores que toma N en cada repetición. se usa para cada uno de los números introducidos
por el teclado, y la variable
Observe como, al principio del algoritmo, se le asigna al acumulador el valor 0. Esta es una
precaución importante que se debe tomar siempre porque el valor que tenga una variable
que no haya sido usada antes es desconocido (no tiene por qué ser 0)
CONMUTADORES
Un conmutador (o interruptor) es una variable que sólo puede tomar dos valores. Pueden
ser, por tanto, de tipo booleano, aunque también pueden usarse variables enteras o de tipo
carácter.
La variable conmutador recibirá uno de los dos valores posibles antes de entrar en el bucle.
Dentro del cuerpo del bucle, debe cambiarse ese valor bajo ciertas condiciones. Utilizando el
conmuntador en la condición de salida del bucle, puede controlarse el número de
repeticiones.
Ejemplo: Escribir un algoritmo que sume todos los números positivos introducidos por el
usuario a través del teclado. Para terminar de introducir números, el usuario tecleará un
número negativo.
algoritmo sumar
variables
suma es entero
N es entero
terminar es lógico
inicio
suma = 0
terminar = falso
mientras (terminar == falso)
inicio
escribir ('Introduce un número (negativo para terminar)')
leer (N)
si (N >= 0) entonces
suma = suma + N
si_no
terminar = verdadero
fin
fin
escribir (suma)
fin
Con este programa, el usuario puede ir introduciendo números indefinidamente, hasta que se
canse. Para indicar al ordenador que ha terminado de introducir números, debe teclear un
número negativo.
El bucle se controla por medio de la variable “terminar”: es el conmutador. Sólo puede tomar
dos valores: “verdadero”, cuando el bucle debe terminar, y “falso”, cuando el bucle debe
repetirse una vez más. Por lo tanto, “terminar” valdrá “falso” al principio, y sólo cambiará a
“verdadero” cuando el usuario introduzca un número negativo.
A veces, el conmutador puede tomar más de dos valores. Entonces ya no se le debe llamar,
estrictamente hablando, conmutador. Cuando la variable toma un determinado valor
especial, el bucle termina. A ese “valor especial” se le suele denominar valor centinela.
Otro ejemplo: Escribir un algoritmo que sume todos los números positivos introducidos por el
usuario a través del teclado. Para terminar de introducir números, el usuario tecleará un
número negativo.
algoritmo sumar
variables
suma es entero
N es entero
inicio
suma = 0
repetir
inicio
escribir ('Introduce un número (negativo para terminar)')
leer (N)
si (N >= 0) entonces
suma = suma + N
fin
mientras que (N >= 0)
escribir (suma)
fin
Tenemos aquí un ejemplo de cómo no siempre es necesario usar contadores para terminar
un bucle “mientras” (o “repetir”). Las repeticiones se controlan con la variable N, de modo
que el bucle termina cuando N < 0. Ese es el valor centinela.
Qué es la programació n modular en mil palabras o
alguna má s
Hay otra confusión que me he encontrado muchas veces entre los estudiantes que empiezan
a hacer sus pinitos con la programación de ordenadores. Consiste en mezclar dos conceptos
relacionados pero no sinónimos (ni mucho menos excluyentes): programación estructurada y
programación modular.
Una vez resueltos todos los subproblemas, es decir, escritos todos los módulos, es necesario
combinar de algún modo las soluciones para generar la solución global del problema.
Cuando el algoritmo principal hace una llamada al subalgoritmo (es decir, lo invoca), se
empiezan a ejecutar las instrucciones del subalgoritmo. Cuando éste termina, devuelve los
datos de salida al algoritmo principal, y la ejecución continúa por la instrucción siguiente a la
de invocación. También se dice que el subalgoritmo devuelve el control al algoritmo principal,
ya que éste toma de nuevo el control del flujo de instrucciones después de habérselo cedido
temporalmente al subalgoritmo.
El programa principal puede invocar a cada subalgoritmo el número de veces que sea
necesario. A su vez, cada subalgoritmo puede invocar a otros subalgoritmos, y éstos a otros,
etc. Cada subalgoritmo devolverá los datos y el control al algoritmo que lo invocó.
Los subalgoritmos pueden hacer las mismas operaciones que los algoritmos, es decir:
entrada de datos, proceso de datos y salida de datos. La diferencia es que los datos de
entrada se los proporciona el algoritmo que lo invoca, y los datos de salida son devueltos
también a él para que haga con ellos lo que considere oportuno. No obstante, un
subalgoritmo también puede, si lo necesita, tomar datos de entrada desde el teclado (o
desde cualquier otro dispositivo de entrada) y enviar datos de salida a la pantalla (o a
cualquier otro dispositivo de salida).
UN EJEMPLO
Vamos a diseñar un algoritmo que calcule el área y la circunferencia de un círculo cuyo radio
se lea por teclado. Se trata de un problema muy simple que puede resolverse sin aplicar el
método divide y vencerás, pero lo utilizaremos como ilustración.
Dividiremos el problema en dos subproblemas más simples: por un lado, el cálculo del área,
y, por otro, el cálculo de la circinferencia. Cada subproblema será resuelto en un
subalgoritmo, que se invocará desde el algoritmo principal. La descomposición en algoritmos
y subalgoritmos sería la siguiente (se indican sobre las flechas los datos que son
interrcambiados entre los módulos):
Lógicamente, los subalgoritmos deben tener asignado un nombre para que puedan ser
invocados desde el algoritmo principal, y también existe un mecanismo concreto de
invocación/devolución.
La respuesta se deja al sentido común y a la experiencia del diseñador del programa. Como
regla general, digamos que un módulo no debería constar de más de 30 ó 40 líneas de
código. Si obtenemos un módulo que necesita más código para resolver un problema,
probablemente podamos dividirlo en dos o más subproblemas. Por supuesto, esto no es una
regla matemática aplicable a todos los casos. En muchas ocasiones no estaremos seguros de
qué debe incluirse y qué no debe incluirse en un módulo.
Tampoco es conveniente que los módulos sean excesivamente sencillos. Programar módulos
de 2 ó 3 líneas daría lugar a una descomposición excesiva del problema, aunque habrá
ocasiones en las que sea útil emplear módulos de este tamaño.
En el diagrama se representan los módulos mediante cajas, en cuyo interior figura el nombre
del módulo, unidos por líneas, que representan las interconexiones entre ellos. En cada línea
se pueden escribir los parámetros de invocación y los datos devueltos por el módulo
invocado.
El diagrama de estructura siempre tiene forma de árbol invertido. En la raíz figura el módulo
principal, y de él “cuelgan” el resto de módulos en uno o varios niveles.
En el diagrama también se puede representar el tipo de relación entre los módulos. Las
relaciones posibles se corresponden exactamente con los tres tipos de estructuras básicas de
la programación estructurada:
Estructura secuencial: cuando un módulo llama a otro, después a otro, después a otro,
etc.
Estructura selectiva: cuando un módulo llama a uno o a otro dependiendo de alguna
condición
Estructura iterativa: cuando un módulo llama a otro (o a otros) en repetidas ocasiones
Las tres estructuras de llamadas entre módulos se representan con tres símbolos diferentes:
A modo de ejemplo, veamos el diagrama de estructura del algoritmo que calcula el área y la
circunferencia de un círculo, que pusimos como ejemplo un poco más arriba. La
descomposición modular que hicimos entonces consistía en un algoritmo principal que
llamaba a dos subalgoritmos: uno para calcular el área y otro para calcular la circunferencia.
Los dos subalgoritmos (o módulos) son llamados en secuencia, es decir, uno tras otro, por lo
que lo representamos con la estructura secuencial. El módulo principal pasará a los dos
subalgoritmos el radio (R) del círculo, y cada subalgoritmo devolverá al módulo principal el
resultado de sus cálculos.
ESCRITURA DEL PROGRAMA
Una vez diseñada la estructura modular, llega el momento de escribir los algoritmos y
subalgoritmos mediante pseudocódigo, diagramas de flujo o cualquier otra herramienta. Lo
más habitual es comenzar por los módulos (subalgoritmos) de nivel inferior e ir ascendiendo
por cada rama del diagrama de estructura.
Funciones y procedimientos
FUNCIONES
Las funciones son subalgoritmos (o módulos) que resuelven un problema sencillo y
devuelven un resultado al algoritmo que las invoca.
Las funciones pueden tener argumentos, aunque no es obligatorio. Los argumentos son los
datos que se proporcionan a la función en la invocación, y que la función utilizará para sus
cálculos.
Además, las funciones tienen, obligatoriamente, que devolver un resultado. Este resultado
suele almacenarse en una variable para usarlo posteriormente.
Por ejemplo, cuando utilizamos las funciones matemáticas de biblioteca (es decir,
predefinidas en el lenguaje), siempre escribimos algún dato entre paréntesis para que la
función realice sus cálculos con ese dato. Pues bien, ese dato es el argumento o parámetro
de entrada:
A = raiz(X)
B = redondeo(7.8)
N = aleatorio(100)
DECLARACIÓN DE FUNCIONES
No sólo de funciones de biblioteca vive el programador. Como es lógico, también podemos
crear nuestras propias funciones para invocarlas cuando nos sea necesario.
Recuerde que una función no es más que un módulo, es decir, un subalgoritmoque depende,
directamente o a través de otro subalgoritmo, del algoritmo principal. Por tanto, su
estructura debe ser similar a la de cualquier otro algoritmo.
De todos estos elementos nuevos, el más complejo con diferencia es la lista de argumentos,
ya que pueden existir argumentos de entrada, de salida y de entrada/salida. El problema de
los argumentos lo trataremos en profundidad en otro post. Por ahora, diremos que es una
lista de esta forma:
Ahí va un ejemplo: una función que calcula el área de un círculo. El radio se pasa como
argumento de tipo real.
La primera línea. En ella aparece más información: el tipo de valor devuelto por la
función (real, puesto que calcula el área del círculo), el nombre de la función
(área_círculo) y la lista de argumentos. En esta función sólo hay un argumento, llamado
radio. Es de tipo real.
La penúltima línea (antes de fin). Contiene el valor que la función devuelve. Debe ser
una expresión del mismo tipo que se indicó en la primera línea (en este ejemplo, real).
INVOCACIÓN DE FUNCIONES
Para que las instrucciones escritas en una función sean ejecutadas es necesario que la
función se llame o invoque desde otro algoritmo.
Como las funciones devuelven valores, es habitual que la invocación aparezca junto con una
asignación a variable para guardar el resultado y utilizarlo más adelante.
algoritmo cuadrado_cubo
variables
N, A, B son reales
inicio
leer(N)
A = cuadrado(N)
B = cubo(N)
escribir("El cuadrado es ", A)
escribir("El cubo es ", B)
fin
PROCEDIMIENTOS
Las funciones son muy útiles como herramientas de programación, pero tienen una seria
limitación: sólo pueden devolver un resultado al algoritmo que las invoca. Y en muchas
ocasiones es necesario devolver más de un resultado.
Para eso existen los procedimientos, también llamados subrutinas, que son, en esencia,
iguales a las funciones, es decir:
Los procedimientos son, por lo tanto, módulos más generales que las funciones. La
declaración de un procedimiento es similar a la de una función, pero sustituyendo la palabra
función por procedimiento y sin indicar el tipo de datos del resultado; tampoco tienen
sentencia devolver al final del código:
procedimiento nombre_procedimiento(lista_de_argumentos)
constantes
lista_de_constantes
variables
lista_de_variables
inicio
acciones
fin
Pero, si no tienen sentencia devolver, ¿cómo devuelve un procedimiento los resultados al
algoritmo que lo invoca? La única posibilidad es utilizar los parámetros como puerta de dos
direcciones, es decir, que no solo sirvan para que el algoritmo comunique datos al
subalgoritmo, sino también para comunicar datos desde el subalgoritmo hacia el algoritmo.
Para ello necesitamos saber más cosas sobre el paso de parámetros, que es lo que tratamos
a continuación.
Paso de parámetros por valor, que es la forma más sencilla pero no permite al
subalgoritmo devolver resultados en los parámetros.
Paso de parámetros por referencia, que es más complejo pero permite a los
subalgoritmos devolver resultados en los parámetros.
Veamos cada método detenidamente.
Por ejemplo, una función que calcula la potencia de un número elevado a otro podría ser así:
A = 5
B = 3
C = potencia(A,B)
En esta invocación de la función potencia(), los parámetros actuales son A y B, es decir, 5 y
3.
Al invocar un subalgoritmo, los parámetros actuales son asignados a los parámetros formales
en el mismo orden en el que fueron escritos. Dentro del subalgoritmo, los parámetros se
pueden utilizar como si fueran variables. Así, en el ejemplo anterior, dentro de la función
potencia(), el parámetro base puede usarse como una variable a la que se hubiera asignado
el valor 5, mientras que exponente es como una variable a la que se hubiera asignado el
valor 3.
Cuando el subalgoritmo termina de ejecutarse, sus parámetros formales base y exponente
dejan de existir y se devuelve el resultado (en nuestro ejemoplo, 53), que se asigna a la
variable C.
Los argumentos pasan sus parámetros por valor excepto cuando indiquemos que el paso es
por referencia colocando el símbolo * (asterisco) delante del nombre del argumento.
La invocación del subalgoritmo se hace del mismo modo que hasta ahora, pero delante del
parámetro que se pasa por referencia debe colocarse el símbolo &:
A = 5
B = 3
C = 0
potencia(A, B, &C)
En este caso, pasamos tres parámetros actuales, ya que el subalgoritmo tiene tres
parámetros formales. El tercero de ellos, C, se pasa por referencia (para señalar esta
circunstancia, se antepone el símbolo &), y por lo tanto queda ligado al parámetro formal
resultado.
El paso de parámetros por referencia suele, por lo tanto, usarse en procedimientos que
tienen que devolver muchos resultados al algoritmo que los invoca. Cuando el resultado es
sólo uno, lo mejor es emplear una función. Esto no quiere decir que las funciones no puedan
tener argumentos pasados por referencia: al contrario, a veces es muy útil.
el paso por valor es unidireccional, es decir, sólo permite transmitir datos del algoritmo
al subalgoritmo a través de los argumentos.
el paso por referencia es bidireccional, es decir, permite transmitir datos del algoritmo al
subalgoritmo, pero también permite al subalgoritmo transmitir resultados al algoritmo.
Efectos laterales: variables globales y locales
En principio, todas las variables declaradas en un algoritmo son locales a ese algoritmo, es
decir, no existen fuera del algoritmo, y, por tanto, no pueden utilizarse más allá de las
fronteras marcadas por inicio y fin. El ámbito de una variable es local al algoritmo donde se
declara.
Por ejemplo, calculemos el cuadrado de un valor X introducido por teclado utilizando diseño
modular.
algoritmo cuadrado
variables
N, result son reales
inicio
leer(N)
calcular_cuadrado()
escribir("El cuadrado es ", result)
fin
Aunque ese mecanismo varía mucho de un lenguaje a otro, diremos como regla general que
las variables globales deben declararse en el algoritmo principal, anteponiendo el
identificador global al nombre de la variable, siendo entonces accesibles a todos los
algoritmos y subalgoritmos que conformen el programa.
Por ejemplo, vamos a volver a calcular el cuadrado de un valor X introducido por teclado
utilizando diseño modular.
algoritmo cuadrado
variables
global N es real
global result es reales
inicio
leer(N)
calcular_cuadrado()
escribir("El cuadrado es ", result)
fin
Pudiera ocurrir que una variable global tenga el mismo nombre que una variable local. En
ese caso, el comportamiento depende del lenguaje de programación (los hay que ni siquiera
lo permiten), pero lo habitual es que la variable local sustituya a la global, haciendo que ésta
última sea inaccesible desde el interior del subalgoritmo. Al terminar la ejecución del
subalgoritmo y destruirse la variable local, volverá a estar accesible la variable global que,
además, habrá conservado su valor, pues no ha podido ser modificada desde el
subalgoritmo.
Es por esto, entre otras razones, que los módulos deben ser independientes entre sí,
comunicándose con otros módulos únicamente mediante los datos de entrada (paso de
parámetros por valor) y los de salida (devolución de resultados – en las funciones – y paso
de parámetros por referencia). Los módulos que escribamos de este modo nos servirán
probablemente para otros programas, pero no así los módulos que padezcan efectos
laterales, pues sus relaciones con el resto del programa del que eran originarios serán
diferentes y difíciles de precisar.
Es habitual agrupar varios algoritmos relacionados (por ejemplo: varios algoritmos que
realicen diferentes operaciones matemáticas) en un mismo archivo, formando lo que se
denomina una biblioteca de funciones. Cada lenguaje trata las librerías de manera distinta,
de modo que volveremos sobre este asunto en los posts dedicados al lenguaje C.
Por último, señalemos que, para reutilizar con éxito el código, es importante que esté
bien documentado. En concreto, en cada algoritmo deberíamos documentar claramente:
2 El Lenguaje C
Lenguajes de alto y bajo nivel
Un lenguaje de programación es, ya lo saben, un conjunto de símbolos que se combinan de
acuerdo con una sintaxis bien definida para posibilitar la transmisión de instrucciones a la
CPU.
Lenguajes de programación hay muchos, cada uno con sus ventajas e inconvenientes.
Conviene, por tanto, clasificarlos en categorías. Suelen hacerse dos clasificaciones:
NIVELES DE ABSTRACCIÓN
El ordenador, como es sabido, solo puede manejar ceros y unos, es decir, código o lenguaje
binario. Los seres humanos, por el contrario, utilizamos un lenguaje mucho más complejo,
con montones de símbolos y reglas sintácticas y semánticas, que denominaremos lenguaje
natural.
Entre estos dos extremos (lenguaje binario y lenguaje natural) se encuentran los lenguajes
de programación. Tienen cierto parecido con el lenguaje natural, pero son mucho más
reducidos y estrictos en su sintaxis y semántica, para acercarse a las limitaciones del
lenguaje binario.
Hay lenguajes de programación muy próximos al lenguaje binario: a éstos los llamamos
lenguajes de bajo nivel de abstracción. Y los hay más próximos al lenguaje natural: son los
lenguajes de alto nivel de abstracción.
Las instrucciones del lenguaje máquina realizan tareas muy sencillas, como, por ejemplo,
sumar dos números, detectar qué tecla se ha pulsado en el teclado o escribir algo en la
pantalla del ordenador. Cuando se combinan adecuadamente muchas de estas instrucciones
sencillas se obtiene un programa de ordenador que puede realizar tareas muy complejas.
110100100101110010100010001001111010010110110
Cuando los ordenadores fueron haciéndose más potentes, pronto se vio que con el lenguaje
máquina no se podrían crear programas que aprovechasen esa potencia por la sencilla razón
de que era demasiado difícil programar así: no se podía hacer nada demasiado complicado
porque el cerebro humano no está “diseñado” para pensar en binario.
Surgió entonces la idea de utilizar el propio ordenador como traductor: ¿por qué no escribir
una instrucción como la anterior, que suma dos números, de una forma más parecida al
lenguaje humano y que luego un pequeño programa de ordenador se encargue de traducir
esa instrucción a su correspondiente ristra de ceros y unos? Así apareció el lenguaje
ensamblador, cuyas instrucciones son equivalentes a las del lenguaje máquina, pero se
escriben con palabras similares a las del lenguaje humano. Por ejemplo, para sumar dos
números, la instrucción en ensamblador puede ser algo como:
ADD D1, D2
Los lenguajes de bajo nivel se caracterizan por ser dependientes del hardware de la
máquina. Es decir: un programa escrito en lenguaje máquina o en ensamblador para una
máquina Pentium IV no funcionará, por ejemplo, en un Apple Macintosh a menos que sea
modificado sustancialmente. Incluso puede tener serios problemas para funcionar en
máquinas de la misma familia pero con el resto del hardware diferente, o con un sistema
operativo distinto.
Una característica muy importante de los lenguajes de alto nivel es que son independientes
del hardware, lo que implica que los programas desarrollados con estos lenguajes pueden
ser ejecutados en ordenadores con hardware totalmente distinto. A esto se le
llama portabilidad.
Ejemplos de lenguajes de alto nivel son: Cobol, C, Fortran, Basic, Pascal, Ada, etc.
Ventajas Inconvenientes
También hay que destacar que no todos los lenguajes de alto nivel son iguales. Los hay de
“más alto nivel” que otros. C tiene sin duda menor nivel de abstracción que, por ejemplo,
Visual Basic; pero, por eso mismo, los programas en C son más rápidos y eficientes que los
escritos en Visual Basic, aunque también pueden llegar a ser más difíciles de escribir y
depurar.
Cuando programamos en un lenguaje distinto del lenguaje máquina, nuestro código debe ser
traducido a binario para que el ordenador pueda entenderlo y ejecutarlo. Existe un programa
específico encargado de hacer esa traducción y que, dependiendo del lenguaje en el que
hayamos escrito nuestro programa, puede ser un ensamblador, un compilador o
un intérprete.
ENSAMBLADORES
Se llaman ensambladores los programas encargados de traducir los programas escritos en
ensamblador a código binario.
Fíjese en que tanto el programa traductor como el lenguaje se llaman del mismo
modo: ensamblador.
Como el lenguaje ensamblador es muy próximo al binario, estos traductores son programas
relativamente sencillos.
COMPILADORES
El compilador es un programa que traduce el código de alto nivel a código binario. Es, por
tanto, parecido al ensamblador, pero mucho más complejo, ya que las diferencias entre los
lenguajes de alto nivel y el código binario son muy grandes.
El programa escrito en lenguaje de alto nivel se denomina programa fuente ocódigo fuente.
El programa traducido a código binario se llama programa objetoo código objeto. Por lo
tanto, el compilador se encarga de convertir el programa fuente en un programa objeto.
El programa objeto, una vez generado, puede ejecutarse en la máquina en la que fue
compilado, o en otra de similares características (procesador, sistema operativo, etc.).
Cuando el programa objeto se haya disperso en diferentes archivos (lo que ocurre con
frecuencia cuando el programa es grande o, sencillamente, cuando usa funciones escritas por
terceras personas), puede ser necesario un proceso previo de enlace de los diferentes
módulos. De eso se encarga un programa llamado enlazador o linker, ya ven qué original,
que suele actuar inmediatamente después del compilador.
INTÉRPRETES
El intérprete es un programa que traduce el código de alto nivel a código binario pero, a
diferencia del compilador, lo hace en tiempo de ejecución. Es decir, no se hace un proceso
previo de traducción de todo el programa fuente a binario, sino que se va traduciendo y
ejecutando instrucción por instrucción.
La ventaja de los intérpretes es que hacen que los programas sean más portables. Así, un
programa compilado en una máquina PC bajo Windows no funcionará en un Macintosh, o en
un PC bajo Linux, a menos que se vuelva a compilar el programa fuente en el nuevo sistema.
En cambio, un programa interpretado funcionará en todas las plataformas, siempre que
dispongamos del intérprete en cada una de ellas.
JavaScript es un ejemplo de lenguaje interpretado. Esto permite que los scripts puedan
funcionar en cualquier máquina que disponga de un navegador de Internet capaz de
interpretarlos, algo común en todos los sistemas actuales. En cambio, C o C++ son
lenguajes compilados, lo que hace que los programas desarrollados con estos lenguajes se
ejecuten más rápido que sus equivalentes en JavaScript, aunque obliga a volver a
compilarlos si se desea ejecutarlos en una máquina con diferente hardware o diferente
sistema operativo (de hecho, muchos programas en C no podrían escribirse en JavaScript,
pero esa es otra historia)
Es decir: los lenguajes compilados no son mejores que los interpretados, ni al revés. Optar
por uno u otro depende de la función para la que vayamos a escribir el programa y del
entorno donde deba ejecutarse.
Ya tiene más de 30 años, lo cual es una eternidad en el universo informático, pero sigue tan
lozano como el primer día. GNU/Linux y, de hecho, casi todos los sistemas operativos están
programados en gran parte con él. Cientos de miles, tal vez millones, de programadores en
todo el mundo lo conocen. Es el lenguaje C. ¿Pero quién es este tipo? ¿A qué se debe su
longevidad?
BREVE HISTORIA DE C
Empecemos por el principio. En 1972, los laboratorios Bell necesitaban un nuevo sistema
operativo. Hasta ese momento, la mayoría de los sistemas operativos estaban escritos en
un lenguaje ensamblador ya que los lenguajes de alto nivel no generaban programas lo
suficientemente rápidos. Pero los programas escritos en ensamblador son difíciles de
mantener y Bell quería que su nuevo sistema operativo se pudiera mantener y modificar con
facilidad. Por lo tanto, se decidieron a inventar un lenguaje de alto nivel nuevo con el que
programar su sistema operativo. Este lenguaje debía cumplir dos requisitos: ser tan
manejable como cualquier otro lenguaje de alto nivel (para que los programas fueran fáciles
de mantener) y generar un código binario tan rápido como el escrito directamente en
ensamblador.
El lenguaje C como tal aparece descrito por primera vez en el libro “The C Programming
Language” (Prentice-Hall, 1978), aunténtica biblia de la programación escrita por Brian
Kerninghan y el propio Dennis Ritchie. El lenguaje se extendió rápidamente y surgieron
diferentes implementaciones con ligeras diferencias entre sí hasta que el instituto de
estándares americano (ANSI) formó un comité en 1983 para definir un estándar del
lenguaje. El primer estándar ANSI C apareció en 1989 (C89) y fue revisado en 1999 (C99).
Una evolución de C fue el lenguaje C++, diseñado por Bjarne Stroustrup en los años 80.
Además de todas las características del ANSI C, C++ incluye soporte para la orientación a
objetos, una técnica de programación ligeramente diferente de la programación
estructurada de la que ya hablaremos otro día. Y en el año 2000, Microsoft presentó
el lenguaje C#, otra evolución de C++ orientada al desarrollo de aplicaciones para
la plataforma .NET de esta compañía. Los dos lenguajes (C++ y C#) cuentan en la
actualidad con sus respectivos estándares ISO.
Hace tiempo leí (en un libro sobre C de Herbert Schildt) la siguiente reflexión, que al
principio puede resultar sorprendente:
Pero… ¿no son todos los lenguajes para programadores? La respuesta es sencillamente: no.
Analizando un poco más las razones del autor para tan rotunda negativa, se llega a la
conclusión de que existen determinados lenguajes (algunos clásicos, como Basic, Cobol o
Fortran, y otros más actuales, como Visual Basic) que han sido diseñados para permitir que
los no programadores puedan leer y comprender los programas y, presumiblemente,
aprender a escribir los suyos propios para resolver problemas sencillos.
Ahora bien, C también tiene sus detractores que lo acusan de ser confuso, críptico
y demasiado flexible. En efecto, con C se pueden desarrollar las técnicas de programación
estructurada, pero también se puede programar “código espagueti”. Otro ejemplo: C++ es
un lenguaje orientado a objetos que, sin embargo, permite saltarse a la torera todas las
reglas de la orientación a objetos.
CARACTERÍSTICAS DE C
Los bloques de código se marcan con las llaves {…}. Son equivalentes alinicio y fin del
pseudocódigo.
Todas las instrucciones terminan con un punto y coma ( ; )
Los identificadores de variables, funciones, etc., no pueden empezar con un número ni
contener espacios o símbolos especiales, salvo el de subrayado ( _ )
Los caracteres se encierran entre comillas simples ( ‘…’ )
Las cadenas de caracteres se encierran entre comillas dobles ( “…” )
El lenguaje es sensitivo a las mayúsculas. Es decir, no es lo mismo escribir main() que
MAIN() o Main()
Los tamaños en bits pueden variar dependiendo del compilador empleado. Por ejemplo, gcc
intepreta que el entero es de 32 bits, y para usar enteros de 16 bits hay que indicarlo
expresamente. Por tanto, no debe usted presuponer ningún tamaño concreto para los tipos si
quiere escribir programas portables.
El tipo char se usa normalmente para variables que guardan un único carácter, aunque lo
que en realidad guardan es un código ASCII, es decir, un número entero de 8 bits sin signo
(de 0 a 255). Los caracteres se escriben siempre entre comillas simples ( ‘…’ ). Por lo tanto,
si suponemos que x es una variable de tipo char, estas dos asignaciones tienen exactamente
el mismo efecto, ya que 65 es el código ASCII de la letra A:
x = 'A';
x = 65;
Mucho cuidado con esto, porque las cadenas de caracteres se escriben con comillas dobles
(“…”) a diferencia de las comillas simples de los caracteres sueltos.
El tipo int se usa para números enteros, mientras que los tipos float y doublesirven para
números reales. El segundo permite representar números mayores, a costa de consumir más
espacio en memoria.
El tipo void tiene tres usos. El primero es para declarar funciones que no devuelven ningún
valor (procedimientos); el segundo, para declarar funciones sin argumentos; el tercero, para
crear punteros genéricos. En otros posts se discutirán los tres usos.
Observe que en C no existe el tipo de dato lógico. Se utiliza en su lugar el tipo int,
representando el 0 el valor falso y cualquier otra cantidad (normalmente 1) el valor
verdadero.
MODIFICADORES DE TIPO
Existen, además, unos modificadores de tipo que pueden preceder a los tipos de
datos char e int. Dichos modificadores son:
unsigned long int: Número entero de 32 bits sin signo. Rango: de 0 a 4294967295
(Recuerde nuevamente lo que decíamos al principio sobre la cantidad de bits asociada a cada
tipo: puede variar dependiendo del compilador y del sistema con el que trabajemos)
Expresiones y operadores en C
22 marzo 2008 in ...en lenguaje C, Programación
(Este artículo forma parte del Curso de Programación en C)
C es un lenguaje muy rico en operadores, por lo que ahora solo hablaremos de los más
habituales, dejando otros muy específicos para artículos posteriores, dentro de cien o
doscientos años.
OPERADORES ARITMÉTICOS
Igual que en pseudocódigo, en C existen los operadores aritméticos típicos, y alguno más
que más abajo comentaremos:
Operador Operación
+ Suma
- Resta
* Multiplicación
/ División
% Módulo
++ Incremento
– Decremento
Se pueden utilizar paréntesis ( ) para cambiar el orden de las operaciones, pero no corchetes
[ ], que C se reserva para otros usos.
Observe que no existe el operador potencia. En C, las potencias se calculan con funciones de
librería.
Los operadores incremento y decremento no suelen existir en otros lenguajes, salvo los que
derivan de C, pero son muy prácticos. Sirven para abreviar las expresiones típicas de los
contadores:
cont++;
es equivalente a
cont = cont + 1;
cont++;
++cont;
Ahora bien, no son exactamente iguales cuando aparecen como parte de una expresión, ya
que la primera se realiza después de evaluar la expresión, y, la segunda, antes. Esto quiere
decir que, en el siguiente caso, tanto la variable x como la variable y tomarán el valor 11:
x = 10;
y = ++x;
Pero, escrito de esta otra forma, la variable x toma el valor 11, pero y se queda con 10, ya
que el incremento (x++) se realiza después de evaluar la expresión y asignarla a la variable
y:
x = 10;
y = x++;
Operadores relacionales
Recuede que, en C, no existe el tipo de dato lógico, sino que se emplean números enteros.
Falso se representa con el valor 0. Verdadero se representa con cualquier valor distinto de
cero, aunque preferentemente se usa el 1.
Los operadores lógicos de C también son los mismos que usamos en pseudocódigo, aunque
se escriben de manera diferente. Recuerde que el resultado de las operaciones lógicas, en C,
no es verdadero o falso, sino 1 ó 0.
Operación Y: &&
Operación O: ||
Operación No: !
OTROS OPERADORES DE C
C dispone de otros operadores sobre los que el lector puede obtener información en
cualquier manual de programación en C. Aquí nos limitaremos a mencionarlos, y, con tiempo
y paciencia, los iremos estudiando en posts posteriores.
Operadores a nivel de bits: & (and), | (or), ^ (xor), ~ (complemento a uno), >>
(desplazamiento a la derecha) y << (desplazamiento a la izquierda). Actúan
directamente sobre los bits de la representación binaria de un dato.
Operador condicional: ? (puede sustituir a condicionales simples y dobles)
Operadores puntero: & (dirección) y * (contenido).
Operador en tiempo de compilación: sizeof (longitud en bytes de un identificador).
Operadores de acceso a elementos de estructuras : . (acceso directo) y -> (acceso por
puntero).
PRECEDENCIA DE OPERADORES Y CONVERSIÓN DE TIPOS EN EXPRESIONES
Las expresiones en C son similares a las que hemos visto con pseudocódigo: combinaciones
de variables, constantes y operadores. Las expresiones se evalúan, es decir, se calcula su
resultado, aplicando las reglas de precedencia de operadores, que pueden alterarse mediante
el uso de paréntesis. Las reglas de precedencia son las mismas que aplicamos en
pseudocódigo.
En una expresión es posible que aparezcan variables y/o constantes de diferentes tipos de
datos. Cuando esto ocurre, C convierte todos los datos al tipo más grande. Por ejemplo, si
aparecen datos de tipo short int, int y long int, todos ellos se convertirán a long int antes de
realizar las operaciones.
Todas las variables deben declararse antes de ser usadas. La sintaxis de la declaración
incluye su tipo y su nombre (identificador):
tipo_de_datos lista_de_variables;
Por ejemplo:
int cont;
char respuesta;
float x, y, resultado;
En C99 no está delimitado el lugar del algoritmo donde deben declararse las variables,
siendo la única condición que se declaren antes de ser usadas por primera vez. Sin embargo,
nosotros haremos siempre la declaración al principio del algoritmo, antes de la primera
instrucción. Esto no cuesta ningún trabajo y nos asegurará la compatibilidad con estándares
de C más antiguos.
Todas las variables son locales a la función donde estén definidas, dejando de existir al
finalizar la función. Las variables globales se declaran fuera del cuerpo de todas las funciones
y antes de la función main(), que es el algoritmo principal. Recuerda que debes evitar el uso
de variables globales a menos que sea estrictamente necesario.
Para asignar un valor a una variable se utiliza la sentencia de asignación, exactamente igual
que en pseudocódigo. Por ejemplo:
cont = cont + 1;
respuesta = 'S';
x = 5.33;
CONSTANTES
Recuerde que también se pueden usar identificadores para asociarlos a valores constantes,
es decir, valores que no cambiarán nunca durante la ejecución del programa.
Para declarar una constante y asignarle un valor se utiliza el modificador const delante de la
declaración:
Por ejemplo:
#define PI = 3.141592
Las directivas no son instrucciones de C, sino consignas comunicadas al compilador para que
sepa que, si encuentra el símbolo PI en el código fuente, debe sustituirlo por 3.141592.
Estudiaremos las directivas con más detalle en posts posteriores. Por ahora nos basta saber
que existen estas dos formas de declarar constantes.
CONVERSIONES DE TIPO
float a;
int b;
b = 5;
a = b;
Se ha asignado un valor entero a la variable “a”, que es de tipo float. En otros lenguajes esto
no está permitido, pero en C se realizan conversiones automáticas de tipo cuando en una
misma expresión aparecen datos de tipos diferentes. Esto, que en principio es una ventaja,
pues elimina algunas limitaciones engorrosas, otras veces es peligroso porque algunos datos
pueden cambiar extrañamente de valor al hacerse esa conversión automática.
Asignación de un valor a una variable que permita más precisión. Por ejemplo, asignar
un número entero a una variable float. En este caso, el número se convierte a real
añadiendo “.0″ a la parte decimal. No hay pérdida de información.
Asignación de un valor a una variable que permita menos precisión. Por ejemplo, asignar
un número long int a una variable de tipo int. En este caso, el número se recorta,
perdiendo sus bits más significativos, es decir, los que están a la izquierda, y por lo tanto
hay pérdida de información. Hay que tener mucho cuidado con este tipo de
conversiones porque pueden producir resultados imprevisibles
Además de las conversiones automáticas de tipo, el programador puede forzar la conversión
de tipos a voluntad utilizando moldes. Un molde es una expresión de un tipo de datos entre
paréntesis que aparece delante de un dato. Entonces, antes de evaluar la expresión, el dato
es convertido al tipo especificado en el molde. Por ejemplo:
float a;
int b;
a = 5;
b = (float)a/2;
Sin el molde (float), la división a/2 sería entera, ya que a es una variable de tipo int, y se
perdería la parte decimal. Al aplicar el molde, se convierte momentáneamente el valor entero
5 al valor real 5.0 y se evalúa la expresión, que ahora sí se realiza como división real,
conservando sus decimales.
Bucles en lenguaje C
24 marzo 2008 in ...en lenguaje C, ...para principiantes, Programación
(Este artículo forma parte del Curso de Programación en C)
Como hicimos con las instrucciones condicionales, presentaremos ahora la sintaxis de las
estructuras iterativas en C. Para más detalles sobre cómo funciona cada estructura, pueden
consultar el artículo donde se explicaronusando pseudocódigo.
BUCLE MIENTRAS
while (condición)
acciones
Un ejemplo de uso:
int n, i;
scanf("%i", &n);
i = 1;
i++;
BUCLE REPETIR
do
acciones
while (condición);
Un ejemplo de uso:
int n, i;
scanf("%i", &n);
i = 1;
do
i++;
Cuidado con el while del final. NO es un while de un bucle “mientras”, claro. Pero, al usarse
la misma palabra reservada, puede mover a error. El while de un bucle “repetir” se distingue
porque lleva un punto y coma (;) detrás, ya que la instrucción “repetir” termina ahí. Un bucle
“mientras” no lleva punto y coma tras el while, porque aún no termina.
BUCLE PARA
acciones
Cuesta acostumbrarse a este bucle al principio, sobre todo si usted tiene experiencia con
otros lenguajes estructurados. Su sintaxis es algo diferente a la que hemos visto en
pseudocódigo. Ya se ha dicho en varias ocasiones que C es a veces un poco críptico. El bucle
para (o bucle for) es un ejemplo típico de ello ya que:
La variable contador debe ser inicializada con una asignación dentro de la instrucción for.
El valor final debe ser expresado en forma de condición, como haríamos en un bucle
mientras.
El incremento del contador hay que indicarlo explícitamente.
Por ejemplo, el siguiente bucle en pseudocódigo:
inicio
acciones
fin
acciones
Como hemos visto anteriormente, C es un lenguaje modular hasta el extremo de que todas
las líneas de código deben pertenecer a alguna función, incluyendo las instrucciones del
algoritmo principal, que se escriben en una función llamada principal (main en inglés).
FUNCIONES
{
...instrucciones...
return expresión;
Observe que las únicas diferencias con el pseudocódigo son que no se usa la palabra
“función”, que las llaves { y } sustituyen a inicio y fin, y que se emplea la palabra return en
lugar de devolver.
PROCEDIMIENTOS
Si el tipo_devuelto es void, se considera que la función no devuelve ningún valor y que, por
lo tanto, es un procedimiento. Entonces, un procedimiento se declara así:
...instrucciones...
PASO DE PARÁMETROS
Los parámetros formales son, como en pseudocódigo, una lista de tipos e identificadores que
se sustituirán por los parámetros actuales y se usarán como variables dentro de la función.
Los parámetros se pasan normalmente por valor, pero también se pueden pasar
por referencia. El paso de parámetros por referencia admite dos sitaxis ligeramente
diferentes en C: anteponiendo el operador * (asterisco) al nombre del parámetro (como
hemos hecho en pseudocódigo1) o anteponiendo el operador &. Veamos ambos casos más
despacio.
Esto quiere decir que la función1 recibirá únicamente el valor de los dos parámetros, x e y.
Podrá utilizar esos valores a lo largo de su código, e incluso podrá cambiarlos. Pero cualquier
cambio en x e y no afectará a los parámetros actuales, es decir, a los parámetros del
programa que llamó a función1.
En la siguiente función, el paso del parámetro “x” es por valor y el del parámetro “y”, por
referencia:
¡OJO! En esto difiere C del pseudocódigo: cada vez que se vaya a usar el parámetro
“y” dentro del código de la función, será necesario acompañarlo del asterisco. Por ejemplo:
*y = 5;
x = 17 + *y;
Por último, también en la llamada a la función hay que indicar explícitamente si alguno de los
parámetros se está pasando por referencia, utilizando el operador &, como en pseudocódigo.
Por lo tanto, para llamar a la funcion2 del ejemplo anterior con los parámetros A y B habrá
que escribir:
Observe que el segundo parámetro (el que se pasa por referencia), lleva delante el operador
&.
Otra forma de pasar un argumento por referencia es usar el operador & en los parámetros
formales, así:
En esta función, el parámetro “x” se pasa por valor y el parámetro “y” se pasa por
referencia. Utilizando esta sintaxis no es necesario añadir asteriscos cada vez que se usa la
“y” en el cuerpo de la función, ni tampoco usar “&” en la llamada a la función.
Pero ¡cuidado!. Esta sintaxis es propia de C++ y no está definida en C. La mayoría de los
compiladores de C compilan también C++, así que se tragarán esta sintaxis sin quejarse
demasiado. Pero, si decide usarla, debe ser consciente de que está introduciendo un
fragmento de código C++ dentro de un programa en C, y que puede causarle problemas
dependiendo de su compilador y de las opciones de compilación del mismo.
UN BONITO EJEMPLO
En el siguiente ejemplo se ilustran los dos tipos de paso de parámetros y, en el paso por
referencia, las dos sintaxis alternativas de que dispone C.
El ejemplo muestra tres funciones muy similares que reciben dos parámetros, a y b. Las tres
intentan intercambiar el valor de a y b mediante una tercera variable, tmp. Sin embargo, en
la primera de ellas el intercambio no tiene ningún efecto en el programa main(), ya que los
parámetros están pasados por valor. En las otras dos funciones sí que se consigue el
intercambio, ya que los parámetros está pasados por referencia.
El objetivo de este ejemplo es mostrar cuál es la sintaxis correcta en cada tipo de paso de
parámetros.
#include <stdio.h>
int tmp = a;
a = b;
b = tmp;
*a = *b;
*b = tmp;
int tmp = a;
a = b;
b = tmp;
// Programa principal
int main()
intercambiar1(dato1, dato2);
intercambiar3(dato1, dato2);
return 0;
Una última observación antes de terminar por hoy: existe una poderosa razón por la que se
utilizan los operadores * y & en el paso de parámetros por referencia. Sin embargo, es
demasiado pronto para explicarla. Volveremos sobre ello cuando veamos los punteros. Es
una amenaza.
La funció n main()
23 marzo 2008 in ...en lenguaje C, ...para estudiantes, ...para principiantes,Programación
(Este artículo forma parte del Curso de Programación en C)
En otros artículos hemos dicho que C es un lenguaje estrictamente modular: todo el código
debe estar ubicado en el interior de funciones.
La función llamada main() es sólo eso, una función. Pero una función especial. Existe en
todos los programas, porque contiene el algoritmo o módulo principal del programa. La
ejecución de un programa siempre empieza por la primera línea de la función main().
La función main(), como todas las funciones de C, puede devolver un valor. El valor devuelto
por main() debe ser de tipo entero. Esto se utiliza para pasar algún valor al programa que
haya llamado al nuestro, que suele ser el sistema operativo. Si main() no devuelve un
número entero al sistema operativo mediante una sentencia return, entonces nuestro
programa devolverá un número desconocido. Moraleja: es una buena idea incluir un return al
final de la función main(). Generalmente, la devolución de un 0 indica al sistema operativo
que el programa a finalizado sin problemas, mientras que cualquier otro valor señala que se
ha producido algún error.
int main(void)
return 0;
Observa que main() no tiene argumentos, por lo que aparece el identificadorvoid entre
paréntesis en la declaración.
Es posible que vea por esos mundos una definición de main() con argumentos, algo así:
Esos dos argumentos sirven para capturar parámetros de entrada desde la línea de
comandos. Bien, por ahora, olvidémonos de esos parámetros. Utilizaremos main() sin
parámetros y, en el apartado de “Aspectos avanzados” de este mismo curso, volveremos
sobre ellos para aprender a usarlos.
Para conseguir que C realice esas comprobaciones se utilizan los prototipos de función. Un
prototipo de función es, en pocas palabras, la declaración de una función. Consiste,
simplemente, en la primera línea del código la función. El prototipo debe aparecer antes de
que la función se invoque por primera vez, aunque el código completo de la función esté en
otra parte. Los prototipos permiten al compilador comprobar que los argumentos de la
función coinciden en tipo y número con los de la invocación de la misma, y que el tipo
devuelto es el correcto.
Los prototipos suelen aparecer al principio del programa, antes de la funciónmain(). Observe,
en el siguiente ejemplo, que el prototipo de la funcióncalcular_area() se coloca delante
de main(). Sin embargo, el código concreto de esta función no aparece hasta después
(incluso podría estar situado en otro archivo diferente):
...instrucciones...
...más instrucciones...
return 0;
... instrucciones...
}
ARCHIVOS DE CABECERA
En C se soluciona este problema con los archivos de cabecera, que son archivos que incluyen
en su interior, entre otras cosas, los prototipos de las funciones de librería. Como funciones
de librería hay muchas, también hay muchos archivos de cabecera. Por ejemplo, el
archivo math.h tiene los prototipos de todas las funciones matemáticas estándar. Todos los
archivos de cabecera tienen la extensión “.h” en su nombre (h de “header”).
Por ejemplo, esta línea de código sirve para incluir todos los prototipos de las funciones de
librería matemática en nuestro programa:
#include <math.h>
Cada vez que necesite usar una de las funciones estándar en un programa, debe escribir al
principio del mismo el #include del archivo de cabecera donde esa función se encuentra
definida para disponer así del prototipo. Esto también es aplicable a las funciones no
estándar, es decir, a las funciones de librerías escritas por terceros, salvo que, en este caso,
el proceso de enlace no es automático y hay que indicarle al compilador dónde puede
encontrar la librería y de qué librería se trata.
Suele resultar útil, sobre todo cuando se empieza a trabajar con un nuevo lenguaje, disponer
de una plantilla con la estructura habitual de un programa en dicho lenguaje. De eso trata
este artículo.
Todo programa en C, desde el más pequeño hasta el más complejo, tiene unafunción
principal denominada main(). Además, por encima de main() deben aparecer los prototipos
de funciones (y esto implica a los archivos de cabecera, si se utilizan funciones de librería) y
las variables y constantes globales, si las hay. Por debajo de main() encontraremos el código
del resto de funciones.
#include <archivo_cabecera.h>
#include <archivo_cabecera.h>
/* Prototipos de funciones escritas por nosotros */
int variable_global;
#define PI 3.14
/* Algoritmo principal */
int main(void)
int a, b;
float x, y;
...
...
...
función1(argumentos);
...
función2(argumentos);
...
return 0;
}
float función2 (argumentos)
El estándar ANSI C dispone de muchas funciones estándar para hacer las entradas y salidas
de datos. En concreto, dispone de un subconjunto de ellas para hacer la entrada y salida por
consola, es decir, por teclado y pantalla.
La función printf() (de “print” = imprimir y “f” = formato) sirve para escribir datos en el
dispositivo de salida estándar (generalmente la pantalla) con un formato determinado por el
programador. La forma general de utilizarla es la siguiente:
printf(cadena_de_formato, datos);
int a;
a = 5;
printf("%i", a);
En una sola instrucción printf() pueden escribirse varios datos. Por ejemplo:
int a;
float x;
a = 5;
x = 10.33;
printf("%i%f", a, x);
Observe detenidamente la cadena de formato: primero aparece “%i” y luego “%f”. Esto
indica que el primer dato que debe imprimirse es un entero, y el segundo, un real. Después,
aparecen esos datos separados por comas y exactamente en el mismo orden que en la
cadena de formato: primero a (la variable entera) y luego x (la variable real). El resultado
será que en la pantalla se escribirán los números 5 y 10.33.
%c – carácter
%d – número entero
%i – número entero
%e – número real con notación científica
%f – número real
%g – usar %e o %f, el más corto
%o – número octal
%s – cadena de caracteres
%u – untero sin signo
%x – número hexadecimal
%p – puntero
Algunos de estos códigos sirven para imprimir tipos de datos que aún no conocemos, pero
que iremos viendo en próximos artículos.
Los códigos numéricos “%i”, “%d”, “%u” (para números enteros) y “%f”, “%e” y “%g”
(para números reales), permiten insertar modificadores de longitud “l” (longitud doble) y
“h” (longitud corta). Así, por ejemplo, “%ld” indica que se va a imprimir un entero de
longitud doble (long int); “%hu” sirve para enteros cortos sin signo (unsigned short int);
“%lf” indica que se imprimirá un número real de longitud doble (double), etc.
El código “%f” (números reales) se pueden usar con un modificador de posiciones
decimales que se desean mostrar. Por ejemplo, con “%10.4f” obligamos a que se
impriman diez dígitos a la izquierda de la coma decimal y cuatro a la derecha. La
escritura se ajusta a la derecha. Para ajustarla a la izquierda se utiliza el modificador “-”,
de esta forma: “%-10.4f”
El código “%s” (cadenas de caracteres) se puede combinar con un especificador de
longitud máxima y mínima de la cadena. Por ejemplo, “%4.8s” escribe una cadena de al
menos cuatro caracteres y no más de ocho. Si la cadena tiene más, se pierden los que
excedan de ocho. También se puede utilizar el modificador “-” para alinear el texto a la
izquierda.
Además de los códigos de formato, en la cadena de formato puede aparecer cualquier texto
entremezclado con los códigos. A la hora de escribir en la pantalla, los códigos serán
sustituidos por los datos correspondientes, pero el resto del texto aparecerá de forma literal.
Por ejemplo:
int a;
float x;
a = 5;
x = 10.33;
Una última observación sobre printf(). Hay ciertos caracteres que no son directamente
imprimibles desde el teclado. Uno de ellos es el salto de línea. Para poder ordenar
a printf() que escriba un salto de línea (o cualquier otro carácter no imprimible) se utilizan
los códigos de barra invertida, que con códigos especiales precedidos del carácter “\”.
En concreto, el carácter “salto de línea” se indica con el código “\n”. Observe las diferencias
entre estos dos bloques de instrucciones para intentar comprender la importancia del salto
de línea:
int a;
a = 5;
a = 14;
int a;
a = 5;
a = 14;
La variable a vale 5
La variable a vale 14
ENTRADA DE DATOS CON FORMATO: LA FUNCIÓN SCANF()
La función scanf() es, en muchos sentidos, la inversa de printf(). Puede leer desde el
dispositivo de entrada estándar (normalmente el teclado) datos de cualquier tipo de los
manejados por el compilador, convirtiéndolos al formato interno apropiado. Funciona de
manera análoga a printf(), por lo que su sintaxis es:
scanf(cadena_de_formato, datos);
La cadena_de_formato tiene la misma composición que la de printf(). Los datos son las
variables donde se desea almacenar el dato o datos leidos desde el teclado. ¡Cuidado! Con
los tipos simples, es necesario utilizar el operador & delante del nombre de la variable,
porque esa variable se pasa por referencia a scanf() para que ésta pueda modificarla.
Por ejemplo:
int a, b;
float x;
scanf("%d", &a);
La primera llamada a scanf() sirve para leer un número entero desde teclado y almacenarlo
en la variable a. La segunda llamada lee dos números: el primero, entero, que se almacena
en b; y, el segundo, real, que se almacena en x.
La función scanf() tiene alguna otra funcionalidad añadida para el manejo de cadenas de
caracteres que por ahora no vamos a discutir, para no agobiar.
En pseudocódigo:
algoritmo suma_y_resta
variables
a y b son enteros
inicio
leer(a, b)
si (a < b) entonces
si_no
En lenguaje C:
#include <stdio.h>
int main()
int a, b;
if (a < b)
else
return 0;
Técnicamente, con printf() y scanf() es posible escribir y leer cualquier tipo de datos desde
cualquier dispositivo de salida o entrada, no solo la pantalla y el teclado, como de hecho
comprobaremos cuando estudiemos los ficheros.
En la práctica, aunque printf() resulta bastante efectiva y versátil, scanf() suele dar muchos
problemas en la mayoría de los entornos de desarrollo. Existe otro grupo de funciones en
ANSI C específicamente diseñadas para hacer la E/S por consola, es decir, por teclado y
pantalla, de manera más simple. Las resumimos a continuación.
FUNCIÓN GETCHAR()
Espera a que se pulse una tecla seguida de INTRO y devuelve su valor en código ASCII, es
decir, en formato carácter. Muestra el eco en la pantalla, es decir, la tecla pulsada aparece
en la pantalla.
char car;
car = getchar();
putchar(car);
FUNCIÓN PUTCHAR()
char c;
c = 'A';
putchar(c);
FUNCIÓN GETS()
gets(cadena);
puts(cadena);
FUNCIÓN PUTS()
scanf() suele causar problemas con el buffer del teclado. Si hacemos un scanf() para leer,
por ejemplo, un número entero y, a continuación, intentamos un scanf() con un carácter o
una cadena, es segundo scanf() fallará.
int i;
char c[50];
scanf("%i", &i);
El motivo del fallo se debe a que el primer scanf() asigna un número a la variable entera,
pero el carácter de retorno de carro queda sin consumir en el buffer de entrada del teclado.
Al llegar al segundo scanf(), el carácter de salto de línea, que aún estaba en el buffer, se
asigna automáticamente a la variable de tipo cadena. Al ejecutar el programa, tendremos la
desagradable sorpresa de que el segundo scanf() es ignorador.
Para evitar este tipo de problemas con scanf(), podemos recurrir a gets() para leer las
cadenas de caracteres. Si necesitamos leer un número, podemos usar gets() y luego
convertir la cadena a un tipo de dato numérico con las funciones de conversión atoi() y
atof(), como se muestra en el siguiente ejemplo:
char cadena[50];
int a;
float x;
Usar la combinación de gets() con atoi() o atof() es más costoso que utilizar scanf().
Primero, porque necesitamos una variable auxiliar de tipo cadena. Y, segundo, porque gets()
es una función peligrosa: si se teclean más caracteres de los que caben en la cadena, el
resultado es imprevisible (a menudo el programa se cuelga). Esto también tiene solución
utilizando en su lugar la función fgets(), de la que hablaremos cuando nos refiramos a los
ficheros. ¡Es debido a este tipo de complicaciones en las cosas más triviales por lo que C
tiene exaltados detractores!
Por último, mencionaremos que los compiladores de Borland tienen dos variaciones de la
función getchar() llamadas getche() y getch(). Estas funciones, no definidas en el estándar
ANSI de C, son como getchar() pero sin necesidad de pulsar INTRO detrás del carácter. La
primera muestra el eco, es decir, escribe en la pantalla la tecla pulsada, y la segunda no. Los
prototipos de estas funciones se encuentran en conio.h (de “con” = consola e “io” =
input/output)
La edición del código (o, como se dice a veces, “picar código”) es un acto cotidiano para el
programador que no está exento de sus pequeños rituales (propios de cada uno) y de
buenas costumbres (generales a todo el mundo). A continuación hablaremos de esas
costumbres; los rituales, que se los busque cada cual. Pero antes, permítanme insistir una
vez más: nunca empiecen a picar código sin haber planificado cuidadosamente su trabajo.
Proceder de ese modo es la forma más segura de tener que tirar a la basura varios días de
trabajo. Revisen este post si no saben de lo que hablo.
A continuación nos referiremos al lenguaje C, pero todo lo que digamos puede aplicarse a la
edición de código en cualquier otro lenguaje.
Para escribir el código nos puede servir cualquier procesador de textos que permita guardar
el documento en forma de texto plano (sin códigos de control y formato propios de los
procesadores avanzados, como Word o Writer). Existen multitud de procesadores de texto
estupendos para programar en lenguaje C, pero el bloc de notas de Windows,
definitivamente, no es uno de ellos (en realidad, no me imagino ninguna situación real en la
que el bloc de notas pueda ser una herramienta estupenda).
No están todas las que son, pero, como suele decirse, sí son todas las que están:
No empiece a teclear código sin haber entendido bien el problema que se le plantea. Si
éste es complejo, es imprescindible elaborar antes unadescomposición modular en papel,
resolviendo los módulos con pseudocódigo o con diagramas de flujo.
Recuerde: comenzar a teclear a lo loco y sin pensar antes la solución detenidamente es
la manera más segura de tardar el mayor tiempo posible en desarrollar un programa
que, además, no funcione bien.
Realice un diseño modular previo del programa. Recuerde que un módulo de más de 30
ó 40 líneas (aproximadamente) empieza a ser demasiado largo.
Evite las variables globales. Evite las instrucciones GOTO. Realmente, no hay ninguna
razón para usarlas, y más aún si ha diseñado correctamente el programa.
Elija bien el nombre de los identificadores (variables, constantes, funciones…), le
ahorrará muchos quebraderos de cabeza. No llame a su variable e si puede
llamarla edad, pero tampoco la llameedad_del_jugador_1 si no es absolutamente
imprescindible. Los identificadores deben ser significativos pero no excesivamente
largos.
Use la identación del texto, es decir, deje las sangrías necesarias para facilitar su lectura.
Use espacios y líneas en blanco siempre que considere que facilita la lectura. Es mucho
más fácil de leer if (x > 5 + c) que if(x>5+c).
Desarrolle su propio estilo de escritura, siempre que sea razonable, y sígalo en todo el
programa. Si está colaborando con otros programadores, use el estilo que hayan
conveniado entre todos.
Sea generoso documentando el código fuente. Mejor que sobren comentarios que no que
falten. Ante la duda, comente. En particular, es importante comentar cada función: qué
hace, qué parámetros recibe, qué significan los valores de los parámetros y qué valores
devuelve.
Si está programando en C, guarde el código fuente en archivos de texto cuya extensión
sea “.c” (por ejemplo: “ejercicio.c”). Fíjese que “.c” no es lo mismo que “.cpp”
CÓMO NO SE DEBE PROGRAMAR
Ignore los mensajes de error: los compiladores, los sistemas operativos, etc, emiten
mensajes de error sólo para que los usen sus creadores, o para justificar sus sueldos.
Escriba el código directamente sin pensar: ¿Qué es lo que estamos construyendo? Un
programa. ¿Qué es lo único imprescindible en un programa? El código. ¿Qué es lo que de
verdad funciona? El código. No hay que perder ni un minuto en usar medios arcaicos
como lápices, bolígrafos o papel.
Aunque el código no compile o no funcione, siga escribiendo: es sabido que los mensajes
de error son una interrupción inadmisible, una traba estúpida a nuestro trabajo. ¿Qué
puede hacer si tiene un error de compilación? Ya hemos visto que leérselo y
comprenderlo no es una opción válida. Se puede intentar hacer algún cambio aleatorio
en el código fuente, a ver si hay forma de engañar a ese estúpido compilador. Pero si
eso no funciona, no pierda más tiempo. NO, no caiga en la tentación de leer el mensaje
de error o intentar comprenderlo.
Construya enormes porciones de código sin compilar / ejecutar / probar: no compile con
frecuencia; no dé pasos pequeñitos. Escriba miles de líneas de código, y ya después se
compilará. Así será mucho más entretenido buscar los errores de compilación y arreglar
el código, lo que constituye un excelente ejercicio.
Compilació n y enlace
29 marzo 2008 in ...para estudiantes, ...para principiantes, Programación
(Este artículo forma parte del Curso de Programación en C)
COMPILACIÓN
Los programas cortos se guardan en un único archivo fuente que se traducirá a un único
archivo objeto. Pero cuando los programas crecen, es habitual distribuir el código fuente en
varios archivos con el objetivo de manipularlo mejor.
Los compiladores de C usan compilación separada. Esto significa que, si un programa largo
está escrito en varios archivos fuente, no es necesario compilarlos todos cada vez que se
modifica algo. Basta con volver a compilar el archivo modificado. Por eso, dividir un
programa fuente largo en varios archivos más cortos también sirve para mejorar los tiempos
de compilación.
Cuando tenemos varios archivos fuente es normal que existan dependenciasentre ellos. Por
ejemplo, supongamos que en un archivo A1 se utiliza (con la directiva #include) un archivo
de cabecera A2. Si modificamos el archivo A2 es necesario volver a compilar el archivo A1,
aunque A1 no haya sido modificado en absoluto. Se dice entonces que existe una
dependencia entre los archivos A1 y A2.
Controlar las dependencias es un trabajo tedioso y propenso a errores. Por fortuna, los
compiladores se encargan de controlarlas por sí mismos o con ayuda de alguna herramienta
adicional. Así que no se extrañe si, al volver a compilar un archivo fuente después de
modificarlo, se compilan automáticamente algunos otros archivos, aunque se hayan
modificado. El control de las dependencias lo puede realizar el compilador de manera
automática o semiautomática (mediante archivos de dependencias o makefiles escritos por el
programador), como veremos en otro momento.
ENLACE (LINK)
Cuando existen varios programas objeto es necesario combinarlos todos para dar lugar al
programa ejecutable definitivo. Este proceso se denomina enlace.
Si tenemos que enlazar otros archivos, bien porque nuestro programa esté repartido en
varios archivos fuentes, o bien porque estemos utilizando librerías no estándar, habrá que
indicar al enlazador cómo enlazar esos archivos adicionales y dónde se encuentran las
librerías. El funcionamiento de Dev-C++ se trata más detenidamente en este post, y, el de
gcc, en este otro.
El enlace de nuestro código objeto con las funciones de librería puede hacerse de dos
maneras:
Enlace estático. Consiste en unir durante el enlace el código objeto de las librerías con
el código del programa, generando así el ejecutable. El programa ejecutable crece
notablemente de tamaño respecto de los archivos objeto, ya que incorpora el código de
todas las funciones de las librerías. El enlace estático es el que normalmente se utiliza a
menos que indiquemos otra cosa.
Enlace dinámico. El código de las librerías no se une al del programa, sino que se
busca durante la ejecución, únicamente cuando es requerido. El enlace dinámico
produce, por lo tanto, ejecuciones más lentas, ya que cada vez que se use una función
de librería dinámica es necesario buscar el archivo en el que se encuentra y ejecutar su
código. Además, pueden producirse errores de enlace durante la ejecución del programa.
Sin embargo, el enlace dinámico tiene las ventajas de reducir el tamaño del archivo
ejecutable y permitir la compartición de librerías entre diferentes aplicaciones.
Depuració n de un programa
29 marzo 2008 in ...para estudiantes, ...para principiantes, Programación
(Este artículo forma parte del Curso de Programación en C)
La depuración del programa consiste en localizar y corregir los errores que se hayan podido
producir durante el desarrollo. El objetivo es conseguir un programa que funcione lo más
correctamente posible, aunque hay que tener presente que ningún programa complejo está
libre de errores al 100%
Se producen al traducir el código fuente a código objeto. El compilador los detecta y marca
en qué línea se han producido, y de qué tipo son, por lo que son relativamente fáciles de
corregir. Los errores de compilación más frecuentes son:
Errores sintácticos: escribir mal alguna instrucción o algún identificador, u olvidarnos del
punto y coma que debe terminar cada instrucción.
Errores de tipos: intentar asignar a una variable de cierto tipo un valor de otro tipo
incompatible, o invocar a una función con argumentos de tipo equivocado. Recuerde que
C puede hacer conversiones de tipo automáticas, por lo que estos errores pueden quedar
enmascarados.
Errores de identificadores no reconocidos: ocurren cuando se intenta utilizar una variable
o una constante que no ha sido declarada, o cuyo ámbito no llega al lugar donde se
intenta utilizar.
Además de los errores, el compilador puede dar avisos (warnings) en lugares donde
potencialmente puede existir un error de compilación. Es conveniente revisar todos los
avisos y tratar de corregirlos antes de continuar con la ejecución.
La causa más habitual de fallo del enlazador es la inconsistencia entre la definición de una
función, la llamada a esa función y su prototipo. Por ejemplo, si tenemos una función cuyo
prototipo es:
int prueba(char a)
Otra causa de fallo del enlazador es el intento de uso de una función sin enlazar
adecuadamente la librería. En la sección de librerías no estándar (SDL, ncurses, etc) del
curso de programación en C puede encontrar información sobre cómo enlazar ésas u otras
librerías adicionales.
Los errores que surgen en tiempo de ejecución son los más complicados de corregir, ya que
muchas veces no está clara la causa del error. En el peor de los casos, puede ser necesario
rediseñar la aplicación por completo. Simplificando mucho, podemos encontrarnos con estos
errores en tiempo de ejecución:
Errores lógicos. Se producen cuando alguna condición lógica está mal planteada.
Entonces, el flujo del programa puede ir por la rama “si_no” cuando debería ir por la
rama “si”, o puede salirse de un bucle cuando debería repetir una vez más, o entrar en
un bucle infinito, etc.
Errores aritméticos. Ocurren cuando una variable se desborda (overflow), o se intenta
una operación de división entre cero, o alguna operación aritmética está mal planteada.
Errores de punteros. Los punteros son herramientas muy potentes que permiten la
manipulación dinámica de la memoria, pero también conllevan grandes riesgos porque
un puntero “descontrolado” puede hacer auténticas locuras en la memoria del ordenador,
hasta el punto de colgar sistemas poco fiables (como los sistemas Windows anteriores a
XP)
Errores de conversión automática de tipos. Se producen cuando C realiza una conversión
automática que no teníamos prevista. Entonces el dato puede cambiar y dar al traste con
la lógica del programa.
Errores de diseño. Ocurren cuando el programa no está bien diseñado y realiza tareas
diferentes de las que se pretendían. Son los peores errores, porque obligarán a modificar
una parte (o la totalidad) del trabajo realizado, debiendo, en ocasiones, volver a las
primeras fases del ciclo de vida para repetir todo el proceso.
Estos y otros errores en tiempo de ejecución pueden manifestarse con distintas frecuencias:
Siempre que se ejecuta el programa: son los más fáciles de localizar y corregir.
Solo cuando se introducen determinados datos de entrada: puede ser complicado dar
con la secuencia de datos de entrada que provocan el error, pero una vez que la
encontramos, puede localizarse con facilidad.
Al azar: algunas veces, los programas fallan sin motivo aparente, cuando han estado
funcionando en el pasado con el mismo conjunto de datos. Son los errores más difíciles
de localizar, porque ni siquiera se sabe bajo qué circunstancias ocurren.
EL DEPURADOR
El depurador es una herramienta fundamental para localizar y corregir los errores en tiempo
de ejecución de los que hablábamos más arriba. Cada depurador tiene sus propias opciones
y características, pero todos suelen coincidir en varios aspectos:
Permiten ejecutar paso a paso cada instrucción del programa, deteniéndose antes de
ejecutar la siguiente para permitirnos ver el estado de las variables o de los dispositivos
de E/S.
Permiten ver y manipular el contenido de las variables en cualquier punto del programa.
Permiten ver y manipular la estructura de la memoria asignada al programa y de los
registros del microprocesador.
Permiten insertar puntos de ruptura (breakpoints), es decir, puntos donde la ejecución
se detendrá momentáneamente para que hagamos alguna comprobación de las
anteriormente expuestas.
Haciendo correcto uso de estas posibilidades, podemos localizar rápidamente cualquier
error en tiempo de ejecución y afrontar la tarea de corregirlo.
En este post existe información más detallada sobre el funcionamiento de los depuradores.
La documentació n externa
30 marzo 2008 in ...para estudiantes, ...para principiantes, Programación
(Este artículo forma parte del Curso de Programación en C)
La documentación no es exactamente una fase del desarrollo del software, sino una actividad
que debe practicarse a lo largo de todo el desarrollo.
El manual técnico
Existen muchos compiladores de C/C++ en entorno Windows, siendo los más populares los
de Microsoft (y sus diferentes versiones del compilador Visual C++) y Borland (tanto el
Builder C++ como el Borland C/C++, bastante más antiguo). Estos compiladores suelen
estar integrados en un IDE (Entorno Integrado de Desarrollo), de manera que bajo el mismo
interfaz se puede controlar el editor, el compilador, el enlazador y el depurador, entre otros.
Los compiladores de C libres (como djgpp o gcc) suelen ser compiladores independientes, es
decir, caracen de IDE. El programador debe encargarse de buscar un editor para escribir su
código fuente y un depurador para corregir errores de ejecución. Esta es la forma clásica de
trabajar en entornos Unix.
¿Existe algo parecido a los IDEs de Borland para Windows, pero con licencia de software
libre? La respuesta es Dev-C++, un IDE desarrollado por Bloodshed Software bajo licencia
GNU. Se trata de un entorno integrado para Windows que proporciona un compilador de
C/C++ (Mingw, basado en gcc), un completo editor de código fuente y un depurador. A
continuación proporcionamos un resumen de las opciones más útiles del IDE. Todo lo que se
explique es fácilmente extensible a otros IDEs, incluidos los que funcionan bajo GNU/Linux.
Ya sé que alguien puede estar pensando: ¿y qué pasa con Eclipse? Bueno, pues es otro IDE
perfectamente válido que ustedes pueden utilizar para desarrollar programas en C, entre
otros lenguajes. Eclipse está orientado a la creación de otros IDEs y programas cliente del
tipo BitTorrent o Azureus, pero es perfectamente posible escribir programas más
convencionales. Eso sí, es un entorno más complejo que el de Dev-C++ y, quizás por eso,
menos indicado para empezar a programar.
EL IDE DE DEV-C++
Menú Archivo
Contiene las opciones para abrir y guardar los archivos fuente. Generalmente, los editores de
C manejan archivos con las siguientes extensiones:
Menú Edición
Tiene las opciones típicas para facilitar la edición de programas, incluyendo las utilísimas
funciones de Cortar, Copiar y Pegar que cualquier programador utilizará con frecuencia
(pero, si se sorprende usted utilizándolas condemasiada frecuencia, debería encenderse su
luz de alarma). Es muy recomendable que te aprenda los atajos de teclado de estas
funciones si aún no los domina.
Menú Buscar
Contiene las opciones para buscar textos en el programa, reemplazarlos por otros, ir a cierta
línea, etc.
Menú Ver: Tiene opciones para acceder a las distintas ventanas de información del depurador
y del compilador.
Menú Proyecto
Con este menú se pueden manejar aplicaciones distribuidas en varios archivos fuente. A
estas aplicaciones se les denomina proyectos. Desde el menú se pueden crear proyectos y
agregarles los archivos implicados en el mismo, así como cambiar las propiedades del
proyecto.
Menú Ejecutar
La opción Reconstruir todo recompila todos los archivos que formen parte del proyecto (lo
cual puede llevar mucho tiempo si el proyecto es grande) y los vuelve a enlazar, mientras
que la opción Compilar sólo compila el archivo activo y los que tengan dependencias con él
Desde aquí también se accede al depurador, que por su importancia explicaremos más
abajo.
Menú Herramientas
Son similares a los de otras aplicaciones Windows. La mayor crítica que se le puede hacer a
este IDE es que el sistema de ayuda en línea es bastante pobre, pero, teniendo un buen
manual de referencia de C a mano, o una conexión a Internet, es un detalle de importancia
menor.
EL DEPURADOR O DEBUGGER
El acceso al depurador desde el IDE es tan sencillo como la invocación del compilador, ya
que basta con activar la opción de menú correspondiente, o bien su atajo por teclado.
Respecto a esto, debe acostumbrarse a utilizar los atajos de teclado del compilador porque
así agilizará mucho el trabajo con las distintas herramientas.
Manejar el depurador es bastante simple y todas sus opciones están en el menú Depurar.
Veamos las opciones más importantes:
Cada día existen más IDEs disponibles para Linux, como Anjuta, KDevelop, Eclipse, Gambas,
etc.
Estos IDEs proporcionan un entorno gráfico para manejar el compilador y el depurador sin
necesidad de recurrir a la línea de comandos. Su utilización es muy silimar a la de los IDEs
para Windows, de los que ya hablamos aquí, así que no nos detendremos en ella de nuevo.
Estudiaremos, en cambio, la forma clásica de proceder en entornos Unix: la compilación
desde la línea de comandos. Esta decisión se justifica por dos razones: primera, el alumno/a
puede encontrarse aún con sistemas Unix y GNU/Linux donde no disponga de ningún IDE;
segunda, el IDE lo que hace, en realidad, es realizar llamadas a la herramienta make y al
compilador gcc, por lo que es conveniente conocer, aunque sea por encima, la forma de
trabajar de ambos comandos.
Cuando el progamador carece de IDE debe utilizar todas sus herramientas manualmente y
por separado. Como es lógico, la herramienta principal es el compilador, pero también
necesitaremos, al menos, un editor de texto con el que escribir y modificar el código fuente y
un depurador que nos ayude a localizar y corregir errores en tiempo de ejecución.
EL COMPILADOR GCC
gcc es un compilador rápido, muy flexible y riguroso. Como ejemplo de sus múltiples
virtudes, diremos que gcc puede funcionar como compilador cruzado para un gran número
de arquitecturas distintas. La forma clásica de utilizar gcc es desde la línea de comandos de
un terminal de texto.
Como curiosidad, mencionar que en realidad gcc no genera código binario alguno,
sino código ensamblador. La fase de ensamblado a código binario la realiza el ensamblador
de GNU (gas), y el enlazado de los objetos resultantes, el enlazador de GNU (ld). Este
proceso es transparente para el usuario, y a no ser que se lo especifiquemos, gcc realiza el
paso desde código en C a un binario ejecutable automáticamente.
Manejo de gcc
Casi siempre, gcc es invocado desde la herramienta make, cuyo funcionamiento se explica
más adelante. Pero debemos saber manejar mínimamente gcc para compilar nuestros
programas.
Vamos a compilar nuestro primer programa con gcc, que, como no podía ser de otra manera,
será “hola mundo”. Supongamos que el código fuente de “hola mundo” se encuentra
almacenado en el archivo holamundo.c. La compilación se realizaría con este comando:
$ gcc holamundo.c
$ ./a.out
Hola mundo
Errores y warnings
$ gcc holamundo.c
Opciones habituales
A continuación mostramos algunas de las opciones más habituales al usar gcc. La colección
completa de modificadores se encuentra en su página de manual,man gcc, cuyo manejo se
explica un poco más adelante.
Para poder ejecutar un depurador sobre nuestros programas en C, debemos especificar a gcc
que incluya información de depuración en los binarios que genere con la opción –g en la línea
de comandos.
ddd es un interfaz gráfico para el depurador gdb. La principal ventaja de ddd es la facilidad
para mostrar los contenidos de las variables durante la ejecución de nuestro programa y la
posibilidad de ver todo el código fuente del mismo en la ejecución paso a paso. El manejo de
ddd es muy similar al de cualquier otro depurador, por lo que pueden remitirse al depurador
de Dev-C++ del que hablamos en este artículo.
Además, los IDEs incluyen sus propios depuradores en el entorno de desarrollo, y también se
manejan de forma similar. Pero todos, en realidad, se dedican a hacer llamadas a gdb sin
que el usuario sea consciente de ello.
La herramienta make nos evita la tarea de comprobar las dependencias entre ficheros. Para
ello se sirve de un fichero (cuyo nombre suele ser Makefile, aunque puede cambiarse) en el
que declaramos las dependencias entre ficheros de código fuente y las órdenes necesarias
para actualizar cada fichero. Una vez escrito el fichero Makefile, cada vez que cambiemos
algún fichero fuente, nos bastará invocar el comando make para que él solito revise todas las
dependencias y recompile todos los archivos que sean necesarios.
$ make
El fichero makefile
El fichero Makefile más simple está compuesto por “reglas” de este aspecto:
comando
...
...
gcc -c main.c
gcc -c pantalla.c
gcc -c procesos.c
clean :
rm -f miprograma *.o
Para crear el archivo ejecutable edit bastará con escribir en la línea de comandos:
$ make
Para borrar el archivo ejecutable y todos los ficheros objeto del directorio, escribiremos:
$ make clean
En este fichero Makefile, los objetivos son el fichero ejecutable (miprograma) y los ficheros
objeto main.o , pantalla.o y procesos.o. Son prerrequisitos ficheros como main.c y defs.h. De
hecho, cada fichero .o es tanto objetivo como prerrequisito.
Un comando de shell sigue a cada línea que contiene un objetivo y prerrequisitos. Estos
comandos de shell indican como actualizar el archivo objetivo, y generalmente son llamadas
al compilador gcc (ro no siempre). Recuerde que hay que poner un tabulador al principio de
cada línea de comando para distinguir líneas de comando de otras líneas en el Makefile. La
herramienta make no sabe nada sobre cómo funcionan los comandos: depende del
programador proporcionar los comandos que actualizarán los archivos objetivo de manera
apropiada.
Con esto hemos visto el funcionamiento más esencial de la herramienta make, pero que
quede clara una cosa: tiene otras muchas posibilidades.
PÁGINAS DE MANUAL
Las páginas de manual se encuentran organizadas en 9 secciones, de las cuales sólo nos
interesan en este momento las 3 primeras:
-a: Muestra de forma consecutiva las secciones en que existe manual del comando
-k: Muestra las paginas de manual y secciones en que se hace referencia a lo buscado.
En el momento de buscar información debemos tener en cuenta que algunas funciones se
encuentran en varias secciones y, por lo tanto, deberemos indicárselo a man antes de su
ejecución. Para especificar la sección sobre la que queremos consultar, lo haremos de la
siguiente forma:
Por ejemplo, para consultar la página de manual de la función printf() usaremos este
comando:
$ man 3 printf
$ man 1 printf
Nuestro recorrido por algunas librerías no estándar para mejorar la interfaz de nuestros
programas en C nos ha llevado por Ncurses y SDL. Vamos a completar el viaje haciendo una
breve visita a conio, un clásico entre las librerías para la consola de MS-DOS.
Las funciones de conio (CONsole Input Output) permiten, como las de Ncurses, cambiar el
color del texto y del fondo, mostrar caracteres en cualquier posición de la consola, leer datos
de entrada sin necesidad de pulsar intro, y un montón de cosas más. Eso sí, es bastante más
restrictiva que Ncurses en otros aspectos, como la definición y manipulación de ventanas.
Conio es una librería no estándar. Estaba disponible en la mayor parte de los compiladores
de C para entornos MS-DOS y Windows 3.x, pero había diferencias sustanciales entre unas
implementaciones y otras, precisamente debido a que la librería no es estándar.
Una de las versiones que más éxito tuvo fue la de los compiladores de Borland (como Turbo
C). De hecho, adquirió tanta popularidad que, en la actualidad, existen versiones que la
emulan en otros entornos. Así, para el compiladorDev-C++ también existe
una emulación (cortesía de C Con Clase) que funciona correctamente en el intérprete de
comandos de Windows XP. Incluso existen algunas emulaciones para Linux, que facilitan la
portabilidad de Windows a Linux, aunque lo más recomendable en estos sistemas es utilizar
Ncurses.
Como la librería no es estándar, tiene que instalarla para el compilador Dev-C++ siguiendo
estos pasos:
A continuación se resumen las funciones más relevantes de la librería conio. Puede encontrar
una referencia completa en http://c.conclase.net/Borland.
cprintf() y cscanf()
textcolor(color)
Cambia el color del texto. Los colores predefinidos son: BLACK, BLUE, RED, GREEN, CYAN,
MAGENTA, BROWN, DARKGRAY. Además, existen las variedades “claras” de estos colores:
LIGHTBLUE, LIGHTRED, LIGHTGREEN, etc.
Así, si ejecutamos:
textcolor (LIGHTRED);
textbackground (color)
Establece el color del fondo del texto. Los colores predefinidos son los mismos que para
textcolor(). Así, este código:
textbackground (BLUE);
…hace que el texto que se escriba a continuación aparezca con el fondo en color azul oscuro.
getch ()
Lee un carácter desde el teclado, sin mostrar el eco y sin necesidad de pulsar Return.
Devuelve el código ASCII del carácter tecleado. Ahí va un ejemplo:
char c;
c = getch();
clrscr ()
Incluso en las aplicaciones de consola, que tienen un interfaz mucho más simple que las
gráficas, es habitual que deseemos utilizar distintos colores para las fuentes y el fondo, o
decidir la posición exacta de la pantalla en la que se tienen que mostrar los caracteres, o
dibujar ventanas, o, en definitiva, cualquier otra cosa propia de las interfaces de texto.
El estándar ANSI C no dispone de funciones para realizar estas tareas, pero existen muchas
librerías para ello. En Windows, la más utilizada es la libreríaconio (CONsole
Input/Output) de Borland, cuyos fundamentos se han descrito en otro artículo. Fue una
librería muy difundida en su momento y aún hay mucha gente que la utiliza, hasta el
extremo que existe una emulación para GNU/Linux.
Por cierto: aunque lo he debido de decir en otros sitios, lo repito ahora porque me parece
importante. Hablamos generalmente de “Librería ncurses” o “Librería conio”, cuando lo
correcto sería decir “Biblioteca ncurses” o “Biblioteca conio”, ya que “Biblioteca” es la
traducción correcta del “Library” original en inglés. Pero, en la jerga de programación, se ha
extendido como una plaga el uso de “Librería” en este contexto, así que, para no confundir al
personal, usaremos también esa palabra.
QUÉ ES NCURSES
Ncurses es una librería de funciones para el manejo de interfaces basadas en texto. Es decir,
se trata de un conjunto de funciones, ya programadas, que podemos utilizar en nuestros
programas para mejorar su presentación.
$ gcc holamundo.c
En cambio, esta otra línea fuerza el enlace del programa con la librería Ncurses:
No hace falta decir que la librería debe estar instalada en nuestro sistema, ¿a que no?.
Además, debemos hacer un #include <ncurses.h> en el programa que vaya a utilizar estas
funciones.
Ncurses tiene muchísimas funciones, pero nosotros sólo nos referiremos a las que
necesitamos para empezar a funcionar con ella.
INICIALIZACIÓN DE NCURSES
Para utilizar las funciones de Ncurses en nuestro programa, basta con que incluyamos la
siguiente llamada:
initscr();
Esta función crea una ventana de texto. La ventana se llama stdscr (que significa “standard
screen”, es decir, “pantalla estándar”). A partir de aquí podremos utilizar cualquier función
de Ncurses, pues todas actúan sobre esa ventana . Por ejemplo, una función que suele ir
justo después es:
Esto sirve para activar la recepción de teclas especiales (como F1, F2, ESC, etc). Si no
llamamos a keypad(), no podremos utilizar ese tipo de teclas en nuestro programa. El primer
parámetro se refiere a la ventana sobre la que queremos actuar (stdscr es la consola en su
totalidad; no vamos a entrar, en este artículo de introducción, en los detalles sobre cómo
crear ventanas dentro de la consola). El segundo parámetro es el que nos interesa: sirve
para activar (1) o desactivar (0) la recepción de teclas especiales.
initscr(): Inicializa Ncurses y crea la pantalla estándar. Debe ser invocada antes que
cualquier otra función de la librería.
keypad(stdscr, activar): Activa / desactiva la recepción de teclas especiales, como F1,
ESC, Intro, etc. Si activar = 1, se activa la recepción. Si activar = 0, se desactiva.
echo() / noecho(): Activa / desactiva el eco de caracteres. Si el eco está activo, lo que
se escriba en el teclado aparece en la pantalla. Si está inactivo, no.
cbreak() / nocbreak(): Activa / desactiva el envío inmediato de teclas. Normalmente,
cuando se teclea algo no es enviado al programa hasta que no se pulsa “intro”. La
función cbreak() hace que todo cuanto se teclee sea enviado al programa sin necesidad
de “intro”. La función nocbreak() desactiva este comportamiento
nodelay(stdscr, activar): Activa / desactiva la espera para lectura de teclado. Las
funciones para leer un solo carácter, como getch(), detienen la ejecución del programa
hasta que se pulsa alguna tecla. Llamando a esta función con el parámetro activar = 1,
conseguiremos que el programa no se detenga en getch() aunque no se pulse tecla
alguna. Para desactivarlo, llamaremos a la función con activar = 0.
endwin(): Finaliza Ncurses. Hay que llamar a esta función antes de terminar el
programa para liberar la memoria ocupada y restaurar la consola al estado inicial.
ESCRIBIR Y LEER
printw() y putstr(): Para escribir usaremos la función printw(), que funciona igual que
printf() pero sobre una ventana de Ncurses. También podemos usar putstr(), que es
como puts(), es decir, sirve para imprimir cadenas
getstr() y getch(): Para leer disponemos de getstr(), que es como gets(), es decir,
sirve para leer cadenas por teclado. De modo que, si queremos leer un número,
debemos leerlo como cadena y luego convertirlo a número (con las funciones estándar
atoi(), atof(), etc). También podemos usar getch(), que lee un único carácter.
move(): Para colocar el cursor usaremos move(y,x). Esto ubica el cursor en la columna
“x” y la fila “y” de la pantalla. Funciona como la función gotoxy() de Borland, pero,
¡cuidado!, porque en move() se indica primero la fila y luego la columna, es decir, justo
al revés que en la función de Borland.
refresh(): Actualiza la pantalla. Es el único modo de asegurarnos de que los cambios
realizados se muestren instantáneamente.
COLORES
Antes de utilizar los colores hay que inicializarlos llamando a la función start_color() sin
argumentos, así:
if (has_colors())
start_color();
La llamada previa a has_colors() se realiza para asegurarnos de que nuestra consola soporta
el uso de colores. Es raro encontrar una consola que no permita colores, pero existen, así
que no está de más hacer la comprobación.
Una vez hecho esto, podemos utilizar los colores básicos definidos enncurses.h, cuyas
constantes son COLOR_BLACK, COLOR_WHITE, COLOR_YELLOW, etc.
Para utilizar esos colores se deben agrupar en parejas: un color para el texto junto con un
color para el fondo. A cada pareja se le asigna un número a través de la función init_pair(),
así:
Esto define a la pareja nº 1 como texto amarillo sobre fondo azul. De este modo podemos
definir, por lo general, hasta 64 parejas.
attron(COLOR_PAIR(1));
Esto activa la pareja de colores nº 1, de manera que todo el texto que se envíe a la pantalla
a partir de este momento se verá amarillo con el fondo azul.
La función attron(), además de para activar parejas de colores, sirve para cambiar otros
atributos del texto. Por ejemplo, lo siguiente se utiliza para escribir en negrita:
attron(A_BOLD);
Puedes obtener más información sobre otras cosas que se pueden hacer con attron() en las
páginas de manual (escribiendo $man attron)
El siguiente programa utiliza Ncurses para escribir el texto HOLA en color rojo sobre fondo
azul y el texto MUNDO en color amarillo sobre fondo verde. El texto HOLA aparece en la línea
11, y MUNDO en la 12. Luego, el programa espera hasta que se pulsa la tecla “flecha arriba”,
y entonces termina.
#include <ncurses.h>
int main(void)
{
char carácter;
// Inicializa Ncurses
initscr();
// Activa teclas especiales (como las flechas)
keypad(stdscr, 1);
// Para no tener que pulsar Intro tras cada carácter
cbreak();
// Activa la pareja 1
attron(COLOR_PAIR(1));
move(11, 1);
printw(“HOLA”);
// Activa la pareja 2
attron(COLOR_PAIR(2));
move(12, 1);
printw(“MUNDO”);
do
{
// Lee un carácter desde el teclado
carácter = getch();
}
while (carácter != KEY_UP);
// Finaliza Ncurses
endwin();
return 0;
}
MÁS INFORMACIÓN
Esto sólo ha sido una pequeña introducción a Ncurses, pero con lo que hemos visto y un
poco de práctica es suficiente para conseguir efectos visuales más que dignos en nuestros
programas para GNU/Linux.
Si, aún así, le ha sabido a poco, puede obtener mucha más información aquí.
CONTENIDOS
* Introducción
* Breve_historia_de_curses
* Alcance_de_este_documento
* Terminología
* Notas_sobre_esta_traduccion
* La_Libreria_Curses
* Una_descripcion_de_Curses
* Compilar_programas_utilizando_Curses
* Actualización_de_pantalla
* Ventanas_Estándar_y_convenciones
* Variables
* Uso_de_la_Librería
* Comenzar
* Salida
* Entrada
* Uso_de_caracteres_de_Formularios
* Atributos_de_caracteres_y_color
* Interfaz_de_Ratón
* Finalización
* Descripción_de_Funciones
* Inicialización_y_Wrapup
* Realizar_la_salida_al_Terminal
* Acceso_a_las_Capacidades_de_bajo_nivel
* Depuración
* Avisos_Consejos_Y_Trucos
* Algunas_notas_de_precaución
* Abandonar_temporalmente_el_modo_Ncurses
* Uso_De_Ncurses_Bajo_Xterm
* Manipulación_de_Múltiples_Terminales
* Prueba_de_las_capacidades_del_terminal
* Sintonización_para_la_velocidad
* Aspectos_especiales_de_Ncurses
* Compatibilidad_con_versiones_anteriores
* Refresco_de_ventanas_superpuestas
* Antecedentes_de_Borrado
* Ajuste_con_XSI_Curses
* Librería_Paneles
* Compilación_con_la_Librería_Paneles
* Descripción_de_Paneles
* Paneles_Salida_y_la_Pantalla_Estándar
* Escondiendo_Paneles
* Otras_Características_Diversas
* Librería_Menú
* Compilación_con_la_Librería_Menú
* Descripción_de_Menús
* Selección_de_Objetos
* Visualización_de_Menú
* Procesamiento_de_la_entrada_de_Menú
* Otros_Aspectos_Diversos
* Librería_Formulario
* Compilación_con_la_librería_Formularios
* Descripción_de_Formularios
* Crear_y_Liberar_Campos_y_Formularios
* Cambiar_Atributos_de_Campos
* Cambio_de_tamaño_y_localización_Datos
* Cambiar_la_localización_de_un_Campo
* El_Atributo_de_Justificación
* Visualización_de_Atributos_de_Campo
* Bits_de_Opciones_de_Campo
* Estados_de_Campo
* Puntero_de_Campo_para_Usuario
* Campos_Variables_de_Tamaño
* Validación_de_Campo
* TYPE_ALPHA
* TYPE_ALNUM
* TYPE_ENUM
* TYPE_INTEGER
* TYPE_NUMERIC
* TYPE_REGEXP
* Manipulación_del_Buffer_Field_Directo
* Atributos_de_Formularios
* Control_de_Visualización_de_Formularios
* Entrada_en_Dispositivo_de_Formularios
* Petición_de_Navegación_de_Página
* Petición_de_Navegación_InterCampo
* Petición_de_Navegación_IntraCampo
* Petición_de_Paginar
* Petición_de_Editado_de_Campos
* Petición_de_Editado_de_Campos
* Petición_de_Orden
* Comandos_de_la_Aplicación
* Cambiar_Enlaces_en_los_Campos
* Comandos_de_cambios_de_Campos
* Opciones_de_Formularios
* Tipos_de_validación_del_Usuario
* Tipos_de_Uniones
* Nuevos_Tipos_de_Campos
* Argumentos_de_Funciones_de_Validación
* Funciones_de_orden_para_Tipos_de_Usuari
* Evitar_Problemas
Introducción
El Curses API puede parecerse a los terminales arcaicos de los entornos UNIX
cada vez más dominados por X, Motif y Tcl/Tk.
System III UNIX de Bell Labs constaba de una librería curses reescrita y
mucho más mejorada. Esta incluía el formato del terminal de
información(terminfo). Este terminal se basa en la base de datos de
capacidades del terminal (termcap) de Berkeley, pero contiene mejoras y
extensiones. Cadenas de caracteres con capacidades parametrizadas fueron
introducidas, haciendo posible describir múltiples atributos de vídeo, colores y
manejar muchos mas terminales no usuales que con el terminal anterior
(termcap). En los posteriores lanzamientos de AT&T System V , curses
desarrolla el uso de mas facilidades y ofrece más capacidades, llegando
incluso más allá que las curses de BSD en poder y flexibilidad.
Soporte de color.
El paquete ncurses fue creado por Pavel Curtis. La persona que mantiene
originalmente el paquete es Zeyd Ben-Halim <[email protected]>. Eric S.
Raymon [email protected] escribió muchas de las nuevas características
en las versiones posteriores a 1.8.1 y escribió la mayor parte de esta
introducción. Las personas que lo mantienen ahora primario actuales son
Thomas Dickey <[email protected]> y Juergen Pfeifer >Juergen.Pfeifer@T-
Online.de>
Terminología
Ventana
Pantallas
Pantalla Terminal
La idea del paquete de que presentación de terminal aparece actualmente, por
ejemplo, cual ve el usuario ahora. Esto es una pantalla especial.
http://bat710.univ-lyon1.fr/~ascil/ncurses/
http://www.aaronsrod.com/freemoney/ncurses-intro.html
http://aotech1.tuwien.ac.at/~dusty/ncurses-intro.html
http://www.ecks.org/docs/ncurses-hack.html (Documento de
ncurses para hackers)
La Librería Curses
#include <curses.h>
Para actualizar la pantalla óptimamente, es necesario para las rutinas saber que
aspecto tiene la pantalla actual y que aspecto quiere el programador que tenga
después. Para este propósito, una tipo de estructura de datos llamada ?
window? se define como la que describe la imagen de una ventana para las
rutinas, incluyendo su posición de comienzo en la pantalla (la coordenada
(y,x) de la esquina superior izquierda) y su tamaño. Una de estas (llamada
curscr, para la pantalla actual) es una imagen de pantalla de cómo el terminal
actual aparece. Otra pantalla (llamada stdscr, para pantalla estándar) es
suministrada por defecto para hacer cambios en ella.
Una sección física dada puede estar sin el alcance de un numero de ventanas
solapadas. Además, se pueden realizar cambios en las ventanas en cualquier
orden, sin poner atención en la eficiencia. Entonces, el programador puede
efectivamente decir ?haz que parezca esto?, y dejar a la implementación del
paquete determinar el camino mas eficiente de repintar la pantalla.
Muchas funciones son definidas para utilizar stdscr como pantalla por defecto.
Por ejemplo, para añadir un carácter a stdsc, se realiza una llamada a addch()
con el carácter deseado como argumento. Para escribir en una ventana
diferente el uso de la rutina waddch() (para una ventana específica ,?w?indow-
specific addch()) es permitido. Esta convención de prefijar los nombres de las
funciones con una ?w? cuando se aplican a ventanas específicas es
consistente. Las únicas rutinas que no sigue esto son aquellas en las que una
ventana debe ser siempre especificadas.
Para mover las coordenadas actuales (y,x) de un punto a otro, se proporcionan
las rutinas move() y wmove().
move(y,x);
addch(ch);
mvaddch(y,x,ch);
wmove(win,y,x);
waddch(win,ch);
mvwaddch(win,y,x,ch);
Variables
La librería curses presenta algunas variables que describen las capacidades del
terminal.
------------------------------------------------------------------
TRUE
FALSE
ERR
OK
Flag de error devuelto por rutinas cuando las cosas acaban con éxito.
Uso de la Librería
#include <curses.h>
#include <signal.h>
(void) cbreak(); /* coger los caracteres de entrada uno cada vez, no esperar
por ellos \n */
if (has_colors())
start_color();
/*
*/
for (;;)
endwin();
exit(0);
Comenzar
Una vez que las ventanas de pantalla han sido situadas en memora, usted
puede establecerlas en su programa. Si quiere permitir a una pantalla hace el
barrido, use scrollok(). Si quiere que el cursor este en la izquierda después del
ultimo cambio, utilice leaveok(). Si esto no se realiza, refresh() moverá el
cursor a la coordenada actual (y,x) de la pantalla después de actualizarla.
Usted puede crear nuevas de ventanas por si mismo utilizando las funciones
newwin(), derwin(), y subwin(). La rutina delwin() le permitirá deshacerse de
ventanas antiguas. Todas estas opciones descritas anteriormente pueden ser
aplicadas a cualquier ventana.
Salida (Output)
Ahora que las cosas han sido establecidas, querrá en realidad actualizar el
terminal. Las funciones básicas utilizadas para cambiar lo que ira en una
ventana son addch() y move(). addch() añade un carácter a las coordenadas
(y,x) actuales. move() cambia las coordenadas (y,x) actuales a donde quiera
usted que estén. Devuelve un ERR si intenta mover fuera de la ventana. Como
se menciona arriba, puede combinar las dos en mvaddch() para hacer ambas
cosas a la vez.
Las otras funciones de salida, como addstr() y printw(), todas llaman a addch()
para añadir caracteres a la ventana.
Después de que haya puesto en la ventana lo que usted quería allí, cuando
quiera que la parte del terminal cubierta por la ventana tenga ese aspecto, debe
llamar a refresh(). Para optimizar el hecho de que se encuentren los cambios,
refresh() asume que parte de la ventana no cambiada desde el ultimo refresh()
de la ventana no ha sido actualizado en el terminal, por ejemplo, que no ha
refrescado la parte del terminal con la ventana solapada. Si no es este caso, la
rutina touchwin() se suministra para hacer que parezca que la ventana entera
ha sido cambiada, de este modo realiza refresh() chequeando la subsección
entera del terminal para los cambios.
Si llama a wrefresh() con curscr como argumento, hará que la pantalla parezca
como curscr piensa que debe parecer. Esto es útil para implementar un
comando que redibuje la pantalla en caso de desorden.
Entrada (Input)
Cuando necesita aceptar una línea orientada a la entrada en una ventana, las
funciones wgetstr() y semejantes están disponibles. Hay incluso una función
wscanw() que puede hacer scanf()(3)- estilo de análisis multi-campo en la
entrada de la ventana. Estas funciones pseudo-lineas-orientadas activan el eco
mientras se ejecutan.
Las más útil de las definiciones ACS son los caracteres de dibujo de
formularios. Puede utilizar estos para dibujar cajas y gráficos simples en la
pantalla. Si el terminal no tiene estos caracteres, curses.h los convertirá a un
reconocible (aunque feo) conjunto de caracteres ASCII.
Hay dos modos de hacer los atributos de pantalla. Uno es el lógico- o poner el
valor del atributo de pantalla que desee en el argumento del carácter de una
llamada addch(), o otra llamada de salida, que tome un argumento chtype. El
otro es poner el valor actual del atributo. Esto esta lógicamente relacionado
con el atributo de pantalla que especifique en el primer formulario. Usted hace
esto con las funciones attron(), attroff(), y attrset(); vea las páginas del manual
para mas detalles. El color es una clase especial de luminosidad.
Interfaz de Ratón
Una vez que el ratón esta activo, el comando de su bucle de aplicación debe
observar el valor devuelto de KEY-MOUSE a través de wgetch(). Cuando vea
esto el evento de ratón recogido ha sido introducido en la cola. Para sacarlo de
ella, utilice la función getmouse() (debe hacer esto antes de la próxima
wgetch(), de otro modo otro evento de ratón puede venir y hacer el primero
inaccesible.
Cada llamada a getmouse() rellena una estructura (la dirección que le pasara)
con el dato del evento del ratón. El dato del evento incluye zero-origin , las
coordenadas de la celda del carácter de la pantalla relativa del puntero de
ratón. Además incluye una mascara de evento. Los bits en esta mascara se les
dará un valor, correspondiendo al tipo de evento que haya sido reconocido.
Finalización
Descripción de Funciones
Inicialización y Wrapup
El valor de term puede ser un NULL, que hará que el valor de TERM sea
utilizado en el entorno. El puntero errret puede también ser un NULL, que
significa que el código de error no se quiere. Si errret tiene el valor por
defecto, y algo va mal, setpterm() escribirá por pantalla un mensaje de error
apropiado y saldrá, antes de regresar. Por esto, un programa simple puede
llamar a setupterm(0,1,0) y no preocuparse de inicializar errores. Después de
la llamada a setupterm(), la variable global cur_term esta establecida como
puntero de la estructura actual de las capacidades del terminal. Llamando a
setupterm() para cada terminal, y salvando y restaurando cur_term, es posible
para un programa utilizar dos o mas terminales a la vez. Setupterm() además
restaura los nombres de la sección de la descripción del terminal en la tabla
ttytype() de caracteres globales. En consecuencia, llama a setupterm() que
sobrescribirá esta tabla, si podrá tenerla guardada para usted si necesita que lo
este.
Depuración
_tracef(): esta función puede ser utilizada para realizar la salida de su propia
información de depuración. Solo esta disponible si realiza el enlace con
_lncurses_g. Puede ser utilizada de la misma manera que printf(),solo produce
la salida a una nueva linea despues de terminar los argumentos. La salida va a
un fichero llamado trace en el directorio actual.
Los registros de traza pueden ser difíciles de interpretar debido a que hay en
ellos un volumen completo del volcado de memoria. Esto es un escrito
llamado ?tracemunch? incluido con la distribución de ncurses que puede
aliviar este problema de alguna manera; esto compacta las secuencias largas
de operaciones similares en líneas simples de pseudo-operaciones. Estas
pseudo-ops pueden ser distinguidas por el hecho de que se las nombra con
letras mayúsculas.
Las paginas del manual son una completa referencia para esta librería. En el
resto de este documento, discutidos varios métodos útiles que no son tan
obvios en las descripciones de las paginas del manual.
Usted estará menos cerca de tener problemas si diseña sus trazados de pantalla
para usar embaldosados mejor que ventanas solapadas. Históricamente, el
soporte curses para ventanas solapadas ha sido flojo, frágil y documentado
pobremente. La librería ncurses no es todavía una excepción a esta regla.
Hay una librería gratuita llamada paneles que se incluye en la distribución de
ncurses que hace un bonito trabajo de dar fuerza a las características de las
ventanas solapadas.
Algunas veces querrá escribir un programa que se utilice la mayor parte del
tiempo en modo pantalla, pero ocasionalmente vuelva a modo cocinado. Una
razón común para esto es que permita la salida del entorno (shell-out). Esta
conducta es fácil de realizar con ncurses.
Hay una funcion booleana, isendwin(), cuyo código puede utilizarse para
testear si el modo ncurses de pantalla esta activo. Devuelve TRUE en el
intervalo entre una llamada a endwin() y el siguiente refresh(), FALSE de otra
forma.
addstr("Shelling out...");
La librería ncurses pretende ser un ajuste de nivel base con la XSI Curses
estándar de X/Open. Muchas características de nivel extendido (de hecho, casi
todas las características que no conciernen directamente con caracteres anchos
e internacionalización) son también soportados.
También, ncurses reúne los requerimientos XSI que cada puntero de entrada
de macro tiene una función correspondiente y seria enlazada (y será un
prototipo chequeado) si la definición de la macro esta desactivada con #undef.
Librería Paneles
La librería ncurses provee por si misma un buen soporte de visualizaciones de
pantalla en el que las ventanas son embaldosadas (no solapadas). En casos
mas generales en los que las ventanas se solaparían, tiene que utilizar series de
llamadas wnoutrefresh() seguidas de doupdate(), y ser cuidadoso acerca del
orden en que hace los refrescos de ventana. Tiene que ser de abajo-arriba,, de
otra manera partes de las ventanas que serian oscurecidas se mostraran a
través.
Sus módulos que utilicen paneles deben importar las definiciones de la librería
paneles con
#include <panel.h>
Descripción de Paneles
Un objeto panel es una ventana que es implícitamente tratada como una parte
de una superficie que incluye todos los demás objetos panel. La superficie
tiene un orden implicito de abajo-arriba de visibilidad. La librería paneles
incluye una funcion de actualizacion (analoga a refrsh()) que visualiza todos
los paneles en la superficie en el orden apropiado para resolver
superposiciones. La ventana estándar, stdscr, es considerada por debajo de
todos los paneles.
Escondiendo Paneles
Cada panel tiene un puntero de uso asociado, no utilizado por el código del
panel, al que puede adjuntar un dato de aplicación. Ver la pagina de
documentación de set_panel_userptr() y panel _userptr para mas detalles.
Librería Menú
Sus módulos que utilicen menú deben importar las declaraciones de la librería
menú con
#include <menu.h>
Descripción de Menús
Los menús creados por esta librería consisten en una colección de objetos
incluyendo una parte que es cadena de caracteres como nombre y una parte
que es una cadena de caracteres como descripción. Para hacer menús, usted
crea grupos de esto objetos y los conecta con la estructura de los objetos de
menú.
El menú puede entonces ser asociado, esto es escrito a una ventana asociada.
Realmente, cada menú tiene dos ventanas asociadas; una ventana de contenido
en la que el programador puede escribir títulos o bordes, y una subventana en
la que los objetos de menú propiamente dichos son visualizados. Si esta
subventana es demasiado pequeña para visualizar todos los objetos, será un
punto de visualización listado con la colección de objetos.
Inicializar curses.
Refresco de pantalla.
Finalizar curses.
Selección de Objetos
Los menús pueden ser multi-valor o (por defecto) valor-simple (vea la pagina
del manual menu_opts(3x) para ver como cambiar el valor por defecto.
Ambos tipos siempre tienen un objeto actual.
Visualización de Menú
La pagina actual de menú puede ser menor que el tamaño del formato. Esto
depende del numero de objeto y el tamaño y si O_ROWMAJOR esta activo.
Esta opción (por defecto) provoca que los objetos menú sean visualizados en
modelo ?raste-scan?, así que si mas de un objeto se ajustara horizontalmente
la primera pareja de objetos lado con lado en la línea superior. La alternativa
es visualizar la mayor columna, que intenta poner primero diferentes objetos
en la primera columna.
Cada menú tiene una cadena de caracteres de marcas para visualmente seguir
de cerca los objetos seleccionados; vea las pagina del manual menu_mark(3x)
para mas detalles. La longitud de la cadena de caracteres de marcas también
influye en el tamaño de la pagina de menú.
La función scale_menu() devuelve el tamaño mínimo de visualización que el
código de menú computeriza de todos estos factores. Hay otras
visualizaciones de atributos de menú incluyendo un atributo de selección, un
atributo para objetos seleccionables, un atributo para objetos no seleccionables
y un carácter de relleno usado para separar el nombre del texto del objeto de la
descripción del texto. Esto tiene valores por defecto razonables que la librería
le permite cambiar (vea la pagina de manual menu_attribs(3x)).
Ventanas de Menú
Cada objeto, en cada menú, tiene asociado un puntero de uso en el que puede
manejar la aplicación de datos. Ver mitem_userptr(3x) y menu_userptr(3x).
Librería Formulario(Forms)
#include <form.h>
Descripción de Formularios
Para hacer formularios, crea grupos de campos y los conecta con objetos
estructura de formularios; la librería formulario realiza hace esto
relativamente sencillo.
Una vez definido, un formulario puede ser asociado, es decir escrito en una
ventana asociada. En realidad, cada formulario tiene dos ventanas asociadas;
una ventana de contenido en la que el programador puede escribir títulos y
bordes, y una subventana en la que los campos de formularios apropiados son
visualizados.
1. Inicializar curses.
5. Refrescar la pantalla.
Los objetos menú ocupan siempre una línea simple, pero los campos de
formulario pueden tener múltiples líneas. Por esto new_field() requiere que
usted especifique la profundidad y altura (los dos primeros argumento, que
ambos deben ser mayores que cero).
Como con campos duplicados, los campos enlazados tienen bits de atributos
separado del original.
Esta función espera ver un valor NULL- una tabla de punteros de campos.
Dichos campos están conectados a una nueva localización de objeto
formulario; su dirección es devuelta ( o NULL si la localización falla).
El Atributo de Justificación
Los valores de modo aceptados y devueltos por estas funciones son macros
preprocesadas are preprocessor macros NO_JUSTIFICATION,
JUSTIFY_RIGHT,
JUSTIFY_LEFT, o JUSTIFY_CENTER.
Los atributos a establecer y devueltos por las primeras cuatro funciones son
valores de atributos de visualización normales de curses(3x).
(A_STANDOUT, A_BOL, A_REVERSE, etc). El bit de pagina de un campo
controla si esta siendo visualizado al comenzar de un nuevo formulario.
Hay también una gran colección de bits de opciones de campo que puede
establecer el control de varios aspectos de procesamiento de formularios.
Puede manipularlos con esta funciones:
Por defecto todos estas opciones están activas. Aquí presentamos los bits de
opciones disponibles:
O_VISIBLE
O_ACTIVE
O_PUBLIC
O_EDIT
Controla si el dato del campo puede ser modificado. Cuando esta opción es
desactivada todas las respuestas editables excepto REQ_PREV_CHOICE y
REQ_NEXT_CHOICE fallaran. Tales campos de solo lectura pueden ser
útiles para mensajes de ayuda.
O_WRAP
O_BLANK
O_AUTOSKIP
O_NULLOK
O_PASSOK
O_STATIC
Los valores de las opciones son mascaras de bits y pueden ser compuesta con
lógicas- o de manera obvia.
Inicializando esta bandera bajo el control del programa puede ser útil si utiliza
el mismo formulario repetidamente, buscando campos modificados cada vez.
Cada estructura de campo contiene una ranura para un puntero de carácter que
no es utilizada por la librería formularios. Es necesario para ser utilizada por
las aplicaciones para almacenar datos privados para el campo. Puede
manipularlo con:
Es valido establecer el puntero de usuario con el campo por defecto (con una
llamada a set_field_userptr() pasándole un puntero de campo NULL). Cuando
un nuevo campo se crea, el puntero de usuario de campo por defecto es
copiado para inicializar el nuevo puntero de usuario de campo.
Un campo dinámico de una línea tendrá una altura fija pero una profundidad
variable, paginación horizontal para visualizar datos dentro del área de campo
como originalmente.
TYPE_ALPHA
Este tipo de campo acepta datos alfabéticos; no espacios en blanco, no dígitos
no caracteres especiales ( es chequeado a tiempo de la entrada de caracteres).
Esto es establecido con:
TYPE_ALNUM
TYPE_ENUM
Este tipo permite restringir los valores del campo entre un conjunto
especificado de valores de cadenas de caracteres (por ejemplo, las dos letra
del código postal para los estados de U.S.). Se establece con:
TYPE_INTEGER
TYPE_NUMERIC
Este tipo de campo acepta un entero. Se establece como se explica a
continuación:
TYPE_REGEXP
Este tipo de campo acepta un dato concordante que corresponde con una
expresión regular. Se establece como se especifica a continuación:
Puede también esta a nulo, en cuyo caso los campo antiguo están
desconectados ( y no liberados) pero nuevos no están conectados.
Las dos ventanas asociadas con cada formulario tiene las mismas funciones
como sus análogas en la Librería_Menú. Ambas ventanas son pintadas cuando
el formulario es asociado y borrado cuando el formulario es quitado de su
asociacion.
Para declarar sus propias estructuras de ventanas para una forma, necesitara
saber el tamaño del rectángulo limite de la forma. Puede conseguir esta
información con:
int scale_form(FORM *form, /* formulario a consultar */
Las dimensiones del formulario se pasan en las asociaciones apuntadas por los
argumentos. Una vez que tiene esta información, puede usarla para declarar
ventanas, entonces usar una de esta funciones:
Finalmente, hay una función para restaura el cursor de ventana del formulario
al valor esperado por el dispositivo del formulario:
Int pos_form_cursor(FORM*)
Si su aplicación cambia el cursor de ventana de la forma, llame a esta función
antes de manejar el control de vuelta al dispositivo de formularios para re-
sincronizarlo.
REQ_NEXT_PAGE
REQ_PREV_PAGE
REQ_FIRST_PAGE
REQ_LAST_PAGE
REQ_NEXT_FIELD
REQ_PREV_FIELD
REQ_FIRST_FIELD
REQ_LAST_FIELD
REQ_SNEXT_FIELD
REQ_SPREV_FIELD
REQ_SFIRST_FIELD
REQ_SLAST_FIELD
REQ_LEFT_FIELD
REQ_RIGHT_FIELD
Mover al campo de la derecha.
REQ_UP_FIELD
REQ_DOWN_FIELD
REQ_NEXT_CHAR
REQ_PREV_CHAR
REQ_PREV_LINE
REQ_NEXT_WORD
REQ_PREV_WORD
REQ_BEG_FIELD
REQ_END_FIELD
REQ_BEG_LINE
REQ_END_LINE
REQ_LEFT_CHAR
REQ_RIGHT_CHAR
REQ_UP_CHAR
REQ_DOWN_CHAR
Petición de Paginar
Los campos que son dinámicos y han crecido y los campos explícitamente
creados con líneas fuera de pantalla se pueden paginar. Los campos de una
línea se pueden paginar horizontalmente; los campos multi-linea se pueden
paginar verticalmente. La mayor parte de la paginación se provoca editando y
con el movimiento intra-campo ( la librería pagina el campo para conservar el
cursor visible). Es posible explicitar la petición de paginación con las
siguientes solicitudes:
REQ_SCR_FLINE
REQ_SCR_BLINE
REQ_SCR_FPAGE
REQ_SCR_BPAGE
REQ_SCR_FHPAGE
REQ_SCR_BHPAGE
REQ_SCR_FCHAR
REQ_SCR_BCHAR
REQ_SCR_HBLINE
REQ_SCR_HFHALF
REQ_SCR_HBHALF
REQ_INS_MODE
REQ_OVL_MODE
REQ_NEW_LINE
REQ_INS_CHAR
REQ_INS_LINE
Insertar una línea en blanco en la localización del carácter.
REQ_DEL_CHAR
REQ_DEL_PREV
REQ_DEL_LINE
REQ_DEL_WORD
REQ_CLR_EOL
REQ_CLR_EOF
REQ_CLEAR_FIELD
Petición de Orden
REQ_NEXT_CHOICE
REQ_PREV_CHOICE
Comandos de la Aplicación
Las peticiones de formularios son representadas como enteros por encima del
valor de curses mayor que KEY_MAX y menor que o igual a la constante
MAX_COMMAND. Si su rutina de virtulización de la entrada devuelve un
valor superior a MAX_COMMAND, el dispositivo de formularios lo
ignorara.
form_init
field_term
Este enlace se le llama después de la validación de campo; esto es, justo antes
de que el campo sea modificado. Es también llamado cuando el formulario no
esta asociado.
form_term
Puede establecer enlaces por defecto para todos los campos pasando un
conjunto de funciones con NULL como primer argumento.
Como con los campos, los formularios pueden tener bits de opciones de
control. Pueden ser cambiados o testeados con estas funciones:
Por defecto, todas las opciones esta activadas. Aquí están disponibles algunos
bits de opciones:
O_NL_OVERLOAD
Permite el sobrecargamiento de REQ_NEW_LINE como se describe en
Peticiones de Edición. El valor de esta opción es ignorado en campo
dinámicos que no han alcanzado su tamaño limite; estos no tienen ultima
línea, por lo que las circunstancia para provocar un REQ_NEW_FIELD nunca
se alcanzan.
O_BS_OVERLOAD
Los valores de opción son mascaras de bits y puede estar compuestas con
lógica- o en el modo obvio.
Tipos de Uniones
FIELDTYPE *type2);
Esta función crea un tipo de campo que aceptara cualquier valor legal para sus
argumentos de tipo campo (que pueden estar predefinidos o definidos por el
programador). Si una llamada a set_field_type() después requiere argumentos,
el nuevo tipo compuesto espera todos los argumentos para el primer tipo, que
todos los argumentos para el segundo. Ordenar funciones
(ver Petición_de_Orden) asociadas con los componentes de los tipos
trabajaran en la composición; lo que hace es chequear la función de validación
para el primer tipo, entonces por el segundo, para figurar que tipo que
contiene el buffer debería ser tratado.
Para crear un tipo de campo que cumpla los requisitos, necesita especificar
una o las dos cosas siguientes:
Una función de validación de carácter, para chequear cada carácter como es
introducido.
make_str
copy_str
Esta función se le llama desde las funciones de la librería form que localizan
nuevas instancias de campos. Se espera coger un puntero de pila, copiar la pila
as almacenamiento localizado, y devolver la dirección de la pila copiada.
free_str
Las funciones make_str y copy_str pueden devolver NULL para mostrar fallo
de localización. Las rutinas de librería las llamaran y devolverán una
indicación de error cuando esto ocurra. De este modo, sus funciones de
validación nunca deberían ver un puntero de fichero NULL y no necesita
chequear especialmente por ello.
Los argumentos de sucesor y predecesor serán cada uno pasados con dos
argumentos; un puntero de campo, y un puntero de pila (como para las
funciones de validación). Se espera que se utilice la función field_buffer()
para leer el valor actual, y set_field_buffer() en el buffer para establecer el
valor siguiente o previo. El enlace puede devolver TRUE para indicar éxito
( un valor siguiente o previo legal esta establecido) o FALSE para indicar
fallo.
Evitar Problemas
Si el tipo de usuario define funciones de orden, tiene que hacer algo intuitivo
con el campo en blanco. Una convención útiles es hacer el sucesor de un
campo en blanco al valor mínimo del tipo, y su predecesor al máximo.
Existen muchas librerías para añadir gráficos a nuestros programas escritos en C. Vamos a
usar una llamada SDL (iniciales de Single DirectMedia Layer), porque tiene muchos puntos a
su favor: es multiplataforma, libre, eficiente y permite manejar cualquier componente
multimedia (gráficos, sonido, joysticks, ratones, discos ópticos, etc). Teniendo en cuenta la
complejidad intrínseca a estos dispositivos, la librería es razonablemente sencilla de usar. Se
ha utilizado como base para algunos desarrollos conocidos en el terreno de los videojuegos,
como Quake 4 o FreeCiv.
Nosotros sólo vamos a proporcionar una introducción a la parte de SDL dedicada a los
gráficos, y aún así nos saldrá un artículo bastante voluminoso. Si quiere más información, en
la página web reseñada antes encontrará una completa documentación.
INSTALACIÓN DE SDL
SDL no es una librería C estándar, es decir, no viene de serie con ningún compilador de C.
Así que debe ser instalada antes de poder utilizarla. A continuación describimos el proceso de
instalación en Linux y en Windows.
Al no ser SDL una librería estándar, el enlace entre nuestro programa y las funciones de SDL
no se produce automáticamente. Hay que indicarle al enlazador (o linker) lo que debe hacer.
Si, por ejemplo, nuestro programa ejecutable se llama “ajedrez” y se construye a partir de 3
programas objeto, llamados “ajedrez.o”, “movs.o” e “interfaz.o”, debemos modificar la
primera parte de nuestro Makefile de este modo:
Eso es todo lo que tiene que hacer para compilar son SDL. Si te interesa saber POR QUÉ,
siga leyendo. Si no, puede pasar al siguiente apartado.
En realidad, lo que hay escrito entre esas comillas invertidas son comandos de SDL que
indican la configuración de la librería. Estos comandos los puede ejecutar desde la consola,
obteniendo más o menos esto:
$ sdl-config --cflags
$ sdl-config --libs
Al añadir estos comandos dentro del Makefile, enmarcados entre esas comillas invertidas,
obligamos a la herramienta make a ejecutar los comandos y a sustituir el texto
entrecomillado por el resultado del comando. Es decir, sería como si hubiéramos puesto esto
en el Makefile:
Pero preferiremos la primera forma porque es más corta y, además, funcionará en todas las
situaciones, mientras que esta segunda depende de dónde y cómo se haya instalado la
librería SDL (fíjese que hace referencia a directorios concretos de nuestro sistema)
Lo siguiente explica cómo compilar y enlazar con SDL desde el compilador Dev-C++, que
tiene licencia GNU y es gratuito. Ya explicamos cómo se usaba eneste artículo. Con otros
compiladores el proceso debe ser similar, aunque es posible que necesite bajar otro paquete
de desarrollo adaptado al compilador concreto.
Para poder compilar y enlazar la libería SDL tiene que abrir las opciones del proyecto (menú
“Proyecto”) y activar la pestaña “Parámetros”. En el cuadro con el título “Linker” escriba lo
siguiente:
Si ha instalado correctamente la librería SDL, con esto debería bastar. Recuerde que el
archivo sdl.dll debe estar en la misma carpeta que el programa ejecutable (o, si no, instalado
con las liberías del sistema de Windows)
Una vez instalada la libería y preparado el compilador, podemos usar las funciones de SDL
como cualquier otra función estándar de C. Su uso es exactamente igual en Windows y en
Linux, por lo que el programa que obtendremos debería compilar sin necesidad de hacerle
ningún cambio en ambos sistemas.
Para usar los gráficos, hay que hacer un #include <SDL/SDL.h> en el archivo fuente, como
es natural. Aparece dos veces el nombre “SDL” porque el archivo SDL.h está dentro de una
carpeta llamada SDL.
Lo siguiente que hay que hacer es inicializar la pantalla gráfica. Para eso disponemos de dos
funciones: SDL_Init() y SDL_SetVideoMode().
SDL_Init() debe ser la primera función en invocarse. No se puede usar ninguna otra función
de SDL si antes no se ha llamado a ésta. Hay que pasarle un parámetro que indica qué tipo
de sistema multimedia queremos manejar (la tarjeta de vídeo, la de sonido, el CD-ROM,
etc). En nuestro caso será la tarjeta de vídeo, ya que sólo nos interesa manipular gráficos.
La constante para ello es SDL_INIT_VIDEO:
SDL_Init(SDL_INIT_VIDEO);
La fución SDL_Init() devuelve –1 si ocurre algún error al iniciar el sistema de gráficos. En ese
caso, el programa no podrá continuar, de modo que debemos comprobar el valor devuelto
por SDL_Init().
Esto crea una ventana gráfica de 800×600 píxels, con 16 bits de profundidad de color. El
último parámetro, SDL_ANYFORMAT, es una constante que indica a SDL que puede
seleccionar otra profundidad de color si la elegida no está disponible. Este cuarto parámetro
puede tomar otros muchos valores que no vamos a ver, pero sí señalaremos que es
conveniente añadir la constante SDL_DOUBLEBUFFER por motivos de rendimiento (ver
ejemplo más abajo).
Siempre se entiende mejor con un ejemplo. Aquí va uno dónde se ilustra la inicialización de
la pantalla gráfica:
#include <SDL/SDL.h>
...
...
if (SDL_Init(SDL_INIT_VIDEO) == -1) {
SDL_Quit();
exit(-1);
SDL_Quit();
exit(-1);
...
Ya tenemos nuestra pantalla gráfica inicializada y lista para empezar a dibujar en ella. Pero,
¿qué tipo de objetos se pueden dibujar?
Aunque las librerías gráficas permiten al programador pintar píxels individuales en cualquier
punto de la pantalla, lo habitual es trabajar con imágenes previamente existentes
llamadas sprites. Un sprite es una imagen guardada en un archivo que puede ser cargada
por el programa y mostrada en cualquier parte de la pantalla gráfica y tantas veces como
sea necesario.
Por lo tanto, lo primero que necesita es hacerse con una colección de sprites para su
programa. Supongamos, por ejemplo, que estamos programando unjuego de ajedrez.
Necesitaremos los siguientes sprites (puede buscarlos en Internet, escanearlos, dibujarlos
usted mismo/a, etc):
Una imagen del tablero, a ser posible de buen tamaño (por ejemplo, de 400×400 píxels
como mínimo)
Una imagen de cada una de las piezas. En total son 12: peón, torre, caballo, alfil, dama
y rey, cada uno en dos colores (blanco y negro). El tamaño de estas imágenes debe ser
adecuado para reproducirlas dentro de cada uno de los recuadros del tablero. Si, por
ejemplo, en el tablero cada casilla mide 45×45 píxels, las imágenes de las piezas deben
ser de alrededor de 40×40 píxels (o incluso algo menos). Además, todas las piezas
deben tener el mismo color de fondo (para simplificar, negro)
Opcionalmente, todas las imágenes adicionales que deseemos para mejorar la estética
del programa.
Los archivos con las imágenes deben estar en formato BMP (SDL admite otros formatos,
pero el BMP es con diferencia el más fácil de manipular)
Para dibujar una imagen en cualquier punto de la pantalla, hay que hacer dos cosas que más
abajo describimos con detalle:
Sólo es necesario cargar las imágenes una vez. Normalmente, se hará al principio del
programa, justo después de la inicialización de SDL. Una vez cargadas en la memoria,
podremos utilizarlas tantas veces como las necesitemos, a menos que liberemos el espacio
de memoria que ocupan. La liberación de espacio, por tanto, debería hacerse al final del
programa, justo antes de terminar.
Para cargar una imagen BMP se usa la función SDL_LoadBMP(), de esta forma:
SDL_Surface *tablero;
tablero = SDL_LoadBMP("tablero.bmp");
if (fondo == NULL) {
SDL_Quit();
exit(-1);
Las imágenes son rectangulares. En muchas ocasiones, necesitamos mostrar una imagen
encima de otra. Es el caso de las piezas, que se mostrarán encima del tablero. Cuando esto
ocurre, el color de fondo de la pieza (que decidimos que fuera negro) aparecerá encima del
tablero como un desagradable recuadro de color negro. En estas situaciones, hay que avisar
a SDL de que, para este sprite en concreto, el color negro va a ser transparente, es decir, no
debe ser mostrado. Esto se hace así:
SDL_Surface *peon_blanco;
peon_blanco = SDL_LoadBMP("peon_bl.bmp");
if (peon_blanco == NULL) {
SDL_Quit();
exit(-1);
Las imágenes cargadas en memoria deben ser liberadas antes de finalizar el programa con
una llamada a SDL_FreeSurface(). Por ejemplo, para liberar la memoria ocupada por la
imagen “tablero.bmp” que hemos cargado antes usaremos el puntero que obtuvimos al
cargarla, así:
SDL_FreeSurface(tablero);
Una vez cargada una imagen BMP en la memoria, podemos mostrarla en la pantalla a través
del puntero SDL_Surface que obtuvimos al cargarla. Una imagen cargada puede ser
mostrada todas las veces que queramos en cualquier posición de la pantalla.
Por ejemplo, para mostrar la imagen del tablero (que cargamos en un ejemplo del apartado
anterior) haríamos lo siguiente (luego comentamos el código)
SDL_Rect rect;
SDL_Flip(pantalla);
Observe que “rect” es la que indica en qué lugar de la pantalla va a aparecer el sprite. En
este ejemplo, aparecerá en (10,10). Se le han reservado 400×400 píxels para dibujarse, es
decir, hasta la posición (410, 410). Si el sprite es más pequeño, no pasará nada (ocupará lo
que mida realmente). Si es más grande, se truncará.
Por último, SDL_Flip() hace que lo que acabamos de dibujar se muestre realmente en la
pantalla.
Para leer el teclado en una ventana gráfica creada con SDL no se pueden usar las funciones
estándar (como getchar() o gets()), sino las propias de SDL. SDL solo permite leer los
caracteres de uno en uno, y no muestra eco por la pantalla (si queremos eco, tenemos que
mostrar los caracteres nosotros mismos después de leerlos)
// Leer teclado
Existen constantes para cualquiera de las otras teclas del teclado. Todas empiezan por
“SDLK_”. Por ejemplo, la tecla “a” tendrá el código “SDLK_a”.
DEFINICIÓN DE COLORES
Aunque en general trataremos con imágenes ya creadas (como la del tablero o las de las
piezas), es posible que necesites definir algún color para usarlo directamente sobre la
pantalla gráfica (por ejemplo, para usar transparencias o para escribir un texto)
En SDL no hay colores predefinidos, como en ncurses. Los colores debemos definirlos
nosotros mezclando los colores básicos RGB (rojo, verde y azul)
Hay dos formas de definir un color: con una variable de tipo “SDL_Color” o con una variable
de tipo “Uint32”. El uso de una u otra dependerá de para qué queramos usar ese color.
Se usaría así:
SDL_Color color;
Los cuatro números definen el color. Deben ser números comprendidos entre 0 y 255. El
primero es el nivel de rojo (R), el segundo el nivel de verde (G) y el tercero, el nivel de azul
(B). El cuarto número es el brillo. El color definido en este ejemplo tiene mucho azul,
bastante verde y poco rojo. El resultado debe ser un azul amarillento.
Uint32 color;
En esta ocasión, “pantalla” debe ser un puntero a una imagen SDL_Surface que hayamos
cargado previamente. Los tres valores siguientes son los niveles RGB. No hay nivel de brillo,
porque éste se toma de la imagen apuntada por “pantalla”.
De las dos maneras se pueden definir colores para usarlos posteriormente. Si el color lo
necesitamos para una transparencia, recurriremos al segundo método (de hecho, ya vimos
un ejemplo de ello al estudiar cómo se cargaban y mostaban las imágenes en SDL; allí
usamos el color negro como transparencia). Si el color lo necesitamos para escribir un texto
en la pantalla gráfica, usaremos el primer método (como se podrá ver en el siguiente
apartado)
La libería SDL_TTF permite cargar fuentes true type que estén guardadas en archivos “.ttf” y
manejarlas como si fueran imágenes BMP en la pantalla gráfica generada por SDL.
Necesitamos SDL_TTF, por lo tanto, para escribir los mensajes de usuario, las opciones del
menú, etc.
En cuanto a la compilación y enlace, sólo tiene que añadir la opción “-lSDL_ttf” a la línea de
compilación del Makefile:
Inicialización de SDL_TTF
Igual que SDL, la librería SDL_TTF necesita ser inicializada antes de usarla, y finalizada antes
de terminar el programa para liberar los recursos adquiridos.
Como SDL_TTF corre por debajo de SDL, debe ser inicializada después de SDL, y debe ser
terminada antes que SDL.
if (TTF_Init() == -1) {
exit(-1);
Inmediatamente después podemos cargar una fuente true type de un archivo TTF, así:
TTF_Font* fuente;
....
if(fuente == NULL) {
exit(-1);
TTF_SetFontStyle(fuente, TTF_STYLE_BOLD);
La variable “fuente” es un puntero a la estructura TTF_Font. La función TTF_OpenFont() abre
el archivo “arial.ttf” y carga el tipo de letra Arial en tamaño 14 para su uso en el programa.
Después es conveniente comprobar que el puntero “fuente” contenga un valor válido y no
NULL.
Finalización de SDL_TTF
TTF_CloseFont(fuente);
La variable “fuente” será de tipo TTF_Font*, y debe coincidir con la que nos devolvió la
función TTF_OpenFont(). Esta operación la repetiremos con cada una de las fuentes que
hayamos cargado.
Después finalizaremos SDL_TTF escribiendo:
TTF_Quit();
Recuerda que esto debe hacerse ANTES de SDL_Quit(), ya que SDL_TTF depende de SDL.
Todo esto lo hacemos con un objetivo: poder escribir texto en la pantalla gráfica y sustituir
así todas las funciones printf() y similares.
Para escribir un texto hay que hacer dos cosas: primero, convertirlo en una imagen;
segundo, mostrar la imagen en la pantalla.
SDL_Color color;
SDL_Surface* txt_img;
if(txt_img == NULL) {
exit(-1);
Como ve, hay que hacer bastantes cosas para mostrar un texto en la pantalla gráfica, pero
todo es acostumbrarse. Primero, hay que definir un color para el texto (cómo se definen los
colores es algo que vimos en el epígrafe anterior). En este caso, hemos escogido un rojo
brillante.
Después se invoca a TTF_RenderText(), pasándole como parámetros el puntero a la fuente
que obtuvimos con TTF_OpenFont(), el texto que queremos mostrar y el color. La función
nos devuelve un puntero de tipo SDL_Surface* que, si recuerdas, es exactamente el mismo
que usábamos con las imágenes cargadas desde un archivo BMP.
TTF_RenderText_Solid(): realiza una conversión del texto en imagen rápida pero de poca
calidad.
TTF_RenderText_Shaded(): la imagen resultante es de gran calidad pero tiene un
recuadro negro alrededor
TTF_RenderText_Blended(): la imagen resultante es de gran calidad y sin recuadro
negro
En general preferiremos el modo “Blended”, que es el que proporciona mejores resultados. El
modo “Shaded” se puede usar en determinados lugares (si no hay otra imagen debajo del
texto). El modo “Solid” sólo debe usarse si hay que mostrar mucho texto y el modo
“Blended” se revela demasiado lento.
Hasta aquí, sólo hemos convertido el texto “Hola mundo” en una imagen, pero aún no la
hemos mostrado en la pantalla. Para hacerlo procederemos como con cualquier otra imagen:
SDL_Flip(scr);
Se supone que “rect” es de tipo SDL_Rect y que pantalla es el puntero a SDL_Surface* que
nos devolvió SDL_SetVideoMode() al inicializar SDL. Así, el texto “Hola mundo” se mostrará
en la posición (500, 280) de la pantalla gráfica, reservándose para él 100 píxels de ancho y
30 de alto.
Los tipos de datos vistos hasta ahora (enteros, reales, caracteres y lógicos) se
denominan simples porque no pueden descomponerse en otros datos más simples aún.
Los tipos de datos complejos son aquellos que se componen de varios datos simples y,
por lo tanto, pueden dividirse en partes más sencillas. A los tipos de datos complejos se les
llama también estructuras de datos.
Las estructuras de datos pueden ser de dos tipos:
Estáticas: son aquéllas que ocupan un espacio determinado en la memoria del
ordenador. Este espacio es invariable y lo especifica el programador durante la escritura
del código fuente.
Dinámicas: sin aquéllas cuyo espacio ocupado en la memoria
puedemodificarse durante la ejecución del programa.
Las estructuras estáticas son mucho más sencillas de manipular que las dinámicas, y son
suficientes para resolver la mayoría de los problemas. Las estructuras dinámicas, de manejo
más difícil, permiten aprovechar mejor el espacio en memoria y tienen aplicaciones más
específicas.
Además, se pueden mencionar como una clase de estructura de datos diferente
las estructuras externas, entendiendo como tales aquéllas que no se almacenan en la
memoria principal (RAM) del ordenador, sino en alguna memoria secundaria (típicamente, un
disco duro). Las estructuras externas, que también podemos
denominar archivos o ficheros, son en realidad estructuras dinámicas almacenadas en
memoria secundaria.
En diferentes puntos del Curso de Programación en C nos dedicamos a estudiar con detalle
los tres tipos de estructuras.
El caso más simple de array es el array unidimensional, también llamado vector. Por
ejemplo, un vector de números enteros es una colección de muchos números enteros a los
que les adjudicamos un único identificador.
tipo_de_datos nombre_vector[número_de_elementos];
Por ejemplo:
int serie[5];
La variable serie será un vector que contendrá 5 números enteros. Los 5 números reciben el
mismo nombre, es decir, serie. Se puede acceder a cada uno de los números que forman el
vector escribiendo a continuación del nombre un número entre corchetes. Ese número se
denomina índice. Observe el siguiente ejemplo:
int serie[5];
serie[2] = 20;
serie[3] = 15;
printf("%i", serie[4]);
Es muy útil representar los vectores de forma gráfica para entenderlos mejor. El vector serie
del ejemplo anterior se puede representar así:
Posiciones 0 1 2 3 4
+---+---+----+----+----+
Valores | ? | ? | 20 | 15 | 35 |
+---+---+----+----+----+
Observe algo muy importante: el primer elemento del vector tiene el índice 0, es decir,
el primer elemento es serie[0]. Como este vector tiene 5 elementos, el último será serie[4],
no serie[5]. Observe también que los elementos 0 y 1 no han sido utilizados y, por lo tanto,
tienen un valor desconocido, exactamente lo mismo que ocurre con cualquier variable de tipo
simple que no se inicialice.
C no realiza comprobación de los índices de los arrays, por lo que es perfectamente posible
utilizar un índice fuera del rango válido (por ejemplo, serie[7]). Es responsabilidad del
programador evitar que esto ocurra, porque los efectos pueden ser desastrosos.
Como es lógico, se pueden construir vectores cuyos elementos sean de cualquier otro tipo
simple, como float o double, con la única restricción de que todos los elementos sean del
mismo tipo. Los vectores de caracteres se denominan cadenas de caracteres, y por sus
especiales características los estudiaremos en un epígrafe posterior.
También es posible construir vectores cuyos elementos sean de un tipo complejo. Así,
podemos tener vectores de vectores o de cualquier otro tipo.
Para asignar valores a los elementos de un vector, por lo tanto, el mecanismo es este:
int serie[5];
serie[0] = 5;
serie[1] = 3;
serie[2] = 7;
...etc...
El resultado de esta declaración será un vector de 5 elementos de tipo entero a los que se les
asigna estos valores:
0 1 2 3 4
+---+---+---+---+----+
| 5 | 3 | 7 | 9 | 14 |
+---+---+---+---+----+
Cada elemento del vector es, a todos los efectos, una variable que puede usarse
independientemente de los demás elementos. Así, por ejemplo, un elemento del vector serie
puede usarse en una instrucción de salida igual que cualquier variable simple de tipo int:
int serie[5];
serie[0] = 21;
printf("%i", serie[0]);
Del mismo modo, pueden usarse elementos de vector en una instrucción de entrada. Por
ejemplo:
int serie[5];
scanf("%i", &serie[0]);
printf("%i", serie[1]);
RECORRIDO DE UN VECTOR
Supongamos, por ejemplo, que tenemos un vector de 10 números enteros declarado como
int v[10]; y una variable entera declarada como int i;. Por medio de un bucle, con ligeras
modificaciones, podemos realizar todas estas operaciones:
v[i] = 0;
scanf("%i", &v[i]);
{
printf("El elemento nº %i vale %i\n", i, v[i]);
d) Realizar alguna operación que implique a todos los elementos. Por ejemplo,
sumarlos:
suma = 0;
Ordenar los elementos de un vector mediante algún criterio es una operación típica y en
absoluto trivial. Por ejemplo, un vector de números enteros puede ordenarse de menor a
mayor. Si el vector original es este:
0 1 2 3 4
+---+---+----+---+---+
| 5 | 3 | 14 | 9 | 8 |
+---+---+----+---+---+
…si lo ordenamos de forma creciente (de menor a mayor), nos quedará este otro vector:
0 1 2 3 4
+---+---+---+---+----+
| 3 | 5 | 8 | 9 | 14 |
+---+---+---+---+----+
Del mismo modo, se pueden ordenar los elementos con cualquier otro criterio: de mayor a
menor, primero los pares y luego los impares, o cualquier otro que nos resulte útil para
resolver un problema.
Métodos de ordenación de vectores hay muchos, desde los más simples (e ineficientes) hasta
los más elaborados, y constituyen un área de estudio muy interesante dentro de la
algorítmica.
La idea general es simple: tomaremos los dos primeros elementos y los compararemos. Si
están desordenados, intercambiamos sus posiciones. Si están ordenados, los dejamos como
están.
Cuando lleguemos al final, el vector no estará todavía ordenado, pero el elemento más
grande del vector habrá subido hasta la última posición, igual que una burbuja de aire en el
agua.
Si volvemos a repetir el proceso desde el principio, el segundo elemento más grande habrá
subido hasta la penúltima posición del vector. Y, la siguiente vez, el tercer elemento más
grande subirá hasta la antepenúltima posición. Y así hasta que ordenemos todos los
elementos.
Si el vector tiene N elementos, hay que repetir el recorrido N veces, aunque en cada
repetición podemos quedarnos una posición más abajo del final, ya que sabemos con
seguridad que los últimos elementos están colocados en su sitio.
int i, j, elem;
{
for (j = LONGITUD_VECTOR - 1; j >=i; j--)
elem = v[j-1];
v[j-1] = v[j];
v[j] = elem;
El método de la burbuja necesita muchos pasos para completarse (y por eso tarda tanto y se
dice que es un algoritmo ineficiente). En concreto, si vector tiene N elementos, hay que
ejecutar alrededor de N*N pasos para completar la ordenación. El tiempo de ejecución es
proporcional al número de pasos necesarios y, por lo tanto, crecerá exponencialmente (al
ritmo de N2) con el tamaño del vector. Por eso no es un método práctico cuando se trata de
vectores muy grandes.
minimo = v[i];
posicion_minimo = i;
for (j=i; j < LONGITUD_VECTOR; j++)
minimo = v[j];
posicion_minimo = j;
v[posicion_minimo] = v[i];
v[i] = minimo;
Ahora dividimos el vector en dos mitades: a la izquierda del pivote y a la derecha del pivote.
Procesaremos cada mitad con el mismo procedimiento: buscar un nuevo pivote e
intercambiar elementos de la izquierda y de la derecha.
Repetiremos el proceso hasta que los vectores sean triviales (es decir, hasta que el pivote
coincida con los extremos izquierdo y/o derecho).
Este algoritmo necesita, por término medio, un número de pasos proporcional a N * log(N).
Puede que no parezca mucho comparado con el N2 que necesita la burbuja, pero, para
tamaños muy grandes, la diferencia de tiempos es enorme.
int i, j, x, w; i = iz;
j = de;
x = v[(iz+de) / 2];
do
if (i <= j)
w = v[i];
v[i] = v[j];
v[j] = w;
i++;
j--;
w = v[i];
v[i] = v[de];
v[de] = w;
En los vectores, como en todas las estructuras de datos que contienen muchos datos en su
interior, también es habitual encontrarse con la operación de búsqueda.
La operación de búsqueda puede llegar a ser muy lenta (con el método de búsqueda
secuencial, que enseguida veremos), por lo que si en un programa tenemos que realizar
búsquedas en vectores grandes repetidas veces, debemos pensar el modo de lograr que las
búsquedas sean más rápidas. Por fortuna, existe una forma muy simple de hacer una
búsqueda en un vector de manera tremendamente rápida (con el método llamado de
búsqueda binaria, que también veremos). Pero esta forma tiene un problema: para que
funcione, el vector debe estar previamente ordenado. El proceso de ordenación, como ya
hemos visto, es lento y costoso, pero, a cambio, obtendremos unos tiempos de búsqueda
notablemente mejores.
Si las búsquedas se realizan pocas veces, o bien los vectores son pequeños, optaremos
por la búsqueda secuencial, que no necesita ordenar previamente el vector.
Si las busquedas se realizan muchas veces y los vectores son de gran tamaño,
optaremos por la búsqueda binaria, pero antes debemos ordenar el vector con alguno de
los métodos que hemos estudiado en la sección anterior.
BÚSQUEDA SECUENCIAL
// Búsqueda secuencial
int i = 0;
int x = -1;
x = i; // Anotamos en x la posición
i++;
return x;
BÚSQUEDA BINARIA
Para que esta búsqueda funcione, el vector debe estar previamente ordenado, como ya
hemos aclarado.
El método consiste en lo siguiente:
1. Supongamos que v es el vector y que contiene N elementos. Llamaremos iz a la posición
del elemento izquierdo del vector (inicialmente, iz = 0). Llamaremos de a la posición del
elemento derecho del vector (inicialmente, de = N-1)
2. Tomamos un x igual al punto medio entre iz y de, es decir, x = (iz/de) / 2
3. Miramos el elemento v[x]. Si es el dato que buscábamos, ya hemos terminado. Si no,
pueden ocurrir dos cosas:
Que v[x] sea mayor que el dato que buscábamos. En ese caso, y dado que el vector
está ordenado, continuamos la búsqueda a la izquierda de x, haciendo que de = x.
Que v[x] sea menor que el dato que buscábamos. En ese caso, continuamos la
busqueda a la derecha de x, haciendo iz = x.
4. Repetimos desde el paso 2 hasta que encontremos el elemento buscado o hasta que iz =
de (lo que significará que el elemento no está en el vector)
He aquí una implementación en C:
encontrado = 0;
izq = 0;
der = LONGITUD_VECTOR - 1;
encontrado = 1;
der = mitad-1;
izq = mitad+1;
}
if (encontrado == 1)
return mitad;
else
return -1;
Vectores y funciones en C
23 abril 2008 in ...en lenguaje C, ...para estudiantes, ...para principiantes, Programación
(Este artículo forma parte del Curso de Programación en C)
Para pasar un vector como argumento a una función, en la llamada a la función se escribe
simplemente el nombre del vector, sin índices. Esto sirve para pasar a la función la dirección
de memoria donde se almacena el primer elemento del vector. Como C guarda todos los
elementos de los vectores en posiciones de memoria consecutivas, conociendo la dirección
del primer elemento es posible acceder a todas las demás.
El hecho de que a la función se le pase la dirección del vector y no sus valores provoca un
efecto importante: que los arrays siempre se pasan por referencia, nunca por valor (si no
sabe de lo que estamos hablando, puede repasar este post). Esto incluye a los vectores, que
son arrays unidimensionales. Por lo tanto, si algún elemento del vector se modifica en una
función, también será modificado en la función desde la que fue pasado.
Como siempre se pasan por referencia, no es necesario utilizar el símbolo & delante del
parámetro. Por ejemplo, supongamos que serie es un vector de 15 números enteros. Para
pasarlo como parámetro a una función llamada funcion1 escribiríamos simplemente esto:
int serie[15];
funcion1(serie);
El resultado de las tres declaraciones es, en principio, idéntico, porque todas indican al
compilador que se va a recibir la dirección de un vector de números enteros. En la práctica,
sin embargo, las dos últimas pueden darnos problemas en algunos compiladores, así que
preferiremos la primera declaración (la que utiliza un array delimitado)
Dentro de la función, el vector puede usarse del mismo modo que en el programa que la
llama, es decir, no es preciso utilizar el operador asterisco.
Por ejemplo: Un programa que sirve para leer 50 números por teclado, y calcular la suma, la
media y la desviación típica de todos los valores. La desviación es una magnitud estadística
que se calcula restando cada valor del valor medio, y calculando la media de todas esas
diferencias.
Después, se invoca a tres funciones que calculan las tres magnitudes. El vector también se
pasa por variable a estas funciones, ya que en C no hay modo de pasar un vector por valor.
#include <stdio.h>
#include <math.h>
int main(void)
float valores[50];
introducir_valores(valores);
suma = calcular_suma(valores);
return 0;
int i;
scanf("%f", &N[i]);
{
int i;
float suma;
suma = 0;
return suma;
int i;
float media;
return media;
int i;
float diferencias;
diferencias = 0;
return diferencias;
Por ejemplo, si v1 es un vector de 10 números de tipo short int (suponiendo que cada
número de dicho tipo ocupa 1 byte de memoria), el compilador asignará un espacio de
memoria al elemento 0. Imaginemos que dicho espacio de memoria se ubica en la dirección
2000. Entonces, el resto de elementos del vector ocuparán la posición 2001, la 2002, la
2003, … hasta la 2009.
Por otro lado, si un vector v2 consta de 50 números de tipo int, y suponemos que los datos
de este tipo ocupan 2 bytes, si el primer elemento tiene asignada la posición 2000, el
siguiente estará en la posición 2002, el siguiente en la 2004, etc.
¿Qué ocurre si se intenta acceder a un elemento del vector más allá de su límite? Dicho de
otro modo, si tenemos un vector de 10 elementos, ¿qué pasa si intentamos utilizar el
elemento undécimo? Lógicamente, que estaremos invadiendo el espacio de direcciones que
hay más allá del límite del vector: la dirección 2010 y siguientes en el caso del vector v1, y
la 2020 y siguientes en el caso del vector v2. Esas direcciones pertenecerán a otras variables
o, lo que es peor, a algún fragmento de código.
Si leemos información de ese espacio de direcciones, lo peor que puede ocurrir es que
obtengamos basura.
La cadenas son sin duda los vectores que más se utilizan y, por ese motivo, tienen ciertas
peculiaridades que comentaremos en este apartado. Todo lo que hemos dicho hasta ahora
sobre vectores es aplicable a las cadenas.
Las cadenas pueden manipularse elemento por elemento, como cualquier vector. Por
ejemplo:
char cadena[50];
cadena[0] = 'H';
cadena[1] = 'o';
cadena[2] = 'l';
cadena[3] = 'a';
Las cadenas deben tener, después de su último carácter válido, un carácter especial
llamado nulo. Este carácter marca el final de la cadena. El carácter nulo se simboliza con
el código . Por lo tanto, en el ejemplo anterior habría que agregar la siguiente línea para que
la cadena estuviera completa:
cadena[4] = '';
Todas las cadenas deben terminar en un carácter nulo. De lo contrario, podemos tener
problemas al imprimirlas en la pantalla o al realizar con ellas cualquier otro proceso. En
consecuencia, en una cadena definida como la anterior, de 50 caracteres, en realidad sólo
tienen cabida 49, ya que siempre hay que reservar una posición para el carácter nulo.
Puede encontrar una referencia a las funciones más habituales en este artículo.
Una de las principales fuentes de error de los programas son los datos de entrada
incorrectos. Por ejemplo, si un programa está preparado para leer un número entero pero el
usuario, por error o por mala fe, introduce un carácter, la función scanf() fallará y el
programa, probablemente, se detendrá.
El programador tiene, por suerte, un modo de prevenir estos errores: leyendo todos los
datos de entrada como cadenas y, luego, convirtiéndolos al tipo de dato adecuado.
Observe el siguiente ejemplo. Sirve para leer un número entero por teclado, pero
previniendo los errores provocados por el usuario que antes mencionábamos. Se utiliza la
función atoi(), que convierte una cadena a un número entero, y cuya sintaxis puedes
encontrar en el tema 2 (en el apéndice dedicado a las funciones de uso frecuente de ANSI
C).
Las funciones de librería ANSI C para manejar cadenas suelen empezar por las letras “str”
(de “string”, que significa “cadena” en inglés) y utilizan el archivo de cabecera string.h. Hay
multitud de funciones estándar, pero la mayor parte de las veces sólo se utilizan unas
cuantas. Pasamos exponerlas esas funciones de uso frecuente a continuación.
GETS() Y PUTS()
Para leer por teclado una cadena de caracteres se puede utilizar también la función scanf()
con la cadena de formato “%s”. Como las cadenas son vectores, no es preciso anteponer el
símbolo & al nombre de la variable. Sin embargo, es preferible emplear la función gets() por
estar específicamente diseñada para la lectura de cadenas. Por ejemplo:
char cadena[50];
gets(cadena);
De manera análoga podemos emplear la función printf() para escribir el contenido de una
cadena en la pantalla, pero preferiremos la función puts(), específica de las cadenas. Por
ejemplo:
puts(cadena);
STRCPY()
Copia el contenido de una cadena en otra, incluyendo el carácter nulo. Su sintaxis es:
strcpy(cadena_origen, cadena_destino);
char cad1[50];
strcpy(cad1, cad2);
strcpy(cad2, "mundo");
STRLEN()
Devuelve la longitud de una cadena, es decir, el número de caracteres de que consta, sin
contar el carácter nulo.
Por ejemplo, en este fragmento de código el resultado debe ser 11. Fíjate que la variable
cadena tiene una longitud total de 50 caracteres, pero strlen() sólo cuenta los que
efectivamente se están usando, es decir, los que hay hasta el carácter nulo:
int longitud;
longitud = strlen(cadena);
STRCMP()
Compara dos cadenas. Devuelve el valor 0 si son iguales, un valor mayor que 0 si la primera
es alfabéticamente mayor que la segunda, o un valor menor que 0 en caso contrario. Su
sintaxis es general es:
strcmp(cadena1, cadena2);
Por ejemplo:
int comparacion;
if (comparacion == 0)
STRCAT()
Concatena dos cadenas. Esta función añade la cadena2 al final de la cadena1, incluyendo el
carácter nulo.
strcat(cadena1, cadena2);
strcat(cad1, cad2);
prinft("%s", cad1);
Una matriz, tabla o array bidimiensional, como un vector, es una colección de elementos
individuales, todos del mismo tipo, agrupados bajo el mismo identificador. La diferencia con
el vector es que, en el momento de declararlo y de acceder a cada elemento individual,
debemos utilizar dos índices en lugar de uno:
int matriz[4][4];
Tenemos aquí una variable compleja llamada matriz que no consta de 4 elementos enteros,
sino de 16, es decir, 4×4. Podemos representar gráficamente la matriz como una tabla:
Columnas
0 1 2 3
+---+---+---+---+
0 | | | | |
F +---+---+---+---+
i 1 | | | | |
l +---+---+---+---+
a 2 | | | | |
s +---+---+---+---+
3 | | | | |
+---+---+---+---+
matriz[0][0] = 5;
matriz[1][0] = 1;
matriz[3][2] = 13;
Columnas
0 1 2 3
+---+---+----+---+
0 | 5 | | | |
F +---+---+----+---+
i 1 | 1 | | | |
l +---+---+----+---+
a 2 | | | | |
s +---+---+----+---+
3 | | | 13 | |
+---+---+----+---+
Por descontado, los dos índices de la matriz pueden ser diferentes, obteniéndose tablas que
son más anchas que altas o más altas que anchas.
Por lo demás, las matrices se utilizan exactamente igual que los vectores. A modo de
ejemplo, éste sería el código para inicializar una matriz de 5×10 enteros con todos sus
elementos a 0. Observe cómo se usan los dos bucles anidados para acceder a todos los
elementos:
int m[5][10];
int i, j;
m[i][j] = 0;
Del mismo modo que a los vectores se les puede añadir un segundo índice, obteniendo las
matrices, se puede generalizar esta práctica, dando lugar a arrays multidimensionales. Por
ejemplo, el siguiente es un array de cinco dimensiones compuesto de números enteros:
int ejemplo[10][10][4][5][7];
Estos arrays no se pueden representar gráficamente (aunque con los de tres dimensiones se
puede intentar dibujar un cubo), pero su utilización es idéntica a la de los arrays de una o
dos dimensiones.
En los arrays, todos los elementos deben ser del mismo tipo. Pero hay ocasiones en las que
debemos agrupar elementos de diversa naturaleza: para eso existen
las estructuras o registros. Una estructura, por tanto, es una agrupación bajo el mismo
nombre de varios datos cuyos tipos pueden ser diferentes.
DECLARACIÓN DE ESTRUCTURAS
struct nombre_estructura
{
tipo1 dato1;
tipo2 dato2;
...
tipoN datoN;
};
struct datos_carnet
char letra;
char nombre[50];
char apellidos[100];
};
La variable dni que se declara en la última línea no es de un tipo simple, como int o float,
sino de un tipo complejo que acabamos de definir, llamado struct datos_carnet. Por lo tanto,
una única variable (dni) va a contener varios datos agrupados en su interior (el número del
DNI, la letra, el nombre y los apellidos)
MANIPULACIÓN DE ESTRUCTURAS
Una vez que se tiene una variable compleja definida mediante una estructura surge la
pregunta: ¿cómo se puede manipular cada uno de los elementos individuales (miembros)
que forman parte de la estructura?
El acceso a los miembros se realiza con el nombre de la variable y el del miembro separados
por un punto, así:
variable_estructura.miembro;
dni.numero = 503202932;
dni.letra = 'K';
strcpy(dni.nombre, "Manuel");
Lógicamente, para escribir un miembro en la pantalla, leerlo por teclado o realizar con él
cualquier otro proceso, se utiliza la misma sintaxis.
PASO DE ESTRUCTURAS A FUNCIONES
1. Que queramos pasar una estructura completa como parámetro a una función
2. Que queramos pasar sólo un miembro de una estructura como parámetro a una función
Paso de estructuras completas como parámetros
Las variables basadas en estructuras se pueden pasar como parámetros por valor o por
referencia, existiendo entre ambos métodos las mismas diferencias que en los tipos de datos
simples.
Para pasar, por ejemplo, la variable dni del ejemplo anterior por valor a una función llamada
escribir_dni(), procederíamos de este modo:
escribir_dni(dni);
Y también puede pasarse por referencia añadiendo el símbolo “&”, de esta otra manera:
escribir_dni(&dni);
Mientras que el paso por variable tiene esta forma (usando el símbolo ” * “):
printf("%s", dni.nombre);
Pero si la estructura dni se ha pasado por variable, se sustituye el punto por la flecha “->”:
printf("%s", dni->nombre);
Así, para pasar por valor únicamente el miembro dni.numero a una función llamada, por
ejemplo, escribir_dni(), haríamos esto:
escribir_dni(dni.numero);
Dentro del cuerpo de la función, la variable número puede usarse como cualquier otra
variable de tipo entero.
Si lo que queremos es pasar el miembro dni.numero por variable, no por valor, lo haremos
igual que con cualquier dato de tipo entero, es decir, agregando el símbolo & a la llamada:
escribir_dni(&dni.numero);
En este caso, cada vez que vaya a usarse el parámetro número dentro del código de la
función, al estar pasado por variable debe ir precedido del símbolo ” * “; por ejemplo:
*numero = 5;
La entrada de datos se hace en una función llamada leer_datos(), a la que se pasa como
parámetro una variable del tipo de la estructura. Luego se hace una pequeña modificación en
la edad del alumno, para convertirla de años a meses, y se muestran los datos en la pantalla
llamando a otra función, escribir_datos().
Preste especial atención a cómo se pasan los parámetros de tipo complejo a las funciones.
En la primera función, leer_datos(), se pasa la estructura por variable. En la segunda,
escribir_datos(), se pasan los miembros de la estructura (no la estructura completa), y
además se hace por valor.
Observe también que la estructura se define antes de la función main(). Esto la convierte en
un tipo de datos global, es decir, utilizable desde cualquier punto del programa. Si la
definiéramos dentro de la función main() sólo podría emplearse en esa función.
#include <stdio.h>
#include <string.h>
int matricula;
char nombre[30];
int edad;
};
int main(void)
{
leer_datos(&alumno);
scanf("%d", &alumno->matricula);
gets(alumno->nombre);
scanf("%d", &alumno->edad);
Uniones en C
12 mayo 2008 in ...en lenguaje C, ...para estudiantes, ...para programadores,Programación
(Esta entrada forma parte del Curso de Programación en C)
Las uniones son muy similares a las estructuras: se declaran de manera análoga (cambiando
la palabra struct por union) y se utilizan exactamente igual. Por ejemplo:
union datos_carnet
char letra;
char nombre[50];
char apellidos[100];
};
La diferencia radica en que todos los miembros de la union comparten el mismo espacio en
memoria, de manera que sólo se puede tener almacenado uno de los miembros en cada
momento.
El tamaño de la union es igual al del miembro más largo (no hagan chistes con esta frase).
Supongamos que, en el ejemplo anterior, la longitud de cada miembro es:
dni.número = 55340394;
strcpy(dni.nombre, "María");
…la cadena “María” ocupará los primeros 5 bytes de la unión y, por lo tanto, se habrá
perdido el número almacenado anteriormente.
Al usar uniones, únicamente debemos acceder a los miembros que en ese momento tengan
algún valor. El siguiente código, por ejemplo, funciona correctamente y escribe en la pantalla
el texto “María”:
dni.número = 55340394;
strcpy(dni.nombre, "María");
printf("%s", dni.nombre);
dni.número = 55340394;
strcpy(dni.nombre, "María");
printf("%d", dni.número);
Por lo demás, las uniones se utilizan exactamente igual que las estructuras, con la ventaja
de que ahorran espacio en memoria. Sin embargo, al compartir todos los miembros las
mismas posiciones de memoria, la utilidad de las uniones queda reducida a determinados
algoritmos en los que esta limitación no representa un problema.
Enumeraciones en C
13 mayo 2008 in ...en lenguaje C, ...para estudiantes, Programación
(Este artículo forma parte del Curso de Programación en C)
Por ejemplo:
Las variables que se declaren del tipo dias_semana serán, en realidad, variables enteras,
pero sólo podrán recibir los valores del 1 al 7, así:
dias_semana dia;
dia = LUNES;
Por último, el programador debe tener en cuenta que los identificadores utilizados en una
enumeración son constantes enteras y que, por lo tanto, lo siguiente imprime en la pantalla
un 2, y no la palabra “MIÉRCOLES”:
dias_semana dia;
dia = MIERCOLES;
printf("%i", dia);
Por ejemplo:
typedef struct
int dia;
int mes;
int anno;
} t_fecha;
Tras esta definición habrá quedado definido un nuevo tipo de datos llamadot_fecha. Por lo
tanto, se podrán declarar variables de ese tipo:
t_fecha fecha_hoy;
t_fecha fecha_nacimiento;
Los identificadores de los tipos deben cumplir todas las reglas habituales (nada de caracteres
especiales ni espacios). Es una buena costumbre que el nombre de un tipo de datos empiece
por la letra “t”, para diferenciar así los identificadores de tipo de los identificadores de
variable.
TIPOS SUPERCOMPLEJOS
En otros lugares de este curso de programación en C hemos visto varios tipos de datos
simples (entero, real, carácter…) y complejos (arrays, estructuras, uniones…). Los tipos
complejos se refieren a datos compuestos por otros datos como, por ejemplo, un array de
números enteros.
Sin embargo, es perfectamente posible que los datos que componen un tipo complejo sean,
a su vez, de tipo complejo. Por ejemplo, es posible tener un array de estructuras, o una
estructura cuyos miembros son arrays u otras estructuras.
En el siguiente ejemplo podemos ver un array unidimensional (vector) cuyos elementos son
estructuras:
/* Array de estructuras */
struct fecha
int dia;
int mes;
int anno;
};
lista_de_fechas[3].día = 5;
Otro caso bastante habitual es el de estructuras que tienen como miembros a otras
estructuras. Veamos un ejemplo:
/* Estructura de estructuras */
struct s_fecha
int dia;
int mes;
int anno;
};
struct s_hora
};
struct calendario
La variable fecha_hoy es de tipo struct calendario, que es un tipo que a su vez está
compuesto de otras dos estructuras. El acceso a los miembros de fecha_hoy se hará del
siguiente modo:
fecha_hoy.fecha.dia = 5;
fecha_hoy.fecha.mes = 12;
fecha_hoy.hora.hh = 23;
Estos datos de tipo supercomplejo pueden combinarse de la forma que más convenga al
problema que tratamos de resolver.
Sólo hay una razón, pero es una razón importante: la elección de una estructura de datos
adecuada puede hacer que la solución de un problema difícil sea fácil. Y al revés. Así que
seleccionar la estructura de datos más adecuada a cada problema es una de las primeras
cosas (¡y de las más importantes!) que tiene que hacer un programador al enfrentarse a un
nuevo programa.
Estamos acostumbrados a usar en nuestra vida cotidiana estructuras de datos sin darnos
cuenta. Nuestro cerebro es un complejo sistema de procesamiento de información, y la
información se almacena y manipula en estructuras.
Los datos más simples necesitan estructuras simples. “Tengo 58 años” es un enunciado que
transmite una determinada información que todos ustedes han comprendido sin esfuerzo. La
información “viaja” transportada en un dato: 58. Es un dato simple de tipo número entero.
Puedo sustituir la frase anterior por “Tengo X años”, donde X es cualquier número entero.
Entonces decimos que X es una variable de tipo entero, porque puede ser asignada a
cualquier número entero. Así no tengo un solo enunciado, sino toda una colección de
enunciados diferentes y válidos que responden al patrón genérico de “Tengo X años”
+---+---+---+----+----+----+---+---+---+----+
v = | 5 | 7 | 2 | 23 | 18 | 19 | 7 | 5 | 3 | 19 |
+---+---+---+----+----+----+---+---+---+----+
Una sola variable (v) es capaz de almacenar y manipular muchos números. v[1] será el
primero de ellos (o v[0], dependiendo del lenguaje de programación que empleemos). v[2]
será el segundo, etc.
“Tengo v[4] años” es un enunciado tan válido como “Tengo X años”. v[4] es una variable
entera simple. v es una variable compleja: un vector de enteros.
1. i = 1
4. i = i +1
Trate de imaginar cómo sería el algoritmo anterior utilizando seis variables independientes
para los seis números, en lugar de un vector. Chungo, ¿a qué sí? En este caso, utilizar una
estructura más simple provoca que el programa sea mucho más complejo.
La elección de una estructura de datos adecuada es, como vemos, importantísima para
resolver con éxito un problema. Por eso hay tantos tipos de estructuras. Los vectores y las
matrices se adaptan muy bien a muchísimos problemas, pero para otros son completamente
inadecuados. Y ahí es donde entran en juego las estructuras de datos exóticas: pilas, colas,
árboles, grafos, etc.
¿Recuerdan el algoritmo para jugar a las tres en raya? La solución por fuerza bruta generaba
el espacio de estados completo, que tenía estructura de árbol. Los árboles son
imprescindibles en muchos problemas de inteligencia artificial o, en general, de búsqueda
exhaustiva de soluciones.
Del mismo modo, las pilas, las colas y las otras estructuras se adaptan como guantes a
determinados problemas tan dispares como, por ejemplo, losanalizadores sintácticos o
el reparto de recursos en el sistema operativo.
Cómo se programan y manejan estas estructuras es otra historia. Suelen usar memoria
dinámica, para crecer y decrecer conforme cambien las necesidades del programa, lo cual es
una complicación añadida. Pero de eso hablaremos en otro artículo.
Hasta este momento, todas las operaciones de entrada y salida de datos de nuestros
programas se han hecho a través del teclado (entrada) y la pantalla (salida). Estos son los
dispositivos de entrada y salida por defecto, pero también se pueden enviar datos hacia un
archivo, o recibirlos de él.
Además, todos los datos que hemos manejado, ya sea mediante tipos de datos simples o
estructuras complejas, han estado alojados en la memoria principal del ordenador, de
manera que al apagar éste, o antes, al terminar el programa, toda esa información se
perdía. Como es natural, también es posible almacenar datos en memoria secundaria, es
decir, en dispositivos tales como discos duros, discos flexibles, discos ópticos, memorias
USB, etc. Estas memorias se caracterizan por ser más lentas que la memoria principal del
ordenador, pero también disponen de más espacio de almacenamiento, y no son volátiles, es
decir, no pierden su contenido al desconectar el ordenador.
Segunda definición importante: Un registro es, por tanto, cada una de las unidades
individuales en las que se divide un fichero. Cada registro debe contener datos
pertenecientes a una misma cosa. Además, cada registro es un estructura de datos, es decir,
está compuesto de otros datos más simples, que llamaremos campos.
Eso nos lleva a la tercera definición importante: Un campo es cada uno de los elementos que
constituyen un registro. Cada campo se caracteriza por un identificador que lo distingue de
los otros campos del registro, y por el tipo de dato que tiene asociado, que, a su vez, puede
ser simple (número entero, carácter, lógico, etc) o compuesto (cadena de caracteres, fecha,
vector, etc).
Observe el siguiente ejemplo de fichero. Contiene información relacionada entre sí: los datos
personales de un conjunto de personas. Toda esa información está distribuida en registros,
que son cada una de las filas de la tabla. Cada registro, por tanto, contiene los datos
pertenecientes a una sola persona. Los registros se dividen en campos, que son cada una de
las unidades de información que contiene cada registro.
C A M P O S
E +--------+-----------+--------------+---------+---------------+
I +--------+-----------+--------------+---------+---------------+
T +--------+-----------+--------------+---------+---------------+
O +--------+-----------+--------------+---------+---------------+
Para diferenciar a un registro de otro es conveniente que alguno de los campos tenga un
valor distinto en todos los registros del archivo. Este campo, que identifica unívocamente
cada registro, se denomina campo clave o, simplemente, clave. En el ejemplo anterior, el
campo clave puede ser NIF, ya que será diferente para cada una de las personas que forman
el archivo.
Un registro físico, también llamado bloque, es diferente de los registros que vimos en esta
entrada (y que, para diferenciarlos, a veces se denominanregistros lógicos). El registro físico
es la cantidad de información que el sistema operativo puede enviar o recibir del soporte de
memoria secundaria en una operación de escritura o lectura. Esta cantidad depende del
hardware.
El registro físico puede ser mayor que el registro lógico, con lo cual, en una sola operación de
lectura o escritura, se podrían transferir varios registros lógicos. También puede ocurrir lo
contrario, es decir, que el registro físico sea de menor tamaño que el lógico, lo que haría que
para transferir un registro lógico fueran necesarias varias operaciones de lectura o escritura.
Se llama factor de bloqueo al número de registros lógicos contenidos en un registro físico.
Como ejemplo vamos a calcular el factor de bloqueo del archivo del epígrafe anterior.
Supongamos que el tamaño del registro físico es de 512 bytes (es decir, en una sola lectura
o escritura del dispositivo de almacenamiento se pueden transferir 512 bytes) y el registro
lógico ocupa 128 bytes, calculados de esta manera1.
Por ejemplo, si el registro lógico ocupase 126 bytes en lugar de 128, en cada registro físico
cabrían 4 registros lógicos pero sobrarían 8 bytes. Esto tiene una gran importancia desde el
punto de vista del rendimiento, ya que cada acceso a la memoria secundaria requiere
bastante tiempo y, por tanto, éstos deben reducirse al máximo.
En todo este curso, cuando hablemos de “registro” a secas, nos estaremos refiriendo
al registro lógico, no al físico.
Pues bien, dependiendo de la longitud de los campos que forman cada registro podemos
clasificar éstos en:
Son los que ocupan siempre el mismo espacio a lo largo de todo el archivo (en el ejemplo
anterior, 128 bytes). Dentro de estos registros, podemos encontrar varias posibilidades:
Igual número de campos por registro e igual longitud de todos los campos
Igual número de campos por registro y distinta longitud de cada campo, aunque igual en
todos los registros
Igual número de campos por registro y distinta longitud de cada campo, pudiendo ser
diferente en cada registro
Distinto número de campos por registro y distinta longitud de cada campo en cada
registro
B) Registros de longitud variable
Aunque es menos habitual, pudiera ocurrir que cada registro del archivo tuviera una longitud
propia y diferente del resto. En este tipo de archivos es necesario programar algún
mecanismo para averiguar cuál es el principio y el final de cada registro.
Además de manipular cada componente del archivo (registros y campos), también se pueden
llevar a cabo operaciones con la totalidad del archivo, como:
Ficheros secuenciales
16 mayo 2008 in ...en lenguaje C, ...para estudiantes, ...para principiantes,Programación
(Este artículo forma parte del Curso de Programación en C)
A lo largo de este artículo y los siguientes (véase el índice del Curso de Programación en C),
estudiaremos los tipos de organización. Un poco más adelante, nos detendremos en las
funciones de C para acceder a archivos y, por último, nos centraremos en la implementación
de los distintos tipos de acceso a archivos que se pueden realizar desde C.
Cuando un archivo secuencial se abre para escribir datos en él, el indicador de posición se
sitúa justo después del último byte del mismo, de manera que los datos sólo se pueden
añadir al final.
1. Para consultar datos individuales, hay que recorrer todo el archivo desde el principio. Es
decir, el acceso a registros individuales es, en general, lento.
2. Las operaciones de inserción y eliminación de registros solo pueden hacerse al final del
archivo. Hacerlas con registros intermedios representa mover grandes bloques de
información y, por lo tanto, consumir mucho tiempo.
La única solución es utilizar un campo clave de entre todos los del registro. Ese campo clave,
que suele ser numérico, permite averiguar la dirección física donde está almacenado el
registro en la memoria secundaria mediante un algoritmo de transformación. Por eso, la
clave suele denominarse dirección de memoria lógica, para distinguirlo de la dirección de
memoria física donde efectivamente se encuentra guardado el registro.
Los archivos relativos son más versátiles que los secuenciales porque permiten acceder a
cualquier parte del fichero en cualquier momento, como si fueran arrays. Las operaciones de
lectura y escritura pueden hacerse en cualquier punto del archivo.
Los archivos con organización relativa tienen dos variantes: los archivosdirectos y los
archivos aleatorios (o indirectos). A lo largo de este artículo estudiaremos cada tipo por
separado. Pero antes veamos un…
EJEMPLO DE HASHING
+------+------+------+------+------+
+------+------+------+------+------+
Registro almacenado | R1 | R2 | R3 | R4 | R5 |
en esa posición | | | | | |
+------+------+------+------+------+
+-----+--+----+--+----+--+----+--+----+
+-----+--+----+--+----+--+----+--+----+
Registro almacenado | R1 | | R2 | | R3 | | R4 | | R5 |
en esa posición | | | | | | | | | |
+-----+--+----+--+----+--+----+--+----+
R1.clave = 500
R2.clave = 600
R3.clave = 2860
R4.clave = 3152
R5.clave = 3159
Entonces, la función hash aplicada a este archivo para averiguar la dirección de cada registro
ha sido
f(clave) = clave x 2
Probemos a aplicar la función hash al primer registro (R1):
Si probamos con otros registros, esta función hash también nos devuelve la dirección. Por
ejemplo, con R3:
Entre los archivos con organización relativa los más sencillos son los directos.
En ellos, el campo clave de cada registro debe ser de tipo numérico, e identifica
directamente el registro físico donde está almacenado. La función hash, en este caso, es la
más simple posible, ya que no transforma la clave:
f(clave) = clave
En el ejemplo anterior, el registro R1 se almacenaría en la dirección 500, el R2 en la 600, el
R3 en la 2860, etc, ya que:
En estos archivos no puede haber dos registros con la misma clave, porque ambos ocuparían
la misma posición física, solapándose. Esto es lo que se llama una colisión y debe ser
evitada.
1. El acceso secuencial, del principio al fin del fichero, puede ser muy lento porque
podemos encontrarnos con muchos huecos, es decir, posiciones que no están siendo
usadas. Existen técnicas de programación avanzadas para el acceso secuencial eficiente
a ficheros directos.
2. Relacionado con la anterior, pueden quedar muchos huecos libres en el dispositivo de
memoria secundaria, desaprovechándose el espacio.
ARCHIVOS DE ORGANIZACIÓN RELATIVA ALEATORIA (O INDIRECTA)
Se denominan así a los archivos relativos que empleen alguna función hash para transformar
la clave y conseguir así la dirección física.
La función hash puede ser muy sencilla, como la del ejemplo que vimos en el apartado 2.2
(que consistía en multiplicar la clave por 2 para obtener la dirección física) o más
complicada, pero el principio es el mismo: transformar la clave para obtener la dirección
física.
Dependiendo de la función hash empleada pueden surgir colisiones, es decir, claves que
proporcionan la misma dirección física.
Por ejemplo, si la función hash es f(clave) = clave / 2 (división entera), tendremos que los
registros con clave 500 y 501 intentarán ocupar la misma dirección física: la 250. Es
responsabilidad del programador evitar estas colisiones y, en caso de que lleguen a
producirse, detectarlas y programar algún mecanismo que las resuelva.
Otras funciones hash, como la ya vista f(clave) = clave x 2, no producen colisiones, pero en
cambio provocan que muchas direcciones físicas no sean utilizadas, con lo que se
desaprovecha el espacio de almacenamiento.
Por lo tanto, la elección de una función hash adecuada es crucial para el correcto rendimiento
y funcionamiento de este tipo de archivos. Existen multitud de funciones hash adaptadas a
los más diversos problemas que ofrecen un máximo aprovechamiento del espacio y un
mínimo número de colisiones, pero su estudio excede los propósitos de este blog… ¡al menos
por ahora!
Las ventajas de los archivos aleatorios son similares a las de los directos, y entre los
inconvenientes podemos quitar el de dejar muchos huecos libres, siempre que, como hemos
visto, la función hash elegida sea adecuada.
Ficheros indexados
17 mayo 2008 in ...en lenguaje C, ...para estudiantes, Programación
(Este artículo forma parte del Curso de Programación en C)
Los archivos con organización indexada tienen una mezcla entre las
organizaciones secuencial y relativa, que ya hemos visto en otros artículos. Se pretende
aprovechar las ventajas de las dos organizaciones, evitando al mismo tiempo sus
inconvenientes.
1) El área primaria. En esta área se encuentran almacenados los registros del archivo
secuencial. Es decir, el área primaria es, en realidad, un archivo secuencial corriente . Los
registros deben estar ordenados (normalmente, se hará en orden creciente según sus
claves). El área primaria suele estar segmentada, es decir, dividida en trozos o segmentos.
En cada segmento se almacenan N registros en posiciones de memoria consecutivas. Para
acceder a un registro individual, primero hay que acceder a su segmento y, una vez
localizado el segmento, buscar secuencialmente el registro concreto.
2) El área de índices. Se trata, en realidad, de un segundo archivo secuencial agregado al
primero. Pero es un archivo especial, cuyos registros solo tienen dos campos: uno contiene
la clave del último registro de cada segmento, y otro contiene la dirección física de comienzo
de cada segmento.
3) El área de excedentes. Puede ocurrir que los segmentos del área primaria se llenen y
no puedan contener algún registro. Esos registros van a parar a un área de excedentes u
overflow.
Imaginemos que cada segmento tiene 4 registros. Por lo tanto, el archivo se dividirá en 3
segmentos. Si suponemos que cada registro ocupa 50 bytes en memoria secundaria, y que
el principio del archivo está en la dirección 100 de dicha memoria, el archivo físico tendrá
este aspecto:
Área primaria:
física
100 1111 Arturo Pérez 348734
Área de índices:
de comienzo registro
1 100 2503
2 300 6705
3 500 9000
Observe primero el área primaria: los registros están dispuestos en orden creciente según la
clave (que, en este caso, es el campo NIF). A la izquierda aparece la dirección física donde
comienza cada registro. Fíjate también en que los registros están agrupados en tres
segmentos.
Luego fíjese en el área de índices: contienen una lista de segmentos, guardando la dirección
de comienzo del segmento y la clave del último registro de ese segmento.
Para acceder, por ejemplo, al registro cuya clave es 5362, el proceso es el siguiente:
En principio, quédese con esta idea: el lenguaje C sólo puede manejar archivos secuenciales
y directos. La mayoría de sus funciones sirven para ambos tipos de organización,
comportándose de forma ligeramente distinta con una y con otra. Y, luego, existen algunas
funciones exclusivamente para archivos secuenciales, y otras para archivos directos, como
iremos viendo. Por último, combinando adecuadamente los accesos directos con los
secuenciales, se puede lograr en C un acceso indexado, aunque es tarea del programador
manejar los índices y todas las complicaciones de este método de organización.
Además de los tipos de archivos que ya hemos visto (según su organización: secuenciales y
relativos con todas sus variedades), en C podemos hacer otras dos clasificaciones de los
archivos:
De texto: sólo permiten guardar caracteres o, mejor dicho, su código ASCII. Para
guardar información numérica en un archivo de texto es necesario convertirla a
caracteres.
Binarios: guardan información binaria de cualquier tipo..
Cuando conozcamos el manejo que de los archivos se puede hacer con C, discutiremos con
más detenimiento las diferencias entre archivos binarios y de texto.
Flujos en C
17 mayo 2008 in ...en lenguaje C, ...para estudiantes, Programación
(Este artículo forma parte del Curso de Programación en C)
Un flujo (o stream en inglés) es una corriente de datos que fluyen desde un origen,
llamado productor, y son recibidos por un destinatario, llamadoconsumidor. Entre el origen y
el destino debe existir una conexión de tal naturaleza que permita la transmisión de datos.
En C, para recibir datos desde cualquier dispositivo de entrada (productor) o para enviar
datos a cualquier dispositivo de salida (consumidor), es necesario establecer un canal que
permita recibir y enviar esos datos. Este canal es lo que llamamos flujo.
En todos los programas escritos en C existen tres flujos o canales abiertos automáticamente:
Archivos y buffers en C
17 mayo 2008 in ...en lenguaje C, ...para estudiantes, Programación
(Este artículo forma parte del Curso de Programación en C)
Para acceder a los archivos, por tanto, es necesario crear flujos nuevos a parte
de stdin y stdout. Crear un flujo con un archivo se denomina comúnmente “abrir el archivo”.
Cuando ya no va a ser necesario escribir ni leer más datos del archivo, el flujo debe
destruirse en la operación denominada“cerrar el archivo”.
El acceso a los archivos se hace a través de un buffer. Se puede pensar en un buffer como si
fuera un array donde se van almacenando los datos dirigidos al archivo, o los datos que el
archivo envía hacia el programa. Esos datos se van colocando en el buffer hasta que éste se
llena, y sólo entonces pasan efectivamente a su destinatario. También es posible forzar
el vaciado del bufferantes de que se llene invocando a determinadas funciones que luego
veremos.
Desde un punto de vista práctico, lo que hay que recordar es lo siguiente: cuando se envían
datos a través de un flujo, éstos no se escriben inmediatamente en el archivo, sino que se
van acumulando en el buffer, y sólo cuando el buffer está lleno los datos se graban
realmente en el archivo. En ese momento el buffer queda vacío y listo para seguir recibiendo
datos. Al cerrar el archivo, se terminan de escribir los últimos datos que pudieran quedar en
el buffer.
ABRIR ARCHIVOS
Para usar un archivo desde un programa en C, tanto secuencial como directo, lo primero que
hay que hacer es abrirlo. Esto crea un flujo que conecta nuestro programa con el archivo.
La función fopen() devuelve un puntero a archivo. El tipo FILE está definido enstdio.h, por lo
que se puede utilizar en cualquier programa que incluya dicha cabecera. El puntero devuelto
por fopen() será fundamental para escribir y leer datos del archivo más adelante. Si fopen(),
por la razón que sea, no puede abrir el archivo, devolverá un puntero a NULL.
Modo “r”: Abre el archivo existente para lectura en modo secuencial. El archivo debe
existir previamente.
Modo “w”: Crea un archivo nuevo para escritura en modo secuencial. ¡Cuidado! Si el
archivo ya existe, se borrará y se creará uno nuevo.
Modo “a”: Abre un archivo existente para escritura en modo secuencial, añadiendo los
datos al final de los que haya. Si el archivo no existe, se crea.
Modo “r+”: Abre el archivo para lectura/escritura en modo directo. El archivo debe
existir previamente. Se puede leer y escribir en cualquier posición del archivo.
Modo “w+”: Crea un archivo para lectura/escritura en modo directo. Si el archivo ya
existe, se elimina y se crea de nuevo. Se puede leer y escribir en cualquier posición del
archivo.
Modo “a+”: Abre un archivo existente para lectura/escritura en modo directo. Si el
archivo no existe, lo crea. La escritura sólo se podrá realizar al final del archivo (modo
“añadir”), aunque se puede leer en cualquier posición.
A todos estos modos se les puede añadir la letra “b” si el archivo es binario, o“t” si es de
texto. Por ejemplo: “rb”, “w+t”, “a+b”, etc. Si no se añade “b” ni “t”, se supone que el
archivo es de texto. Los archivos de texto deben usarse para almacenar texto ASCII. Los
archivos binarios suelen utilizarse para guardar información más compleja, aunque también
pueden guardar texto. De esto hablamos más detenidamente en este otro artículo.
Por ejemplo:
FILE* archivo;
El archivo “datos.txt” es de texto y se abre para lectura. No se podrán escribir datos en él,
sólo leerlos. La variable puntero archivo será imprescindible para actuar más adelante sobre
el archivo. Fíjese en cómo se comprueba si el archivo ha sido abierto comparando el puntero
con NULL.
CERRAR ARCHIVOS
Cuando un archivo no va a usarse más, su flujo debe ser cerrado para liberar memoria.
Aunque teóricamente todos los archivos abiertos por un programa se cierran
automáticamente al finalizar dicho programa, el programador, por precaución, debe ocuparse
de hacerlo dentro del código.
Por ejemplo:
FILE *archivo;
fclose(archivo);
Una anotación al margen: fclose() devuelve un número entero. Este número se puede utilizar
para averiguar si ha ocurrido un error al cerrar el archivo, ya que tomará el valor EOF si ha
sido así (EOF es otra constante definida en stdio.h)
Por lo demás, y teniendo siempre presentes estas diferencias, las funciones de lectura y
escritura son las mismas y se comportan de modo similar con los archivos directos y con los
secuenciales. Todo lo que sigue es aplicable, además, tanto a archivos binarios como de
texto, aunque luego veremos que algunas funciones se usan más con un tipo de archivos y
otras con el otro tipo.
Para escribir un carácter en un archivo de texto se pueden utilizar las funciones putc() o
fputc(), que son idénticas:
Observe que putc() recibe un entero, no un carácter. Esto obedece a razones históricas,
pero, en realidad, putc() sólo se fija en los 8 bits de menos peso del entero, por lo que, a
todos los efectos, es como si fuera un carácter.
Por ejemplo, de este modo se escribiría el carácter “s” al final del archivo “datos.txt”:
FILE* archivo;
Para leer un carácter de un archivo de texto se utilizan las funciones getc() o fgetc(), que
también son iguales y tienen esta forma:
Observa que fgetc() devuelve un entero, no un carácter, por las mismas razones que putc();
si lo asignamos a una variable de tipo carácter el resultado será correcto.
LEER Y ESCRIBIR CADENAS DE CARACTERES: FGETS() Y FPUTS()
Para escribir en un archivo de texto una cadena de caracteres completa se utiliza la función
fputs(), que es igual que fputc() pero con cadenas:
Del mismo modo, existe una función fgets() para leer cadenas de caracteres de un archivo
de texto. En este caso, hay que indicar cuántos caracteres se quieren leer. La función irá
leyendo caracteres del archivo hasta que encuentre un fin de línea o hasta que haya leído
longitud – 1 caracteres. Comenzará leyendo desde el primer carácter del archivo y a partir
de ahí irá avanzando secuencialmente:
Por ejemplo, este fragmento de código escribe los caracteres “15 más 5 son 20″ en el
archivo “datos.txt”:
FILE* archivo;
int a, b;
a = 15;
b = 5;
if (archivo != NULL)
También existe una hermana gemela de scanf(); se llama fscanf() y lee los datos de un
archivo, en lugar de hacerlo del flujo stdin (es decir, del teclado). Su prototipo es:
Esta función escribe en el archivo especificado un número (num) de elementos del tamaño
indicado en bytes (tam_bytes). Los elementos se cogen de la memoria principal, a partir de
la dirección apuntada por puntero_a_memoria.
Por ejemplo, la siguiente instrucción escribe en el archivo apuntado por el flujo fich 16
números de tipo float. Los números se leen de la memoria a partir de la dirección apuntada
por ptr. Observe el uso que se hace de sizeof() para saber cuánto ocupa cada elemento (en
este caso, cada número de tipo float):
La función complementaria de fwrite() es fread(), que sirve para leer un bloque de datos de
un archivo y colocarlo en la memoria, a partir de determinada dirección apuntada por un
puntero. El prototipo es:
En este caso, se leen num elementos de tamaño tam_bytes del archivo. Todos los bytes se
colocan en la memoria principal, en las direcciones situadas a partir de puntero_a_memoria.
La función fread() devuelve el número de bytes leídos correctamente.
Estas dos funciones (fread() y fwrite()) suelen utilizarse con archivos binarios, mientras que
el resto de funciones de lectura y escritura (fgets(), fgetc(), fscanf(), etc) es más frecuente
usarlas con archivos de texto.
Esto no funciona con archivos binarios, porque el valor de EOF puede ser confundido con una
parte del último registro del archivo.
Para evitar este problema existe la función feof(), que nos dice si hemos alcanzado el final de
un archivo, tanto de texto como binario. Devuelve un 0 (falso) si aún no se ha llegado al
final, y otro valor cuando se ha alcanzado. Es muy útil para saber si podemos seguir leyendo
caracteres o ya los hemos leído todos. Su prototipo es:
Otra función del ANSI C muy útil es rewind(). Sirve para situar el indicador de posición al
comienzo del archivo; es como si hubiéramos abierto el archivo en ese momento. Su
prototipo es:
Como ya comentamos, la salida de datos hacia archivos suele hacerse a través de un buffer
por motivos de rendimiento. Así, cuando vamos escribiendo datos en un archivo, éstos
pueden no escribirse inmediatamente en el dispositivo de almacenamiento, sino que
pemanecen durante un tiempo en un espacio intermedio de memoria llamado buffer, donde
se van acumulando. Sólo cuando el buffer está lleno se realiza físicamente la operación de
escritura.
Podemos forzar un vaciado del buffer con la función fflush(), que tiene este prototipo:
Al llamar a fflush(), todos los datos que estuvieran pendientes de ser escritos en el
dispositivo de memoria secundaria se escribirán, y el buffer quedará vacío.
Aunque, como dijimos, el lenguaje C maneja con las mismas funciones los archivos
secuenciales y los directos, dispone de algunas funciones exclusivas para archivos de
organización relativa directa. Estas funciones, que, por lo tanto, no tienen sentido con los
archivos secuenciales, son fseek() y ftell().
La función fseek() sirve para situarnos directamente en cualquier posición del fichero, de
manera que el resto de lecturas se hagan a partir de esa posición. Su prototipo es:
El argumento origen debe ser una de estas tres constantes definidas en stdio.h:
Y con esta otra nos desplazamos 2 bytes más allá de la posición actual:
fseek(archivo, 2, SEEK_CUR);
Esta función devuelve 0 si se ejecuta correctamente o cualquier otro valor si ocurre algún
error.
La función ftell(), por su parte, devuelve el indicador de posición del archivo, es decir,
cuántos bytes hay desde el principio del archivo hasta el lugar donde estamos situados en
ese momento. Su prototipo es:
fseek(archivo, 0, SEEK_END);
tam_archivo = ftell(archivo);
Es decir, primero nos colocamos al final del archivo con fseek(), y luego preguntamos a
ftell() el byte en el que nos hemos situado (contando desde el comienzo del archivo). Como
resultado, ftell() nos proporcionará en tamaño (en bytes) del archivo.
Como hemos dicho anteriormente, en los archivos de texto todos los datos se almacenan en
forma de texto ASCII. Esto hace que podamos abrirlos, consultarlos y modificarlos con
cualquier editor de texto, mientras que con los binarios no es posible.
En los archivos de texto, y dependiendo del compilador y del sistema operativo empleado,
pueden producirse ciertas transformaciones automáticas de caracteres. En particular, es
frecuente que el carácter invisible de fin de línea (representado habitualmente como LF) se
convierta en dos caracteres al escribirlo en un archivo (fin de línea – LF – más retorno de
carro – CR – ). También pueden ocurrir conversiones a la inversa, es decir, durante la lectura
del archivo. Esto no sucede con los archivos binarios.
Todas las funciones de E/S sirven para ambos tipos de archivo, pero algunas pueden dar
problemas con según qué tipos. Por ejemplo, fseek() no funciona bien con archivos de texto
debido a las conversiones automáticas que antes mencionábamos. Desde cierto punto de
vista, puede considerarse que un archivo de texto no es más que un archivo secuencial en el
que cada registro es un carácter, por lo que tiene sentido que las funciones de acceso directo
tengan problemas para tratar este tipo de archivos.
Como normas generales (que nos podemos saltar si la situación lo requiere, ya que C es lo
bastante flexible como para permitirlo) propondremos las siguientes:
Cuando se trate de manipular datos simples, usaremos archivos de texto. Esto implica
convertir los números a texto ASCII (lo cual es muy fácil de hacer usando fprintf() y
fscanf() junto con las cadenas de formato). Sólo en el caso de que estas conversiones
representen un inconveniente grave recurriremos a los archivos binarios.
Cuando tratemos con estructuras de datos más complejas, es mejor usar archivos
binarios, a menos que nos interese abrir esos archivos con un editor de texto, en cuyo
caso seguiremos usando archivos de texto.
Si necesitamos acceso directo, usaremos archivos binarios.
Las funciones fread() y fwrite() se usarán preferentemente con achivos binarios, y el
resto de funciones de lectura y escritura se reservarán para archivos de texto.
Manipulació n de archivos y directorios en C
18 mayo 2008 in ...en lenguaje C, ...para estudiantes, Programación
(Este artículo forma parte del Curso de Programación en C)
A este respecto hay que destacar que la barra invertida (“\”) que separa los directorios en
Windows no puede utilizarse directamente en una cadena, ya que en C la barra invertida se
usa para los caracteres especiales (por ejemplo, el retorno de carro se representa “\n”). Para
usar la barra invertida en una constante de cadena debemos usar la secuencia de escape
“\\”. Por ejemplo, para borrar el archivo C:\PRUEBAS\DATOS.TXT debemos escribir:
remove("C:\\PRUEBAS\\DATOS.TXT");
remove()
Borra un archivo del directorio. Devuelve 0 si el borrado se realiza con éxito, u otro valor en
caso de error. Si el archivo está abierto no podrá borrarse hasta que se cierre.
rename()
chdir()
La función devuelve 0 si el cambio se produce con éxito u otro valor en caso contrario
mkdir()
Crea un directorio dentro del directorio activo. Devuelve 0 si la operación tiene éxito.
rmdir()
Borra un directorio. Para que el borrado tenga éxito, el directorio debe de estar vacío.
Devuelve 0 si el borrado se completa correctamente.
Además, existen otras funciones para leer el contenido de un directorio (es decir, la lista de
archivos que lo componen) y procesar dicho contenido. Dichas funciones escapan a la
extensión de este artículo, pero el lector interesado puede buscar información sobre ellas:
son opendir(), closedir(), readdir(), etc.
Implementando archivos secuenciales en C
23 mayo 2008 in ...en lenguaje C, ...para estudiantes, Programación
(Este artículo forma parte del Curso de Programación en C)
Una vez hecho esto, usaremos sucesivas instrucciones de escritura para insertar los registros
(si el archivo es binario) o los caracteres (si es de texto). Ten en cuenta que los datos se
grabarán en el archivo exactamente en el mismo orden en el que los escribas.
Las funciones de escritura que se deben usar dependen de la naturaleza del problema y de
las preferencias del programador, pero recuerde que, en general, fwrite() suele reservarse
para archivos binarios y el resto (fputc(), fprintf(), fputs()…) para archivos de texto.
FILE *fich;
int i, N;
if (fich == NULL)
else
N = random(1000)+1;
fclose(fich);
}
LECTURA DE DATOS DE UN ARCHIVO SECUENCIAL
Al abrir un archivo secuencial para lectura (en modo “r”), el indicador de posición se sitúa en
el primer byte del archivo. Cada vez que se lea un dato, el indicador de posición se desplaza
automáticamente tantos bytes adelante como se hayan leído. Las lecturas se pueden
continuar haciendo hasta que se alcance el final del archivo.
Fíjate también en cómo se usa la función feof() para comprobar si se ha alcanzado el final
del archivo.
int N;
FILE *fich;
if (fich == NULL)
else
fclose(fich);
FILE *fich;
int encontrado;
if (fich == NULL)
else
scanf("%i", &n_busq);
linea = 0;
encontrado = 0;
while (!feof(fich))
linea++;
encontrado = 1;
break;
if (encontrado == 0)
fclose(fich);
int N, linea;
else
linea = 0;
while (!feof(f_orig))
linea++;
fclose(f_orig);
fclose(f_nuevo);
remove("ejemplo.txt");
rename("temporal", "ejemplo.txt");
}
MODIFICACIÓN DE DATOS EN UN ARCHIVO SECUENCIAL
En los archivos secuenciales sólo puede escribirse al final del archivo. Por lo tanto, para
modificar un registro hay que actuar de forma similar al primer método de borrado: creando
un segundo archivo en el que se copiarán todos los registros exactamente igual que en el
archivo original, excepto el que se pretende cambiar.
Hasta ahora todos los ejemplos han tratado con archivos de texto muy simples, en los que
sólo había un número entero en cada línea.
Estas técnicas pueden extenderse a los archivos cuyos registros sean más complejos: sólo
hay que modificar la función de lectura o escritura para adaptarla al formato de los datos del
archivo.
struct s_alumno {
int matricula;
char nombre[30];
int edad;
};
Cada registro del archivo se corresponderá exactamente con una estructura. Así, para añadir
un alumno al archivo podemos usar el siguiente algoritmo:
FILE *fich;
struct s_alumno a;
if ((fich == NULL))
else
fclose(fich);
}
Observe que el procedimiento es el mismo que en el caso de sencillos número enteros, salvo
que, al tratase de una estructura compleja, es preferible usar archivos binarios y la función
fwrite() en lugar de archivos de texto y la función fprintf(). Pero podría usarse perfectamente
fprintf() de este modo (entre otros):
Lógicamente, para hacer la lectura de este archivo será necesario usar fread() si se escribió
con fwrite(), o fscanf() si se escribió con fprintf().
Los procedimientos de lectura, búsqueda, borrado, etc también son fácilmente extensibles a
este tipo de archivos más complejos.
El siguiente programa trata de ilustrar cómo se utilizan los archivos de textocon C. Se trata
de un programa que se divide en dos funciones. Por un lado, escribir_archivo() sirve para
escribir un texto en la pantalla y volcarlo a un archivo llamado “prueba.txt”. Todo lo que se
va tecleando va apareciendo en la pantalla y, al mismo tiempo, se va enviando, carácter a
carácter, al archivo de disco, hasta que se introduce el carácter “#”. Por otro lado,
leer_archivo() hace lo contrario: lee todo lo que haya grabado en “prueba.txt” y lo muestra
por la pantalla.
Fíjese en cómo se usa feof() para saber cuándo se ha llegado al final del archivo. Además,
observa que se han preferido las funciones fgetc() y fputc() en lugar de fscanf() y fprintf(),
por ser más adecuadas a la naturaleza de este problema.
#include <stdio.h>
int main(void)
int opción;
puts("Teclee 1 ó 2: ");
scanf("%i", opcion);
if (opcion == 1) escribir_archivo();
if (opcion == 2) leer_archivo();
return 0;
void escribir_archivo()
FILE* f;
char car;
f = fopen("prueba.txt", "w");
if (f == NULL)
do
fclose(f);
void leer_archivo()
FILE* f;
char car;
f = fopen("prueba.txt", "r");
if (f == NULL)
else
do
fclose(f);
}
EJEMPLO: ARCHIVOS SECUENCIALES BINARIOS
El siguiente ejemplo utiliza archivos binarios para escribir o leer un array de 30 estructuras.
En el programa principal se pregunta al usuario qué desea hacer y dependiendo de su
respuesta se invoca a una de estos dos funciones:
leer_archivo()
Abre el archivo “alumnos.dat” para lectura y recupera los datos que haya en él,
mostrándolos en la pantalla. Observe que es un archivo binario y fíjese sobre todo en el uso
de fread():
fread(&alumno[i],sizeof(struct s_alumno),1,archivo);
Observe que esa instrucción se repite NUM_ALUMNOS veces, ya que esa es la cantidad de
elementos que tiene el array. Podríamos haber sustituido todo el bucle por una sola
instrucción de escritura como esta:
fread(alumno,sizeof(struct s_alumno),NUM_ALUMNOS,archivo);
Aquí sólo pasamos la dirección del primer elemento del array y luego le decimos que escriba
NUM_ALUMNOS elementos en lugar de sólo 1.
escribir_archivo()
Primero se le pide al usuario que introduzca los datos por teclado y luego se guardan todos
esos datos en “alumnos.dat”. Observa el uso de la función fwrite(), que es similar al que
antes hacíamos de fread().
#include <stdio.h>
#define NUM_ALUMNOS 30
struct s_alumno {
int matricula;
char nombre[30];
int edad;
};
void escribir_archivo();
int main()
int opcion;
puts("Teclee 1 ó 2: ");
scanf("%i", &opcion);
if (opcion == 1) escribir_archivo();
if (opcion == 2) leer_archivo();
return 0;
void leer_archivo()
int i;
FILE *archivo;
archivo = fopen("alumnos.dat","rb");
else
fread(&alumno[i],sizeof(struct s_alumno),1,archivo);
fclose(archivo);
void escribir_archivo()
{
int i;
FILE *archivo;
scanf("%d",&alumno[i].matricula);
gets(alumno[i].nombre);
scanf("%d",&alumno[i].edad);
archivo = fopen("alumnos.dat","ab+");
else
fwrite(&alumno[i],sizeof(struct s_alumno),1,archivo);
fclose(archivo);
5. RECURSIVIDAD
Planteamiento de soluciones recursivas
Normas para aplicar correctamente la recursividad
Ventajas e inconvenientes de las soluciones recursivas
Divide y vencerás
Backtracking
¿Pero qué demonios es eso de los punteros? Conceptos
bá sicos en C
19 mayo 2008 in ...en lenguaje C, ...para estudiantes, Programación
(Este artículo forma parte del Curso de Programación en C)
Dentro de la memoria del ordenador, cada dato almacenado ocupa una o más celdas
contiguas de memoria. El número de celdas de memoria requeridas para almacenar un dato
depende de su tipo. Por ejemplo, un dato de tipo entero puede ocupar 16 bits (es decir, 2
bytes) o 32 bits (4 bytes), mientras que un dato de tipo carácter ocupa 8 bits (es decir, 1
byte).
Pues bien, un puntero no es más que una variable cuyo contenido no es un dato, sino la
dirección de memoria donde está almacenado un dato.
Veámoslo a través de un ejemplo. Imaginemos que v es una variable de tipo carácter y que,
por tanto, necesita 1 byte para ser almacenada. La declaración e inicialización de la variable
será como la siguiente:
char v;
v = 'A';
Al ejecutar este código, el sistema operativo asigna automáticamente una celda de memoria
para el dato. Supongamos que la celda asignada tiene la dirección 1200. Al hacer la
asignación v = ‘A’, el sistema almacena en la celda 1200 el valor 65, que es el código ASCII
de la letra ‘A’:
----+----+----+----+----+----+----
----+----+----+----+----+----+----
Contenido | | | 65 | | |
----+----+----+----+----+----+----
Cuando usamos la variable v a lo largo del programa, el sistema consulta el dato contenido
en la celda de memoria asignada a la variable. Esa celda será siempre la misma a lo largo de
la ejecución: la 1200. Por ejemplo, al encontrar esta instrucción:
printf("%c", v);
.. la CPU acude a la celda 1200 de la memoria, consulta el dato almacenado en ella en ese
momento lo muestra en la pantalla con formato de carácter. Así vemos en la pantalla una
letra A.
El programador no tiene modo de saber en qué posición de memoria se almacena cada dato,
a menos que utilice punteros. Los punteros sirven, entonces, para conocer la dirección de
memoria donde se almacena el dato, y no el dato en sí.
OPERADORES PARA PUNTEROS: & Y *
La dirección ocupada por una variable v se determina escribiendo &v. Por lo tanto, & es un
operador unario, llamado operador dirección, que proporciona la dirección de una variable.
char* p;
p = &v;
Resultará que esta nueva variable es un puntero a v, es decir, una variable cuyo contenido
es la dirección de memoria ocupada por la variable v. Representa la dirección de v y no su
valor. Por lo tanto, el contenido de p será 1200, mientras que el contenido de v será 65.
El dato almacenado en la celda apuntada por la variable puntero puede ser accedido
mediante el operador asterisco aplicado al puntero. Así pues, la expresión *p devuelve el
valor 65, que es el contenido de la celda apuntada por p. El operador * es un operador
unario, llamado operador indirección, que opera sólo sobre una variable puntero.
Resumiendo: podemos tener variables “normales” y utilizar el operador & para conocer su
dirección de memoria. O podemos tener variables puntero, que ya son en sí mismas
direcciones de memoria, y utilizar el operador * para acceder al dato que contienen. Así
pues:
El operador dirección (&) sólo puede actuar sobre variables que no sean punteros. En
el ejemplo anterior, la variable v vale 65 y la expresión &v vale 1200.
El operador indirección (*) sólo puede actuar sobre variables que sean punteros. En el
ejemplo anterior, la expresión *p vale 65 y la variable p vale 1200.
Las variables puntero pueden apuntar a direcciones donde se almacene cualquier tipo de
dato: enteros, flotantes, caracteres, cadenas, arrays, estructuras, etc. Esto es
tremendamente útil y proporciona una enorme potencia al lenguaje C, pero también es una
fuente inagotable de errores de programación difíciles de detectar y corregir, como iremos
viendo en los siguientes temas
Las variables de tipo puntero, como cualquier otra variable, deben ser declaradas antes de
ser usadas.
Cuando una variable puntero es definida, el nombre de la variable debe ir precedido por un
*. El tipo de dato que aparece en la declaración se refiere al tipo de dato que se almacena en
la dirección representada por el puntero, en vez del puntero mismo. Así, una declaración de
puntero general es:
tipo_dato *puntero;
donde puntero es la variable puntero y tipo_dato el tipo de dato apuntado por el puntero. Por
ejemplo:
int *numero;
char *letra;
int *numero;
int a;
numero = &a;
El puntero numero ahora sí está en condiciones de ser usado, porque está apuntado a la
dirección de la variable a, que es de tipo int, como el puntero.
Otra posibilidad es hacer que un puntero apunte a NULL. El identificador NULL es una
constante definida en el lenguaje que indica que un puntero no está apuntando a ninguna
dirección válida y que, por lo tanto, no se debe utilizar.
Se puede asignar una variable puntero a otra siempre que ambas apunten al mismo tipo de
dato. Al realizar la asignación, ambos punteros quedarán apuntando a la misma dirección de
memoria.
Observe este ejemplo y juegue un rato a tratar de determinar qué resultado se obtiene en la
pantalla (antes de leer la solución que aparece más abajo; si no, no tiene gracia):
int a, b, c;
a = 5;
b = *p1;
Aritmética de punteros
19 mayo 2008 in ...en lenguaje C, ...para estudiantes, Programación
(Este artículo forma parte del Curso de Programación en C)
Con las variables de tipo puntero sólo se pueden hacer dos operaciones aritméticas: sumar o
restar a un puntero un número entero, y restar dos punteros. Pero el resultado de esas
operaciones no es tan trivial como puede parecer. Por ejemplo, si sumamos un 1 a un
puntero cuyo valor sea 1200, el resultado puede ser 1201… ¡pero también puede ser 1202 ó
1204! Esto se debe a que el resultado depende del tipo de dato al que apunte el puntero.
dirección_original + N * tamaño_del_tipo_de_dato
char* p;
p = p + 5;
Supongamos que p apunta a la dirección de memoria 800. Como cada carácter ocupa 1 byte,
al incrementarlo en 5 unidades, p apuntará a la dirección 805.
Veamos ahora que pasa si, por ejemplo, el puntero p apunta a un número entero:
int* p;
p = p + 5;
Todo esto también explica qué ocurre cuando se resta un número entero de un puntero, sólo
que entonces las direcciones se decrementan en lugar de incrementarse.
A los punteros también se les puede aplicar las operaciones de incremento (++) y
decremento (–) de C, debiendo tener el programador en cuenta que, según lo dicho hasta
ahora, la dirección apuntada por el puntero se incrementará o decrementará más o menos
dependiendo del tipo de dato apuntado.
Por ejemplo, si los datos de tipo int ocupan 2 bytes y el puntero p apunta a la dirección 800,
tras la ejecución de este fragmento de código, el puntero p quedará apuntado a la dirección
802:
int *p;
p++;
RESTA DE DOS PUNTEROS
La resta de punteros se usa para saber cuantos elementos del tipo de dato apuntado caben
entre dos direcciones diferentes.
Por ejemplo, si tenemos un vector de números reales llamado serie podemos hacer algo así:
float serie[15];
int d;
p1 = &tabla[4];
p2 = &tabla[12];
d = p1 – p2;
Punteros y arrays en C
19 mayo 2008 in ...en lenguaje C, ...para estudiantes, ...para programadores,Programación
(Este artículo forma parte del Curso de Programación en C)
PUNTEROS Y VECTORES
Los punteros y los arrays tienen una relación muy estrecha en C, ya que el nombre de un
array es en realidad un puntero al primer elemento de ese array. Si x es un array
undimensional, la dirección del primer elemento puede ser expresada como &x[0] o
simplemente como x. La dirección del elemento i-ésimo se puede expresar como &x[i] o
como (x+i).
(En este caso, la expresión (x+i) no es una operación aritmética convencional, sino una
operación con punteros, de cuyas peculiaridades ya hemos hablado en un epígrafe anterior)
Si &x[i] y (x+i) representan la dirección del i-ésimo elemento de x, podemos decir que x[i] y
*(x+i) representan el contenido de esa dirección, es decir, el valor del i-ésimo elemento de
x. Observe que la forma x[i] es la que hemos estado utilizando hasta ahora para acceder a
los elementos de un vector.
Los arrays, por lo tanto, pueden utilizarse con índices o con punteros. Al programador suele
resultarle mucho más cómodo utilizar la forma x[i] para acceder al elemento i-ésimo de un
array. Sin embargo, hay que tener en cuenta que la forma *(x+i) es mucho más
eficiente que x[i], por lo que suele preferirse cuando la velocidad del ejecución es un factor
determinante.
Los paréntesis que rodean al puntero deben estar presentes para que la sintaxis sea
correcta.
int x[10][20];
Y también como:
int (*x)[20];
x[2][5];
*(*(x+2)+5);
Un array multidimensional puede ser expresado como un array de punteros en vez de como
un puntero a un grupo contiguo de arrays. En términos generales un array bidimensional
puede ser definido como un array unidimensional de punteros escribiendo:
tipo_dato *variable[expresión1];
tipo_dato variable[expresión1][expresión2];
Observe que el nombre del array precedido por un asterisco no está cerrado entre
paréntesis. Ese asterisco que precede al nombre de la variable establece que el array
contendrá punteros.
int *x[25];
Aquí x[0] apunta al primer elemento de la primera columna, x[1] al primer elemento de la
segunda columna, y así sucesivamente. Observe que el número de elementos dentro de cada
columna no está especificado explícitamente. Un elemento individual del array, tal com x[2]
[5] puede ser accedido escribiendo:
*(x[2]+5)
En esta expresión, x[2] es un puntero al primer elemento en la fila 2, de modo que (x[2]+5)
apunta al elemento 5 de la fila 2. El objeto de este puntero, *(x[2]+5), refiere, por tanto, a
x[2][5].
Los arrays de punteros ofrecen un método conveniente para almacenar cadenas. En esta
situación cada elemento del array es un puntero que indica dónde empieza cada cadena.
Punteros y funciones en C
19 mayo 2008 in ...en lenguaje C, ...para estudiantes, Programación
(Este artículo forma parte del Curso de Programación en C)
A menudo los punteros son pasados a las funciones como argumentos. Esto permite que
datos de la porción de programa desde el que se llama a la función sean accedidos por la
función, alterados dentro de ella y devueltos de forma alterada. Este uso de los punteros se
conoce como paso de parámetros por variable o referencia y lo hemos estado utilizando
hasta ahora sin saber muy bien lo que hacíamos.
Cuando los punteros son usados como argumento de una función, es necesario tener cuidado
con la declaración y uso de los parámetros dentro de la función. Los argumentos formales
que sean punteros deben ir precedidos por un asterisco. Observe detenidamente el siguiente
ejemplo:
#include <stdio.h>
int main(void)
int u, v;
u = 1;
v = 3;
funcion1(u,v);
funcion2(&u,&v);
u=0;
v=0;
*pu=0;
*pv=0;
La función de nombre funcion1 utiliza paso de parámetros por valor. Cuando es invocada, los
valores de las variables u y v del programa principal son copiados en los parámetros u y v de
la función. Al modificar estos parámetros dentro de la función, el valor de u y v en el
programa principal no cambia.
En cambio, funcion2 utiliza paso de parámetros por variable (también llamado paso de
parámetros por referencia o por dirección). Lo que se pasa a la función no es el valor de las
variables u y v, sino su dirección de memoria, es decir, un puntero a las celdas de memoria
donde u y v están almacenadas. Dentro de la función, se utiliza el operador asterisco para
acceder al contenido de pu y pv y, en consecuencia, se altera el contenido de las posiciones
de memoria apuntadas por pu y pv. El resultado es que las variables u y v del programa
principal quedan modificadas.
Recuerde que la función scanf() requiere que sus argumentos vayan precedidos por &,
mientras que printf() no lo necesitaba. Hasta ahora no podíamos comprender por qué, pero
ahora podemos dar una razón: scanf() necesita que sus argumentos vayan precedidos del
símbolo & porque necesita las direcciones de los datos que van a ser leídos, para poder
colocar en esas posiciones de memoria los datos introducidos por teclado. En cambio, printf()
no necesita las direcciones, sino únicamente los valores de los datos para poder mostrarlos
en la pantalla.
Al estudiar los arrays y las estructuras ya vimos en detalle cómo se deben pasar como
parámetros a las funciones. Recuerda que los arrays siempre se pasan por variable y no es
necesario usar el símbolo & en la llamada, ya que el propio nombre del array se refiere, en
realidad, a la dirección del primer elemento.
DEVOLUCIÓN DE PUNTEROS
Una función también puede devolver un puntero. Para hacer esto, la declaración de la
función debe indicar que devolverá un puntero. Esto se realiza precediendo el nombre de la
función con un asterisco. Por ejemplo:
double *funcion(argumentos);
Cuando esta función sea invocada, devolverá un puntero a un dato de tipo double, y por lo
tanto debe ser asignada a una variable de ese tipo. Por ejemplo, así:
double *pf;
pf = funcion(argumentos);
printf("%lf", *pf);
Punteros a funciones en C
19 mayo 2008 in ...en lenguaje C, ...para estudiantes, ...para programadores,Programación
(Este artículo forma parte del Curso de Programación en C)
Las funciones de C, como todo el código de todos los programas que se ejecutan en el
ordenador, también ocupan unas posiciones concretas de la memoria principal. Por lo tanto,
es posible disponer de un puntero a una función, es decir, de una variable que contenga la
dirección de memoria en la que comienza el código de una función.
Puede preguntarse usted, con toda razón, qué ventajas proporciona este método rebuscado
de invocar. funciones. Bien, hay algunas (si no, no se habrían inventado los punteros a
funciones). Una es la eficiencia: es más rápido invocar a una función a través de un puntero.
Otra es que, aunque no lo crea, hay determinados casos en los que es más fácil tener
punteros a funciones que llamadas convencionales. Podemos, por ejemplo, construir un array
cuyos elementos sean punteros a funciones, para poder invocar a cada función sencillamente
indexando el array.
tipo_de_dato **nombre_puntero;
Por ejemplo, el resultado del siguiente fragmento de código en C debe ser que se imprima el
número 15 en la pantalla:
int n;
int* p1;
int** p2;
**p2 = 15;
printf("%i", n);
Mediante este procedimiento pueden crearse indirecciones aún más “indirectas”: punteros
que apuntan a punteros que a su vez apuntan a punteros, y así hasta el infinito. Sin
embargo, la doble indirección suele ser el límite práctico que habitualmente se maneja.
Además, las indirecciones triples, cuádruples o más, pueden ser condenadamente difíciles de
comprender.
Aunque los que empiezan con el lenguaje C creen a menudo que los punteroshan sido
inventados para amargar la vida del estudiante, la verdad es que son tremendamente útiles.
Entre sus utilidades, las dos más notables son:
int vector1[100];
int* vector2;
El vector1 se define del modo convencional de un array. Esto produce la reserva de un
bloque fijo de memoria al empezar la ejecución del programa lo suficientemente grande
como para almacenar 100 números enteros.
El vector2 se define como puntero a entero. En este caso, no se reserva ninguna
cantidad de memoria para almacenar los números enteros.
Si intentamos acceder a los elementos de los vectores obtendremos resultados diferentes:
vector1[5] = 83;
La primera asignación funcionará correctamente, ya que el quinto elemento del vector1 tiene
un espacio de memoria asignado. La segunda asignación producirá un efecto impredecible,
ya que vector2 no tiene ningún espacio de memoria asignado y, por lo tanto, el dato 27 se
escribirá en una posición de memoria correspondiente a otro dato u otro programa. La
consecuencia puede llegar a ser bastante desagradable.
Se necesita, pues, reservar un fragmento de memoria antes de que los elementos del array
sean procesados. Tales tipos de reserva se realizan mediante la función malloc() o alguna de
sus variedades.
int *x;
Suponiendo que sizeof(int) fuera 2 (es decir, que cada número de tipo int ocupase 2 bytes),
lo que se le está pidiendo a malloc() es que reserve 100 * 2 bytes, es decir, 200 bytes de
memoria.
Además, es necesario usar el molde (int *), ya que malloc() devuelve un puntero sin tipo (es
decir, un puntero a void), así que hay que convertirlo a puntero a entero antes de asignarlo a
la variable x, que efectivamente es un puntero a entero.
De esta manera, la variable vector2 pasa a ser lo que podemos denominar un array
dinámico, en el sentido de que se comporta como un array y puede usarse como tal, pero su
tamaño ha sido definido durante la ejecución del programa (más adelante, en el mismo
programa, podemos redefinir el tamaño del array para acortarlo o alargarlo)
int *x, i;
if (x == NULL)
else
{
printf("Introduzca un número:");
scanf("%i", &x[i]);
LIBERACIÓN DE MEMORIA
1. Asignar memoria a un puntero antes de usarlo con malloc() u otra función similar
2. Liberar la memoria asignada, cuando ya no va a usarse más, con free() u otra función
similar.
Si no se libera la memoria asignada a un puntero, teóricamente no ocurre nada grave, salvo
que podemos terminar por agotar la memoria disponible si reservamos continuamente y
nunca liberamos. Es, en cualquier caso, una costumbre muy saludable.
Para liberar la memoria reservada previamente con malloc() u otra función de su misma
familia, se utiliza la función free(). Observe su uso en este ejemplo:
int *x, i;
free(x);
Toda la memoria reservada con malloc() quedará liberada después de hacer free() y se
podrá utilizar para guardar otros datos o programas. El puntero x quedará apuntado a NULL
y no debe ser utilizado hasta que se le asigne alguna otra dirección válida.
Además de malloc() y free() existen otras funciones similares pero con pequeñas diferencias.
A continuación resumimos las más usuales y mostramos un ejemplo de su uso.
Pero antes haremos una advertencia: todas las funciones de reserva de memoria devuelven
un puntero a NULL si no tienen éxito. Por lo tanto, deben ir seguidas de un condicional que
compruebe si el puntero apunta a NULL antes de utilizarlo: no nos cansaremos de repetir que
utilizar un puntero a NULL es una manera segura de estrellar el programa.
calloc()
Reserva un bloque de memoria para almacenar num elementos de tam bytes y devuelve un
puntero void al comienzo del bloque. La sintaxis es:
free()
Libera el bloque de memoria apuntado por un puntero y que previamente había sido
reservado.
free(puntero);
malloc()
Reserva un bloque de memoria de tam bytes y devuelve un puntero void al comienzo del
mismo, según esta sintaxis:
void* malloc(tam);
Por ejemplo, para reservar espacio para una cadena de 100 caracteres:
char* texto;
realloc()
Cambia el tamaño de un bloque de memoria apuntado por puntero. Dicho bloque ha debido
ser previamente asignado con malloc() u otra función similar. El nuevo tamaño será de tam
bytes. Devuelve un puntero void al comienzo del bloque, y la sintaxis es:
En el siguiente ejemplo, se reserva espacio para 100 caracteres, pero luego se modifica el
tamaño del bloque para dar cabida hasta 500 caracteres:
char* texto;
int dato;
};
El campo otro_nodo apuntará a otro objeto del tipo nodo. De este modo, cada nodo
puede usarse como un ladrillo para construir estructuras más complejas, y cada uno
mantendrá una relación con otro u otros nodos (esto dependerá del tipo de estructura
dinámica, como veremos).
A lo largo del tema usaremos una representación gráfica para mostrar las estructuras de
datos dinámicas. El nodo anterior se representará así:
Dependiendo del número de punteros que haya en cada nodo y de las relaciones entre ellos,
podemos distinguir varios tipos de estructuras dinámicas. A lo largo de los siguientes
artículos iremos viendo algunas de estas estructuras, pero aquí las vamos a enumerar todas:
Listas abiertas: cada elemento sólo dispone de un puntero, que apuntará al
siguiente elemento de la lista.
Pilas: son un tipo especial de lista, conocidas como listas LIFO (Last In, First
Out: el último en entrar es el primero en salir). Los elementos se “amontonan”
o apilan, de modo que sólo el elemento que está encima de la pila puede ser
leído, y sólo pueden añadirse elementos encima de la pila.
Colas: otro tipo de listas, conocidas como listas FIFO (First In, First Out: El
primero en entrar es el primero en salir). Los elementos se almacenan en una
lista, pero sólo pueden añadirse por un extremo y leerse por el otro.
Listas circulares: o listas cerradas, son parecidas a las listas abiertas, pero el
último elemento apunta al primero. De hecho, en las listas circulares no puede
hablarse de “primero” ni de “último”.
Listas doblemente enlazadas: cada elemento dispone de dos punteros, uno
apunta al siguiente elemento y el otro al elemento anterior. Al contrario que las
listas abiertas, estas listas pueden recorrerse en los dos sentidos.
Árboles: cada elemento dispone de dos o más punteros, pero las referencias
nunca son a elementos anteriores, de modo que la estructura se ramifica y
crece de modo jerárquico.
Árboles binarios: son árboles donde cada nodo sólo puede apuntar a dos
nodos.
Árboles binarios de búsqueda (ABB): son árboles binarios ordenados, por lo
que la búsqueda de información en ellos es menos costosa. Desde cada nodo
todos los nodos de una rama serán mayores, según la norma que se haya
seguido para ordenar el árbol, y los de la otra rama serán menores.
Árboles AVL: son también árboles de búsqueda, pero su estructura está más
optimizada para reducir los tiempos de búsqueda.
Árboles B: son otro tipo de árboles de búsqueda más complejos y optimizados
que los anteriores.
Tablas HASH: son estructuras auxiliares para ordenar listas de gran tamaño.
Grafos: son árboles no jerarquizados, es decir, en los que cada nodo puede
apuntar a nodos de nivel inferior o de nivel superior. De hecho, no se puede
hablar de nivel “superior” e “inferior”. Son las estructuras dinámicas más
complejas.
Para terminar con esta introducción, señalar que también pueden existir estructuras
dinámicas aún más complejas, en las que los nodos pueden ser de distinto tipo.
En esta estructura, los nodos se organizan de modo que cada uno apunta al siguiente, y el
último no apunta a nada, es decir, el puntero al nodo siguiente vale NULL.
En las listas abiertas existe un nodo especial: el primero. Para manejar la lista es necesario
mantener un puntero a ese primer nodo, que llamaremos cabezade la lista. Mediante ese
único puntero-cabeza podemos acceder a toda la lista. Cuando el puntero-cabeza vale NULL,
diremos que la lista está vacía.
Esta lista contiene 4 datos. Observe cómo cada dato está enlazado con el nodo que contiene
el siguiente dato y, además, el puntero primero apunta a la cabeza de la lista, es decir, al
primer elemento. Es muy importante no perder nunca el valor de ese puntero, ya que en tal
caso sería imposible acceder al primer nodo y, desde él, a todos los demás.
De aquí en adelante supondremos, por simplicidad, que estamos manejando una lista abierta
de números enteros, pero el lector debe tener en cuenta que el tipo de dato con el que se
construye la lista puede ser cualquiera, sin más que modificar la estructura del nodo.
Para construir una lista abierta de números enteros debemos definir los siguientes tipos de
datos y variables:
struct s_nodo {
int dato;
};
t_nodo *primero;
Observe que la estructura s_nodo contiene un dato (en nuestro caso, de tipo entero) seguido
de un puntero a otro nodo. Después, se define una variable llamada primero, que será el
puntero al primer nodo de la lista.
Con las definiciones anteriores aún no tendremos disponible una lista abierta. Es importante
darse cuenta de que el tipo de dato “lista abierta dinámica” no existe en C estándar. Para
crearlo, debemos declarar los tipos de datos anteriores y, además, construir funciones en C
que nos sirvan para utilizar esos datos. Entonces sí que tendremos disponible un nuevo tipo
de dato para utilizar en nuestros programas y, además, podremos reutilizarlo en todos los
programas en los que nos haga falta.
Las operaciones básicas que debemos programar para obtener el nuevo tipo “lista abierta”
son:
Con esa idea en mente, vamos a ver en los próximos artículos cómo podemos implementar
las operaciones básicas para manejar listas abiertas.
Nos referíamos en un artículo anterior a las listas abiertas como la estructura dinámica más
sencilla. Veremos ahora cómo realizar con ella la operación más básica: insertar elementos.
Como comprenderemos enseguida, no es lo mismo insertar un dato en una lista vacía que en
una lista que ya tiene elementos, del mismo modo que no es lo mismo insertar un
elemento al principio de la lista que insertarlo al final. De modo que trataremos cada
situación por separado.
Si una lista está vacía significa que no contiene ningún nodo y, por lo tanto, el puntero
primero estará apuntando a NULL. Esto lo representaremos así:
1. Crear ese nodo reservando memoria para el mismo (con malloc() o una función similar).
Tras la creación, dispondremos de un puntero apuntando al nodo (llamaremos nodo a la
variable puntero a nodo).
2. Hacer que nodo->siguiente apunte a NULL
3. Hacer que primero apunte a nodo.
El resultado de la ejecución de estos tres pasos debe ser:
Veamos como se implementa esto en C. Dispondremos de una variable primero, que apunta
al primer elemento de la lista, y de una variable nodo, que será el elemento que
pretendemos insertar en la lista. El valor del dato de este nodo será, por ejemplo, 5.
En este caso dispondremos de una lista no vacía y de un nuevo nodo que queremos insertar
al principio de la lista:
Si lo escribimos en C:
t_nodo *nuevo;
Razonando del mismo modo podemos insertar un nuevo nodo al final de una lista no vacía,
sólo que en este caso necesitamos un puntero que nos señale al último elemento de la lista.
La forma de conseguir este puntero es muy sencilla: basta con recorrer uno a uno todos los
elementos de la lista hasta llegar al último. Podemos reconocer el último porque es el único
cuyo elemento siguiente valdrá NULL.
1. Hacer que el último elemento deje de apuntar a NULL y pase a apuntar al nuevo nodo.
2. Hacer que el nuevo nodo apunte a NULL
ultimo = primero;
ultimo = ultimo->siguiente;
Si aplicamos este código a la lista de ejemplo del apartado anterior obtendremos esta otra
lista:
Para insertar un nodo nuevo en cualquier posición de una lista, es decir, entre otros dos
nodos cualesquiera, el procedimiento es similar al anterior, sólo que ahora, en lugar de un
puntero al último elemento, necesitaremos disponer de un puntero al nodo exacto a partir
del cual pretendemos hacer la inserción.
Supongamos que queremos insertar el nuevo nodo entre los elementos 2 y 3 de la lista;
entonces necesitaremos un puntero al elemento 2, así:
Con todos esos elementos, basta con reasignar los punteros para obtener la nueva lista:
Como hemos hecho en los otros casos, vamos la implementación en C de este tipo de
inserción.
Supondremos que estamos trabajando con la misma lista que en los ejemplos de los
anteriores epígrafes, y que se desea insertar un nuevo nodo entre los datos 5 y 18.
Necesitamos obtener un puntero al nodo que contiene el dato 5, y para ello debemos ir
mirando los datos contenidos en todos los nodos desde el primero.
elemento = primero;
elemento = elemento->siguiente;
Muy a menudo necesitaremos recorrer una lista abierta, ya sea buscando un valor particular
o un nodo concreto. De hecho, es algo que ya hemos necesitado hacer en algunos de los
algoritmos de inserción que hemos presentado en el artículo anterior.
Las listas abiertas sólo pueden recorrerse en un sentido, ya que cada nodo apunta al
siguiente, de modo que no se puede obtener un puntero al nodo anterior desde un nodo
cualquiera.
1. Usaremos un puntero auxiliar (a modo del contador que se usa para recorrer un array)
2. El valor inicial del puntero auxiliar será igual al primer elemento de la lista
3. Iniciamos un bucle que, al menos, debe tener una condición: que el puntero auxiliar no
sea NULL. Cuando el puntero auxiliar tome el valor NULL significará que hemos llegado al
final de la lista.
4. Dentro del bucle asignaremos al puntero auxiliar el valor del nodo siguiente al actual.
Por ejemplo, este fragmento de código muestra los valores de los nodos de la lista de los
ejemplos anteriores:
t_nodo *aux;
aux = primero;
printf("%d\n", aux->dato);
aux = aux->siguiente;
La condición de salida del bucle puede complicarse si queremos añadir algún criterio de
búsqueda, pero siempre debemos conservar la comparación (aux != NULL) para terminar el
bucle en caso de llegar al final de la lista. Si no, el programa fallará.
Por ejemplo, el siguiente código busca el dato 50 en una lista de números enteros. Si existe,
se mostrará en la pantalla, y, si no, se dará un mensaje de error:
t_nodo *aux;
aux = primero;
aux = aux->siguiente;
if (aux->dato == 50)
else
Igual que al insertar datos en una lista abierta, a la hora de eliminar nodos debemos
considerar por separado diferentes casos, ya que su implementación es ligeramente distinta:
eliminar el primer nodo, eliminar el último, etc.
Para eliminar el primer nodo de una lista usaremos un puntero auxiliar que apunte al
segundo, de esta manera:
1. Hacer que el puntero auxiliar apunte a primero->siguiente (es decir, al segundo nodo)
2. Eliminar el elemento primero, liberando la memoria con free() o una función similar
3. Reasignar el puntero primero para que pase a apuntar al que antes era el segundo nodo,
y que ahora se habrá convertido en el primero.
Partimos, por tanto, de esta situación:
t_nodo *segundo;
if (primero != NULL) { // Comprobamos que la lista no
esté vacía
En todos los demás casos, eliminar un nodo se hace siempre del mismo modo. Únicamente
necesitamos disponer de un puntero al nodo anterior al que queremos eliminar, y un nodo
auxiliar que apunte al siguiente, es decir, al que vamos a eliminar:
1. Hacemos que el puntero auxiliar apunte al nodo que queremos borrar (anterior-
>siguiente)
2. Asignamos como nodo siguiente del nodo anterior, el siguiente al que queremos
eliminar. Es decir, anterior->siguiente = aux->siguiente.
3. Eliminamos el nodo apuntado por aux, liberando la memoria.
Como hacemos siempre, presentamos una implementación de este algoritmo en C. Para ello,
supondremos que queremos eliminar el nodo siguiente a aquél que contiene en dato 7:
anterior = primero;
anterior = anterior->siguiente;
}
ELIMINAR TODOS LOS NODOS DE UNA LISTA
Para eliminar una lista completa hay que recorrer todos los nodos e ir liberando la memoria
de cada uno, hasta que alcancemos el último nodo (que reconoceremos porque estará
apuntando a NULL).
Hemos visto en los artículos anteriores el fundamento de las operaciones básicas sobre listas
abiertas, enlazadas o simples (que de las tres formas se llaman). Esas operaciones son
la inserción, la búsqueda y la eliminación de nodos o elementos de la lista.
Es cierto que con éstas no se agotan las posibles operaciones sobre una lista. Ya
mencionamos otras posibilidades: eliminar todos los nodos, comprobar si una lista está
vacía, ordenar una lista, etc. Pero sí que constituyen el núcleo central de la lista. Sin ellas, la
estructura de datos no sería funcional, no serviría para nada. Con ellas, podemos utilizar la
lista como utilizamos un array (usando las funciones programadas en lugar de las
operaciones típicas de arrays, claro)
Supondremos que ya se ha definido la estructura del nodo y que la lista sirve para almacenar
números enteros (para que almacene otro tipo de información basta con cambiar la
estructura del nodo)
Implementaremos una función diferente para cada operación sobre la lista, de manera que
estas mismas funciones puedan utilizarse en otros programas:
Función insertar(): servirá para añadir un dato a la lista. Recibirá como parámetros el
puntero al primer elemento de la lista y el dato (número entero en nuestro ejemplo) que
se quiere insertar. Insertaremos el dato siempre en la primera posición de la lista, pero
esta función se puede modificar para insertar el dato al final o en cualquier otra
ubicación (por ejemplo, se puede mantener la lista ordenada insertando el dato en la
posición que le corresponda)
Función borrar(): servirá para borrar un dato de la lista. Recibirá como parámetros el
puntero al primer elemento y el dato que se quiere borrar (un número entero). Buscará
en la lista ese dato y, si lo encuentra, lo eliminará. Devolverá 1 si el borrado se ha hecho
con éxito, o –1 si ha fallado.
Función buscar(): servirá para buscar un dato en la lista. Recibirá como parámetros el
puntero al primer elemento y la posición del dato que se quiere buscar. Luego recorrerá
la lista hasta la posición indicada y devolverá el número almacenado en ella, o bien –1 si
esa posición no existe. Fíjese en que esto difiere de la operación “buscar” que vimos
antes. Allí buscábamos un nodo a través del dato que contenía, y aquí vamos a buscarlo
a partir de la posición que ocupa en la lista.
Tenga en cuenta que ésta es sólo una posible implementación de una lista. Dependiendo de
la naturaleza del problema, puede ser necesario modificar las funciones para que actúen de
otro modo ligeramente distinto.
Por último, y antes de pasar a ver el código, observe que, al utilizar funciones para cada
operación, tenemos que pasar por variable o referencia el puntero al primer elemento de la
lista. Y como el puntero al primer elemento ya es un puntero, hay que pasar como
parámetro un puntero a puntero. Eso plantea algunos problemas sintácticos que debe usted
observar con detalle (en el caso de la función buscar() eso no ocurre porque el parámetro se
puede pasar por valor)
t_nodo* nuevo;
anterior = *primero;
aux = anterior->siguiente;
anterior = anterior->siguiente;
return borrado;
t_nodo* nodo;
valor = nodo->dato;
valor = -1;
return valor;
Desde el programa principal se usarán estas funciones en el orden adecuado para resolver el
problema que se nos haya planteado. Por ejemplo, estas son algunas llamadas válidas:
insertar(primero, 5);
insertar(primero, n);
insertar(primero, 2);
borrar(primero, 5);
n = buscar(primero, 1);
...etc...