Esta sección concluye nuestra introducción a C++. Presentamos conceptos orientados a objetos "reales" y constestamos la pregunta acerca de como se escribe realmente un programa en C++.
class Point3D : public Point { int _z; public: Point3D() { setX(0); setY(0); _z = 0; } Point3D(const int x, const int y, const int z) { setX(x); setY(y); _z = z; } ~Point3D() { /* Nada que hacer */ } int getZ() { return _z; } void setZ(const int val) { _z = val; } };
El tipo de herencia influye sobre los privilegios de acceso a elementos de las diversas superclases. Utilizando la herencia pública, todo lo que es declarado private (privado) en una superclase, permanece private en la subclase. En forma semejante, todo lo que es public (público) permanece public. Cuando se usa la herencia privada, las cosas son muy diferentes, tal como se muestra en la tabla 9.1.
La columna de la izquierda enlista los privilegios de acceso posibles para los elementos de clases. También incluye un tercer tipo protected (protegido). Este tipo se usa para elementos que deberían ser usados directamente en las subclases pero que no debería estar accesibles desde afuera. Así, uno podría decir que elementos de este tipo están entre privados y públicos en el sentido que pueden ser utilizados dentro de la jerarquía de clase cuya raíz está representada por la clase correspondiente.
La segunda y tercera columna muestran los privilegios de acceso resultantes de los elementos de una superclase cuando la subclase es derivada en forma privada y en forma pública, respectivamente.
Cuando creamos un objeto con
Point3D point(1, 2, 3);
es invocado el segundo constructor de Point3D. Antes de la ejecución del cuerpo del constructor, es invocado el constructor Point(), para inicializar la parte point del objeto point. Afortunadamente, hemos definido un constructor que no lleva argumentos. Este constructor inicializa las coordenadas de 2a. dimensión _x y _y a 0 (cero). Como Point3D se deriva solamente de Point no hay otras llamadas a constructores y el cuerpo de Point3D(const int, const int, const int) es ejecutado. Aquí, nosotros invocamos los métodos setX() y setY() para override explícitamente las coordenadas de 2a. dimensión. Subsecuentemente, es establecido el valor de la tercera coordenada _z.
Esto es muy poco satisfactorio porque hemos definido un constructor Point() que lleva dos argumentos para inicializar sus coordenadas con ellos. Así, debemos solamente poder decir que, en lugar de usar el constructor "de default" Point(), debería de usarse el parametrizado Point(const int, const int). Nosotros podemos hacer eso por medio de especificar los constructores deseados después de un signo de dos puntos ( : ) justamente antes del cuerpo del constructor Point3D():
class Point3D : public Point { ... public: Point3D() { ... } Point3D( const int x, const int y, const int z) : Point(x, y) { _z = z; } ... };
Si hubieran más superclases, simplemente proveeríamos sus llamadas a constructor como una lista separada por comas. Usamos también este mecanismo para crear objetos contenidos. Por ejemplo, supongamos que la clase Part solamente define un constructor con un argumento. Entonces, para crear correctamente un objeto de la clase Compound debemos invocar Part() con su(s) argumento(s):
class Compound { Part part; ... public: Compound(const int partParameter) : part(partParameter) { ... } ... };
Esta inicialización dinámica también puede ser usada con tipos de datos integrados. Por ejemplo, los constructores de la clase Point podrían ser escritos así:
Point() : _x(0), _y(0) {} Point(const int x, const int y) : _x(x), _y(y) {}
Deberías usar este método de inicialización tan seguido como sea posible, porque ésto permite al compilador crear variables y objetos correctamente inicializados en lugar de crearlos con un valor "de deafult" y usar una asignación adicional (u otro mecanismo) para establecer su valor.
class DrawableString : public Point, public DrawableObject { ... public: DrawableString(...) : Point(...), DrawableObject(...) { ... } ~DrawableString() { ... } ... };
No usaremos este tipo de herencia en el resto de este tutorial. Por ello no entraremos aquí en más detalles.
class DrawableObject { public: virtual void print(); };
La clase DrawableObject define un método print(), el cuál es virtual. De esta clase podemos derivar otras clases :
class Point : public DrawableObject { ... public: ... void print() { ... } };
Nuevamente, print() es un método virtual, debido a que hereda esta propiedad de DrawableObject. La función display() que es capaz de dibujar cualquier tipo de objeto desplegable, puede por tanto ser definida como :
void display(const DrawableObject &obj) { // prepare anything necessary obj.print(); }
Cuando se usan métodos virtuales, algunos compiladores se quejan si el destructor de la clase correspondiente no es declarado también virtual. Esto es necesario cuando se usan apuntadores a subclases (virtuales) cuando llega el momento de destruirlas. Debido a que el apuntador está declarado como superclase, su destructor normalmente sería llamado. Si el destructor es virtual, el destructor del objeto real referenciado es llamado (y entonces, en forma recursiva, todos los destructores de sus superclases). He aquí un ejemplo adoptado de [1]:
class Colour { public: virtual ~Colour(); }; class Red : public Colour { public: ~Red(); // Virtualidad heredada de Colour }; class LightRed : public Red { public: ~LightRed(); };
Usando estas clases, podemos definir una paleta del siguiente modo:
Colour *palette[3]; palette[0] = new Red; // Creación dinámica de un nuevo objeto Red palette[1] = new LightRed; palette[2] = new Colour;
El operador new recién introducido crea un nuevo objeto del tipo especificado en la memoria dinámica y regresa un apuntador a dicho objeto. Así, el primer new regresa un apuntador a un objeto reservado de la clase Red y lo asigna al primer elemento del arreglo palette. Los elementos de palette son apuntadores a Colour y, debido a que Red es-un(a) Colour, la asignación es válida.
El operador opuesto a new es delete que explícitamente destruye un objeto referenciado por el apuntador provisto. Si aplicamos delete a los elementos de palette tienen lugar las siguientes llamadas a destructor:
delete palette[0]; // Llama al destructor ~Red() seguido de ~Colour() delete palette[1]; // Llama a ~LightRed(), ~Red() y ~Colour() delete palette[2]; // Llama a ~Colour()
Las diversas llamadas a destructor tienen lugar solamente debido al uso de los destructores virtuales. Si no los hubiésemos declarado virtuales, cada delete solamente habrían llamado ~ Colour() (debido a que palette[i] es de tipo apuntador a Colour).
class DrawableObject { ... public: ... virtual void print() = 0; };
Esta definición de clase forzaría a que cada clase derivada de la que se crearían objetos, definiera un método print(). Estas declaraciones de métodos también son llamadas métodos puros.
Los métodos puros también deben ser declarados virtual(es), debido a que nosotros solamente queremos usar objetos de clases derivadas. Las clases que definen métodos puros son llamadas clases abstractas.
class Complex { double _real, _imag; public: Complex() : _real(0.0), _imag(0.0) {} Complex(const double real, const double imag) : _real(real), _imag(imag) {} Complex add(const Complex op); Complex mul(const Complex op); ... };
Entonces, podríamos hacer uso de números complejos y "calcular" con ellos :
Complex a(1.0, 2.0), b(3.5, 1.2), c; c = a.add(b);
Aquí, asignamos a c la suma de a y b. Aunque es totalmente correcto, no provee una manera conveniente de expresión. Lo que nosotros más bien quisiéramos usar es el muy familiar signo "+" para expresar la adición de dos números complejos. Afortunadamente, C++ nos permite sobrecargar casi todos sus operadores por tipos recién creados. Por ejemplo, podríamos definir un operador "+" para nuestra clase Complex:
class Complex { ... public: ... Complex operator +(const Complex &op) { double real = _real + op._real, imag = _imag + op._imag; return(Complex(real, imag)); } ... };
En este caso, hemos hecho del + un miembro de la clase Complex. Una expresión de la forma
c = a + b;
es traducida a una llamada a método
c = a.operator +(b);
Así, el operador binario + solamente necesita un argumento. El primer argumento es provisto implícitamente por el objeto invocante (en este caso a).
Sin embargo, una llamada a operador puede también ser interpretada como una llamada a función, como en
c = operator +(a, b);
En este caso, el operador sobrecargado no es un miembro de una clase. Está más bien definido afuera como una función sobrecargada normal. Por ejemplo, podríamos definir el operador + de esta manera:
class Complex { ... public: ... double real() { return _real; } double imag() { return _imag; } // ¡No hay necesidada de definir el operador aquí! }; Complex operator +(Complex &op1, Complex &op2) { double real = op1.real() + op2.real(), imag = op1.imag() + op2.imag(); return(Complex(real, imag)); }
En este caso, debemos definir métodos de acceso para las partes real e imaginaria debido a que el operador es definido afuera del ámbito de la clase. Sin embargo, el operador está tan cercanamente relacionado a la clase, que tendría sentido permitir al operador que accesara los miembros privados. Esto puede hacerse declarándolo friend (amigo) de la clase Complex.
Nosotros podemos definir que funciones o clases sean amigos de una clase para permitirles acceso directo a sus miembros de datos privados. Por ejemplo, en la sección anterior nos gustaría que la función para el operador + tuviese acceso a los miembros de datos privados _real e _imag de la clase Complex. Con ese fin declaramos que el operador + sea amigo de la clase Complex:
class Complex { ... public: ... friend Complex operator +( const Complex &, const Complex & ); }; Complex operator +(const Complex &op1, const Complex &op2) { double real = op1._real + op2._real, imag = op1._imag + op2._imag; return(Complex(real, imag)); }
No deberías usar amigos muy seguido debido a que rompen con el principio del aislamiento de datos en sus fundamentos. Si tienes que usar amigos muy seguido, es señal de que es el momento de reestructurar tu gráfica de herencia.
Gruesamente hablando, los módulos consisten de dos tipos de archivos : descripiciones de interface y archivos de implementación. Para distinguir estos tipos, se usa un conjunto de sufijos cuando se compilan programas de C y C++. La Tabla 9.2 muestra algunos de ellos.
En este tutorial usaremos .h para archivos de cabecera (header), .cc para archivos de C++ y .tpl para archivos de definición de plantillas (templates). Aún si "solamente" estamos escribiendo código en C, tiene sentido usar .cc para forzar al compilador a tratarlo como C++. Esto simplifica la combinación de ambos, desde el momento que el mecanismo interno de como el compilador arregla los nombres en el programa difiere en ambos lenguajes .
Después de una compilación exitosa, el conjunto de archivos objeto es procesado por un linker. Este programa combina los archivos, añade las bibliotecas necesarias y crea un ejecutable. Bajo UNIX este archivo es llamado a.out si no se especifica otro. Estos pasos se ilustran en la Figura 9.1.
Con los compiladores modernos, ambos pasos pueden ser combinados. Por ejemplo, nuestros pequeños programas de ejemplo pueden ser compilados y enlazados ("linkeados") con el compilador C++ de GNU del siguiente modo ("ejemplo.cc" es por supuesto solamente un nombre de ejemplo) :
gcc ejemplo.cc
Para evitar la inclusión de copias múltiples causadas por dependencias mutuas, usamos codificación condicional. El preprocesador también define instrucciones condicionales para checar varios aspectos de su procesamiento. Por ejemplo, podemos checar si una macro ya ha sido definida :
#ifndef MACRO #define MACRO /* define MACRO */ ... #endif
Las líneas entre #ifndef y #endif son incluídas solamente si MACRO no ha sido ya definida. Podemos usar este mecanismo para prevenir las copias múltiples :
/* ** Ejemplo para "checar" si un archivo de cabecera ya ha ** sido incluído. Asumamos que el nombre del archivo de cabecera ** es 'myheader.h' */ #ifndef __MYHEADER_H #define __MYHEADER_H /* ** Las declaraciones de la interface van aquí */ #endif /* __MYHEADER_H */
__MYHEADER_H es un nombre único para cada archivo de cabecera. Tu podrías querer seguir la convención de usar el nombre del archivo con dos subguiones como prefijo. La primera vez que el archivo es incluído__MYHEADER_H no está definido, así que cada línea es incluída y procesada. La primera línea solo define una macro llamada __MYHEADER_H. Si en forma accidental el archivo debería ser incluído una segunda vez (mientras se procesa el mismo archivo de entrada), __MYHEADER_H es definida, así, todo lo que conduzca al #endif es ignorado.
void display(const DrawableObject obj);no produce la salida deseada.