- Inicialización garantizada con el constructor - Sobrecarga de métodos - Cómo se distingue entre métodos sobrecargados - Sobrecarga con primitivas - Sobrecarga de los valores de retorno - Constructores predeterminados - La palabra clave this - Invocación de constructores desde otros conductores - El significado de static - Limpieza: finalización y depuración de memoria - ¿Para qué se utiliza finalize()? - Es necesario efectuar las tareas de limpieza - La condición de terminación - Cómo funciona un depurador de memoria - Inicialización de miembros - Especificación de la inicialización - Inicialización mediante constructores - Orden de inicialización - Inicialización de datos estáticos - Inicialización static explícita - Inicialización de instancias no estáticas - Inicialización de matrices - Listas variables de argumentos - Tipos enumerados
A medida que se abre paso la revolución informática, la programación "no segura" se ha convertido en uno de los mayores culpables del alto coste que tiene el desarrollo de programas.
Dos cuestiones relacionadas con la seguridad son la inicialización y la limpieza. En Java los objetos tienen que ser inicializados antes de ser utilizados, para ello se utiliza un constructor, que es un método especial que se invoca automáticamente cada vez que se crea un objeto. Además Java dispone de un depurador de memoria que libera los recursos de memoria cuando ya no se utilicen. En este tema se van a ver ambas cuestiones.
INICIALIZACIÓN GARANTIZADA CON EL CONSTRUCTOREn Java se garantiza la inicialización de los objetos mediante un constructor. Si una clase tiene un constructor, Java lo invoca automáticamente cuando se crea un objeto, antes incluso de que los usuarios puedan llegar a utilizarlo. Así queda garantizada la inicialización.
El nombre del constructor coincide con el nombre de la clase. Así resulta fácil invocar ese método automáticamente durante la inicialización. Vamos a ver un ejemplo:
//: initialization/SimpleConstructor.java // Demonstration of a simple constructor. class Rock { Rock() { // This is the constructor System.out.print("Rock "); } } public class SimpleConstructor { public static void main(String[] args) { for(int i = 0; i < 10; i++) new Rock(); } } /* Output: Rock Rock Rock Rock Rock Rock Rock Rock Rock Rock *///:~
Cuando se crea un objeto:
new Rock();
Se asigna el espacio de almacenamiento y se invoca el constructor, así, el objeto está correctamente inicializado antes de utilizarlo. Observa que la primera letra del constructor comienza por letra mayúscula, ya que debe coincidir con el nombre de la clase.
Un constructor como el anterior, que no tome ningún argumento se denomina constructor predeterminado o sin argumentos. A continuación vamos a ver un ejemplo de constructor con argumentos, que nos permite especificar cómo hay que crear el objeto:
//: initialization/SimpleConstructor2.java // Constructors can have arguments. class Rock2 { Rock2(int i) { System.out.print("Rock " + i + " "); } } public class SimpleConstructor2 { public static void main(String[] args) { for(int i = 0; i < 8; i++) new Rock2(i); } } /* Output: Rock 0 Rock 1 Rock 2 Rock 3 Rock 4 Rock 5 Rock 6 Rock 7 *///:~
Los argumentos de un constructor proporcionan una forma de pasar parámetros para la inicialización de un objeto. Si tenemos una clase Tree con un único constructor que toma un número entero como argumento que indica la altura del árbol, crearemos un objeto Tree de la siguiente forma:
Tree t=new Tree(12); // Árbol de 12 metros
Sólo podremos crear un objeto con su constructor, de ninguna otra manera.
Los constructores eliminan problemas y hacen el código más fácil de leer. La creación e inicialización de un objeto están unidas.
El constructor no tiene valor de retorno y no podemos hacer que un constructor devuelva un valor, new devuelve una referencia al objeto recién creado, pero el constructor no tiene un valor de retorno. En caso de que hubiera valor de retorno, el compilador necesitaría saber de qué tipo es ese valor.
Ejercicio 1. Crea una clase que contenga una referencia de tipo String no inicializada. Demuestra que esta referencia la inicializa Java con el valor null.
Ejercicio 2. Crea una clase con un campo String que se inicialice en el punto donde se defina, y otro campo que sea inicializado por el constructor ¿Cuál es la diferencia entre las dos técnicas?
SOBRECARGA DE MÉTODOSUna característica importante en cualquier lenguaje es el uso de nombres. Cuando se crea un objeto se proporciona un nombre a un área de almacenamiento. Un método es un nombre que designa una acción. Una serie de nombres bien elegida hará que creemos un programa fácil de entender y modificar.
Los problemas vienen a la hora de aplicar el concepto de matiz del lenguaje humano a los lenguajes de programación. Una misma palabra puede tener diferentes significados, se trata de palabras polisémicas, aunque en el campo de la programación diríamos que están sobrecargadas. La mayoría de los lenguajes humanos son redundantes, de manera que podemos conocer el significado aun cuando nos perdamos algunas de las palabras. No necesitamos identificadores unívocos, podemos deducir el significado a partir del contexto.
La mayoría de los lenguajes de programación exigen que cada método tenga un identificador único. No podemos tener un método print() para imprimir enteros y otro print() para imprimir número en coma flotante.
En Java, tenemos el constructor, que debe tener el mismo nombre que la clase, no puede tener otro nombre. Entonces, si queremos crear un objeto de diferentes formás, ¿cómo lo haremos? Pues utilizaremos más de un constructor, con el mismo nombre, cada uno para ser utilizado de manera diferente, tendremos así, métodos sobrecargados. Para poder sobrecargar un método, cada método tiene que tener diferentes tipos de argumentos. La sobrecarga de métodos se puede realizar tanto con el constructor como con cualquier otro método. Vamos a ver un ejemplo de constructores y métodos sobrecargados:
//: initialization/Overloading.java // Demonstration of both constructor // and ordinary method overloading. import static net.mindview.util.Print.*; class Tree { int height; Tree() { print("Planting a seedling"); height = 0; } Tree(int initialHeight) { height = initialHeight; print("Creating new Tree that is " + height + " feet tall"); } void info() { print("Tree is " + height + " feet tall"); } void info(String s) { print(s + ": Tree is " + height + " feet tall"); } } public class Overloading { public static void main(String[] args) { for(int i = 0; i < 5; i++) { Tree t = new Tree(i); t.info(); t.info("overloaded method"); } // Overloaded constructor: new Tree(); } } /* Output: Creating new Tree that is 0 feet tall Tree is 0 feet tall overloaded method: Tree is 0 feet tall Creating new Tree that is 1 feet tall Tree is 1 feet tall overloaded method: Tree is 1 feet tall Creating new Tree that is 2 feet tall Tree is 2 feet tall overloaded method: Tree is 2 feet tall Creating new Tree that is 3 feet tall Tree is 3 feet tall overloaded method: Tree is 3 feet tall Creating new Tree that is 4 feet tall Tree is 4 feet tall overloaded method: Tree is 4 feet tall Planting a seedling *///:~
Tenemos dos formas de crear un objeto Tree. La primera es mediante el constructor Tree(), en este caso, tendríamos una semilla de un árbol ya que la altura es 0. La segunda forma es mediante el constructor Tree(int initialHeight), aquí vamos a plantar un árbol con una altura determinada.
Por otro lado tenemos dos métodos sobrecargados, por una lado info(), nos muestra información sobre la altura del árbol, por otro lado info(String s), que nos da información sobre la altura del árbol pero añade un mensaje que le pasamos como argumento. Vemos que los métodos sobrecargados, tanto constructores como métodos normales, tienen el mismo nombre pero diferentes argumentos.
Cómo se distingue entre métodos sobrecargados
Los métodos sobrecargados se distinguen porque cada uno tiene una lista distintiva de tipos de argumento. Incluso una ordenación diferente de los argumentos bastaría para distinguir dos métodos entre sí. Vamos a verlo con un ejemplo:
//: initialization/OverloadingOrder.java // Overloading based on the order of the arguments. import static net.mindview.util.Print.*; public class OverloadingOrder { static void f(String s, int i) { print("String: " + s + ", int: " + i); } static void f(int i, String s) { print("int: " + i + ", String: " + s); } public static void main(String[] args) { f("String first", 11); f(99, "Int first"); } } /* Output: String: String first, int: 11 int: 99, String: Int first *///:~
Los métodos f tienen los mismos argumentos pero en orden diferente y eso es lo que les distingue.
Sobrecarga con primitivas
Una primitiva puede ser convertida automáticamente desde un tipo de menor tamaño a otro de mayor tamaño, y esto puede inducir a confusión cuando combinamos este mecanismo con el de sobrecarga. Vamos a ver con un ejemplo qué pasa cuando pasamos una primitiva a un método sobrecargado:
//: initialization/PrimitiveOverloading.java // Promotion of primitives and overloading. import static net.mindview.util.Print.*; public class PrimitiveOverloading { void f1(char x) { printnb("f1(char) "); } void f1(byte x) { printnb("f1(byte) "); } void f1(short x) { printnb("f1(short) "); } void f1(int x) { printnb("f1(int) "); } void f1(long x) { printnb("f1(long) "); } void f1(float x) { printnb("f1(float) "); } void f1(double x) { printnb("f1(double) "); } void f2(byte x) { printnb("f2(byte) "); } void f2(short x) { printnb("f2(short) "); } void f2(int x) { printnb("f2(int) "); } void f2(long x) { printnb("f2(long) "); } void f2(float x) { printnb("f2(float) "); } void f2(double x) { printnb("f2(double) "); } void f3(short x) { printnb("f3(short) "); } void f3(int x) { printnb("f3(int) "); } void f3(long x) { printnb("f3(long) "); } void f3(float x) { printnb("f3(float) "); } void f3(double x) { printnb("f3(double) "); } void f4(int x) { printnb("f4(int) "); } void f4(long x) { printnb("f4(long) "); } void f4(float x) { printnb("f4(float) "); } void f4(double x) { printnb("f4(double) "); } void f5(long x) { printnb("f5(long) "); } void f5(float x) { printnb("f5(float) "); } void f5(double x) { printnb("f5(double) "); } void f6(float x) { printnb("f6(float) "); } void f6(double x) { printnb("f6(double) "); } void f7(double x) { printnb("f7(double) "); } void testConstVal() { printnb("5: "); f1(5);f2(5);f3(5);f4(5);f5(5);f6(5);f7(5); print(); } void testChar() { char x = 'x'; printnb("char: "); f1(x);f2(x);f3(x);f4(x);f5(x);f6(x);f7(x); print(); } void testByte() { byte x = 0; printnb("byte: "); f1(x);f2(x);f3(x);f4(x);f5(x);f6(x);f7(x); print(); } void testShort() { short x = 0; printnb("short: "); f1(x);f2(x);f3(x);f4(x);f5(x);f6(x);f7(x); print(); } void testInt() { int x = 0; printnb("int: "); f1(x);f2(x);f3(x);f4(x);f5(x);f6(x);f7(x); print(); } void testLong() { long x = 0; printnb("long: "); f1(x);f2(x);f3(x);f4(x);f5(x);f6(x);f7(x); print(); } void testFloat() { float x = 0; printnb("float: "); f1(x);f2(x);f3(x);f4(x);f5(x);f6(x);f7(x); print(); } void testDouble() { double x = 0; printnb("double: "); f1(x);f2(x);f3(x);f4(x);f5(x);f6(x);f7(x); print(); } public static void main(String[] args) { PrimitiveOverloading p = new PrimitiveOverloading(); p.testConstVal(); p.testChar(); p.testByte(); p.testShort(); p.testInt(); p.testLong(); p.testFloat(); p.testDouble(); } } /* Output: 5: f1(int) f2(int) f3(int) f4(int) f5(long) f6(float) f7(double) char: f1(char) f2(int) f3(int) f4(int) f5(long) f6(float) f7(double) byte: f1(byte) f2(byte) f3(short) f4(int) f5(long) f6(float) f7(double) short: f1(short) f2(short) f3(short) f4(int) f5(long) f6(float) f7(double) int: f1(int) f2(int) f3(int) f4(int) f5(long) f6(float) f7(double) long: f1(long) f2(long) f3(long) f4(long) f5(long) f6(float) f7(double) float: f1(float) f2(float) f3(float) f4(float) f5(float) f6(float) f7(double) double: f1(double) f2(double) f3(double) f4(double) f5(double) f6(double) f7(double) *///:~
Recordad que printnb es como System.out.print(), imprime el argumento dado sin salto de línea. Vemos que la constante 5 se trata como int, si hay un método sobrecargado que tome un objeto int, se utilizará dicho método. En los demás casos si tenemos un tipo de datos más pequeño que el argumento del método, dicho tipo será promocionado. El que actúa de manera diferente es char, ya que si no encuentra una correspondencia exacta con char, se le promociona a int.
Ahora vamos a ver qué sucede si nuestro argumento es mayor que el argumento esperado por el método sobrecargado:
//: initialization/Demotion.java // Demotion of primitives and overloading. import static net.mindview.util.Print.*; public class Demotion { void f1(char x) { print("f1(char)"); } void f1(byte x) { print("f1(byte)"); } void f1(short x) { print("f1(short)"); } void f1(int x) { print("f1(int)"); } void f1(long x) { print("f1(long)"); } void f1(float x) { print("f1(float)"); } void f1(double x) { print("f1(double)"); } void f2(char x) { print("f2(char)"); } void f2(byte x) { print("f2(byte)"); } void f2(short x) { print("f2(short)"); } void f2(int x) { print("f2(int)"); } void f2(long x) { print("f2(long)"); } void f2(float x) { print("f2(float)"); } void f3(char x) { print("f3(char)"); } void f3(byte x) { print("f3(byte)"); } void f3(short x) { print("f3(short)"); } void f3(int x) { print("f3(int)"); } void f3(long x) { print("f3(long)"); } void f4(char x) { print("f4(char)"); } void f4(byte x) { print("f4(byte)"); } void f4(short x) { print("f4(short)"); } void f4(int x) { print("f4(int)"); } void f5(char x) { print("f5(char)"); } void f5(byte x) { print("f5(byte)"); } void f5(short x) { print("f5(short)"); } void f6(char x) { print("f6(char)"); } void f6(byte x) { print("f6(byte)"); } void f7(char x) { print("f7(char)"); } void testDouble() { double x = 0; print("double argument:"); f1(x);f2((float)x);f3((long)x);f4((int)x); f5((short)x);f6((byte)x);f7((char)x); } public static void main(String[] args) { Demotion p = new Demotion(); p.testDouble(); } } /* Output: double argument: f1(double) f2(float) f3(long) f4(int) f5(short) f6(byte) f7(char) *///:~
Aquí los métodos admiten valores primitivos más pequeños. Si el argumento es de mayor anchura, será necesario un estrechamiento mediante una proyección, en caso contrario el compilador dará un mensaje de error.
Sobrecarga de los valores de retorno
Cuando tenemos varios métodos sobrecargados, éstos se distinguen por sus argumentos. Podríamos preguntarnos entonces, ¿por qué no distinguirlos por su valor de retorno? Tenemos:
void f(){} int f(){return 1;}
Esto podría funcionar si el compilador pudiera determinar inequívocamente el significado a partir del contexto, por ejemplo: int x=f() o char c=f(). sin embargo, podríamos llamar al método de la forma f() (a lo que se llama invocar un método por su efecto colateral, no nos preocupa el valor de retorno, sino el resto de instrucciones del método). De esta forma el compilador no sabría qué método sobrecargado invocar. Tampoco podría saberlo alguien que está leyendo el código.
CONSTRUCTORES PREDETERMINADOS
Un constructor predeterminado es aquel que no tiene argumentos y que se utiliza para crear un "objeto predeterminado". Si creamos una clase que no tenga constructores el compilador creará automáticamente un constructor predeterminado. Por ejemplo:
//: initialization/DefaultConstructor.java class Bird {} public class DefaultConstructor { public static void main(String[] args) { Bird b = new Bird(); // Default! } } ///:~
Así new Bird() crea un nuevo objeto y llama al constructor predeterminado, incluso sin haber definido uno de manera explícita. Sin ese constructor no podríamos invocar a ningún método del objeto. Sin embargo, si definimos algún constructor con o sin argumentos, el commpilador no sintetizará ningun constructor por nosotros:
//: initialization/NoSynthesis.java class Bird2 { Bird2(int i) {} Bird2(double d) {} } public class NoSynthesis { public static void main(String[] args) { //! Bird2 b = new Bird2(); // No default Bird2 b2 = new Bird2(1); Bird2 b3 = new Bird2(1.0); } } ///:~
Si escribimos new Bird2() el compilador nos dirá que no puede localizar ese constructor en la clase Bird2, ya que no tenemos ningún constructor sin argumentos. Cuando no definimos ningún constructor el compilador dice: "Es necesario utilizar un constructor, voy a definir uno por ti". Pero si definimos algún constructor el compilador dice: "Has definido un constructor, así que debes utilizarlo, no voy a crear ninguno predeterminado por ti".
Ejercicio 3. Crea una clase con un constructor predeterminado (uno que no tome ningún argumento) que imprima un mensaje. Cree un objeto de esa clase.
Ejercicio 4. Añade un constructor sobrecargado al ejercicio anterior que admita un argumento de tipo String e imprima la correspondiente cadena de caracteres junto con el mensaje.
Ejercicio 5. Crea una clase denominada Dog con un método sobrecargado bark() (método "ladrar"). Este método debe estar sobrecargado basándose en diversos tipos de datos primitivos y debe imprimir diferentes tipos de ladridos, gruñidos, etc.. dependiendo de la versión sobrecargada que se invoque. Escribe un método main() que invoque todas las distintas versiones.
Ejercicio 6. Modifica el ejercicio anterior de modo que dos de los métodos sobrecargados tengan dos argumentos (de dos tipos distintos), pero en orden inverso uno respecto del otro. Verifica que estas definiciones funcionan.
Ejercicio 7. Crea una clase sin ningún constructor y luego crea un objeto de esa clase en main() para verificar que se sintetiza automáticamente el constructor predeterminado.
LA PALABRA CLAVE THIS
Si tenemos dos objetos a y b nos podemos preguntar cómo podemos invocar un método peel (pelar fruta) para ambos objetos, vamos a verlo:
//: initialization/BananaPeel.java class Banana { void peel(int i) { /* ... */ } } public class BananaPeel { public static void main(String[] args) { Banana a = new Banana(), b = new Banana(); a.peel(1); b.peel(2); } } ///:~
Como vemos sólo hay un método peel en la clase BananaPeel, entonces, ¿cómo puede saber ese método si está siendo llamado por a o por b?
El compilador se encarga de realizar cierto trabajo entre bastidores por nosotros. Cuando llamamos al método se pasa un argumento secreto que es la referencia al objeto que se está manipulando. Así las dos llamadas anteriores se convierten en algo parecido a:
Banana.peel(a,1); Banana.peel(b,2);
Esto es lo que ocurre internamente cuando realizamos las llamadas al método peel().
¿Qué ocurre si estamos dentro de un método y queremos obtener una referencia al objeto actual? El lenguaje utiliza la palabra clave this. Esta palabra, que sólo se puede emplear en métodos que no sean de tipo static, devuelve la referencia al objeto para el cual ha sido invocado el método. Si estamos dentro de una clase, para invocar a otro método de la misma clase, no hay que utilizar this, simplemente se invoca al método. Si tenemos:
//: initialization/Apricot.java public class Apricot { void pick() { /* ... */ } void pit() { pick(); /* ... */ } } ///:~
Dentro de pit() podríamos decir this.pick() pero no es necesario, el compilador lo hace por nosotros. La palabra clave this sólo se utiliza cuando es necesario utilizar la referencia al objeto actual. Muchas veces se utiliza en instrucciones return cuando se quiere devolver la referencia al objeto actual:
//: initialization/Leaf.java // Simple use of the "this" keyword. public class Leaf { int i = 0; Leaf increment() { i++; return this; } void print() { System.out.println("i = " + i); } public static void main(String[] args) { Leaf x = new Leaf(); x.increment().increment().increment().print(); } } /* Output: i = 3 *///:~
El método increment() devuelve la referencia al objeto actual (x) a través de this, así se pueden realizar varias operaciones con un mismo objeto.
La palabra clave this también es útil para pasar el objeto actual a otro método:
//: initialization/PassingThis.java class Person { public void eat(Apple apple) { Apple peeled = apple.getPeeled(); System.out.println("Yummy"); } } class Peeler { static Apple peel(Apple apple) { // ... remove peel return apple; // Peeled } } class Apple { Apple getPeeled() { return Peeler.peel(this); } } public class PassingThis { public static void main(String[] args) { new Person().eat(new Apple()); } } /* Output: Yummy *///:~
Tenemos la clase Person (persona) con el método comer, eat(Apple apple), la clase pelador, Peeler(pelador) con el método pelar, peel(Apple apple), y una clase Apple (manzana) con el método getPeeled(), obtener pelada. Al crear un objeto Person, podemos utilizar el método eat de Person con un nuevo objeto manzana, new Person().eat(new Apple()), una persona quiere comer una manzana. Cuando invocamos el método eat de Person se llama al método getPeeled de Apple, el cual nos devolverá el objeto manzana que creamos en main pelado (return Peeler.peel(this));), ya que utilizamos la palabra clave this.
El objeto Apple (manzana) invoca Peeler.peel(), que es un método de utilidad externo que realiza una operación que necesita ser externa a Apple (quizá el método puede aplicarse a diferentes clases). Para que el objeto pueda pasarse a sí mismo al método externo hay que emplear this.
Ejercicio 8. Crea una clase con dos métodos. Dentro del primer método invoque al segundo método dos veces: la primera vez sin utilizar this y la segunda utilizando dicha palabra clave. Realice este ejemplo simplemente para ver cómo funciona el mecanismo, no debe utilizar esta forma de invocar a los métodos en la práctica.
Invocación de constructores desde otros constructores
Cuando se crean varios constructores, en ocasiones conviene invocar un constructor desde otro, así no se duplica código. Esto podemos hacerlo mediante la palabra clave this.
Dentro de un constructor, la palabra clave this, funciona de manera diferente a como hemos visto antes, en este caso a this se le proporciona una lista de argumentos y realiza una llamada al constructor que tenga esa lista de argumentos. Así invocamos a otros constructores:
/: initialization/Flower.java // Calling constructors with "this" import static net.mindview.util.Print.*; public class Flower { int petalCount = 0; String s = "initial value"; Flower(int petals) { petalCount = petals; print("Constructor w/ int arg only, petalCount= " + petalCount); } Flower(String ss) { print("Constructor w/ String arg only, s = " + ss); s = ss; } Flower(String s, int petals) { this(petals); //! this(s); // Can't call two! this.s = s; // Another use of "this" print("String & int args"); } Flower() { this("hi", 47); print("default constructor (no args)"); } void printPetalCount() { //! this(11); // Not inside non-constructor! print("petalCount = " + petalCount + " s = "+ s); } public static void main(String[] args) { Flower x = new Flower(); x.printPetalCount(); } } /* Output: Constructor w/ int arg only, petalCount= 47 String & int args default constructor (no args) petalCount = 47 s = hi *///:~
El constructor Flower(String s,int petals) invoca al constructor Flower(int petals) con la instrucción this(petals). Hay que observar dos cosas, primero, no se pueden invocar dos constructores con this, sólo uno y segundo, this es la primera instrucción que debe ir en el constructor. Además vemos otro uso de la palabra clave this (this.s=s), al existir dos variables s, una es el nombre del argumento del constructor y otra es el miembro de datos s, existe una ambigüedad. Para resolverla utilizamos this.s, para referirnos al miembro de datos.
En printPetalCount() vemos que el compilador no nos permite invocar con this un constructor desde un método que no sea constructor.
Ejercicio 9. Crea una clase con dos constructores (sobrecargados). Utilizando this, invoca el segundo constructor desde dentro del primero.
El significado de static
Si definimos un método como static no existirá ningún objeto this para ese método concreto. No se pueden invocar métodos no static desde dentro de métodos static (a la inversa sí). Se puede invocar un método static sin necesidad de especificar ningún objeto. Esta es la principal aplicación de los métodos static, es como si estuviéramos creando un método global. Pero los métodos globales no están permitidos en Java, al incluir un método static dentro de una clase permite a los objetos de esa clase acceder a métodos static y a campos static.
Algunas personas no están de acuerdo con los métodos estáticos, ya que dicen que no son orientados a objetos porque tienen una semática de un método global. Un método estático no envía un mensaje a un objeto, ya que no existe referencia this. Sin embargo, aunque conviene no abusar de su uso, hay veces que son bastante prácticos y necesarios.
LIMPIEZA: FINALIZACIÓN Y DEPURACIÓN DE MEMORIA
Los programadores se olvidan de que, además de la inicialización, la limpieza es importante. ¿Quién necesita limpiar un valor int? Sin embargo, con las bibliotecas, olvidar un objeto después de haber acabado de utilizarlo no siempre resulta seguro. Java dispone del depurador de memoria, pero si definimos un objeto al que se asigna memoria "especial", sin utilizar new, tenemos un problema, ya que el depurador de memoria sólo sabe liberar la memoria asignada con new. Para estos casos Java proporciona el método finalize() que se puede definir para la clase. Este método funciona de la siguiente manera: cuando el depurador de memoria esté listo para liberar el almacenamiento utilizado por el objeto, invocará primero a finalize() y sólo reclamará la memoria del objeto en la siguiente pasada del depurador de memoria. Al utilizar finalize(), tendremos la posibilidad de realizar tareas de limpieza importantes en el momento en que se produzca la depuración de memoria.
Algunos programadores, sobre todo los que provienen de C++, pueden considerar a finalize() el destructor de C++, que es una función que se ha de invocar siempre cuando se destruye un objeto. Hay que tener en cuenta que en C++ los objetos siempre se destruyen, mientras que en Java:
1. Puede que el depurador de memoria no procese los objetos.
2. La depuración de memoria no es equivalente a la destrucción del objeto.
Recordar estos dos principios puede evitar muchos problemas. Lo que quieren decir es que si hemos de realizar alguna acción antes de que un objeto deje de ser necesario, deberemos realizar dicha acción nosotros mismos. Java no dispone de destructores por lo que hay que crear un método para esta tarea de limpieza. Por ejemplo, tenemos un objeto que cuando se crea se dibuja a si mismo en pantalla. Si nosotros no borramos esa imagen puede que nunca se borre. Si dentro de finalize() incluimos algunas instrucciones para el borrado de esta imagen, cuando el objeto se vea sometido al proceso de depuración de memoria y se invoque finalize() (aunque no existe garantía de que esto suceda), entonces se eliminará primero la imagen de la pantalla, siempre que hayamos incluido instrucciones para el borrado, ya que si no, la imagen permanecerá.
Puede ocurrir que el espacio de almacenamiento de un objeto nunca llegue a liberarse, debido a que el programa no se acerca a un punto en el que exista riesgo de quedarse sin espacio. Si el depurador no entra en acción, cuando el programa finalice el espacio será devuelto al sistema operativo. El depurador de memoria actúa cuando es necesario, si no actúa, no se genera ningún gasto adicional de recursos de procesamiento.
¿Para qué se utiliza finalize()?
Entonces, ¿para qué sirve finalize()? Hay que tener en cuenta un tercer punto:
3. La depuración de memoria sólo se preocupa de la memoria.
La misión del depurador de memoria es recuperar la memoria que no se esté utilizando. Así, cualquier actividad relacionada con la depuración de memoria, y especialmente el método finalize(), debe encargarse sólo de la memoria y de su desasignación.
Pero si un objeto contiene otros objetos, ¿finalize() debe liberar también esos otros objetos? No, el depurador de memoria libera toda la memoria de los objetos sin tener en cuenta cómo han sido creados los objetos. El método finalize() sólo es necesario en aquellos casos en los que un objeto pueda asignar espacio de almacenamiento usando alguna técnica distinta de la propia creación de métodos. Si en Java todo es un objeto, ¿cómo puede ocurrir esto?
El método finalize() se ha incluido para cuando el programador realice alguna actividad del estilo C, asignando memoria mediante algún mecanismo distito del utilizado en Java. Esto puede suceder con los métodos nativos, que son una forma de invocar desde Java código escrito en otro lenguaje diferente. C y C++ son los únicos lenguajes soportados, pero desde ellos se pueden invocar subprogramas escritos en otros lenguajes, por lo que en definitiva podremos invocar cualquier cosa que queramos. Dentro del código no Java se pueden utilizar funciones, como malloc() en C, para asignar espacio de almacenamiento y si no utilizamos free() para liberarlo, este espacio no será liberado. La función free() de C y C++ habría que invocarla desde finalize() mediante un método nativo.
Normalmente finalize() no se utilizará de manera frecuente, es más, incluso algunos autores desaconsejan su uso por considerar a los finalizadores peligrosos e innecesarios. Realmente dicho método no es el lugar adecuado para realizar tareas de limpieza. Entonces, ¿dónde se realizan estas tareas de limpieza?
Es necesario efectuar las tareas de limpieza
Para limpiar un objeto, se debe invocar un método de limpieza en el lugar donde queramos que ésta se realice. En C++ todos los objetos se destruyen (o deberían). En C++ si el objeto se crea como local (en la pila), la destrucción se produce en la llave de cierre del ámbito en que el objeto se ha creado. Si el objeto se creo mediante new, el destructor se invoca llamando al método delete. Si olvidamos esto último se produce una fuga de memoria y las otras partes del objeto nunca llegarán a limpiarse. Este tipo de error es muy difícil de detectar.
Java no permite crear objetos locales, siempre se ha de utilizar new. En Java en lugar de utilizar un método delete para recuperar espacio de almacenamiento, se utiliza el depurador de memoria que trabaja por nosotros. Sin embargo, según vayamos avanzando veremos que el depurador de memoria no elimina ni la necesidad ni la utilidad de los destructores (finalize() no es una solución como hemos visto antes). Si necesitamos que se realice algún tipo de limpieza distinta de la propia liberación del espacio de almacenamiento, sigue siendo necesario invocar un método apropiado, que será el equivalente al destructor de C++ sin la comodidad de éste.
No se garantizan ni la depuración de memoria ni la finalización. Si la máquina virtual Java (JVM) no está cerca de quedarse sin memoria, puede que no pierda tiempo recuperando espacio mediante el depurador de memoria.
La condición de terminación
Como no podemos confiar en que finalize() sea invocado, tendremos que crear métodos de "limpieza" e invocarlos. Parece que finalize() sólo resulta útil para oscuras tareas de limpieza que la mayoría de programadores nunca van a tener que utilizar. Sin embargo, existe un uso de finalize() interesante, es la verificación de la condición de terminación.
Cuando ya no necesitemos un objeto y esté listo para ser borrado, este objeto deberá estar en un estado en el que su memoria pueda ser liberada sin riesgo. Por ejemplo, si el objeto representa un archivo abierto, se deberá cerrar dicho archivo antes de que el objeto sea sometido a la depuración de memoria. Podemos utilizar finalize() para descubrir esta condición. Vamos a ver un ejemplo:
//: initialization/TerminationCondition.java // Using finalize() to detect an object that // hasn't been properly cleaned up. class Book { boolean checkedOut = false; Book(boolean checkOut) { checkedOut = checkOut; } void checkIn() { checkedOut = false; } protected void finalize() { if(checkedOut) System.out.println("Error: checked out"); // Normally, you'll also do this: // super.finalize(); // Call the base-class version } } public class TerminationCondition { public static void main(String[] args) { Book novel = new Book(true); // Proper cleanup: novel.checkIn(); // Drop the reference, forget to clean up: new Book(true); // Force garbage collection & finalization: System.gc(); } } /* Output: Error: checked out *///:~
La condición de terminación es que todos los objetos Book (libro) deben ser devueltos (check in) antes de que los procese el depurador de memoria. En main() hay un error de programación ya que uno de los libros no es devuelto. Sin finalize() para verificar la condición de terminación (que todos los libros hayan sido devueltos), este error puede ser difícil de localizar.
Observa que se utiliza System.gc() para forzar la finalización. Incluso aunque no usáramos este objeto es probable que descubriésemos el objeto Book erróneo ejecutando repetidamente el programa (suponiendo que el programa asigne un espacio de memoria suficiente para que sea ejecutado el depurador de memoria).
Hay que asumir que la versión de finalize() de la clase base también estará llevando a cabo alguna actividad importante por lo que convendrá invocarla utilizando super. En el ejemplo se ha desactivado con comentarios porque debe utilizar los mecanismos de tratamiento de excepciones que todavía no hemos visto.
Ejercicio 10. Crea una clase con un método finalize() que imprima un mensaje. En main(), crea un objeto de esa clase. Explique el comportamiento del programa.
Ejercicio 11. Modifica el ejercicio anterior de modo que siempre se invoque el método finalize().
Ejercicio 12. Crea una clase denominada Tank (tanque) que pueda ser llenado y vaciado, y cuya condición de terminación es que el objeto debe estar vacío en el momento de limpiarlo. Escribe un método finalize() que verifique esta condición de terminación. En main(), compruebe los posibles casos que pueden producirse al utilizar los objetos Tank.
Cómo funciona un depurador de memoria
Se puede pensar que como Java asigna todo el espacio de almacenamiento (excepto para las primitivas) en el cúmulo de memoria (Tema 2 - Todo es un objeto) esto puede resultar caro. Sin embargo, el mecanismo de depuración de memoria puede contribuir a acelerar la creación de objetos. Esto puede parecer un poco raro, pero así funcionan algunas máquinas JVM, la asignación de espacio de almacenamiento en el cúmulo de memoria para los objetos Java puede ser casi tan rápida como crear espacio de almacenamiento en la pila para otros lenguajes.
Por ejemplo, en C++ el cúmulo de memoria es como si fuera una parcela de terreno en la que cada objeto ocupa su lote de espacio. Este terreno puede ser abandonado y debe ser reutilizado. En algunas JVM de Java el cúmulo es diferente: se asemeja más a una cinta transportadora que se desplaza hacia adelante cuando se asigna un nuevo objeto. La asignación de espacio es, por tanto, bastante más rápida, simplemente se desplaza hacia adelante el "puntero del cúmulo de memoria" hacia un espacio vacío, por lo que es equivalente en la práctica a la asignación de espacio de almacenamiento en la pila de C++, lógicamente con cierto gasto adicional de recursos de procesamiento asociado a las tareas de administración del espacio.
Se podría pensar que el cúmulo no puede considerarse una cinta transportadora, ya que de esta forma comenzarán a entrar en acción mecanismos de paginación de memoria, desplazando información desde y hacia el disco, de forma que pueda parecer que disponemos de más memoria de la que realmente tenemos. Los mecanismos de paginación afectan a la velocidad y después de crear un número grande de objetos, se agotará la memoria. Pero la depuración de memoria trabaja liberando espacio de almacenamiento que ya no es necesario y compactando todos los objetos en el cúmulo, así el "puntero del cúmulo de memoria" se situara más cerca del comienzo de la cinta transportadora y más alejado de donde pueda producirse un fallo de página.
Vamos a ver esquemas de depuración de memoria en otros sistemas. Una técnica es la del recuento de referencias, cada objeto tiene un contador de referencias y cada vez que se asocia una referencia a ese objeto el contador se incrementa. Si la referencia se sale de ámbito o se le asigna un valor null, se reduce el contador de referencias. El depurador de memoria recorre la lista de objetos y libera aquellos cuyo contador es cero, sin embargo, los mecanismos basados en recuento de referencias liberan los objetos tan pronto como el contador es 0. La desventaja es que si hay una serie de objetos que se refieren circularmente entre sí, tendremos objetos con un número de referencias distintos a 0 a pesar de que ya no son necesarios. La localización de estos objetos exige al depurador un trabajo adicional bastante significativo. Este mecanismo de recuento de referencias no parece que se use en ninguna JVM.
Otros esquemas de depuración de memoria se basan en que cualquier objeto que no esté muerto debe, en último término, ser trazable hasta otra referencia que esté localizada en la pila o en el almacenamiento estático. Así, si comenzamos en la pila y en el área de almacenamiento estático y vamos recorriendo todas las referencias, localizaremos a todos los objetos vivos. Para cada referencia que encontremos, debemos continuar con el proceso de traza, entrando en el objeto al que apunta la referencia y siguiendo todas las referencias incluidas en ese objeto, entrando en los objetos a los que esas referencias apuntan, etc., hasta recorrer todo el árbol que se origina en la pila o en el almacenamiento estático. Cada objeto por el que pasemos estará vivo. Los objetos no pueden referirse circularmente entre sí, ya que esos objetos no serán recorridos durante el proceso de construcción del árbol y por tanto hay que depurarlos.
En la técnica anteriormente descrita, la JVM utiliza un esquema de depuración adaptativo, lo que se hace con los objetos vivos dependerá de la variante del esquema que se esté utilizando actualmente. Una de esas variantes es parar y copiar, primero se detiene el programa ya que no actúa en segundo plano. Cada objeto se copia desde un punto del cúmulo a otro, empaquetándose para que ocupen menos espacio, dejando atrás los objetos muertos. Al desplazar los objetos hay que cambiar las referencias que apuntan a esos objetos. Se modifican las referencias que apunten al objeto desde el cúmulo o desde el área de almacenamiento estático y también las referencias que se encuentren durante la construcción del árbol. Las referencias se irán modificando a medida que sean encontradas.
Estos depuradores copiadores son poco eficientes. Primero, debemos disponer de dos áreas del cúmulo de memoria para poder mover objetos de un lugar a otro, es decir, el doble de la memoria necesaria. Segundo, una vez que el programa se estabilice puede que no genere ningún objeto muerto o muy pocos. Pero el copiador seguirá copiando objetos de un lugar a otro, desperdiciando sin necesidad recursos. Algunas JVM detectan que no se están generando objetos muertos y conmutan a un esquema distinto (se adaptan, parte adaptativa). Este otro esquema se denomina marcar y eliminar, utilizado por las anteriores versiones de la JVM de Sun. Esta técnica es lenta pero rápida si sabemos de antemano que no se están generando objetos muertos.
Mediante la técnica de marcar y eliminar a partir de la pila y del almacenamiento estático se trazan todas las referencias para encontrar los objetos vivos. Se marcan todos ellos y cuando el marcado ha finalizado se produce el proceso de limpieza. Durante esta fase se libera la memoria ocupada por los objetos muertos. No se produce ningún proceso de copia, si el depurador de memoria decide compactar un cúmulo de memoria fragmentado, tendrá que hacerlo moviendo los objetos de un sitio a otro.
En el concepto de parar y copiar la depuración de memoria no se hace en segundo plano, el programa se detiene mientras tiene lugar la depuración. Recordemos que este método era propio de las primeras JVM de Sun. Cuando el depurador de memoria detectaba poca memoria libre, paraba el programa. La técnica de marcar y limpiar también requiere que se detenga el programa.
En la JVM aquí descrita la memoria se asigna en bloques de gran tamaño. Si asignamos un objeto grande, éste obtendrá su propio bloque. La técnica de detención y copiado estricta requiere que todos los objetos vivos se copien desde el cúmulo de memoria de origen hasta un nuevo cúmulo de destino antes de poder liberar el primero, lo que implica gran cantidad de memoria. Utilizando bloques se pueden copiar los objetos a los bloques muertos a medida que se van depurando. Cada objeto dispone de un contador de generacion para saber si está vivo. Sólo se compactan los bloques creados desde la última pasada de depuración de memoria, para los demás bloques se incrementará el contador de generación si han sido referenciados desde algún sitio. Esto permite gestionar el caso normal en que se dispone de un gran número de objetos temporales de corta duración. Periódicamente se hace una limpieza completa en la que los objetos de gran tamaño no serán copiados, (se incrementará su contador de generación) y los bloques con objetos más pequeños se copiarán y compactarán. La JVM comprueba la eficiencia del depurador, si éste constituye una pérdida de tiempo porque los objetos son de larga duración, conmuta al mecanismo de marcado y limpieza. De forma similar también comprueba la eficiencia de la técnica de marcado y limpieza, si el cúmulo de memoria comienza a estar fragmentado, conmuta al mecanismo de detención y copiado. El mecanismo es por tanto adaptativo conmutando entre detención - copiado y marcado - limpieza, según cual sea más eficiente.
Existen varias opciones para aumentar la velocidad de una JVM. Una afecta a la operación del cargador y es lo que se denomina compilador just-in-time (JIT). Un compilador JIT convierte parcial o totalmente un programa a código máquina nativo, de esta forma no tiene que ser interpretado por la JVM y se ejecutará más rápido. Cuando se cargue una clase (normalmente la primera vez que queramos crear un objeto de esa clase) se localiza el archivo .class y se carga en memoria el código intermedio de esa clase. Una posible técnica consistiría en compilar todo el código, sin embargo tiene dos desventajas: necesita algo más de tiempo, si tenemos en cuenta toda la vida del programa, esto puede suponer el gasto de recursos adicionales. Además incrementa el tamaño del ejecutable y esto puede provocar la aparición de la paginación, lo que ralentiza enormemente los programas. Otra técnica es la evaluación lenta, que consiste en que no se compila código intermedio hasta el momento necesario. Así puede que nunca se compile el código que no se va a ejecutar. Las tecnologías HotSpot de Java en los kits de desarrollo JDK recientes adoptan una técnica similar, optimizando de manera incremental un fragmento de código cada vez que se ejecuta, así cuantas más veces se ejecute más rápido lo hará.
INICIALIZACIÓN DE MIEMBROS
Java adopta medidas especiales para asegurarse de que las variables se inicialicen correctamente antes de ser usadas. En el caso de variables locales de un método, si éstas no son inicializadas, se produce un error en tiempo de compilación. Por tanto, si escribimos:
void f(){ int i; i++; // Error i no inicializada }
Tendremos un error en la compilación. Si el compilador diera un valor predeterminado a i, en realidad estaría encubriendo un error cometido por el programador.
La cosa es distinta si tenemos un campo de tipo primitivo en una clase. En este caso cada campo tiene un valor predeterminado. Vamos a verlo con un ejemplo:
//: initialization/InitialValues.java // Shows default initial values. import static net.mindview.util.Print.*; public class InitialValues { boolean t; char c; byte b; short s; int i; long l; float f; double d; InitialValues reference; void printInitialValues() { print("Data type Initial value"); print("boolean " + t); print("char [" + c + "]"); print("byte " + b); print("short " + s); print("int " + i); print("long " + l); print("float " + f); print("double " + d); print("reference " + reference); } public static void main(String[] args) { InitialValues iv = new InitialValues(); iv.printInitialValues(); /* You could also say: new InitialValues().printInitialValues(); */ } } /* Output: Data type Initial value boolean false char [ ] byte 0 short 0 int 0 long 0 float 0.0 double 0.0 reference null *///:~
Vemos como los campos se inicializan automáticamente. Así, no existe el riesgo de trabajar con variables no inicializadas. Si definimos una referencia a objeto dentro de una clase como nuevo objeto, dicha referencia se inicializa con null.
Especificación de la inicialización
Si queremos dar un valor inicial a una variable podemos asignar el valor en el punto en que definamos la variable dentro de la clase. Lo vemos con un ejemplo:
//: initialization/InitialValues2.java // Providing explicit initial values. public class InitialValues2 { boolean bool = true; char ch = 'x'; byte b = 47; short s = 0xff; int i = 999; long lng = 1; float f = 3.14f; double d = 3.14159; } ///:~
Para inicializar objetos no primitivos:
//: initialization/Measurement.java class Depth {} public class Measurement { Depth d = new Depth(); // ... } ///:~
Si d no hubiese sido inicializado con new y tratáramos de usarlo, obtendríamos un error en tiempo de ejecución llamado excepción.
Podemos llamar a un método para proporcionar un valor de inicialización:
//: initialization/MethodInit.java public class MethodInit { int i = f(); int f() { return 11; } } ///:~
El método utilizado puede tener argumentos pero estos argumentos no pueden ser miembros de la clase que todavía no hayan sido inicializados. Podemos hacer esto:
//: initialization/MethodInit2.java public class MethodInit2 { int i = f(); int j = g(i); int f() { return 11; } int g(int n) { return n * 10; } } ///:~
Pero no esto:
//: initialization/MethodInit3.java public class MethodInit3 { //! int j = g(i); // Illegal forward reference int i = f(); int f() { return 11; } int g(int n) { return n * 10; } } ///:~
En este ejemplo, el compilador se queja acerca de las referencias anticipadas, ya que este caso tiene que ver con el orden de inicialización más que con la forma en que se compila el programa. El orden correcto es el de la clase anterior (MethodInit2). Aquí estamos intentando inicializar j con una función que tiene un argumento sin inicializar (i).
INICIALIZACIÓN MEDIANTE CONSTRUCTORES
Podemos emplear el constructor para realizar la inicialización, esto proporciona mayor flexibilidad ya que podemos invocar métodos y realizar acciones en tiempo de ejecución para determinar los valores iniciales. Sin embargo, esto no excluye la inicialización automática que tiene lugar antes de entrar en el constructor. Por ejemplo:
//: initialization/Counter.java public class Counter { int i; Counter() { i = 7; } // ... } ///:~
Primero i se inicializará con el valor predeterminado 0 y luego con el valor 7. Esto es cierto para todos los tipos primitivos y para las referencias a objetos, incluyendo aquellos a los que se inicialice en el punto en que se los defina. Por esta razón el compilador no nos obliga a inicializar ningún elemento en ningún sitio concreto dentro del constructor o antes de utilizarlos, ya que la inicialización está garantizada.
Orden de inicialización
En una clase el orden de inicialización se determina mediante el orden en que se definen las variables en la clase. Las definiciones de variables pueden estar dispersas a través de y entre las definiciones de métodos, pero las variables se inicializan antes de que se pueda invocar cualquier método, incluso el constructor. Por ejemplo:
//: initialization/OrderOfInitialization.java // Demonstrates initialization order. import static net.mindview.util.Print.*; // When the constructor is called to create a // Window object, you'll see a message: class Window { Window(int marker) { print("Window(" + marker + ")"); } } class House { Window w1 = new Window(1); // Before constructor House() { // Show that we're in the constructor: print("House()"); w3 = new Window(33); // Reinitialize w3 } Window w2 = new Window(2); // After constructor void f() { print("f()"); } Window w3 = new Window(3); // At end } public class OrderOfInitialization { public static void main(String[] args) { House h = new House(); h.f(); // Shows that construction is done } } /* Output: Window(1) Window(2) Window(3) House() Window(33) f() *///:~
Si nos fijamos, los objetos Windows se inicializan antes de entrar en el constructor o de que suceda cualquier otra cosa. Además, w3 se reinicializa dentro del constructor.
Como w3 se inicializa dos veces, una antes y otra dentro del constructor, el primer objeto será eliminado y podrá ser procesado por el depurador de memoria. Aunque esto no parece eficiente, al menos tenemos garantizada la inicialización de w3 ya que podríamos encontrarnos el caso de tener un constructor sobrecargado que no inicializara w3 y no hubiera inicialización "predeterminada" de la variable en su definición.
Inicialización de datos estáticos
Existe una única área de almacenamiento para un dato de tipo static, independientemente del número de objetos que se creen. No puede haber variables locales static. Si un campo static no se inicializa obtendrá el valor predeterminado correspondiente a su tipo, como cualquier otro campo.
Si inicializamos las variables static en el punto de la definición, será similar al caso de las variables no estáticas. Vamos a ver cuándo se inicializa el almacenamiento de tipo static:
//: initialization/StaticInitialization.java // Specifying initial values in a class definition. import static net.mindview.util.Print.*; class Bowl { Bowl(int marker) { print("Bowl(" + marker + ")"); } void f1(int marker) { print("f1(" + marker + ")"); } } class Table { static Bowl bowl1 = new Bowl(1); Table() { print("Table()"); bowl2.f1(1); } void f2(int marker) { print("f2(" + marker + ")"); } static Bowl bowl2 = new Bowl(2); } class Cupboard { Bowl bowl3 = new Bowl(3); static Bowl bowl4 = new Bowl(4); Cupboard() { print("Cupboard()"); bowl4.f1(2); } void f3(int marker) { print("f3(" + marker + ")"); } static Bowl bowl5 = new Bowl(5); } public class StaticInitialization { public static void main(String[] args) { print("Creating new Cupboard() in main"); new Cupboard(); print("Creating new Cupboard() in main"); new Cupboard(); table.f2(1); cupboard.f3(1); } static Table table = new Table(); static Cupboard cupboard = new Cupboard(); } /* Output: Bowl(1) Bowl(2) Table() f1(1) Bowl(4) Bowl(5) Bowl(3) Cupboard() f1(2) Creating new Cupboard() in main Bowl(3) Cupboard() f1(2) Creating new Cupboard() in main Bowl(3) Cupboard() f1(2) f2(1) f3(1) *///:~
Tenemos una clase Bowl con un constructor y una función f1. También tenemos dos clases más, Table y Cupboard que crean variables estáticas de Bowl dispersas por las definiciones de las clases. Cupboard crea un objeto bowl3 no estático antes de las definiciones de tipo static.
La inicialización de static tiene lugar sólo en caso necesario. En la clase Table, los objetos estáticos bowl1 y bowl2 se inicializan cuando se crea el primer objeto Table (cuando tiene lugar el primer objeto static). Después de eso los objetos static no se reinicializan como podemos comprobar con los dos objetos Cupboard que se crean dentro de main() de la clase StaticInitialization(). Como ya se habían creado con anterioridad fuera de este método (objeto cupboard), cuando se crean nuevos objetos Cupboard las variables estáticas no se vuelven a inicializar.
En la clase Cupboard podemos ver que se inicializan primero los objetos estáticos, si no han sido inicializados con anterioridad, y después los no estáticos. Lo podemos comprobar en la clase StaticInitialization cuando se crea el objeto cupboard. En esta clase primero se inicializan los campos estáticos table y cupboard, lo que hace que se carguen estas clases y como ambas contienen objetos Bowl estáticos, también se carga la clase Bowl. Como vemos, todas las clases se cargan antes de que dé comienzo main(). Éste no es el caso habitual ya que en los programas típicos no tendremos todo vinculado mediante valores estáticos.
Vamos a resumir el proceso de creación de un objeto, consideramos una clase Dog:
1. Aunque no utilice la palabra static, el constructor es en la práctica un método static. La primera vez que se crea un objeto Dog o la primera vez que se accede a un método estático o a un campo estático de la clase Dog, el intérprete de Java debe localizar Dog.class, analizando la ruta de clases que en ese momento haya definida (classpath).
2. Según se va cargando Dog.class (creando un objeto Class que veremos más adelante) se van ejecutando los inicializadores de tipo static. Así la inicialización static sólo tiene lugar una vez, cuando se carga por primera vez el objeto Class.
3. Cuando se crea un objeto con new Dog(), se asigna primero el suficiente espacio de almacenamiento para el objeto Dog en el cúmulo de memoria.
4. El espacio de almacenamiento se rellena con ceros. Se asignan automáticamente los valores predeterminados a todas las primitivas del objeto Dog (cero a los número y el equivalente a char y boolean) y el valor null a las referencias a objetos.
5. Se ejecutan las inicializaciones especificadas en el lugar donde se definan los campos.
6. Se ejecutan los constructores.
Inicialización static explícita
Java permite agrupar otras inicializaciones estáticas dentro de una cláusula especial static, también llamada bloque estático en una clase. El aspecto es el siguiente:
//: initialization/Spoon.java public class Spoon { static int i; static { i = 47; } } ///:~
Parece un método pero es una palabra clave static seguida de un bloque de código. Este código, al igual que otras inicializaciones estáticas, sólo se ejecuta la primera vez que se crea un objeto de esa clase o la primera vez que se accede a un miembro de tipo static de esa clase. Veamos un ejemplo:
//: initialization/ExplicitStatic.java // Explicit static initialization with the "static" clause. import static net.mindview.util.Print.*; class Cup { Cup(int marker) { print("Cup(" + marker + ")"); } void f(int marker) { print("f(" + marker + ")"); } } class Cups { static Cup cup1; static Cup cup2; static { cup1 = new Cup(1); cup2 = new Cup(2); } Cups() { print("Cups()"); } } public class ExplicitStatic { public static void main(String[] args) { print("Inside main()"); Cups.cup1.f(99); // (1) } // static Cups cups1 = new Cups(); // (2) // static Cups cups2 = new Cups(); // (2) } /* Output: Inside main() Cup(1) Cup(2) f(99) *///:~
Los inicializadores static de Cups se ejecutan cuando tiene lugar el acceso del objeto estático cup1 que está marcado con (1) o si se desactiva mediante un comentario esta línea y se quitan los comentarios de las líneas marcadas con (2). Si se desactivan mediante comentarios (1) y (2), la inicialización static de Cups nunca tiene lugar. La inicialización estática tiene lugar una sola vez: cuando esté activada (1) o cuando esté activada alguna de las (2), aunque no esté activada (1).
Ejercicio 13. Verifica las afirmaciones contenidas en el párrafo anterior.
Ejercicio 14. Crea una clase con una campo estático String que sea inicializado en el punto de definición, y otro campo que se inicialice mediante el bloque static. Añade un método static que imprima ambos campos y demuestre que ambos se inicializan antes de usarlos.
Inicialización de instancias no estáticas
Java proporciona una sintaxis similar, llamada inicialización de instancia, para inicializar las variables no estáticas de cada objeto. Veamos un ejemplo:
//: initialization/Mugs.java // Java "Instance Initialization." import static net.mindview.util.Print.*; class Mug { Mug(int marker) { print("Mug(" + marker + ")"); } void f(int marker) { print("f(" + marker + ")"); } } public class Mugs { Mug mug1; Mug mug2; { mug1 = new Mug(1); mug2 = new Mug(2); print("mug1 & mug2 initialized"); } Mugs() { print("Mugs()"); } Mugs(int i) { print("Mugs(int)"); } public static void main(String[] args) { print("Inside main()"); new Mugs(); print("new Mugs() completed"); new Mugs(1); print("new Mugs(1) completed"); } } /* Output: Inside main() Mug(1) Mug(2) mug1 & mug2 initialized Mugs() new Mugs() completed Mug(1) Mug(2) mug1 & mug2 initialized Mugs(int) new Mugs(1) completed *///:~
Tenemos la cláusula de inicialización de instancia:
{ mug1 = new Mug(1); mug2 = new Mug(2); print("mug1 & mug2 initialized"); }
Fijaos que es como la cláusula de inicialización estática, excepto porque falta la palabra clave static. Esta sintaxis es necesaria para soportar la inicialización de clases internas anónimas (que veremos en el Tema 10), pero también nos permite garantizar que ciertas operaciones tendrán lugar independientemente del constructor que se invoque. Podemos ver que la cláusula de inicialización se ejecuta antes que los dos constructores.
Ejercicio 15. Crea una clase con un campo String que se inicialice mediante una cláusula de inicialización de instancia.
INICIALIZACIÓN DE MATRICES
Una matriz es una secuencia de objetos o primitivas que son todos del mismo tipo y que se empaquetan juntos, utilizando un único nombre identificador. Las matrices se definen y usan el operador de indexación []. Una forma de definir una referencia de una matriz es:
int[] a1;
Otra forma es la siguiente:
int a1[];
El primero de los dos estilos es una sintaxis más adecuada ya que comunica mejor que estamos definiendo una matriz de variables de un tipo específico, en este caso int.
El compilador no permite especificar el tamaño de la matriz. Lo que tenemos en este punto es una referencia a una matriz, sin que se haya asignado ningún espacio para el objeto matriz. Para crear espacio de almacenamiento es necesario escribir una expresión de inicialización. Puede hacerse en cualquier lugar del código, pero también podemos utilizar una inicialización especial que sólo puede emplearse en el punto donde se cree la matriz. Esta inicialización especial es un conjunto de valores encerrados entre llaves. En este caso el compilador se ocupa de la asignación de espacio (el equivalente a new). Por ejemplo:
int[] a1={1,2,3,4,5};
¿Por qué definir una referencia a una matriz sin definir la propia matriz?
int[] a2;
La razón es que en Java es posible asignar una matriz a otra:
a2=a1;
En realidad lo que estamos haciendo es copiar una referencia:
//: initialization/ArraysOfPrimitives.java import static net.mindview.util.Print.*; public class ArraysOfPrimitives { public static void main(String[] args) { int[] a1 = { 1, 2, 3, 4, 5 }; int[] a2; a2 = a1; for(int i = 0; i < a2.length; i++) a2[i] = a2[i] + 1; for(int i = 0; i < a1.length; i++) print("a1[" + i + "] = " + a1[i]); } } /* Output: a1[0] = 2 a1[1] = 3 a1[2] = 4 a1[3] = 5 a1[4] = 6 *///:~
Como vemos a a1 se le da un valor de inicialización y a a2 se le asigna a1 (a2=a1). Como a1 y a2 apuntan a la misma matriz los cambios que se realicen en a2 podrán verse en a1 ya que ambos apuntan al mismo lugar.
Todas las matrices tienen un miembro intrínseco que se puede consultar para saber los elementos que hay en la matriz, se trata de length. Las matrices comienzan a contar a partir de cero y el elemento máximo que pueden indexar es length-1. Si nos salimos de estos límites, en Java se produce un error en tiempo de ejecución (una excepción).
Comprobar cada acceso a una matriz cuesta tiempo y código, pero para aumentar la seguridad en Internet y la productividad de los programadores, los diseñadores de Java pensaron que resultaba conveniente pagar este precio para evitar errores asociados con las matrices.
Si no sabemos cuántos elementos vamos a necesitar en una matriz en el momento de escribir el programa, utilizaremos new para crear los elementos de la matriz. Aquí new funciona incluso aunque se esté creando una matriz de primitivas, pero new no permite crear una primitiva simple que no forme parte de una matriz. Vamos a ver un ejemplo:
//: initialization/ArrayNew.java // Creating arrays with new. import java.util.*; import static net.mindview.util.Print.*; public class ArrayNew { public static void main(String[] args) { int[] a; Random rand = new Random(47); a = new int[rand.nextInt(20)]; print("length of a = " + a.length); print(Arrays.toString(a)); } } /* Output: length of a = 18 [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] *///:~
El tamaño de la matriz se selecciona aleatoriamente entre cero y diecinueve (rand.nextInt(20)). Está claro que la creación de la matriz ocurre en tiempo de ejecución. La salida del programa muestra que todos los elementos de la matriz toman el valor predeterminado para int, ya que no se han inicializado.
El método Arrays.toString(), forma parte de la biblioteca java.util y como vemos imprime el contenido de la matriz unidimensional.
La matriz también se puede definir e inicializar en la misma instrucción:
int[] a=new int[rand.nextInt(20)];
Es mejor hacerlo así siempre que se pueda.
Si creamos una matriz que no es de tipo primitivo, en realidad se crea una matriz de referencias. Vamos a considerar el tipo envoltorio Integer, que como recordarás es una clase:
//: initialization/ArrayClassObj.java // Creating an array of nonprimitive objects. import java.util.*; import static net.mindview.util.Print.*; public class ArrayClassObj { public static void main(String[] args) { Random rand = new Random(47); Integer[] a = new Integer[rand.nextInt(20)]; print("length of a = " + a.length); for(int i = 0; i < a.length; i++) a[i] = rand.nextInt(500); // Autoboxing print(Arrays.toString(a)); } } /* Output: (Sample) length of a = 18 [55, 193, 361, 461, 429, 368, 200, 22, 207, 288, 128, 51, 89, 309, 278, 498, 361, 20] *///:~
Como tenemos una matriz de referencias (Integer[] a=new Integer(rand.nextInt(20)]), la inicialización no se completa hasta que no se inicialicen las referencias, creando nuevos objetos Integer (en este caso mediante el mecanismo de conversión automática):
for(int i = 0; i < a.length; i++) a[i] = rand.nextInt(500); // Autoboxing
Si no creamos uno de los elementos (objeto) de la matriz, obtendremos una excepción en tiempo de ejecución.
Podemos inicializar matrices de objetos mediante una lista encerrada entre llaves. Aquí tenemos dos formas de hacerlo:
//: initialization/ArrayInit.java // Array initialization. import java.util.*; public class ArrayInit { public static void main(String[] args) { Integer[] a = { new Integer(1), new Integer(2), 3, // Autoboxing }; Integer[] b = new Integer[]{ new Integer(1), new Integer(2), 3, // Autoboxing }; System.out.println(Arrays.toString(a)); System.out.println(Arrays.toString(b)); } } /* Output: [1, 2, 3] [1, 2, 3] *///:~
La coma final de las listas de inicializadores son opcionales.
La primera forma que hemos visto:
int[] a = { 1, 2, 3, 4, 5 };
Es útil pero limitada, sólo puede realizarse en el punto donde se define la matriz.
Las otras formas:
Integer[] b = new Integer[rand.nextInt(20)]; Integer[] c = { new Integer(1), new Integer(2), 3, // Autoboxing }; Integer[] d = new Integer[]{ new Integer(1), new Integer(2), 3, // Autoboxing };
Se pueden utilizar en cualquier lugar, incluso dentro de una llamada a un método.
A continuación vemos un ejemplo en el que creamos una matriz de String para pasarla a otro método main() y proporcionar así argumentos alternativos a la línea de comandos para ese método main().
//: initialization/DynamicArray.java // Array initialization. public class DynamicArray { public static void main(String[] args) { Other.main(new String[]{ "fiddle", "de", "dum" }); } } class Other { public static void main(String[] args) { for(String s : args) System.out.print(s + " "); } } /* Output: fiddle de dum *///:~
La matriz creada para el argumento de Other.main() se crea en el punto correspondiente a la llamada al método, podemos así proporcionar argumentos alternativos en el momento de la llamada.
Ejercicio 16. Crea una matriz de objetos String y asigna un objeto String a cada elemento. Imprime la matriz utilizando un bucle for.
Ejercicio 17. Crea una clase con un constructor que tome un argumento String. Durante la construcción, imprime el argumento. Crea una matriz de referencias a objetos de esta clase, pero sin crear ningún objeto para asignarlo a la matriz. Cuando ejecutes el programa, objserva si se imprimen los mensajes de inicialización correspondientes a las llamadas al constructor.
Ejercicio 18. Completa el ejercicio anterior creando objetos que asociar a la matriz de referencias.
Listas variables de argumentos
La tercera forma vista proporciona una sintaxis cómoda para crear e invocar métodos que pueden producir un efecto similar a las listas variables de argumentos de C (varargs). Esto puede incluir un número desconocido de argumentos que a su vez pueden ser de tipos desconocidos. Como todas las clases heredan de la clase Object, podemos crear un método que admita una matriz de Object e invocarlo así:
//: initialization/VarArgs.java // Using array syntax to create variable argument lists. class A {} public class VarArgs { static void printArray(Object[] args) { for(Object obj : args) System.out.print(obj + " "); System.out.println(); } public static void main(String[] args) { printArray(new Object[]{ new Integer(47), new Float(3.14), new Double(11.11) }); printArray(new Object[]{"one", "two", "three" }); printArray(new Object[]{new A(), new A(), new A()}); } } /* Output: (Sample) 47 3.14 11.11 one two three A@1a46e30 A@3e25a5 A@19821f *///:~
El método printArray tiene como argumento una matriz de Object y recorre la matriz utilizando la sintaxis foreach imprimiendo cada objeto. Los dos primeros usos que se hacen de printArray generan una salida correcta, pero el tercer uso de printArray genera el nombre de la clase A, seguido por el signo '@' y por una serie de dígitos hexadecimales. En este caso, si no se define un método toString() para la clase, se imprime el nombre de la clase y la dirección del objeto.
A partir de Java SE5, ha sido añadida la característica de generar listas variables de argumentos y se define mediante puntos suspensivos. Vemos un ejemplo:
//: initialization/NewVarArgs.java // Using array syntax to create variable argument lists. public class NewVarArgs { static void printArray(Object... args) { for(Object obj : args) System.out.print(obj + " "); System.out.println(); } public static void main(String[] args) { // Can take individual elements: printArray(new Integer(47), new Float(3.14), new Double(11.11)); printArray(47, 3.14F, 11.11); printArray("one", "two", "three"); printArray(new A(), new A(), new A()); // Or an array: printArray((Object[])new Integer[]{ 1, 2, 3, 4 }); printArray(); // Empty list is OK } } /* Output: (75% match) 47 3.14 11.11 47 3.14 11.11 one two three A@1bab50a A@c3c749 A@150bd4d 1 2 3 4 *///:~
Esta forma de generar listas variables de argumentos se denomina varargs (argumentos variables). Tenemos un método al que se le puede pasar como argumento una matriz con un número variable de elementos y, en este caso, de diferentes tipos. Fijaos en el ejemplo, cuando llamamos a printArray() utilizamos listas de argumentos. Sin embargo, es algo más que una simple conversión entre una lista de argumentos y una matriz. Si observamos la penúltima línea tenemos una matriz de Integer creados mediante conversión automática, se proyecta sobre una matriz Object, así se evita que el compilador genere una advertencia y se pasa a printArray(). Obviamente el compilador determina que se trata de una matriz y no realiza ninguna conversión con ella. Por tanto, si tenemos un grupo de elementos, podemos pasarlos como una lista y si tenemos una matriz, se aceptará esa matriz como lista variable de argumentos. También vemos en la última línea que se pueden pasar cero argumentos a una lista vararg. Esto es útil cuando tenemos argumentos finales opcionales, lo vemos con un ejemplo:
//: initialization/OptionalTrailingArguments.java public class OptionalTrailingArguments { static void f(int required, String... trailing) { System.out.print("required: " + required + " "); for(String s : trailing) System.out.print(s + " "); System.out.println(); } public static void main(String[] args) { f(1, "one"); f(2, "two", "three"); f(0); } } /* Output: required: 1 one required: 2 two three required: 0 *///:~
En el ejemplo vemos que se pueden utilizar varargs con un tipo diferente de Object, en este caso String. Se puede utilizar cualquier tipo de argumentos en las listas varargs, incluyendo tipos primitivos. El siguiente ejemplo muestra que la lista vararg se transforma en una matriz y si no hay nada en la lista se tratará como una matriz de tamaño cero:
//: initialization/VarargType.java public class VarargType { static void f(Character... args) { System.out.print(args.getClass()); System.out.println(" length " + args.length); } static void g(int... args) { System.out.print(args.getClass()); System.out.println(" length " + args.length); } public static void main(String[] args) { f('a'); f(); g(1); g(); System.out.println("int[]: " + new int[0].getClass()); } } /* Output: class [Ljava.lang.Character; length 1 class [Ljava.lang.Character; length 0 class [I length 1 class [I length 0 int[]: class [I *///:~
El método getClass() es parte de Object, (lo veremos más adelante), devuelve la clase de un objeto y al imprimir la clase vemos una representación del tipo de la clase en forma de caracteres codificada. El carácter inicial '[' indica que se trata de una matriz de un tipo específico, en este caso, 'I' para int o bien 'Ljava.lang.Character' para Character. Lo podemos ver en las llamadas a los métodos f y g, también en System.out.println("int[]: "+new int[0].getClass());. Con esto comprobamos que la utilización de varargs no depende de la conversión automática, sino que utiliza en la práctica los tipos primitivos. Sin embargo, las listas varargs funcionan con conversión automática:
//: initialization/AutoboxingVarargs.java public class AutoboxingVarargs { public static void f(Integer... args) { for(Integer i : args) System.out.print(i + " "); System.out.println(); } public static void main(String[] args) { f(new Integer(1), new Integer(2)); f(4, 5, 6, 7, 8, 9); f(10, new Integer(11), 12); } } /* Output: 1 2 4 5 6 7 8 9 10 11 12 *///:~
Fijaos que se pueden mezclar los tipos en una misma lista de argumentos y la característica de conversión automática promociona selectivamente los argumentos int a Integer.
Las listas vararg complican el proceso de sobrecarga aunque éste parece suficientemente seguro a primera vista:
//: initialization/OverloadingVarargs.java public class OverloadingVarargs { static void f(Character... args) { System.out.print("first"); for(Character c : args) System.out.print(" " + c); System.out.println(); } static void f(Integer... args) { System.out.print("second"); for(Integer i : args) System.out.print(" " + i); System.out.println(); } static void f(Long... args) { System.out.println("third"); } public static void main(String[] args) { f('a', 'b', 'c'); f(1); f(2, 1); f(0); f(0L); //! f(); // Won't compile -- ambiguous } } /* Output: first a b c second 1 second 2 1 second 0 third *///:~
El compilador utiliza la característica de conversión automática para determinar qué método sobrecargado tiene que utilizar. Lógicamente cuando llama a f() sin argumentos no puede saber a cuál de los dos métodos debe llamar. Podemos tratar de resolver este problema añadiendo un argumento no vararg a uno de los métodos:
//: initialization/OverloadingVarargs2.java // {CompileTimeError} (Won't compile) public class OverloadingVarargs2 { static void f(float i, Character... args) { System.out.println("first"); } static void f(Character... args) { System.out.print("second"); } public static void main(String[] args) { f(1, 'a'); f('a', 'b'); } } ///:~
El comentario {CompileTimeError} excluye este archivo del proceso de construcción Ant del libro. Recuerda que Ant es una herramienta utilizada en la compilación y creación de programas Java. Si compilamos este ejemplo da el siguiente error:
reference to f is ambigous, both method f(float,java.lang.Character...) in
OverloadingVarargs2 and method f(java.lang.Character...) in OverloadingVarargs2 match
Si proporcionamos argumentos no-vararg a ambos métodos funcionará:
//: initialization/OverloadingVarargs3.java public class OverloadingVarargs3 { static void f(float i, Character... args) { System.out.println("first"); } static void f(char c, Character... args) { System.out.println("second"); } public static void main(String[] args) { f(1, 'a'); f('a', 'b'); } } /* Output: first second *///:~
Generalmente sólo debe usarse una lista variable de argumentos en una única versión de un método sobrecargado. O bien no utilizar la lista variable de argumentos.
Ejercicio 19. Escribe un método que admita una matriz vararg de tipo String. Verifica que puede pasar una lista separada por comas de objetos String o una matriz String[] a este método.
Ejercicio 20. Crea un método main() que utilice varargs en lugar de la sintaxis main() normal. Imprima todos los elementos de la matriz args resultante. Prueba el método con diversos conjuntos de argumentos de línea de comandos.
TIPOS ENUMERADOS
La palabra clave enum, nos facilita la tarea de agrupar y utilizar un conjunto de tipos enumerados. En el pasado, teníamos que crear un conjunto de valores enteros constantes, que no casaban muy bien con los conjuntos que se necesitaban definir. A partir de Java SE5, disponemos de enum, veamos un ejemplo:
//: initialization/Spiciness.java public enum Spiciness { NOT, MILD, MEDIUM, HOT, FLAMING } ///:~
Tenemos el tipo enumerado Spiciness con cinco valores nominados. Las instancias de los tipos enumerados son constantes y por eso se escriben en mayúsculas, si hay múltiples palabras en un nombre se separan mediante guiones bajos. Para utilizar un tipo enum creamos una variable de ese tipo y la asignamos uno de los valores nominados:
//: initialization/SimpleEnumUse.java public class SimpleEnumUse { public static void main(String[] args) { Spiciness howHot = Spiciness.MEDIUM; System.out.println(howHot); } } /* Output: MEDIUM *///:~
Un tipo enum tiene una serie de características útiles proporcionadas por el compilador. Crea un método toString() para visualizar el nombre de una instancia enum, por eso hemos podido imprimir MEDIUM en el ejemplo. También crea un método ordinal() que indica el orden de declaración de una constante enum concreta y un método static values() que genera una matriz con las constantes enum en el orden en que fueron declaradas:
//: initialization/EnumOrder.java public class EnumOrder { public static void main(String[] args) { for(Spiciness s : Spiciness.values()) System.out.println(s + ", ordinal " + s.ordinal()); } } /* Output: NOT, ordinal 0 MILD, ordinal 1 MEDIUM, ordinal 2 HOT, ordinal 3 FLAMING, ordinal 4 *///:~
El tipo enum provoca que el compilador realice una serie de actividades mientras genera una clase para el tipo enum, por lo que enum se puede tratar en muchos sentidos como una clase. De hecho los tipos enum son clases y tienen sus propios métodos.
Con las instrucciones switch los tipos enum pueden usarse de forma especial:
//: initialization/Burrito.java public class Burrito { Spiciness degree; public Burrito(Spiciness degree) { this.degree = degree;} public void describe() { System.out.print("This burrito is "); switch(degree) { case NOT: System.out.println("not spicy at all."); break; case MILD: case MEDIUM: System.out.println("a little hot."); break; case HOT: case FLAMING: default: System.out.println("maybe too hot."); } } public static void main(String[] args) { Burrito plain = new Burrito(Spiciness.NOT), greenChile = new Burrito(Spiciness.MEDIUM), jalapeno = new Burrito(Spiciness.HOT); plain.describe(); greenChile.describe(); jalapeno.describe(); } } /* Output: This burrito is not spicy at all. This burrito is a little hot. This burrito is maybe too hot. *///:~
La instrucción switch se utiliza para seleccionar dentro de un conjunto limitado de posibilidades, por lo que se complementa perfectamente con enum. Con los nombres enum se ve más claramente qué es lo que pretende hacer el programa.
El tipo enum es como una forma de crear un tipo de datos y luego utilizamos los resultados. Su uso es muy simple. Más adelante, veremos con más profundidad estos tipos.
Ejercicio 21. Crea un tipo enum con los seis tipos de billetes de euro de menor valor. Recorre en bucle los valores utilizando values() e imprima cada valor y su orden correspondiente con ordinal().
Ejercicio 22. Escribe una instrucción switch para el tipo enum del ejercicio anterior. En cada case, imprime una descripción de ese billete concreto.
muy completo, me ayudo demasiado.
ResponderEliminarMuchas gracias, me alegro de que te haya servido.
EliminarUn saludo.