C++ Punteros V: Referencias

[forCode]

En los cuatro posts anteriores de esa serie hemos dado un vistazo a los punteros y a sus principales características… Pero C++ incorpora otro mecanismo que parece lo mismo pero no lo es: las referencias. Vamos a ver en qué consisten y por qué narices las incorporaron en el lenguaje.

¿Por qué solo con punteros no es suficiente?

Antes de nada veamos qué es una referencia en C++ y luego nos embarcaremos en la tarea de responder a la pregunta del título. Las referencias son variables que contienen una dirección de memoria, igual que los punteros. Pero a diferencia de los punteros, las referencias no pueden ser inicializadas a un valor arbitrario y, más importante, se referencian y dereferencian automáticamente: no es necesario usar los operadores unarios * ó &.

Veamos un ejemplo:

	int all = 42;
	int& refall = all;
	refall = 30;
	std::cout << "all:" << all;

La variable all es un int con valor 42 y la variable refall es una referencia a dicha variable. Fíjate que el tipo de refall es int&. Del mismo modo que int* se leía como «puntero a int», int& se lee como «referencia a int». Las diferencias con los punteros son dos:

  1. No usamos el operador & para asignar la dirección de memoria a la referencia (en un puntero haríamos int* pall = &all). El operador de referenciación (unario &) es llamado automáticamente.
  2. Cuando modificamos el valor de la referencia estamos realmente modificando el valor de su contenido. En un puntero tendríamos que hacer *pall = 30, pero en una referencia no.

Otra diferencia con los punteros es que las referencias deben ser inicializadas a un valor cuando se declaran. Es decir el siguiente código no es correcto:

	int all = 42;
	int& refall;
	refall = 30;

El compilador nos dará un error en la segunda línea, indicándonos que debemos inicializar refall (eso no ocurre con los punteros, que pueden declararse e inicializarse de forma separada).

Al igual que los punteros, las referencias nos permiten el paso por referencia. Una función puede declarar que ciertos parámetros son referencias:

class Beer {
public:
	char name[32];
	Beer() {
		strcpy(name, "");
	}
};

void renameBeer(Beer& beer) {
	std::cout << "Old name:" << beer.name << "\n";
	strcpy(beer.name, "Heineken");
}

int _tmain(int argc, _TCHAR* argv[])
{
	Beer beer = Beer();
	strcpy(beer.name, "Voll Damm");
	renameBeer(beer);
	std::cout << "New name:" << beer.name << "\n";
	return 0;
}

Observa que la función renameBeer recibe una referencia a una Beer. Por lo tanto puede modificar el objeto pasado (en este caso asignarle un nuevo nombre) exactamente igual que como lo haría si recibiese un puntero. Pero fíjate como el código de renameBeer trata el parámetro beer como si fuese un objeto (usa beer.name y no beer->name). Del mismo modo cuando llamamos al método renameBeer pasamos directamente la variable beer y no su dirección (no usamos &beer como deberíamos hacer si la función renameBeer declarase un puntero). Es decir, la referencia nos permite pasar un objeto por referencia pero mantener la ilusión de que lo estamos pasando por valor. Esto simplifica la sintaxis a costa de «ocultar» el hecho de que la función que recibe la referencia puede modificar el valor del parámetro recibido (como en nuestro ejemplo). Por supuesto, se pueden declarar referencias a constante (const Beer&) que impiden que quien use dicha referencia pueda modificar su contenido.

Ahora que ya sabemos que son las referencias, ya podemos intentar responder la pregunta de porque son necesarias. Es decir que nos aportan que no fuese posible con los punteros. Pues la respuesta tiene que ver con la ortogonalidad del lenguaje. C++ intenta ser un lenguaje altamente ortogonal, es decir que no haya diferencias entre los tipos definidos con el lenguaje y los que nos creemos nosotros: todo lo que se puede hacer con unos debe ser posible hacerlo con los otros y manteniendo la sintaxis. Es por ello que C++ permite la sobrecarga de operadores y es por ello que se han añadido las referencias al lenguaje.

