Java – Paso a paso Tema 8

Written by lopezatienza on 05/09/2009 – 19:19 -

TEMA8. Orientación a objetos en Java

En esta Unidad Didáctica veremos los conceptos más avanzados sobre orientación a objetos en el lenguaje de programación Java como son el encapsulamiento, el polimorfismo y la herencia entre otros.

1. Encapsulamiento
2. Reutilización de código
3. Polimorfismo
4. Constructores y herencia

1. Encapsulamiento

A la hora de diseñar una clase podemos hacerlo de dos formas, dependiendo de lo que estemos intentando llevar a cabo en ese momento. Una primera aproximación, a bajo nivel, consiste en pensar en los detalles del objeto desde el principio, para lo cual hay que diseñar la clase que represente el objeto, decidir sus variables y métodos.

Una segunda forma, a más alto nivel, es pensar en el objeto de manera más general, pensando sólo en los servicios que el objeto debe proporcionar, y no parándonos en cómo han de implementarse dichos servicios. Se trata por tanto de tratar al objeto como si fuera un proveedor de servicios y a los programas u otros objetos que lo utilicen como sus clientes.

Este segundo nivel de abstracción funciona si las variables contenidas en el objeto deberían poder ser modificadas únicamente desde el interior del objeto y sólo por los métodos internos al objeto. De este modo un objeto quedará encapsulado del resto del sistema y solo interactuará con otras partes de un programa a través del conjunto de métodos expuestos, los cuales constituyen los servicios del objeto. A este conjunto de métodos que proporcionan servicios a otras partes del programa se le denomina interfaz del objeto.

Una de las ventajas es la flexibilidad ante el cambio de la implementación de los detalles de un sistema. A cambio de esto, cuando se haga un cambio interno el programador deberá mantener la interfaz externa de la clase, es decir, el cómo se ve la clase desde fuera. Otra de las ventajas es la seguridad: los datos internos son manipulados desde fuera exclusivamente a través de las interfaces externas, por lo que se mantienen siempre seguros de su mal uso.

El objeto expone su interfaz a otros objetos que actúan como clientes. Los datos quedan aislados en el interior del objeto y únicamente los métodos del propio objeto son los que pueden acceder a los datos.

Modificadores de acceso

En Java el encapsulamiento se puede implementar mediante los modificadores de acceso. Un modificador de acceso es una palabra reservada de Java que controla la visibilidad de los miembros de una clase. Las palabras reservadas public y private son los modificadores de acceso de uso más común que se utilizan en Java. Existe un tercer modificador de acceso, protected.

El uso de variables públicas en una clase provoca que se viole el principio de encapsulamiento. Al hacerse públicas permiten que código externo, lea o modifique el valor de dichas variables a voluntad. Por lo tanto, las variables de instancia deben ser definidas siempre con visibilidad privada y así solo pueden acceder a ellas los métodos de la clase.

Con respecto a los métodos, la visibilidad que se les aplique dependerá del propósito de ese método. Los métodos que proporcionan servicios deben ser declarados como públicos para que puedan ser invocados desde fuera. Estos métodos se suelen llamar métodos de servicio o simplemente servicios. Dado que los métodos privados no pueden ser usados desde fuera de la clase, el único propósito de un método privado sería el de servir de ayuda a otros métodos de la clase. Estos métodos suelen llamarse métodos de soporte.

Como ejemplo de la utilidad del encapsulamiento supongamos la siguiente clase Moneda que se muestra sin encapsular:

1. public class Moneda {
2. public static final int CARA = 0;
3. public static final int CRUZ = 1;
4. public int valor;
5. }

La clase anterior permite que un objeto de tipo moneda tome un valor no válido, como se hace por ejemplo en las siguientes líneas:

1. public class ProgramaMoneda {
2. public static void main(String[] _args) {
3. Moneda m = new Moneda();
4. m.valor = 3;
5. System.out.println(m.valor);
6. }
7. }

Si encapsulamos la clase Moneda, obtenemos una versión más larga pero a la vez más segura:

