- Sintaxis de la composición - Sintaxis de la herencia - Inicialización de la clase base - Delegación - Combinación de la composición y de la herencia - Cómo garantizar una limpieza apropiada - Ocultación de nombres - Cómo elegir entre la composición y la herencia - protected - Upcasting (generalización) - ¿Por qué "upcasting"? - Nueva comparación entre la composición y la herencia - La palabra clave final - Datos final - Métodos final - Clases final - Una advertencia sobre final - Inicialización y carga de clases - Inicialización con herencia
Una de las características más atractivas de Java es la posibilidad de reutilizar el código. Pero para ser verdaderamente revolucionario es necesario ser capaz de hacer mucho más que simplemente copiar el código y modificarlo.
En Java el código se reutiliza partiendo de las clases existentes. Se trata de utilizar las clases sin modificar el código existente. Hay dos formas de llevar a cabo esto. La primera de ellas es crear objetos de una clase existente en una nueva clase. Esto se llama composición. La nueva clase está compuesta de objetos de otras clases existentes.
La segunda técnica consiste en crear una nueva clase como un tipo de una clase existente. Esta técnica se denomina herencia y consiste en tomar la forma de la clase existente y añadirla código sin modificarla. La herencia es una de las piedras angulares de la programación orientada a objetos.
SINTAXIS DE LA COMPOSICIÓN
Hasta el momento hemos utilizado bastante la composición, colocando referencias a objetos dentro de las clases. Si queremos construir un objeto que almacene varios String, alguna primitiva y otro objeto de otra clase, para objetos no primitivos colocamos referencias dentro de la nueva clase, las primitivas se definen directamente:
//: reusing/SprinklerSystem.java // Composition for code reuse. class WaterSource { private String s; WaterSource() { System.out.println("WaterSource()"); s = "Constructed"; } public String toString() { return s; } } public class SprinklerSystem { private String valve1, valve2, valve3, valve4; private WaterSource source = new WaterSource(); private int i; private float f; public String toString() { return "valve1 = " + valve1 + " " + "valve2 = " + valve2 + " " + "valve3 = " + valve3 + " " + "valve4 = " + valve4 + "\n" + "i = " + i + " " + "f = " + f + " " + "source = " + source; } public static void main(String[] args) { SprinklerSystem sprinklers = new SprinklerSystem(); System.out.println(sprinklers); } } /* Output: WaterSource() valve1 = null valve2 = null valve3 = null valve4 = null i = 0 f = 0.0 source = Constructed *///:~
Si nos fijamos en ambas clases vemos un método especial, toString(). Todo objeto no primitivo tiene un método toString() que se invoca cuando el compilador quiere una cadena de caracteres String pero lo que tiene es un objeto. Así, en la expresión contenida en SprinklerSystem.toString():
"source= "+source;
Estamos intentando añadir un objeto WaterSource a un String. Como sólo podemos añadir un objeto String a otro String, el compilador convierte source en un objeto String invocando toString() y así se puede utilizar en System.out.println(). Siempre que queramos este comportamiento en una clase, debemos crear un método to String().
Como vemos, las primitivas se inicializan a cero, las referencias a objetos se inicializan con el valor null y si tratamos de invocar métodos con ellas obtendremos un error en tiempo de ejecución. La referencia null sí que se puede imprimir.
El compilador no crea un objeto predeterminado para todas las referencias ya que esto supondría un gasto adicional de recursos innecesarios en muchos casos. Para inicializar las referencias lo podermos hacer:
1. En el lugar donde los objetos se definan. Esto significa que estarán siempre inicializados antes de que se invoque el constructor.
2. En el constructor correspondiente a esa clase.
3. Justo antes de donde se necesite el objeto. Esto se denomina inicialización diferida. Resulta útil cuando la creación de objetos es muy cara y no se necesita crear el objeto todas las veces.
4. Utilizando la técnica de inicialización de instancia.
Vamos a ver todas estas técnicas con un ejemplo:
//: reusing/Bath.java // Constructor initialization with composition. import static net.mindview.util.Print.*; class Soap { private String s; Soap() { print("Soap()"); s = "Constructed"; } public String toString() { return s; } } public class Bath { private String // Initializing at point of definition: s1 = "Happy", s2 = "Happy", s3, s4; private Soap castille; private int i; private float toy; public Bath() { print("Inside Bath()"); s3 = "Joy"; toy = 3.14f; castille = new Soap(); } // Instance initialization: { i = 47; } public String toString() { if(s4 == null) // Delayed initialization: s4 = "Joy"; return "s1 = " + s1 + "\n" + "s2 = " + s2 + "\n" + "s3 = " + s3 + "\n" + "s4 = " + s4 + "\n" + "i = " + i + "\n" + "toy = " + toy + "\n" + "castille = " + castille; } public static void main(String[] args) { Bath b = new Bath(); print(b); } } /* Output: Inside Bath() Soap() s1 = Happy s2 = Happy s3 = Joy s4 = Joy i = 47 toy = 3.14 castille = Constructed *///:~
En el constructor Bath primero se ejecuta una instrucción antes de las inicializaciones. Si no se realiza la inicialización en el punto de definición, no se garantiza la inicialización antes de enviar un mensaje a una referencia al objeto y, por tanto, tendremos un error en tiempo de ejecución.
Ejercicio 1. Crea una clase simple. Dentro de una segunda clase, define una referencia a un objeto de la primera clase. Utiliza el mecanismo de inicialización diferida para instanciar este objeto.
SINTAXIS DE LA HERENCIA
La herencia es una parte esencial de Java y de todos los lenguajes orientados a objetos. Recuerda que siempre que creamos una clase heredamos de la clase raíz Object.
Cuando heredamos lo que hacemos es decir "esta clase es similar a la antigua clase". La herencia tiene una sintaxis especial, antes de la llave de apertura de la clase se utiliza la palabra clave extends seguida del nombre de la clase base. Así obtenemos todos los campos y métodos de la clase base. Veamos un ejemplo:
//: reusing/Detergent.java // Inheritance syntax & properties. import static net.mindview.util.Print.*; class Cleanser { private String s = "Cleanser"; public void append(String a) { s += a; } public void dilute() { append(" dilute()"); } public void apply() { append(" apply()"); } public void scrub() { append(" scrub()"); } public String toString() { return s; } public static void main(String[] args) { Cleanser x = new Cleanser(); x.dilute(); x.apply(); x.scrub(); print(x); } } public class Detergent extends Cleanser { // Change a method: public void scrub() { append(" Detergent.scrub()"); super.scrub(); // Call base-class version } // Add methods to the interface: public void foam() { append(" foam()"); } // Test the new class: public static void main(String[] args) { Detergent x = new Detergent(); x.dilute(); x.apply(); x.scrub(); x.foam(); print(x); print("Testing base class:"); Cleanser.main(args); } } /* Output: Cleanser dilute() apply() Detergent.scrub() scrub() foam() Testing base class: Cleanser dilute() apply() scrub() *///:~
Vemos que con el método append() de la clase Cleanser, se concatenan cadenas de caracteres con s utilizando +=. Tanto la clase Cleanser como Detergent contienen un método main(). De esta forma podemos probar cada una de las clases, no es necesario eliminar el método cuando terminemos, podemos dejarlo para pruebas posteriores. Sólo se invocará el método main de la clase especificada en la línea de comandos. Si escribimos java Detergent, se invocará Detergent.main(), pero si escribimos java Cleanser se invocará Cleanser.main(), incluso aunque Cleanser no sea una clase pública o tenga acceso de paquete, si el método main() es público se podrá acceder a él.
La clase Detergent.main() llama a Cleanser.main() pasándole los mismos argumentos de la línea de comandos, sin embargo le podemos pasar cualquier matriz de objetos String.
Es importante que los métodos de Cleanser sean públicos. Si no se utiliza ningún especificador de acceso, los miembros tendrán acceso de paquete, esto sólo permite el acceso a otros miembros del mismo paquete. Así, si alguna clase heredara de Cleanser, sólo podría acceder a los miembros de tipo public. Para definir la herencia debemos definir los campos private y los métodos como public. Los miembros de tipo protected también permiten el acceso a las clases derivadas. En algunos casos podemos hacer excepciones, pero esta directriz suele resultar bastante útil.
Observamos que Cleanser tiene una serie de métodos en su interfaz. Al derivar Detergent de Cleanser, automáticamente obtiene todos esos métodos como parte de su interfaz. Por tanto, podemos considerar la herencia como una forma de reutilizar la clase.
En la clase Detergent vemos que podemos tomar un método de la clase base y modificarlo, scrub(). Si quisiéramos invocar el método de la clase base desde la nueva versión, es decir, llamar al método scrub() de Cleanser desde el método scrub() de Detergent, no podríamos ya que se produciría una llamada recursiva. Para resolver este problema se utiliza la palabra super que hace referencia a la clase base. Así, para invocar al método scrub() de Cleanser desde Detergent escribiríamos super.scrub().
Cuando heredamos una clase, no estamos limitados a utilizar los métodos de la clase base sino que podemos añadir nuevos métodos, en la clase Detergent vemos el método foam().
En el método main() de Detergent vemos como se invocan los métodos de Cleanser y también los de la propia clase Detergent.
Ejercicio 2. Crea una nueva clase que herede de la clase Detergent. Sustituye el método scrub() y añade un nuevo método denominado sterilize.
Inicialización de la clase base
Como ahora tenemos dos clases (la clase base y la derivada), puede resultar confuso imaginar cómo será el objeto resultante generado por una clase derivada. Desde fuera parece que la nueva clase tiene la misma interfaz que la clase base y quizá algunos métodos y campos adicionales. Pero el mecanismo de herencia no se limita a copiar la interfaz de la clase base. Cuando creamos un objeto de la clase derivada, éste contiene en su interior un subobjeto de la clase base idéntico al que hubiéramos obtenido al crear un objeto de la clase base. Visto desde fuera, el subobjeto de la clase base está envuelto por el objeto de la clase derivada.
Es esencial que el objeto de la clase base se inicialice correctamente, ésto sólo se garantiza realizando la inicialización en el constructor invocando al constructor de la clase base, que es quien tiene los conocimientos y privilegios para llevar a cabo correctamente la inicialización de la clase base. Java inserta automáticamente llamadas al constructor de la clase base dentro del constructor de la clase derivada. El siguiente ejemplo muestra este mecanismo en acción con tres niveles de herencia:
//: reusing/Cartoon.java // Constructor calls during inheritance. import static net.mindview.util.Print.*; class Art { Art() { print("Art constructor"); } } class Drawing extends Art { Drawing() { print("Drawing constructor"); } } public class Cartoon extends Drawing { public Cartoon() { print("Cartoon constructor"); } public static void main(String[] args) { Cartoon x = new Cartoon(); } } /* Output: Art constructor Drawing constructor Cartoon constructor *///:~
La construcción tiene lugar desde la base hacia "afuera", así, la clase base se inicializa antes de que los constructores de la clase derivada puedan acceder a ella. Incluso si no creamos un constructor para Cartoon() el compilador sintetizaría un constructor predeterminado que invocaría al constructor de la clase base.
Ejercicio 3. Demuestra la afirmación anterior.
Ejercicio 4. Demuestra que los constructores de la clase base (a) se invocan siempre y (b) se invocan antes que los constructores de la clase derivada.
Ejercicio 5. Crea dos clases, A y B, con constructores predeterminados (listas de argumentos vacías) que impriman un mensaje informando de la construcción de cada objeto. Crea una nueva clase llamada C que herede de A, y crea un mienbro de la clase B dentro de C. No crees un constructor para C. Crea un objeto de la clase C y observa los resultados.
Constructores con argumentos
En el ejemplo anterior se utilizan constructores predeterminados (sin argumentos). Para el compilador es fácil invocar estos constructores ya que no hay dudas sobre los argumentos que hay que pasar. Si queremos invocar un constructor de la clase base con argumentos, es necesario hacer una llamada a este constructor utilizando la palabra clave super y la lista de argumentos apropiada.
//: reusing/Chess.java // Inheritance, constructors and arguments. import static net.mindview.util.Print.*; class Game { Game(int i) { print("Game constructor"); } } class BoardGame extends Game { BoardGame(int i) { super(i); print("BoardGame constructor"); } } public class Chess extends BoardGame { Chess() { super(11); print("Chess constructor"); } public static void main(String[] args) { Chess x = new Chess(); } } /* Output: Game constructor BoardGame constructor Chess constructor *///:~
Si no se invoca al constructor de la clase base en BoardGame(), el compilador dirá que no encuentra un constructor de la forma Game(). Además, la llamada al constructor de la clase base debe ser lo primero que hagamos en el constructor de la clase derivada (si lo olvidamos el compilador avisará).
Ejercicio 6. Utilizando Chess.java, demuestra las afirmaciones del párrafo anterior.
Ejercicio 7. Modifica el ejercicio 5 de modo que A y B tengan constructores con argumentos en lugar de constructores predeterminados. Escribe un constructor para C que realice toda la inicialización dentro del constructor de C.
Ejercicio 8. Crea una clase base que sólo tenga un constructor no predeterminado y una clase derivada que tenga un constructor predeterminado (sin argumentos) y otro no predeterminado. En los constructores de la clase derivada, invoca al constructor de la clase base.
Ejercicio 9. Crea una clase denominada Root que contenga una instancia de cada una de las siguientes clases (que también deberá crear):Component1, Component2 y Component3. Deriva una clase Stem de Root que también contenga una instancia de cada "componente". Todas las clases deben tener constructores predeterminados que impriman un mensaje relativo a la clase.
Ejercicio 10. Modifica el ejercicio anterior de modo que cada clase sólo tenga constructores no predeterminados.
DELEGACIÓNUna tercera relación, no directamente soportada en Java, se denomina delegación. Está a caballo entre la herencia y la composición, se trata de incluir un objeto miembro en la clase que estemos construyendo (como en la composición), y al mismo tiempo exponer todos los métodos del objeto miembro en la nueva clase (como en la herencia). Por ejemplo, una nave espacial (spaceship) necesita un módulo de control:
/: reusing/SpaceShipControls.java public class SpaceShipControls { void up(int velocity) {} void down(int velocity) {} void left(int velocity) {} void right(int velocity) {} void forward(int velocity) {} void back(int velocity) {} void turboBoost() {} } ///:~
Una forma de construir la nave consiste en emplear el mecanismo de herencia:
//: reusing/SpaceShip.java public class SpaceShip extends SpaceShipControls { private String name; public SpaceShip(String name) { this.name = name; } public String toString() { return name; } public static void main(String[] args) { SpaceShip protector = new SpaceShip("NSEA Protector"); protector.forward(100); } } ///:~
Un objeto SpaceShip no es realmente "un tipo de" objeto SpaceShipControls. Es más preciso decir que la nave espacial (el objeto SpaceShip) contiene un módulo de control (objeto SpaceShipControls) y que, al mismo tiempo, todos los métodos de SpaceShipControls deben quedar expuestos en el objeto SpaceShip. La delegación permite resolver esto:
//: reusing/SpaceShipDelegation.java public class SpaceShipDelegation { private String name; private SpaceShipControls controls = new SpaceShipControls(); public SpaceShipDelegation(String name) { this.name = name; } // Delegated methods: public void back(int velocity) { controls.back(velocity); } public void down(int velocity) { controls.down(velocity); } public void forward(int velocity) { controls.forward(velocity); } public void left(int velocity) { controls.left(velocity); } public void right(int velocity) { controls.right(velocity); } public void turboBoost() { controls.turboBoost(); } public void up(int velocity) { controls.up(velocity); } public static void main(String[] args) { SpaceShipDelegation protector = new SpaceShipDelegation("NSEA Protector"); protector.forward(100); } } ///:~
Si nos fijamos todos los métodos se redirigen hacia el objeto controls subyacente y la interfaz es la misma que con la herencia. Con la delegación tenemos más control ya que podemos decidir qué métodos utilizar, en este caso del objeto controls.
Aunque Java no soporta directamente la delegación, las herramientas de desarrollo sí que suelen hacerlo.
Ejercicio 11. Modifica Detergent.java de modo que utilice el mecanismo de delegación.
COMBINACIÓN DE LA COMPOSICIÓN Y DE LA HERENCIA
Es bastante común utilizar la composición y la herencia conjuntamente. Vamos a crear una clase compleja utilizando herencia y composición así como la necesaria inicialización mediante los constructores:
//: reusing/PlaceSetting.java // Combining composition & inheritance. import static net.mindview.util.Print.*; class Plate { Plate(int i) { print("Plate constructor"); } } class DinnerPlate extends Plate { DinnerPlate(int i) { super(i); print("DinnerPlate constructor"); } } class Utensil { Utensil(int i) { print("Utensil constructor"); } } class Spoon extends Utensil { Spoon(int i) { super(i); print("Spoon constructor"); } } class Fork extends Utensil { Fork(int i) { super(i); print("Fork constructor"); } } class Knife extends Utensil { Knife(int i) { super(i); print("Knife constructor"); } } // A cultural way of doing something: class Custom { Custom(int i) { print("Custom constructor"); } } public class PlaceSetting extends Custom { private Spoon sp; private Fork frk; private Knife kn; private DinnerPlate pl; public PlaceSetting(int i) { super(i + 1); sp = new Spoon(i + 2); frk = new Fork(i + 3); kn = new Knife(i + 4); pl = new DinnerPlate(i + 5); print("PlaceSetting constructor"); } public static void main(String[] args) { PlaceSetting x = new PlaceSetting(9); } } /* Output: Custom constructor Utensil constructor Spoon constructor Utensil constructor Fork constructor Utensil constructor Knife constructor Plate constructor DinnerPlate constructor PlaceSetting constructor *///:~
El compilador nos obliga a inicializar la clase base al principio del constructor, pero esto no nos asegura que inicialicemos los objetos miembro, por tanto, hay que prestar atención a este detalle.
Las clases quedan separadas limpiamente. No se necesita el código fuente de los métodos para poder reutilizar ese código. Basta con limitarnos a importar un paquete (esto es cierto con la herencia y con la composición).
Cómo garantizar una limpieza apropiada
Como sabemos, en Java no existe el destructor para destruir objetos. En Java normalmente nos olvidamos de los objetos en lugar de destruirlos, el depurador de memoria se encarga de reclamar memoria cuando sea necesario.
Normalmente esto es suficiente, sin embargo, hay ocasiones en las que es necesario realizar la limpieza. Recordamos que no sabemos cuándo va a ser invocado el depurador de memoria, incluso ni siquiera si va a ser invocado. Si queremos limpiar algo concreto, hay que escribir un método especial y asegurarnos de que el programador de clientes sepa que debe invocar dicho método. Además, como veremos en el Tema 12, Tratamiento de errores con excepciones, debemos protegernos frente a la aceleración de posibles excepciones incorporando dicha actividad de limpieza en una cláusula finally.
Vamos a ver un ejemplo en el que hay que dibujar imágenes en pantalla:
//: reusing/CADSystem.java // Ensuring proper cleanup. package reusing; import static net.mindview.util.Print.*; class Shape { Shape(int i) { print("Shape constructor"); } void dispose() { print("Shape dispose"); } } class Circle extends Shape { Circle(int i) { super(i); print("Drawing Circle"); } void dispose() { print("Erasing Circle"); super.dispose(); } } class Triangle extends Shape { Triangle(int i) { super(i); print("Drawing Triangle"); } void dispose() { print("Erasing Triangle"); super.dispose(); } } class Line extends Shape { private int start, end; Line(int start, int end) { super(start); this.start = start; this.end = end; print("Drawing Line: " + start + ", " + end); } void dispose() { print("Erasing Line: " + start + ", " + end); super.dispose(); } } public class CADSystem extends Shape { private Circle c; private Triangle t; private Line[] lines = new Line[3]; public CADSystem(int i) { super(i + 1); for(int j = 0; j < lines.length; j++) lines[j] = new Line(j, j*j); c = new Circle(1); t = new Triangle(1); print("Combined constructor"); } public void dispose() { print("CADSystem.dispose()"); // The order of cleanup is the reverse // of the order of initialization: t.dispose(); c.dispose(); for(int i = lines.length - 1; i >= 0; i--) lines[i].dispose(); super.dispose(); } public static void main(String[] args) { CADSystem x = new CADSystem(47); try { // Code and exception handling... } finally { x.dispose(); } } } /* Output: Shape constructor Shape constructor Drawing Line: 0, 0 Shape constructor Drawing Line: 1, 1 Shape constructor Drawing Line: 2, 4 Shape constructor Drawing Circle Shape constructor Drawing Triangle Combined constructor CADSystem.dispose() Erasing Triangle Shape dispose Erasing Circle Shape dispose Erasing Line: 2, 4 Shape dispose Erasing Line: 1, 1 Shape dispose Erasing Line: 0, 0 Shape dispose Shape dispose *///:~
Todo en este ejemplo es algún tipo de objeto Shape. Cada clase sustituye el método dispose() de Shape, y también invoca la versión de dicho método de la clase base con super. Las clases Shape específicas, Circle, Triangle y Line, tienen constructores que "dibujan" estas formas geométricas, aunque cualquier método puede realizar tareas que luego requieran ciertas tareas de limpieza. Cada clase tiene un método dispose() que restaura todas esas cosas no relacionadas con la memoria y las deja en el estado en que estaban antes de que el objeto se creara.
En main() hay dos palabras claves todavía no vistas, try y finally, que se utilizan para el tratamiento de excepciones. La palabra clave try indica que el bloque de instrucciones, delimitado por llaves, que hay dentro es una región protegida, se le da un tratamiento especial. Uno de estos tratamientos especiales es que el bloque situado dentro de finally siempre se ejecuta, independientemente de cómo se salga del bloque try (esto lo veremos en el Tema 12). En el ejercicio anterior finally dice: "Llama siempre a dispose() para x, independientemente de lo que suceda".
En el método de limpieza, en este caso dispose(), hay que prestar atención al orden de llamada de los métodos de limpieza de la clase base y de los objetos miembro, en caso de que un subobjeto dependa de otro. El orden que hay que seguir es el mismo que los destructores de C++: primero realizamos la tarea de limpieza específica de la clase en orden inverso a su creación. Después, se invoca el método de limpieza de la clase base.
Cuando hay que realizar un trabajo de limpieza explícita, se requiere mucha diligencia y atención, el depurador de memoria no sirve de ayuda en este aspecto. Puede que nunca llegue a ser invocado y en caso de que lo sea, podría reclamar los objetos en el orden que quisiera. No podemos confiar en la depuración de memoria para nada que no sea reclamar zonas de memoria no utilizadas. Si queremos que las tareas de limpieza se lleven a cabo, debemos definir nuestros propios métodos de limpieza y no emplear finalize().
Ejercicio 12. Añade una jerarquía adecuada de métodos dispose a todas las clases del Ejercicio 9.
Ocultación de nombres
Si una clase base tiene un método sobrecargado varias veces, redefinir dicho nombre de método en la clase derivada no ocultará ninguna de las versiones de la clase base. La sobrecarga funciona independientemente de si el método ha sido definido en este nivel o en una clase base:
//: reusing/Hide.java // Overloading a base-class method name in a derived // class does not hide the base-class versions. import static net.mindview.util.Print.*; class Homer { char doh(char c) { print("doh(char)"); return 'd'; } float doh(float f) { print("doh(float)"); return 1.0f; } } class Milhouse {} class Bart extends Homer { void doh(Milhouse m) { print("doh(Milhouse)"); } } public class Hide { public static void main(String[] args) { Bart b = new Bart(); b.doh(1); b.doh('x'); b.doh(1.0f); b.doh(new Milhouse()); } } /* Output: doh(float) doh(char) doh(float) doh(Milhouse) *///:~
Los métodos sobrecargados de Homer están disponibles en Bart, y en Bart se introduce un nuevo método sobrecargado (en C++ se ocultarían los métodos de la clase base). Normalmente se sobrecargan métodos del mismo nombre, usando la misma signatura y mismo tipo de retorno. De lo contrario el código podría resultar confuso.
Java SE5 ha añadido la anotación @Override (no es una palabra clave pero puede utilizarse como si lo fuera). Si queremos sustituir un método, añadimos esta anotación y el compilador generará error si sobrecargamos accidentalmente el método en lugar de sustituirlo.
//: reusing/Lisa.java // {CompileTimeError} (Won't compile) class Lisa extends Homer { @Override void doh(Milhouse m) { System.out.println("doh(Milhouse)"); } } ///:~
Con Ant el marcador {CompileTimeError} excluye el archivo del proceso de construcción. Si lo compilamos a mano, obtendremos este error:
method does not override a method from its superclass
Con @Override evitaremos sobrecargar un método accidentalmente cuando no es lo queremos hacer.
Ejercicio 13. Crea una clase con un método que esté sobrecargado tres veces. Define una nueva clase que herede de la anterior y añade una nueva versión sobrecargada del método. Muestra que los cuatro métodos están disponibles en la clase derivada.
COMO ELEGIR ENTRE LA COMPOSICIÓN Y LA HERENCIA
La composición y la herencia nos permiten incluir subobjetos dentro de una nueva clase (la composición de forma explícita, la herencia de forma implícita). ¿Cuál es la diferencia entre ellos y cuándo conviene elegir entre uno y otro?
La composición se usa cuando queremos añadir la funcionalidad de la clase existente dentro de la nueva clase pero no su interfaz. Integramos un objeto para utilizarlo y así poder implementar ciertas características en la nueva clase, el usuario de la nueva clase verá la interfaz definida para la nueva clase en lugar de la interfaz del objeto incrustado. Para lograr esto, integramos objetos private de clases existentes dentro de la nueva clase.
En ocasiones, nos interesa que los objetos miembros sean públicos y así poder acceder a ellos. Los objetos ocultan su implementación, por tanto, no hay ningún riesgo. Cuando un usuario está ensamblando un conjunto de elementos, comprenderá mejor la interfaz que hayamos definido. Un ejemplo:
//: reusing/Car.java // Composition with public objects. class Engine { public void start() {} public void rev() {} public void stop() {} } class Wheel { public void inflate(int psi) {} } class Window { public void rollup() {} public void rolldown() {} } class Door { public Window window = new Window(); public void open() {} public void close() {} } public class Car { public Engine engine = new Engine(); public Wheel[] wheel = new Wheel[4]; public Door left = new Door(), right = new Door(); // 2-door public Car() { for(int i = 0; i < 4; i++) wheel[i] = new Wheel(); } public static void main(String[] args) { Car car = new Car(); car.left.window.rollup(); car.wheel[0].inflate(72); } } ///:~
En este ejemplo la composición de un coche forma parte del análisis del problema y no simplemente del diseño subyacente, hacer los miembros públicos ayuda al programador de clientes a entender cómo utilizar la clase y disminuye la complejidad del código que el creador de la clase tiene que desarrollar. Pero este es un caso particular, lo normal es que los campos sean privados.
Con la herencia lo que hacemos es coger una clase existente y definir una versión especial de la misma. Es decir, tomamos una clase de propósito general y la especializamos para una necesidad concreta. No tendría sentido componer un coche utilizando un objeto vehículo, es decir, un objeto vehículo dentro de la clase coche, un coche no contiene vehículos sino que es un vehículo. La relación es - un se expresa mediante herencia, la relación tiene - un mediante composición.
Ejercicio 14. En Car.java añade un método service() a Engine e invoca este método desde main().
PROTECTED
Con la herencia la palabra protected adquiere su pleno significado. Con esta palabra hacemos que un elemento sea privado con respecto al usuario de la clase, pero está disponible para cualquiera que herede de esta clase y para quien forme parte del mismo paquete, es decir, también proporciona acceso de paquete.
Sin embargo, lo mejor es definir los campos como privados, ya que así podremos modificar la implementación subyacente. De esta forma, los herederos de nuestra clase dispondrán de un método controlado utilizando métodos protected:
//: reusing/Orc.java // The protected keyword. import static net.mindview.util.Print.*; class Villain { private String name; protected void set(String nm) { name = nm; } public Villain(String name) { this.name = name; } public String toString() { return "I'm a Villain and my name is " + name; } } public class Orc extends Villain { private int orcNumber; public Orc(String name, int orcNumber) { super(name); this.orcNumber = orcNumber; } public void change(String name, int orcNumber) { set(name); // Available because it's protected this.orcNumber = orcNumber; } public String toString() { return "Orc " + orcNumber + ": " + super.toString(); } public static void main(String[] args) { Orc orc = new Orc("Limburger", 12); print(orc); orc.change("Bob", 19); print(orc); } } /* Output: Orc 12: I'm a Villain and my name is Limburger Orc 19: I'm a Villain and my name is Bob *///:~
Vemos que change() tiene acceso a set() ya que es de tipo protected. El método toString de Orc se ha definido utilizando el método toString de la clase base.
Ejercicio 15. Crea una clase dentro de un paquete. Esa clase debe estar dentro de un paquete. Esa clase debe contener un método protected. Fuera del paquete, trata de invocar el método protected y explica los resultados. Ahora define otra clase que herede de la anterior e invoque el método protected desde un método de la clase derivada.
UPCASTING (GENERALIZACIÓN)
La herencia además de proporcionar métodos para la nueva clase, relaciona la nueva clase con la clase base de manera especial, la nueva clase es un tipo de la clase existente.
Esta descripción está soportada por el lenguaje. Veamos un ejemplo, tenemos una clase Instrument que representa instrumentos musicales y una clase derivada llamada Wind (instrumentos de viento). Con la herencia todos los métodos de la clase base están disponibles en la clase derivada, así cualquier mensaje que enviemos a la clase base puede enviarse también a la clase derivada. Si la clase base tiene un método play(), también lo tendrán los instrumentos de la clase Wind. Así, un objeto Wind es también un objeto de tipo Instrument. Vamos a verlo con un ejemplo:
//: reusing/Wind.java // Inheritance & upcasting. class Instrument { public void play() {} static void tune(Instrument i) { // ... i.play(); } } // Wind objects are instruments // because they have the same interface: public class Wind extends Instrument { public static void main(String[] args) { Wind flute = new Wind(); Instrument.tune(flute); // Upcasting } } ///:~
Vemos que hay un método tune() (afinar) en Instrument, con un argumento de tipo Instrument. En la clase Wind llamamos a tune con una referencia a un objeto Wind. ¿Por qué acepta un objeto Wind si el argumento del método tune() es de tipo Instrument? Lo acepta porque el objeto Wind también es un objeto Instrument y no existe ningún método que tune() pudiera invocar para un objeto Instrument y que no se encuentre también en Wind. En tune() funcionará para los objetos Instrument así como para los objetos derivados de Instrument, esto se denomina upcasting (generalización).
¿Por qué generalizar?
El diagrama de herencia de la clase Wind es el siguiente:
Como vemos la clase raíz está en la parte superior y las clases derivadas en la parte inferior. Cuando hacemos una proyección de un tipo derivado a un tipo base, nos movemos hacia arriba en el diagrama de herencia, por eso en inglés se dice upcasting (proyección hacia arriba). La generalización o upcasting es segura ya que pasamos de una clase más específica a una más general. La clase derivada contiene los métodos de la clase base y además puede contener más métodos. En la generalización lo único que puede ocurrir con la interfaz de la clase es que pierda métodos, no que los gane, por eso el compilador permite la generalización sin efectuar ninguna proyección ni notación especial.
El inverso de la generalización es la especialización (downcasting), esto lo veremos en el siguiente tema.
Nueva comparación entre la composición y la herencia
En programación orientada a objetos, lo más habitual es empaquetar datos y métodos en una clase y usar los objetos de dicha clase. También utilizamos otras clases para crear nuevas clases mediante composición. La herencia se utiliza menos. Cuando aprendemos POO se hace un gran hincapié en el tema de la herencia, pero no hay que utilizarla en todo momento, esto es muy importante ya que muchos programadores novatos pueden perderse por tratar de buscar siempre la herencia. Debe emplearse sólo cuando resulte útil. Una forma de determinar si debemos utilizar composición o herencia es preguntarse si va a ser necesario recurrir en algún momento a la generalización de la nueva clase a la clase base. Si es así, la herencia es necesaria, si no, hay que pensar si verdaderamente la herencia es necesaria.
Ejercicio 16. Crea una clase denominada Amphibian (anfibio). A partir de ésta, define una nueva clase denominada Frog (rana) que herede de la anterior. Incluye una serie de métodos apropiados en la clase base. En main(), crea un objeto Frog y realiza una generalización a Amphibian, demostrando que todos los métodos siguen funcionando.
Ejercicio 17. Modifica el Ejercicio 16 para que el objeto Frog sustituya las definiciones de métodos de la clase base (proporciona las nuevas definiciones utilizando las mismas signaturas de métodos). Observa lo que sucede en main().
LA PALABRA CLAVE FINAL
Aunque tiene diferentes significados dependiendo del contexto, la palabra clave final siempre quiere decir: "Este elemento no puede modificarse". Tenemos dos razones para no permitir cambios: diseño y eficiencia. A continuación vamos a ver los tres lugares donde se puede utilizar final: para los datos, para los métodos y para las clases.
Datos final
La mayoría de los lenguajes de programación disponen de formas para comunicarle al compilador que un dato es constante. Las constantes son útiles por dos razones:
1. Puede ser una constante de tiempo de compilación que nunca va a cambiar.
2. Puede ser un valor inicializado en tiempo de ejecución que no queremos que cambie.
En el caso de una constante en tiempo de compilación, el compilador puede compactar el valor en aquellos cálculos en los que se utilice, así el cálculo se puede hacer en tiempo de compilación. En Java estos tipos de constantes deben ser primitivas y utilizar la palabra clave final, cuando se definen hay que darles un valor.
Un campo static y final tendrá una única zona de almacenamiento que no puede ser modificada.
Si utilizamos final con referencias a objetos el significado puede ser confuso. Con una primitiva, final hace que un valor sea constante, con una referencia a objeto lo que conseguimos es que la referencia sea constante. Cuando se inicia la referencia a un objeto no se puede cambiar para que apunte a otro objeto. Sin embargo, el propio objeto sí que puede ser modificado. Java no tiene ningún mecanismo para hacer objetos constantes, lo que podemos hacer es escribir nuestras clases de modo que tengan el efecto de que los objetos sean constantes. Esto incluye a las matrices que también son objetos.
Vamos a ver un ejemplo con campos final. Por convenio, los campos que son a la vez static y final se escriben en mayúsculas, separando las palabras con guiones bajos.
//: reusing/FinalData.java // The effect of final on fields. import java.util.*; import static net.mindview.util.Print.*; class Value { int i; // Package access public Value(int i) { this.i = i; } } public class FinalData { private static Random rand = new Random(47); private String id; public FinalData(String id) { this.id = id; } // Can be compile-time constants: private final int valueOne = 9; private static final int VALUE_TWO = 99; // Typical public constant: public static final int VALUE_THREE = 39; // Cannot be compile-time constants: private final int i4 = rand.nextInt(20); static final int INT_5 = rand.nextInt(20); private Value v1 = new Value(11); private final Value v2 = new Value(22); private static final Value VAL_3 = new Value(33); // Arrays: private final int[] a = { 1, 2, 3, 4, 5, 6 }; public String toString() { return id + ": " + "i4 = " + i4 + ", INT_5 = " + INT_5; } public static void main(String[] args) { FinalData fd1 = new FinalData("fd1"); //! fd1.valueOne++; // Error: can't change value fd1.v2.i++; // Object isn't constant! fd1.v1 = new Value(9); // OK -- not final for(int i = 0; i < fd1.a.length; i++) fd1.a[i]++; // Object isn't constant! //! fd1.v2 = new Value(0); // Error: Can't //! fd1.VAL_3 = new Value(1); // change reference //! fd1.a = new int[3]; print(fd1); print("Creating new FinalData"); FinalData fd2 = new FinalData("fd2"); print(fd1); print(fd2); } } /* Output: fd1: i4 = 15, INT_5 = 18 Creating new FinalData fd1: i4 = 15, INT_5 = 18 fd2: i4 = 13, INT_5 = 18 *///:~
Las variables valueOne y VALUE_TWO son primitivas final con valores definidos en tiempo de compilación, no se diferencian en ningún aspecto importante. VALUE_THREE es la forma más normal de definir dichas constantes: public, static para enfatizar que sólo hay una y final para decir que se trata de una constante.
Una variable final no implica que su valor se conozca en tiempo de compilación. Las variables i4 e INT_5 se inician en tiempo de ejecución mediante números generados aleatoriamente. También vemos la diferencia entre hacer un valor final estático o no estático. Esta diferencia sólo se ve cuando se inicializan las variables en tiempo de ejecución, el compilador trata de la misma forma los valores de tiempo de compilación. La diferencia se ve al ejecutar el programa. Los valores de i4 son diferentes para fd1 y fd2, sin embargo el valor de INT_5 no cambia aunque creemos un segundo objeto FinalData. Esto se debe a que es estático y se inicializa una sóla vez durante la carga y no cada vez que se crea un nuevo objeto.
Vamos a ver ahora v1 a VAL_3. Como podemos observar v2 es final pero se puede modificar en main(), se trata de una referencia, la palabra final significa que no se puede asociar v2 con un nuevo objeto. Esto también es cierto para las matrices que son otro tipo de referencia. Hacer referencias final parece menos útil que definir las primitivas como final.
Ejercicio 18. Crea una clase con un campo static final y un campo final y demuestra la diferencia entre los dos.
Valores final en blanco
Java permite la creación de valores finales en blanco, son campos final pero que no se les proporciona un valor de inicialización. Sin embargo, este valor final debe ser inicializado antes de ser utilizado. Estos valores final en blanco proporcionan mucha más flexibilidad, ya que un campo final dentro de una clase podrá ser diferente para cada objeto y seguir siendo inmutable. Veamos un ejemplo:
//: reusing/BlankFinal.java // "Blank" final fields. class Poppet { private int i; Poppet(int ii) { i = ii; } } public class BlankFinal { private final int i = 0; // Initialized final private final int j; // Blank final private final Poppet p; // Blank final reference // Blank finals MUST be initialized in the constructor: public BlankFinal() { j = 1; // Initialize blank final p = new Poppet(1); // Initialize blank final reference } public BlankFinal(int x) { j = x; // Initialize blank final p = new Poppet(x); // Initialize blank final reference } public static void main(String[] args) { new BlankFinal(); new BlankFinal(47); } } ///:~
Tenemos que asignar un valor a las variables final bien en el punto de definición del campo o bien en cada constructor. Así el campo final siempre estará inicializado antes de utilizarlo.
Ejercicio 19. Crea una clase con una referencia final en blanco a un objeto. Realiza la inicialización de la referencia final en blanco dentro de todos los constructores. Demuestra que se garantiza que el valor final estará inicializado antes de utilizarlo, y que no se puede modificar una vez inicializado.
Argumentos final
En Java se pueden definir argumentos final en un método. De esta forma no se podrá cambiar aquello a lo que apunte la referencia del argumento:
//: reusing/FinalArguments.java // Using "final" with method arguments. class Gizmo { public void spin() {} } public class FinalArguments { void with(final Gizmo g) { //! g = new Gizmo(); // Illegal -- g is final } void without(Gizmo g) { g = new Gizmo(); // OK -- g not final g.spin(); } // void f(final int i) { i++; } // Can't change // You can only read from a final primitive: int g(final int i) { return i + 1; } public static void main(String[] args) { FinalArguments bf = new FinalArguments(); bf.without(null); bf.with(null); } } ///:~
Los métodos f() y g() muestran qué pasa al utilizar argumentos final, se puede leer el argumento pero no modificarlo. Esto se utiliza para pasar datos a las clases internas anónimas, lo veremos en el Tema 10.
Métodos final
Hay dos razones para utilizar métodos final. La primera es impedir que cualquier clase que herede de ésta cambie su significado, así nos aseguramos de que se retenga el comportamiento del método y evitamos que pueda ser sustituido.
La segunda razón es la eficiencia. En las implementaciones anteriores de Java, si definíamos un método final, permitíamos convertir todas las llamadas a ese método en llamadas en línea. Cuando el compilador encontraba una llamada a un método final, podía saltarse el modo normal de insertar el código correspondiente al mecanismo de llamada al método (insertar argumentos en la pila, ir al código del método, ejecutarlo, saltar hacia atrás y eliminar de la pila los argumentos y tratar el valor de retorno), para sustituir en su lugar la llamada al método por una copia del propio código contenido en el cuerpo del método. Así eliminamos el gasto adicional de recursos asociado a la llamada al método. Si el método es muy grande el código crecerá mucho y no detectaremos ninguna mejora de velocidad.
En las versiones más recientes, la máquina virtual (particularmente la tecnología hotspot), puede detectar estas situaciones y eliminar el paso adicional de indirección, por lo que ya no es necesario usar final para ayudar al optimizador. Con Java SE5/6, el compilador y la JVM se encargan de la eficiencia, y sólo debemos definir un método como final si queremos impedir la sustitución del método en las clases derivadas.
Final y private
Los métodos privados de una clase son implícitamente de tipo final. No podemos acceder a un método privado, por tanto, no podemos sustituirlo en una clase derivada. Si añadimos final a un método private no tendrá ningún efecto adicional.
Fijémonos en el siguiente ejemplo:
//: reusing/FinalOverridingIllusion.java // It only looks like you can override // a private or private final method. import static net.mindview.util.Print.*; class WithFinals { // Identical to "private" alone: private final void f() { print("WithFinals.f()"); } // Also automatically "final": private void g() { print("WithFinals.g()"); } } class OverridingPrivate extends WithFinals { private final void f() { print("OverridingPrivate.f()"); } private void g() { print("OverridingPrivate.g()"); } } class OverridingPrivate2 extends OverridingPrivate { public final void f() { print("OverridingPrivate2.f()"); } public void g() { print("OverridingPrivate2.g()"); } } public class FinalOverridingIllusion { public static void main(String[] args) { OverridingPrivate2 op2 = new OverridingPrivate2(); op2.f(); op2.g(); // You can upcast: OverridingPrivate op = op2; // But you can't call the methods: //! op.f(); //! op.g(); // Same here: WithFinals wf = op2; //! wf.f(); //! wf.g(); } } /* Output: OverridingPrivate2.f() OverridingPrivate2.g() *///:~
En este caso las clases que heredan de WithFinals y OverridingPrivate no heredan los métodos privados f y g de cada clase, estos métodos no forman parte de la interfaz de la clase base. Por tanto, no podemos hablar de sustitución de métodos en las clase derivadas, simplemente estamos creando otros nuevos.
Ejercicio 20. Demuestra que la notación @Override resuelve el problema descrito en esta sección.
Ejercicio 21. Crea una clase con un método final. Crea otra clase que herede de la clase anterior y trata de sustituir ese método.
Clases final
Cuando definimos una clase final, lo que no queremos es heredar de esta clase ni permitir que nadie lo haga. En nuestra clase privada no vamos a necesitar ningún cambio o bien por razones de seguridad no queremos que nadie defina clases derivadas.
//: reusing/Jurassic.java // Making an entire class final. class SmallBrain {} final class Dinosaur { int i = 7; int j = 1; SmallBrain x = new SmallBrain(); void f() {} } //! class Further extends Dinosaur {} // error: Cannot extend final class 'Dinosaur' public class Jurassic { public static void main(String[] args) { Dinosaur n = new Dinosaur(); n.f(); n.i = 40; n.j++; } } ///:~
Los campos pueden ser final o no. A los campos final se les aplica las mismas reglas independientemente de si la clase es final o no. Como la clase final impide la herencia, todos los métodos son implícitamente final.
Ejercicio 22. Crea una clase final y trata de definir otra clase que herede de ella.
Una advertencia sobre final
Hay que tener cuidado cuando se define un método final pensando que nadie va a querer sustituir ese método. Es difícil saber cómo se va a reutilizar una clase, especialmente cuando es de propósito general. Al definir un método como final, puede que estemos impidiendo reutilizar la clase a través de la herencia en algún otro proyecto de programación, por no imaginar que la clase pudiera utilizarse de esa forma.
Tenemos un ejemplo con la biblioteca de Java, particularmente con la clase Vector. Podría haber resultado mucho más útil si no se hubieran hecho todos los métodos final. Los diseñadores decidieron que esto era lo apropiado. Resulta bastante irónico que se tomara esta decisión por dos razones. Primero, Stack (pila) hereda de Vector, es decir, Stack es un Vector, cosa que no es cierta. Los propios diseñadores de Java decidieron que una clase heredara de Vector, ahí debieron darse cuenta de que los métodos final eran bastante restrictivos.
Segundo, muchos métodos de Vector, como addElement() y elementAt() están sincronizados (synchronized). Esta característica, que veremos en el Tema 21, restringe las prestaciones, por lo que se anula cualquier ganancia proporcionada por final. Este diseño tan pobre pasó a formar parte de la biblioteca estándar y todo el mundo lo sufrió. La biblioteca moderna de contenedores Java sustituye Vector por ArrayList que tiene un comportamiento mucho mejor.
INICIALIZACIÓN Y CARGA DE CLASESEn los lenguajes tradicionales, los programas se cargan de una vez como parte del proceso de arranque. Seguidamente va la inicialización y luego el programa. En estos lenguajes hay que cuidar el orden de inicialización de los valores no estáticos. En C++ está el problema de que uno de los valores estáticos espere que otro valor estático sea válido antes de que el segundo haya sido inicializado.
Java no tiene este problema ya que el proceso de carga es distinto. Cada clase está almacenada en su propio archivo. Este archivo se carga cuando es necesario. El código de una clase se carga cuando se utiliza por primera vez. Normalmente esto sucede cuando se construye un objeto de esa clase, también cuando accedemos a un campo o método estático. El constructor también es un método estático, aunque la palabra static no sea explícita. Por tanto, una clase se carga por primera vez cuando se accede a cualquiera de sus métodos estáticos.
El lugar del primer uso es donde se produce la inicialización de los elementos estáticos. Éstos elementos y el bloque de código static se inicializan en el orden en que estén escritos, en el punto donde se produzca la carga. Los valores estáticos sólo se inicializan una vez.
Inicialización con herencia
Vamos a ver el proceso de inicialización completo, incluyendo la herencia:
//: reusing/Beetle.java // The full process of initialization. import static net.mindview.util.Print.*; class Insect { private int i = 9; protected int j; Insect() { print("i = " + i + ", j = " + j); j = 39; } private static int x1 = printInit("static Insect.x1 initialized"); static int printInit(String s) { print(s); return 47; } } public class Beetle extends Insect { private int k = printInit("Beetle.k initialized"); public Beetle() { print("k = " + k); print("j = " + j); } private static int x2 = printInit("static Beetle.x2 initialized"); public static void main(String[] args) { print("Beetle constructor"); Beetle b = new Beetle(); } } /* Output: static Insect.x1 initialized static Beetle.x2 initialized Beetle constructor i = 9, j = 0 Beetle.k initialized k = 47 j = 39 *///:~
Cuando se ejecuta Beetle se trata de acceder a Beetle.main() (método estático) por lo que el cargador localiza el código compilado de Beetle. Durante la carga, el cargador ve que hay una clase base (extends), por lo que se carga. Esto sucede independientemente de si se va a construir un objeto de dicha clase base. Si la base tiene otra clase base también se cargaría, y así sucesivamente.
A continuación se realiza la inicialización static de la clase raíz (Insect), y después de la clase derivada. Una cosa importante es que la inicialización static de la clase derivada puede depender de que el miembro de la clase base haya sido inicializado correctamente.
En este punto ya se habrán cargado todas las clases necesarias, por lo que se podrá crear el objeto. Primero, se asignan los valores predeterminados a todas las primitivas de este objeto y el valor null a las referencias a objetos. A continuación, se invoca el constructor de la clase base, esta llamada es automática, también la podríamos haber especificado utilizando super en el constructor Beetle(). El constructor de la clase base pasa a través del mismo proceso y en el mismo orden que el constructor de la clase derivada. Una vez completado el constructor de la clase base, las variables de instancia se inicializan en orden textual. Finalmente se ejecuta el resto del cuerpo del constructor.
Ejercicio 23. Demuestra que el proceso de carga de una clase sólo tiene lugar una vez. Demuestra que la carga puede ser provocada por la creación de la primera instancia de esa clase o por el acceso a un miembro estático de la misma.
Ejercicio 24. En Beetle.java, define una nueva clase que represente un tipo específico de la clase Beetle de la que debe heredar, siguiendo el mismo formato que las clases existentes. Traza y explica los resultados de salida.
No hay comentarios:
Publicar un comentario