Imagina que tenemos una clase Vector2D que nos representa un vector bidimensional. Los vectores son entidades matemáticas muy bien definidas, y tienen definidos varios operadores matemáticos. Es decir si v1 y v2 son dos vectores entonces la expresión v3 = v1+v2 es válida y el resultado es otro vector. Y C++ nos quiere permitir que del mismo modo que una expresión c = a+b es correcta si c,a y b son, pongamos, ints, la misma expresión tiene que ser correcta en el caso de que c, a y b sean Vector2D, una clase creada por nostros.

De hecho eso es posible sin usar ni punteros ni referencias. Dada la siguiente clase:

class Vector2D {
public:
	int x;
	int y;
	Vector2D(int x, int y) {
		this->x = x;
		this->y = y;
	}

	inline Vector2D operator+(Vector2D other)
	{
		return Vector2D(x + other.x, y + other.y);
	}
};

Podemos usar el siguiente código para sumar dos objetos Vector2D:

	auto a = Vector2D(2, 3);
	auto b = Vector2D(5, 6);
	auto c = a + b;

La variable c es un Vector2D con el resultado de sumar los valores de a y b. ¿Entonces porque necesitamos las referencias si, sin ellas, obtenemos la sintaxis deseada? Pues porque es cierto que obtenemos la sintaxis deseada pero fíjate que estamos usando el paso por valor. Es decir lo que ocurre cuando hacemos c = a+b realmente es:

  • La llamada a+b es traducida por el compilador al código a.operator+(b)
  • Se crea una copia de b que es la que es pasada al parámetro other operator+
  • Dentro de operator+ se crea un nuevo Vector2D que es devuelto por valor.
  • Se crea una copia del valor devuelto que es asignado a la variable c.

Por lo tanto en este proceso dos objetos Vector2D han sido copiados para luego ser olvidados. Eso en una clase como Vector2D que es muy pequeña no es mayor problema, pero en una clase cuyos objetos sean grandes (imagina que en lugar de un vector bidimensional tus objetos son matrices de 100×100) no nos podemos permitir todas esas copias adicionales. Tenemos que usar el paso por referencia, simple y llanamente por rendimiento).

En este caso podríamos optar por usar punteros en la definición de operator+:

	inline Vector2D* operator+(Vector2D* other)
	{
		return new Vector2D(x + other->x, y + other->y);
	}

El problema ahora lo tenemos en como debemos usar esta función:

auto c = a + &b;

Este código no es muy coherente. Además ojo porque c ahora ya no es un Vector2D si no un Vector2D*, y por lo tanto debemos usar el operador -> para acceder a sus miembros (c->x en lugar de c.x). Existe otro problema y es que el Vector2D es creado con new con lo que bueno… debemos acordarnos de liberarlo (delete c) cuando lo hayamos usado. Una sintaxis más coherente sería:

auto c = &a + &b;

Esta es mejor que la anterior, ya que sumamos dos Vector2D* y el resultado es un tercer Vector2D*, pero olvídate: no es posible en C++ conseguir esa sintaxis. Los punteros son un tipo integral del lenguaje y así como la suma entre un puntero y un escalar entero está definida (aritmética de punteros) la suma de dos punteros ni lo está ni se puede definir. Usar punteros es un callejón sin salida.

Pero por suerte, ahora conocemos las referencias… así que podemos intentar usarlas para solucionar nuestro problemilla:

Vector2D& operator+ (Vector2D& one, Vector2D& two) {
	auto v = new Vector2D(one.x + two.x, one.y + two.y);
	return *v;
}

Usando esta definición (observa que he movido operator+ desde del interior de la clase Vector2D hacia una función global fuera de la clase. C++ me permite declarar un operador como miembro de una clase o como una función global. Cada opción tiene sus ventajas), consigo la sintaxis deseada y el paso por referencia. Ahora puedo hacer auto c = a+b y usar c como un Vector2D (es decir usar c.x) que era nuestro objetivo. Tan solo tengo un problema: un memory leak. El resultado devuelto por operator+ es un objeto creado mediante new, lo que significa que alguien tiene que destruirlo. Pero para destruirlo se necesita un puntero a dicho objeto y c es una referencia, por lo que no puedo usar delete… no voy a poder librerar esa memoria.