1. public class Moneda {
2.
3. public static final int CARA
4. public static final int CRUZ
5. private int valor;
6.
7. public void setValor(int v){
8. if (v == CARA || v == CRUZ)
9. valor = v;
10. else
11. throw new RuntimeException(“Valor inválido: ”+v);
12. }
13.
14. public int getValor(){
15. return valor;
16. } }

Para encapsular la clase se declara la variable valor como privada y para poder acceder a dicha variable se crean dos métodos accesotes: setValor( ), que establece el valor de dicha variable solamente cuando dicho valor sea correcto (o CARA o CRUZ). En caso de que el valor introducido sea incorrecto se generará una excepción en tiempo de ejecución informando que el valor introducido no es correcto. Para poder mostrar el valor de la variable se ha creado un segundo método llamado getValor( ) que simplemente devuelve el valor de la moneda. El modo de uso de la nueva clase encapsulada sería el siguiente:

1. public class ProgramaMoneda {
2. public static void main(String[] _args) {
3. Moneda m = new Moneda();
4. m.setValor(Moneda.CARA);
5. System.out.println(m.getValor());
6. m.valor = 3;//Dará error al compilar
7. m.setValor(3);//Dará error de ejecución
8. }
9. }

La línea 6 del fragmento de código directamente provocará que el programa no compile, puesto que en ella se está intentando acceder de forma directa a una variable privada. Por otro lado la línea 7 no dará error al compilar (si se omite el error de la línea 6 obviamente) pero producirá un error en ejecución, ya que en ella se está intentando dar un valor inválido a la moneda. Dado que la excepción es del tipo RuntimeException no hace falta rodear la sentencia 7 con try-catch o declararla en la cabecera del método con throws.

Métodos accesores

Tal como hemos estado comentando varias veces, es bueno tener dos métodos de acceso por cada variable privada que tengamos en nuestra clase: uno para establecer el valor de esa variable y otro para obtener el valor de esa variable. Esto ayuda al encapsulamiento de la información.

2. Reutilización de código

Escribir clases bien encapsuladas usualmente requiere más trabajo en las etapas iniciales de lo que habría sido requerido para producir la misma funcionalidad en otros modelos no orientados a objetos. No obstante, aunque esto si que es cierto, usando las técnicas de orientación a objetos se suele reducir el tiempo total requerido para producir programas. Esto ocurre por dos razones: la primera es que al escribir clases robustas, se requerirá menos tiempo para corregir fallos de programación (bugs). La segunda razón es que diseñando las clases cuidadosamente se puede conseguir reutilizarlas en muchas circunstancias, incluso diferentes a aquellas para las que habían sido creadas inicialmente.

Relaciones orientadas a objetos

La reutilización es posible mediante dos formas de relacionar clases entre sí: la primera es mediante relaciones de composición y la segunda mediante relaciones de herencia.

Las relaciones de composición conectan dos conceptos mediante el verbo “tiene” y las relaciones de herencia mediante el verbo “es”. Como ejemplo sencillo para describir estas dos relaciones consideremos la implementación en Java de una clase Hogar descrita en la siguiente frase:

“Un hogar es una casa que tiene una familia y un animal doméstico”. Esta descripción daría lugar al siguiente boceto de clase Java:

1. public class Hogar extends Casa {
2. Familia habitantes;
3. AnimalDomestico perro;
4. }

Nótese la correspondencia directa entre el verbo “es” y la palabra reservada extends y la correspondencia directa entre los elementos tras el verbo “tiene” y las variables habitantes y perro.

Herencia

Es el proceso en el cual una nueva clase es creada a partir de una clase ya existente. La clase nueva al extender de la original contendrá automáticamente las variables y métodos de la clase padre. Una vez extendida el programador puede añadir nuevas variables y métodos de la clase padre. Una vez extendida el programador puede añadir nuevas variables y métodos a la clase derivada o modificar los miembros heredados. Crear clases heredando de otras ya existentes es, en general, más rápido y “barato” que crearlas desde cero. Es por ello por lo que el objetivo principal de la herencia es la reutilización de código. Al usar componentes de software ya existentes nos aprovechamos no sólo del código fuente, sino del esfuerzo realizado en el diseño y pruebas fechas en el software existente.

