1. Resumen en Español:
CAPÍTULO 10
“El paradigma Orientado a Objetos”
LIBRO:
Programming Languajes:
Principles and Paradigms
by
Maurizio Gabrielli and Simone Martini
AUTOR:
Cristian Arciniegas
2. Chapter 10.- The Object-Oriented Paradigm (10.2, 10.3)
10.2 CONCEPTOS FUNDAMENTALES
- Los siguientes cuatro requisitos se satisfacen con el paradigma orientado a objetos y podemos
tomarlos como características esenciales de este paradigma que separa un lenguaje orientado
a objetos de otro que no lo es.
a. Permiten la encapsulación y ocultar información.
b. Equipados con un mecanismo que, bajo ciertas condiciones, soporta la herencia de la
implementación de ciertas operaciones de otras, construcciones análogas.
c. Se enmarcan en una noción de compatibilidad definida en términos de las operaciones
admisibles para una determinada construcción.
d. Permiten la selección dinámica de operaciones en función del “tipo efectivo” de los
argumentos a los que se aplican.
- Por tanto, los tomaremos en el siguiente orden:
1. Encapsulación y Abstracción.
2. Subtipos, que es una relación de compatibilidad basada en la funcionalidad de un
objeto.
3. Herencia, que es la posibilidad de reutilizar la implementación de un método
previamente definido en otro objeto o para otra clase.
4. Selección de método dinámico.
- Estos son mecanismos distintos, cada uno exhibido por sí mismo por otros paradigmas. Como
se verá, es su interacción lo que hace que el paradigma orientado a objetos sea tan atractivo
para el diseño de software, incluso de grandes sistemas.
10.2.1 OBJETOS
- La construcción principal de los lenguajes orientados a objetos es (claramente) la de objeto.
Una cápsula que contiene tanto los datos como las operaciones que los manipulan y que
proporciona una interfaz para el mundo exterior a través de la cual se puede acceder al objeto.
- La idea metodológica que comparten los objetos con los ADT(Tipo de Dato Abstracto) es que
los datos deben “permanecer juntos” con las operaciones que los manipulan.
- Existe, entonces, una gran diferencia conceptual entre los objetos y los ADT. Aunque, en la
definición de un ADT, los datos y las operaciones están juntos, cuando declaramos una variable
de tipo abstracto, esa variable representa solo los datos que se pueden manipular mediante las
operaciones. Cada objeto, por otro lado, es un contenedor que (al menos conceptualmente)
encapsula tanto los datos como las operaciones. Un ejemplo es el objeto Counter c.
- La figura sugiere que podemos imaginar un objeto como un registro. Algunos campos
corresponden a datos (modificables), por ejemplo, el campo c; otros campos corresponden a
las operaciones que están permitidas para manipular los datos (es decir, reset, get e inc).
- Las operaciones se denominan métodos (o campos funcionales o funciones miembro) y
pueden acceder a los elementos de datos contenidos en el objeto, que se denominan variables
de instancia (o miembros o campos de datos). La ejecución de una operación se realiza
enviando al objeto un mensaje que consta del nombre del método a ejecutar, posiblemente
junto con los parámetros.
3. - Para representar tales invocaciones, la notación Java y C++: object.method(parameters)
- Un aspecto que no siempre está claro es que el objeto que recibe el mensaje es, al mismo
tiempo, también un parámetro (n implícito) del método invocado. Además, los miembros de
datos son accesibles utilizando el mismo mecanismo (si no están ocultos por cápsula,
obviamente). Si un objeto, o, tiene una variable de instancia, v, con o.v, solicitamos o por el
valor de v.
- Si bien la notación sigue siendo uniforme, observamos que acceder a los datos es un
mecanismo distinto al de invocar un método. La invocación de un método implica (o puede
implicar) la selección dinámica del método que se va a ejecutar, mientras que el acceso a un
elemento de datos es estático, aunque existen excepciones.
- El nivel de opacidad de la cápsula se define cuando se crea el objeto. Los datos pueden ser
más o menos directamente accesibles desde el exterior (por ejemplo, los datos podrían ser
directamente inaccesibles, con el objeto, en cambio, proporcionando "observadores"), algunas
operaciones pueden ser visibles en todas partes, otras visibles para algunos objetos, mientras
que, finalmente, otros son completamente privados, que solo están disponibles dentro del
propio objeto.
- Junto con los objetos, los lenguajes de este paradigma también ponen a disposición
mecanismos organizativos para los objetos. Estos mecanismos permiten agrupar objetos con
la misma estructura (o con estructura similar). Sin un principio organizativo, que haga explícita
la similitud de todos estos objetos, el programa perdería claridad expositiva y sería más difícil
de documentar. Además, desde el punto de vista de la implementación, está claro que sería
apropiado que el código de cada método se almacenara una sola vez, en lugar de ser copiado
dentro de cada objeto con estructura similar.
- Para resolver estos problemas, todo lenguaje orientado a objetos se basa en algunos principios
organizativos. Entre estos principios, el que es de lejos el más conocido es el de las clases,
aunque existe toda una familia de lenguajes orientados a objetos que carecen de clases.
10.2.2 CLASES
- Una clase es un modelo para un conjunto de objetos. Establece cuáles serán sus datos (tipo
junto con su visibilidad) y fija nombre, firma, visibilidad e implementación para cada uno de sus
métodos. En un lenguaje con clases, cada objeto pertenece (al menos) a una clase. Por
ejemplo, un objeto como el objeto Counter podría ser una instancia de la siguiente clase:
class Counter{
private int x;
public void reset() {
x = 0;
}
public void get() {
return x;
}
public void inc() {
x = x+1;
}
}
- Esta clase contiene la implementación de tres métodos los cuales son declarados public
(visibles para todos), mientras que la variable de instancia x es private(inaccesible desde fuera
del propio objeto pero accesible en la implementación de los métodos de esta clase).
- Los objetos se crean dinámicamente mediante una instancia de su clase. Se asigna un objeto
específico cuya estructura está determinada por su clase. Esta operación difiere de manera
fundamental de un lenguaje a otro y depende del estatus lingüístico de las clases. En C ++ y
Java las clases corresponden a un tipo y todos los objetos que instancian una clase A son
4. valores de tipo A. Tomando este punto de vista en Java, con su modelo basado en referencias
para variables, podemos crear un objeto Counter: Counter c = new Counter();
- El nombre c, de tipo Counter, está vinculado a un nuevo objeto que es una instancia de
Counter. Podemos crear un nuevo objeto, distinto al anterior pero con la misma estructura, y
vincularlo a otro nombre: Counter d = new Counter(); A la derecha del símbolo de asignación,
podemos ver dos operaciones distintas: la creación de los objetos (asignación del
almacenamiento necesario, new constructor) e inicialización (invocación del constructor de la
clase, representado por el nombre de la clase y paréntesis.
- Podemos suponer que el código de los métodos se almacena una sola vez en su clase y que
cuando un objeto necesita ejecutar un método específico, este código se buscará en la clase
de la que es una instancia. Para que esto suceda, el código del método debe acceder
correctamente a las variables de instancia que son diferentes para cada objeto y, por lo tanto,
no todas están almacenadas juntas en la clase sino dentro de la instancia.
- Los métodos de la clase Counter se refieren a sus variables de instancia con el nombre this.
Hemos visto que cuando un objeto recibe un mensaje solicitando la ejecución de un método, el
objeto en sí es un parámetro de método implícito. Cuando se hace una referencia a una
variable de instancia en el cuerpo de un método, hay una referencia implícita al objeto que
actualmente ejecuta el método.
- Desde el punto de vista lingüístico, el objeto actual generalmente se denota con un nombre
único, generalmente self o this. Por ejemplo, la definición del método inc podría escribirse de la
siguiente manera. Aquí, la referencia implícita al objeto actual se hace explícita:
public void inc() {
this.x = this.x+1;
}
- En el caso de una llamada a un método a través de this, el vínculo entre el método y el código
al que se refiere se determina dinámicamente. Este es un aspecto importante de este
paradigma.
- Algunos lenguajes permiten que algunas variables y métodos se asocien con clases (y no con
sus instancias). Se denominan variables y métodos de clase o estáticos. Las variables
estáticas se almacenan juntas en la clase y los métodos estáticos no pueden, obviamente,
referirse por this en su cuerpo porque no tienen un objeto actual.
----------------------------------------------------------------------------------------------------------------------------
Lenguajes basados en delegación
- También existen lenguajes orientados a objetos que carecen de clases. En estos lenguajes,
que se basan en la delegación (o en prototipos), el principio organizador no es la clase, sino la
delegación; es decir, el principio de que un objeto puede pedir a otro objeto (su padre) que
ejecute un método para él. Entre estos lenguajes, el progenitor es Self, que se desarrolló en
Xerox PARC y Stanford a finales de la década de 1980. Otros lenguajes basados en la
delegación son Dylan, que fue diseñado para distribuirse en una de las primeras versiones de
Netscape.
- Un objeto se crea a partir de la nada, o más a menudo, copiando (o clonando) otro objeto (que
se llama su prototipo). Los prototipos juegan el papel metodológico de clases. Proporcionan el
modelo para la estructuración y el funcionamiento de otros objetos, pero no son objetos
especiales. Lingüísticamente, son objetos ordinarios que, sin embargo, no se utilizan para
calcular, sino como modelo. Cuando se clona un prototipo, la copia mantiene una referencia al
prototipo como padre.
- Cuando un objeto recibe un mensaje pero no tiene un campo con ese nombre, pasa el mensaje
a su padre y el proceso se repite. Cuando el mensaje llega a un objeto que lo entiende, se
ejecuta el código asociado al método. El lenguaje asegura que la referencia a self se resuelva
5. correctamente como una referencia al objeto que originalmente recibió el mensaje. En general,
los datos del objeto se consideran iguales a los métodos.
- El mecanismo de delegación (y la unificación de código y datos) hace que la herencia sea
más poderosa. Es posible y natural crear objetos que comparten porciones de datos (en un
lenguaje basado en clases, esto solo es posible usando datos estáticos asociados con clases;
sin embargo, tal plantilla es demasiado “estática” para ser usada de manera rentable).
Además, un objeto puede cambiar la referencia a su padre de forma dinámica y, por lo tanto,
cambiar su propio comportamiento.
----------------------------------------------------------------------------------------------------------------------------
Objetos en el montón y en la pila. Todo lenguaje orientado a objetos permite la creación
dinámica de objetos. El lugar donde se crean los objetos depende del lenguaje.
La solución más común es asignar objetos en el montón y acceder a ellos usando referencias
(que serán punteros reales en los lenguajes que lo permitan, pero serán variables si el lenguaje
admite un modelo de referencia). Algunos lenguajes permiten la asignación y desasignación
explícita de objetos en el montón (C++ es uno); otros, probablemente la mayoría, optan por un
recolector de basura.
- La opción de crear objetos en la pila como variables ordinarias no es muy común. C ++ es uno
de esos lenguajes. Cuando se elabora una declaración de una variable de tipo clase, se realiza
la creación e inicialización de un objeto de ese tipo. El objeto se asigna como valor a la
variable. Las dos operaciones que contribuyen a la creación de un objeto (asignación e
inicialización) ocurren implícitamente en los casos en que no se llama explícitamente al
constructor. Algunos lenguajes, finalmente, permiten crear objetos en la pila y dejarlos sin
inicializar.
- En nuestro pseudolenguaje, asumimos que la creación de objetos ocurre explícitamente en el
montón y tiene un modelo de variable de referencia.
10.2.3 ENCAPSULACIÓN
- La encapsulación y la ocultación de información representan dos de los puntos cardinales de la
abstracción de datos. Todos los lenguajes permiten la definición de objetos ocultando alguna
parte de ellos (ya sean datos o métodos). En cada clase hay, por lo tanto, al menos dos vistas:
partes privadas y públicas. En la vista privada, todo es visible: es el nivel de acceso posible
dentro de la propia clase (por sus métodos). Sin embargo, a la vista del público, solo la
información exportada explícitamente es visible. Decimos que la información pública es la
interfaz de una clase, por analogía con los ADTs(Tipo de Dato Abstracto).
10.2.4 SUBTIPOS
- Se puede hacer que una clase se corresponda, de forma natural, con el conjunto de objetos
que son instancias de esa clase. Este conjunto de objetos es el tipo asociado con esa clase. En
los lenguajes tipados, esta relación es explícita. Una definición de clase también introduce la
definición de un tipo cuyos valores son las instancias de esa clase. En los lenguajes sin tipo
(como Smalltalk), la correspondencia es sólo convencional e implícita.
- Entre los tipos así obtenidos, se define una relación de compatibilidad en términos de las
operaciones posibles sobre valores del tipo. El tipo asociado a la clase T es un subtipo de S
cuando todo mensaje entendido (es decir, el que se puede recibir sin generar un error) de los
objetos de S también es entendido por los objetos de T. Si un objeto se representa como un
registro que contiene datos y funciones, la relación de subtipo corresponde al hecho de que T
es un tipo de registro que contiene todos los campos de S, así como posiblemente otros
campos. Más precisamente, teniendo en cuenta el hecho de que algunos campos de los dos
tipos podrían ser privados, podríamos decir que T es un subtipo de S cuando la interfaz de S es
6. un subconjunto de la interfaz de T (nótese la inversión: un subtipo es obtenido cuando tenemos
una interfaz más grande).
- Algunos lenguajes (como C++ y Java) utilizan una equivalencia de tipos basada en el nombre
que no se extiende adecuadamente a una relación de compatibilidad completamente
estructural. En tales lenguajes, no es, por tanto, la única propiedad estructural entre interfaces
que define la relación de subtipo, pero debe ser introducida explícitamente por el programador.
Este es el papel de la definición de subclases, o clases derivadas, que en nuestro
pseudolenguaje se denotarán usando la construcción neutral extending:
class NamedCounter extending Counter {
private String name;
public void set_name(String n) {
name = n;
}
public String get_name() {
return name;
}
}
- La clase NamedCounter es una subclase de Counter (que, a su vez, es una superclase de
NamedCounter), es decir, el tipo NamedCounter es un subtipo de Counter. Las instancias de
NamedCounter contienen todos los campos de Counter (incluso sus campos privados, pero son
inaccesibles en la subclase), además de tener nuevos campos introducidos por la definición. De
esta manera, se puede garantizar la compatibilidad estructural (una subclase se deriva
explícitamente de su superclase) pero esto se indica explícitamente en el programa.
Redefinición de un método
- En el ejemplo simple de NamedCounter, las subclases se limitan a extender la interfaz de la
superclase. Una característica fundamental del mecanismo de subclase es la capacidad de una
subclase para modificar la definición (la implementación) de un método presente en su
superclase. Este mecanismo se denomina anulación de método. Así, se puede definir una
subclase de Counter como:
class NewCounter extending Counter {
private int num_reset = 0;
public void reset() {
x = 0;
num_reset = num_reset + 1;
}
public int howmany_resets() {
return num_reset;
}
}
- La clase NewCounter amplía simultáneamente la interfaz de Counter con nuevos campos y
redefine el método de reinicio. Un método de reinicio enviado a una instancia de NewCounter
provocará la invocación de la nueva implementación.
Shadowing(Ocultamiento) Además de modificar la implementación de un método, una
subclase también puede redefinir una variable de instancia (o campo) definida en una
superclase. Este mecanismo se llama shadowing. Por razones de implementación, el
shadowing es significativamente diferente del overriding. Por el momento, simplemente
notamos que, en una subclase, una variable de instancia se puede redefinir con el mismo
nombre y el mismo tipo que en la superclase. Podríamos, por ejemplo, modificar los contadores
7. extendidos usando la siguiente subclase de NewCounter donde, por alguna (extraña) razón, el
valor inicial de num_reset se inicializa a 2 y se incrementa en 2 cada vez:
class EvenNewCounter extending NewCounter {
private int num_reset = 2;
public void reset() {
x = 0;
num_reset = num_reset + 2;
}
public int howmany_resets() {
return num_reset;
}
}
- Usando la noción habitual de visibilidad en lenguajes estructurados en bloques, cada referencia
a num_reset dentro de EvenNewCounter se refiere a la variable local (inicializada en 2) y no a
la declarada en NewCounter. Un mensaje reset enviado a una instancia de EvenNewCounter
provocará la invocación de la nueva implementación para reset que utilizará el nuevo campo
num_reset. Sin embargo, existe una gran diferencia, tanto a nivel semántico como a nivel de
implementación, entre overriding y shadowing.
Clases abstractas Para simplificar, hemos introducido el tipo asociado con una clase como el
conjunto de sus instancias. Sin embargo, muchos lenguajes permiten la definición de clases
que no pueden tener instancias porque la clase carece de la implementación de algún método.
En tales clases, solo existe el nombre y el tipo (es decir, la firma) de uno o más métodos; se
omite su implementación.
Las clases de este tipo se denominan clases abstractas. Las clases abstractas sirven para
proporcionar interfaces y pueden recibir implementaciones en subclases que redefinen (o,
define por primera vez) el método que carece de implementación. Las clases abstractas
también corresponden a tipos y el mecanismo que proporciona implementaciones para sus
métodos también genera subtipos.
La relación de subtipo En general, los lenguajes prohíben los ciclos en la relación de subtipo
entre clases, es decir, no podemos tener tanto A subtipo B como B subtipo A, a menos que A y
B coincidan. La relación de subtipo es, por tanto, un orden parcial. En muchos lenguajes, esta
relación tiene un elemento máximo: el tipo del cual todos los demás tipos definidos por clases
son subtipos. Denotaremos tal tipo con el nombre Object. Los mensajes que acepta una
instancia de Object son bastante limitados (es un objeto de máxima generalidad). En general,
encontramos un método de clonación que devuelve una copia del objeto, un método de
igualdad y algunos otros. Además, algunos de estos métodos pueden ser abstractos en Object
y, por lo tanto, deben ser redefinidos antes de poder usarse.
Se puede ver que no se garantiza que, dado un tipo A, exista un tipo B único que es el
supertipo inmediato de A. En el siguiente ejemplo:
abstract class A {
public int f();
}
abstract class B {
public int g();
}
class C extending A, B {
private x = 0;
8. public int f() {
return x;
}
public int g() {
return x+1;
}}
- El tipo C es un subtipo tanto de A como de B y no hay otro tipo incluido entre C y los otros dos.
La jerarquía de subtipos no es un árbol en general, sino sólo un gráfico orientado acíclico.
Constructores. Ya hemos visto que un objeto es una estructura compleja que incluye datos y
código, todos juntos. La creación de un objeto es, por tanto, también una operación de cierta
complejidad que consta de dos acciones distintas: primero, la asignación de la memoria
necesaria (en el montón o en la pila) y la inicialización adecuada de los datos del objeto. Esta
última acción la realiza el constructor de la clase. Es decir, por código asociado a la clase y
que el lenguaje garantiza que se ejecutará exactamente al mismo tiempo que se crea la
instancia. El mecanismo del constructor es de cierta complejidad porque los datos de un
objeto consisten no sólo en los declarados explícitamente en la clase cuya instancia se está
creando, sino también en los datos declarados en sus superclases. Además, a menudo se
permite más de un constructor para una clase.
Tenemos, por tanto, una serie de preguntas sobre constructores que se pueden resumir en las
siguientes:
- Selección de constructor. Cuando el lenguaje lo permite, hay más de un constructor para una
clase, entonces, ¿cómo se elige qué clase usar al crear un objeto específico?
En algunos lenguajes (por ejemplo, C++ y Java), el nombre del constructor es el mismo que el
de la clase. Múltiples constructores tienen todos el mismo nombre y deben distinguirse por su
tipo o el número de argumentos o ambos (por lo tanto, están sobrecargados, con resolución
estática). Dado que C++ permite la creación implícita de objetos en la pila, existen mecanismos
específicos para seleccionar el constructor apropiado a utilizar en cada caso. Otros lenguajes
permiten al programador elegir libremente los nombres de los constructores (pero los
constructores permanecen sintácticamente distintos de los métodos ordinarios) y requieren que
cada operación de creación esté siempre asociada con un constructor.
- Encadenamiento de constructores. ¿Cómo y cuándo se inicializa la parte del objeto que
pertenece a la superclase?
Algunos lenguajes se limitan a ejecutar el constructor de la clase cuya instancia están
construyendo. Si el programador tiene la intención de llamar a constructores de superclase,
debe hacerlo explícitamente. Otros lenguajes (entre ellos C++ y Java) garantizan, por otro lado,
que cuando se inicializa un objeto, el constructor de su superclase será invocado
(encadenamiento de constructor) antes de que el constructor específico de la subclase realice
cualquier otra operación.
Una vez más, hay muchas cuestiones que cada lenguaje tiene que resolver. Entre estos, los
dos más importantes son determinar qué constructor de superclase invocar y cómo determinar
sus argumentos.
9. 10.2.5 HERENCIA
- Hemos visto que una subclase puede redefinir los métodos de su superclase. Pero, ¿qué pasa
cuando la subclase no los redefine? En tales casos, la subclase hereda métodos de la
superclase, en el sentido de que la implementación del método en la superclase está disponible
para la subclase. Por ejemplo, NewCounter hereda de Counter el elemento de datos x y los
métodos inc y get (pero no reset, que es redefinido).
- De manera más general, podemos caracterizar la herencia como un mecanismo que permite la
definición de nuevos objetos basados en la reutilización de los preexistentes. La herencia
permite la reutilización del código en un contexto ampliable. Al modificar la implementación de
un método, una clase automáticamente pone la modificación a disposición de todas sus
subclases, sin que se requiera intervención por parte del programador.
- Es importante comprender la diferencia entre la relación de herencia y la de subtipo. El
concepto de subtipo tiene que ver con la posibilidad de utilizar un objeto en otro contexto. Es
una relación entre las interfaces de dos clases. El concepto de herencia tiene que ver con la
posibilidad de reutilizar el código que manipula un objeto. Es una relación entre las
implementaciones de dos clases.
- Estamos tratando con dos mecanismos que son completamente independientes. Tanto C++
como Java tienen construcciones que pueden introducir simultáneamente ambas relaciones
entre las dos clases, pero esto no significa que los conceptos sean los mismos.
- En la literatura, a veces vemos la distinción entre herencia de implementación (herencia) y
herencia de interfaz (nuestra relación de subtipo).
Herencia y visibilidad Hemos visto que hay dos visiones de cada clase: la privada y la pública,
esta última compartida entre todos los clientes de la clase. Una subclase es un cliente particular
de la superclase. Utiliza los métodos de la superclase para ampliar la funcionalidad de la
superclase, pero a veces tiene que acceder a algunos datos no públicos. Por tanto, muchos
lenguajes introducen una tercera vista de una clase: una para las subclases. Tomando el
término de C++, podemos referirnos a él como la vista protegida de una clase.
- Si la subclase tiene acceso a algunos detalles de la implementación de la superclase, la
subclase depende mucho más de la superclase. Cada modificación de la superclase requerirá
una modificación de la subclase. Desde el punto de vista pragmático, esto es razonable sólo si
las dos clases están "próximas" entre sí, por ejemplo, si pertenecen al mismo paquete. Cuanto
más fuerte sea el emparejamiento de las dos clases, más difícil será modificar y mantener el
sistema resultante. Sin embargo, hacer coincidir las interfaces públicas y protegidas puede
resultar demasiado restrictivo. Al poder acceder a la representación de las estructuras de datos,
la subclase podrá implementar sus propias operaciones de una manera más eficiente.
Herencia única y múltiple En algunos lenguajes, una clase puede heredar de una única
superclase inmediata. La jerarquía de herencia es, por tanto, un árbol y decimos que el
lenguaje tiene herencia única (o simple). Otros lenguajes, por otro lado, permiten que una clase
herede métodos de más de una superclase inmediata; la jerarquía de herencia en tal caso es
un grafo orientado acíclico y el lenguaje tiene herencia múltiple.
- Hay solo unos pocos lenguajes que soportan herencia múltiple (entre los que se encuentran
C++ y Eiffel), porque esto presenta problemas que no tienen una solución elegante ni a nivel
conceptual ni de implementación. Los problemas fundamentales se relacionan con los
conflictos de nombres. Tenemos un conflicto de nombres cuando una clase C hereda
simultáneamente de A y B, que proporcionan implementación para métodos con la misma
firma. El siguiente es un ejemplo simple:
class A {
int x;
10. int f() {
return x;
}
}
class B {
int y;
int f() {
return y;
}
}
class C extending A, B {
int h() {
return f();
}
}
----------------------------------------------------------------------------------------------------------------------------
Herencia y subtipos en Java
- La relación de subtipo se introduce en Java utilizando la cláusula extends (para definir
subclases) o la cláusula implements cuando una clase se declara subtipo de una o más
interfaces (para Java, una interfaz es un tipo de clase abstracta incompleta en la que solo los
nombres y firmas de métodos se incluyen - no incluyen implementaciones). La relación de
herencia se introduce con la cláusula extends siempre que la subclase no redefina un método
y, por lo tanto, usa una implementación de la superclase. Cabe señalar que nunca hay herencia
de una interfaz porque la interfaz no tiene nada que heredar. El lenguaje restringe cada clase a
tener una sola superclase inmediata (es decir, una sola superclase puede ser nombrada con
una extends), pero permite que una sola clase (o interfaz) implemente más de una interfaz:
interface A {
int f();
}
interface B {
int g();
}
class C {
int x;
int h() {
return x+2;
}
}
class D extends C implements A, B {
int f() {
return x;
}
int g() {
return x+1;
}
}
11. - Por tanto, Java tiene herencia única. La jerarquía de herencia es un árbol organizado por
cláusulas extends. Además, la relación de herencia es, en Java, siempre una sub jerarquía de
la jerarquía de subtipos.
- En el ejemplo, cuando al mismo tiempo tenemos herencia de superclase e implementación de
alguna interfaz abstracta, a menudo se denomina herencia mixta (porque los nombres de los
métodos abstractos en la interfaz se mezclan con las implementaciones heredadas). En
muchos manuales (e incluso en la definición oficial de Java...), se dice que Java tiene una
herencia única para las clases, pero una herencia múltiple para las interfaces. Según nuestra
terminología, no existe una verdadera herencia en lo que respecta a las interfaces.
----------------------------------------------------------------------------------------------------------------------------
----------------------------------------------------------------------------------------------------------------------------
Herencia y subtipos en C++
- Los mecanismos de C++ que permiten la definición de la relación de herencia, y los
responsables de la subtipificación, no son independientes. La definición de clases derivadas (el
término de C++ para subclase) introduce la relación de herencia. También introduce una
relación de subtipo cuando la clase derivada declara su clase base (que es la superclase) como
public; cuando la clase base no es pública, la clase derivada hereda de la clase base pero no
existe una relación de subtipo.
class A {
public:
void f() {...}
…
}
class B : public A {
public:
void g() {...}
…
}
class C : A {
public:
void h() {...}
…
}
- Ambas clases, B y C, heredan de A, pero solo B es un subtipo de A.
- Dado que la relación de subtipo sigue a la de subclase, con las herramientas vistas hasta
ahora, no podríamos introducir un subtipo de interfaz, es decir, una clase derivada de una
clase base que no proporciona implementaciones sino que solo corrige una interfaz. Es con
este fin que C++ introduce el concepto de clase base abstracta, en la que algunos métodos no
necesitan tener una implementación. En tales casos, como ocurre con las interfaces Java,
podemos tener subtipos sin herencia.
----------------------------------------------------------------------------------------------------------------------------
- ¿Cuál de los dos métodos llamados f se hereda en C? Podemos solucionar este problema de
tres formas diferentes, ninguna de las cuales es totalmente satisfactoria:
1. Prohibir los choques sintácticos de nombres.
2. Exija que cualquier conflicto sea resuelto por el programador calificando
apropiadamente cada referencia a un nombre que esté en conflicto. Por ejemplo, el
cuerpo de h en la clase C, debe escribirse como B :: f () o como A :: f (), que es la
solución adoptada por C++.
3. Decidir sobre una convención para resolver el conflicto, por ejemplo, favoreciendo la
primera clase nombrada en la cláusula extending
12. - En lo que respecta a la solución explícita, se puede ver que el conflicto podría observarse no en
C sino en una de sus subclases (si no se llama a f dentro de C). Por tanto, el diseñador debe
conocer la jerarquía de clases con cierta precisión. Sin embargo, en casos como este, es
metodológicamente mejor redefinir f en C. Por ejemplo:
class C extending A, B {
int f() {
return A :: f();
}
int h() {
return this.f();
}
}
de modo que en las subclases de C, no hay conflictos de nombres.
- Los problemas más interesantes de la herencia múltiple están presentes en el llamado
problema del diamante. Este caso surge cuando una clase hereda de dos superclases, cada
una de las cuales hereda de la misma superclase única. Una situación simple de este tipo es la
siguiente (la jerarquía de herencia en forma de diamante):
class Top {
int w;
int f() {
return w;
}
}
class A extending Top {
int x;
int g() {
return w+x;
}
}
class B extending Top {
int y;
int f() {
return w+y;
}
int k() {
return y;
}
}
class Bottom extending A,B {
int z;
int h() {
return z;
}
}
13. - En este caso, también, tenemos el problema habitual del conflicto de nombres, pero es
fundamentalmente la cuestión de implementación la que es más relevante. Es decir, idear una
técnica que permita la selección correcta y eficiente del código para f cuando se invoca este
método en una instancia de la clase Bottom.
- En conclusión, la herencia múltiple es una herramienta muy flexible para la combinación de
abstracciones correspondientes a distintas funcionalidades. Algunas de las situaciones en las
que trabaja se expresan mejor en realidad utilizando relaciones de subtipo (“heredando” de
clases abstractas). No existen soluciones simples, inequívocas y elegantes a los problemas que
plantea la herencia múltiple. El balance de costo-beneficio entre la herencia única y múltiple es
ambiguo.
10.2.5 BÚSQUEDA DE MÉTODO DINÁMICO
- La búsqueda (o envío) de métodos dinámicos es el corazón del paradigma orientado a objetos.
En esto, las características que ya se han discutido se combinan para formar una nueva
síntesis. En particular, la búsqueda de métodos dinámicos permite que coexistan la
compatibilidad de subtipos y la abstracción. Esto es, en particular, algo que se consideró
problemático para los tipos de datos abstractos.
- Conceptualmente, el mecanismo es muy simple, ya hemos visto que un método definido para
un objeto se puede redefinir (anular-overridden) en objetos que pertenecen a subtipos del
objeto original. Por lo tanto, cuando se invoca un método, m, sobre un objeto, o, puede haber
muchas versiones de m posibles para o. La selección de qué implementación para m se
utilizará en una invocación es una función del tipo de objeto que recibe el mensaje.
o.m (parámetros);
Tenga en cuenta que lo relevante es el tipo de objeto que recibe el mensaje, no el tipo de
referencia (o nombre) a ese objeto (que en cambio es información estática).
- Vamos a dar un ejemplo en nuestro pseudo-lenguaje con las clases y contadores vistos en
secciones anteriores, y en ese contexto ejecutamos el siguiente fragmento:
NewCounter n = new NewCounter();
Counter c;
c = n;
c.reset();
class Counter {
private int x;
public void reset() {
x = 0;
}
public int get() {
return x;
}
public void inc() {
x = x+1;
}
}
class NewCounter extending Counter {
private int num_reset = 0;
public void reset() {
x = 0;
num_reset = num_reset + 1;
}
public int howmany_resets() {
14. return num_reset;
}
}
- El tipo (estático) del nombre c es Counter pero se refiere (dinámicamente) a una instancia de
NewCounter. Por lo tanto, será el método reset de NewCounter el que se invoque. Tanto
Counter como NewCounter se almacenan en una estructura de datos cuyo tipo es su supertipo:
Counter V[100];
Ahora aplicamos el método reset a cada uno:
for (int i=1; i<100; i=i+1)
V[i].reset();
- La búsqueda dinámica nos asegura que se invocará el método correcto en cualquier contador.
En general, un compilador no podrá decidir cuál será el tipo de objeto cuyo método será
invocado, de ahí la dinámica de este mecanismo.
- El lector debería haber notado una cierta analogía entre la sobrecarga y la búsqueda de
métodos dinámicos. En ambos casos el problema es el mismo: el de resolver una situación
ambigua en la que un solo nombre puede tener más de un significado.
- Sin embargo, bajo sobrecarga, la ambigüedad se resuelve estáticamente, según el tipo de
nombres involucrados. En la búsqueda de métodos, por otro lado, la solución del problema está
en tiempo de ejecución y hace uso del tipo dinámico del objeto y no de su nombre. Sin
embargo, no es un error pensar en la búsqueda de métodos como una operación de
sobrecarga en tiempo de ejecución en la que el objeto que recibe el mensaje se considera el
primer argumento (implícito) del método cuyo nombre debe resolverse.
- Es importante observar explícitamente que la búsqueda de métodos dinámicos funciona incluso
cuando un método en un objeto invoca un método en el mismo objeto, como sucede en el
siguiente fragmento:
class A {
int a = 0;
void f() {g();}
void g() {a=1;}
}
class B extending A {
int b = 0;
void g() {b=2;}
}
- Supongamos ahora que tenemos un objeto b que es una instancia de B y queremos invocar en
b el método f heredado por b de A. Ahora, f llama a g: ¿cuál de las dos implementaciones de g
se ejecutará?
Recuerde que el objeto que recibe el mensaje también es un parámetro importante del método.
La llamada de g en el cuerpo de f se puede escribir más explícitamente como this.g(), donde,
recuerde, this es una referencia al objeto actual. El objeto actual es b y, por lo tanto, el método
que se invocará es el de la clase B. Se puede ver que, de esta manera, una llamada a un
método como this.g () en el cuerpo de f puede referirse a ( implementaciones de) métodos que
aún no están escritos y que estarán disponibles sólo más adelante a través de la jerarquía de
clases. Este mecanismo, a través del cual el nombre this se vincula dinámicamente al objeto
actual, se denomina vinculación tardía de sí mismo (o de this).
- Notemos explícitamente que, a diferencia del overriding, shadowing es un mecanismo
completamente estático. Considere, por ejemplo, el código:
class A {
int a = 1;
int f() {return -a;}
}
15. class B extending A {
int a = 2;
int f() {return a;}
}
B obj_b = new B(); A obj_a = obj_b;
print(obj_b.f()); print(obj_a.f());
print(obj_b.a); print(obj_a.a);
----------------------------------------------------------------------------------------------------------------------------
Envío dinámico en C ++
- En lenguajes como Java o Smalltalk, la invocación de cada método se realiza mediante el envío
dinámico. C++ tiene como objetivos de diseño, eficiencia de ejecución y compatibilidad con C,
lo que significa que el uso de una construcción C en un programa C++ debe ser eficiente como
en C.
- En C++, tenemos, por lo tanto, un método de envío estático (análogo a la llamada de función),
así como una forma de envío dinámico que se realiza mediante funciones virtuales. Cuando se
define un método (que es una función miembro en terminología C++), es posible especificar si
es una función virtual o no. Solo se permite overriding de funciones virtuales. El envío
dinámico se realiza en funciones de miembros virtuales.
- Observemos, dicho sea de paso, que no está prohibido definir, en alguna clase, una función del
mismo nombre y firma que una función no virtual definida en la superclase. En tal caso, no
tenemos redefinición, sino sobrecarga, y esto puede ser resuelto estáticamente por el
compilador usando el tipo de nombre usado para referirse a ese objeto. En el ejemplo que
sigue declaramos una clase A y una subclase B:
class A {
public:
void f() {printf("A");}
virtual void g() {printf("A");}
}
class B : public A {
public:
void f() {printf("B");}
virtual void g() {printf("B");}
}
Si, ahora, tenemos un puntero a de tipo A*, que apunta a una instancia de B, la invocación de
la función a-> f () imprime A, mientras que la invocación de la función virtual a-> g () imprime B.
----------------------------------------------------------------------------------------------------------------------------
- donde print denota un método que genera el valor entero pasado como argumento. Las dos
primeras invocaciones de print producen el valor 2 ‘dos veces’, como debería ser obvio. La
tercera llamada también imprimirá el valor 2, dado que, como ya se vio anteriormente, el
método f se ha redefinido en la clase B. De hecho, el objeto creado (con new B()) es una
instancia de B y, por lo tanto, cada acceso a él, incluso aquellos a través de una variable de tipo
A (como en el caso de obj_a), utiliza el método redefinido (que obviamente es utilizando los
campos redefinidos en la clase B). La última aparición de print, en cambio, produce el valor 1.
En este caso, de hecho, dado que no estamos tratando con la invocación de un método sino
accediendo a un campo, es el tipo de referencia actual el que determina qué campo es para ser
considerado. Dado que obj_a es de tipo A, cuando escribimos obj_a.a, el campo en cuestión es
el de la clase A (inicializado a uno).
16. - Esta distinción entre overring y shadowing, se explica a nivel de implementación utilizando el
hecho de que el objeto de instancia de la clase B también contiene todos los campos de las
superclases de B, como aclararemos en la siguiente sección.
----------------------------------------------------------------------------------------------------------------------------
Lenguajes multimétodo
- En los lenguajes que hemos considerado hasta ahora, la invocación de un método tiene la
forma: o.m(parámetros);
- La búsqueda de métodos dinámicos usa el tipo de tiempo de ejecución del objeto que recibe el
método, mientras que usa los tipos estáticos de los parámetros. En algunos otros lenguajes
(por ejemplo, CLOS), esta asimetría se elimina. Los métodos ya no están asociados con clases,
sino que son nombres globales. Cada nombre de método está sobrecargado por una serie de
implementaciones (que en este caso se denominan métodos múltiples) y el objeto en el que
se invoca el método se pasa a cada método múltiple. Al invocar un método múltiple, el código
que debe ejecutarse se elige dinámicamente en función del tipo (dinámico) tanto del receptor
como de todos los argumentos.
- En estos lenguajes, hablamos de envío múltiple en lugar de envío único en lenguajes donde
existe un receptor privilegiado.
- En lenguajes con despacho múltiple, la analogía entre despacho (múltiple) dinámico y
“sobrecarga dinámica” es más evidente; El envío múltiple consiste en la resolución (en tiempo
de ejecución) de una sobrecarga sobre la base de información de tipo dinámico.
----------------------------------------------------------------------------------------------------------------------------
10.3 ASPECTOS DE IMPLEMENTACIÓN
- Cada lenguaje tiene su propio modelo de implementación que está optimizado para las
características específicas que proporciona el lenguaje. Sin embargo, podemos señalar algunas
líneas comunes que indican los principales problemas y sus soluciones.
Objetos. Un objeto de instancia de la clase A puede usarse como el valor de cualquier
superclase de A. En particular, es posible acceder no solo a las variables de instancia (campos
de datos) explícitamente definidas en A, sino también a las definidas en sus superclases.
Un objeto se puede representar como si fuera un registro, con tantos campos como variables
haya en la clase de la que es instancia, además de todas las que aparecen en sus superclases.
- En un caso de shadowing, o más bien cuando se usa un nombre de una variable de instancia
de la clase con el mismo tipo en una subclase, el objeto contiene campos adicionales, cada uno
correspondiente a una declaración diferente (a menudo, el nombre usado en la superclase no
es accesible en la subclase, si no se utilizan calificaciones particulares, por ejemplo super). La
representación también contiene un puntero al descriptor de la clase de la que es una instancia.
- En el caso de un lenguaje con un sistema de tipos estáticos, esta representación permite la
implementación simple de compatibilidad de subtipos (en el caso de herencia única). Para cada
variable de instancia, el desplazamiento desde el inicio del registro se registra estáticamente. Si
el acceso a un objeto de instancia de una clase se realiza utilizando una referencia (estática)
que tiene como tipo una de las superclases de ese objeto, la verificación de tipo estático
asegura que el acceso solo se puede realizar a un campo definido en la superclase, que está
asignado en la parte inicial del registro.
Clases y herencia. La representación de clases es la clave de la máquina abstracta de
orientación a objetos. La implementación más simple e intuitiva es la que representa la
jerarquía de clases mediante una lista vinculada. Cada elemento representa una clase y
contiene (apunta a) la implementación de los métodos que están definidos o redefinidos en esa
clase. Los elementos se vinculan mediante un puntero que apunta desde la subclase a la
17. superclase inmediata. Cuando se invoca un método m de un objeto o que es una instancia de
una clase C, el puntero almacenado en o se usa para acceder al descriptor de C y determinar si
C contiene una implementación de m. Si no es así, el puntero se establece en la superclase y
se repite el procedimiento. Por ejemplo: (una implementación simple de herencia)
Enlace tardío de sí mismo. Un método se ejecuta de forma similar a una función. Un registro
de activación para las variables locales del método, los parámetros y toda la otra información se
inserta en la pila. Sin embargo, a diferencia de una función, un método también debe acceder a
las variables de instancia del objeto en el que se llama; la identidad del objeto no se conoce en
el momento de la compilación.
- Sin embargo, la estructura de dicho objeto es conocida (depende de la clase) y, por lo tanto, el
desplazamiento de cada variable de instancia en la representación de un objeto es
estáticamente conocido (está sujeto a condiciones que dependen del lenguaje). Un puntero al
objeto que recibió el método también se pasa como parámetro cuando se invoca un método.
Durante la ejecución del cuerpo del método, este puntero es el método de this. Cuando se
invoca el método en el mismo objeto que lo invoca, this todavía se pasa como parámetro.
Durante la ejecución del método, cada acceso a una variable de instancia utiliza el
desplazamiento de este puntero (en lugar de tener un desplazamiento de un puntero en los
registros de activación, como es el caso de las variables locales de funciones).
- Desde un punto de vista lógico, podemos suponer que el puntero this se pasa a través del
registro de activación normalmente con todos los demás parámetros, pero esto causaría un
acceso doblemente indirecto para cada variable de instancia (uno para acceder al puntero
usando el puntero del registro de activación y uno para acceder a la variable usando this). De
manera más eficiente, la máquina abstracta mantendrá el valor actual de this en un registro.
10.3.1 HERENCIA ÚNICA
- Bajo la hipótesis de que el lenguaje tiene un sistema de tipos estáticos, la implementación que
utiliza listas enlazadas puede ser reemplazada por otra más eficiente, en la que la selección del
método requiere un tiempo constante (en lugar de un tiempo lineal en la profundidad de la
jerarquía de clases).
- Si los tipos son estáticos, el conjunto de métodos que cualquier objeto puede invocar se conoce
en tiempo de compilación. La lista de estos métodos se guarda en el descriptor de clase. La
18. lista contiene no sólo los métodos que se definen o redefinen explícitamente en la clase, sino
también todos los métodos heredados de sus superclases.
- Siguiendo la terminología de C ++, usaremos el término vtable (tabla de funciones virtuales)
para referirnos a esta estructura de datos. Cada definición de clase tiene su propia vtable y
todas las instancias de la misma clase comparten la misma vtable. Cuando se define una
subclase, B, de la clase A, la vtabla de B se genera copiando la de A, reemplazando todos los
métodos redefinidos en B y agregando los nuevos métodos que B define en la parte inferior.
- La propiedad fundamental de esta estructura de datos es que, si B es una subclase de A, la
vtable de B contiene una copia de la vtable de A como parte inicial; los métodos redefinidos se
modifican adecuadamente. De esta manera, la invocación del método cuesta solo dos accesos
indirectos, siendo el desplazamiento de cada método en la vtable conocido estáticamente. Se
puede observar que esta implementación obviamente tiene en cuenta el hecho de que es
posible acceder a un objeto con una referencia que pertenece estáticamente a una de sus
superclases.
- En el ejemplo de la figura anterior, cuando se invoca el método f, el compilador calcula un
desplazamiento que permanece igual si f se invoca en un objeto de la clase A o en un objeto de
la clase B. Diferentes implementaciones de f definidas en las dos clases están ubicados en el
mismo desplazamiento en las vtables de A y B.
- En general, si tenemos una referencia estática pa de la clase A a algún objeto de una de sus
subclases, podemos compilar una llamada al método f (que, asumimos que es el n-ésimo en la
vtable de A) de la siguiente manera (hemos asumido que la dirección de un método ocupa w
bytes):
R1 := pa // acceder al objeto
R2 := *(R1) // vtable
R3 := *(R2 + (n − 1) × w) // indirecto a f
call *(R3) // llamada a f
- Ignorando el soporte para herencia múltiple, este esquema de implementación es casi el mismo
que se usa en C++.
Downcasting. Si la vtable de una clase también contiene el nombre de la clase en sí, la
implementación que hemos discutido permite conversiones descendentes en la jerarquía de
clases. Esto se llama downcasting y es un mecanismo que se utiliza con bastante frecuencia.
Permite la especialización del tipo de objeto corriendo en dirección opuesta a la jerarquía de
subtipos. Un ejemplo canónico del uso de este mecanismo se encuentra en algunos métodos
de librerías que se definen como lo más generales posible. La clase Object podría usar la
siguiente firma para definir un método para copiar objetos: Object clone(){...}
19. - La semántica es que clone devuelve una copia exacta del objeto que lo invocó (misma clase,
mismos valores en sus campos, etc.). Si tenemos un objeto o de la clase A, una invocación de
o.clone() devolverá una instancia de A, pero el tipo estático determinado por el compilador para
esta expresión es Object. Para poder usar esta nueva copia de manera significativa, tenemos
que "forzarla" al tipo A, lo que podemos indicar con una conversión: A a = (A) o.clone();
- Con esto, queremos decir que la máquina abstracta verifica que el tipo dinámico del objeto sea
realmente A (caso contrario, tendríamos un error de tipo en tiempo de ejecución).
10.3.2 EL PROBLEMA DE LA CLASE BASE FRÁGIL
- En el caso de herencia simple, la implementación del mecanismo descrito anteriormente es
razonablemente eficiente, dado que toda la información importante, excepto el puntero this,
está determinada estáticamente. Sin embargo, se puede demostrar que esta estática es la
fuente de problemas en un contexto conocido como el problema de la clase base frágil.
- Un sistema orientado a objetos se organiza en términos de un gran número de clases utilizando
una elaborada jerarquía de herencia. A menudo, algunas de estas clases generales las
proporcionan las librerías. Las modificaciones a una clase ubicada muy alto en la jerarquía, se
pueden sentir en sus subclases. Algunas superclases pueden, por lo tanto, comportarse de una
manera "frágil" porque una modificación aparentemente inocua de la superclase puede causar
el mal funcionamiento de las subclases.
- No es posible identificar la fragilidad analizando solo la superclase. Es necesario considerar
toda la jerarquía de herencia, algo que a menudo es imposible porque quien escribió la
superclase generalmente no tiene acceso a todas las subclases. El problema puede surgir por
varias razones. Aquí, sólo distinguiremos dos casos principales:
1. El problema es arquitectónico. Algunas subclases explotan aspectos de la
implementación de la superclase que se han modificado en la nueva versión. Este es un
problema extremadamente importante para la ingeniería de software. Puede limitarse
reduciendo la relación de herencia a favor de la relación de subtipo.
2. El problema es de implementación. El mal funcionamiento de la subclase depende solo
de cómo la máquina abstracta (y el compilador) ha representado la herencia. Este caso
a veces se conoce como el problema de la interfaz binaria frágil.
- En este texto, el primer caso no es nuestro principal interés; el segundo lo es. Un caso típico
aparece en el contexto de una compilación separada donde se agrega un método a una
superclase, incluso si el nuevo método no interactúa con nada más. Partamos de lo siguiente:
class Upper {
int x;
int f() {..}
}
class Lower extending Upper {
int y;
int g() {return y + f();}
}
la superclase se modifica así:
class Upper {
int x;
int h() {return x;}
int f() {..}
}
- Si la herencia se implementa como se describe en la Sect. 10.3.2, la subclase Lower (que ya
está compilada y vinculada a Super) deja de funcionar correctamente porque se ha cambiado el
desplazamiento utilizado para acceder al método f, ya que se determina estáticamente. Para
20. solucionar este problema es necesario recompilar todas las subclases de las clases
modificadas, solución que no siempre es fácil de realizar.
- Para obviar esta pregunta, es necesario calcular dinámicamente el desplazamiento de métodos
en la vtable (y también el desplazamiento de variables de instancia en la representación de
objetos), de alguna manera razonablemente eficiente. La siguiente sección describe una
posible solución.
10.3.3 DESPACHO DE MÉTODO DINÁMICO EN LA JVM
- En esta sección presentamos una descripción simple de la técnica utilizada por la máquina
virtual Java (JVM), la máquina abstracta que interpreta el lenguaje intermedio (bytecode)
generado por el compilador estándar de Java. Por razones de espacio, no podemos entrar en
los detalles de la arquitectura de la JVM; observamos que es una máquina basada en pilas (no
tiene registros accesibles para el usuario y todos los operandos de operación se pasan a una
pila contenida en el registro de activación de la función que está siendo ejecutado
actualmente). La JVM tiene módulos para verificar la seguridad de la operación. Nos limitamos
a discutir las líneas generales de la implementación de la herencia y el envío de métodos.
- En Java, la compilación de cada clase produce un archivo que la máquina abstracta carga
dinámicamente cuando el programa en ejecución se refiere a la clase. Este archivo contiene
una tabla (constant pool-el grupo de constantes) para los símbolos utilizados en la propia
clase. El grupo de constantes contiene entradas, por ejemplo, variables, métodos públicos y
privados, métodos y campos de otras clases utilizadas en el cuerpo de métodos, nombres de
otras clases mencionadas en el cuerpo de la clase, etc. Con cada variable de instancia y
nombre de método se registra información como la clase donde se definen los nombres y su
tipo. Cada vez que el código fuente usa el nombre, el código intermedio de la JVM busca el
índice de ese nombre en el grupo constante (para ahorrar espacio, no busca el nombre en sí).
Cuando durante la ejecución se hace referencia a un nombre por primera vez (utilizando su
índice), se resuelve utilizando la información del pool de constantes, se cargan las clases
necesarias (por ejemplo aquella en la que se introducen los nombres), se realizan
comprobaciones de visibilidad (por ejemplo, el método invocado debe existir realmente en la
clase que se refiere a él, no es privado, etc.); También se realizan comprobaciones de tipo. En
este punto, las máquinas abstractas guardan un puntero a esta información para que la
próxima vez que se use el mismo nombre, no sea necesario realizar la resolución por segunda
vez.
- La representación de métodos en un descriptor de clase puede considerarse análoga a la
vtable. La tabla de una subclase comienza con una copia de su superclase, donde los métodos
redefinidos tienen sus nuevas definiciones en lugar de las antiguas. Sin embargo, las
compensaciones no se calculan estáticamente. Cuando se va a ejecutar un método, se pueden
distinguir cuatro casos principales (que corresponden a distintas instrucciones de código de
bytes):
1. El método es estático. Esto es para un método asociado a una clase y no a una
instancia. No se puede hacer ninguna referencia (explícita o implícita) a this.
2. El método debe distribuirse dinámicamente (un método "virtual").
3. El método debe distribuirse dinámicamente y se invoca mediante this (un método
"especial").
4. El método proviene de una interfaz (es decir, de una clase completamente abstracta que
no proporciona implementación, un método de "interfaz").
- Ignorando el último caso por el momento, los otros 3 casos se pueden distinguir principalmente
por los parámetros pasados al método. En el primer caso, solo se pasan los parámetros
nombrados en la llamada. En el segundo caso, se pasa una referencia al objeto en el que se
llama al método. En el caso 3, se pasa una referencia a this. Por lo tanto, supongamos que
podemos invocar el método m en el objeto o como: o.m(parámetro)
21. - Usando la referencia a o, la máquina abstracta accede al grupo constante de su clase y extrae
el nombre de m. En este punto, busca este nombre en la tabla vtable de la clase y determina su
desplazamiento. El desplazamiento se guarda en caso de un uso futuro del mismo método en
el mismo objeto.
- Sin embargo, el mismo método también podría invocarse en otros objetos, posiblemente
pertenecientes a subclases de la que pertenece o (por ejemplo, un bucle for en cuyo cuerpo se
repite una llamada a m en todos los objetos de una matriz). Para evitar calcular el
desplazamiento cada vez (que sería el mismo independientemente de la clase efectiva de la
que se llama al método m es una instancia), el intérprete de JVM utiliza una técnica de
"reescritura de código". Esto sustituye a la instrucción de búsqueda estándar generada por el
compilador por una forma optimizada que toma como argumento el desplazamiento del método
en la vtable. Para arreglar ideas (y simplificar mucho), al traducir la invocación de un método
virtual m, el compilador podría generar la instrucción de código de bytes: invokevirtual index
donde index es el índice del nombre m en el grupo constante. Durante la ejecución de esta
instrucción, el intérprete de JVM calcula el desplazamiento d de m en su vtable y reemplaza la
instrucción anterior con lo siguiente: invokevirtual_quick d np
- Aquí, np es el número de parámetros que espera m (y que se encontrarán en la pila que forma
parte de su registro de activación). Cada vez que el flujo de control vuelve a esta instrucción, se
invoca m sin sobrecarga.
- Queda el caso de la invocación de un método de interfaz (es decir, el caso 4 anterior). En este
caso, el desplazamiento podría no ser el mismo en 2 invocaciones del mismo método en
objetos de diferentes clases. Considere la siguiente situación (en la que usamos sintaxis Java
en lugar de pseudocódigo):
interface Interface {
void foo();
}
public class A implements Interface {
int x;
void foo(){...}
}
public class B implements Interface {
int y;
int fie(){...}
int foo(){...}
}
Tanto A como B implementan Interface y, por lo tanto, son sus subtipos. Sin embargo, el
desplazamiento de foo es diferente en las dos clases. Considere nuestro ciclo habitual:
Interface V[10];
…
for (int i = 0; i<10; i=i+1)
V[i].foo();
En tiempo de ejecución, no sabemos si los objetos contenidos en V serán instancias de A, B o
alguna otra clase que implemente Interface. El compilador podría haber generado la instrucción
JVM para el cuerpo del bucle: invokeinterface index, 0
(el 0 sirve para llenar un byte que se utilizará en la versión “rápida”). No sería correcto
reemplazar directamente esta instrucción por una versión “rápida” que devuelve solo el
desplazamiento porque cambiar el objeto también podría cambiar la clase de la que es una
instancia. Lo que podemos hacer es guardar el desplazamiento pero no podemos destruir el
nombre original del método; por lo tanto, reescribimos la instrucción como:
invokeinterface_quick nome_di_foo, d
22. Aquí name_of_foo es información adecuada con la que reconstruir el nombre y la firma de foo;
d es el desplazamiento determinado de antemano. Cuando se vuelve a ejecutar esta
instrucción, el intérprete accede a la vtable utilizando el desplazamiento d y comprueba que
existe un método con el nombre y la firma solicitados. En el caso positivo, se invoca; en el caso
negativo, busca el método por nombre en la vtable, como si fuera la primera vez que fue vista, y
determina un nuevo desplazamiento d’, luego escribe este valor en el código en lugar de d.
10.3.4 HERENCIA MÚLTIPLE
- La implementación de la herencia múltiple plantea problemas interesantes e impone una
sobrecarga no despreciable. Los problemas son de 2 órdenes: por un lado, está el problema de
identificar cómo es posible adaptar la técnica vtable para manejar las llamadas a métodos; por
otro (y el problema es más interesante y también tiene un impacto sobre el lenguaje) está la
necesidad de determinar qué hacer con los datos presentes en las superclases. Esto dará lugar
a 2 interpretaciones diferentes de la herencia múltiple, a las que podemos referirnos como
herencia replicada y compartida, abordaremos estos problemas sucesivamente.
Estructura Vtable. Consideraremos el ejemplo de la figura siguiente. Está claro que no es
posible organizar la representación de una instancia de C, ni una vtable para C de tal forma que
la parte inicial coincida con la estructura correspondiente tanto en A como en B. (Un ejemplo de
herencia múltiple)
class A {
int x;
int f() {
return x;
}
}
class B {
int y;
int g() {
return y;
}
int h() {
return 2 * y;
}
}
class C extending A,B {
int z;
int g() {
return x + y + z;
}
int k() {
return z;
}
}
- Para representar una instancia de C, podemos comenzar con los campos de A y luego agregar
los campos de B. Finalmente, necesitamos enumerar los campos específicos de
C.(Representación de objetos y vtables para herencia múltiple)
23. - Sabemos que, usando la relación de subtipo, podemos acceder a una instancia de C usando
una referencia estática a cualquiera de los tres tipos A, B y C. Hay 2 casos distintos
correspondientes a las 2 vistas diferentes de una instancia de C. Si se trata de un acceso con
referencia estática de tipo C o de tipo A, la técnica descrita para herencia simple funciona
perfectamente (excepto que las compensaciones estáticas de las variables de instancia reales
en C deben tener en cuenta que las variables estáticas pertenecientes a B están en el medio).
- Cuando, por el contrario, una instancia de C se ve como un objeto de B, es necesario tener en
cuenta el hecho de que las variables de B no están al inicio del registro sino a una distancia, d,
de su inicio que está determinado estáticamente. Cuando, por tanto, accedemos a una
instancia de C a través de una referencia con tipo estático B, es necesario sumar la constante,
d, a la referencia.
- Surgen problemas similares para la estructura de la vtable. Una tabla para C se divide en dos
partes distintas: la primera contiene los métodos de A (posiblemente como redefiniciones) y los
métodos que realmente pertenecen a C. Una segunda parte comprende los métodos de B,
posiblemente con sus redefiniciones. En la representación de un objeto de instancia de C, hay
dos punteros a vtable. Correspondiente a la "vista como A y C", tendrá punteros a la vtable con
métodos de A y C. Correspondiente a la "vista como B", tendrá punteros a la vtable con los
métodos de B (tenga en cuenta que esto es una vtable para la clase C; la clase B tiene otra
vtable, que se usa para sus propias instancias.
- En general, cada superclase de una clase bajo herencia múltiple tiene su propia parte especial
en las vtables de sus subclases). La invocación de un método que pertenece a C, o que fue
redefinido o heredado de A, sigue las mismas reglas que la herencia simple. Para invocar un
método heredado (o redefinido) de B, el compilador debe tener en cuenta que el puntero a la
vtable para este método no reside al principio del objeto sino que está desplazado por d
posiciones. En el ejemplo de la figura, para llamar al método h de una instancia de C visto
como un objeto de tipo B (del cual, por lo tanto, tenemos un nombre estático, pb): se agrega d a
la referencia pb; utilizando un acceso indirecto, se obtiene la dirección de inicio de la segunda
vtable (la de B), luego se usa el método de invocación de desplazamiento estático apropiado h.
Esta llamada necesita una operación adicional (la primera adición) además de la requerida para
la herencia simple.
- Sin embargo, hemos descuidado la vinculación para this. ¿Qué referencia de objeto actual
deberíamos pasar a los métodos de C? Si se trata de métodos en la primera vtable (al que se
accede mediante un this que apunta al inicio del objeto, es decir con la “vista A y B”), es
necesario pasar el valor actual de this. Sin embargo, esto sería incorrecto para los métodos en
la segunda vtable (al que se accede usando el puntero this más el desplazamiento d).
Debemos distinguir dos casos:
● El método se hereda de B (el caso de h en la figura). En tal caso, es necesario pasar la
vista del objeto a través del cual hemos encontrado la vtable.
● El método se redefine en C (el caso de g). En este caso, el método puede referirse a
variables de instancia de A, por lo que es necesario pasarle una vista de la superclase.
24. - La situación es incómoda porque la búsqueda de métodos dinámicos requiere que esta
corrección del valor de this se realice en tiempo de ejecución. La solución más sencilla es
almacenar esta corrección en la vtable, junto con el nombre del método. Cuando se invoca el
método, esta corrección se sumará al valor actual de this. En nuestro ejemplo, las correcciones
se muestran en la figura (Representación de objetos y vtables para herencia múltiple) junto al
nombre del método asociado. La corrección se agrega a la vista del objeto a través del cual se
encontró la vtable.
- En general, si tenemos una referencia, pa, a una vista de la clase C de algún objeto, podemos
compilar una llamada al método h (que podemos asumir que es el n-ésimo en la vtable de B) de
la siguiente manera (la dirección de un método y la corrección cada uno ocupa w bytes):
R1 := pa // vista A
R1 := R1 + d // vista B
R2 := *(R1) // vtable de B
R3 := *(R2 + (n − 1) × 2 × w) // dirección de h
R2 := *(R2 + (n − 1) × 2 × w + w) // corrección
this := R1 + R2
call *(R3) // llamada a h
- Tenemos tres instrucciones y un acceso indirecto además de la secuencia para llamar a un
método usando herencia única.
Herencia múltiple replicada. La subsección anterior se ha ocupado del caso en el que una
clase hereda de 2 superclases. Estas superclases, sin embargo, pueden heredar por sí mismas
de una superclase común, produciendo un diamante, como discutimos en la Sección. 10.2.5:
class Top {
int w;
int f() {
return w;
}
}
class A extending Top {
int x;
int g() {
return w+x;
}
}
class B extending Top {
int y;
int f() {
return w+y;
}
int k() {
return y;
}
}
class Bottom extending A,B {
int z;
int h() {
return z;
}
}
25. - Las instancias y la vtable para A tienen una parte inicial que es una copia de la estructura
correspondiente a Top. Lo mismo ocurre con las instancias y la vtable replicada para B. Bajo la
herencia múltiple replicada, Bottom se construye utilizando el enfoque que ya hemos discutido
y, por lo tanto, consta de dos copias de las variables de instancia y los métodos de Top, como
se muestra en la figura siguiente (Implementación de herencia múltiple replicada).
- La implementación no plantea otros problemas además de los ya discutidos. Los conflictos de
nombres deben resolverse explícitamente (en particular, no será posible invocar el método f de
Top en un objeto de clase Bottom, ni asignar una instancia de Bottom a una referencia estática
de tipo Top, porque no sabríamos cuál de los dos copias de Top elegir).
Herencia múltiple compartida. La herencia múltiple replicada no siempre es la solución
conceptual que un ingeniero de software tiene en mente al diseñar una situación de diamante.
Cuando la clase en la parte inferior del diagrama contiene una única copia de la clase en la
parte superior, hablamos de herencia múltiple replicada. En tal caso, tanto A como B poseen su
propia copia de Top, pero Bottom solo tiene una copia.
- C++ permite la herencia compartida usando clases de base virtual. Cuando una clase se
define como virtual, todas sus subclases siempre contienen una sola copia de la misma, incluso
si hay más de una ruta de herencia, como ocurre en el caso del diamante. Con este
mecanismo, una clase se declara virtual o no virtual de una vez por todas. Si tenemos una
clase no virtual y, más tarde, descubrimos que necesitamos herencia con el intercambio, no hay
nada más que hacer que reescribir la clase y recompilar todo el sistema. Peor aún, una clase
es virtual para todas sus subclases, incluso si en algunos casos deseamos que sea virtual para
algunos y no virtual (que se replica) para otros. En tales casos, es necesario definir dos copias
de la clase, una virtual y otra no virtual.
- Con la herencia con el uso compartido suele haber un problema de conflicto de nombres. El
problema se resuelve de forma arbitraria mediante diferentes lenguajes. En C++, por ejemplo,
en el caso de un método de clase base virtual que se redefine en una subclase, se requiere
que siempre haya una redefinición que domine a todas las demás en el sentido de que aparece
en una clase que es una subclase de todas las demás clases donde se define este método. En
nuestro ejemplo, para el método f, la redefinición dominante es la de la clase B. Es, por tanto, la
26. heredada por Bottom. Si ambas clases A y B redefinen f, las definiciones de estas clases serían
ilegales en C++ porque no habría una redefinición dominante. Otros lenguajes permiten que las
clases heredadas elijan por qué camino se heredará el método; alternativamente, permiten una
calificación completa de los nombres para elegir explícitamente el método deseado. Como es
habitual, cuando se trata de herencia múltiple, no existe una solución elegante que sea
claramente mejor que cualquier otra.
- Ahora llegamos a la implementación de vtable para herencia múltiple compartida.
- En la figura anterior, la implementación utilizada en C++ se muestra esquemáticamente. C++ es
un lenguaje en el que todas sus subclases comparten una clase virtual. Dado que Bottom
contiene una única copia de Top, ya no es posible representar subclase y superclase de forma
contigua. A cada clase del diamante le corresponde una vista específica del objeto. En
correspondencia con cada vista, hay un puntero a la vtable correspondiente y un puntero a la
parte compartida del objeto. El acceso a las variables de instancia y métodos de Bottom, A y B
es el mismo que en el caso de herencia múltiple sin compartir. Para acceder a las variables de
instancia, o para invocar un método de Top, se requiere un acceso indirecto preliminar
(supongamos que f es el método n de la vtable para Top y cada dirección ocupa w bytes):
R1 := pa // vista Bottom
R1 := *(R1 + w) // vista Top
R2 := *(R1) // vtable de Top
R3 := *(R2 + (n − 1) × 2 × w) // dirección de f
R2 := *(R2 + (n − 1) × 2 × w + w) // corrección
this := R1 + R2
call *(R3) // llamada a h
- El valor necesario para corregir el valor de this es la diferencia entre la vista de la clase en la
que se declara el método y la vista de la clase en la que se redefine.
- Los lenguajes que permiten situaciones más elaboradas (por ejemplo, una sola superclase se
replica en algunas clases y se comparte en otras) requieren técnicas más sofisticadas que no
se pueden explicar en este libro.