Definición de clases en C++
Desgraciadamente, la división entre interfaz e implementación no es tan limpia en C++ como en el pseudocódigo. Las clases se definen en C++ mediante una construcción class
dividida en dos partes: una parte privada (private
) que contiene algunos detalles de la implementación, y una parte pública (public
) que contiene todo el interfaz.
class NombreClase {
private:
// implementación de la clase
// solamente los atributos
public:
// interfaz de la clase
};
En la parte privada de la construcción class
aparecen sólo los atributos de la clase y algunos tipos intermedios que puedan ser necesarios. En C++, la implementación de los métodos de la clase se facilita aparte. En la parte pública, suelen aparecer solamente las declaraciones (cabeceras) de los métodos de la clase. Por ejemplo, la siguiente es una definición de la clase CComplejo
que representa números complejos:
class CComplejo {
private:
// atributos
double real, imag;
// los métodos se implementan aparte
public:
void asigna_real(double r);
void asigna_imag(double i);
double parte_real();
double parte_imag();
void suma(const CComplejo& a, const CComplejo& b);
};
Los campos real
e imag
son los atributos de la clase y codifican el estado de un objeto de la clase CComplejo
. Puesto que los atributos están declarados en la parte privada de la clase, forman parte de la implementación y no es posible acceder a ellos desde fuera de la clase. Su acceso está restringido: sólo se puede acceder a ellos en la implementación de los métodos de la clase.
Los métodos que aparecen en la parte pública forman el interfaz de la clase y describen su comportamiento; es decir, las operaciones que podemos aplicar a un objeto del tipo CComplejo
. En particular, con estos métodos podemos asignar valores a las partes real e imaginaria, leer las partes real e imaginaria, y sumar dos números complejos.
Implementación de métodos en C++
Como comentamos anteriormente, la implementación de los métodos de una clase en C++ se realiza fuera de la construcción class {...}
. La sintaxis de la definición de un método es similar a la de la definición de una función (o procedimiento), excepto que el nombre del método debe estar precedido por el nombre de la clase de la que forma parte:
void CComplejo::asigna_real(double r) {
// cuerpo del método...
}
Como puede apreciarse, el método asigna_real
no recibe ningún argumento de tipo CComplejo
. ¿Cómo es posible entonces que este método sepa qué número complejo tiene que modificar? La respuesta es que todos los métodos de la clase CComplejo
reciben como argumento de entrada/salida implícito el complejo al que se va a aplicar el método. Surge entonces la siguiente pregunta: si este argumento es implícito y no le hemos dado ningún nombre, ¿cómo accedemos a sus atributos? La respuesta en este caso es que podemos referirnos a los atributos de este parámetro implícito simplemente escribiendo los nombres de los atributos, sin referirnos a qué objeto pertenecen. C++ sobreentiende que nos referimos a los atributos del argumento implícito. Así, el método asigna_real
se implementa como sigue:
void CComplejo::asigna_real(double r) {
real = r;
}
donde el atributo real
que aparece a la izquierda de la asignación es el atributo del argumento implícito. Incluso un método como parte_imaginaria
, que aparentemente no tiene argumentos, recibe este argumento implícito que representa el objeto al que se aplica el método:
double CComplejo::parte_imaginaria() {
return imag; // atributo imag del argumento implícito
}
Por otro lado, un método puede recibir argumentos explícitos de la clase a la que pertenece. Por ejemplo, el método suma
recibe dos argumentos explícitos de tipo CComplejo
. Al definir el método suma
, podemos acceder a los atributos de los argumentos explícitos utilizando la notación punto usual, como si se tratara de una estructura:
void CComplejo::suma(const CComplejo& a, const CComplejo& b) {
real = a.real + b.real;
imag = a.imag + b.imag;
}
Obsérvese que desde el cuerpo del método suma
podemos realizar accesos directos a los atributos de a
y b
. Esto es posible porque este método forma parte de la implementación de la clase y, por lo tanto, conoce cómo está implementada. Cuando escribimos el código de un método, adoptamos el papel de implementadores y por lo tanto tenemos acceso a todos los detalles de implementación de la clase.
Uso de clases en C++
Una vez que se ha definido e implementado una clase, es posible declarar objetos de esta clase y usarlos desde fuera de la clase como si se tratara de tipos predefinidos. En concreto, una variable de un tipo clase se declara como cualquier otra variable:
CComplejo a, b, s;
CComplejo v[10]; // array de 10 objetos CComplejo
Sobre un objeto de una clase sólo pueden aplicarse las siguientes operaciones:
- aquellas definidas en el interfaz de la clase
- la asignación
- el paso de parámetros, por valor o por referencia. A lo largo del curso siempre pasaremos los objetos por referencia. Si no queremos que el parámetro sea modificado, le añadiremos
const
a la definición del parámetro, tal y como aparece en la definición del métodosuma
Es muy importante recordar que como usuario de una clase es imposible acceder de forma directa a los atributos privados de un objeto de tal clase. Por ejemplo, no podemos inicializar el complejo a
de la siguiente forma:
a.real = 2; // ERROR: acceso no permitido al usuario
a.imag = 5; // ERROR: acceso no permitido al usuario
puesto que los atributos real
e imag
son privados. Si queremos inicializar el complejo a
, debemos hacerlo aplicando los métodos asigna_real
y asigna_imag
.
La aplicación de métodos a un objeto se realiza mediante paso de mensajes. La sintaxis de un mensaje es similar a la de la llamada a una función, excepto que el nombre del método va precedido por el nombre del objeto al que se aplica el método. Por ejemplo, para asignar 7
a la parte real del complejo a
, basta aplicar el método asigna_real
al objeto a
componiendo el mensaje:
a.asigna_real(7);
Aparte de la asignación y del paso de parámetros por referencia, toda la manipulación de los objetos por parte del usuario debe hacerse a través de paso de mensajes a los mismos. El siguiente código muestra un ejemplo de uso de la clase CComplejo
:
CComplejo a, b, s;
a.asigna_real(1);
a.asigna_imag(3);
b.asigna_real(2);
b.asigna_imag(7);
s.suma(a,b);
cout << s.parte_real() << ", " << s.parte_imag() << "i" << endl;
Constructores y destructores
Como todas las variables de C++, los objetos no están inicializados por defecto. Si el usuario declara objetos CComplejo
y opera con ellos sin asignarles un valor inicial, el resultado de la operación no estará definido:
CComplejo a,b,s; // no inicializados
a.suma(a,b);
cout << s.parte_real() << ", " // imprime basura
<< s.parte_imag() << "i"<< endl;
Los valores mostrados en pantalla por el ejemplo anterior son del todo imprevisibles. Una solución es inicializar los complejos explícitamente, como hicimos en el ejemplo de la sección anterior mediante los métodos asigna_real
y asigna_imag
:
CComplejo a,b,s;
a.asigna_real(1.0);
a.asigna_imag(2.0);
b.asigna_real(-1.5);
b.asigna_imag(3.5);
s.suma(a,b);
cout << s.parte_real() << ", "
<< s.parte_imag() << "i"<< endl;
Esta inicialización explícita, además de ser engorrosa, conlleva sus propios problemas. Por un lado, es posible que el programador olvide invocar todos los métodos necesarios para inicializar cada objeto que aparece en su programa; o bien que inicialice algún objeto más de una vez. Por otro lado, no siempre tendremos un valor adecuado para inicializar los objetos, de forma que su inicialización explícita resulta un tanto forzada.
Para paliar este problema, C++ permite definir un método especial llamado el constructor de la clase, cuyo cometido es precisamente inicializar por defecto los objetos de la clase. Para que nuestros números complejos estén inicializados, basta añadir un constructor a la clase CComplejo
de la siguiente manera:
class CComplejo {
private:
double real, imag;
public:
CComplejo(); // constructor
void asigna_real(double r);
void asigna_imag(double i);
double parte_real();
double parte_imag();
void suma(const CComplejo& a, const CComplejo& b);
};
La implementación del constructor se hace fuera de la construcción class
. En nuestro ejemplo, podríamos optar por inicializar los complejos a 1 + 0i
:
CComplejo::CComplejo(){
real= 1;
imag= 0;
}
Un constructor es un método muy diferente de todos los demás. En primer lugar, su nombre coincide siempre con el de la clase a la que pertenece (en nuestro caso, CComplejo
). Además, un constructor no es ni un procedimiento ni una función, y por lo tanto no tiene asociado ningún tipo de retorno (ni siquiera void
). Por último, el usuario nunca invoca un constructor de manera explícita. Esto no tendría sentido, pues de lo que se trata es de que los objetos sean inicializados de manera implícita por C++, sin intervención alguna por parte del usuario. Por ello, el constructor de una clase es invocado automáticamente justo después de cada declaración un objeto de esa clase. Siguiendo con nuestro ejemplo, en el código:
CComplejo a,b,s; // inicializados a 1+0i por el constructor
a.suma(a,b);
cout << s.parte_real() << ", " // imprime 2, 0i
<< s.parte_imag() << "i"<< endl;
El constructor CComplejo::CComplejo()
se invoca automáticamente 3 veces, para inicializar los objetos a
, b
y s
, respectivamente.
El constructor que hemos descrito anteriormente es el constructor por defecto. Se llama así porque los objetos son todos inicializados a un valor por defecto. Además del constructor por defecto, es posible asociar a una clase un constructor extendido en que se indiquen mediante argumentos los valores a los que se debe inicializar un objeto de la clase. Podemos añadir un constructor extendido (con argumentos) a la clase CComplejo
como sigue:
class CComplejo {
private:
double real, imag;
public:
CComplejo(); // constructor por defecto
CComplejo(double r, double i); // constructor extendido
void asigna_real(double r);
void asigna_imag(double i);
double parte_real();
double parte_imag();
void suma(const CComplejo& a, const CComplejo& b);
};
La implementación del constructor extendido es inmediata, basta emplear los argumentos para inicializar los atributos:
CComplejo::CComplejo(double r, double i) {
real = r;
imag = i;
}
Para que C++ ejecute de forma automática el constructor extendido, basta añadir a la declaración de un objeto los valores a los que desea que se inicialice:
CComplejo a; // inicializado por defecto
CComplejo b(1,5); // inicializado a 1+5i
CComplejo c(2); // ERROR: o dos argumentos o ninguno...
De la misma manera que C++ permite definir constructores para los objetos de una clase, también es posible definir un destructor que se encargue de destruir los objetos automáticamente, liberando los recursos que pudieran tener asignados:
class CComplejo {
private:
double real, imag;
public:
CComplejo();
CComplejo(double r, double i);
~CComplejo(); // destructor
void asigna_real(double r);
void asigna_imag(double i);
double parte_real();
double parte_imag();
void suma(const CComplejo& a, const CComplejo& b);
};
El nombre del destructor es siempre el de la clase a la que pertenece antecedido por el símbolo ~
(en nuestro ejemplo, ~CComplejo()
). Al igual que los constructores, los destructores se definen fuera de la construcción class {...}
y no tienen tipo de retorno alguno:
CComplejo::~CComplejo(){
// destruir el número complejo...
}
Es importante recordar que sólo puede definirse un destructor para cada clase, y que éste nunca toma argumentos. Además, el usuario nunca debe ejecutar un destructor de forma explícita. Los destructores son invocados automáticamente por C++ cada vez que un objeto deja de existir. Por ejemplo:
void ejemplo(){
CComplejo a; // inicializado por defecto
CComplejo b(1,5); // inicializado a 1+5i
//...
}
Al terminar de ejecutarse la función ejemplo
, el destructor CComplejo::~CComplejo()
es invocado automáticamente 2 veces para destruir los objetos locales a
y b
.
Los destructores se emplean típicamente en clases cuyos objetos tienen asociados recursos que se deben devolver al sistema. Durante este curso, los emplearemos sobre todo para liberar la memoria dinámica asignada a un objeto implementado con punteros.