Por ejemplo, todos los mamíferos comparten ciertas características comunes: tienen sangre caliente, tienen pelo y amamantan a sus descendientes. Ahora bien, si consideramos un subconjunto de los mamíferos, los caballos, vemos que ellos tienen todas las características de los mamíferos más algunas otras añadidas que les hacen diferentes de otros mamíferos. Si ahora trasladamos esta idea a términos de software, crearíamos una clase Mamifero que contendría una serie de variables y métodos que describirían el estado y el comportamiento de los mamíferos. Una clase Caballo podría ser derivada de la clase Mamifero heredando automáticamente todas sus propiedades y métodos. La clase Caballo podría referirse a las variables y métodos heredados como si hubieran sido declarados localmente. Nuevas variables y métodos podrían ser añadidos a la clase derivada para distinguir un caballo de otros mamíferos. La herencia de este modo permite modelar de manera jerárquica muchas situaciones del mundo real.

La clase original que es usada para derivar una nueva clase se llama clase padre, superclase o clase base. La clase derivada se llama clase hija o subclase. Java usa la palabra reservada extends para indicar que una clase está siendo derivada de una clase existente.

El proceso de derivación establece entre la clase padre y la clase hija una relación “es un” o “es una”. Esto quiere decir que la clase hija es una versión más especializada que la clase padre. Por ejemplo, un caballo (lo particular) es un mamífero (lo general). Así no todos los mamíferos son caballos, pero todos los caballos son mamíferos.

Como la clase padre representa lo general, y la clase hija lo particular, un cambio realizado en la clase padre afectará a todas sus clases hijas, pero no al contrario, es decir, un cambio en una subclase no afecta en nada a su clase padre.

Considérese como ejemplo de herencia la siguiente clase Libro (no se creará encapsulada en este ejemplo por sencillez):

1. public class Libro {
2. public int paginas = 0;
3. }

Supongamos que se quiere crear una clase Diccionario para representar un objeto diccionario, que además del número de páginas, tendrá un número de definiciones. Sin herencia podríamos crear directamente la siguiente clase:

1. public class Diccionario {
2. public int paginas = 0;
3. public int definiciones = 0;
4. }

Sin embargo, dado que un diccionario es un tipo particular de libro, que tiene todas sus características, podríamos usar la herencia en su definición, con lo que evitaríamos tener que crear explícitamente la variable paginas y sólo habría que añadir la nueva variable definiciones:

1. public class Diccionario extends Libro {
2. public int definiciones = 0;
3. }

Aunque de manera explícita no aparezca en su definición, la clase anterior posee dos variables, la variable paginas, heredada de Libro, y la variable definiciones, propia de Diccionario. De hecho se podría añadir un método numPaginas() a la clase Diccionario y acceder a paginas de igual forma que si estuviera declarada explícitamente en Diccionario tal y como puede verse en la línea 4 de la nueva definición de la clase:

1. public class Diccionario extends Libro {
2. public int definiciones = 0;
3. public void numPaginas() {
4. System.out.println(“Número de paginas = ”+paginas);
5. }
6. }

No solo se pueden acceder a las variables heredadas de manera directa desde dentro de la definición de la clase hija, sino también desde fuera de ella, tal y como puede verse en el siguiente programa de ejemplo:

1. public class Libreria {
2. public static void main(String[] _args) {
3. Diccionario collins = new Diccionario();
4. collins.paginas = 1500;
5. collins.definiciones = 55200;
6. }
7. }

Composición

La composición es el proceso por el cual se almacenan objetos como variables de instancia de otros objetos más globales que los contienen. De este modo la funcionalidad y los datos de los objetos contenidos puede ser utilizada por el objeto contendor. Este tipo de relación se usa cuando el objeto contenido es de naturaleza diferente a la del objeto contendor. En esos casos el uso de la herencia no está indicado.

Por ejemplo, supongamos una clase que represente un empleado de una empresa, y otra clase que represente a la propia empresa. Está claro que la empresa tiene un conjunto de empleados, por lo que una posible implementación de estas entidades sería:

