Los punteros

Objetivos

 

  • Repite conmigo: Los punteros son sencillos, yo puedo con ellos.
  • Cómo definimos un puntero. Comprender la sintaxis del uso de los punteros.
  • Comprender lo que es un puntero y como usarlo con seguridad.
  • Pasando valores por valor y por referencia.
  • Presentar ejemplos lo más minimalistas posibles del uso de punteros.
  •  

     

    Material requerido.

    Imagen de Arduino UNO

     Arduino UNO o similar.

    Taza de café  Un café cargadito siempre ayuda

     

    Centrando el tema

     

    En los capítulos previos hemos ido haciendo una introducción al lenguaje C++ que nos permitiera desarrollar nuestros propios programas para gestionar nuestros proyectos. Hemos visto buen parte de las instrucciones de C++ precisas para ello (Que no todas aún), pero deliberadamente habíamos pasado por alto una de las características clave de C++ que son los punteros (Pointers).

    No hay duda de que los punteros son una de las características más definitorias de C y constituyen una de sus grandes fortalezas… a la vez que uno de sus mayores problemas, especialmente para novatos.

    Esperamos que a estas alturas tengáis ya claro,  que no hay nada tan difícil que no puedas aprender (En serio), basta con te lo explique alguien que lo entienda un poco (Algo no demasiado frecuente) y luego poner ganas y trabajo.

    La experiencia demuestra que el concepto de puntero, es extraño, e inicialmente provocan la típica reacción de ¿Y para que quiero yo este rollo tan raro? Y además tienen la dudosa virtud de ser potencialmente peligrosos en nuestros programas.

    Se suele decir que todos los lenguajes de programación disponen de herramientas con las que puedes acabar disparándote en el pie, pero en el caso de los punteros, la cosa es más bien volarte la pierna y parte del brazo si te descuidas.

    Bien usados, son excepcionalmente útiles para resolver cierto tipo de problemas, pero aprender a manejarlos puede provocar improperios y serios dolores de cabeza, especialmente cuando tratas de depurar un programa que se niega a funcionar como debe.

  • Hace unos años equivocarte en un programa con los punteros solía acabar colgando tu PC sin previo aviso, lo que tenía gracia siempre y cuando hubieses salvado a tiempo(Algo a lo que te acostumbrabas con rapidez)
  • Actualmente los PCs con sistemas operativos modernos, Tanto Windows como Linux y Mac, aíslan tu programa de la maquina con lo que no es fácil que dinamites tu entorno de trabajo, pero es relativamente fácil bloquear tu programa.
  • Claro que en Arduino no tenemos un SO que nos evite el destrozo, así que será relativamente fácil colgarlo, así que ojito y salvad a menudo.
  •  

    Para los que aún no hayan huido de esta sesión, tenemos buenas noticias. Tampoco son para tanto, especialmente en un primer nivel. Hay otro nivel en el que por ahora no nos arriesgaremos a navegar y ya hablaremos en su momento. (No conviene enfangarse en soluciones a problemas que aún no habéis tenido).

    Así que poneros cómodos que vamos a meternos con los punteros y esto exige, para los primerizos, tranquilidad de espíritu y ganas de aprender. Pero para ello, tendremos que dar un rodeo previo y hablar de las variables y de cómo se almacenan en la memoria

     

    Las variables en C++

     

    Lo sé. Las variables de C++ ya casi no tienen secretos para los seguidores de estas sesiones. Pero el truco está en el casi y aún nos queda hablar un poco más de como maneja el compilador de C++ las variables (Si, me temo que es imprescindible).

    En primer lugar la memoria de tu Arduino (O de tu PC) esta numerada. Cada una de las posibles posiciones de memoria tiene una dirección única que debe ser especificada cuando quieres leer o escribir su valor.

    Cuando vimos los tipos de memorias de que disponían los diferentes modelos de Arduino, dijimos que el modelo UNO tenía 32k de Flash para almacenar los programas y 2k de RAM para almacenar las variables.

    Por eso cuando definimos una variable mediante una instrucción, el compilador le asigna una posición en la RAM de un byte si es un char o byte, de dos posiciones para un int y de 4 posiciones si es un long, de modo que los tipos más largos se puedan almacenar en posiciones consecutivas que los acomoden. Si declaro una variable así:

    int j ;

    Simplemente informo al compilador de que voy a usar una variable llamada j. Si la defino como

    j =10 ;

    Lo que le decimos al compilador es que voy a usar la variable j que definí antes y que debe asignarle ese valor de 10 que especifico.

  •  Por eso declarar y definir una variable no es lo mismo, a pesar de que muchas veces usamos los términos como intercambiables.
  •  

    Pero si pensamos un momento en lo que tiene que hacer el compilador, veremos que hay dos conceptos distintos. Por una parte está la definición de la variable j, el compilador tiene que crear una tabla donde anotemos que existe una variable llamada j y por otro lado tiene que asignar físicamente una o más posiciones de memoria para contener el valor de las variables.

    Si el compilador le asigna a la variable j la posición de memoria 2020, y graba en ella el valor que hemos pedido de 10, tendremos

    Nombre Dirección de memoria Contenido
    j 2020 10

    Es decir, que para el compilador la variable j esta almacenada en la posición de memoria 2020 y el contenido de esa posición (o de la variable, que es lo mismo) es 10.

    C++ nos exige que declaremos las variables, antes de usarlas porque debe reservar espacio para ellas del tamaño adecuado, y cuando las definimos o asignamos valores, escribe el valor en la dirección de memoria que la tabla le indica.

    Así, que la clave es comprender que una cosa es la dirección de memoria que contiene el valor y otra distinta es el contenido de esa dirección de memoria.

  • Que en la jerga informática se suelen llamar lvalue por location o dirección y rvalue, por contenido.
  •  

    Esto nos da una idea de porque es un desastre si escribimos un valor long en una dirección de memoria que corresponde a un int: Como el long son 4 bytes cuando los intentemos meter en un cajón de int, de 2 bytes, va a pisar el contenido de las siguientes posiciones de memoria sin saber si están en uso o no. Probad esto:

    void setup()
       { Serial.begin(9600); }
    
    void loop()
       {
          int v ;
          long L = 100000 ;
          v = L ;
          Serial.println(v);
       }

    Uno esperaría que el compilador se diera cuenta de esto y lo considerara un error, pero C++ , tan cachondo como siempre, ignora el tema y nos informa tranquilamente que el resultado de v es de     -31.072, que es lo que sale de los dos últimos bytes del 100.000 en binario y con signo. Toma ya.

    Claro está, que en el caso de que el valor de L sea inferior a lo que cabe en un entero con signo, no notaríamos el problema, aparte de que acaba de sobrescribir posiciones de memoria contiguas que a su vez puede ser otra variable o peor aún, un puntero a una función, con lo que tendríamos una variable que mientras su valor es inferior a 215  (un int son 15 bits de datos y uno de signo) , no pasa nada pero que cuando pase de ahí nos devuelve valores insensatos, y ya veréis lo que os cuesta averiguar qué demonios está pasando.

    El caso es aún peor si has sobrescrito la dirección de una función, porque entonces, en algún momento tu programa intentará ejecutar una función con un salto a una dirección que es sencillamente basura y acabas de conseguir un bonito cuelgue completo de tu programa y de tu Duino.

     

    Los punteros en C++

     

    Una vez comprendida la diferencia entre la dirección de una variable y su contenido estamos ya capacitados para entender los punteros (Pointers en inglés). Un puntero es, simplemente, un tipo de datos que contiene la dirección física de algo en el mapa de memoria.

    Cuando declaramos un puntero se crea una variable de tipo pointer, y cuando le asignamos el valor, lo que hacemos es apuntarlo a la dirección física de memoria, donde se encuentra algo concreto, sea un entero, un long o cualquier ente que el compilador conozca.

    ¿Fácil , no?

    Como en Arduino UNO el mapa de memoria es de menos de 64k, los punteros que especifican una dirección de memoria se codifican con 16 bits o 2 bytes (216  =  65.536  >  32.768). En los PCs que disponen de Gigas de memoria, los punteros deben necesariamente ser mayores para poder indicar cualquier posición de memoria.

    Una curiosidad de los punteros, es que o bien tiene una dirección de 16 bits en Arduino, o contienen basura, pero no hay más opciones.

    Porque aunque pueden apuntar a tipos de diferente longitud, la memoria en la que empiezan se sigue definiendo con 16 bits (Aunque el tipo indica cuantos bytes hay que leer para conseguir el dato completo).

    Naturalmente  si tenemos una variable como v en Arduino, podemos conseguir la dirección en la que esta almacenada, con el operador ‘&’, sin más que hacer  &v:

    int v = 100 ;Serial.println( &v)

    Lamentablemente, esto sí que generará una queja por parte del compilador diciendo que el tema es ambiguo y tenemos que hacer un cast de tipo de la siguiente manera:

    int v = 100 ;
    Serial.println( (long)&v);

    Que en mi caso me responde diciendo 2290  pero en el vuestro puede ser otro.

  • Un cast consiste en forzar la conversión de un tipo en otro, y se efectúa precediendo a la variable que queremos forzar por el tipo que deseamos entre paréntesis.
  •  

    En realidad un puntero es sencillamente otro tipo de datos que contiene una dirección de memoria  y cuando entiendes esto, comprendes que puedes definir punteros, por si mismos, para usarlos de diferentes maneras.

    Para declarar un puntero usamos el operador ‘*’, basta con declararlo precedido de un *:

    int *p_data ;

    Que significa, créame un puntero a un int llamado p_data.

    Aquí es donde la cosa se empieza a complicar. Aunque el puntero a un int es una dirección, lo mismo que un puntero a un long, es imprescindible indicarle  al compilador a qué demonios vamos a apuntar, para que sepa cuantos bytes tiene que leer o escribir cuando se lo pidamos.

    Si leemos un long donde hay un int, leeremos basura. Si escribimos un long donde hay un int acabamos de corromper el valor de otras posibles variables y estamos en el caso que definimos antes con las variables.

  • A los nombres de los punteros, se les aplican las mismas reglas que a los nombres de variables o funciones, pero conviene dejar claro que es un puntero para que quien lo lea, no se despiste y malinterprete el programa.
  • Así es muy frecuente que al nombre de los punteros se les empiece por algo como “p_” o “ptr”, siempre ayuda tener las cosas claras ( y porque los errores con los punteros suelen acabar en choque con incendio).
  •  

    Nos surge entonces una pregunta ¿Cómo asigno la dirección de una variable, por ejemplo, a un puntero que he creado? Pues de nuevo, muy fácil:

    int num = 5;
    int *ptrNum;
    ptrNum = #

    Como &num nos da la dirección física donde se almacena num, basta con asignarla a ptrNum por las buenas. El operador “&” precediendo a una variable devuelve su dirección de memoria.

    Ahora ptrNum apunta a la dirección donde está almacenada la variable num. ¿Podría usar esta información para modificar  el valor almacenado allí? Desde luego:

    int num = 5;
    int *ptrNum;
    ptrNum = #
    *ptrNum = 7 ;
    Serial.println( num);

    Vereis que la respuesta en la consola al imprimir num es 7. Hemos usado un puntero para modificar el contenido en la celda a la que apunta usando el operador *.

    Podemos asignar un valor a la posición a la que apunta un puntero, basta con referirse a él con el * por delante. Y si queremos leer el contenido de la posición de memoria a la que apunta un puntero usamos el mismo truco:

    int num = 5;
    int *ptrNum;
    ptrNum = #
    Serial.println( *ptrNum);

    El resultado será 5.

    Normalmente con lo visto hasta ahora, suele ser suficiente para que la cabeza empiece a doler, así que os recomiendo que volváis a llenaros la taza de café y probéis los programitas de arriba y a ser posible que os imaginéis algún ejemplo y lo programéis vosotros desde el principio.

    Vamos a recapitular las ideas básicas:

  • Un puntero es una variable que apunta a una dirección concreta de nuestro mapa de memoria.
  • Para conocer la dirección concreta de donde algo está almacenado, basta con preceder el nombre de ese algo con el operador “&” y esa es su dirección, que podemos asignar a un puntero previamente definido (Del mismo tipo por favor).
  • Usamos el operador “*” precediendo al nombre del puntero, para indicar que queremos leer o escribir en la dirección a la que apunta, y no, cambiar el valor del puntero.
  •  

    Mucho cuidado con lo siguiente. La instrucción

    *ptrNum = 7 ;

    Tiene todo el sentido del mundo, pues gurda un 7 en la dirección al que el valor de ptrNum apunta. Pero en cambio

    ptrNum = 7 ;

    Es absurda, porque acabamos de apuntar a la dirección de memoria número  7. Si escribimos algo en una posición de memoria cuyo uso desconocemos será como un tirito a la sien con una ruleta rusa.

  • Salvo que seáis un ingeniero de sistemas apuntando a la dirección de algo concreto que sabéis con certeza, que hay en la dirección 7 del mapa de memoria, claro.
  •  

     

    Otra vez, ¿Para qué sirven los pointers o punteros?

     

    En primer lugar porque sí. Si podemos pensar en ello … para algo servirán. Este argumento es una variante de… lo hago porque puede hacerse. Pero además hay otras razones.

    La primera es que los argumentos que pasamos a las funciones se pasan por valor, es decir que una función no puede cambiar el valor de la variable que le pasamos, algo que ya vimos en la sesión de ámbito de las variables. Probad esto:

    void setup()
      {  Serial.begin(9600);}
    
    void loop()
       { int k = 10;
         Serial.print("Desde loop k vale: ");
         Serial.println (k);
         dobla(k);
         Serial.println("...................");
       }
    
    void dobla(int k)
       { k = k*2 ;
         Serial.print("Desde la funcion k vale: ");
         Serial.println (k);
       }

    El resultado es asi:

    Salida a pantalla

    Como la variable k del programa principal y la k de la función dobla son de ámbito diferente, no pueden influirse la una a la otra.

    Cuando llamamos a la función dobla(k), lo que el compilador hace es copiar el valor de k y pasárselo por valor a la función, pero se guarda mucho de decirle a la función, la dirección de la variable k. De ese modo aislamos el ámbito de las dos variables. Nada de lo que se haga en dobla influirá en el valor del k de la función principal.

    Pero como soy caprichoso, a veces me puede interesar que una función modifique el valor de la variable. Podríamos definir una variable global y con eso podríamos forzar a usar la misma variable para que modifique su valor. El problema es que a medida que los programas crecen, el número de variables globales tienden al infinito, y seguirlas puede complicarse mucho, pero que mucho, mucho, creedme.

    Otra solución, cantidad de limpia y elegante es pasar a una función la dirección de la variable de marras y ahora la función sí que puede modificar el valor de esta, Prueba esto:

    int k = 10;
    void setup()
       {  Serial.begin(9600);}
    
    void loop()
       {  Serial.print("Desde loop k vale: ");
          Serial.println (k);
          dobla( &k );              // Pasamos la direccion de k y no su valor
          Serial.println("...................");
       }
    
    void dobla(int *k)        // Avisamos a la funcion de que recibira un puntero
       {   *k = *k * 2 ;
            Serial.print("Desde la funcion k vale: ");
            Serial.println (*k);
       }

  • He sacado la definición de k fuera del loop para que no me inicialice el valor en cada vuelta (Y sí, he acabado definiendo una variable global que es lo que queríamos evitar, pero la cuestión no es esa ahora mismo).
  •  

    El resultado es:

    Salida a pantalla

     

    Al pasarle la variable por referencia, la función dobla() sí que ha podido modificar el contenido de la variable, y en cada ciclo la dobla.

    Cuando hablamos de las funciones, en las sesiones previas, dijimos que podíamos pasar a una función tantos parámetros como quisiéramos, pero que solo podía devolvernos un valor.

    Pero con nuestro reciente dominio de los punteros podemos entender porque nos importa un pito. Podemos pasar tantas variables como queramos por referencia a una función, con punteros, de modo que no necesitamos que nos devuelva múltiples valores, ya que la función puede cambiar múltiples variables.

     

    Punteros y Arrays

     

    Hacía mucho que no hablamos de Arrays (Si, sé que en el fondo os gustan, a pesar de las caras de asco) y no podíamos perder la ocasión. Vamos a probar este programita

    void setup()
       {  Serial.begin(9600);}
    
    void loop()
       {    char h[] = { 'P','r','o','m','e','t','e','c','\n'} ;
     
            for (int i=0 ; i < 9 ; i++)
                 Serial.print( h[i] );
            Serial.flush();
            exit(0);
       }

    El resultado es este:

    Salida de impresion

  • Si no usas el Serial.Flush, probablemente no veras el mensaje completo. La razón es que lo que envías por la puerta serie, se transmite en bloques y no carácter a carácter.
  • Por eso, si quieres garantizar que todo se ha enviado usa el flush() antes de salir con exit(0).
  •  

    Hemos utilizado p como un array de char y usado un loop para recorrerlo e imprimirlo. Nada nuevo en esto. Pero hagamos un pequeño cambio en el programa:

    void setup()
       {  Serial.begin(9600);}
    
    void loop()
       {    char h[] = { 'P','r','o','m','e','t','e','c','\n'} ;
    
            for (int i=0 ; i < 9 ; i++)
                 Serial.print( *(h + i)) );
            Serial.flush();
            exit(0);
       }

    Hemos cambiado la línea:

    Serial.print( h[i] );

    Por esta otra:

    Serial.print( *(h + i)) );

    Y el resultado es… exactamente lo mismo. ¿Por qué?

    Pues porque un array es una colección de datos, almacenada en posiciones consecutivas de memoria  (y sabemos el tamaño de cada dato porque lo hemos declarado como char o int o lo que sea), y lo que el compilador hace cuando usamos el nombre del array con un índice como h[i] es apuntar a la dirección de memoria donde empieza el array y sumarle el índice multiplicado por la longitud en bytes, de los datos almacenados, en este caso 1 porque hemos declarado un char).

    En realidad es otra forma de decir lo mismo. Como esto:

    Serial.print(*( h+ i * sizeof(char)));

    Cuando usamos h+i, el compilador entiende que sumemos i al puntero que indica el principio del array, y por eso, al usar el operador *, busca el contenido almacenado en h+i, que es exactamente lo mismo que la forma anterior.

    Así que si usas un array, sin índice, lo que en realidad estás haciendo es pasar un puntero al comienzo en memoria del array, o sea, su dirección de inicio. ¡ Sorpresa!.

    ¿Y qué te parece esto?:

    void setup()
       { Serial.begin(9600);}
    
    void loop()
       {   char h[] = { 'P','r','o','m','e','t','e','c','\n'} ;
           char *ptr = h ;
           for (int i=0 ; i < 9 ; i++)
                Serial.print(*ptr++);

    Pues más de lo mismo, pero en ese estilo típicamente críptico que caracteriza algunos de los aspectos más oscuros de C++.  Declaramos sobre la marcha un puntero que apunta a h con:

    char *ptr = h ;

    Utilizamos el bucle para contar simplemente, pero *ptr significa el contenido al que ptr apunta, o sea h, y después de imprimirlo, incrementamos ptr, con lo que apunta al siguiente char del array

    Para los no iniciados en los arcanos de C++, el programa anterior vaciará la sala entre los gritos del público asistente.

    Personalmente no soy demasiado partidario de un estilo tan escueto. Comprendo muy bien su elegancia, su eficacia y lo conciso de una notación tan escasa. Pero a cambio también es difícil de leer, más difícil de comprender más que por los iniciados y siempre hay que pensar en aquellos que van a leer nuestros programas… y apiadarse de ellos.

     

    Un último comentario

     

    Tenéis que comprender que esto solo ha sido un primer acercamiento a los punteros. Me viene a la memoria aquella frase de: Hay más que lo que el ojo ve.

    A medida que vayáis profundizando en los punteros, veréis que son prácticos y elegantes para resolver problemas, pero también creo que hemos empezado a atisbar porque son peligrosos. Porque pueden ser muy difíciles de desentrañar y porque los errores suelen acabar en sobrescribir zonas de memoria que no tocaba y normalmente eso acaba mal (Habitualmente, apagando y vuelta a encender).

    Me recuerdan a afeitarse con una navaja barbera bien afilada. Es una magnífica herramienta y proporciona un apurado perfecto… pero no es raro hacerse un corte o dos aprendiendo, pero en fin, mientras conservemos ambas orejas.

    Los punteros son al final, una herramienta. Lo que hagas con ella es cosa tuya, pero no puedes ignorarlos si vas a programar en C++, Así que apréndete los y olvídate de las excusas.  Son un poco intimidantes pero poco a poco acabaran gustándote.

     

    Resumen de la sesión

     

  • Hemos visto el concepto que representa un puntero: Una dirección de memoria física.
  • Hemos descrito la forma en que podemos definirlos y usarlos para leer o modificar el contenido de una posición de memoria o variable.
  • Presentamos las ideas, de pasar variables a las funciones  por  valor o por referencia.
  • Jugamos con la relación entre punteros y arrays.
  •    

    Deja una respuesta