07 noviembre, 2006

Programación Orientada a Objetos

Java es un lenguaje de programación orientado a objetos. Conocer la teoría de objetos no solo es necesario para desarrollar nuestras propias aplicaciones. También es fundamental para poder entender, utilizar y aprovechar al máximo las clases propias de Java.

Teoría de Objetos se basa en tres temas: Encapsulamiento, Sobrecarga y Polimorfismo. Para entender estos conceptos plantearemos una serie de ejemplos que iremos desarrollando progresivamente.


Encapsulamiento

Supongamos que tenemos que desarrollar una aplicación para manejar números complejos. Si pensamos en programación estructurada estaremos pensando en una estructura (un struct en C por ejemplo) como la siguiente:



Una función main() que utilize esta estructura será:



Lo anterior implica un doble problema para el programador de la función main(): por un lado el problema propio de manipular los números complejos (su verdadero problema) pero por otro lado el programador tiene que conocer de antemano como están definidas las variables (dentro de la estructura complejo) que representan las componentes real e imaginaria que (en este caso) son real e imaginario respectivamente.

Decimos entonces que la programación estructurada no le permite al programador realizar operaciones conceptuales y abstraerse de las cuestiones de implementación.

Si en lugar de pensar la estructura complejo como struct Complejo la pensamos como una clase resulta:



y ahora las asignaciones c.real = 10 y c.imaginario = 3 no son posibles ya que las clases (por definición) proveen encapsulamiento para sus datos. Decimos entonces que los datos de la clase Complejo están encapsulados y la única forma de accederlos es a través de funciones (o métodos) que la misma clase provea.

Es decir: una clase es una estructura que combina los datos con las funciones necesarias para manipularlos. Llamamos “objeto” a toda variable cuyo tipo de dato es una clase.
  • Clase: estructura que combina datos con las funciones (o métodos) necesarias para accederlos.
  • Objeto: variable cuyo tipo de dato es una clase.
La idea es que el usuario (en este caso el programador que utiliza la clase Complejo) no tenga que preocuparse por la complejidad propia de la clase. Las asignaciones c.real=10 y c.imaginario=3 implican conocer de antemano que los datos se almacenan en variables llamadas real e imaginario. Esto nos hace entrar en complejidades ajenas a nuestra incumbencia.

En programación orientada a objetos la misma clase tiene que proveer los métodos necesarios para acceder a los datos; métodos para asignarles valores, para consultar por el valor que contienen y para efectuar operaciones entre los ellos.



Con este nuevo esquema, la función main() queda como sigue:




El Constructor

En Java los objetos son punteros por lo tanto es necesario asignarles una dirección de memoria válida antes utilizarlos. Para esto usamos el operador new y le pasamos como parámetro el constructor de la clase.

El constructor de la clase es un método especial que tiene 3 características:
  1. Se llama igual que la clases
  2. No tiene valor de retorno
  3. Solo puede invocarse como argumento del operador new
Toda clase tiene al menos un constructor. Si no lo programamos explicitamente igual disponemos de un constructor nulo o “por default”.



también podemos utilizar los setters para asignar los valores que recibe como parámetros:



y ahora la función main() será:




Sobrecarga

Permite extender la funcionalidad de una clase.

En nuestro ejemplo podemos agregarle a la clase Complejo la funcionalidad de trabajar con otra notación para números complejos sin afectar ni poner en riesgo el funcionamiento (estable) que tiene la clase hasta el momento.

Sobrecargar un método simplemente es escribirlo dos o más veces en la misma clase pero con diferentes parámetros y (obviamente) diferentes implementaciones.



Veamos como sobrecargar el constructor de la clase Complejo.



Podríamos abstraernos del trabajo que implica hacer el tratamiento de cadena para separar las componentes real e imaginaria del String (“10+3i” en nuestro ejemplo) y delegar esa tarea en los métodos setReal y setImaginario. Para esto tenemos que sobrecargar los métodos.



Veamos las implementaciones sobrecargadas de los métodos setReal y setImaginario.



La versión final de la clase Complejo queda así:




Polimorfismo

Veamos una aplicación para administrar el área de recursos humanos de una empresa.

Comenzamos por analizar (y programar) la clase Empleado que es fundamental en el contexto del área de RRHH de la empresa.



Vemos que la clase Empleado tiene los atributos leg y nom. Decimos que los atributos definen la identidad de un objeto.

Con la siguiente función main() podemos explicar mejor este concepto:



