C++ Punteros II

[forCode]

En la entrada anterior vimos que era un puntero, como declararlo y como pasar datos por referencia. Hoy avanzaremos un poco más y veremos una característica esencial de los punteros: la aritmética de punteros.

Aritmética de punteros

Recuerda que los punteros son variables que contienen una dirección de memoria. Lo habitual es obtener la dirección de memoria de un objeto (o un dato cualquiera) con el operador &, pero de hecho un puntero puede ser inicializado a cualquier valor. De todos modos eso es algo que, aunque sin duda bajo alguna circunstancia puede ser necesario, no es algo que vayas a querer hacer. De hecho C++ te impide hacer eso:

int* foo = 0xabc1234f;

Este código no compila, ya que el compilador te dirá que no puede convertir un int (el valor hexadecimal abc1234f) a un int*. Entonces… ¿como podemos inicializar un puntero a una dirección de memoria arbitraria? Pues usando reinterpret_cast. El operador reinterpret_cast es uno de los 4 operadores de conversión (los otros 3 son static_cast, dynamic_cast y const_cast) y es el más potente de ellos. Literalmente el uso de reinterpret_cast significa decirle al compilador que se deje de comprobaciones que tu sabes lo que estás haciendo. Así pues el código para inicializar un puntero a una dirección arbitraria es:

int* foo = reinterpret_cast<int*>(0xabc1234f);
*foo = 9000; // Metemos un 9000 en esa dirección de memoria.

Este código hace que foo apunte a una dirección de memoria arbitraria y coloca un 9000 en dicha dirección de memoria. Eso significa que estamos sobreescribiendo el contenido de dicha dirección de memoria. Por supuesto eso en la mayoría de los casos traerá consigo efectos catastróficos: lo habitual es que el SO no nos deje hacer eso y directamente termine nuestro proceso (no se puede ir por ahí sobreescribiendo la memoria, a no ser que sepas que dicha memoria te pertenece).

Así pues el valor de un puntero es una dirección de memoria y mediante el operador * podemos leer y/o escribir el contenido de dicha dirección de memoria. Pero fíjate que los punteros tienen tipo. Nunca hablamos de un puntero a secas. En este caso foo no es «solo un puntero» es «un puntero a int». Pero la memoria no está tipada, la memoria es simplemente un conjunto de bytes, uno tras otro con determinado contenido. El tipo del puntero nos da un mecanismo para «ver» el contenido que empieza en el byte apuntado por el puntero como si fuese de un determinado tipo. Veamos un ejemplo de ello:

class A {
public:
	int a1;
	int a2;
};
class B {
public:
	int b[2];
};

int main(int argc, wchar_t* argv[])
{
	auto a = A();
	a.a1 = 10;
	a.a2 = 20;
	A* pa = &a;
        std::cout << "pA es" << pa << "\n";
	B* pb = reinterpret_cast<B*>(pa);
	pb->b[1] = 100;
	std::cout << a.a2;
}

Este código crea un objeto de tipo A, incializa sus propiedades a1 a 10 y a2 a 20. Luego asigna su dirección de memoria a un puntero (pA) e imprime el valor de dicho puntero. Este valor es la dirección de memoria del objeto a (el valor es distinto a cada ejecución). Luego crea un puntero a un objeto de tipo B y lo asigna al mismo valor que pa. Esa asignación no podemos hacerla directamente porque el compilador nos dirá que no puede asignar un valor A* a un valor B*, pero usamos reinterpret_cast para que se calle. Ahora tenemos dos punteros que apuntan a la misma dirección de memoria pero uno (pa) nos permite ver el contenido que empieza en dicha dirección como si fuese un objeto A (en este caso lo que es realmente) y otro (pb) nos permite ver el contenido que empieza en dicha dircción de memoria como si fuese un objeto B (que no lo es).

A través de pb modifico el contenido que empieza en dicha dirección de memoria. Sí, estoy todo el tiempo repitiendo «empieza» y es para que quede claro: el puntero apunta a una única dirección de memoria (un byte) pero los datos suelen ocupar más de un byte. P. ej. un int mismo ocupa por lo general 4 bytes (aunque C++ no define un tamaño exacto). Así si tengo una variable int llamada i, y el valor de &i es 0x1000, eso significa que en este byte (0x1000) es el primero de los bytes que contienen dicha variable en memoria. Si el tamaño de int en mi plataforma es de 4 bytes, esto significa que los bytes 0x1000, 0x1001, 0x1002 y 0x1003 son los que contienen el valor de la variable i.