1. class Empleado {
2. String nombre;
3. int numeroEmpleado;
4. }
5.
6. public class Empresa {
7. Empleado[] plantilla;
8. }

Como puede verse en la línea 7, la clase Empresa hace uso de la composición para almacenar la lista de sus empleados y lo hace como un array de objetos de tipo Empleado.

3. Polimorfismo

Una de las características muchas veces menos comprendidas y más potentes de la orientación a objetos es el polimorfismo. El término polimorfismo significa “tener muchas formas” y en POO se refiere al hecho de que una misma sentencia de código puede generar diferentes resultados dependiendo de la situación.

El polimorfismo lo proporciona Java mediante dos mecanismos, la sobrecarga y la sobrescritura, aunque los más teóricos dirían que el auténtico polimorfismo es el proporcionado únicamente por la sobrescritura.

Es obvio que cuando se construyen clases y se añaden métodos a ellas, hay circunstancias en las que interesa llamar a varios métodos con el mismo nombre. En Java hay dos maneras de hacer esto. En primer lugar usando el mismo nombre del método pero con diferentes argumentos y quizás con tipo de retorno también diferente, a lo que se le conoce como sobrecarga de métodos. En segundo lugar usando el mismo nombre, los mismos argumentos y el mismo tipo de retorno, en cuyo caso estamos hablando de sobrescritura.

Las reglas que rigen como han de implementarse la sobrecarga y la sobrescritura son las siguientes:

- En dos clases no relacionadas mediante herencia, no se pueden ni sobrecargar ni sobrescribir sus métodos: se pueden llamar como se quiera.
- En la clase en la que se define un método, o en una clase hija de ella, se puede crear otro método con el mismo nombre que el original, siempre que la lista de los argumentos difiera en, al menos, el tipo de uno de ellos. De este modo se está sobrecargando el nombre del método original. El que un método sólo difiera del original en su tipo de retorno no es causa suficiente para poder sobrecargarlo, por lo que se producirá error de compilación.
- En una clase hija de la clase que define el método original, el nombre del método podrá ser reutilizado con el mismo tipo y el mismo orden de los argumentos, así como el mismo tipo de retorno. En este caso el método de la clase hija estará sobrescribiendo al método de la clase padre.

Sobrecarga de nombre de métodos

En Java un método está unívocamente identificado por la combinación de su nombre de clase (junto al nombre del paquete que la contiene, si es que la clase pertenece a un paquete), el nombre del método y la secuencia exacta de los tipos de sus argumentos. Esta combinación se denomina firma del método. La sobrecarga (overload en inglés) es la reutilización del nombre de un método ya sea en la misma clase o en una clase hija de ella. La sobrecarga realmente no tiene nada que ver con la orientación a objetos, ya que un lenguaje procedimental podría proporcionar esta funcionalidad, siendo mera coincidencia el que los lenguajes orientados a objetos suelan ser más proclives a soportar la sobrecarga. Nótese que la sobrecarga no es nada más que un truco que permite que existan varios métodos con el mismo nombre, de ahí que este apartado se llame “sobrecarga de nombre de métodos” y no “sobrecarga de métodos”. Así por ejemplo todos los métodos siguientes son diferentes:

1. int suma(int a, double b)
2. int suma(int a, double b, int c)
3. int suma(double b, int a)
4. int suma(int a)
5. int suma()

Todos estos métodos tienen el mismo tipo de retorno y el mismo nombre, pero por tener diferentes tipos de argumentos o por estar éstos en distinto orden, se consideran métodos diferentes. Sin embargo los dos métodos que vienen a continuación no son considerados diferentes de los métodos 1 y 5 anteriores. El método 6 porque tiene el mismo número de argumentos y del mismo tipo que el método 1, y el método 7 porque sólo difiere del método 5 en el tipo de retorno, lo cual no es suficiente.

6. int suma(int i, double d)
7. double suma()