En la función main() definimos dos empleados: e1 y e2. Los dos son objetos de la misma clase pero cada uno tiene valores particulares para sus atributos. Estos valores particulares los diferencian entre si y por lo tanto constituyen su identidad.

Vamos a programar ahora la clase Gerente que también es relevante en el contexto del área de recursos humanos.

Los gerentes tienen los mismos atributos que los empleados por lo tanto también son empleados pero deben tener otros atributos que los identifican como gerentes.

Podemos decir que la clase Gerente hereda de la clase Empleado. En Java esto se indica con la palabra extends.



En el constructor de Gerente lo primero que hacemos es invocar al constructor de su clase base: Empleado.

La palabra super utilizada como función representa al constructor de la clase base que (como en Java no existe la herencia múltiple) es única.

Cuando programamos la clase Empleado no definimos de donde iba a heredar. Por default, si no indicamos otra cosa, todas las clases heredan de la clase base Object. Por lo tanto (directa o indirectamente) todos los objetos son Object.

Veamos una función main() que utilize también la clase Gerente.



En el ejemplo anterior invocamos el método imprimir() del objeto g (cuya clase es Gerente). Como Gerente no define ese método en su clase entonces toma la definición que hereda de la clase base (Empleado).

El problema que que el método imprimir() que hereda de Empleado no se adapta por completo a las necesidades de la clase. Solo imprime los datos propios de un empleado (legajo y nombre) pero no tiene en cuenta los datos del gerente como el sac (sector a cargo).

La solución es redefinir el método. Decimos que sobreescribimos el método imprimir() en la clase Gerente.



Notemos que la primera línea del método imprimir() llama a super.imprimir(). Estamos llamando al método imprimir() del padre. La palabra super utilizada como puntero hace referencia al padre. Recordemos que la misma palabra super() pero utilizada como función hace referencia al constructor del padre.

En el método imprimir() de Gerente solo tenemos que preocuparnos por imprimir los atributos propios del gerente. La responsabilidad de imprimir los datos que heredamos de Empleado es del método imprimir() de la clase Empleado.

Seamos ahora un poco más exigentes con la clase Gerente. Un gerente seguramente tendrá gente a cargo. Podría ser muy útil que el método imprimir() de Gerente no solo imprima los datos del gerente sino también los datos de toda la gente que depende de él.

Para esto vamos a modificar la clase Gerente agregándole una estructura (colección) de datos que pueda almacenar un conjunto de empleados y un método a través del cual podamos agregar empleados a dicha estructura.



Ahora en la función main() podemos hacer que un empleado trabaje para un gerente.



Recordemos que los datos de la clase Gerente están encapsulados por lo tanto el método ponerACargo() es necesario para agregar el empleado al vector del gerente.

El siguiente paso es modificar el método imprimir() de la clase Gerente para que imprima la ficha de cada uno de los empleados que el gerente tiene a su cargo.



Es necesario “castear a Empleado” el objeto que retorna el método elementAt(). El vector trabaja con objetos genéricos del tipo Object. Si nos fijamos en la API veremos que los prototipos de los métodos add() y elementAt() son:
  • public void add(Object element)
  • public Object elementAt(int index)
Como todos los objetos son Object (recordemos que todas las clases directa o indirectamente heredan de Object) el vector permite almacenar objetos de cualquier tipo pero, al momento de recuperar un objeto este saldrá con formato Object. Por eso lo casteamos y lo volvemos a su formato original (para invocarle el método imprimir()).

Desarrollemos ahora una función main() completa para luego analizar su salida.



Analicemos ahora la llamada a g1.imprimir().



A simple vista parece haber un problema. En la vuelta i=2 el elemento almacenado en el vector es un objeto Gerente el cual estamos casteando a Empleado y le incovamos el método imprimir(). La pregunta es: ¿Que método “imprimir()” se llamará? ¿El de Gerente o el de Empleado? La respuesta es “por polimorfismo se invoca el método imprimir() de Gerente”.

Polimorfismo es la propiedad por la cual los objetos se reconocen como miembros de una clase (por lo tanto) al invocarles un método reaccionan como su propia clase lo define.

Es decir: el objeto no pierde su identidad. Siempre sabe que es (en nuestro ejemplo) una instancia de Gerente.

El hecho de haberlo casteado a Empleado solo afecta al tiempo de compilación pero luego, en tiempo de ejecución, solo existen objetos que interactuan entre si. No existen más los tipos de dato. Solo existen objetos interactuando.

