Tipos de relaciones
Considérense por un momento las analogías y diferencias entre las siguientes clases de objetos: flores, margaritas, rosas rojas, rosas amarillas, pétalos y mariquitas. Pueden hacerse las observa- ciones siguientes:
❚
❚ Una margarita es un tipo de flor. ❚
Orient a ción a O bje tO s. t eO ría y pr á c tic
a ❚❚ Las rosas rojas y las rosas amarillas son tipos de rosas.
❚
❚ Un pétalo es una parte de ambos tipos de flores. ❚
❚ Las mariquitas se comen a ciertas plagas como los pulgones, que pueden infectar ciertos tipos de flores.
Partiendo de este simple ejemplo se concluye que las clases, al igual que los objetos, no existen aisladamente. Antes bien, para un dominio de problema específico, las abstracciones clave suelen estar relacionadas por vías muy diversas e interesantes, formando la estructura de clases del diseño [21].
Se establecen relaciones entre dos clases por una de dos razones. Primero, una relación entre clases podría indicar algún tipo de compartición. Por ejemplo, las margaritas y las rosas son tipos de flores, lo que quiere decir que ambas tienen pétalos con colores llamativos, ambas emiten una fragancia, etc. Segundo, una relación entre clases podría indicar algún tipo de conexión semántica. Así, se dice que las rosas rojas y las rosas amarillas se parecen más que las margaritas y las rosas, y las margaritas y las rosas se relacionan más estrechamente que los pétalos y las flores. Análogamente, existe una conexión simbiótica entre las mariquitas y las flores: las mariquitas protegen a las flores de ciertas plagas, que a su vez sirven de fuente de alimento para la mariquita.
En total, existen tres tipos básicos de relaciones entre clases [22]. La primera es la genera- lización/especialización, que denota una relación “es-un” (is a). Por ejemplo, una rosa es un tipo de flor, lo que quiere decir que una rosa es una subclase especializada de una clase más general, la de las flores. La segunda es la relación todo/parte (whole/part), que denota una relación “parte-de” (part of ). Así, un pétalo no es un tipo de flor; es una parte de una flor. La tercera es la asociación, que denota alguna dependencia semántica entre clases de otro modo independientes, como entre las mariquitas y las flores. Un ejemplo más: las rosas y las velas son clases claramente independientes, pero ambas representan cosas que podrían utilizarse para decorar la mesa de una cena.
En los lenguajes de programación han evolucionado varios enfoques comunes para plasmar relaciones de generalización/especialización, todo/parte y asociación. Específicamente, la mayo- ría de los lenguajes orientados a objetos ofrecen soporte directo para alguna combinación de las siguientes relaciones: ❚ ❚ Asociación. ❚ ❚ Herencia. ❚ ❚ Agregación. ❚ ❚ Uso. ❚
❚ Instanciación (creación de instancias o ejemplares). ❚
❚ Metaclase.
Un enfoque alternativo para la herencia involucra un mecanismo lingüístico llamado delegación, en el que los objetos se consideran prototipos (también llamados ejemplares) que delegan su comportamiento en objetos relacionados, eliminando así la necesidad de clases [23].
De estos seis tipos diferentes de relaciones entre clases, las asociaciones son el más general, pero también el de mayor debilidad semántica. La identificación de asociaciones entre clases es fre- cuentemente una actividad de análisis y de diseño inicial, momento en el cual se comienza a descubrir las dependencias generales entre las abstracciones. A medida que se continúa el diseño y la implementación, se refinarán a menudo estas asociaciones débiles orientándolas hacia una de las otras relaciones de clase más concretas.
La herencia es quizás la más interesante, semánticamente hablando, de estas relaciones concretas, y existe para expresar relaciones de generalización/especialización. Según nuestra experiencia, sin embargo, la herencia es un medio insuficiente para expresar todas las ricas relaciones que pueden darse entre las abstracciones clave en un dominio de problema dado. Se necesitan también relaciones de agregación, que suministran las relaciones todo/parte que se manifiestan en las instancias de las clases. Además, son necesarias las relaciones de uso, que establecen los enlaces entre las instancias de las clases. Para lenguajes como Ada, C++ y Eiffel, se necesitan también las relaciones de instanciación que, al igual que la herencia, soportan un tipo de generalización, aunque de forma completamente diferente. Las relaciones de me- taclase son bastante distintas y solo las soportan explícitamente lenguajes como Smalltalk y CLOS. Básicamente, una metaclase es la clase de una clase, un concepto que permite tratar a las clases como objetos.
Asociación
Ejemplo: en un sistema automatizado para un punto de venta al por menor, dos de las abstraccio nes clave incluyen productos y ventas. Como se ve en la figura 3.4, se puede mos- trar una asociación simple entre estas dos clases: la clase Producto denota los productos que se venden como parte de una venta, y la clase Venta denota la transacción por la cual varios productos acaban de venderse. Por implicación, esta asociación sugiere una relación bidirec- cional: dada una instancia de Producto, deberíamos ser capaces de encontrar el objeto que denota su venta, y, dada una instancia de Venta, deberíamos ser capaces de localizar todos los productos vendidos en esa transacción.
Puede capturarse esta semántica en C++ utilizando lo que Rumbaugh llama punteros escon- didos u ocultos (buried pointers) [24]. Por ejemplo, considérese la declaración muy resumida de estas dos clases:
Producto
productoVendido ultimaVenta N
Venta
Orient a ción a O bje tO s. t eO ría y pr á c tic a class Producto; class Venta; class Producto { public: ... protected: Venta* ultimaVenta; }; class Venta { public: ... protected: Producto** productoVendido; };
Aquí se muestra una asociación uno-a-muchos: cada instancia de Producto puede tener un puntero a su última venta, y cada instancia de Venta puede tener una colección de punteros que denota los productos vendidos.
Dependencias semánticas. Como sugiere este ejemplo, una asociación solo denota una depen- dencia semántica y no establece la dirección de esta dependencia (a menos que se diga lo con- trario, una asociación implica relación bidireccional, como en el ejemplo) ni establece la forma exacta en que una clase se relaciona con otra (solo puede denotarse esta semántica nombrando el papel que desempeña cada clase en relación con la otra). Sin embargo, esta semántica es sufi- ciente durante el análisis de un problema, momento en el cual solo es necesario identificar esas dependencias. Mediante la creación de asociaciones, se llega a plasmar quiénes son los partici- pantes en una relación semántica, sus papeles y, como se verá, su cardinalidad.
Cardinalidad. El ejemplo ha introducido una asociación uno-a-muchos, lo que significa que para cada instancia de la clase Venta existen cero o más instancias de la clase Producto, y por cada producto, existe exactamente una venta. Esta multiplicidad denota la cardinalidad de la asociación. En la práctica, existen tres tipos habituales de cardinalidad en una asociación:
❚ ❚ Uno a uno. ❚ ❚ Uno a muchos. ❚ ❚ Muchos a muchos.
Una relación uno a uno denota una asociación muy estrecha. Por ejemplo, en las operacio- nes de venta con tarjeta, se encontraría una relación uno a uno entre la clase Venta y la clase
TransaccionTarjetaCredito: cada venta se corresponde exactamente con una transacción de tar- jeta de crédito, y cada transacción se corresponde con una venta. Las relaciones muchos a muchos
también son habituales. Por ejemplo, cada instancia de la clase Cliente podría iniciar una tran- sacción con una instancia de la clase Vendedor, y cada uno de esos vendedores podría interactuar con muchos clientes distintos. Existen variaciones sobre estas tres formas básicas de cardinalidad.
Herencia
Ejemplos: cuando se lanzan sondas espaciales, remiten informes a las estaciones terrestres con datos acerca del estado de subsistemas importantes (como energía eléctrica y sistemas de pro- pulsión) y diferentes sensores (como sensores de radiación, espectrómetros de masas, cámaras, detectores de colisión de micrometeoritos, etc.). Globalmente, esta información retransmitida recibe el nombre de datos de telemetría. Los datos de telemetría se transmiten normalmente como un flujo de bits consistente en una cabecera, que incluye una marca de la hora y algunas claves que identifican el tipo de información que sigue, más varias tramas de datos procesados de los diferentes subsistemas y sensores. Puesto que esto parece ser una clara agregación de diversos tipos de datos, podríamos vernos tentados a definir un tipo de registro para cada tipo de datos de telemetría. Por ejemplo, en C++ se podría escribir:
class Hora...
struct DatosElectricos { Hora marcaHora; int id;
float tensionCelulaCombustible1, tensionCelulaCombustible2; float corrienteCelulaCombustible1, corrienteCelulaCombustible2; float energiaActual;
};
Existen varios problemas con esta declaración. Primero, la representación de DatosElectricos
no está encapsulada en absoluto. Así, no hay nada que evite que un cliente cambie el valor de un dato importante como marcaHora o energiaActual (que es un atributo derivado, directamente proporcional a la tensión y corriente actuales extraídas de ambas células de combustible). Más aun, la representación de esta estructura está expuesta, así que si se cambiase la representación (por ejemplo, añadiendo nuevos elementos o cambiando la alineación de bits de los que existen), todos los clientes serían afectados. Como mínimo, habría que recompilar con toda seguridad cualquier referencia a esta estructura. Más importante es que tales cambios podrían violar las suposiciones que los clientes habían hecho sobre esta representación expuesta y causar una rup- tura en la lógica del programa. Además, esta estructura es enormemente carente de significado: se puede aplicar una serie de operaciones a las instancias de esta estructura como un todo (tales como transmitir los datos, o calcular una suma de comprobación para detectar errores durante la transmisión), pero no existe forma de asociar directamente esas operaciones con esta estructura.
Orient a ción a O bje tO s. t eO ría y pr á c tic a
Finalmente, supóngase que el análisis de los requisitos del sistema revela la necesidad de varios cientos de tipos diferentes de datos de telemetría, incluyendo otros datos eléctricos que abar- caban la información precedente y también incluían lecturas de la tensión en varios puntos de prueba extendidos por el sistema. Se vería que la declaración de estas estructuras adicionales crearía una considerable cantidad de redundancia, en términos tanto de estructuras repetidas como de funciones comunes.
Una forma ligeramente mejor de capturar las decisiones sería declarar una clase para cada tipo de datos de telemetría. De este modo, podría ocultarse la representación de cada clase y asociar su comportamiento con sus datos. Aun así, este enfoque no soluciona el problema de la redundancia.
Una solución mucho mejor, sin embargo, es capturar las decisiones construyendo una jerarquía de clases, en la que las clases especializadas heredan la estructura y comportamiento definidos por clases más generalizadas. Por ejemplo:
class DatosTelemetria { public:
DatosTelemetria();
virtual ~DatosTelemetria(); virtual void transmitir();
Hora horaActual() const; protected:
int id;
Hora marcaHora; };
Esto declara una clase con un constructor y un destructor virtual (lo que significa que se espera que haya subclases), así como las funciones transmitir y horaActual, ambas visibles para todos los clientes. Los objetos miembro protected id y marcaHora están algo más encapsulados, y así son accesibles solamente para la propia clase y sus subclases. Nótese que se ha declarado la fun- ción horaActual como un selector public, que posibilita a un cliente acceder a marcaHora, pero no cambiarlo.
A continuación, se va a reescribir la declaración de la clase DatosElectricos:
class DatosElectricos : public DatosTelemetria { public:
DatosElectricos(float t1, float t2, float c1, float c2); virtual ~DatosElectricos();
float energiaActual() const; protected:
float tensionCelulaCombustible1, tensionCelulaCombustible2; float corrienteCelulaCombustible1, corrienteCelulaCombustible2; };
Esta clase hereda la estructura y comportamiento de la clase DatosTelemetria, pero añade cosas a su estructura (los cuatro nuevos objetos miembro protected), redefine su comportamiento (la función transmitir) y le añade cosas (la función energiaActual).
Herencia simple. Dicho sencillamente, la herencia es una relación entre clases en la que una clase comparte la estructura y/o el comportamiento definidos en una (herencia simple) o más clases (herencia múltiple). La clase de la que otras heredan se denomina superclase. En el ejemplo,
DatosTelemetria es una superclase de DatosElectricos. Análogamente, la clase que hereda de otra o más clases se denomina subclase; DatosElectricos es una subclase de DatosTelemetria. La herencia define, por tanto, una jerarquía “de tipos” entre clases, en la que una subclase hereda de una o más superclases. Esta es de hecho la piedra de toque para la herencia. Dadas las clases
A y B, si A no “es-un” tipo de B, entonces A no debería ser una subclase de B. En este sentido,
DatosElectricos es un tipo especializado de la clase DatosTelemetria, más generalizada. La capacidad de un lenguaje para soportar o no este tipo de herencia distingue a los lenguajes de programación orientados a objetos de los lenguajes basados en objetos.
Una subclase habitualmente aumenta o restringe la estructura y comportamiento existentes en sus superclases. Una subclase que aumenta sus superclases se dice que utiliza herencia por extensión. Por ejemplo, la subclase ColaVigilada podría extender el comportamiento de su su- perclase Cola proporcionando operaciones extra que hacen que las instancias de esta clase sean seguras en presencia de múltiples hilos de control. En contraste, una subclase que restringe el comportamiento de sus superclases se dice que usa herencia por restricción. Por ejemplo, la sub- clase ElementoPantallaNoSeleccionable podría restringir el comportamiento de su superclase,
ElementoPantalla, prohibiendo a los clientes la selección de sus instancias en una vista. En la práctica, no siempre está tan claro cuándo una subclase aumenta o restringe a su superclase; de hecho, es habitual que las subclases hagan las dos cosas.
La figura 3.5 ilustra las relaciones de herencia simple que se derivan de la superclase
DatosTelemetria. Cada línea dirigida denota una relación “es-un”. Por ejemplo, DatosCamara “es- un” tipo de DatosSensor, que a su vez “es-un” tipo de DatosTelemetria. Es igual que la jerarquía que se encuentra en una red semántica, una herramienta que utilizan a menudo los investigadores en ciencia cognitiva e inteligencia artificial para organizar el conocimiento acerca del mundo [25]. Realmente, como se trata después en el capítulo 4, diseñar una jerarquía de herencias conveniente entre abstracciones es en gran medida una cuestión de clasificación inteligente.
Se espera que algunas de las clases de la figura 3.5 tengan instancias y que otras no las ten- gan. Por ejemplo, se espera tener instancias de cada una de las clases más especializadas (también
Orient a ción a O bje tO s. t eO ría y pr á c tic
a llamadas clases hoja o clases concretas), tales como DatosElectricos y DatosEspectrometro. Sin
embargo, probablemente no haya ninguna instancia de las clases intermedias, más generales, como DatosSensor o incluso DatosTelemetria. Las clases sin instancias se llaman clases abstrac-
tas. Una clase abstracta se redacta con la idea de que las subclases añadan cosas a su estructura y
comportamiento, usualmente completando la implementación de sus métodos (habitualmente) incompletos. De hecho, en Smalltalk, un desarrollador puede forzar a una subclase a redefinir el método introducido por una clase abstracta utilizando el método subclassResponsibility para implantar un cuerpo para el método de la clase abstracta. Si la subclase no lo redefine, la invo- cación del método tiene como resultado un error de ejecución. C++ permite análogamente al de- sarrollador establecer que un método de una clase abstracta no pueda ser invocado directamente inicializando su declaración a cero. Tal método se llama función virtual pura (o pure virtual
function), y el lenguaje prohíbe la creación de instancias cuyas clases exporten tales funciones.
La clase más generalizada en una estructura de clases se llama la clase base. La mayoría de las aplicaciones tienen muchas de tales clases base, que representan las categorías más generalizadas de abstracciones en el dominio que se trata. De hecho, especialmente en C++, las arquitecturas orientadas a objetos bien estructuradas suelen tener bosques de árboles de herencias, en vez de una sola trama de herencias de raíces muy profundas. Sin embargo, algunos lenguajes requieren una clase base en la cima, que sirve como la clase última de todas las clases. En Smalltalk, esta clase se denomina Object.
Una clase cualquiera tiene típicamente dos tipos de clientes [26]: ❚
❚ Instancias. ❚
❚ Subclases.
Frecuentemente resulta de utilidad definir interfaces distintas para estos dos tipos de clientes [27]. En particular, se desea exponer a los clientes instancia solo los comportamientos visi- bles exteriormente, pero se necesita exponer las funciones de asistencia y las representaciones solamente a los clientes subclase. Esta es precisamente la motivación para las partes public, protected y private de una definición de clase en C++: un diseñador puede elegir qué miembros son accesibles a las instancias, a las subclases o a ambos clientes. Como se mencionó anterior- mente, en Smalltalk el desarrollador tiene menos control sobre el acceso: las variables instancia son visibles a las subclases, pero no a las instancias, y todos los métodos son visibles tanto a las instancias como a las subclases (se puede marcar un método como private, pero esta ocultación no es promovida por el lenguaje).
Existe una auténtica tensión entre la herencia y el encapsulamiento. En un alto grado, el uso de la herencia expone algunos de los secretos de una clase heredada. En la práctica, esto implica que, para comprender el significado de una clase particular, muchas veces hay que estudiar todas sus superclases, a veces incluyendo sus vistas internas.
La herencia significa que las subclases heredan la estructura de su superclase. Así, en el pa- sado ejemplo, las instancias de la clase DatosElectricos incluyen los objetos miembro de la superclase (tales como id y marcaHora), así como los de las clases más especializadas (como
tensionCelulaCombustible1, tensionCelulaCombustible2, corrienteCelulaCombustible1 y
corrienteCelulaCombustible2).12
Las subclases también heredan el comportamiento de sus superclases. Así, puede actuar- se sobre las instancias de la clase DatosElectricos con las operaciones horaActual (heredada de su superclase), energiaActual (definida en la propia clase), y transmitir (redefinida en la subclase). La mayoría de los lenguajes de programación orientados a objetos permiten que los métodos de una superclase sean redefinidos y que se añadan métodos nuevos. En Smalltalk, por ejemplo, cualquier método de una superclase puede redefinirse en una subclase. En C++, el de- sarrollador tiene un poco más de control. Las funciones miembro que se declaran como virtual (como la función transmitir) pueden redefinirse en una subclase; los miembros declarados de otro modo (por defecto) no pueden redefinirse (como la función horaActual).
Polimorfismo simple. Para la clase DatosTelemetria, podría implantarse la función miembro
transmitir como sigue:
void DatosTelemetria::transmitir() {
// transmitir el id // transmitir la marcaHora }
Se podría implantar la misma función miembro para la clase DatosElectricos como sigue:
void DatosElectricos::transmitir() { DatosTelemetria::transmitir(); // transmitir la tensión // transmitir la corriente }
En esta implantación, se invoca primero la función correspondiente de la superclase (utilizando el nombre calificado DatosTelemetria::transmitir), que transmite los datos id y marcaHora, y a continuación se transmiten los datos particulares de la subclase DatosElectricos.
Supóngase que se tiene una instancia de cada una de esas dos clases:
DatosTelemetria telemetria;
DatosElectricos electricos(5.0, —5.0, 3.0, 7.0);
12 Unos pocos lenguajes orientados a objetos, en su mayoría experimentales, permiten a una subclase reducir la
Orient a ción a O bje tO s. t eO ría y pr á c tic a
Ahora, dada la siguiente función no miembro,
void transmitirDatosRecientes(DatosTelemetria& d, const Hora& h) {
if (d.horaActual() >= h) d.transmitir(); }
¿qué pasa cuando se ejecutan las dos sentencias siguientes?
transmitirDatosRecientes(telemetria, Hora(60)); transmitirDatosRecientes(electricos, Hora(120));
En la primera sentencia, se transmite una serie de bits que consta solamente de un id y una
marcaHora. En la segunda sentencia, se transmite una serie de bits que consta de un id, una marcaHora y otros cuatro valores en coma flotante. ¿Cómo es esto? En última instancia, la implantación de la función transmitirDatosRecientes simplemente ejecuta la sentencia
d.transmitir(), que no distingue explícitamente la clase de d.
La respuesta es que este comportamiento es debido al polimorfismo. Básicamente, el poli-