Disponible la nueva versión "donationware" 7.3 de OrganiZATOR
Descubre un nuevo concepto en el manejo de la información.
La mejor ayuda para sobrevivir en la moderna jungla de datos la tienes aquí.

Curso C++

[Home]  [Inicio]  [Índice]


4.13.2  Clases dentro de clases

§1 Preámbulo

Es frecuente que, en las fase inicial del abordaje de un problema, nos encontremos frente a disyuntivas en las que debemos elegir entre dos o más soluciones posibles. Esta circunstancia, que no es exclusiva del ámbito de la programación de ordenadores, sino de muchas otras situaciones de la vida cotidiana; es conocida en el mundo del software por su acrónimo inglés TMTOWTDI ("There's more than one way to do it"). La diversidad de soluciones puede referirse tanto al planteamiento estratégico (global) del problema, como a cuestiones de detalle, e incluso a los recursos sintácticos utilizados. Por ejemplo, un for puede ser sustituido por un while y un contador. En este sentido el lenguaje C++ es lo suficientemente rico y flexible como para que en la mayoría de los casos puedan plantearse distintas soluciones que, en principio, nos parecen igualmente válidas.

Siendo generosos, podríamos afirmar que cualquier solución que sirva (resuelva el problema) puede considerarse aceptable [1]. Sin embargo, en cada caso hay una que es mejor que las demás.  Quizás sea la de código más elegante; quizás la de más fácil lectura; la de código más compacto; la de más rápida ejecución; la de menor demanda de recursos, Etc.

Nota: al hilo de estas consideraciones, existe una circunstancia adicional que también debe ser tenida en cuenta. Me refiero a las condiciones de desarrollo y mantenimiento del programa. No pueden mantenerse los mismos considerandos para una aplicación mediana o pequeña, en la que pueden estar involucrados dos o tres programadores, que en una gran aplicación en la que participen varias decenas (lo que es corriente en compañías de software medianas y grandes).


Puede que elegir la alternativa más adecuada sea cuestión de técnica, inspiración, o sencillamente experiencia. Es difícil dar recetas generales, y en mi opinión, solo los grandes maestros pueden hablar con auténtica autoridad sobre el tema [3]. No obstante, admitido lo anterior, y sin pretender por tanto que mis palabras deban ser tomadas como el oráculo, a continuación incluyo algunos consejos y consideraciones que en su día constituyeron para mí motivo de meditación. Uno de los conceptos que más me inquietaba y que, he de reconocer, más me costó entender del todo, era relativo a la utilización de clases. En concreto la utilización de clases dentro de clases y sus diversas posibilidades de uso.

§2 Miembros de clases anidadas "versus" miembros de clases externas

En el capítulo dedicado a la construcción de clases ( 4.11.2a) señalamos que los miembros de clases pueden ser otras clases (clases anidadas ) u objetos (instancias de otras clases). La situación puede esquematizarse como sigue:

§2.1  Clases independientes:

class A { ... };

class B {              // Clases independientes

   A a1, a2, a3;       // Ok. B contiene objetos tipo A

   ...

};