Volvamos ahora a pb, y el objeto de tipo A que tenemos allí. Supongamos que un int ocupa 4 bytes (o sea sizeof(int) ==4) y supongamos que &a es 0x1000. En este caso pa vale 0x1000 y pb lo mismo. En esa dirección empieza el objeto a. El objeto a es básicamente dos ints (uno llamado a1 y otro llamado a2). Podemos asumir que el primero que declaramos en la clase A (a1) empieza en 0x1000 y por lo tanto va de los bytes 0x1000 hasta 0x1003. Entonces a partir de 0x1004 y hasta 0x1007 empezará el segundo int (a2).

Nota: Doy por supuesto que conoces el operador sizeof. Dicho operador toma un tipo o una variable y devuelve el tamaño en bytes necesarios para tener datos de ese tipo o ocupados por esa variable. Una diferencia de C++ con lenguajes como C# o Java es que C++ no define los tamaños de los tipos. Así como en C# sabes que un int siempre ocupará 4 bytes, en C++ un int puede ocupar 2 bytes, o 4 ó 8,… dependerá de la plataforma. Sí, eso afecta al valor máximo que puedes guardar en un int, y es uno de los problemas a la hora de hacer código interoperable. Al final se tiene que recurrir a #defines y typedefs para hacerlo. Quizá dediquemos un post a todo eso algún dia ;-)

Fijémonos ahora en la clase B. La clase B consta de un array de dos ints. Los arrays en C++ se guardan consecutivos en memoria, por lo que, si tuivesemos (que no es el caso) un objeto de tipo B que empezase en la dirección 0x1000, enonces los primeros 4 bytes (0x1000-0x1003) contendrían el primer elemento del array b[0] y los 4 siguientes (0x1004-0x1007) contendrían el segundo elemento (b[1]).

Bien, la línea pb->b[1] = 100 lo que hace es colocar el valor 100 en el elemento b[1] del contenido de pb. En este caso pb apunta a la misma dirección que pa que apunta a la dirección donde empieza un objeto A. En el ejemplo que estamos siguiendo pa valía 0x1000, pb vale lo mismo (apuntan a la misma dirección), por lo que pb->b[1] modifica los bytes que van de 0x1004 a 0x1007… que equivalen justo a los bytes que guardan la propiedad a2 del objeto A que está en esta dirección de memoria. Es por eso que la última línea de código imprime 100 por pantalla. En el mundo real esto no suele usarse demasiado pero me interesa que te quede claro que un puntero nos permite modificar el contenido que empieza en una dirección de memoria tratando dicho contenido como si fuese de un tipo determinado. Lo suyo es que realmente en dicha dirección de memoria empiece el contenido del tipo indicado por el puntero, ya que si no lo más habitual es que tengamos problemas.

Bien, si tenemos claro lo que es y que contiene un puntero (una dirección de memoria) estamos listos para entender la aritmética de punteros. Tomemos ahora el siguiente código:

	int a = 42;
	int *pa = &a;
	std::cout << pa << "\n";
	pa++;
	std::cout << pa << "\n";

