miércoles, 7 de agosto de 2024

Programación del Sinclair QL (XXIII): Punteros

Índice de entradas sobre programación del Sinclair QL



Tras la entrada sobre las variables, entraremos a explicar lo que son los punteros, ya que la definición habitual es real pero muy poco explicativa: Un puntero es una variable que apunta a otra variable.

En la siguiente entrada usaremos ya los punteros en SuperBasic, que aunque no los soporta de forma nativa, hay maneras de simularlos.

Punteros

Como vimos en la entrada anterior, los compiladores e intérpretes usan una tabla en donde almacenar los datos de las variables que se utilizan en el programa. Las entradas de esta tabla disponen de una serie de datos importantes: nombre, tipo, longitud y posición en memoria donde se almacena (lo que se denomina dirección base de la misma, y su ocupación es la longitud de la misma (normalmente viene dados por el tipo).

Un puntero es una variable de tipo entero, que almacena una dirección de memoria, usualmente la dirección base de otra variable o de una zona de memoria libre. Veamos los dos tipos con ejemplos en  lenguaje C (lo tengo bastantes oxidado, quizá no sea del todo correcta la sintaxis): 


[1] float a=78.65;
[2] float *b=&a;
[3] float *c=malloc(4);
[4] printf('%d %f', b, *b);
[5] printf('%d %f', c, *c);
[6] *c = a;
[7] printf('%f', c);


  • En la línea 1 creamos una variable de tipo decimal que ocupa 4 octetos, y le damos como valor inicial 78'65. 
  • En la línea 2 creamos una variable de tipo puntero, que apuntará a una zona de memoria que contiene una variable de tipo decimal de 4 octetos, y la inicializamos con la dirección de base de la variable a. 
  • En la línea 3 creamos una variable de tipo puntero que apuntará a una variable de tipo decimal, y lo inicializamos a una nueva zona de memoria en donde se reservan 4 octetos.
  • En la línea 4 el sistema imprimirá la dirección de memoria a la que apunta la variable b, seguida por el contenido de la memoria a la que apunta b, en este caso 78'65.
  • En la línea 5 el sistema imprimirá la dirección de memoria a la que apunta la variable c, seguida del contenido de esa dirección de memoria, en este caso indefinido pues no se ha inicializado a ningún valor, puede ser cero o dar un error por no servir el contenido como representación de una variable decimal.
  • En la línea 6 asociamos a la dirección base apuntada por la variable c el contenido de la variable a, sería lo mismo que hacer un c=a si ambas variables fueran float.
  • En la línea 7 se imprime 78'65

Como vemos, si una variable de tipo puntero la usamos por su nombre, obtenemos la dirección de memoria a la que apunta, pero si el nombre lo precedemos por asterisco, obtenemos el contenido de dicha dirección de memoria. Igualmente una variable precedida por & nos retorna al dirección de memoria de dicha variable y no su valor.

Uso de los punteros a variables

Al definirse las funciones, los parámetros que les pasamos son variables locales cuyo valor inicial es que que les pasemos en la llamada, y al finalizar  solo retornan un valor, pero en C definieron un sistema para poder alterar los parámetros  usando los punteros. Vemos un ejemplo:


[1] void incrementar(int i) { i = i + 1; }
[2] void duplicar(int *j) { j = j + 1; }
[3] int a=5; incrementar(a); printf('%c', a);
[4] int b=5; duplicar(&b); printf('%c', b);

  • En la línea 1 creamos una función que recibe un parámetro, que es una variable local a la misma, esto incrementa la variable en una unidad. En la línea 2 definimos una semejante, pero recibe un puntero como parámetro.
  • En la línea 3 definimos una variable entera con valor 5, llamamos a incrementar, al que le pasamos el valor de a que es 5, como en la línea 1 estamos incrementando la variable local no se afecta, y luego se imprime otra vez 5. 
  • En la línea 4 definimos una variable entera con valor 5, llamamos a duplicar, al que le pasamos la dirección de b, como en la línea 2 estamos recibiendo el puntero, al incrementar será la zona de memoria a la que apunte, por tanto al final de la línea imprimirá 6.

De esta manera es sencillo para el programador decidir que variables serán alterada en las funciones y cuales no, protegiendo mejor el programa. En lenguajes modernos sin punteros, se añade una palabra, normalmente var, antes de la variable para indicar que su valor podrá ser alterado, lo que internamente es usar un puntero de forma transparente.

Otro uso habitual es emplear punteros a funciones, de esta manera en una variable podemos guardar una u otra función en relación con los datos que estamos procesando, pudiendo alterar el manejo del programa sin necesidad de complicar mucho la lógica. 

En lenguajes modernos esto es transparente, por ejemplo un objeto es solo un puntero a una posición de memoria que contiene funciones y variables, lo manejamos sin necesidad de saber siquiera lo que es un puntero.

Uso de punteros a memoria

La principal ventaja de los punteros es el manejo de memoria libre de forma no estructurada y de longitud variable, pudiendo crear elementos libremente cuando los necesitemos, a diferencia de los vectores que debemos definir su número de elementos y el tamaño de estos antes de empezar a manejarlos. Esto es muy útil para manejar estructuras de datos, como una lista de cadenas, cuando necesitamos un elemento llamamos a una función que nos retorne una dirección de memoria libre, del tamaño que necesitemos para esa cadena más el puntero al siguiente elemento, guardamos los datos, ajustamos los punteros, y ya tenemos una lista que podemos recorrer, sin saber cuanto ocuparán las cadenas de caracteres ni cuantas usaremos. 

Esto lo hacen los lenguajes modernos de forma transparente, al crear una estructura de tipo lista, que luego podemos recorrer con un FOREACH.

Problemas de los punteros

Tras muchos años manejando punteros, por los problemas que pueden ocasionar su uso, en los lenguajes actuales se ha optado por eliminarlos, aunque el sistema los maneja de forma interna de forma transparente, en nuestro programa no podemos usarlos.

El principal problema de los punteros es que son propensos a usarse de forma errónea provocando errores en el programa o, lo que es peor, en el sistema. Si apunto mal en una lista, puedo crear una circular y no salir nunca del bucle que la recorra, o apuntar mal un elemento y salirnos de la lista, por lo que el programa trabajará con datos erróneos. En un vector no podemos usar mas elementos de los definidos, pero con punteros podemos pasarnos y seguir leyendo elementos incorrectos. Esto se soluciona siendo cuidadosos al programar y probar bien nuestros programas con muchos datos diferentes.

Otro problema es la fragmentación, si creamos y destruimos muchos trozos pequeños de memoria, el sistema no puede reutilizarlos y podemos ocupar toda la memoria posible sin darnos cuenta. Esto tiene difícil solución, habría que compactar la memoria reubicando los registros y los punteros, lo que puede ser largo. Esto es realmente lo que hacen en lenguajes modernos los recolectores de basura de forma automática y transparente, ocultando los punteros a los programadores.

Otro problema es la seguridad, mediante un puntero podemos acceder a cualquier posición de memoria, por tanto podemos leer en otras zonas de memoria que no son específicas de nuestro programa, en sistemas como UNIX que son multi-usuario, podemos leer la memoria asignada a otro usuario y obtener por ejemplo claves de acceso. Hay mecanismos en el sistema operativo para que esto no sea posible, dando errores si accedemos a ubicaciones de memoria que no son del rango que tenemos asignado a nuestro programa.