next up previous
Siguiente: 10 La Lista - Arriba: Introducción a la Programación Orientada a Objetos Anterior: 8 De C a

Subsecciones

9 Más sobre C++

 
Peter Müller
Globewide Network Academy (GNA)
pmueller@uu-gna.mit.edu

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++.

9.1 Herencia

  En nuestro pseudo lenguaje, formulamos la herencia con "hereda de". En C++ estas palabras son reemplazadas por dos puntos ( : ). Como ejemplo, diseñemos una clase para puntos en 3a. dimensión. Por supuesto que queremos reutilizar nuestra clase Point ya existente. Empezamos por diseñar nuestra clase como sigue :

  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; }
  };

9.1.1 Tipos de Herencia

  Podrás notar nuevamente la palabra clave public usada en la primera línea de la definición de la clase (su signature, firma o rúbrica en español). Esto es necesario porque C++ distingue dos tipos de herencia: pública y privada. "Por default", las clases se derivan unas de otras en forma privada. Consecuentemente, debemos decirle explícitamente al compilador que use herencia pública.

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.


 
Table 9.1:  Privilegios de acceso y herencia.

\begin{tabular}
{\vert l\vert l\vert l\vert} \hline
 & \multicolumn{2}{\vert c\v...
 ...rivate & protected \\  \hline
 public & private & public \\  \hline\end{tabular}

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.

9.1.2 Construcción

  Cuando creamos una instancia de la clase Point3D es llamado su constructor. Desde el momento que Point3D se deriva de Point el constructor de la clase Point también es llamado. Sin embargo, este constructor es llamado antes que se ejecute el cuerpo del constructor de la clase Point3D. En general, anterior a la ejecución del cuerpo particular del constructor, los constructores de cada superclase son llamados para inicializar su parte del objeto creado.

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.

9.1.3 Destrucción

  Si un objeto se destruye, por ejemplo al dejar su ámbito de definición, se invoca el destructor de la clase correspondiente. Si esta clase es derivada de otras clases, sus destructores también son llamados ; induciendo una cadena de llamadas recursivas.

9.1.4 Herencia Múltiple

  C++ permite que una clase sea derivada de más de una superclase, como ya se mencionó brevemente en las secciones anteriores. Tú puedes derivar fácilmente de más de una clase especificando las superclases en una lista separada por comas :

  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.

9.2 Polimorfismo

  En nuestro pseudo lenguaje, nosotros podemos declarar que los métodos de las clases sean virtual (virtuales), con el fin de forzar que su evaluación se base en el contenido de los objetos más que en su tipo. En C++ también podemos usar esto:

  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).

9.3 Clases Abstractas

  Las clases abstractas se definen justamente como las clases ordinarias. Sin embargo, algunos de sus métodos están designados para ser definidos necesariamente por sus subclases. Solamente mencionamos su signature (nombre del método más sus argumentos) incluyendo el tipo que regresa, pero no una definición. Se podría decir que omitimos el cuerpo del métdodo o, en otras palabras, no especificamos "nada". Esto se expresa añadiendo "= 0" después de las "signatures" de los métodos :

  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.

9.4 Sobrecarga de Operadores

  Si recordamos el tipo de datos abstracto para números complejos, Complex, podríamos crear un clase de C++ como sigue :

  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.

9.5 Amigos

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.

9.6 Como Escribir un Programa

  Hasta ahora, solamente hemos presentado partes de o programas muy pequeños que fácilmente podrían ser manejados en un archivo. Sin embargo, proyectos más grandes, un programa de calendario, por ejemplo, debería ser repartido en secciones manejables, con frecuencia llamadas módulos. Los módulos se implementan en archivos separados y ahora discutiremos brevemente se realiza la modularización en C y en C++. Esta discusión está basada en UNIX y el compilador C++ de GNU. Si tú estás usando otras constelaciones, lo que sigue podría variar de tu lado. Esto es especialmente importante para aquéllos que están usando ambientes integrados de desarrollo (IDEs), por ejemplo, Borland C++.

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.


 
Tabla 9.2:  Extensiones y tipos de archivo.

\begin{tabular}
{\vert l\vert p{0.4\textwidth}\vert} \hline
{\bf Extension(s)} &...
 ...\  \hline
{\tt .tpl} & interface description (templates) \\  \hline\end{tabular}

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 [*].

9.6.1 Pasos de la Compilación

  El proceso de compilación toma los archivos .cc, los preprocesa (eliminando comentarios, añadiendo archivos de cabecera)[*] y los traduce en archivos objeto[*]. Sufijos típicos para ese tipo de archivo son .o o .obj.

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.


 
Figura 9.1:   Pasos de la Compilación.
\begin{figure}
{\centerline{
\psfig {file=FIGS/l8compilation.eps}
}}\end{figure}

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

9.6.2 Una Nota acerca del Estilo

  Los archivos de cabecera se usan para describir la interface de archivos de implementación. Por consecuencia, son incluídos en cada archivo de implementación que use la interface del archivo de implementación en particular. Como se mencionó en las secciones anteriores, esta inclusión se logra por medio de una copia del contenido del archivo de cabecera en cada instrucción #include del preprocesador, llevando a un "enorme" archivo C++ en bruto.

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.

9.7 Ejercicios

1.
Polimorfismo. Explica por qué
	  void display(const DrawableObject obj);
no produce la salida deseada.

next up previous
Siguiente: 10 La Lista - Arriba: Introducción a la Programación Orientada a Objetos Anterior: 8 De C a
P. Mueller
8/31/1997