C++ Classes IV

[forCode]

La programación orientada a objetos se basa en el concepto de objeto, y define las tres características fundamentales que deben tener todos los objetos: identidad, estado y comportamiento. C++ es un lenguaje que soporte al paradigma de orientación a objetos a través del concepto de clase (de forma similar a C# y Java) así que en este post veremos como los objetos de C++ cumplen con las tres características fundamentales.

Estado

En C++ el estado de un objeto viene dado por el valor de todas sus variables internas. Supongamos la siguiente clase:

class Beer
{
public:
	std::string name;
	float abv;
	int ebc;
	int ibus;
};

El valor de los campos name, abv, ebc ibus nos da el estado de cada objeto que creemos:

auto beer = Beer();
beer.name = "Heineken";
beer.ebc = 2;
beer.abv = 4.0;
beer.ibus = 10;

El estado del objeto beer es que su nombre es «Heineken», su color (ebc) es 2 (pálido), su graduación alcohólica (abv) es 4 y su amargor (ibus) es 10 (poco amarga). En todo momento un objeto tiene un estado concreto. Obviamente el estado de un objeto es cambiante con el tiempo.

Cuando hablamos de estados de objetos debemos conocer el concepto de invariantes de clase (o de objeto). Los invariantes son condiciones que deben cumplirse siempre durante todo el ciclo de vida del objeto. Un ejemplo de invariante podría ser «El nombre de una cerveza no puede ser cadena vacía». Este invariante significa que hacer lo siguiente:

beer.name = "";

Es decir, asignar la cadena vacía al valor de name es incorrecto. Algunos lenguajes permiten definir los invariantes al crear una clase y detectar incluso algunos de ellos en tiempo de compilación. Pero C++ no tiene soporte para invariantes. En este caso debemos añadir nosotros código para validar los valores asignados a name y hacer lo que corresponda si se rompe el invariante (decidir el qué hacer, corre por nuestra parte). Así deberíamos convertir name en una variable privada y añadir los métodos setName y getName:

class Beer
{
	std::string _name;
public:
	float abv;
	int ebc;
	int ibus;
	std::string getName();
	void setName(std::string newName);
};

// En el fichero .cpp
std::string Beer::getName() {
	return _name;
}
void Beer::setName(std::string newName) {
	if (newName != "") {
		_name = newName;
	}
}

En este caso optamos por ignorar el cambio de nombre si se intenta establecer el nombre a cadena vacía… Podríamos lanzar un error, o hacer otra cosa… eso ya depende de nosotros.

De este modo aseguramos cumplir el invariante… pero se nos escapa un caso. ¿Se te ocurre cual? Si no lo ves pregúntate cual es el nombre del objeto beer creado con el siguiente código:

auto beer = Beer();
beer.ebc = 2;
beer.abv = 4.0;
beer.ibus = 10;

Fíjate que en ningún momento establecemos el valor de name. En C++ las variables de una clase que no se establecen son inicializadas a su valor por defecto, que en el caso de std::string es una cadena vacía, por lo que rompemos el invariante. La solución es que debemos obligar a proporcionar un nombre al crear el objeto. Y esto lo conseguimos con un constructor parametrizado. El constructor puede tener parámetros:

class Beer
{
	std::string _name;
public:
	float abv;
	int ebc;
	int ibus;
	std::string getName();
	void setName(std::string newName);
	Beer(std::string initialName);
};
// En el fichero .cpp
Beer::Beer(std::string initialName)
{
	setName(initialName != "" ? initialName : "unknown beer");
}

Observa que usamos el operador ternario (?:) para usar el nombre «unknown beer» en el caso de que el usuario pasase la cadena vacía como parámetro al constructor.

Ahora para crear un objeto Beer debemos pasarle el nombre al constructor. En caso contrario el código no funciona.

auto beer = Beer("Heinken");

Identidad

Los objetos tienen todos ellos una identidad, es decir son «únicos» per se. Dos objetos pueden ser idénticos, pero no por ellos dejaran de ser dos objetos. Podemos tener dos heinekens, que tendrán el mismo nombre, color, alcohol y amargor… pero no por ello dejarán de ser dos heinekens!

auto beer = Beer("Heinken");
beer.ebc = 2;
beer.abv = 4.0;
beer.ibus = 10;

auto beer2 = Beer("Heineken");
beer2.ebc = 2;
beer2.abv = 4.0;
beer2.ibus = 10;

Podemos ver que tanto beer como beer2 tienen el mismo estado. Son dos objetos idénticos, pero siguen siendo dos objetos. La identidad en C++ viene dada por la dirección de memoria de un objeto. Dos objetos no pueden tener la misma dirección de memoria, incluso aunque, como en este caso, contengan los mismos datos. Lo podemos comprobar fácilmente:

bool same = &beer == &beer2;

El valor de same será true si el objeto beer y el objeto beer2 tienen la misma dirección de memoria (recuerda que el operador unario & (referenciación) devuelve la dirección de memoria de un objeto). En este caso, ya os lo digo, same vale false.

En C++ es posible que dos o más variables apunten al mismo objeto. Pero para ello hay una condición: todas (o todas menos una) de esas variable deben ser de tipo puntero o referencia. Las variables beer y beer2 no son ni referencia a Beer (Beer&) ni puntero a Beer (Beer*). Son simplemente objetos (Beer), por lo que ellas dos a la vez nunca van a poder apuntar al mismo objeto porque cada una de ellas es (contiene) un objeto.

Pero imaginemos ahora el siguiente código:

auto beer = Beer("Heinken");
// asignamos propiedades beer
Beer* beerp = &beer;
Beer* beerp2 = beerp;
Beer& beerr = beer;
Beer* beerpr = &beerr;

Ahora todas esas variables apuntan al mismo objeto. Pero observa que de todas ellas, solo una (beer) es de tipo Beer. El resto, o bien son punteros (Beer*) o bien son referencias (Beer&). Si colocamos un punto de ruptura y usamos el depurador podemos ver como todas las variables apuntan a la misma dirección de memoria (objeto):

clasesiv-img1

 

Comportamiento

El comportamiento de un objeto lo definen los métodos definidos en la clase. Los métodos definidos en una clase pueden acceder a las variables privadas de la clase.

Antes cuando hemos visto el ejemplo de invariantes, hemos introducido los métodos setNamegetName que introducían un comportamiento concreto: si se asigna un nombre vacío a una cerveza dicho nombre es ignorado y la cerveza continua con su nombre anterior.

El comportamiento va muy ligado con la reusabilidad de código y con el concepto de interfaz. La interfaz de una clase es todo aquello que es público en dicha clase. La interfaz define lo que puede hacerse con los objetos de una determinada clase. Está muy ligado al concepto de encapsulación (otro pilar de la programación orientada a objetos) y al que dedicaremos el siguiente post de esta serie.

Saludos!