Es importante destacar que si la clase Object tuviese un método imprimir() el casteo ya no sería necesario. Podríamos aplicarle libremente el este método a los objetos almacenados en el vector teniendo la seguridad de que el imprimir() que se ejecutará siempre será el que corresponda.


La versión completa de las clases Empleado, Gerente y una clase TestEmpleados están a continuación.








Clases Abstractas

Pensemos en una clase para manejar figuras geométricas: si bien a toda figura geométrica se puede calcular su área, resulta que es imposible calcular el área de una figura geométrica sin saber previamente cual es la figura geométrica en cuestión. No es lo mismo calcular el área para un círculo que para un rectángulo. Así y todo, podemos definir la clase Figura con el método método abstracto area(). Entonces las subclases de Figura (Circulo, Triangulo, Rectangulo, etc.) deberán sobreescribir adecuadamente el método area().

Como la clase Figura tiene al menos un método abstracto debe ser declarada como clase abstracta. Una clase abstracta no puede ser instanciada. Veamos el código de la clase Figura.



Como vemos, en el método imprimirArea() se llama al método area(). Esto puede resultar confuso por ser area() un método abstracto. Pero como las clases abstractas no pueden ser instanciadas resulta entonces que el método imprimirArea() solo podrá ser aplicado a instancias de las subclases de Figura, en las cuales el método area() tendrá una implementación concreta. Así, por polimorfismo el método area() que se invocará será el de la subclase, no el de la clase Figura.

Veamos como las clase Circulo y Rectangulo implementan el método area() de la forma más conveniente.





Ahora podemos pensar en una clase TestFiguras que haga uso de las clases Circulo y Rectangulo.



Notemos como al desarrollar el método areaPromedio() nos abstraemos de la particularidad de cada figura geométrica y simplemente trabajamos al nivel de Figura. Como la clase Figura no puede ser instanciada, resulta que fg[i] solo podrá ser una instancia de cualquier subclase de Figura. Así, por polimorfismo cuando le apliquemos el método area() será invocado el método de la clase a la que pertenece la instancia contenida en fg[i].


Convensiones de Nomenclatura

Si bien no es obligatorio, por cuestiones de convención debemos respetar una serie de reglas de nomenclatura para las clases, los métodos, los atributos y constantes.

Clases
Siempre comienzan con Mayúscula. Si el nombre se compone de varias palabras entonces cada inicial debe ser en Mayúscula.
  • public class NombreDeLaClase { ... }
Métodos
Siempre comienzan con minúscula. Si el nombre se compone de varias palabras entonces cada inicial subsiguiente debe ser en Mayúscula.
  • public void nombreDelMetodo() { ... }
Constantes
Completamente en Mayúscula. Si el nombre se compone de varias palabras entonces cada palabra se separa de la anterior por medio del carácter “_”
  • public static final int NOMBRE_DE_LA_CTE = 1;
Atributos
Nunca comienzan en Mayúscula. Debe utilizarse la misma nomenclatura definida para los métodos.
  • private String nombreDelAtributo;

Instancias Desreferenciadas y el Garbage Collector

En Java todos los objetos son punteros o referencias a bloques de memoria previamente alocada con el operador new.

Definamos la clase X (léase “equis grande”) que nos servirá para ilustrar algunas situaciones.



Vemos que la clase X tiene dos atributos: a y b. Decimos que a y b son variables de instancia porque cada instancia de X tendrá su propios valores a y b.



Vemos que en realidad x1 y x2 son punteros a espacios de memoria donde están almacenados los valores de sus variables de instancia.

Ahora volvemos a asignar una instancia de X a x2 haciendo que x2 deje de apuntar a la instancia que apuntaba para pasar a apuntar a la nueva instancia. La instancia vieja queda desreferenciada: ningún puntero la está direccionando por lo tanto se vuelve inaccesible.



Ahora, como la instancia (4,8) está desreferenciada será eliminada automaticamente por el Garbage Collector.

El Garbage Collector es un proceso que corre dentro de la máquina virtual y libera las instancias que quedaron desreferenciadas.

A direfencia del lenguaje C donde antes de realocar memoria la tenemos que liberar explicitamente (con la función free) en Java nos olvidamos del problema. Simplemente dejamos que ese trabajo lo realice el Garbage Collector.

Analizaremos un programa que prueba el funcionamiento del Garbage Collector pero primero tenemos que explicar brevemente otro concepto de la teoría de objetos: las variables y los métodos de clase.


Atributos y Métodos de la Clase