Una vez conocido en que consiste la sobrecarga, cabe preguntarse ¿para qué sirve?. Al programar, existen veces en las que se crean varios métodos que realizan funciones muy parecidas bajo condiciones diferentes. Por ejemplo, supongamos que creamos varios métodos que calculen el máximo de dos argumentos recibidos, para los tipos de datos int, long, float y double. A pesar de que la operación a realizar en los cuatro casos es muy parecida, un lenguaje que no soportara sobrecarga obligaría a crear cuatro métodos llamados de manera diferente, tales como:

1. int max_int(int a, int b)
2. long max_long(long a, long b)
3. float max_float(float a, float b)
4. double max_double(double a, double b)

Sin embargo Java, al soportar sobrecarga, permite llamar a los cuatro métodos con el mismo nombre, hecho que, aparte de descargar al programador de la tarea de decidir nombres de métodos, mejora la legibilidad del programa. Así se hace por ejemplo en la clase java.lang.Math en la cual sus programadores han creado cuatro versiones de un método max() para calcular el máximo de dos argumentos de los cuatro tipos básicos numéricos más importantes de Java:

1. int max(int a, int b)
2. long max(long a, long b)
3. float max(float a, float b)
4. double max(double a, double b)

Sobreescritura de métodos

Hemos visto que la sobrecarga es esencialmente un truco con los nombres de los métodos al tratar la lista de los argumentos como si fueran parte del nombre del método. La sobrescritura es algo más sutil, y está relacionada directamente con la herencia y por lo tanto con la naturaleza orientada a objetos de un lenguaje. Cuando se extiende una clase para producir una nueva, se tiene acceso a todos los métodos no privados de la clase padre. Sin embargo a veces se podría necesitar modificar el comportamiento de alguno de esos métodos heredados para adaptarlo a las necesidades de la nueva clase. En este caso lo que se hace es redefinir el método en la clase hija, a lo cual de se denomina en programación sobrescritura de métodos (overriding).

Hay un número de diferencias clave entre sobrecarga y sobrescritura:

- Los métodos sobrecargados se complementan entre sí; un método sobrescrito sin embargo reemplaza al método al que sobrescribe.
- Los métodos sobrecargados pueden existir en cualquier número dentro de la misma clase. Por otro lado cada método de una clase padre sólo puede ser sobrescrito como máximo una vez en una subclase.
- Los métodos sobrecargados deben tener diferentes listas de argumentos; los métodos sobrescritos deben tener listas de argumentos idénticas, tanto en tipo como en orden (si no fuera así se considerarían simplemente como métodos sobrecargados).
- El tipo de retorno de un método sobrecargado puede ser elegido libremente; el tipo de retorno de un método sobrescrito debe ser idéntico al del método que reemplaza.

Vimos en el apartado anterior que la sobrecarga permitía múltiples implementaciones de funcionalidades similares usando para ello métodos del mismo nombre. Por otro lado la sobrescritura se ha visto que permite modificar la implementación de un método rescribiéndolo en una subclase. Para ver la utilidad de la sobrescritura consideremos tres clases: la primera llamada Animal que represente un animal cualquiera, la segunda Mamifero, que será subclase de Animal, y la tercera Ave, también subclase de Animal. La clase Animal tendrá un método saludo() que mostrará por pantalla un mensaje indicando que se trata de un objeto de tipo Animal:

1. class Animal {
2. public void saludo() {
3. System.out.println(“Soy un animal!“);
4. }
5. }

En las dos clases hijas de Animal interesaría que el mensaje mostrado en saludo() fuera más específico, para lo cual se sobrescribe dicho método:

6. class Mamifero extends Animal {
7. public void saludo() {
8. System.out.println(“Soy un mamífero!“);
9. }
10. }
11. class Ave extends Animal {
12. public void saludo() {
13. System.out.println(“Soy un ave!“);
14. }
15. }

Considérese ahora el siguiente programa que hace uso de las tres clases anteriores:

16. public class Zoo {
17. public static void main(String[] _args) {
18. Animal[] animales = new Animal[3];
19. animales[0] = new Animal();
20. animales[1] = new Mamifero();
21. animales[2] = new Ave();
22. int i = (int) (3*Math.random());
23. animales[i].saludo();
24. }
25. }

