Professional Documents
Culture Documents
Una de las características más atractivas de Java es la reutilización de código. Pero para ser
revolucionario, es necesario poder hacer muchísimo más que copiar código y cambiarlo.
Este es el enfoque que se utiliza en los lenguajes procedurales como C, pero no ha funcionado muy
bien. Como todo en Java, la solución está relacionada con la clase. Se reutiliza código creando nuevas
clases, pero en vez de crearlas de la nada, se utilizan clases ya existentes que otra persona ya ha construido
y depurado.
Se deben usar las clases sin modificar el código existente. Existen dos formas de lograrlo. La primera
es bastante directa: simplemente se crean objetos de la clase existente dentro de la nueva clase. A esto se le
llama composición, porque la clase nueva está compuesta de objetos de clases existentes. Simplemente se
está reutilizando la funcionalidad del código, no su forma.
El segundo enfoque es más sutil. Crea una nueva clase como un tipo de una clase ya existente.
Literalmente se toma la forma de la clase existente y se le añade código sin modificar a la clase ya
existente. Este acto mágico se denomina herencia, y el compilador hace la mayoría del trabajo. La
herencia es una de las clases angulares de la programación orientada a objetos.
Resulta que mucha de la sintaxis y comportamiento son similares, tanto para la herencia, como para
la composición (lo cual tiene sentido porque ambas son formas de construir nuevos tipos a partir de tipos
existentes).
La composición y la herencia son formas de construir nuevos tipos a partir de tipos existentes.
1. Sintaxis de la Composición.
Para usar la composición simplemente se ubican referencias a objetos dentro de nuevas clases.
Por ejemplo, suponga que se desea tener un objeto que albergue varios objetos de tipo cadena de
caracteres, un par de datos primitivos, y un objeto de otra clase.
En el caso de los objetos no primitivos, se ponen referencias dentro de la nueva clase, pero se
definen los datos primitivos directamente.
Se ha implementado la clase FuenteAgua, que tiene como atributo una referencia a un objeto
de la clase String, un método constructor y el método toString, conocido también como
método de impresión por defecto, el cual es invocado en situaciones especiales cuando el compilador
desea obtener un objeto como cadena de caracteres.
A primera vista, uno podría asumir – siendo Java tan seguro y cuidadoso como es – que el
compilador podría construir automáticamente objetos para cada una de las referencias en el código
del método escribir de la clase Aspersor; por ejemplo, invocando al constructor por defecto
para FuenteAgua para inicializar fuente.
valvula1 = null
valvula2 = null
valvula3 = null
valvula4 = null
i = 0
f = 0.0
fuente = null
Los datos primitivos son campos de una clase que se inicializan automáticamente a cero. Pero
las referencias a objetos se inicializan a null, y si se intenta invocar a métodos de cualquiera de ellos,
se sigue obteniendo una excepción. De hecho, es bastante bueno (y útil) poder seguir imprimiéndolos
sin lanzar excepciones.
Tiene sentido que el compilador no cree un objeto por defecto para cada referencia, porque eso
conllevaría una sobrecarga innecesaria en la mayoría de los casos. Si se quieren referencias
inicializadas, se puede hacer:
1. En el punto en que se definen los objetos. Esto significa que siempre serán inicializados
antes de invocar al constructor.
2. En el constructor de esa clase.
3. Justo antes de que, de hecho, se necesite el objeto. A esto se le llama inicialización perezosa.
Puede reducir la sobrecarga en situaciones en las que no es necesario crear siempre el
objeto.
A continuación, se muestran los tres enfoques a través de la implementación de las clases Hoja
y Libro:
En el constructor Libro se ejecuta una sentencia antes de que tenga lugar ninguna
inicialización. Cuando no se inicializa en el momento de la definición, sigue sin haber garantías de que
se lleve a cabo ningún tipo de inicialización antes de que se envíe un mensaje a una referencia a un
objeto – excepto la inevitable excepción en tiempo de ejecución. Cuando se invoca al método
escribir(), éste rellena s4 para que todos los campos estén inicializados correctamente cuando se
usen. He aquí la salida del programa:
2. Sintaxis de la Herencia.
La herencia es una parte integral de Java (y de todos los lenguajes de POO en general). Resulta
que siempre se está haciendo herencia cuando se crea una clase, pero a menos que se herede
explícitamente de otra clase, se hereda implícitamente de la clase raíz estándar de Java Object. He
aquí un ejemplo:
La sintaxis para la composición es obvia, pero para llevar a cabo herencia se realiza de distinta
forma. Cuando se hereda, se dice: "Esta clase nueva es como esa clase vieja". Se dice esto en el
código dando el nombre de la clase, como siempre, pero antes de abrir el paréntesis del cuerpo de la
clase, se pone la palabra clave extends seguida del nombre de la clase base. Cuando se hace esto,
automáticamente se tienen todos los datos miembro y métodos de la clase base. Note que en el
método agregar() de la clase ProductoLimpieza, se concatenan cadenas de caracteres a s
utilizando el operador +=, que es uno de los operadores (junto con '+') que los diseñadores de Java
"sobrecargaron" para que funcionara con cadenas de caracteres. He aquí la salida del programa al
ejecutar la clase ProductoLimpieza:
Producto de Limpieza
diluir()
aplicar()
fregar()
Producto de Limpieza
diluir()
aplicar()
Detergente.frotar()
fregar()
aclarar()
Esta técnica de poner un método main() en cada clase permite llevar a cabo pruebas para cada
clase de manera sencilla. Y no es necesario eliminar el método main() cuando se han acabado las
pruebas; se puede dejar ahí por si hubiera que usarlas para otras pruebas más adelante. Aquí, se puede
ver que Detergente.main() llama a ProductoLimpieza.main() explícitamente, pasándole
los mismos argumentos de la línea de comandos (sin embargo, se podría pasar cualquier array de
cadenas de caracteres).
Es importante que todos los métodos de ProductoLimpieza sean públicos. Si se deja sin
poner cualquier modificador de miembro, el miembro será por defecto "amistoso", lo cual permite
acceder sólo a los miembros del paquete. Por consiguiente, dentro de este paquete, cualquiera podría
usar esos métodos si no hubiera modificador de acceso. Detergente no tendría problemas, por
ejemplo.
Sin embargo, si se fuera a heredar desde ProductoLimpieza una clase de cualquier otro
paquete, ésta sólo podría acceder a las clases públicas. Por tanto, al planificar la herencia, como regla
general, deben hacerse todos los campos privados y todos los miembros públicos (los miembros
protegidos también permiten accesos por parte de clases derivadas).
Como se ve en frotar(), es posible tomar un método que se haya definido en la clase base y
modificarlo. En este caso, se podría desear llamar al método desde la clase base dentro de la nueva
versión. Pero dentro de frotar() no se puede simplemente invocar a frotar(), dado que eso
produciría una llamada recursiva, que no es lo que se desea. Para solucionar este problema Java tiene
la palabra clave super que hace referencia a la "superclase" de la cual ha heredado la clase actual.
Por consiguiente, la expresión super.frotar() llama a la versión que tiene la clase base del
método frotar().
Al heredar, uno no se limita a usar los métodos de la clase base. También se pueden añadir
nuevos métodos a la clase derivada, exactamente de la misma manera que se introduce un método en
una clase: simplemente se definen. El método aclarar() es un ejemplo de esta afirmación.
Dado que ahora hay dos clases involucradas – la clase base y la clase derivada – en vez de
simplemente una, puede ser un poco confuso intentar imaginar el objeto resultante producido
por una clase derivada. Desde fuera, parece que la nueva clase tiene la misma interfaz que la
clase base, y quizás algunos métodos y campos adicionales.
Pero la herencia no es una simple copia de la interfaz de la clase base. Cuando se crea un
objeto de la clase derivada, éste contiene dentro de él un subobjeto de la clase base. Este
subobjeto es el mismo que si se hubiera creado un objeto de la clase base en sí. Es simplemente
que, desde fuera, el subobjeto de la clase base está envuelto dentro del objeto de la clase
derivada.
Es esencial que el subobjeto de la clase base se inicialice correctamente y sólo hay una
forma de garantizarlo: llevar a cabo la inicialización en el constructor, invocando al constructor
de la clase base, que tiene todo el conocimiento y privilegios apropiados para llevar a cabo la
inicialización de la clase base. Java inserta automáticamente llamadas al constructor de la clase
base en el constructor de la clase derivada.
Constructor de arte
Constructor de dibujo
Constructor de animacion
Se puede ver que la construcción se da desde la base "hacia fuera", de forma que se
inicializa la clase base antes de que los constructores de la clase derivada puedan acceder a ella.
El ejemplo anterior tiene constructores por defecto; es decir, no tienen ningún parámetro.
Para el compilador es fácil invocarlos porque no hay ningún problema que resolver respecto al
paso de parámetros. Si una clase no tiene parámetros por defecto, o si se desea invocar a un
constructor de una clase base que tiene parámetros, hay que escribir explícitamente la llamada al
constructor de la clase base usando la palabra clave super y la lista de parámetros apropiada:
La composición suele usarse cuando se quieren mantener las características de una clase ya
existente dentro de la nueva, pero no su interfaz. Es decir, se empotra un objeto de forma que se
puede usar para implementar su funcionalidad en la nueva clase, pero el usuario de la nueva la clase
ve la interfaz que se ha definido para la nueva clase en vez de la interfaz del objeto empotrado. Para
lograr este efecto, se empotran objetos privados de clases existentes dentro de la nueva clase. En
ocasiones, tiene sentido permitir al usuario de la clase acceder directamente a la composición de la
nueva clase; es decir, hacer a los objetos miembros públicos. Los objetos miembro usan por sí
mismos la ocultación de información, de forma que esto es seguro. Cuando el usuario sabe que se
está ensamblando un conjunto de partes, construye una interfaz más fácil de entender. Un objeto
coche es un buen ejemplo:
Dado que la composición de un coche es parte del análisis del problema ('y no simplemente
parte del diseño subyacente), hacer sus miembros públicos ayuda al entendimiento por parte del
programador cliente de cómo usar la clase, y requiere menos complejidad de código para el creador
de la clase. Sin embargo, hay que ser consciente de que éste es un caso especial y en general los
campos se harán privados.
Cuando se hereda, se toma una clase existente y se hace una versión especial de la misma. En
general, esto significa que se está tomando una clase de propósito general y especializándola para una
necesidad especial. Simplemente pensando un poco se verá que no tendría sentido componer un
coche utilizando un objeto vehículo -un coche no contiene un vehículo, es un vehículo. La relación
es-un se expresa con herencia, y la relación tiene-un se expresa con composición.
5. Protegido (protected).
Ahora que se ha presentado el concepto de herencia, tiene sentido finalmente la palabra clave
protected. En un mundo ideal, los miembros privados siempre serían irrevocablemente privados,
pero en los proyectos reales hay ocasiones en las que se desea hacer que algo quede oculto del mundo
en general, y sin embargo, permitir acceso a miembros de clases derivadas. La palabra clave
La mejor conducta a seguir es dejar los miembros de datos privados – uno siempre debería
preservar su derecho a cambiar la implementación subyacente. Posteriormente se puede permitir
acceso controlado a los descendientes de la clase a través de los métodos protegidos:
6. Desarrollo Incremental.
Aunque la herencia puede ser una técnica útil de cara a la experimentación, en algún momento,
una vez que las cosas se estabilizan es necesario echar un nuevo vistazo a la jerarquía de clases
definida intentando encajarla en una estructura con sentido. Recuérdese que bajo todo ello, la
herencia simplemente pretende expresar una relación que dice: "Esta nueva clase es un tipo de esa
otra clase". Al programa no deberían importarle los bits, sino el crear y manipular objetos de varios
tipos para expresar un modelo en términos que provengan del espacio del problema.
El aspecto más importante de la herencia no es que proporcione métodos para la nueva clase.
Es la relación expresada entre la nueva clase y la clase base. Esta relación puede resumirse diciendo:
"La nueva clase es un tipo de la clase existente".
Esta descripción no es simplemente una forma elegante de explicar la herencia – está soportada
directamente por el lenguaje. Como ejemplo, considérese una clase base denominada Instrumento
que representa los instrumentos musicales, y una clase derivada denominada Viento. Dado que la
herencia significa que todos los métodos de la clase base también están disponibles para la clase
derivada, cualquier mensaje que se pueda enviar a la clase base podrá ser también enviado a la clase
derivada. Si la clase Instrumento tiene un método tocar(), también lo tendrán los instrumentos
Viento. Esto significa que se puede decir con precisión que un objeto Viento es también un tipo
de Instrumento. El ejemplo siguiente muestra cómo soporta este concepto el compilador:
Prestemos atención al método afinar(), que acepta una referencia a Instrumento. Sin
embargo, en Viento.main(), se llama al método afinar() proporcionándole una referencia a
Viento.
Dado que Java tiene sus particularidades en la comprobación de tipos, parece extraño que un
método que acepte un tipo llegue a aceptar otro tipo, hasta que uno se da cuenta de que un objeto
Viento es también un objeto Instrumento, y no hay método al que pueda invocar afinar()
para un Instrumento que no esté en Viento.
Dentro de afinar(), el código funciona para Instrumento y cualquier cosa que se derive de
Instrumento, y al acto de convertir una referencia a Viento en una referencia a Instrumento se
le denomina hacer conversión hacia arriba.
página, y creciendo hacia abajo (por supuesto, se puede dibujar un diagrama de cualquier manera
que uno considere útil.)
Instrumento
Viento
La conversión de clase derivada a base se mueve hacia arriba dentro del diagrama de
herencia, por lo que se denomina conversión hacia arriba. Esta operación siempre es segura
porque se va de un tipo más específico a uno más general. Es decir, la clase derivada es un
superconjunto de la clase base. Podría contener más métodos que la clase base, pero debe
contener al menos los métodos de ésta última. Lo único que puede pasar a la interfaz de clases
durante la conversión hacia arriba es que pierda métodos en vez de ganarlos. Ésta es la razón
por la que el compilador permite la conversión hacia arriba sin ningún tipo de conversión
especial u otras notaciones especiales.
Si uno recuerda preguntar: ''¿Necesito una conversión hacia arriba?" obtendrá una buena
herramienta para decidir entre la composición y la herencia.
La palabra clave final de Java tiene significados ligeramente diferentes dependiendo del
contexto, pero en general dice: "Esto no puede cambiarse". Se podría querer evitar cambios por dos
razones: diseño o eficiencia. Dado que estas dos razones son bastante diferentes, es posible utilizar
erróneamente la palabra clave final.
Muchos lenguajes de programación tienen una forma de indicar al compilador que cierta
parte de código es "constante". Una constante es útil por varias razones:
a. Puede ser una constante en tiempo de compilación que nunca cambiará.
b. Puede ser un valor inicializado en tiempo de ejecución que no se desea que se llegue a
cambiar.
Un campo que es estático y final sólo tiene un espacio de almacenamiento que no se puede
modificar. Al usar final con referencias a objetos en vez de con datos primitivos, su significado
se vuelve algo confuso. Con un dato primitivo, final convierte el valor en constante, pero con
una referencia a un objeto, final hace de la referencia una constante. Una vez que la referencia se
inicializa a un objeto, ésta nunca se puede cambiar para que apunte a otro objeto. Sin embargo,
se puede modificar el objeto en sí; Java no proporciona ninguna manera de convertir un objeto
arbitrario en una constante (sin embargo, se puede escribir la clase, de forma que sus objetos
tengan el efecto de ser constantes). Esta restricción incluye a los arrays, que también son
objetos.
class Valor {
int i = 1;
}
Dado que i1 y VAL_DOS son datos primitivos final con valores de tiempo de
compilación, ambos pueden usarse como constantes de tiempo de compilación y su uso no
difiere mucho. VAL_TRES es la manera más usual en que se verán definidas estas constantes:
pública de forma que puedan ser utilizadas fuera del paquete, estática para hacer énfasis en que
sólo hay una, y final para indicar que es una constante. Fíjese que los datos primitivo static
final con valores iniciales constantes (es decir, las constantes de tiempo de compilación) se
escriben con mayúsculas por acuerdo además de con palabras separadas por guiones bajos. La
diferencia se muestra en la salida de una ejecución:
fdl: i4 = 15, i5 = 9
Creando un nuevo DatosConstante
fdl: i4 = 15, i5 = 9
fd2: i4 = 10, i5 = 9
Fíjese que los valores de i4 para fdl y fd2 son únicos, pero el valor de i5 no ha
cambiado al crear el segundo objeto DatosConstante. Esto es porque es estático y se
inicializa una vez en el momento de la carga y no cada vez que se crea un nuevo objeto.
class Elemento { }
class ConstanteBlanca {
final int i=0; // Constante inicializada
final int j; // Constante blanca
final Elemento p; // Referencia a constante blanca
// Constantes blancas DEBEN inicializarse en el constructor:
ConstanteBlanca() (
j=1; // Inicializar la la constante blanca
p = new Elemento();
}
ConstanteBlanca(int x) (
j=x; // Inicializar la constante blanca
p = new Elemento();
}
public static void main(String[] args) {
ConstanteBlanca bf = new ConstanteBlanca();
}
}
class Artilugio {
public void girar() {
}
}
Fíjese que se puede seguir asignando una referencia null a un parámetro constante
sin que el compilador se dé cuenta, al igual que se puede hacer con un parámetro no
constante. Los métodos f() y g() muestran lo que ocurre cuando los parámetros
primitivos son constante: se puede leer el parámetro pero no se puede cambiar.
Hay dos razones que justifican los métodos constantes. La primera es poner un "bloqueo"
en el método para evitar que cualquier clase heredada varíe su significado. Esto se hace por
razones de diseño cuando uno se quiere asegurar de que se mantenga el comportamiento del
método durante la herencia, evitando que sea sobreescrito. La segunda razón para los métodos
constante es la eficiencia. Si se puede hacer un método constante se está permitiendo al
compilador convertir cualquier llamada a ese método en llamadas rápidas. Cuando el compilador
ve una llamada a un método constante puede (a su discreción) saltar el modo habitual de insertar
código para llevar a cabo el mecanismo de invocación al método (meter los argumentos en la
pila, saltar al código del método y ejecutarlo, volver al punto del salto y eliminar los parámetros
de la pila, y manipular el valor de retorno) o, en vez de ello, reemplazar la llamada al método con
una copia del código que, de hecho, se encuentra en el cuerpo del método. Esto elimina la
sobrecarga de la llamada al método. Por supuesto, si el método es grande, el código comienza a
aumentar de tamaño, y probablemente no se aprecien ganancias de rendimiento en la
sustitución, puesto que cualquier mejora se verá disminuida por la cantidad de tiempo invertido
dentro del método. Está implícito el que el compilador de Java sea capaz de detectar estas
situaciones, y elegir sabiamente. Sin embargo, es mejor no confiar en que el compilador sea
capaz de hacer esto siempre bien, y hacer un método constante sólo si es lo suficientemente
pequeño o se desea evitar su modificación explícitamente.
método, sino que se habrá creado uno nuevo). Se puede añadir el modificador final a un
método privado pero esto no da al método ningún significado extra. Este aspecto puede
causar confusión, porque si se desea modificar un método privado (que es implícitamente
constante) parece funcionar:
class ConConstantes {
private final void f() { // Idéntico a únicamente "privado"
Systern.out.println("ConConstantes.f()");
}
private void g() { // También automáticamente "constante"
System.out.println("ConConstantes.g()");
}
}
Cuando se dice que una clase entera es constante (precediendo su definición de la palabra
clave final) se establece que no se desea heredar de esta clase o permitir a nadie más que lo haga.
En otras palabras, por alguna razón el diseño de la clase es tal que nunca hay una necesidad de
hacer cambios, o por razones de seguridad no se desea la generación de subclases. De manera
alternativa, se pueden estar tratando aspectos de eficiencia, y hay que asegurarse de que cualquier
actividad involucrada con objetos de esta clase sea lo más eficiente posible.
class CerebroPequenio { }
Fíjese que los atributos pueden ser constantes o no, como se desee. Las mismas reglas se
aplican a los atributos independientemente de si la clase se ha definido como constante.
Definiendo la clase como constante simplemente se evita la herencia – nada más. Sin embargo,
dado que evita la herencia, todos los métodos de una clase constante son implícitamente
constante, puesto que no hay manera de modificarlos. Por tanto, el compilador tiene las mismas
opciones de eficiencia como tiene si se declara un método explícitamente constante.
Se puede añadir el especificador constante a un método en una clase constante, pero esto
no añade ningún significado.
En lenguajes más tradicionales, los programas se cargan de una vez como parte del proceso de
arranque. Éste va seguido de la inicialización y posteriormente comienza el programa. El proceso de
inicialización en estos lenguajes debe controlarse cuidadosamente de forma que el orden de
inicialización de los datos estáticos no cause problemas. C++, por ejemplo, tiene problemas si uno de
los datos estáticos espera que otro dato estático sea válido antes de haber inicializado el segundo.
Java no tiene este problema porque sigue un enfoque diferente en la carga. Dado que todo en
Java es un objeto, muchas actividades se simplifican, y ésta es una de ellas: el código compilado de
cada clase existe en su propio archivo separado. El archivo no se carga hasta que se necesita el
código. En general, se puede decir que "El código de las clases se carga en el momento de su primer
uso". Esto no ocurre generalmente hasta que se construye el primer objeto de esa clase, pero también
se da una carga cuando se accede a un dato o método estático.
El momento del primer uso es también donde se da la inicialización estática. Todos los objetos
estáticos y el bloque de código estático se inicializarán en orden textual (es decir, el orden en que se
han escrito en la definición de la clase) en el momento de la carga. Los datos estáticos, por supuesto,
se inicializan únicamente una vez.
class Insecto {
int i=9;
int j;
Insecto() {
visualizar("i = " + i + ", j = " + j);
j = 39;
}
static int xl = visualizar("static Insecto.x1 inicializado");
static int visualizar(String s) {
System.out.println(s);
return 47;
}
}
Lo primero que ocurre al ejecutar Escarabajo bajo Java es que se intenta acceder a
Escarabajo.main() (un método estático), de forma que el cargador sale a buscar el código
compilado de la clase Escarabajo (que resulta estar en un fichero denominado
Escarabajo.class). En el proceso de su carga, el cargador se da cuenta de que tiene una
clase base (que es lo que indica la palabra clave extends), y por consiguiente, la carga. Esto
ocurrirá tanto si se hace como si no un objeto de esa clase.
Si la clase base tiene una clase base, las segunda clase base se cargaría también, y así
sucesivamente. Posteriormente, se lleva a cabo la inicialización estática de la clase base raíz (en
este caso Insecto), y posteriormente la siguiente clase derivada, y así sucesivamente. Esto es
importante porque la inicialización estática de la clase derivada podría depender de que se
inicialice adecuadamente el miembro de la clase base.
En este momento, las clases necesarias ya han sido cargadas de forma que se puede crear el
objeto. Primero, se ponen a sus valores por defecto todos los datos primitivos de este objeto, y
las referencias a objetos se ponen a null – esto ocurre en un solo paso poniendo la memoria
del objeto a ceros binarios. Después se invoca al constructor de la clase base. En este caso, la
llamada es automática, pero también se puede especificar la llamada al constructor de la clase
base (como la primera operación en el constructor de Escarabajo()) utilizando super. La
construcción de la clase base sigue el mismo proceso en el mismo orden, como el constructor de
la clase derivada. Una vez que acaba el constructor de la clase base se inicializan las variables de
instancia en orden textual. Finalmente se ejecuta el resto del cuerpo del constructor.