Este código toma la dirección de una variable int (a) y la guarda en el puntero pa. Luego imprime el valor del puntero (es decir la dirección de memoria donde empieza dicha variable a), luego incrementa en uno el valor del puntero y finalmente imprime el valor del puntero de nuevo (la nueva dirección de memoria a la que ahora apunta el puntero. La pregunta es, si suponemos que el primer std::cout imprime 0x1000 ¿qué valor imprimirá el segundo std::cout? 

¿Ya te lo has pensado? Bien, si has respondido 0x1001, lamento decirte que te has equivocado. El valor de pa despues de incrementarlo en uno no es el del siguiente byte, si no el del primer byte en el cual puede empezar el siguiente entero, suponiendo que en 0x1000 empieza uno. Es decir, si los ints ocupan 4 bytes, el valor de pa después de incrementarse en uno, no es 0x1001 sino 0x1004. Porque si en 0x1000 (valor de pa antes de incrementar) empieza un int, y los ints ocupan 4 bytes (sizeof(int) en mi plataforma) entonces el siguiente int puede empezar en 0x1004 (porque el int apuntado por pa ocupa los bytes que van desde 0x1000 hasta 0x1003).

La aritmética de punteros se resume en esto: dado un puntero de tipo X, se le puede restar o sumar una cantidad entera N, y eso desplaza el puntero un número de bytes que es sizeof(X)*N (sumar desplaza el puntero hacia adelante y restar lo hace hacia atrás).

lvalues y rvalues

Vamos a introducir ahora dos conceptos que son simple terminología, pero que se usan mucho en C++: los conceptos de lvalue y rvalue. De hecho a veces el compilador escupe warnings y errores que contienen esas palabrejas o sea que está bien saber al menos que son, porque realmente es muy sencillo.

Imagina el siguiente código:

	int a = 32;
	int* pa = &a;
	&a = 1000;
	32 = a;

Todos entendéis ya lo que hacen las dos primeras líneas pero… ¿y las dos últimas? Bueno, ya os lo digo yo, no os devanéis los sesos: no compilan, Es lógico, ¿no? La tercera línea intenta modificar el valor de la dirección de memoria donde empieza la variable a, lo que equivaldría (si eso fuese posible) a mover la variable en la memoria del PC. Mover variables y objetos en la memoria (lo que se conoce como relocate en inglés) no es posible en C++ (bueno… existe algo llamado move semantics pero no vamos a verlo). Por último la cuarta línea intenta asignar el valor de a… a una constante, cosa que todos vemos claro que no es posible (C++ puede ser raro pero no tanto).

Pues bien, ahora las palabrejas: a y pa son lvalues mientras que 32 y &a son rvalues. Es decir se llama lvalue a todo aquello que puede aparecer a la izquierda de una asignación (es decir todo aquello a lo que se le puede asignar un valor) mientras que llamamos rvalue a todo aquello que sólo puede aparecer a la derecha de una asignación. Las constantes son rvalues y las direcciones de memoria también lo son.

Punteros y arrays

Fantástico… ahora que ya conocemos los conceptos de rvalue y lvalue ya podemos empezar a usarlos y así parecer desarrolladores de C++ de verdad. Miremos el siguiente código:

	int a[2];
	a[0] = a[1] = 42;
	int b[2];
	b[0] = b[1] = 32;
	a = b;

La pregunta sería… ¿cuál es el valor de a[0] y a[1] después de ejecutar este código?

¿Ya lo has meditado? Si has dicho 32 lamento decirte que estás equivocado. Y si has dicho 42 pues también. La verdad es que este código no compila. Y la razón es que una variable que sea un array no es un lvalue. Y eso es porque en C++ el nombre de un array (en este caso, a o b a secas) contiene la dirección de memoria en la cual empieza dicho array.

¡Sí! Eso significa que puedes asignar un array a un puntero de forma directa:

	int a[2];
	a[0] = a[1] = 42;
	int* pa = a;

Este código compila y es totalmente correcto. Fíjate que asignamos pa (un puntero a int) sin usar &a. No tenemos que usar el operador & para obtener la dirección de memoria de nada, porque la variable a ya contiene dicha dirección de memoria. Y después de la asignación el puntero pa también la contiene…

… Y sí. Eso significa que podemos acceder a través del puntero a los contenidos del array. Recuerda además que los arrays se guardan consecutivos en memoria por lo que podemos ir incrementando un puntero para recorrer un array (recuerda que al incrementar el puntro nos desplazaremos cada vez tantos bytes como ocupa cada elemento del array, por lo que el puntero quedará apuntando, después de cada incremento, en la dirección de memoria donde empieza el siguiente elemento):

	int a[4] = { 1, 2, 3, 0 };
	int* pa = a;
	int sum = 0;
	while (*pa != 0) {
		sum += *pa;
		pa++;
	}
	std::cout << sum;

Este código recorre los elementos del array a y los va sumando, dejando el total en sum (dicho código imprime 6). Un tema importante es que en C++ los arrays no tienen ningún indicador del número de elementos que tienen. Es decir dado un array no hay manera de preguntarle cuantos elementos tiene (no hay ninguna propiedad tipo length o Count). Por eso en C++ se suele usar la técnica del «valor marcador» que es que se va recorriendo la memoria hasta encontrar un valor determinado, que en este caso es 0 (otra técnica que se usa es la de pasar como parámetro la longitud del array).

En nuestro caso, eso significa que si modificas la primera línea para que sea:

    int a[4] = { 1, 0, 3, 0 };

El valor de sum al final será 1, porque a pesar de que el array a tiene 4 elementos (1, 0, 3, 0) nuestro código deja de sumar tan buen punto encuentra el primer 0 (fíjate que la condición de salida del while es cuando el contenido de pa sea cero).

Esta dualidad arrays/punteros es muy importante en C++ y se usa continúamente, de hecho se dice que en C++ los punteros y los arrays son equivalentes. Eso no significa que sean la misma cosa, eso significa que se puede usar un puntero para recorrer y acceder a los elementos de un array, y que se puede pasar un array de tipo X a una función que espere un puntero de tipo X. De hecho el siguiente código compila correctamente:


int add_all(int* pa, int marker) {
	auto sum = 0;
	while (*pa != marker) {
		sum += *pa;
		pa++;
	}
	return sum;
}
int main(int argc, wchar_t* argv[])
{
	int a[4] = { 1, 2, 3, 0 };
	auto sum = add_all(a, 0);
	std::cout << sum;
}

Punteros a constantes

Los punteros habilitan el paso por referencia y en el post anterior vimos como el paso por referencia permitía a un método modificar el valor de un argumento. Pero la verdad es que la causa principal de la existencia del paso por referencia no es permitir a un método modificar un argumento, eso es más bien un efecto colateral. La razón principal del paso por referencia (y de la existencia de los punteros) es rendimiento. Imagina que tienes un método que recibe un objeto de una clase B. Supón que dicha clase declara varios campos, por lo tanto el objeto B ocupa bastante memoria. Pongamos que sizeof(B) es 1024. Eso significa que cada objeto B ocupa 1KB de memoria. Imagina ahora un método que recibe un objeto B como parámetro. En el post anterior vimos como pasar un objeto, pasaba realmente una copia de él. Si pasamos por valor un objeto que ocupa 1KB cada vez que llamemos al método se tiene que clonar un objeto que ocupa 1KB de memoria. Puede parecer poco pero ya te digo que muy eficiente no es si estás haciendo eso constantemente.

En cambio un puntero ocupa muy poco. Por ejemplo en x86 un puntero ocupa 4 bytes y en x64 son 8 bytes. En plataformas más antiguas los punteros ocupan incluso menos (1 ó 2 bytes). Es mucho más rápido copiar un puntero (4 ó 8 bytes no más) que un objeto grande. Esa es la razón principal de que existan. Por eso lenguajes como C# o Java pasan automáticamente por referencia los objetos, simplemente porque es más eficiente. El qué luego puedas modificar su contenido es, insisto, un efecto colateral, que algunas veces es deseado pero otras no.

En C# o en Java no hay manera de evitar que un método que recibe un objeto modifique el valor de alguna propiedad de dicho objeto. Para evitarlo los desarrolladores deben trabajar con interfaces, pero muchas veces eso o no es posible o consume demasiado tiempo. En C++ hay una técnica más sencilla: una función puede declarar que recibe un puntero a constante. Entonces esta función puede leer los datos apuntados por el puntero (el objeto) pero no puede modificarlos.

Pongamos como ejemplo la función add_all que hemos visto antes. Esta función recibe un puntero a int, y lo va incrementando mientras no lea un valor determinado (parámetro marker) y devuelve la suma de los resultados. El código era el siguiente:

int add_all(int* pa, int marker) {
	auto sum = 0;
	while (*pa != marker) {
		sum += *pa;
		pa++;
	}
	return sum;
}

Esta función puedo llamarla pasándole un array de ints (tal y como hemos visto en el punto anterior). Pero como alguien a quien llama a dicha función, ¿qué garantía tengo de que la función no modifica los datos que se le pasen? Pues la verdad es que ninguna. Por supuesto el desarrollador de add_all puede meterlo en un comentario o yo puedo ir y mirar el código fuente (si lo tengo) pero ninguna de esas soluciones es satisfactoria. La solución pasa porque add_all declare que recibe un puntero a constante:

int add_all(const int* pa, int marker) {
	auto sum = 0;
	while (*pa != marker) {
		sum += *pa;
		pa++;
	}
	return sum;
}

Fíjate que tan solo hemos añadido la palabra clave const en la firma de la función. Es decir, antes el parámetro pa era «un puntero a int» y ahora es «un puntero a const int». Si ahora desde el código de add_all se intenta modificar el contenido de pa, el código no compilará. P. ej. si intento colocar una línea que sea algo como *pa = 0, el código dejará de compilar.

El tema de los punteros a constantes funciona incluso en objetos complejos aunque eso lo veremos quizá en un post futuro. Pero la idea es la siguiente: si una función declara un parámetro que es un puntero a constante (const X*) entonces la función no puede modificar el contenido de dicho parámetro.

Para finalizar solo comentar que no debes confundir un puntero a constante (const X*) con un puntero constante (X* const) que son distintas. Un puntero constante (X* const) es aquel en el que no puedes modificar el puntero (pero si el contenido). Es decir no puedes desplazarlo. Y por supuesto se pueden tener punteros constantes a constantes (const X* const).

Bueno… en el siguiente post entraremos en el gran meollo de los punteros: la gestión de memoria ;-)