En este programa en primer lugar se crea un array de 3 objetos de tipo Animal (línea 18). En dicho array introducimos 3 referencias a objetos que creamos, todos animales (de tipo Animal o subclases suyas). Esto es posible porque el array se define del tipo más general de las tres clases, por lo que todos los objetos de clases más específicas “tienen cabida” en él (líneas 19-21).

Posteriormente se genera un número entero aleatorio entre 0 y 2 y se almacena en la variable entera llamada i (línea 22). En la siguiente línea se accede con dicha variable i a uno de los animales almacenados en el array, de forma que lo que realmente se está haciendo es escoger uno de ellos al azar puesto que el valor de i es aleatorio.

Mediante una referencia de tipo Animal se ejecuta el método sobrescrito saludo() que de manera “inteligentemente” Java descubre el tipo real del objeto y muestra el mensaje correcto. Por ejemplo, si al ejecutar el programa la variable i tomara el valor 2 el mensaje que se mostraría por pantalla sería Soy un ave!. Si i tomara el valor 0, el mensaje sería Soy un Animal!. Esta capacidad de decidir en tiempo de ejecución el método a ejecutar se denomina en ensamblaje tardío (late binding en inglés) o ensamblaje dinámico (dynamic binding) y no la tienen los lenguajes procedimentales como C o Pascal, en donde es al compilar cuando se realiza el ensamblaje.

De hecho en C para implementar la funcionalidad del programa anterior tendríamos que decidir dentro de la función saludo() el mensaje a mostrar mediante sentencias if-else en función del tipo de animal. Sería algo parecido a lo siguiente:

1. #include
2. #include
3.
4. void saludo(int i)
5. {
6. if (i==0)
7. printf(“Soy un animal!”);
8. else if(i==1)
9. printf(“Soy un mamífero!”);
10. else
11. printf(“Soy un ave!”);
12. }
13.
14. int main()
15. {
16. int i = rand()%3;
17. saludo(i);
18. }

Si ahora se quisiera añadir un nuevo tipo de animal, por ejemplo un reptil, en el caso del programa C habría que modificar la función saludo() añadiendo una nueva condición que contemple el nuevo tipo de animal:

void saludo(int i) {
if (i==0)
printf(“Soy un animal!”);
else if(i==1)
printf(“Soy un mamífero!”);
else if(i==2)
printf(“Soy un ave!”);
else
printf(“Soy un reptil!”);
}

Sin embargo en el caso del programa Java sólo habría que crea la nueva clase Reptil:

class Reptil extends Animal{
public void saludo() {
System.out.println(“Soy un reptil!“);
}
}

La parte importante del programa Zoo (la línea 23) quedaría inalterada, ya que la referencia animales[i] es polimórfica y sabría por tanto encontrar la versión correcta del método saludo() a ejecutar. La única parte que habría que añadir al programa Zoo sería un nuevo objeto de tipo Reptil al array animales.

En Java existe un operador lógico a disposición del programador para determinar en tiempo de ejecución la clase a la que pertenece un objeto, es decir, programar de manera manual lo que hace automáticamente Java cuando se encuentra métodos sobrescritos. Dicho operador es instanceof y un ejemplo de uso sería el siguiente:

Animal animal = new Animal();
Ave ave = new Ave();
boolean b1 = animal instanceof Animal; //true
boolean b2 = ave instanceof Animal; //true
boolean b3 = animal instanceof Ave; //false

No obstante debe evitarse en la medida de lo posible el uso de instanceof prefiriéndose la sobrescritura “automática” de métodos.

Hasta aquí hemos visto como se puede sobrescribir un método, redefiniendo de este modo la funcionalidad heredada de la clase padre. Al invocar en la clase hija al método por su nombre, se ejecutará siempre la versión sobrescrita, quedando el método de la clase padre inaccesible

Sin embargo Java permite acceder al método original mediante el uso de la palabra clave super. Una llamada del tipo super.xxx() invoca al método xxx() de la clase padre, obteniéndose el mismo comportamiento que si no se hubiera sobrescrito.

