C++ Punteros I

[forCode]

Los punteros son a la vez una de las características más potentes y más temidas de C++. De hecho lenguajes como Java y C# los han eliminado sustituyéndolos por las referencias. ¿Pero, de verdad son tan peligrosos? En esta serie de posts veremos exactamente como funcionan los punteros en C++ y que debemos tener presente al trabajar con ellos para evitar problemas.

Paso por valor vs paso por referencia

Cuando pasamos argumentos a un método podemos distinguir entre «paso por valor» y «paso por referencia». En el primer caso el método recibe una copia del argumento pasado, por lo que si lo modifica, dichas modificaciones se pierden al salir del método, puesto que son modificaciones realizadas sobre la copia. En el segundo caso, el método recibe una referencia al argumento por lo que modificaciones realizadas a éste dentro del método persisten al salir.

C++ tiene dos mecanismos básicos para permitir el paso por referencia: el uso de punteros y el uso de referencias. No vamos a ver nada de referencias por el momento, aunque no son muy complicadas de entender una vez se entienden los punteros. El uso de punteros es el más general y es el que vamos a tratar en esta serie de posts

#include <iostream>;
void foo(int i) {
    i = i + 1;
}

int main(int argc, wchar_t* argv[])
{
    auto i = 10;
    foo(i);
    std::cout <<; i;
}

Se podría esperar que dicho programa imprimiese 11 como resultado pero no es así. Imprime el valor de 10. Probablemente 10 es la respuesta que pensabas si vienes de Java o C# ya que esos lenguajes se comportan igual. Eso es porque la variable i (con valor inicial a 10) se pasa por valor, por ello, el parámetro i del método foo es una copia y la modificación que efectuamos sobre dicho parámetro (sumarle uno) se pierde al salir de la función foo.

Para forzar el paso por referencia podemos usar un puntero. Para ello debemos modificar la declaración de foo indicando que ahora no espera un int si no un «puntero a int»:

void foo(int* i) {
    *i = *i + 1;
}

Podemos ver como el parámetro ha cambiado de tipo int a tipo int*. El asterisco final denota «puntero a «, por lo que int* es un puntero a int. Es decir, un puntero que apunta a un valor cuyo tipo es int.

El código también cambia, usando también el *. En este contexto el operador (unario) * es el operador de «dereferenciación». Dicho operador siempre toma un puntero y devuelve el valor al que apunta. Es decir, el puntero i apunta a un valor int, pues para obtener dicho valor debemos usar *i. Por ello la línea *i = *i + 1, se lee como «el valor al que apunta i, pasa a ser el valor al que apunta i sumándole uno».

Ahora la función foo acepta un puntero a int, es por ello que no podemos llamar dicha función pasándole un valor int. Debemos pasarle un puntero:

    auto i = 10;
    foo(&i);

Ahora hemos usado el operador (unario) &. Dicho operador es el operador de «referenciación» y hace precisamente lo contrario que el operador de dereferenciación: toma un valor y devuelve su dirección de memoria, o lo que es lo mismo, un puntero.

Paso de objetos

En C++ los objetos pueden ser pasados por valor o por referencia exactamente igual que los tipos simples:

class Beer {
public:
	char name[128];
	Beer()
	{
		strcpy_s(name, "");
	}
};

void foo(Beer beer) {
	strcpy_s(beer.name, "Estrella");
}

int main(int argc, wchar_t* argv[])
{
	auto beer = Beer();
	strcpy_s(beer.name, "Voll damm");
	foo(beer);
	std::cout << beer.name;
}

¿Cual es la salida de ese programa? Antes de responder piénsalo bien. Bien… si has respondido «Estrella» lamento decirte que te has equivocado… lo que es muy posible si vienes de lenguajes como C# o Java, ya que en estos el código equivalente imprimiría «Estrella». La razón es que en Java o en C# los objetos se pasan por referencia de forma automática. No es posible en esos lenguajes pasar un objeto por valor, es decir pasar una copia de dicho objeto. Pero en C++ los objetos al igual que los tipos simples (como int) se pasan por valor. Es decir, C++ no hace una distinción «artificial» entre lo que se pasa por valor y por referencia. Por ello el parámetro beer de la función foo tiene una copia del objeto beer y cuando modifica el nombre a «Estrella» modifica el nombre de la copia… pero el objeto original sigue teniendo el nombre «Voll damm». Efectivamente, mientras se ejecuta foo hay dos objetos creados: la copia y el original.

Por supuesto podemos declarar que nuestra función foo acepta un puntero para permitir el paso por referencia:

void foo(Beer* beer) {
    strcpy_s((*beer).name, "Estrella");
}

Fíjate como ahora de nuevo el parámetro es Beer* (puntero a Beer) en lugar de Beer. También debemos usar el operador de dereferenciación para acceder al valor del puntero beer y un vez tenemos el valor a la propiedad name. Los paréntesis son obligatorios debidos a la prioridad de operadores. Pero nunca o casi nunca vas a ver esa sintaxis *(x).y en código C++ (o casi nunca, hay gustos para todo). No es que sea incorrecta, pero es que C++ tiene un atajo equivalente:

void foo(Beer* beer) {
    strcpy_s(beer->name, "Estrella");
}

El operador -> es este atajo y significa «dereferencia el puntero para obtener su valor y accede a la propiedad de dicho valor (que debe ser un objeto)». Se puede ver que la sintaxis es mucho más cómoda y por ello es la que vas a ver siempre.

Por supuesto al llamar al método foo que ahora acepta un puntero a Beer usaremos de nuevo el operador de referenciación (&):

    auto beer = Beer();
    strcpy_s(beer.name, "Voll damm");
    foo(&beer);

Declaración de punteros

Hasta ahora hemos usado siempre el operador & para pasar un puntero a una función a partir de un objeto, pero un puntero es un tipo de datos y podemos crear variables que sean punteros:

    auto beer = Beer();
    Beer* pbeer;
    strcpy_s(beer.name, "Voll damm");
    pbeer = &beer;
    foo(pbeer);
    std::cout << beer.name;

En este caso declaramos un puntero a Beer llamado pbeer. La línea pbeer = &beer lo que hace es asignar a dicho puntero la dirección de memoria de la variable beer. Eso es lo que guardan los punteros: direcciones de memoria. Así este código es totalmente equivalente al anterior.

Así pues el resumen seria que un puntero es una variable que guarda una dirección de memoria y que nos habilita el paso por referencia. Fíjate que deliberadamente no he dicho que un puntero «apunta a un objeto» porque debemos encargarnos nosotros de que el puntero apunte a una dirección de memoria donde haya realmente un objeto o un valor válido (usando el operdaor &). Tener un puntero que apunta a una dirección errónea se conoce como dangling pointer y es uno de los errores más difíciles de detectar.

En el siguiente post hablaremos de la aritmética de punteros, la relación entre punteros y arrays en C++ y punteros constantes.