Este diseño suele denominarse de contención ("containtment"). Se dice que la clase B contiene una A ("'has-a' relationship" en la literatura inglesa). Es típico cuando se desea ampliar la funcionalidad de una clase contenedora (B) utilizando en su interior objetos de otra clase A.  En estos casos, si además se desea que algunas de las propiedades o métodos públicos de la clase interior A, sean accesible desde el exterior de un objeto tipo-B, se suelen implementar (en B) los acessors y mutators adecuados ( E4.11), lo que se denomina delegación ("delegation").  Por esta razón este diseño es conocido también como de contención/delegación ("contention/delegation).

§2.2 Clases anidadas

class B {

  class A { ... };      // clase anidada

  A a1, a2, a3;         // Ok. B contiene objetos tipo A

  ...

};

Esta sería otra forma de contención/delegación si estamos seguros de que los objetos de la clase A nunca van a ser utilizados fuera de la clase B.


§2.3
Por supuesto también puede ocurrir que la clase B no necesite contener ningún objeto de A:

class B {

  class A { ... };     // clase anidada

  ...

};

Este diseño equivaldría en realidad a definir un espacio de nombres (namespace) en el interior de la clase contenedora.


§2.4
La opción §2.2 puede ser llevada al extremo, cuando solo necesitamos un objeto de la clase anidada:

class B {

  class { x; y; z; ... } a;

  ...

};

Esta situación podría ser equivalente a trasladar todas las propiedades y métodos de A a la B:

class B {

  x; y; z;

  ...

};

sin embargo, un principio de encapsulamiento y claridad conceptual podrían justificar la fórmula §2.2 , manteniendo la definición de la clase A como ente independiente, aunque contenido en B.

§2.5 Otra alternativa a considerar en este último caso, sería la proporcionada por la herencia (teniendo en cuenta las excepciones correspondientes 4.11.2b):

class A { x; y; z; ... };

class B : A { ... };

Esta última forma es conocida como "subclassing", que quizás podríamos traducir aquí por derivación.

Aunque todas las combinaciones son posibles, debemos señalar aquí que en las aplicaciones reales son mucho más frecuentes los diseños §2.1 (clases independientes) y §2.5 (herencia simple o múltiple), mientras que los diseños §2.2, §2.3 y §2.4 son menos utilizados.


§2.6 Volviendo a las dos formas básicas, clases independientes o anidadas, aunque ambas soluciones son teóricamente posibles. La primera es más flexible que la segunda.

En principio la utilización de §2.1 está justificada cuando se dan algunas de las condiciones siguientes:

  • Deben utilizarse varias instancias a1, a2, ... etc. de la misma clase.
  • Los objetos de la clase A, o de clases derivadas de ella, tienen justificación por sí mismos. Por ejemplo, pueden ser utilizados para otros usos con independencia de que sean componentes de B.  Observe que estos "otros usos" pueden ser incluso formar parte de otras clases C, D, Etc. y que los objetos tipo-A nunca sean instanciados directamente.  De hecho, en múltiples ocasiones la clase A suele ser abstracta ( 4.11.8c).

La utilización de §2.2 se justifica cuando:

  • Deben utilizarse varias instancias a1, a2, ... etc. de la misma clase.
  • Los objetos de la clase A no pueden tener existencia independiente más que como miembros de la clase contenedora B, sin participar en ninguna otra.


El lector habrá observado que las opciones consideradas hasta ahora, han incluido instancias de clases como miembros de clases. Son las opciones ya mencionadas, que esquematizamos a continuación:

Clases independientes Clases anidadas

class A { ... };

class B {

   A a1, a2, a3;

   ...

};

class B {

   class A { ... };

   A a1, a2, a3;

   ...

};

En realidad, el hecho de que la clase A sea independiente de B, o anidada en ella, es una cuestión de detalle que solo afecta al ámbito en que se define A;  en uno y otro caso los miembros a1, a2, a3 de B siguen siendo objetos de tipo-A.

Como señalaba al principio, uno de los conceptos que más me inquietaba se refería a la utilización de clases dentro de clases y sus diversas posibilidades de uso. Por ejemplo, me asaltaba el siguiente tipo de cuestión: Supongamos una clase A contenida en otra B. A continuación, instancio la clase exterior B (contenedora) para obtener un objeto b. ¿Que pasa con la clase A anidada?. ¿Podría instanciar a su vez diversos objetos a1, a2, a3, etc dentro del objeto b?. Se formaría así una especie de submundo dentro de dicho objeto?. El creador del lenguaje dice a este respecto [4]: "La simple declaración de una clase anidada en otra no supone que la clase contenedora incluya un objeto de la clase interior. El anidamiento solo indica ámbito ("scoping") no inclusión de sub-objetos".

Nota: la Librería Estándar C++, que en sí misma es un compendio de programación extraordinariamente avanzado, ofrece varios ejemplos de clases anidadas. Por ejemplo, la clase locale ( 5.2.2) cuya definición es del tipo:

class B {

  class A;

  ...

};

class B::A {

  ...

};


§2.7 La clase anidada está en el ámbito de la clase contenedora, y su identificador es local al ámbito de aquella. Ejemplo:

class B {

  class A { /* ... */ };

  ...

};

...

A a;           // Error!! A no es visible

B b;           // Ok.


§2.8
Las declaraciones en la clase anidada solo pueden utilizar typenames ( 3.2.1e), miembros estáticos ( 4.11.7) y enumeradores ( 4.8) de la clase contenedora (siempre que sean públicos). Por lo demás, aparte de los conocidos (por ejemplo declararlas friend 4.11.2a1), no existe ningún mecanismo especial de acceso entre los miembros de la clase exterior sobre la clase anidada o viceversa.

Ejemplo:

class B {
   public:
   static int counter;
   enum { KT1 = 33 };
   class A {
      int x;
      public:
      A() : x(KT1 + counter) {}    // Ok.

   };

   B(): KT2(34), y(0) {}
};

int B::counter = 35;


Observe que la visibilidad de los miembros KT1 y counter desde la clase A no se produce en cualquier otra circunstancia [5]. Ejemplo:

class B {

   public:
   static int counter;
   enum { KT1 = 33 };
   B(): KT2(34), y(0) {}
};

int B::counter = 35;

 

class A {
   int x;
   public:
   A() : x(KT1 + counter) {}   // Error!!

};

§3 Utilización de miembros-objeto (instancias de otras clases)

Los miembros de clases pueden ser objetos (instancias de otras clases). Este sería el caso del ejemplo clásico del automóvil que tiene un motor cuatro ruedas, etc. Estos objetos son a su vez miembros de las clases de los motores de las ruedas, etc. Lo hemos esquematizado en el epígrafe §2.1 .

A continuación se exponen dos ejemplos que muestran la técnica de utilización. En realidad se trata de dos versiones del mismo supuesto, con la diferencia de que en el primer caso los miembros son instancias de una clase externa; correspondería al caso §2.1 .  En el segundo, la clase auxiliar se define dentro del ámbito de la clase anfitriona; corresponde al caso §2.2 .

§3.1 Ejemplo-1

En el siguiente programa se construye una clase Triangulo destinada a representar triángulos en el plano. Esta clase tiene tres propiedades, denominadas vértices, que son los puntos que caracterizan a cada posible triángulo. A su vez tiene varios métodos, el principal de ellos es la función getArea, que devuelve el área delimitada en el triángulo.

Para definir los vértices se ha utilizado una clase auxiliar, de modo que estas propiedades de la clase Triangulo son objetos de otra clase denominada Vertice.

A su vez la clase Vertice tiene dos propiedades y dos métodos principales. Las propiedades son las coordenadas cartesianas X e Y de cada objeto (que representa un punto en el plano).  El método getxy muestra las coordenadas X, Y del objeto. El método getDist obtiene la distancia entre el objeto y otro que se pasa como argumento (distancia entre dos puntos).

#include <iostream>
using namespace std;

class Vertice {               // L.4: Clase auxiliar
  float x, y;                 // privados por defecto
  public:
  friend class Triangulo;     // L.7:
  void getxy() {              // muestra las propiedades x y del vertice
    cout << "vertice (" << x << "," << y << ")" << endl;
  }

  float getDist(const Vertice&);
  Vertice(int i =0, int j =0) { x = i; y = j; }      // L.12: constructor
  Vertice(const Vertice& rf) { x = rf.x; y = rf.y; } // constructor-copia
}

float Vertice::getDist(const Vertice& rf) {
  float dX = rf.x - x, dY = rf.y - y;
  float dist = pow(dX * dX + dY * dY, .5);           // L.17:
  cout << "distancia: " << dist << endl;
  return dist;
}

class Triangulo {            // L.22: Clase contenedora
  Vertice vA, vB, vC;        // L.23: miembros-objeto
  public:
  Triangulo(Vertice v1 = Vertice(),     // L.25: Constructor

            Vertice v2 = Vertice(),

            Vertice v3 = Vertice() ) {
      vA = v1; vB = v2; vC = v3;
  }
  void getT() { vA.getxy(); vB.getxy(); vC.getxy(); }  // L.30:
  float getArea();           // declaracion de getArea
};

float Triangulo::getArea() {    // L.32: definición de getArea
  int Ax = vB.x - vA.x; int Ay = vB.y - vA.y;
  int Bx = vC.x - vA.x; int By = vC.y - vA.y;
  double s = fabs ((Ax * By - Ay * Bx) * .5);
  cout << "Area: " << s << endl;
  return s;
}

void main() {                 // =======================
  Vertice origen, v1(1,2), v2(3,4);        // M.1:
  origen.getDist(v2);
  v1.getDist(v2);
  Triangulo T1(v1, v2, Vertice(7,3)), T2;  // M.4:
  T1.getT(); T1.getArea();

  T2.getT(); T2.getArea();
}

Salida:

distancia: 5
distancia: 2.82843
vertice (1,2) vertice (3,4) vertice (7,3)
Area: 5
vertice (0,0) vertice (0,0) vertice (0,0)
Area: 0

Comentario:

En L.7 la clase auxiliar Vertice declara a la clase Triangulo como friend ( 4.11.2a1) al objeto de que desde esta última, puedan ser accedidos todos sus miembros (incluso privados). De esta forma, desde la función Triangulo::getArea (L.33 y 34) se puede acceder a las propiedades x e y (privadas) de los vértices.

El método Vertice::getDist calcula la distancia entre el objeto sobre el que se aplica la función (señalado por el puntero this 4.11.6) y otro objeto que se pasa por referencia. Se trata pues del cálculo de la distancia entre dos puntos de coordenadas cartesianas conocidas, para lo que utilizamos el consabido teorema de Pitágoras (L.17).

Nota: el ANSI C dispone en la Librería Estándar de la función sqrt() que calcula directamente la raíz cuadrada, pero esta función no es estándar en ANSI C++, por lo que usamos pow() que sí lo es.


Además del método anterior, la clase dispone de un constructor por defecto y un constructor-copia definidos explícitamente. Observe (L.12) que cuando este constructor es invocado sin argumentos, se obtiene un punto de coordenadas (0, 0). Este es precisamente el caso del objeto origen instanciado en la función main; este objeto tiene coordenadas (0, 0), y su distancia respecto al objeto v2 de coordenadas (3,4) es precisamente la primera salida del programa.

Observe que en la definición de la clase principal Triangulo, los miembros vA, vB y vC, que son los vértices del objeto-triángulo, se definen directamente como instancias de la clase Vertice (L.23).

Merece especial atención el constructor por defecto definido en L.25. Esta función acepta como argumentos tres objetos de la clase Vertice, que son precisamente los vértices del objeto-triángulo que se pretende construir, pero cuando es invocada sin argumentos, el constructor adopta como argumentos por defecto el resultado de sendas invocaciones (también sin argumentos) al constructor de la clase Vertice.  Hemos visto que cuando este constructor es invocado sin argumentos se obtiene un punto de coordenadas (0, 0), así que invocando Vertice() sin argumentos, obtendríamos un triángulo de tres vértices coincidentes y superficie 0. Este es precisamente el resultado que se obtiene para el objeto T2 (las cuatro últimas salidas).

Observe (L.30) como el método Triangulo::getT  utiliza directamente los métodos de las propiedades vA, vB y vC.

El método Triangulo::getArea, definido en L.32 y siguientes, utiliza las propiedades del producto vectorial de dos vectores [2] para calcular el área del triángulo definido por ellos.


El producto vectorial de los vectores A y B de la figura adjunta, es el área del paralelogramo determinado por ambos (de forma que el área del triángulo formado por los vértices vA-vB-vC es la mitad de dicho producto).

El valor del producto vectorial A ^ B en función de sus componentes cartesianas viene determinado por la expresión:

            A ^ B = Ax * By - Ay * Bx

Esta es precisamente la fórmula utilizada para el cálculo en L.35.


La función main se limita a instanciar tres objetos de la clase Vertice en M.1 y dos de la clase Triangulo en M.4. Además se invocan algunos métodos sobre ambos objetos.

Observe que para crear el objeto T1 en M.4, se realiza una invocación implícita al constructor de la clase con argumentos; que los dos primeros (v1 y v2) son objetos ya creados, mientras que el último es el resultado de una invocación directa (con argumentos) al constructor de la clase Vertice.

Por simplicidad se ha limitado la extensión de la función main, pero se podría implementar fácilmente una función para obtener los datos de cualquier triángulo cuyos vértices se introdujeran por teclado.

Ejemplos adicionales en: 4.9.18e;

§3.2 Ejemplo-2:

Presentamos aquí una modificación del caso de los triángulos , en el que la clase Vertice se incluye dentro de la clase principal Triangulo.  Aparte de este cambio de posición, el programa es prácticamente idéntico, con pequeñísimas modificaciones de detalle que comentamos:

La inclusión de la clase Vertice se ha realizado al principio, con objeto de que en las sucesivas referencias a dicha clase (la primera ocurre en L.17), el compilador sepa a que corresponde este identificador, de lo contrario tendríamos que haber usado una declaración adelantada ( 4.11.4a).

Los miembros-objeto vA, vB y vC que eran privados, se han hecho públicos porque nos interesa acceder sus métodos desde el exterior (invocaciones de L.46 y L.47).

Recordar que los vértices de cualquier triángulo serán los proporcionados por el constructor al crear el objeto a través de sus argumentos (explícitos, o por defecto). Para poder modificar los valores iniciales de los vértices se han incluido tres nuevos métodos: setvAsetvB y setvC, que permiten modificar las coordenadas de cualquiera de ellos.


#include <iostream>
using namespace std;

class Triangulo {              // clase contenedora
  class Vertice {              // clase auxiliar (anidada)
    float x, y;
    public:
    friend class Triangulo;
    void getxy() {
      cout << "vertice (" << x << "," << y << ")" << endl;
    }
    float getDist(const Vertice&);
    Vertice(int i =0, int j =0) { x = i; y = j; }      // constructor
    Vertice(const Vertice& rf) { x = rf.x; y = rf.y; } // constructor-copia
  };

  public:
  Vertice vA, vB, vC;         // L.17: miembros-objeto
  Triangulo(Vertice v1 = Vertice(),                    // constructor
            Vertice v2 = Vertice(),
            Vertice v3 = Vertice() ) {
      vA = v1; vB = v2; vC = v3;
  }
  void getT() { vA.getxy(); vB.getxy(); vC.getxy(); }
  float getArea();
  void setvA(int i, int j) { vA.x = i; vA.y = j; }     // L.25:
  void setvB(int i, int j) { vB.x = i; vB.y = j; }
  void setvC(int i, int j) { vC.x = i; vC.y = j; }
};


float Triangulo::Vertice::getDist(const Vertice& rf) {
  float dX = rf.x - x, dY = rf.y - y;
  float dist = pow(dX * dX + dY * dY, .5);
  cout << "distancia: " << dist << endl;
  return dist;
}


float Triangulo::getArea() {
  int Ax = vB.x - vA.x; int Ay = vB.y - vA.y;
  int Bx = vC.x - vA.x; int By = vC.y - vA.y;
  double s = fabs ((Ax * By - Ay * Bx) * .5);
  cout << "Area: " << s << endl;
  return s;
}

void main() {                  // L.43: ======================
  Triangulo T1(Triangulo::Vertice(1,2), Triangulo::Vertice(3,4)), T2;
  T1.setvC(7,3);               // L.45:
  T2.vA.getDist(T1.vB);        // L.46
  T1.vA.getDist(T1.vB);        // L.47
  T1.getT();
  T1.getArea();
}

Salida:

distancia: 5
distancia: 2.82843
vertice (1,2) vertice (3,4) vertice (7,3)
Area: 5

Comentario:

En contra de lo que ocurría en el primer ejemplo, aquí los objetos Vertice no tienen existencia aislada, solo existen como miembros de los objetos-triángulo. Nótese como en la función main no existen ahora vértices como tales objetos aislados.

Es interesante la utilización de los dos primeros argumentos explícitos del constructor de la clase al crear el objeto T1 en L.44. Se trata de invocaciones explícitas al constructor de la subclase Vertice con los argumentos adecuados (observe la utilización del operador de resolución de ámbito). Podríamos haber usado tres, pero se han usado solo dos parámetros; en estas condiciones el constructor Triangulo() suple el tercero utilizando su valor por defecto. Recuerde que en caso de omitirse argumentos en C++, deben ser siempre los últimos ( 4.4.5). Para restituir el valor del tercer vértice a las coordenadas deseadas, en L.45 se utiliza el método setvC sobre el objeto.

Tal como se comprueba en las salidas, con la modificación de L.45, los objetos T1 y T2 son idénticos a sus homólogos del primer ejemplo (tienen los mismos vértices).

Observe en L.46/47 la notación utilizada para acceder al método getDist del miembro vA en los objetos T2 y T1.  Observe así mismo el formato del argumento pasado en ambos casos.

Ejemplos adicionales en: 4.9.18e.

  Inicio.


[1] Esta afirmación me recuerda inevitablemente una frase que leí hace tiempo, atribuida al inventor del cambio de marchas de los automóviles, en respuesta a algún comentario crítico respecto a su invento (podemos suponer como serían aquellos primitivos cambios no sincronizados): "Es brutal, pero funciona !!".

[2] Estas definiciones pueden consultarse en cualquier libro de estática o cálculo vectorial básico.

[3] Precisamente el libro de Stroustrup ( TC++PL) incluye al final de cada capítulo una serie de avisos y sugerencias, que constituyen en sí mismas todo un tratado de programación C++.

[4] Stroustrup & Ellis: ACRM  §9.7

[5]  En la página Nam-lookup ( 1.2.1w1) encontrará un completa explicación del porqué de este comportamiento.