Retomando al ejemplo anterior de las clases de animales, si en el método saludo() en vez de mostrar únicamente el mensaje del método sobrescrito, se quisiera mostrar además el mensaje de la clase padre, se podría redefinir el método saludo() en las clases Mamífero y Ave del siguiente modo:

class Mamifero extends Animal {
public void saludo() {
super.saludo();
System.out.println(“Soy un mamífero!“);
}
}

class Ave extends Animal{
public void saludo() {
super.saludo();
System.out.println(“Soy un ave!“);
}
}

No importa si el método “padre” está definido en la clase inmediatamente superior o en alguna más alejada en el árbol de jerarquía, con super se accede siempre al método de la clase superior inmediata, independientemente de su posición en el árbol. Hay que tener cuidado porque no se puede saltar más de un nivel en la jerarquía. Esto es, si tres clases A, B y C definen todas un método m() y todas ellas forman parte de una jerarquía de herencia en la que A es la clase raíz, B extiende de A y C extiende de B, entonces el método m() de la clase A no puede ser accedido desde la clase C. Resumiendo todo lo dicho, estos son los puntos clave a tener en cuenta cuando se sobrescriben métodos:
- Un método que tenga idéntico nombre, e idéntico número, tipo y orden de argumentos que un método en una clase padre es un método sobrescrito.
- Cada método de la clase padre puede ser sobrescrito en una subclase una sola vez. Esto es, porque no se pueden tener dos métodos idénticos en la misma clase.
- Los métodos sobrescritos deben devolver el mismo tipo que el método que sobrescriben.
- Un método xxx() de una clase superior es completamente reemplazado por el método sobrescrito xxx() de una clase hija a menos que desde éste se invoque al de arriba con super.xxx().

4. Constructores y herencia

La herencia, ya sabemos, hace que el código y los datos que están definidos en una clase padre estén disponibles para su uso en una subclase. Esto está sujeto a la visibilidad de forma que, por ejemplo, los miembros privados de la clase padre no son directamente accesibles desde los métodos de la subclase, incluso aunque realmente sabemos que existen. Sin embargo los constructores son un caso especial, ya que no pueden heredarse, sino que siempre deben definirse en la propia clase. Cuando se crea una clase normalmente se invoca a un constructor en la forma new MiClase(<Argumentos>). En estas condiciones debe existir un constructor definido en la propia clase MiClase que tenga una lista de argumentos del mismo tipo y el mismo orden que los usados tras la palabra reservada new. En el caso de los constructores no es suficiente con que exista un constructor con esa misma lista de argumentos en alguna clase padre, sino que es obligatorio que el constructor se defina explícitamente en la propia clase MiClase. La excepción a esta regla es el constructor por defecto.

El constructor por defecto no recibe argumentos y es creado por el compilador automáticamente si no se definen otros constructores en la clase. Es como un “regalo” del compilador para que tengamos que escribir menos. Este constructor por defecto no es heredado, y el compilador sólo lo crea si no proporcionamos otro constructor en el código fuente de la clase. Si nos molestamos en crear otro constructor (un constructor específico) el compilador deja de hacernos el “regalo” y deberíamos escribir nosotros el constructor sin argumentos en caso de que lo quisiéramos tener junto al específico. Aunque no se hereden los constructores, Java nos permite que no tengamos que escribir de nuevo todo el código de inicialización hecho en una clase padre cada vez que la extendamos. Para ello se puede invocar desde cualquier constructor de la clase hija a cualquier constructor de la clase padre mediante la palabra reservada super() Considérese como ejemplo de esto las dos siguientes clases Padre e Hija:

1. class Padre {
2. private int a;
3. public Padre(int _a) {
4. a = _a;
5. }
6. }
7.
8. class Hija extends Padre {
9. private int b;
10. public Hija(int _a, int _b){
11. super(_a);
12. b = _b;
13. }
14. }