La realidad es que no podemos devolver un objeto creado mediante new, debemos devolver un objeto de la pila. Pero no podemos devolver un puntero o referencia a un objeto de la pila porque ese se destruye al salir de operator+. Así no nos queda otra: debemos devolver el resultado por valor:

Vector2D operator+ (Vector2D& one, Vector2D& two) {
	auto v = Vector2D(one.x + two.x, one.y + two.y);
	return v;
}

Ahora podemos seguir usando la sintaxis habitual (Vector2D c = a + b) y tanto a como b se pasan por referencia. La única copia es la que ocurre des de el objeto devuelto por operator+ (v) hacia la variable final (c), ya que el objeto es devuelto por valor.

En general la regla es que si un operador entre dos valores devuelve un tercer valor, los dos valores pueden pasarse por referencia, pero el valor devuelto debe devolverse por valor. Por otro lado si un operador entre dos valores modifica uno de ellos ambos valores pueden pasarse por referencia y si el valor de retorno es el valor modificado puede devolverse por referencia también.

Un ejemplo de esa última sentencia, sería el operador +=. Una posible implementación sería:

	inline Vector2D& operator+=(const Vector2D& other) {
		x = x += other.x;
		y = y += other.y;
		return (*this);
	}

Observa que este método estaría en la clase Vector2D. Esto nos permite devolver *this pero por referencia. Esto es seguro porque sabemos que despues de finalizar operator+= el objeto apuntado por this sigue existiendo, por lo que es seguro. Vale, ahora veamos un tema interesante:

	auto a = Vector2D(2, 3);
	auto b = Vector2D(5, 6);
	auto c= a += b;
	// En este punto a.x = 7 y a.y = 9.
	c.x = 100;

Recuerda que el valor devuelto por operator+= es una referencia a *this. Es decir una referencia al propio a. De hecho antes de ejecutar la última línea tenemos que a.x = 7 al igual que c.x obviamebte. La pregunta es qué valor tendrá a.x después de ejecutar c.x = 100;. ¿O dicho de otro modo contienen o referencian a y c el mismo objeto?

Pues la respuesta es depende. Y depende de como declares el tipo de la variable c. Si el tipo de la variable c la declaras como auto (inferido por el compilador) o bien como Vector2D entonces c y a contienen objetos distintos (obviamente: una variable Vector2D contiene un objeto. Dos variables Vector2D contienen un objeto cada una de ellas). Por supuesto en este caso ha habido una copia del objeto a hacia el objeto c. El causante de esta copia es que en operator+= devolvemos un Vector2D& pero si c lo declaramos de tipo Vector2D, entonces se copia el objeto referenciado, porque una variable de tipo Vector2D siempre contiene un objeto. Si usamos auto ocurre lo mismo porque en este caso la palabra clave auto, define la variable de tipo Vector2D. Pero si en lugar de auto (o Vector2D) definieras c como una referencia a Vector2D (Vector2D&):

	Vector2D& c= a += b;
	c.x = 100;

Ahora c es una referencia a Vector2D y realmente c referencia al mismo objeto que a (lógico pues desde el operator+= devolvíamos *this que es precisamente el objeto a.

O sea que cuando asignas un valor T& a una variable de tipo T se crea una copia del objeto referenciado por el valor T& de forma automática.

Vale… lo vamos a dejar aquí por el momento. Hemos visto lo básico de las referencias y la motivación de su existencia. En sucesivos posts veremos más usos de las referencias (el constructor de copia) así como interesantes relaciones entre referencias y punteros (efectivamente las referencias son variables, entonces que nos impide tener un puntero a… una referencia? Pues eso ;-)).

Hasta entonces… sed buenos! :D