La respuesta a la pregunta anterior es que el lenguaje C permite hacer algo que no se puede hacer con lenguajes como BASIC y FORTRAN usados para elaborar programas de aplicación a ser usados por un usuario final: permite elaborar y construír los sistemas operativos como DOS, Windows y Linux con los cuales se echa a andar una computadora al ser encendida. Los sistemas operativos no son elaborados con lenguajes de alto nivel como BASIC y FORTRAN, los cuales esconden la mayoría de los detalles internos del hardware de las máquinas, tienen que ser elaborados en un lenguaje ensamblador o lo más cercano posible a un lenguaje ensamblador. Y C es un lenguaje lo más cercano posible a un lenguaje ensamblador. De hecho, el lenguaje C fue diseñado originalmente para poder elaborar el sistema operativo UNIX. Aunque el sistema operativo UNIX podría haber sido elaborado usando directamente lenguaje ensamblador, la desventaja es que a un nivel tan bajo el código elaborado sólo sería utilizable para construír en forma independiente el sistema operativo de cada máquina al variar la arquitectura del hardware de una máquina a otra. Y lo que querían los diseñadores del lenguaje C era construír un lenguaje que pudiera ser portátil de una máquina a otra con cambios mínimos, lo cual a su vez podría ayudar en la construcción de sistemas operativos de una gran variedad de máquinas. Si bien es cierto que para una máquina M1 se requiere de un ensamblador A1 que pueda convertir un código fuente en C a código ejecutable para dicha máquina, y que para otra máquina M2 se requiere de un ensamblador A2 distinto que pueda convertir código fuente en C a código ejecutable para la otra máquina, el código fuente en C con cambios mínimos de una máquina a otra puede ser esencialmente el mismo para ambas máquinas, ahorrándole a los programadores de sistemas una cantidad considerable de trabajo en su diseño de sistemas operativos. C desde luego puede ser usado también no solo por los programadores de sistemas sino también por los que elaboran programas de aplicación que interactúan directamente con el usuario final de muchas maneras (procesadores de palabras, programas de retoque de imágenes, etc.), y ofrece muchas ventajas tales como mayor velocidad y eficiencia. Pero C permite el acceso necesario para poder tomar control directo del hardware de la máquina permitiendo cosas tales como conectar una cámara de video a una computadora permitiendo que las imágenes aparezcan en una ventana con ciertas características, y esto ya no lo pueden hacer ni BASIC ni FORTRAN.
Si vamos a empezar a tomar el control directo del hardware de la máquina, tenemos que comenzar en algún punto, y el punto ideal para comenzar es logrando acceso directo a la memoria RAM de la máquina, y no estamos hablando simplemente de la porción de la memoria RAM en donde se ejecutan los programas de aplicación o la porción de la memoria RAM en donde reside el sistema operativo una vez que se ha terminado de cargar después de encender la máquina; estamos hablando de TODA la memoria RAM. Y lo que nos permite lograr tal cosa en C son los punteros.
El conocimiento y el uso correcto de los punteros es crucial para la creación de la mayoría de los programas C exitosos. Hay tres razones para esto. En primer lugar, los punteros proporcionan la manera mediante la cual las funciones que se definan en C pueden modificar los argumentos que las invocan. En segundo lugar, son usados para dar soporte a las rutinas de adjudicación dinámica de los recursos de la memoria. Y en tercer lugar, pueden usarse en la substitución de arrays en muchas situaciones.
Pero así como los punteros son una de las características más fuertes del lenguaje C, los punteros también son una de las cosas más peligrosas en manos de programadores inexpertos. El mejor ejemplo de ello consiste en el uso de punteros no-inicializados o punteros salvajes que pueden hacer que el sistema (la computadora) se colapse. Peor aún, es relativamente fácil usar accidentalmente punteros en forma incorrecta, lo cual puede ocasionar errores que son extremadamente difíciles de detectar.
Un puntero es una manera simbólica de usar los domicilios RAM de la computadora que de otro modo tendrían que ser descritos en sistema binario o sistema hexadecimal. Definimos un puntero como una variable que contiene el domicilio en la memoria de otro objeto, y en este sentido el puntero apunta hacia algo (en algunos textos al puntero se le llama apuntador, aunque esto último se puede prestar a confusiones en virtud de que al usar punteros no se está apuntando algo sino que se está apuntando hacia algo). Más comúnmente, este domicilio es la localidad de otra variable en la memoria, aunque puede ser el domicilio de un puerto de entrada/salida de la computadora (por ejemplo, un puerto RS232) o un bloque especializado de memoria RAM (como la memoria RAM de una tarjeta de video). Si una variable contiene el domicilio de otra variable, entonces se dice que la primera <i>apunta</i> hacia la segunda. Podemos ilustrar esta situación con la siguiente figura en la cual el domicilio 1000 en la memoria RAM apunta hacia el domicilio de memoria 1003, o sea hacia el domicilio que contiene la variable puesta el domicilio de memoria 1003:
Si una variable va a ser la representación simbólica de un puntero, entonces tiene que ser declarada como tal. Una declaración de puntero consiste de un tipo de base, un asterisco (*), y el nombre de la variable. La sintaxis para declarar una variable de puntero toma la siguiente forma:
tipo *nombre;
en donde tipo puede ser cualquier tipo de dato válido en C, y el nombre es el nombre que se le da a la variable de puntero. El tipo de base del puntero es lo que define hacia qué tipo de variable (int, float, double, etc.) está apuntando el puntero. En los siguientes enunciados declaramos como variables de puntero primero un puntero hacia un caracter, un puntero hacia un entero, y dos punteros hacia números de punto decimal flotante:
char *p;
int *cantidad;
float *velocidad, *aceleracion;
En la programación llevada a cabo usando punteros, hay dos operadores especiales usados en el manejo de los punteros, el ampersand (&) y el asterisco (*).
El & es un operador unario que nos regresa el domicilio en la memoria RAM del operando.
De este modo, en la siguiente instrucción:
p = &x;
se almacena en p el domicilio en la memoria RAM en donde está colocado el valor de la variable x. El domicilio es la localidad interna en la computadora de la variable, y no tiene absolutamente nada que ver con el valor de la variable x. La operación efectuada por & puede ser entendida como regresándonos “el domicilio de” la variable a la cual precede. Por lo tanto, la asignación dada en el ejemplo puede ser leída como “p recibe el domicilio de x”. Para entender mejor la asignación, supóngase que el valor de la variable x está puesta en el domicilio E5A8 (hexadecimal) de la memoria RAM. Entonces, una vez que se ha efectuado la asignación anterior, p contendrá E5A8, o sea un domicilio.
Al hablar de los domicilios de la memoria RAM, lo hacemos refiriéndonos a cada localidad no usando numeración en sistema decimal sino numeración en sistama hexadecimal. Y esto mismo se acostumbra hacer en el lenguaje C. A menos de que se indique lo contrario, podemos tomar el domicilio E5A8 como un domicilio dado en sistema hexadecimal, que en notación decimal corresponderá al número 58792.
El segundo operador, que ya vimos previamente, o sea el operador *, es el complemento del operador &, y es el operador * conocido como el operador de indirección.
El * es un operador unario que nos regresa el valor de la variable que se encuentra localizada en el domicilio en la memoria RAM que le sigue.
De este modo, si la variable de puntero p contiene el domicilio en la memoria RAM de la variable valor, entonces podemos obtener del modo siguiente el valor de la variable hacia la cual apunta:
valor = *p;
ya que la instrucción pone el valor *p en la variable valor. Si *p hubiera tenido originalmente el valor 100, entonces después de la asignación valor contendrá el valor 100 porque ese es el valor almacenado en la localidad de la memoria E5A8. La operación del * se puede recordar como “valor en el domicilio”.
En virtud de que un operador de puntero como * es entresacado por el compilador como algo independiente que tiene peso propio, comiéndose los espacios en blanco, las siguientes tres declaraciones son iguales:
int *p;
int* p;
int * p
Sin embargo, esta liberalidad que proporciona C a veces es abusada por programadores que quieren imponer sus propios “estilos” haciendo alarde de creatividad e imaginación, con el resultado final de que los programas que escriben en C son difíciles de leer, e inclusive los propios creadores de estos programas batallan para poder entender el propósito de sus propias creaciones cuando las vuelven a ver después de haber transcurrido varios años de haberlos elaborado. Es mejor adherirse a convenciones como las que se están dando aquí, las cuales son universalmente legibles.
Otro hecho desafortunado (y el cual puede dar lugar a confusiones) es que el signo usado para las operaciones aritméticas de multiplicación y el signo usado para “en el domicilio” es que son el mismo signo, aunque estos operadores no tienen ninguna relación el uno con el otro. Al usar punteros en la elaboración de instrucciones que contienen expresiones, tanto el * como el & tienen una precedencia mayor que la de todos los demás operadores aritméticos con la excepción del menos unario que es usado para invertir el signo aritmético.
A continuación se muestra un programa en el que se usan los ejemplos de instrucciones dados arriba:
#include <stdio.h>
main()
{
int *p, x, valor;
x = 100;
p = &x; /* asignar a p domicilio de x */
valor = *p; /* obtener el valor en ese domicilio */
printf("%d", valor); /* se imprime 100 */
}
El programa imprimirá el número 100 en la pantalla. En dicho programa, obsérvese que fue posible asignarle en forma indirecta a valor el valor 100 puesto previamente en la variable x a través de un puntero que apunta a x. El procedimiento usado puede dar lugar a una pregunta que no es trivial: ¿cómo sabe el compilador cuántos bytes copiará hacia valor del domicilio hacia el cual apunta p? ¿Cómo transfiere el compilador el número apropiado de bytes a cualquier asignación cuando se usa un puntero? La respuesta es que el tipo de base del puntero es lo que determina el tipo de dato al que el compilador supone que se está apuntando. En el ejemplo que se acaba de dar, p es declarado al principio como un puntero de tipo int, y por lo tanto dos bytes de información son copiados hacia valor del domicilio hacia el cual se apunta desde la variable p. En algunos sistemas en los que al tipo de dato double se le reservan ocho bytes, si se hubiera usado no un puntero de tipo int sino un puntero de tipo double entonces se habrían copiado ocho bytes y no dos.
Podemos esquematizar el ejemplo que se acaba de dar con la siguiente ilustración:
Generalmente, el domicilio en la memoria RAM bajo el cual está almacenada una variable de puntero como en el p puesta en la ilustración no lo conocemos ni nos interesa (por eso se puso un signo de interrogación “?” en donde de otra manera se habría puesto algún domicilio hexadecimal); lo que nos interesa es el domicilio en RAM hacia el cual apunta (podemos, desde luego, obtener el domicilio en el que está almacenada la variable de puntero con <&p, pero en el programa dado arriba la información es superflua e innecesaria), y podemos por lo tanto (en la figura) decir que p.=.E5A8 y que *p es el valor que contiene dicho domicilio. Lo más importante a recordar es que al declarar una variable de puntero su tipo de dato aunque sea una cosa independiente del domicilio hacia el cual apuntará de todos modos determinará rigurosamente cuántos bytes se asignarán al valor hacia el cual apuntará, y esto deberá permanecer inalterable.
Al usar punteros, es importante asegurarse de que las variables de puntero siempre apuntarán hacia el tipo correcto de dato. Un ejemplo de ello es que, cuando se declara un puntero de tipo int, el compilador dá por hecho de que cualquier domicilio que contenga apuntará hacia una variable de tipo entero. Si un programador novato elabora algo como lo siguiente tratando de pasar el valor de una variable A a una variable B:
#include <stdio.h>
main()
{
float A = 9.45, B;
int *p;
p = &A;
B = *p;
printf("%f",B);
}
el compilador marcará un mensaje de error en muchos compiladores en los cuales a los enteros declarados con int se les proporcinan dos bytes de almacenamiento. El código anterior no asignará el valor de A a B. Puesto que p es declarado como un puntero de tipo int, únicamente se transferirán 2 bytes de información a B, y no los cuatro bytes (o más) usados normalmente para definir un número de punto flotante.
Al usar punteros, podemos formular expresiones que usan punteros haciendo asignaciones de punteros, llevar a cabo aritmética de punteros e inclusive hacer operaciones de comparación lógica entre dos variables de punteros. Las expresiones que involucran punteros obedecen las mismas reglas que las que obedecen las demás expresiones en C.
Al igual que como con cualquier otra variable ordinaria, podemos usar un puntero en el lado derecho de una operación de asignación para pasarle su valor a otro puntero. Un ejemplo de ello es el siguiente programa (nota importante: el resultado obtenido del programa variará de máquina a máquina, en este caso se trata de una máquina con 512 megabytes de RAM instalada en ella con un sistema operativo Windows XP):
#include <stdio.h>
main()
{
int x;
int *p1, *p2;
x = 85;
p1 = &x;
p2 = p1;
/* imprimir el valor hexadecimal del domicilio */
/* de x (no el valor de x en sí) */
printf("en la localidad %p ", p2);
/* imprimir el valor de x */
printf("esta el valor %d\n", *p2);
}
Si se ejecuta el programa anterior, se obtiene una respuesta como la siguiente:
en la localidad 1B9F:2030 esta el valor 85
|
Obsérvese que se ha introducido en el programa un código de formato nuevo que no habíamos visto antes para ser usado en la función printf() , el código %p, el cual instruye que se imprima en notación hexadecimal el domicilio de puntero p2.
En lo que toca a operaciones aritméticas que se puedan llevar a cabo en una aritmética de punteros, solo hay dos operaciones permisibles: la operación de adición y la operación de substracción. Para poder entender lo que ocurre en la aritmética de punteros, supóngase que puntero1 es un puntero hacia un entero (representado con dos bytes) que contiene un valor de puntero igual a 4000. Entonces, después de la operación:
puntero1++;
el contenido de puntero1 no será 4001 sino 4002, ya que cada vez que puntero1 es incrementada apuntará hacia el siguiente entero. Lo mismo aplica en el caso de los decrementos. Esto significa que después de la operación:
puntero1--;
la variable puntero1 tendrá el valor 3998, suponiendo que su valor previo era 4000.
Así pues, cada vez que se incrementa un puntero, dicho puntero apuntará hacia la localidad de la memoria del siguiente elemento que corresponde a su tipo de base. Y cada vez que sea decrementado apuntará hacia la localidad del elemento previo. Solo en el caso de los punteros a caracteres, los cuales requieren de un solo byte para su representación, la aritmética parecerá tomar un patrón “normal”. Pero esto es una excepción; todos los demás punteros aumentarán o decrementarán de acuerdo a la longitud del tipo de dato hacia el cual apuntan.
No estamos limitados únicamente a operaciones de incremento o decremento. Podemos efectuar operaciones de adición o substracción de enteros de o a punteros. La expresión:
p1 = p1 + 15;
hará que p1 apunte hacia el noveno elemento del tipo de base de p1 más allá de aquél hacia el cual está apuntando.
Podemos también restar un puntero de otro. Si ambos punteros apuntan hacia diferentes elementos que forman parte de un array de elementos, entonces el resultado de la resta será lo mismo que restar los índices hacia los cuales están apuntando.
En lo que toca a comparaciones de punteros, podemos efectuar una comparación lógica entre dos punteros en una expresión relacional. El siguiente enunciado efectúa la comparación entre dos punteros p1 y p2:
if(p1 > p2) printf("p1 apunta hacia memoria mayor que p2\n");
Al hacer este tipo de cosas, es mejor limitar las comparaciones entre punteros cuando dos o más punteros apuntan hacia el mismo objeto.
En la entrada previa tratamos el tema de los arrays, y hemos tratado en esta entrada el tema de los punteros. Sin embargo, en muchos textos se suele tratar ambos temas en forma conjunta, en virtud de que existe una relación muy estrecha entre los punteros y los arrays. Considérese por ejemplo el siguiente fragmento:
char hilera[60], *puntero;
puntero = hilera;
En el fragmento, puntero ha sido fijado al domicilio del primer elemento en el array hilera. En C, el nombre de un array cuando es proporcionado sin índice alguno es el domicilio del inicio del array. En esencia, es un puntero hacia el array, habido el hecho de que todos los elementos de un array deben ser del mismo tipo de dato, y con esto podemos entresacar del array cualquiera de sus elementos. Podemos generar el mismo resultado, un puntero hacia el primer elemento del array, de la siguiente manera:
puntero = &hilera[0];
Sin embargo, esta segunda forma es menos usada por la mayoría de los programadores profesionales ser menos elegante y menos legible.
En lo anterior, si quisiéramos accesar el octavo elemento del array hilera, podemos hacerlo escribiendo ya sea:
hilera[7]
o
*(puntero + 7)
Ambos enunciados nos regresan el mismo elemento. Obsérvese que los arrays empiezan a partir de un índice cero, y por lo tanto cuando se usa un 7 para indexar el octavo elemento de hilera también agregamos un 4 al puntero puntero porque éste siempre apunta al primer elemento de hilera.
Lo anterior nos revela que en C contamos con dos métodos diferentes para accesar los elementos de un array. Esto es importante sobre todo al manejar arrays grandes porque la aritmética de punteros puede ser más rápida que el indexado de un array, y la velocidad frecuentemente es una consideración en la elaboración de programas. A continuación se darán dos versiones de un mismo programa, en el primero de los cuales se implementa indexado de array, y en el segundo de los cuales se implementa la versión en puntero.
Esta es la versión en array:
#include <stdio.h>
#include <ctype.h>
main()
{
char hilera[80];
int i;
printf("dame una hilera en mayusculas: ");
gets(hilera);
printf("esta es la hilera en minusculas: ");
for(i=0; hilera[i]; i++) printf("%c", tolower(hilera[i]));
}
Esta es la versión en puntero:
#include <stdio.h>
#include <ctype.h>
main()
{
char hilera[80], *p;
int i;
printf("dame una hilera en mayusculas: ");
gets(hilera);
printf("esta es la hilera en minusculas: ");
p = hilera; /* obtener el domicilio de hilera */
while(*p) printf("%c", tolower(*p++));
}
La versión array es más lenta que la versión puntero porque se requiere de mayor tiempo el indexado de un array que el usar el operador *.
Sin embargo, no hay que incurrir en la creencia de que nunca debemos usar indexado de arrays solo porque el uso de los punteros es más eficiente. Si un array va a ser accesado siguiendo estrictamente un orden ascendente o descendente, sin duda alguna los punteros son más rápidos y más fáciles de usar. Pero si vamos a estar accesando los elementos de un array en forma aleatoria, entonces es preferible usar el indexado de array porque generalmente será tan rápido como la evaluación de una expresión compleja de punteros, además de que es más fácil de codificar y comprender. Además, cuando usamos indexado de array estamos dejando que el compilador haga parte de nuestro trabajo (obsérvese en los dos programas anteriores que la versión array requiere de menos instrucciones que la versión puntero).
Como una muestra más de la estrecha relación que hay entre punteros y arrays, tenemos el siguiente ejemplo que nos muestra cómo es posible indexar un puntero como si fuese un array. El programa imprime en la pantalla los números del 1 al 8:
#include <stdio.h>
main()
{
int i[8] = {1, 2, 3, 4, 5, 6, 7, 8};
int *p, t;
p = i;
for(t=0; t<8; t++) printf("%d ", p[t]);
}
Como puede apreciarse en el programa que se acaba de dar, el enunciado p[t] idéntico al enunciado (p+t).
¿Cómo trabaja exactamente la relación entre punteros y arrays? En virtud de que el nombre de un array que carece de un índice numérico es simplemente un puntero al primer elemento del array, lo que realmente sucede cuando usamos las funciones de hilera que vimos en la entrada anterior es que lo único que se le pasa a las funciones es un puntero a las hileras y no las hileras en sí. Un ejemplo de cómo trabaja esto es la siguiente definición que podemos dar a la función de longitud de hilera strlen():
strlen(char *h)
{
int i = 0;
while(*h) {
i++;
h++;
}
return i;
}
En la formulación del fragmento anterior, se toma en cuenta que todas las hileras en C son terminadas con un nulo, el cual lógicamente es un valor falso. Por lo tanto, un enunciado de bucle como:
while(*h)
será verdadero hasta que se haya llegado al final de la hilera en donde se encuentra el nulo. De este modo, la función strlen() regresará con return un valor igual a cero si la función es invocada con una hilera cuya longitud es cero. En caso contrario, la función produce la longitud de la hilera en cuestión.
En caso de que el lector se esté preguntando cómo la función strlen() puede ser invocada proporcionándole como argumento una constante de hilera, o sea cómo puede funcionar una instrucción como la siguiente:
printf("la longitud de ARMANDO es %d", strlen("ARMANDO"));
se aclarará que cuando se usa una constante de hilera lo único que se le pasa a la función strlen() es un puntero, en tanto que la función de hilera es almacenada por el compilador. Esto hace posible que el siguiente programa funcione:
#include <stdio.h>
main()
{
char *hilera;
hilera = "este es un programa de prueba";
printf(hilera);
}
Puesto en otros términos, los caracteres que constituyen una constante de hilera son almacenados por el compilador en una tabla de hilera creada por el mismo compilador, mientras que un programa como el que se acaba de dar utiliza únicamente un puntero a dicha tabla.
Hasta este punto en los ejemplos que hemos visto, únicamente se ha asignado el domicilio del inicio de un array a un puntero. Pero es posible asignar el domicilio no solo del inicio de un array sino de cualquier elemento específico de un array. Esto se logra aplicándole el operador & a un array indexado. A modo de ejemplo, el siguiente fragmento coloca el domicilio del quinto elemento del array A en p:
p = &A[4];
Esto puede ser útil cuando queremos encontrar una subhilera dentro de una hilera (por ejemplo, la subhilera “fito” en la hilera “Arnulfito”). En el siguiente programa se toma entrada proporcionada desde el teclado por el usuario, e imprimirá el remanente de la hilera que fue introducida hasta el punto en el cual se encuentra el primer espacio en blanco:
#include <stdio.h>
main()
{
char hilera[80];
char *puntero;
int i;
printf("dame una hilera: ");
gets(hilera);
/* encontrar el primer espacio en blanco */
/* o el final de la hilera */
for(i=0; hilera[i] && hilera[i]!=' '; i++);
puntero = &hilera[i];
printf(puntero);
}
Si le proporcionamos al programa la hilera:
aeropuerto de la ciudad de mexico
se imprimirá:
de la ciudad de mexico
El programa logra su propósito porque puntero estará apuntando ya sea hacia un espacio en blanco (o hacia un nulo en caso de que no haya espacios en blanco en la hilera de caracteres). Si es un espacio en blanco, entonces se imprimirá el resto de la hilera. Y si es un nulo entonces la función printf() no imprimirá nada.
Habiendo visto el tema de los punteros hacia arrays, tal vez resulte sorprendente ver ahora que también se posible crear arrays de punteros. Esto es posible porque los punteros pueden ser indexados en un array tal y como lo hacemos con cualquier otro tipo de datos. La declaración hacia un array de punteros toma una forma como la siguiente:
float *estatura[70];
La forma en la cual asignamos el domicilio de una variable tipo int que llamaremos valor al sexto elemento del array de punteros es la siguiente:
estatura[5] = &valor;
Y para encontrar el valor de valor escribimos esto:
*estatura[5]
Un uso que se le puede dar a los arrays de punteros es en la construcción de punteros hacia mensajes de error. En una variedad de sistemas operativos, es común asignarle códigos numéricos a los errores que se pueden suscitar, y esos códigos numéricos se pueden usar para apuntar hacia tales mensajes de error. Por ejemplo:
char *error[] = {
"dispositivo mal conectado\n",
"fuera de memoria\n",
"no hay un archivo con ese nombre\n",
"operacion no permisible\n"
};
imprimir_mensaje(int numero)
{
printf{"%s", error[numero]}
}
En el ejemplo mostrado, la función printf() es invocada dentro de imprimir_mensaje() con un puntero de caracter, que apunta hacia alguno de los cuatro mensajes de error indexados mediante el número de error que se le pasa a la función. De este modo, si se le pasa el número 1, entonces se imprimirá el mensaje “fuera de memoria”.
Con el uso de los punteros nos vamos aproximando un poco más al acceso del hardware de la máquina, habiendo empezado por el libre acceso a la memoria RAM de la máquina. Pero si queremos ir más allá, tenemos que contar con una mayor disponibilidad de recursos. Supóngase, por ejemplo, que queremos elaborar un programa C para controlar un robot elemental. Lo podemos hacer en C, si contamos con un conjunto de funciones que nos permitan enviarle comandos específicos al robot. El lenguaje C en sí no proporciona funciones para tales cosas, lo deja al arbitrio de los programadores. ¿Pero de dónde van a salir tales funciones? Saldrán de compiladores que contengan los recursos para llevar a cabo la programación de cada dispositivo de este tipo puesto a la venta. Y los programas de este tipo serán evidentes en los archivos de cabecera que se tengan que incluír en un programa para tomar control de los recursos de hardware. En el caso de un robot elemental, suponemos que los fabricantes del robot ya elaboraron para su producto una biblioteca de funciones con funciones tales como moverbrazo(), jalar(), detenerse(), accionarLED(), etcétera. Habrá archivos de cabecera tales como:
ROBOT.H
BRAZODER.H
BRAZOIZQ.H
los cuales en un programa C serán incluídos al principio del programa como:
#include <robot.h>
#include <brazoder.h>
#include <brazoizq.h>
Los fabricantes de cualquier cosa que se vaya a conectar a la computadora no necesitan entrar en el laborioso proceso de construír su propio programa compilador C para cada familia de computadoras a las cuales se conectarán sus productos, solo tienen que elaborar las bibliotecas de funciones especializadas que puedan ser enlazadas en el proceso de compilación hacia un programa ejecutable.
Para las familias de computadoras clones compatibles con las computadoras modeladas sobre la arquitectura expandible de computadoras caseras IBM, en lo que toca a los sistemas operativos DOS usados en dichas computadoras (PC-DOS, MS-DOS) varios compiladores incluían como parte de su paquete una función llamada system() que permitía enviar comandos directamente al sistema operativo DOS. La sintaxis de este comando tiene la siguiente sintaxis:
system("comando DOS");
en donde el comando DOS es el comando del sistema operativo (DOS) a ser ejecutado, el cual tiene que ser un número entero al cual corresponde la tarea DOS que va a ser ejecutada. El prototipo de esta función se encuentra definido en el archivo de cabecera:
STDLIB.H
Usando arrays inicializados de punteros de caracteres, podemos crear el siguiente listado de menú para una ventana de líneas de comandos, presentándonos un menú que pueda llevar a cabo lo mismo que lo que se hace con los comandos DOS tales como RENAME (cambiar el nombre de un archivo), DIR (listar contenidos de un directorio) y DATE (cambiar fecha del sistema).
#include <stdio.h>
#include <stdlib.h>
#include <conio.h>
main()
{
/* creacion de un array de hileras */
char *comando[] = {
"DATE",
"TIME",
"DIR",
"RENAME"
};
char ch;
for(;;) {
do {
printf("1: fijar la fecha\n");
printf("2: fijar el tiempo\n");
printf("3: listar contenidos de directorio\n");
printf("4: cambiar nombre del archivo\n");
printf("5: salir del menu\n");
printf("\nseleccion de comando: ");
ch = getche();
printf("\n");
} while ((ch<'1') || (ch>'5'));
if(ch=='5') break; /* salir del menu */
/* ejecucion del comando DOS */
/* invocando system() */
system(comando[ch-'1']);
}
}
Para que este programa se pueda ejecutar, es necesario compilarlo directamente hacia un archivo ejecutable (con un nombre como MENU.EXE) sin tratar de correrlo dentro del entorno protegido del IDE, abrir una ventana DOS en la máquina, y desde la ventana DOS echar a andar el programa.
Este sencillo programa de menu se puede expandir para incluír otros comandos DOS tales como COPY haciendo que el programa le pida al usuario (tal y como lo hace el sistema operativo DOS) el nombre del archivo a ser copiado y el lugar en donde se pondrá la copia del archivo. De hecho, usando la función system(), era posible duplicar todos los comandos de línea de los sistemas operativos MS-DOS y PC-DOS.
Otro ejemplo interesante que nos muestra la conexión estrecha que hay entre punteros y arrays es el siguiente programa traductor del Inglés al Castellano, que no es muy diferente del punto de partida usado por muchos programas traductores usados hoy en día. El programa permite introducir una frase en Inglés y proporciona como salida la frase traducida al Castellano. Para construír el programa lo primero que tenemos que hacer es construír una tabla que usada como diccionario nos permita llevar a cabo la traducción. En virtud de que la función principal main() invoca las funciones cotejar() y obtener_palabra(), ha sido puesta al final para permitir la compilación sin problemas del programa traductor:
#include#include char diccionario[][25] = { "a", "un", "am", "soy", "best", "mejor", "engineer", "ingeniero", "I", "yo", "of", "de", "sculptor", "escultor", "teacher", "maestro", "the", "el", "therapist", "terapeuta", "world", "mundo", "", "" }; char entrada[80]; char palabra[80]; char *p; /* La funcion cotejar() regresa la localidad */ /* de un apareamiento entre la hilera a la */ /* cual se apunta por s asi como por el */ /* array diccionario */ cotejar(char *s) { int i; for(i=0; *diccionario[i]; i++) if(!strcmp(diccionario[i], s)) break; if(*diccionario[i]) return i; else return -1; } obtener_palabra() { char *q; /* cargar el domicilio de palabra cada */ /* vez que la funcion sea invocada */ q = palabra; /* obtener la siguiente palabra */ while(*p && *p!=' ') { *q = *p; p++; q++; } if(*p==' ') p++; /* terminador nulo de cada palabra */ *q = '\0'; } main() { int loc; printf("Dame una oracion en Ingles: "); gets(entrada); /* darle a p el domicilio del array entrada */ p = entrada; printf("Traduccion aproximada: "); obtener_palabra(); /* obtener la primera palabra*/ /* Este es el bucle principal que se encarga de */ /* leer una palabra a la vez del array de entrada */ /* traduciendo cada palabra al Castellano. */ do { /* encontrar el indice de la palabra inglesa */ /* en el diccionario */ /* imprimir en Castellano si se encuentra */ /* una palabra inglesa */ if(loc!=-1) printf("%s ", diccionario[loc+1]); else printf(" [palabra desconocida] "); /* obtener la siguiente palabra */ obtener_palabra(); /* repetir hasta encontran una hilera nula */ } while(*palabra); }
Podemos ver que el punto de partida es una tabla Ingles-Castellano implementada con un array bidimensional al cual se le dá el nombre de “diccionario”:
char diccionario[][25] = {
"a", "un",
"am", "soy",
"best", "mejor",
"engineer", "ingeniero",
"I", "yo",
"of", "de",
"sculptor", "escultor",
"teacher", "maestro",
"the", "el",
"therapist", "terapeuta",
"world", "mundo",
"", ""
};
El análisis de cualquier programa C comienza con la lectura de lo que hay en la función principal main(), y el programa traductor dado arriba no es ninguna excepción. Primero que nada, al usuario se le pide que escriba una oración en Inglés. La oración dada por el usuario es leída hacia la hilera entrada. Hecho esto, al puntero p se le pasa el domicilio del punto de comienzo de entrada (obsérvese que, antes de main(), p es declarada como una variable global, una variable de puntero de tipo char). El puntero es utilizado por la función obtener_palabra() para leer una palabra a la vez de la hilera de entrada dada por el usuario, y cada palabra es puesta en otro array llamado palabra (obsérvese que, también antes y fuera de main(), el array palabra es declarado como un array global). Prosigue un bucle principal do-while que revisa y coteja cada palabra usando la función cotejar(), la cual regresa el índice (correspondiente a la tabla) de la palabra inglesa, o menos 1 (-1) en caso de que la palabra no se encuentre dentro de la tabla. Basta con sumar 1 a este índice para encontrar el equivalente castellano de la palabra inglesa.
La función cotejar():
cotejar(char *s)
{
int i;
for(i=0; *diccionario[i]; i++)
if(!strcmp(diccionario[i], s)) break;
if(*diccionario[i]) return i;
else return -1;
}
es invocada con un puntero hacia la palabra inglesa, y cotejar() nos regresa el índice de dicha palabra si la palabra se encuentra en la tabla, y menos 1 (-1) en caso contrario.
Por otro lado, en la función obtener_palabra():
obtener_palabra()
{
char *q;
/* cargar el domicilio de palabra cada */
/* vez que la funcion sea invocada */
q = palabra;
/* obtener la siguiente palabra */
while(*p && *p!=' ') {
*q = *p;
p++;
q++;
}
if(*p==' ') p++;
/* terminador nulo de cada palabra */
*q = '\0';
}
las palabras están delimitadas la una de la otra mediante el uso de espacios en blanco o un terminador nulo. La función obtener_palabra() cambiará la variable global palabra, y al terminar de ejecutarse dicha variable global contendrá ya sea la siguiente palabra inglesa en la oración o un nulo.
Como un ejemplo de traducción, si el usuario introduce la hilera “I am the best therapist of the world” el programa responderá con “yo son el mejor terapeuta del mundo”, o si introduce la hilera “I am a teacher” el programa responderá con “yo soy un maestro”. Vale la pena estudiar la interacción que ocurre entre los punteros y los arrays del programa. Lo más importante a recordar es que diccionario[0] es lo mismo que un puntero a la primera entrada de la tabla, diccionario[1] es un puntero hacia la segunda entrada de la tabla, y así sucesivamente. El array diccionario es de hecho un array de punteros hacia las hileras de texto mostradas en su declaración. Las hileras en sí son guardadas por el compilador en una área separada.
Si usando el lenguaje C podemos construír un programa elemental que sea capaz de traducir oraciones del Inglés al Castellano, ¿qué nos impide construír un programa que pueda tomar instrucciones formuladas en un lenguaje de alto nivel como BASIC o inclusive el mismo C, convirtiéndolas a instrucciones en lenguaje de máquina? En realidad, nada. Y este es precisamente uno de los objetivos del lenguaje C, el cual ha sido usado ampliamente no solo para construír sistemas operativos sino inclusive para construír compiladores de lenguajes como BASIC, FORTRAN, e irónicamente, el mismo C. Sin embargo, el proceso de construcción de compiladores involucra otros detalles tales como la definición de las funciones de parsificación que se encargan de tomar “frases” tales como:
IF (X > Y) THEN A = (X*Y + 23.75)/(X + 8*Y)
descomponiéndolas en sus constituyentes esenciales (del mismo modo en que una oración en Castellano se puede descomponer en artículo, sustantivo, adjetivo, y verbo) y acomodándolas en “árboles” con los cuales con la ayuda de tablas se pueda llevar a cabo la conversión de un programa de alto nivel a su equivalente en código ejecutable. Apenas hemos tocado los rudimentos, pero el lector ya puede empezar a visualizar las posibilidades y se puede dar una idea de lo que involucra la construcción de un compilador (en las instituciones académicas, es común en los cursos que tratan sobre el tema de la construcción de compiladores el proporcionarles a los estudiantes una lista de lo que serán las palabras clave de un lenguaje X, las estructuras de control que serán válidas en dicho lenguaje así como los bucles que se podrán implementar, los tipos de variables que serán considerados como válidos, etcétera, y una vez delineadas las especificaciones del lenguaje se les pide construír el compilador real usando un lenguaje como C; el maestro rara vez tiene que revisar el código completo excepto para comprobar que un estudiante no le haya copiado su diseño a otro, todo lo que tiene que hacer es compilar en su propia máquina a un archivo ejecutable el proyecto que le haya entregado cada estudiante, y si un estudiante hizo bien su trabajo su compilador cumplirá con los requisitos impuestos desde un principio).
Generalmente no hay confusión en el concepto de arrays de punteros porque el uso de índices de array se encarga de mantener claro el significado. Sin embargo, hay otro concepto más difícil de digerir que suele causar muchas confusiones en los principiantes, el uso de punteros que apuntan hacia otros punteros.
Un puntero a un puntero es una forma de lo que se conoce como una indirección múltiple, conceptualizada como una cadena de punteros. Podemos ver en las siguientes figuras que en el caso de un puntero normal el valor del puntero es el domicilio en la memoria RAM de la variable que contiene el valor deseado, mientras que en el caso de un puntero a un puntero el primer puntero contiene el domicilio en la memoria RAM del segundo puntero, el cual a su vez apunta hacia la variable que es la que contiene el valor deseado:
En principio, podemos extender una indirección múltiple tanto como queramos, podemos por ejemplo tener un puntero a un puntero que apunta hacia otro puntero, pero (afortunadamente) hay pocos casos en los que se requiera más de un puntero hacia otro puntero. La indirección excesiva es algo que está sujeto a muchos errores de interpretación y errores conceptuales (es importante no confundir la indirección múltiple con el concepto de <i>listas enlazadas</i>, un tema que es propio del estudio de lo que se conoce como Estructuras de Datos).
Una variable que es un puntero hacia otro puntero es declarada de la siguiente manera poniendo un asterisco adicional al frente del nombre de la variable de puntero:
float **aceleracion;
Obsérvense en este ejemplo los dos asteriscos, uno tras otro. La declaración le dice al compilador que aceleracion es un puntero a un puntero del tipo float. Lo importante a recordar es que aceleracion no es un puntero hacia un número de punto flotante, sino un puntero hacia un puntero float.
Para poder accesar el valor objetivo al cual se está apuntando indirectamente con un puntero a un puntero, el operador asterisco se tiene que aplicar por partida doble, como en el siguiente ejemplo con el cual se imprime en la pantalla el valor 28:
#include <stdio.h>
main()
{
int x, *p, **q;
x = 28;
p = &x;
q = &p;
printf("%d", **q); /* imprimir el valor de x */
}
En este ejemplo, se declara a p como un puntero de tipo int a un entero, y a q como un puntero (también de tipo int) hacia un puntero que apunta a un entero.
Un hecho importante a tener en cuenta es que después de que se ha declarado un puntero, antes de que contenga un valor puede contener cualquier valor. O sea que puede contener el domicilio de cualquier localidad de la memoria RAM, el que sea. Si un programador intenta usar un puntero antes de asignarle un valor, lo más probable es que enviará abajo no solo a su programa sino inclusive al sistema operativo que también reside en la memoria RAM.
Es por lo anterior que un puntero que no esté apuntando hacia ninguna parte se le debe asignar el valor nulo para indicar que no está apuntando a ningún lado.
Sin embargo, el hecho de que a un puntero se le asigne un valor nulo no significa que el puntero sea algo “seguro”. Si se usa un puntero nulo en el lado izquierdo de una operación de asignación, aún así se corre el riesgo de colapsar el programa y el sistema operativo.
Podemos usar el hecho de que se supone que un puntero nulo no está siendo utilizado para hacer muchas de las rutinas de puntero más eficientes y más breves de codificar. Podemos, por ejemplo, usar un puntero nulo para marcar el final de un array de punteros; si se hace esto entonces una rutina que accesa ese array sabrá que ha llegado al final del array cuando el valor nulo es encontrado. Un ejemplo de esto es mostrado con el siguiente bucle for:
for(x=0; p[x]; ++p)
if(!strcomp(p[x], nombre)) break;
Este bucle, usado en una operación de búsqueda que supone que el último elemento es un nulo, continuará ejecutándose hasta que se encuentre un apareamiento o se encuentre un puntero nulo. En virtud de que el final de todo array está marcado en su final por un nulo, la condición que controla la ejecución del bucle encontrara una condición de falso con lo cual el bucle llegará a su conclusión.
Una práctica común en programas C elaborados profesionalmente es la de inicializar hileras. Una variación a los ejemplos que vimos arriba al cubrir arrays de punteros es el siguiente tipo de declaración de hileras:
char *puntero = "me llamo Armando\n";
Como puede verse en la declaración, la variable puntero no es un array, ya que de haberlo sido habría sido declarada como puntero[18] o simplemente como puntero[]. Y sin embargo, en muchos compiladores C este tipo de inicialización es aceptada y los programas son compilados sin problema alguno. La razón por la cual este tipo de inicialización a veces funciona tiene que ver con la manera en la cual un compilador maneja constantes de hilera. En los casos en los que funciona este tipo de declaración, el compilador crea una tabla de hilera que es usada internamente por el compilador para almacenar las constantes de hilera usadas por el programa. Por lo tanto, el anterior enunciado coloca el domicilio de la hilera “me llamo Armando”, como es almacenada en la tabla de hilera, en la variable puntero. Y a través de la ejecución del programa puntero puede ser usada como si fuera una hilera. Sobre esta base, el siguiente programa que imprime una hilera de texto al derecho y al revés es un programa válido:
#include <stdio.h>
#include <string.h>
char *p="me llamo Armando";
main()
{
int t;
printf(p);
for(t=strlen(p)-1; t>-1; t--) printf("%c", p[t]);
}
Es importante agregar que el programa en caso de ser modificado no debe hacer asignaciones a la tabla de hilera a través de p porque en caso de hacerse tal cosa la tabla será corrompida.
Las advertencias que se han estado dando sobre la precaución que se debe tener al usar punteros aplican a todo lo que tiene que ver con punteros, los cuales pueden ser una gran ventaja o pueden meter al programador en problemas, dándole un gran poder además de ser necesarios para muchos programas, pero causándole dolores de cabeza cuando un puntero contiene un valor incorrecto, lo cual puede ser el tipo de error que es el más difícil de encontrar. Un error de puntero no es fácil de detectar porque el problema es que cada vez que se lleva a cabo una operación usando un puntero erróneo se está leyendo o peor aún alterando una porción desconocida de la memoria RAM. Cuando es leído, lo peor que puede ocurrir es que se obtenga como salida del programa pura información carente de sentido; pero cuando se escribe algo hacia un puntero erróneo estaremos escribiendo encima de porciones de código o datos de nuestro programa, o inclusive encima del sistema operativo, lo cual puede que no salga a flote sino tiempo después de que el programa se está ejecutando, llevando al programador a buscar el bicho (bug) del programa en los lugares equivocados. Puede que haya muy poca o tal vez ninguna evidencia de que el problema fatal radica en un puntero erróneo.
Al toparse con problemas en un programa C que es rechazado por el compilador, los novicios en el arte de la programación suelen creer que el problema radica en el compilador, que el compilador está defectuoso en alguna parte de su estructura interna y por ello no puede aceptar que el programa que le fue entregado es un programa impecable libre de errores, y seguramente el compilador C merece recibir un remiendo o parche de su fabricante para corregirlo. Esto tal vez pueda ser cierto en los compiladores C de bajo costo, pero en compiladores C producidos por empresas de software reconocidas los errores casi siempre radican en errores de programación, y en tales casos al toparse con problemas hay que suponer siempre desde un principio que algo anda mal en el programa y no en el compilador que rechaza el programa.
Un error que puede cometerse al usar punteros es el del puntero no-inicializado. A continuación se tiene un ejemplo de este tipo de error (se advierte de antemano que este programa no debe tratar de ser compilado o ejecutado dentro de un entorno IDE de lenguaje C, ya que el programa contiene un error deliberado):
main {
int a, *puntero;
a = 50;
*puntero = a;
}
Obsérvese que la variable puntero no está inicializada. Este programa lo que hace es asignar el valor 50 a una localidad desconocida de la memoria, al no haberse dado a la variable de puntero ningún valor, y por lo tanto contiene información garabato. Este mismo tipo de problema ocurre cuando un puntero apunta al lugar equivocado, lo cual puede ocurrir cuando sí se le hace una asignación a un puntero pero se le hace para que apunte hacia un domicilio equivocado. Este tipo de problema suele pasar desapercibido cuando el programa es muy pequeño porque siempre hay posibilidades de que puntero contenga un domicilio que podríamos llamar “seguro”, un domicilio en la memoria RAM que no está en el código, el área de datos o el sistema operativo. Pero conforme el programa se va haciendo más y más grande, van aumentando las probabilidades de que la variable de puntero apunte hacia algo vital, hasta que se llega al punto en el que el programa deja de funcionar. Para evitar esto, es imperativo efectuar la inicialización del puntero, asegurándose de que apunte hacia algo válido antes de ser usado.
Otro error que puede cometerse al usar punteros es causado cuando no se entiende bien sobre cómo usar correctamente un puntero. A continuación se tiene un ejemplo de este tipo de error (se advierte de antemano que este programa no debe tratar de ser compilado o ejecutado dentro de un entorno IDE de lenguaje C, ya que el programa contiene un error deliberado):
main() {
int a, *puntero;
a = 50;
p = a;
printf("%d", *puntero);
}
En este caso, la invocación que se hace a printf() no imprimirá en la pantalla el valor de a el cual es 50. La razón de esto es que la asignación:
p = a;
es una asignación equivocada. Con tal enunciado, se ha asignado el valor 50 a la variable puntero que se supone contiene un domicilio de la memoria RAM. Para corregir el programa, se debe escribir:
p = &a;
En el último ejemplo que se ha dado varios compiladores le advertirán al programador acerca del error que hay en el programa. Sin embargo, no hay que confiarse en que todos los errores de este tipo podrán ser detectados por el compilador, por bueno que sea.
Apenas hemos tocado la superficie de las numerosas posibilidades que ofrece el uso correcto de los punteros. Una aplicación sencilla consiste en su uso en programas en donde se lleva a cabo un intercambio de valores en dos (o más) variables como x e y, lo cual ocurre en tareas frecuentes tales como cuando se lleva a cabo un ordenamiento alfabético de un conjunto de datos (operaciones conocidas en lengua inglesa como sort). Para algo como esto, la simple secuencia:
x = y;
y = x;
no funciona, porque cuando se llega a la segunda línea el valor original de x se ha perdido, y tenemos que poner una línea adicional para conservar el valor original de x:
temporal = x;
x = y;
y = temp;
En el siguiente programa usamos punteros para lograr que una función bautizada como intercambio() cumpla su cometido:
#include <stdio.h>
main()
{
int x = 5, y = 10;
printf("Originalmente x=%d, y=%d.\n",x,y);
intercambio(&x, &y); /* enviar domicilios a la funcion */
printf("Ahora x=%d, y=%d.\n", x, y);
}
intercambio(u, v)
/* se declaran a u y v como punteros */
int *u, *v;
{
int temporal;
/* temporal obtiene el valor al que apunta u */
temporal = *u;
*u = *v;
*v = temporal;
}
Como resultado de esto, se imprimirá en la pantalla lo siguiente:
Originalmente
x=5, y=10.
Ahora x=10, y=5. |
En este último programa se ha definido una función intercambio() a la cual en vez de enviarle argumentos de cierto tipo de datos (int, float, etc.) lo que se le envían como argumentos son domicilios. Esto significa que hemos llegado a cierta madurez en nuestros conocimientos del lenguaje C y estamos entrando ya en el ámbito que corresponde a los programadores profesionales. El siguiente paso consiste en formalizar la manera en la cual declaramos las funciones en C de modo tal que nuestro código pueda ser portátil a la gran mayoría de compiladores C y entornos de programación en C que hay disponibles en la actualidad, y aunque C nos perdona omisiones en la formalidad y el rigorismo es importante tomar conocimiento bajo qué condiciones nos perdona estas omisiones y que suposiciones formula cuando incurrimos en ellas.