Como puede verse en la línea 11, para inicializar la variable a de la clase Padre se ha invocado a su constructor mediante super(). Tras ello se inicializa la variable propia de la clase Hija directamente (línea 12). Nótese que la variable a es privada, por lo que no es accesible desde la clase Hija y la línea 11 no se podría haber sustituido por la siguiente: 11. a = _a; Es importante recordar que se puede invocar cualquier constructor de la clase padre desde cualquier constructor de la clase hija con super(), pero eso si, el compilador obliga a que se haga como la primera sentencia del cuerpo del constructor. Esto es para asegurar que las variables de la clase padre hayan sido inicializadas antes de ejecutar algún código en el constructor de la clase hija. Hasta aquí hemos visto como ejecutar constructores en sentido “vertical”, es decir, ejecutar constructores de una clase padre desde un constructor de una subclase. Pero al igual que ocurre con el resto de métodos, es interesante que se pueda invocar desde un constructor de una clase a otro constructor definido en esa misma clase. Esto sería una ejecución “horizontal” de constructores. Para realizar esto Java proporciona la palabra reservada this(). Veamos su utilidad con el siguiente ejemplo:

1. public class Clase {
2. private int a = 0;//Nunca negativo
3. private int b = -1;//Siempre negativo, nunca positivo
4.
5. public Clase(int _a) {
6. if (_a > 0)
7. a = _a;
8. }
9.
10. public Clase(int _a, int _b) {
11. this(_a);
12. if (_b < 0)
13. b = _b;
14. }
15.
16. }

La clase anterior proporciona dos constructores para inicializar sus variables que deben mantenerse siempre como se indica en el comentario. El primero de ellos (línea 5) permite crear un objeto con un valor de a siempre mayor o igual a cero, y dejaría a la variable b con su valor por defecto (-1). En este primer constructor se ha incluido código para asegurar que el valor de a no sea nunca negativo.

El segundo constructor (línea 10) inicializa las dos variables de la clase. Para ello aprovecha el código de verificación escrito en el primer constructor, en vez de repetirlo en su propio cuerpo, ejecutándolo con this() como primera línea de su cuerpo (línea 11). Tras ello se hace la verificación de que b sea siempre negativo, nunca positivo.

Al igual que ocurre con super(), this() debe ser siempre la primera sentencia de un constructor y es por ello por lo que no pueden coexistir estas dos palabras reservadas en un mismo constructor. De todas formas esto no es ningún problema, ya que por un lado si escribimos un constructor sin super() y sin this() el compilador inserta como sabemos una llamada al constructor por defecto de la clase padre super(). Cuidado con esto porque si en la clase padre el constructor por defecto no está disponible (por haber creado otro constructor especializado) tendremos un error de compilación y habría que incluir una llamada explícita con super() al constructor especializado de la superclase. Por otro lado si se hace una llamada explícita a otro constructor usando this(), entonces el compilador no incluirá la llamada al constructor por defecto. En este caso sólo se ejecutará el constructor de la clase padre si lo hace el constructor que hemos invocado con this() (o éste llama también con this() a otro que sea el que realmente haga super()).

Resumamos los conceptos clave sobre constructores y herencia:

- Los constructores no se heredan por lo que hay que dotar a la clase con tantos constructores como sean necesarios.
- Si no se define ningún constructor en la clase el compilador nos “regala” el constructor por defecto. El compilador no quita ese “regalo” si creamos un constructor especializado.

- Es bastante común crear varios constructores en una misma clase (constructores sobrecargados). Estos constructores pueden llamarse entre si con this(). Esta sentencia this() debe ser la primera del constructor.
- Si no se incluye ni this() ni super() explícitamente como primera sentencia del constructor, el compilador incluye una sentencia super() que invoca al constructor sin argumentos de la clase padre.

si los tiene. Es convenio de programación el definir los constructores de una clase como los primeros métodos, justo después de las variables de instancia.

<< Volver al tema anterior  || >> Ir al tema siguiente

 

Referencias:

 

http://www.jmordax.com/

 


Autor: Antonio Lopez Atienza


Tags:
Posted in Java | No Comments »

Leave a Comment

 

RSS
MCC D5E