Un atributo (o variable) de la clase es un atributo común a todas las instancias de la clase. No existe un valor propio de ese atributo para cada objeto, todos los objetos comparten el mismo valor y si alguno lo modifica entonces todos lo verán modificado.

Para indicar que un atributo es un atributo (o variable) de clase tenemos que definirlo estático.





Vemos que existe una variable a propia para cada instancia y que todas las instancias comparten la misma variable b. Cuando instanciamos x2 estamos seteando un valor 8 en b entonces se reemplaza el valor anterior.

A nivel método decimos que un método de clase es un método que no depende de ningún valor de instancia.

Por ejemplo:



Vemos que el método sumar() recibe dos valores y retorna su suma. El método está en función exclusivamente de sus argumentos. No depende de ningún valor de instancia.

Una función main() podría ser:



Como el método sumar es un método de la clase entonces no necesitamos instanciar la clase para poderlo utilizar. Simplemente lo invocamos directamente a través de la clase haciendo: Clase.metodo().

Diferente hubiera sido este caso:



Una función main() para este ejemplo será:



Algunos ejemplos de métodos estáticos son:
Si una clase tiene todos (o la mayoría de) sus métodos estáticos decimos que es una clase utilitaria. En Java la clase Math es un ejemplo de clase utilitaria.

Ahora podemos probar el funcionamiento del Garbage Collector



La clase TestGC tiene la variable de clase contador que al momento de definirla se inicializa en 0. Luego, cada vez que la clase es intanciada (en el constructor) se incrementa su valor y se muestra por pantalla. Recordemos que por ser una variable de clase las instancias sucesivas verán el valor incrementado.

También estamos sobreescribiendo el método finalize(). Este método está definido en la clase base Object y es invocado automaticamente por el Garbage Collector antes de liberar una instancia desreferenciada.

En nuestro ejemplo, sobreescribimos el método finalize() para detectar el momento en que la instancia será eliminada y entonces decrementamos el valor de contador.

La función main() simplemente crea continuamente instancias de la clase TestGC y no las referencia.

Una corrida del programa podrá ser:



Vamos a modificar el ejemplo anterior para hacer que el programa mantenga referenciadas todas las instancias. Definiremos un vector y las iremos almacenando en él. Al mantenerlas referenciadas no serán eliminadas por el Garbage Collector y el programa quedará sin memoria.




Alcance y Visibilidad

Podemos definir la visibilidad de los métodos y de las variables de instancia y de clase utilizando los modificadores public, protected y private.

A grandes razgos podemos decir que si definimos un atributo como private entonces el atributo está encapsulado. Si lo definimos como public entonces no está encapsulado.

Veamos la clase MiClase que define dos atributos privados (tal como venimos trabajando hasta ahora)



Como estudiamos al principio, los atributos privados son inaccesibles desde afuera de la clase y la única forma de poderlos acceder es a través de los métodos que la clase defina para su acceso (en general serán setters y getters)

Ahora veamos una clase que define sus atributos públicos:



En este caso los atributos son públicos. Se los puede acceder desde cualquier otra clase. Esta es la forma de emular lo que sería un struct en el lenguaje C.

Un main() que pruebe esta clase será:



La siguiente tabla resume la visibilidad que se logra según sea el modificador que se aplique al método o atributo.




Packages

Los paquetes son directorios y subdirectorios que podemos utilizar para organizar las clases de la aplicación. Graficamente se representan como carpetas, las cuales contienen clases.



En el gráfico vemos que el paquete p1 contiene las clases X, X1 y Z. El paquete p2 contiene la clase X2 y U, y el paquete p3 contiene la clase T y la interface W. Además X1 y X2 son subclases de X y U es una implementación de W (esto lo veremos más adelante).

Para indicar que una clase pertenece a un paquete se utiliza la palabra package.

Si codificamos las clases que están representadas en el diagrama resulta:




El Modificador final

El modificador final permite definir constantes. Aplicado a metodos hace que no se puedan sobreescribir y aplicado a clases hace que no se puedan extender.


Interfaces

Para comenzar podemos decir que una interface “es una clase abstracta con todos los métodos abstractos”. Pero a diferencia de las clases, las interfaces no se extienden: se implementan. Así, una clase puede extender a otra e implementar una, ninguna o varias interfaces heredando así todas las definiciones de sus métodos abstractos.

Las interfaces son un recurso fundamental para la programación Java.


Interfaces para definir Contratos

En la siguiente figura vemos una instancia de la clase Monitor que es vista por instancias de las clases Usuario, Empleado y Medico. Cada una de estas instancias está interesada en diferentes aspectos de la instancia de Monitor. Por ejemplo: al usuario solo le interesa poder setearle el brillo, el contraste y poder encenderlo y apagarlo: los aspectos funcionales del monitor. Al empleado del depósito le interesan los aspectos relacionados con las dimensiones y al médico (oftalmólogo) le interesan los aspectos relacionados con la salud.



Veamos el código de las interfaces propuestas en el ejemplo.







Ahora vemos el código de la clase Monitor que implementa las interfaces. Notemos que la clase monitor extiende a la clase AparatosConPantalla. Esto demuestra que implementar interfaces no limita la herencia.



Ahora veamos las clases Usuario, Medico y Empleado.







Y ahora veamos una funcion main() que utilize todo lo anterior.



Como vemos, la instancia de Monitor matchea con AspectosFuncionales, AspectosMedicinales y contra AspectosDimensionales. Obviamente también con Monitor y AparatosPantalla.

En las clases Empleado, Medico y Usuario podemos ver al monitor a través de la interface, sin preocuparnos por la implementación (en este caso la clase Monitor).

Decimos entonces que:
  • existe un contrato entre la clase Usuario y la clase Monitor: AspectosFuncionales
  • existe un contrato entre la clase Empleado y la clase Monitor: AspectosDimensionales
  • existe un contrato entre la clase Medico y la clase Monitor: AspectosMedicinales
Monitor sabe exactamente lo que tiene que ofrecer para su “vista” AspectosFuncionales y Usuario sabe exactamente lo que puede pedir. Ninguno de los dos se puede apartar de lo que está pactado en el contrato (la interface AspectosFuncionales). El mismo análisis corresponde para las clases Empleado y Medico y sus respectivas interfaces.


Abstracción

Las intefaces permiten un nivel de abstracción fundamental.

Pensemos en un método para ordenar elementos. No habría ningún problema si los elementos fueran enteros, caracteres o String. Ya que estos tienen un orden natural. ¿Pero que pasaría si los elementos fueran monitores? Los monitores no tienen un orden natural por lo tanto el criterio de precedencia no es único. Podrían ordenarse con cualquier criterio.

La solución a este problema es definir una interface que tenga un método como el siguiente:
  • public boolean precedeA(Object o);


Ahora, en la clase Monitor podemos implementar esta interface y especificar el criterio de precedencia para los monitores (en este caso el criterio de precedencia será: precio de venta).



El método que ordena puede abstraerse y solo recibir instancias de objetos “Ordenables”.



En una función main() podemos trabajar de la siguiente forma.



Logramos abstraernos de las características particulares de los elementos a ordenar. Nos alcanza simplemente con que cada elemento pueda decidir sobre si precede o no a otro elemento de su misma especie.

Con el mismo criterio podemos implementar la interface Ordenable en la clase Empleado y estaremos en condiciones de ordenar empleados usando el mismo método ordenar() de la clase Util.





Así, desarrollamos un método totalmente reusable que nos permite ordenar objetos sin tener en cuenta a que clase pertenecen. Solo deben ser instancias válidas de Ordenable.

Por último, veremos un ejemplo de interfaces utilizando las APIs de Java.


Problema
Queremos hacer un programa que muestre los archivos y subdirectorios (no recursivo) de un directorio cuyo pathname se pasa como parámetro.

Solución
En el paquete java.io existe la clase File. Esta clase nos permite acceder al header de un archivo o directorio. Es decir: no al contenido en sí sino a la información de cabecera como por ejemplo fecha, permisos, tipo de archivo, longitud, etc.

La clase File tiene un método (sobrecargado) como el siguiente:
  • public String[] list()
  • public String[] list(FilenameFilter)
La primer versión, muestra todo el contenido del directorio con lo que de manera muy simple podemos resolver nuestro problema.



Ahora queremos recibir también la terminación del nombre de archivo (la extensión) y solamente mostrar los archivos que correspondan según la extensión de su nombre. Tenemos que utilizar la otra versión del método list(FilenameFilter).

Si nos fijamos en la API veremos que FilenameFilter es una interface que tiene un único método:
  • public boolean accept(File dir, String name)
Como FilenameFilter es una interface necesitamos una clase que la implemente y sobreescriba adecuadamente el método abstracto.



Ya tenemos una clase que implementa la interface. Ahora, los objetos de esta clase son instancias válidas de FilenameFilter.