Buscar
Social
Ofertas laborales ES
lunes
nov302009

Curso de programación Java V - Abraham Otero

Curso de programación Java V

 

Artículo publicado originalmente en la revista Sólo Programadores

 

Hasta ahora hemos cubierto lo básico de la sintaxis del lenguaje de programación Java. La sintaxis que conoces es suficiente para que comiences a andar sólo y a construir tus primeros programas. Sin embargo, esto no quiere decir que podamos dar por terminada esta serie de artículos. Y es que un lenguaje de programación no es sólo sintaxis; esto sólo fue así en los albores de la programación. Un lenguaje de programación además de una sintaxis debe proporcionar unas librerías básicas que permitan resolver gran parte de las tareas más estándar de programación a las cuales los programadores se van a tener que enfrentar.

 

En este artículo vamos a abordar uno de los pilares del API standard de Java: el paquete java.io , que contiene la funcionalidad relacionada con las operaciones de entrada y salida (acceso al sistema de ficheros). Acceder a la entrada y salida es fundamental para la construcción de aplicaciones: toda la información que esté almacenada en la memoria RAM del equipo se pierde cuando éste se reinicia. El almacenamiento persistente lo proporciona el sistema de ficheros. Si nuestra aplicación no es capaz de acceder al sistema de ficheros y almacenar información en él, cada vez que se ejecute será igual que si se estuviese ejecutando por primera vez: no será posible recordar nada de ejecuciones anteriores ni permitirá guardar ninguna información al usuario. Obviamente, la mayor parte de las aplicaciones informáticas no funcionan de este modo, sino que requieren guardar algún tipo de información de modo persistente para poder acceder a ella en la siguiente ejecución. Precisamente esto será lo que aprendamos en este artículo.

 

1 La clase File 

Lo más importante para comprender cómo funciona la clase File  es comprender que no es un archivo. Su nombre, francamente, ha sido poco afortunado. File  es una forma de referenciar de una ruta en un sistema de ficheros. Esa ruta podría no existir físicamente, es decir, puede que apunte un fichero que no existe. O podría ser la ruta correspondiente con un directorio (una carpeta para los que siempre habéis vivido en sistemas operativos de ventanas), y no con un fichero.

 

La clase File  tiene cuatro constructores; los dos más empleados son:

 

File(String pathname) 
File(String parent, String child) 
 

 

Al primero se le pasa la ruta absoluta que queremos que represente el objeto File  (por ejemplo, "C:/carpeta/fichero.txt" ); al segundo se le pasa el directorio en el cual se sitúa el archivo y el nombre del archivo (por ejemplo, "C:/carpeta"  y "fichero.txt" es ). El lector seguramente habrá observado que he empleado como separador de la ruta el carácter "/" . En Java puede emplearse como separador tanto el carácter "/"  como el carácter "\" , y el programa funcionará correctamente en cualquier plataforma, independientemente de qué separador concreto use el sistema operativo. Java se encargará de traducir el separador empleado por el programa al adecuado en el sistema operativo. 

 

De todos modos, debemos recordar una cosa: el carácter "\" es un carácter de escape dentro de una cadena de caracteres en Java. Para representar dicho carácter debemos de emplear la secuencia de escape "\\". Por tanto, para representar con este separador la ruta del ejemplo anterior deberemos escribir "C:\\carpeta\\fichero.txt".

 

Si creamos un objeto File  empleando el constructor al cual sólo se le pasa una cadena de caracteres, y esa cadena de caracteres no es una ruta absoluta dentro de nuestro sistema de ficheros, el constructor la tratará como una ruta de acceso relativa. Esta ruta se considera siempre que es relativa al directorio donde se está ejecutando la máquina virtual de la aplicación. Es posible averiguar cuál es el directorio actual de trabajo de la aplicación mediante la sentencia:

 

System.getProperty("user.dir"));

 

A continuación enumeraremos algunos de los métodos más útiles de la clase File:

 

  • createNewFile(): si el fichero representado por el objeto File  no existe, lo crea.
  • delete(): borra el fichero o directorio representado por este objeto.
  • isDirectory()  y isFile(): devuelven true  cuando el objeto de referencia a un directorio o a un fichero, respectivamente. En caso contrario, devuelven false .
  • mkdir()  y mkdirs(): crean el directorio representado por el objeto File . El primero, sólo crea un directorio; el segundo creará todos los directorios padres que sean necesarios para crear un fichero cuya ruta coincida con la representada en este objeto.
  • length(): devuelve el tamaño del fichero referenciado por este objeto.
  • listFiles(): devuelve un array de objetos File  conteniendo todos los archivos que estén dentro del directorio representado por el objeto File  sobre el cual se invocó.
  • canExecute(), canRead() y canWrite(): devuelven true cuando tenemos permiso para realizar la operación de ejecución, lectura o escritura sobre el fichero correspondiente y false en caso contrario.

 

 

La clase File  posee muchos otros métodos. Te recomiendo que le eches un vistazo a su javadoc para familiarizarte con ellos.

 

En el listado 1 vemos un programa que contiene una cadena de caracteres que representa un directorio en nuestro sistema de ficheros. El programa lista todos los archivos contenidos en dicho directorio y, para cada uno de ellos, muestra una serie de información como su nombre, su ruta absoluta, sus permisos de lectura, escritura y ejecución, etcétera.

 

 

//LISTADO 1: Programa que lista los archivos contenidos en un directorio y muestra información sobre ellos
import java.io.*;
import java.util.*;
 
public class Ejemplo1 {
  public static void main(String args[]) throws IOException {
    File directorio = new File("C:/");
    if ( (directorio.exists()) && (directorio.isDirectory())) {
      File[] lista = directorio.listFiles();
      for (int i = 0; i < lista.length; i++) {
        System.out.println(lista[i].getAbsolutePath());
        System.out.println("Nombre: " + lista[i].getName());
        System.out.println("Ruta absoluta " + lista[i].getAbsolutePath());
        System.out.println("Ruta: " + lista[i].getPath());
        System.out.println("Padre: " + lista[i].getParent());
        System.out.println("¿Puedo leerlo? " + lista[i].canRead());
        System.out.println("¿Puedo escribirlo? " + lista[i].canWrite());
        System.out.println("Tamaño en bytes: " + lista[i].length());
        System.out.println("Fecha de la última modificación: " +
                           new Date(lista[i].lastModified()));
        System.out.println("\n");
      }
    }
    else {
      System.out.println("El directorio no existe");
    }
  }
}

 

 

2 Flujos de datos

 

Los flujos de datos (streams en inglés) son una abstracción empleada en muchos lenguajes de programación, entre ellos Java, para representar cualquier fuente que produzca o consuma información. Su nombre (flujo) viene de que pueden considerarse como una cadena de datos continúa con una longitud que, posiblemente, es desconocida, al igual que la "longitud" de un flujo de agua que está corriendo.

 

FIGURA 1: Los flujos de datos o streams representan cualquier fuente que proporcione datos al programa, o cualquier sumidero que tome datos del programa

 

Existen dos tipos de flujos de datos: los binarios o de bytes, y los de texto. En los primeros, como su nombre indica, la información que fluye está en formato binario, mientras que en los segundos la información es texto. Cada una de estas dos categorías puede volver a subdividirse en flujos de datos de entrada y flujos de datos de salida. Los primeros serían flujos que nos proporcionan datos, es decir, entradas de nuestro programa. Los segundos serían flujos en los cuales nuestro programa escribe datos, es decir, salidas de nuestro programa.

 

En este apartado vamos a ver las principales clases para representar flujos binarios y de texto, de entrada y de salida que proporciona Java.

 

2.1 Flujos de salida de bytes

 

En la figura 2 podemos ver la jerarquía de los flujos de salida de bytes de Java. El organizar de modo jerárquico las clases que permiten acceder a la funcionalidad de entrada y salida va a ser un patrón que veremos varias veces a lo largo de este artículo. Como podemos observar en la figura, la clase padre de todos los flujos de salida de Java es OutputStream . Se trata de una clase abstracta (por tanto no vamos a poder crear objetos de ella porque su funcionalidad está "incompleta") que representa un flujo de datos de salida binario cualquiera.

 

 

FIGURA 2: Jerarquía de los flujos de salida de bytes

 

Sus métodos son los siguientes:

 

  • close(): cierra el flujo de datos y libera cualquier recurso que el flujo de datos pudiera estar consumiendo. Por ejemplo, este método permite liberar los recursos del sistema operativo consumidos por un fichero al cual hemos terminado de escribir información.
  • flush(): sincroniza este flujo de datos con el dispositivo al cual se están escribiendo los datos. Este método es necesario porque, habitualmente, los OutputStream  tienen un buffer de datos interno al cual van escribiendo la información antes de enviarla al dispositivo de entrada y salida correspondiente. De este modo, se pueden agrupar varias operaciones de escritura y efectuarlas de un modo más eficiente. Este método hace que los últimos cambios realizados sobre el buffer de memoria se sincronicen con el dispositivo de entrada y salida. 
  • write(byte[] b): escribe el array b  de bytes que se le pasa como argumento al flujo de salida.
  • write(byte[] b, int off, int len): escribe len  bytes del array b  al flujo de salida, empezando a escribirlos en el offset indicado por off . 
  • abstract  void write(int b): escribe 1 byte al flujo de salida.

 

 

Dado que la clase OutputStream  es abstracta nunca vamos a poder crear un objeto de ella. Tendremos que crear objetos de alguna de sus subclases que, como podemos ver en la figura 2, son bastantes. Resumamos, brevemente, cuál es el propósito de cada una de ellas:

 

  • ByteArrayOutputStream: como su nombre indica, este flujo de salida representa un array de bytes que se almacena en memoria. A partir de él puede obtenerse una cadena de caracteres que represente todos los datos escritos. Obviamente, no soluciona el problema de almacenar información de modo persistente.
  • FileOutputStream: flujo de salida para la escritura de datos a un objeto de tipo File .
  • FilterOutputStream: esta clase encapsula a otro objeto de tipo OutputStream  e intercepta todas las operaciones de escritura para, posiblemente, realiza alguna transformación sobre los datos que se están escribiendo. Una de sus subclases, DataOutputStream , resulta particularmente útil para escribir distintos tipos de datos primitivos a un flujo de datos de salida.
  • ObjectOutputStream: encapsula otro objeto de tipo OutputStream   y permite escribir objetos Java completos al flujo de datos de salida representado por el OutputStream  correspondiente. 
  • PipedOutputStream: junto con la clase PipedInputStream  permite emplear pipes para comunicar (habitualmente) dos threads.

 

 

Como podemos observar, cada una de las clases hijas de OutputStream  proporciona una funcionalidad más específica a la clase padre. Dos de ellas (FilterOutputStream  y ObjectOutputStream ) actúan como decoradores sobre cualquier OutputStream ; es decir, encapsulan un objeto de tipo OutputStream  y le proporcionan funcionalidad adicional (aplicar algún tipo de filtrado sobre los datos, o permitir escribir objetos Java).

 

Por limitaciones de espacio, no podemos presentar en detalle los distintos tipos de flujos de salida que hay en Java. Por otro lado, varios de ellos se emplean para problemas relativamente complejos que van más allá de lo que debe presentarse en un curso básico de programación. Aquí presentaremos con detalle los que se emplean más comúnmente.

 

Cuando queremos escribir datos en binario a un archivo lo más habitual es comenzar creando un objeto File  que represente a dicho archivo y creando un flujo de datos de salida mediante la clase FileOutputStream:

 


   File f2 = new File ("C:/datos.dat");
   FileOutputStream out = new FileOutputStream(f2);

 

 

El siguiente paso depende de qué tipo de datos queramos escribir. Hay dos escenarios habituales: queremos escribir tipos de datos primitivos (float , double , boolean ...), o queremos escribir objetos Java completos. En el primer caso, debemos emplear la clase DataOutputStream ; al crear el objeto de esta clase le pasaremos a su constructor el FileOutputStream  que representa el fichero al cual queremos escribir los tipos de datos primitivos:

 

 
      DataOutputStream out = new DataOutputStream(fout);
 

 

DataOutputStream  tiene un conjunto de métodos con nombre writeXXX  donde XXX son los nombres de los distintos tipos de datos primitivos existentes en Java. Cada uno de esos métodos permite escribir el tipo de dato primitivo correspondiente al flujo de datos de salida:

 

      out.writeBoolean(true);
      out.writeInt(45);
      out.writeDouble(4.8);
 

 

Si lo que queremos escribir son objetos Java, debemos emplear la clase ObjectOutputStream  y, al crear el objeto de esta clase, le pasaremos a su constructor el FileOutputStream  que representa el fichero al cual queremos escribir los objetos:

 

 
      FileOutputStream fout2 = new FileOutputStream(f2);
      ObjectOutputStream out2 = new ObjectOutputStream(fout2);
      out2.writeObject(new Date());
 

la última sentencia está escribiendo un objeto de tipo java.util.Date  al fichero representado por el File f2.

 

Finalmente, siempre que terminemos de trabajar con un flujo de datos debemos cerrarlo para liberar los recursos que dicho flujo de datos está consumiendo. Esto se hace invocando a su método close().

 

Si intentas emplear estas líneas de código en cualquier programa Java, obtendrás errores de compilación que (probablemente) resultarán incomprensibles para ti. Estas sentencias pueden lanzar excepciones, y es necesario gestionarlas. Todavía no hemos visto qué son las excepciones, así que las introduciremos en la siguiente sección.

 

2.1.1 Breve introducción a las excepciones en Java

 

Cuando todas las operaciones que le pedimos a un programa que realice sólo involucran a la CPU y a la memoria RAM es muy poco probable que suceda algo imprevisto con el hardware (no es habitual que alguien abra su ordenador cuando está funcionando y, por ejemplo, retire uno de los módulos de memoria). Sin embargo, cuando accedemos a la entrada y salida (dispositivos de almacenamiento masivo como el disco duro o CD, la red, una impresora, un escáner, etc.) hay muchas cosas que pueden ir mal, cosas sobre las que nuestro programa no tiene ningún control.

 

Centrándonos en el problema que nos atañe, el de los medios de almacenamiento masivos, puede suceder que le pidamos a nuestro programa que habrá para leer un archivo que no existe en el disco duro; o que escriba un archivo en un CD no grabable, o que nos quedemos sin espacio en el disco duro, o que el usuario retire su memoria flash USB del ordenador mientras estamos escribiendo, etcétera.

 

Por ello, cuando se accede a un dispositivo de almacenamiento masivo siempre existe la posibilidad de que suceda un evento "excepcional" que nuestro programa no puede resolver. A pesar de ello, nuestro programa debería seguir ejecutándose y, en la medida de lo posible, informar al usuario del problema que ha sucedido.

 

Las excepciones son el mecanismo que Java emplea para gestionar este tipo de errores. En Java si dentro de un método sucede algún problema que nos impide completar la operación que se supone que el método debe realizar, el método puede lanzar una excepción. Esta excepción hace que termine inmediatamente la ejecución del método y que la excepción escale en el stack hasta llegar al método que invocó el código donde se generó la excepción.

 

Algunas excepciones, que se denominan uncheked, no tienen porque gestionarse de modo necesario, y el compilador no nos obliga a gestionarlas, aunque sea posible que en nuestro código se produzcan excepciones de este tipo. Un ejemplo es la NullPointerException . A menudo este tipo de excepciones están causadas por bugs en el programa. Hay otras excepciones, que se denominan cheked, que suelen representar problemas con el hardware que se considera que el programador debe siempre tener en mente cuando está construyendo su programa. Estas excepciones estamos obligados a gestionarlas.

 

Las excepciones que lanza la librería de entrada y salida de Java son precisamente de este segundo tipo. Necesitamos, por tanto, aprender a gestionarlas. Cuando invoquemos a código (constructores o métodos) que potencialmente puede lanzar una excepción cheked debemos colocar ese código dentro de un bloque try-catch:

 


   try {
      //aquí invocamos código que puede lanzar excepciones
    }
    catch (Exception ex) {
//aquí gestionamos las excepciones
    }
 

 

el bloque de código Java que queda dentro del try  es un bloque de código que vamos a "intentar ejecutar", pero puede que suceda una condición excepcional que impida su ejecución. En ese caso, se abandonará inmediatamente el bloque de código try  y saltaremos al bloque de código del catch . En ese bloque debemos escribir código capaz de gestionar esa condición excepcional; muchas veces lo único que puedes hacer es informar al usuario de que ha habido un problema. Observa que a este último bloque de código se le pasa un objeto que representa la excepción (el problema) que ha sucedido. En ocasiones es posible extraer información de ese objeto para ayudar a gestionar el problema.

 

El código del bloque catch  sólo se ejecuta si se lanza la excepción. En ocasiones (cuando se trabaja con ficheros, por ejemplo) hay ciertas líneas que se deben de ejecutar siempre, independientemente de si se lanza o no la excepción. Por ejemplo, la operación de cerrar los flujos de datos debe ejecutarse siempre, incluso si se produce algún error mientras se estaba leyendo o escribiendo al flujo. Para no tener que repetir esa línea de código tanto en el bloque try  como en el bloque catch , estas sentencias pueden colocarse dentro de un tercer bloque de código que va después del catch  que se llama finally:

 


   try {
      //código que puede lanzar excepciones
    }
    catch (IOException ex) {
//sólo se ejecuta si se lanza una excepción
    }
    finally{
      //se ejecuta siempre
    }
 

 

En el listado 2 podemos ver un código que hace uso tanto de un DataOutputStream  como de un ObjectOutputStream . Ambos están asociados con un File  que se creará en C:, el nombre del primer fichero será datos.dat y el del segundo objetos.dat. Observa como las operaciones de creación de los flujos de datos y las escrituras están dentro de un bloque try-catch . Si se produce una excepción, mostraremos un mensaje de error por la consola. En la cláusula finally  cerramos los ficheros y los flujos de datos. Observa que estas sentencias también están dentro de un bloque try-catch , ya que también lanzan excepciones, que en el caso que nos atañe, son excepciones del tipo IOException  (Input-Output Exception ).

 
//LISTADO 2: Este programa demuestra la escritura de datos en formato binario a un fichero
//fichero Ejemplo2.java del CD
import java.io.*;
...
 File f = new File("C:/datos.dat");
    File f2 = new File("C:/objetos.dat");
    FileOutputStream fout=null, fout2=null;
    DataOutputStream out=null;
    ObjectOutputStream out2=null;
 
    try {
      fout = new FileOutputStream(f);
      out = new DataOutputStream(fout);
      out.writeBoolean(true);
      out.writeInt(45);
      out.writeDouble(4.8);
      fout2 = new FileOutputStream(f2);
      out2 = new ObjectOutputStream(fout2);
      out2.writeObject(new Date());
 
    }
    catch (IOException ex) {
      System.out.println(" Se ha producido un error antes de terminar las escrituras");
    }finally{
      try {
          out.close();
          fout.close();
          out2.close();
          fout2.close();
}
...
 

 

2.2 Flujos de entrada de bytes

 

Ya sabemos cómo enviar información en binario a un archivo. Ahora necesitamos aprender cómo recuperarla. En la figura 3 podemos ver la jerarquía de los flujos de entrada de bytes de Java. Como podemos observar en la figura, la clase padre de todos los flujos de entrada de Java es InputStream. Se trata de una clase abstracta que representa un flujo binario de datos de salida.

 

FIGURA 3: Jerarquía de los flujos de entrada de bytes

 

Sus métodos son los siguientes:

 

 

  • close(): cierra el flujo de datos y libera cualquier recurso que el flujo pudiera estar consumiendo.
  • int available(): devuelve una estimación del número de bytes que se pueden leer de este flujo de datos de entrada sin producirse un bloqueo. 
  • int read(byte[] b): lee los bytes que haya disponibles en este flujo de entrada, leyendo nunca más del tamaño del array b , y los almacena en este array. Devuelve el número de bytes que ha leído correctamente. Devuelve -1 si se ha alcanzado el final del flujo de entrada. Si no hubiera ningún byte disponible el método espera hasta que haya datos que se puedan leer; esto hace que se detenga el programa que invoca este método hasta que haya datos (realmente sólo se bloquea el thread que ejecuta el método, no todo el programa).
  • int read():  funciona exactamente del mismo modo que el anterior método, a excepción de que lee un único byte.
  • abstract  int write(byte[] b, int off, int len): lee hasta len  bytes del flujo de entrada y los almacena en el array b , empezando a leer los datos en el offset indicado por off . Su comportamiento es similar al de los métodos anteriores.
  • skip(long n): ignora los próximos n  bytes del flujo de entrada.

 

 

Dado que la clase InputStream  es abstracta siempre tendremos que usar alguna de sus clases hijas. Las clases hijas más comunes son ByteArrayInputStream , FileInputStream , FilterInputStream , InputStream , ObjectInputStream  y PipedInputStream . ¿Te comienzan a resultar familiares los nombres?. Efectivamente, todas estas clases son las recíprocas de los flujos de salida. Nuevamente, las más empleadas son ObjectInputStream  y DataInputStream . La primera se emplea para leer objetos de un flujo de entrada. La segunda es una subclase de FilterInputStream  y se emplea para leer todo tipo de datos primitivos; para ello se emplean un conjunto de métodos readXXX , donde XXX son los nombres de todos los tipos de datos primitivos existentes en Java. Estos métodos leen el correspondiente dato primitivo del flujo de entrada y lo proporcionan como dato de retorno.

 

La forma más habitual de crear objetos de estas dos clases es partiendo de un objeto FileInputStream ; ambas clases "envuelven" al objeto FileInputStream  y proporcionan funcionalidad adicional (lectura de tipos de datos primitivos, o de objetos completos) a él. El FileInputStream  se puede crear directamente pasándole un objeto de tipo File  en su constructor.

 

En el listado 3 vemos un programa que permite abrir los archivos generados por el código del listado 2 y muestra su contenido por consola. La fecha que se mostrará no es la fecha actual, si no la fecha que se escribió en el fichero cuando se creó. La capacidad de Java para escribir un objeto completo con una sola instrucción, sin importar lo complejo que sea el objeto, es muy atractiva. Nos permite almacenar una gran cantidad de información de un modo muy sencillo. La desventaja de este mecanismo de persistencia es que el formato en el que se guarda la información es binario y es muy complejo acceder a esa información desde otro lenguaje de programación. Es más, si modificamos el código fuente de la clase que hemos serializado probablemente cuando intentemos leer un objeto de esa clase no lo vamos a recuperar de modo correcto. Por ello, por lo general, no es recomendable emplear esta estrategia para almacenar información a largo plazo; aunque sí resulta muy práctica para almacenar información de modo temporal (por ejemplo, para mantener copias de respaldo de la sesión de trabajo del usuario).

 

 
//LISTADO 3: Este programa demuestra la lectura de datos en formato binario desde un fichero
//fichero Ejemplo2.java del CD
   ...
    File f = new File("C:/datos.dat");
    File f2 = new File("C:/objetos.dat");
    FileInputStream fin=null, fin2=null;
    DataInputStream in=null;
    ObjectInputStream in2=null;
 
    try {
      fin = new FileInputStream(f);
      in = new DataInputStream(fin);
 
      System.out.println(in.readBoolean());
      System.out.println(in.readInt());
      System.out.println(in.readDouble());
      fin2 = new FileInputStream(f2);
      in2 = new ObjectInputStream(fin2);
      System.out.println(in2.readObject());
 
    }
    catch (Exception ex) {
      System.out.println(" Se ha producido un error antes de terminar las lecturas");
    }
    finally{
      try {
          in.close();
          ...
 

 

 

2.3 Flujos de salida de texto

 

Como norma general, almacenar información de modo binario es más simple que almacenarla en modo texto. Además, la información en binario suele ser mucho más compacta (ocupa menos espacio) que en modo texto. Sin embargo, la información que se almacena en modo texto es mucho más fácil de leer desde otros lenguajes de programación; no tendremos problemas si una clase de la cual estamos serializado objetos cambia y el archivo que generemos será comprensible para un ser humano.

 

 

FIGURA 4: Jerarquía de los flujos de salida de texto

 

En la figura 4 mostramos la jerarquía de clases de los flujos de salida de texto en Java. En lo alto de la jerarquía tenemos, nuevamente, una clase abstracta: Writer . Esta clase representa cualquier flujo de datos de salida donde la información se va a representar en modo texto. Sus métodos principales son:

 

 

  • close(): cierra el flujo de datos y libera cualquier recurso que el flujo pudiera estar consumiendo. 
  • flush(): sincroniza este flujo de datos con el dispositivo al cual se están escribiendo los datos. 
  • write(byte[] b): escribe el array b  de bytes que se le pasa como argumento al flujo de salida.
  • write(byte[] b, int off, int len): escribe len  bytes del array b  al flujo de salida, empezando a escribirlos en el offset indicado por off . 
  • abstract  void write(int b): escribe 1 byte al flujo de salida.

 


Supongo que habrás visto el paralelismo con los métodos de OutputStream . Nuevamente, la clase Writer  no se puede emplear directamente, sino que tenemos que usar alguna de sus subclases: 

 

  • BufferedWriter: esta clase es básicamente idéntica a principio códigos o probadores Writer , sólo que no realiza las operaciones de escritura directamente sino que las almacena en un buffer para aumentar su eficiencia.
  • CharArrayWriter: representa un flujo de salida constituido por un array en memoria.
  • FilterWriter: envuelve a un objeto de tipo Writer  y aplica un conjunto de filtros a las operaciones de escritura que se realizan sobre él.
  • OutputStreamWriter: esta clase es una especie de puente entre flujos binarios y flujos de caracteres. Todos los caracteres que se escriban a ella serán almacenados como bytes empleando un cierto carset para codificarlos.
  • PipedWriter: junto con la clase PipeReader , suele emplearse para comunicar dos thread que quieren compartir información.
  • StringWriter: todas las escrituras a los objetos de esta clase se almacenan en memoria, y a partir del objeto es posible recuperar una cadena de caracteres con todo el texto escrito.
  • PrintWriter: esta clase es bastante similar a DataOutputStream ; contiene un conjunto de métodos print  y println  que permiten escribir todos los tipos de datos primitivos de Java en modo texto al flujo de datos de salida. Para ello, obviamente, ambos métodos están sobrecargados (existen muchas versiones de los métodos que se diferencian en el tipo de parámetro que se le pasa).

 

 

PrintWriter  posiblemente sea la clase más empleada para construir archivos de texto. Podemos construir una instancia de ella proporcionando un objeto File  en su constructor, o también un objeto de tipo OutputStream. 

 

2.4 Flujos de entrada de texto

 

En la figura 5 podemos ver la jerarquía de clases que permiten manipular flujos de entrada de texto en Java. La clase abstracta que está en la cima, Reader , representa un flujo de entrada de texto cualquiera y sus métodos más importantes son: 

 

  • close(): cierra el flujo de datos y libera cualquier recurso que el flujo pudiera estar consumiendo.
  • int read(char[] b): lee los datos que haya disponibles en este flujo de entrada, leyendo nunca más del tamaño del array b , y los almacena en este array. Devuelve el número de caracteres que ha leído correctamente. Devuelve -1 si se ha alcanzado el final del flujo de entrada. Si no hubiera ningún dato disponible, el método espera hasta que haya datos que se puedan leer.
  • int read(): funciona exactamente del mismo modo que el anterior método, a excepción de que lee un único char .
  • abstract  int write(char [] b, int off, int len): lee hasta len caracteres del flujo de entrada y los almacena en el array b , empezando a leer los datos en el offset indicado por off . Su comportamiento es similar al de los métodos anteriores.
  • boolean ready(): devuelve true  si hay caracteres disponibles para leer, y false  en caso contrario.
  • skip(long n): ignora los próximos n  bytes del flujo de entrada.

 

 

FIGURA 5: Jerarquía de los flujos de entrada de texto 

 

Las clases concretas, hijas de la clase Reader , que se emplean para la lectura de datos son BufferedReader , CharArrayReader , FilterReader , InputStreamReader , PipedReader  y StringReader . Son las clases recíprocas de los hijos de Writer . Una forma bastante común de leer un archivo de texto en Java es crear un objeto de tipo FileInputStream  a partir de un objeto de tipo File  que lo represente; con este objeto creamos un InputStreamReader  y éste, a su vez, se lo pasamos a un BufferedReader.

 

BufferedReader  tiene un método, readLine() , que devuelve una línea de texto del flujo de datos de entrada. Si no hay ninguna línea de texto disponible en ese momento, se bloquea y espera a que haya texto disponible. Si hemos llegado al final del flujo de datos, devuelve null.

 

En el listado 4 vemos un código Java que abre un fichero de texto con nombre "original.txt" (el fichero debe estar situado en la raíz de nuestro disco C:) y, en el mismo directorio, crea una copia del fichero con nombre "copia.txt".

 

 
//LISTADO 4: Programa que nos permite crear una copia de un fichero de texto
//fichero Ejemplo4.java del CD
  ...
    File f = new File("C:/original.txt");
    File f2 = new File("C:/copia.txt");
    FileInputStream is=null;
    InputStreamReader isr=null;
    BufferedReader br=null;
    PrintWriter pw=null;
    try {
      is = new FileInputStream(f);
      isr = new InputStreamReader(is);
      br = new BufferedReader(isr);
      String s = br.readLine();
      pw = new PrintWriter(f2);
 
      do {
        System.out.println(s);
        pw.println(s);
      }
      while ( (s = br.readLine()) != null);
    }
    catch (IOException ex) {
      System.out.println("Error durante el proceso de copia");
    ...
 

 

La técnica empleada para leer y escribir documentos de texto del listado 4 funciona perfectamente mientras toda la información que manipulamos sean cadenas de caracteres. Pero ¿qué pasa si tenemos que trabajar con datos numéricos, por ejemplo, que están en formato de texto?. Estos datos vamos a tener que leerlos inicialmente como cadenas de caracteres, y después deberemos transformar la cadena de caracteres al tipo de dato adecuado.

 

Para realizar esta transformación podemos apoyarnos en un conjunto de clases "wraper" de los tipos de datos primitivos que podemos encontrar en el paquete java.lang . Estas clases son Boolean , Byte , Short , Character , Integer , Long , Float  y Double . Cada una de ellas encapsula una instancia del tipo de dato primitivo correspondiente. Cada una de estas clases tiene un método con nombre de parseXXX (String s) , donde XXX es el nombre de la propia clase. A este método se le pasa una cadena de caracteres que contiene un valor del tipo primitivo correspondiente y nos devuelve el valor representado en la cadena como un tipo de dato primitivo. Por ejemplo, la sentencia:

 
Double d = Double.parseDouble("3.4");

 

almacenará el valor 3.4 en la variable d . Si el formato de la cadena de caracteres que le pasamos no se corresponde con el tipo de dato que espera el método (por ejemplo, si le pasamos al método anterior la cadena de caracteres "hola") el método lanzará una excepción. En el listado 5 podemos ver un código que escribe varios tipos de datos primitivos, como texto, a un fichero y los vuelve a leer.

 

//LISTADO 5: Lectura y escritura de varios tipos de datos primitivos a un fichero de texto
    ...
 
    File f = new File("C:/datos.txt");
    FileInputStream is=null;
    InputStreamReader isr=null;
    BufferedReader br=null;
    PrintWriter pw=null;
    try { 
      pw = new PrintWriter(f);
      pw.println(25);
      pw.println(2.5);
      pw.println(true);
      pw.println(3.6F);
      pw.close();
      is = new FileInputStream(f);
      isr = new InputStreamReader(is);
      br = new BufferedReader(isr);
      int i = Integer.parseInt(br.readLine());
      double d = Double.parseDouble(br.readLine());
      boolean b = Boolean.parseBoolean(br.readLine());
      float g = Float.parseFloat(br.readLine());
      System.out.println(i+ " " +d+ " " +b+ " " +g);
 
    }
    ...
 

Si quisiésemos almacenar un objeto completo como texto tendríamos que ir almacenando todos sus atributos como texto. Aquellos atributos que sean tipos de datos primitivos pueden transformarse directamente a una cadena de caracteres. Si alguno de los atributos del objeto vuelven a ser objetos, deberemos guardar todos los atributos del objeto-atributo como texto, y así sucesivamente. Como podéis intuir, este proceso puede llegar a ser bastante tedioso. Nada que ver con lo simple y sencillo que resulta serializar un objeto en formato binario. Eso sí, el fichero de salida será mucho más portable entre lenguajes y diferentes versiones de la plataforma Java.

 

3 Leyendo datos de la consola

 

Posiblemente te hayas dado cuenta que todavía no hemos visto ninguna forma de crear un programa Java que sea capaz de tomar alguna entrada del usuario. Los programas, habitualmente, no son autocontenidos sino que necesitan que los usuarios introduzcan algunos datos para realizar su trabajo. A pesar de ser algo tan esencial en programación, todavía no hemos visto cómo se hace en Java. El motivo es simple: hasta ahora no estábamos en disposición de entenderlo. En Java toda la entrada y salida está organizada en torno a las clases que hemos presentado en este artículo. Inclusive la entrada y salida de la consola, y la entrada y la salida de red (es decir, trabajar con sockets).

 

Explicarle a alguien cómo escribir cosas a la consola sin conocer lo que en este capítulo hemos expuesto es simple. Aunque realmente no entienda que de System  es una clase de la librería estándar de Java, y out es un atributo estático de dicha clase cuyo tipo de dato es PrintWriter ,  una clase hija de FilterOutputStream  que proporciona métodos sobrecargados para escribir todos los tipos de datos primitivos de Java. Estos métodos se llaman println . ¿Comienzan a tener ahora más sentido todas esas sentencias System.out.println()  que hemos usado a lo largo de esta serie de artículos?. También existen un conjunto de métodos equivalentes a éste, pero con nombre print , que no imprimen un retorno de carro después de imprimir el dato. out  representa el flujo de datos asociado con la salida estándar del sistema.

 

Sin embargo, explicarle a alguien que no conoce la librería de entrada y salida estándar de Java que para leer algo de la consola tiene que envolver el atributo estático in, de la clase System , cuyo tipo es InputStream , en un InputStreamReader . Después debe envolverlo en un BufferedReader , y a continuación proceder del mismo modo que hemos procedido en el listado 5 no es fácil. En el listado 6 podemos ver un código que lee exactamente los mismos datos que el código de listado 5, sólo que en vez de leerlos de un fichero lo hace de la entrada estándar, es decir, de System.in .

 
//LISTADO 6: Este código demuestra cómo leer datos de teclado en la consola
//fichero Ejemplo6.java del CD
...
      InputStreamReader isr=null;
      BufferedReader br=null;
      try {
        isr = new InputStreamReader(System.in);
        br = new BufferedReader(isr);
        int i = Integer.parseInt(br.readLine());
        double d = Double.parseDouble(br.readLine());
        boolean b = Boolean.parseBoolean(br.readLine());
        float g = Float.parseFloat(br.readLine());
        System.out.println(i+ " " +d+ " " +b+ " " +g);
 
 

Por último, y para demostrar que, por suerte o por desgracia, desde Java están simple (o complicado) leer datos a través de la red como leerlos de la consola, mostramos en el listado 7 un pequeño programa que abre un socket y espera a recibir datos por él. Para ello se emplea el método accept()  de la clase ServerSocket ; este método espera hasta que se realiza algún intento de conexión al puerto donde el ServerSocket  está escuchando (el 8081 en nuestro ejemplo). Cuando se realiza un intento de conexión el método devuelve un objeto tipo Socket , al cual le podemos pedir un InputStream , para leer los datos que lleguen al socket (datos que envía el equipo que se ha conectado), y un OutputStream , para escribir datos en el socket y enviarlos al equipo que ha establecido la conexión. Tanto la clase ServerSocket  como la clase Socket  se encuentran en el paquete java.net. Como podéis ver en el código, la forma de tratar con estos flujos de entrada y de salida es idéntica a la forma de tratar con el flujo de entrada y de salida de un fichero del disco duro, o de la consola.

 
//LISTADO 7: La comunicación a través de la red en Java se realiza de un modo similar a cómo se accede a ficheros
import java.net.*;
...
    ServerSocket sv = null;
    Socket s = null;
         DataOutputStream dos = null;
         DataInputStream dis = null;
        try {
          sv = new ServerSocket(8081);
         s= sv.accept();
          OutputStream os = s.getOutputStream();
          InputStream is = s.getInputStream();
          dis = new DataInputStream(is);
          System.out.println(dis.readLine());
          System.out.println(dis.readInt()); 
          dos = new DataOutputStream(os);
          dos.writeBytes("Hola qué tal\n");
          dos.writeFloat( 2.3F);
        }
 

 

En el listado 8 vemos un pequeño programa cliente que intenta conectarse al localhost (la IP 127.0.0.1). Una vez establecida la conexión, se obtienen los objetos InputStream  y OutputStream  vinculados con este socket y se emplean para mandar una cadena de caracteres y un número entero. El socket espera recibir una cadena de caracteres y un número real del servidor. Ambos programas pueden ser ejecutados en dos máquinas distintas simplemente cambiando en el código fuente la IP  del localhost por la IP de máquina en la que corra el programa servidor (el de listado 7).

 

//LISTADO 8: Programa cliente que se conecta al programa de listado 7
...
    Socket s = null;
     DataOutputStream dos = null;
     DataInputStream dis = null;
    try {
      s = new Socket("127.0.0.1", 8081);
      OutputStream os = s.getOutputStream();
      dos = new DataOutputStream(os);
      dos.writeBytes("Hola qué tal\n");
      dos.writeInt( 23);
      InputStream is = s.getInputStream();
      dis = new DataInputStream(is);
      System.out.println(dis.readLine());
      System.out.println(dis.readFloat());   
    }

 

Para que los programas de los listados 7 y 8 funcionen de modo correcto, primero hay que lanzar el programa servidor (el del listado 7) y después el cliente (el del listado 8). BlueJ no permite lanzar de modo simultáneo en el entorno dos programas. Se intentas ejecutar alguno de estos programas en BlueJ no podrás ejecutar el segundo y, por tanto, el primer programa nunca terminará por que nunca va a conseguir leer nada del socket. Esto hará que no se pueda volver a ejecutar ningún otro programa hasta que se reinicie BlueJ. Por tanto, si queréis ejecutar estos programas debéis emplear la consola de comandos, o un entorno de desarrollo como NetBeans. Y hablando de NetBeans, ese es precisamente el tema del videotutorial que acompaña a este artículo. A partir de ahora, espero que dejes de usar BlueJ y comiences a usar NetBeans para ejecutar y seguir los ejemplos de este tutorial.

 

4 Conclusiones

 

En este artículo hemos presentado el API estándar de entrada y salida de Java. Este API juega un papel crucial a la hora de almacenar información de modo persistente en los programas Java. También puede usarse para obtener entrada del usuario usando la consola, aunque en la actualidad es mucho más común emplear interfaces gráficas para interactuar con el usuario (ya llegaremos allí en un par de números...). Finalmente, hemos visto de un modo muy breve las capacidades del paquete java.net. 

 

En el próximo número continuaremos viendo más componentes de la librería estándar de Java: el framework de collections. Os espero a todos el mes que viene.


 

Descargas

 

 

 

Cápitulos anteriores del curso:

 

 

lunes
nov302009

Curso de programación Java IV - Abraham Otero

Curso de programación Java IV

 

 Artículo publicado originalmente en la revista Sólo Programadores 

 

En el último artículo de la serie comenzamos a presentar el soporte que Java proporciona para la programación orientada a objetos, un paradigma de programación bastante diferente de la programación estructurada. En este artículo terminaremos de cubrir la programación orientada a objetos en Java describiendo en mayor profundidad las implicaciones de usar herencia, y presentando las interfaces y los paquetes.

 

1 Algunos apuntes más sobre la herencia

 

En el capítulo anterior de esta serie vimos cómo la herencia permite a una clase heredar el código de su clase padre. También vimos cómo, sobreescribiendo los métodos del padre, la clase hija puede modificar el comportamiento del padre.

 

En ocasiones, cuando se diseña un código el programador no quiere que una de sus clases sea modificada; dado el diseño interno de la clase ésta debe emplearse tal cual, o no emplearse en absoluto (ya conocemos una clase que ha sido diseñada de este modo: la clase Math). Es posible impedir que el comportamiento de nuestra clase sea extendido o modificado mediante la herencia empleando el modificador final . Del mismo modo que cuando este modificador se emplea al definir una variable viene indicar que dicha variable nunca va a cambiar de valor, cuando se aplica a una clase su significado es que "la clase no puede modificarse". Y el mecanismo para modificar una clase es la herencia; por tanto, será imposible heredar de una clase final. Así, por ejemplo, si tenemos la clase:

 
public final class AlgunaClase {...}
e intentamos definir una clase que herede de ella:
public class OtraClase extends  AlgunaClase {...}
 

el compilador producirá un error, ya que la clase que estamos intentando extender es una clase final . En ocasiones no queremos eliminar completamente la posibilidad de extender o modificar algunas partes de nuestra clase. Pero sí existe alguna funcionalidad que no queremos que se modifique bajo ningún concepto; esto es, existen algunos métodos que no queremos que sean modificados (sobreescritos) por las clases fijas. Este efecto puede conseguirse sin declarar la clase final , pero indicando que los métodos que no queremos que sean sobreescritos son finales:

 

public final String algunMetodo () {...}

 

Si una clase hija define otro método con el mismo nombre y parámetros que el nuestro (es decir, si define un método que sobreescriba a nuestro método) el compilador producirá un error.

 

1.1 Clases abstractas

 

El último punto que trataremos (por lo de ahora) referente a la herencia es la utilidad de las clases abstractas. En el número anterior lo vimos a nivel teórico: una clase abstracta representa una categoría de objetos "abstractos" del mundo real. Por ejemplo, podría representar los "seres vivos". Esa es una categoría abstracta, en el sentido de que no existe ningún ser vivo como un ente puro. Existen perros, gatos, personas, plantas... y todos ellos son seres vivos. Pero no hay ningún ente que sea un ser vivo y que no pertenezca a otra categoría más específica.

 

Estas categorías de objetos abstractos juegan un papel muy importante para ayudarnos a organizar la información dentro de nuestro cerebro. De un modo similar, las clases abstractas pueden ayudarnos a organizar nuestro código fuente. Una clase abstracta representa una entidad que contiene ciertas propiedades y funcionalidad comunes a un conjunto de clases. Podemos decir que la clase abstracta constituye una abstracción (valga la redundancia) de las clases concretas que derivan de ella. Sin embargo, por sí sola no tiene una funcionalidad completa; no tiene sentido. Pero puede ser útil para reutilizar, mediante la herencia, sus propiedades y su funcionalidad.

 

Supongamos que en un programa tenemos que representar un conjunto de funciones matemáticas. Estas funciones matemáticas deberán ser capaces de hacer tres cosas: deben poder recibir un valor de x y devolver el valor de f(x) correspondiente; deben permitir devolver una representación textual de la función que representan (por ejemplo, " 3.6x^2 + 5.0x +2"); y deben contar con un método al cual se le pasa un valor de x, y muestra por consola la representación textual de la función, y su valor en dicho punto. Tenemos que representar varios tipos de funciones: lineales, cuadráticas, exponenciales... cada una de estas funciones va requerir de operaciones diferentes para evaluar la función en x. La forma de generar su representación textual también va a depender de cada función. Sin embargo, la operación de mostrar dicha representación textual en la consola y el resultado de evaluar la función en un punto puede implementarse una sola vez y reutilizarse para todas las funciones. Para ello necesitamos emplear una clase abstracta, como la que se muestra en el listado 1.

 
//LISTADO 1: Clase que representa cualquier función matemática. Es imposible crear instancias de ella, ya que la clase es abstracta
public abstract class FuncionAbstracta {
  public void mostrarResultadoEvaluar(float x) {
    System.out.println("El valor de la funcion " +
    getRepresentacion() +" en "+x+ " es: "+  evalua(x));
  }
 
  public abstract float evalua (float x);
  public abstract String getRepresentacion ();    
}
 
 

En el listado 1 podemos observar como la clase que hemos declarado emplea el modificador abstract . También hay dos métodos que tienen ese modificador. Cuando este modificador se emplea en un método quiere decir que vamos a declarar el método, pero que no vamos a proporcionar ninguna implementación para él. Una clase que contenga métodos abstractos obligatoriamente tiene que ser abstracta. Si heredamos de una clase abstracta y queremos que la clase hija no sea abstracta, obligatoriamente tendremos que sobrescribir todos los métodos abstractos de la clase padre y proporcionar una implementación para ellos.

 

En nuestra clase abstracta existe un método que sí que tiene implementación: mostrarResultadoEvaluar(float x) . Es el método que muestra la función y el resultado de evaluar la función por consola. Observa que para ello ¡emplea los otros dos métodos abstractos!. No hay ningún problema en ello: nunca nadie va a poder crear un objeto de esta clase, ya que es abstracta. Y si alguna clase no abstracta hereda de nuestra clase, obligatoriamente va a tener que proporcionar una implementación para los métodos abstractos. Por tanto, cuando se cree un objeto de la clase hija tenemos garantizado que los métodos evalua(flota x)  y getRepresentacion()  habrán sido definidos.

 

En el listado 2 podemos ver dos clases que heredan de la clase FuncionAbstracta . La primera implementa una función lineal (ax + b), mientras que la segunda implementa una función cuadráica (ax^2 + bx + c). Empleando BlueJ puedes comprobar como es posible crear objetos de la clase FuncionLineal  o de la clase FuncionCuadratica , aunque no de la clase FuncionAbstracta . También puedes comprobar como el método mostrarResultadoEvaluar(float x)  de la clase FuncionLineal  y de la clase FuncionCuadratica  funcionan perfectamente, y hacen dos cosas diferentes (uno muestra y evalúa la función lineal, y el otro muestra y evalúa la función cuadrática). A pesar de ello, sólo tuvimos que escribir una vez su código, en la clase padre. La herencia hizo el resto de la magia.

 

 

//LISTADO 2: Dos clases que heredan de la clase FuncionAbstracta e implementan una función lineal y una función cuadrática
public class FuncionLineal extends FuncionAbstracta {
    float a,b;
 
   public FuncionLineal(float a, float b) {
        this.a = a;
        this.b = b;
    }
 
   public float evalua(float x) {
        return a*x+b;
    }
 
   public String getRepresentacion() {
        return a+"x + "+b;
    }
}
 
public class FuncionCuadratica extends FuncionAbstracta {
    float a,b,c;
 
   public FuncionCuadratica(float a, float b, float c) {
        this.a = a;
        this.b = b;
        this.c = c;
    }
 
   public float evalua(float x) {
        return (float)(a*Math.pow(x,2)) + b*x + c;
    }
 
    public String getRepresentacion() {
        return a+"x^2 + " + b + "x +" + c;
    }
}
 

 

2 Las interfaces

En Java no está soportada la herencia múltiple, esto es, no está permitido que una misma clase pueda heredar de varias clases padres. En principio esto pudiera parecer una propiedad interesante que le daría una mayor potencia al lenguaje de programación. Sin embargo los creadores de Java decidieron no implementar la herencia múltiple por considerar que ésta añade al código una gran complejidad que no se ve compensada con la potencia que proporciona (lo que hace que muchas veces los programadores que emplean lenguajes que sí la soportan no lleguen a usarla).

 

Sin embargo, para no privar completamente a Java de la potencia de la herencia múltiple, sus creadores introdujeron un nuevo concepto: el de interface. Una interfaz es  similar a una clase, pero tiene dos diferencias: sus métodos están vacíos, no hacen nada, y a la hora de definirla en vez de utilizar la palabra clave class  se utiliza inteface. 

 

Aunque en este momento no le veamos demasiado sentido, podríamos hacer que la clase abstracta que representa una función genérica del apartado anterior implementarse una interfaz que defina todas las operaciones que deben ser comunes para todas las funciones. Esto nos proporcionaría un nivel de indireción adicional que puede resultar muy útil en ciertos casos. Elaboraremos más sobre este punto a lo largo de esta serie de artículos. Por lo de ahora simplemente vamos a creernos que es una buena idea hacer que la clase FuncionAbstracta  herede de la interfaz que se muestra en el listado 3.

 

//LISTADO 3: Interfaz que representa una función cualquiera
public interface Funcion {
    public float evalua (float x);
    public void mostrarResultadoEvaluar(float x);
    public String getRepresentacion ();
}
 

 

Cabe preguntarnos cuál es el uso de una interfaz si sus métodos están vacíos. Cuando una clase implementa una interfaz lo que estamos haciendo es una promesa de que esa clase va a implementar todos los métodos de la interfaz en cuestión. Si la clase que implementa la interfaz no sobrescribiese alguno de los métodos de la interfaz automáticamente esta clase se convertiría en abstracta y no podríamos crear ningún objeto de ella. 

 

 

FIGURA 1: Jerarquía de clases del ejemplo de las funciones incluyendo la interfaz Funcion

 

Si ahora queremos que la clase FuncionAbstracta  implemente la interfaz Funcion  debemos definirla como se muestra en el listado 4. La clase tiene que ser obligatoriamente abstracta, ya que no sobrescribe dos métodos de la interfaz. Las clases que deriven de ella (como, por ejemplo, la clase FuncionLineal ) deberán sobrescribir los dos métodos que quedan por sobrescribir de la interfaz. Tras hacer estos cambios a la clase FuncionAbstracta , tanto la clase FuncionLineal  como la clase FuncionCuadratica  no necesitan ninguna modificación para seguir funcionando correctamente. En la figura 1 podemos ver cómo BlueJ representa la jerarquía de clases de nuestro ejemplo.

 

 
//LISTADO 4: La clase FuncionAbstracta ahora implementa la interfaz Funcion
public abstract class FuncionAbstracta implements Funcion{
     public void mostrarResultadoEvaluar(float x) {
           System.out.println("El valor de la funcion " +
                getRepresentacion() +" en "+x+ " es: "+ evalua(x));
    }   
}
 

 

Las variables que se definen dentro de una interfaz llevan todas el atributo final  (aunque nuestro código no lo indique), y es obligatorio darles un valor dentro del cuerpo de la interfaz. Además no pueden llevar modificadores private  ni protected , sólo public . Su función es la de ser una especie de constantes para todos los objetos que implementen dicha interfaz.

 

Por último, mencionar que aunque una clase sólo puede heredar propiedades de otra clase, puede implementar cuantas interfaces se desee, recuperándose así parte de la potencia de la herencia múltiple. Para ello, basta con poner la lista de interfaces a implementar después de la palabra reservada implements , separando los nombres de las interfaces con comas. En el listado 5 mostramos como una clase puede implementar varias interfaces.

 
//LISTADO 5: Una clase puede implementar cuantas interfaces se desee 
interface Interfaz1 {
    public void metodo1();
 
interface Interfaz2{
    public void metodo2();
 
interface Interfaz3{
    public void metodo3();
public class AlgunaClase implements Interfaz1, Interfaz2, Interfaz3 {
   //dentro de esta clase estamos obligados a sobrescribir
// metodo1(),metodo2() y metodo3(); 
...
 

 

3 Los packages 

A estas alturas deberías tener claro que una clase tiene una parte privada que oculta a los demás y que no es necesario conocer para poder acceder a su funcionalidad. Si hacemos cambios a la parte privada de la clase, mientras se respete la parte pública, cualquier código cliente que emplee la clase no se dará cuenta de dichos cambios.

 

Imagínate que tú y un compañero vais a construir un programa complejo juntos. Os repartís el trabajo entre los dos y cada uno de vosotros implementa su parte como un montón de clases Java. Cada uno de vosotros en su código va a emplear parte de las clases del otro. Por tanto, os ponéis de acuerdo en las interfaces de esas clases. Sin embargo, cada uno de vosotros para construir la funcionalidad de esas clases probablemente se apoye en otras clases auxiliares. A tu compañero le dan igual las clases auxiliares que tú emplees. Es más, dado que el único propósito de esas clases es servir de ayuda para las que realmente constituyen la "interfaz" de tu parte del trabajo sería contraproducente que él pudiese acceder a esas clases que son detalles de implementación: tú en el futuro puedes decidir cambiar esas clases, modificándolas o incluso eliminándolas.

 

Dada esta situación ¿no sería interesante poder "empaquetar" tu conjunto de clases de tal modo que ese "paquete" sólo dejase acceder a tu compañero a las clases que tú quieras y oculte las demás?. Esas clases a las que se podría acceder serían la interfaz de ese "paquete"; serían la parte pública del paquete. Dentro del paquete tú puedes tener clases adicionales. Pero esas no son accesibles por tu compañero y podrás cambiarlas en cualquier momento sin que él tenga que modificar su código. Es la misma idea que hay detrás de una clase pero llevada a un nivel superior: una clase puede definir cuáles de sus partes son accesibles y no accesibles para los demás. El paquete permitiría meter dentro cuantas clases quieras pero mostraría al exterior sólo aquellas que tú indiques. Parece una buena idea ¿no?

 

Pues esa es precisamente la utilidad de los package  en Java. Agrupar un montón de clases y permitir indicar cuáles serán accesibles para los demás y cuáles no. Para empaquetar las clases simplemente debemos poner al principio del archivo donde definimos la clase, en la primera línea que no sea un comentario, una sentencia que indique a qué paquete pertenece:

 
package mipaquete;
 

Una clase que esté en el paquete mipaquete  debe situarse dentro de un directorio con nombre mipaquete . En Java los paquetes se corresponden con una jerarquía de directorios. Por tanto, si para construir un programa quiero emplear dos paquetes diferentes con nombres paquete1 y paquete2 en el directorio de trabajo debo crear dos subdirectorios con dichos nombres y colocar dentro de cada uno de ellos las clases correspondientes. En la figura 1, el directorio de trabajo desde el cual deberíamos compilar y ejecutar la aplicación es Paquetes. En cada uno de los dos subdirectorios colocaremos las clases del paquete correspondiente.

 

 

FIGURA 2: En Java, los paquetes se corresponden con una estructura de directorios

 

Cuando una clase se encuentra dentro de un paquete el nombre de la clase pasa a ser NombrePaquete.NombreClase . Así, la clase  ClasePaquete1  que se encuentra físicamente en el directorio paquete1 y cuya primera línea de código es:

 

 
package paquete1;
 

tendrá como nombre completo paquete1.ClasePaquete1 . Si deseamos, por ejemplo, ejecutar el método main  de dicha clase debemos situarnos en el directorio Paquetes y teclear el comando:

 
java paquete1.ClasePaquete1
 

Cuando en una clase no se indica que está en ningún paquete, como hemos hecho hasta ahora en todos los ejemplos de esta serie de artículos, esa clase se sitúa en el "paquete por defecto" (default package). En ese caso, el nombre de la clase es simplemente lo que hemos indicado después de la palabra reservada class , sin precederlo del nombre de ningún paquete.

 

Es posible anidar paquetes; por ejemplo, en el directorio paquete1 puedo crear otro directorio con nombre paquete11 y colocar dentro de él la clase OtraClase . La primera línea dentro del fichero de código fuente de dicha clase deberá ser:

 
 
package paquete1.paquete11;

 

y el nombre de la clase será  paquete1.paquete11.OtraClase .

 

¿Cómo indico qué clases serán visibles en un paquete y qué clases no serán visibles?. Cuando explicamos cómo definir clases vimos que antes de la palabra reservada class  podíamos poner un modificador de visibilidad. Hasta ahora siempre hemos empleado el modificador public . Ese modificador significaría que la clase va a ser visible desde el exterior; es decir, forma parte de la interfaz del paquete. Si no ponemos el modificador public  la clase tendrá visibilidad de paquete, es decir, no será visible desde fuera del paquete pero sí será visible para las demás clases que se encuentren en el mismo paquete que ella. Aunque hay más opciones para el modificador de visibilidad de una clase, para un curso básico como éste estas dos son suficientes.

 

¿Y cómo hacemos para emplear clases que se encuentren en otros paquetes diferentes al paquete en el cual se encuentra nuestra clase?. Para eso es precisamente para lo que vale la sentencia import : para indicar que vamos a emplear clases de paquetes diferentes al nuestro. Así, si desde la clase MiClase , que se encuentra definida dentro de paquete1 , quiero emplear la clase OtraClase , que se encuentra en paquete2 , en MiClase  debo añadir la sentencia:

 

 
import paquete2.OtraClase;

 

A partir de ese momento, si OtraClase  era pública, podré acceder a ella y crear instancias. El importar una clase sólo será posible si dicha clase forma parte de la interfaz pública del paquete; en caso contrario el compilador dará un error. La sentencia:

 
import paquete2.*;
 

hace accesibles todas las clases públicas que se encuentren en paquete2. El importar un paquete nunca es recursivo; es decir, al escribir la sentencia anterior sólo importamos el contenido de paquete2. Si existiese otro paquete con nombre paquete2.subpaquete , esa sentencia no está importando las clases de subpaquete.

 

Una opción alternativa a emplear la sentencia import  es emplear el nombre completo de la clase cuando vayamos a acceder a ella para crear un objeto o para invocar uno de sus métodos estáticos. Así, si no hemos importado las clases del paquete2 , para crear un objeto de una de sus clases debemos escribir:

 
paquete2.OtraClase objeto = new paquete2.OtraClase ();
 

Es posible que las clases que estén dentro de un paquete hereden de clases que forman la parte pública de otro paquete. En este caso, se aplican las normas que ya hemos presentado en el artículo anterior para la herencia: la clase hija podrá acceder a la parte pública y protegida de la clase padre. Observa que, si no está involucrada la herencia, una clase nunca podrá acceder a las partes no públicas de las clases de otro paquete.

 

En los listados 7 y 8  podemos ver dos clases, en cada uno de ellos, que pertenecen, respectivamente a los paquetes paquete2  y paquete1 . Las clases de paquete1 son las que van a usar las clases del segundo paquete. En concreto, la clase ClasePaquete1  empleará a una clase del segundo paquete (creará a una instancia de ella e invocará métodos) y la clase ClasePaquete1Herencia  heredará de una clase del otro paquete. En el segundo paquete tendremos una clase pública con nombre ClasePaquete2 ; esta clase constituye la interfaz del segundo paquete. También tendremos una clase que es inaccesible fuera del paquete, ClasePaquete2Privada de , pero que es empleada por la primera clase. Esa clase es "un detalle de implementación" de este paquete. Si en el futuro la modificamos, la eliminamos, creamos más clases para repartir sus responsabilidades... ningún código que emplee paquete2  se dará cuenta de dichos cambios ya que nunca conoció la existencia de dicha clase.

 

//LISTADO 7: Este código demuestra las distintas visibilidades entre clases que están en distintos paquetes
package paquete2;
class ClasePaquete2Privada {
void visibilidadPublica()	{
     System.out.println("Mensaje del método con visibilidad pública ...");
    }
    void visibilidadPaquete(){
      System.out.println("Mensaje del método con visibilidad de paquete ...");
   }
 
    protected void visibilidadProtegida(){
    System.out.println("Mensaje del método protegido ...");
   }
 
private void visibilidadPrivada (){
   System.out.println("Mensaje del método privado...");
  }
}
//comienza una nueva clase, la pública de este paquete
package paquete2;
 
public class ClasePaquete2{
 
public void saludar()	{
System.out.println("Hola");
...
       //aquí usamos los "detalles de implementación" del paquete
   ClasePaquete2Privada objeto2 = new ClasePaquete2Privada();
   objeto2.visibilidadPublica();
   objeto2.visibilidadPaquete ();
   objeto2.visibilidadProtegida ();
    }
    void visibilidadPaquete(){
   System.out.println("Mensaje del método con visibilidad de paquete");
   }
 
    protected void visibilidadProtegida(){
     System.out.println("Mensaje del método con visibilidad protegida");
   }
 
private void privado (){
    System.out.println("Mensaje del método privado");
  }
}
 
 

 

//LISTADO 8:
package paquete1;
import paquete2.*;
 
public class ClasePaquete1{
public static void main (String[] args){
   ClasePaquete2 objeto = new ClasePaquete2();
objeto.saludar();
   }
}
 
package paquete1;
//comienza la segunda clase
import paquete2.*;
public class ClasePaquete1Herencia extends ClasePaquete2{
 
       public static void main (String[] args){
        ClasePaquete1Herencia objeto = new ClasePaquete1Herencia();
        objeto.visibilidadProtegida();
        objeto.saludar();
   }
}
 

 

La salida que produce la ejecución del método main  de ClasePaquete1  se muestra en la figura 3, y la que produce el método main  de ClasePaquete1Herencia  se muestra la figura 4.

 

 

FIGURA 3: Resultado de la ejecución del método main de ClasePaquete1

 

 

FIGURA 4: Resultado de la ejecución del método main de ClasePaquete1Herencia

 

 

3.1 Sobre el nombre de los paquetes

Por último, para terminar con este apartado dedicado a los packages, comentaremos un convenio de nomenclatura muy extendido para los nombres paquetes. Además de proporcionar niveles de visibilidad para las clases, los paquetes evitan colisiones entre espacios de nombres. Los paquetes me permiten definir una clase Cliente , y permiten que otro programador tenga también su clase Cliente  y que ambas clases sean distinguibles, mientras se encuentren en paquetes diferentes. Para que haya una colisión a nivel de clase, las clases deberán tener el mismo nombre y estar dentro del mismo paquete.

 

Para evitar que haya colisiones de paquetes, por convenio, suele emplearse URLs (que el World Wide Consortium garantiza que son siempre únicas, es decir, no hay dos URLs iguales) para nombrar paquetes. Por ejemplo, si la editorial de esta revista quisiese crear su propio paquete siguiendo este convenio en el nombre del paquete debería ser com.revistas.profesionales.XXX . No hay nada dentro de la sintaxis de Java que nos obligue a seguir este convenio, pero es recomendable seguirlo y la mayor parte del software profesional lo hace.

 

3.2 El paquete java.lang

 

Cuando queremos acceder a la funcionalidad de algún paquete de la librería estándar de Java también es necesario importarlo. Esto ya lo hemos hecho en alguna ocasión: por ejemplo, cuando quisimos usar la clase Random del paquete java.util. Sin embargo, ha habido muchas ocasiones en las que hemos usado clases de la librería de Java sin importarlas: Math o System son dos ejemplos.

 

Dentro de las librerías estándar de Java hay un paquete especial: java.lang. Todos los programas Java importan todas las clases que hay en ese paquete por defecto. A todos los efectos, al principio de cualquier programa Java hay un import java.lang.* implícito. Esto se debe a que en ese paquete se encuentran librerías de uso muy frecuente en cualquier programa y es virtualmente imposible escribir un programa en Java sin necesitar usar una o varias de las clases de este paquete. Por ello este paquete siempre "está importado por defecto". Y, obviamente, es en él donde se encuentran las clases Math y System.

 

 

4 Un ejemplo donde se ponga todo esto junto

 

Vamos a desarrollar un ejemplo que va a incorporar muchos conceptos relacionados con la herencia, con la programación orientada a objetos y con la organización del código en paquetes que hemos estado viendo a lo largo de este artículo y del anterior. Supongamos que tenemos que desarrollar un software que permita evaluar múltiples funciones matemáticas diferentes en un mismo punto del eje x. Este software debe contar con un "contenedor" de funciones, al cual se deberán poder añadir todas las funciones matemáticas sobre las cuales queremos trabajar. Será posible pedirle a esta especie de contenedor que evalúe todas las funciones que contiene en un determinado punto, y como respuesta a esta petición el contenedor deberá mostrar cada una de las funciones y el valor de la función en el punto indicado.

 

El software deberá ser extensible, en el sentido de que los usuarios podrán crear las funciones matemáticas que deseen y emplearlas con él. No obstante, deberemos proporcionar implementaciones de algunas funciones matemáticas básicas (funciones lineales, cuadráticas y exponenciales, por ejemplo).

 

Vamos a organizar nuestro código en dos paquetes diferentes. Por un lado tendremos un paquete donde colocaremos el contenedor de las funciones y una interfaz que deberán implementar todas las funciones que vayan a ser gestionadas por nuestro contenedor. En ese paquete también colocaremos una clase abstracta que implementa la interfaz y proporciona cuerpo para una de sus funciones. El contenido de este paquete, que denominaremos funciones, se muestra en el listado 9.

 
//LISTADO 9: Contenido del paquete funciones de nuestro ejemplo
package funciones;
// el cuerpo es igual que el que se muestra en el listado 3
public interface Funcion {...}
 
package funciones;
// el cuerpo es igual que el que se muestra en el listado 4
public abstract class FuncionAbstracta implements Funcion {...}
 
package funciones;
 
public class ContenedorDeFunciones {
    private int numFunciones = 0;
    private Funcion[] funciones = new Funcion[10];
 
    public void anhadirFuncion(Funcion f) {
        if (numFunciones < 10) {
            funciones[numFunciones] = f;
            numFunciones++;
        }...
 
     public void evaluarFunciones(float x) {
          if (numFunciones > 0) {
            System.out.println("Evaluando las funciones...\n");
             for (int i = 0; i < numFunciones; i++) {
               System.out.println("El valor de la funcion " + funciones[i].getRepresentacion() +
                                " En el punto " + x + "es " + funciones[i].evalua(x));
            }
        }...
 
     public void listarFunciones() {
        if (numFunciones > 0) {
            System.out.println("Las funciones almacenadas son:");
            for (int i = 0; i < numFunciones; i++) {
              System.out.println(funciones[i].getRepresentacion());
            }
        }...
 

 

En el listado 9 podemos observar como la interfaz Funcion  y la clase abstracta y FuncionAbstracta  son idénticas a las que ya hemos presentado anteriormente en este artículo, sólo que esta vez están definidas dentro del paquete funciones. La clase ContenedorDeFunciones  contiene un array de objetos Funcion . Es posible añadir funciones al contenedor a través del método anhadirFuncion(Funcion f) . Podemos mostrar en la consola una representación textual de todas las funciones que contiene el contenedor empleando el método listarFunciones() . Observa que este método delega en cada una de las funciones del array para generar cada una de las cadenas de caracteres que representan las funciones. Finalmente, podemos evaluar todas las funciones del contenedor empleando el método evaluarFunciones(float x) . Nuevamente, este método delega en cada una de las funciones tanto para obtener una representación textual de ellas como para obtener el valor de la función en el punto que se especifica.

 

Lo bonito del código del paquete funciones  es que no hace absolutamente ninguna suposición sobre qué tipo de funciones (polinomios, exponenciales, senos, etc.) está tratando. El paquete es capaz de gestionar cualquier función que implemente la interfaz Funcion . Y dicho paquete no depende de ningún recurso externo a él. Es decir, cualquier cambio que se produzca fuera del paquete funciones no va a afectar en absoluto a ninguna de las tres clases que hemos presentado, ya que ninguna de estas clases depende de nada que quede fuera de su paquete.

 

Esto ha sido posible empleando de modo adecuado a la abstracción: sabemos que todas las funciones tienen una representación textual. Podemos no saber cuál es la representación textual concreta de una función determinada, pero es responsabilidad de la propia función proporcionarla bajo la forma de un String . Tampoco sabemos cómo evaluar cualquier función que se le pueda ocurrir a cualquier programador. Pero sabemos que a todas las funciones de una variable se les pasa un valor del eje x y nos devuelven a cambio el valor de la función en dicho punto. No sabemos los "cómos" de estas dos operaciones. Pero sí sabemos el "qué" tienen que hacer. Y este "qué" está capturado en la interfaz Funcion . Y tanto el código de la clase abstracta FuncionAbstracta  como el de ContenedorDeFunciones  se basan en lo qué deben poder hacer las funciones (que está definido, lo repetiré una vez más, en la interfaz contenida en el paquete) pero no en cómo lo harán. El cómo es un detalle de implementación que no importa.

 

A continuación presentamos el contenido de un segundo paquete, al que denominaremos funciones.implementaciones . En ese paquete será donde nosotros coloquemos las funciones que ya hemos implementado en nuestro software. En dicho paquete, además de las clases FuncionLineal  y FuncionCuadratica  (que ya hemos presentado en el artículo) añadiremos una tercera clase, Exponencial , cuyo código mostramos en el listado 10. Esta clase representa una función exponencial de la forma a*e^ (b*x).

 
//LISTADO 10: Clase que representa una función exponencial
package funciones.implementaciones;
 
import funciones.FuncionAbstracta;
 
public class Exponencial extends FuncionAbstracta {
   float a, b;
 
    public Exponencial(float a, float b) {
        this.a = a;
        this.b = b;
    }
 
    public float evalua(float x) {
        return (float) (a * Math.exp(b * x));
    }

   public String getRepresentacion() {
        return a + "e^" + b + "x";
    }
}
 

 

Observa como en el código de la clase Exponencial  hemos tenido que importar la clase FuncionAbstracta  del paquete funciones. Observa también la sentencia package  al principio del código declarando el paquete en el cual se encuentra esta clase. Por último, resaltar una vez más que el código del paquete funciones desconoce completamente las funciones que hemos implementado en este segundo paquete. Sin embargo, es capaz de manipularlas sin ningún problema porque implementa la interfaz Funciones.

 

 

FIGURA 5: Representación que BlueJ realiza del proyecto, y del paquete funciones

 

Finalmente, vamos a crear un código cliente que use el código de ambos paquetes; el del paquete funciones para almacenar y evaluar un grupo de funciones, y el del paquete funciones.implementaciones  para usar las funciones que ya están implementadas en él y para pasárselas al contenedor de funciones del anterior paquete. Este código, que se situará en el paquete cliente , puede verse en el listado 11.

 
//LISTADO 11: Código cliente que hace uso del contenedor de funciones
package cliente;
 
import funciones.ContenedorDeFunciones;
import funciones.implementaciones.*;
 
public class Principal {
    public static void main(String[] args) {
        ContenedorDeFunciones e = new ContenedorDeFunciones();
        e.anhadirFuncion(new FuncionLineal(4, 6));
        e.anhadirFuncion(new FuncionCuadratica(3.6F, 5, 2.4F));
        e.anhadirFuncion(new FuncionLineal(5, 8.4F));
        e.anhadirFuncion(new Exponencial(4.3F, 2));
        e.anhadirFuncion(new FuncionLineal(6, 3));
        e.listarFunciones();
        e.evaluarFunciones(2);
    }
}

 

 

Observa como el código cliente tiene que importar la clase ContenedorDeFunciones  del paquete funciones para poder acceder a su funcionalidad. También tiene que importar todo el contenido del paquete funciones.implementaciones  para poder acceder a las funciones lineales, cuadráticas y exponenciales. Observa, sin embargo, que ni el paquete funciones ni el paquete funciones.implementaciones  saben de la existencia de este tercer paquete. En la figura 6 puedes ver el resultado de ejecutar el método main  de la clase Principal.

 

La flexibilidad del código de este ejemplo todavía va más allá. El paquete cliente puede definir funciones propias que implementen la interfaz Funcion , o extiendan la clase abstracta FuncionAbstracta . Estas funciones, siendo completamente ajenas al código que yo he implementado, serán manipuladas de modo correcto por él. Te dejo como deberes que tú crees alguna otra función matemática (por ejemplo, una que sea una combinación lineal de un seno y un coseno) y que modifiques el código de la clase Principal  del paquete cliente  para añadir al contenedor de funciones instancias de tu clase. No me hubiera costado más de dos minutos poner este código en el CD, pero la forma de comprobar que uno realmente entiende estas cosas es haciéndolas uno mismo. No obstante, si alguno de vosotros lo intenta y no lo consigue, que me escriba un correo y estaré encantado de echarle una mano.

 

FIGURA 6: Resultado de ejecutar el método main de la clase Principal 

 

5 Conclusiones

Con este artículo terminamos de ver el grueso de la sintaxis de Java. Nos quedan un par de detalles más, que iremos presentando según nos vayan haciendo falta a lo largo del curso. Sin embargo, un lenguaje de programación es más que una sintaxis: también son un conjunto de librerías estándar en las cuales los programadores se apoyan para construir sus programas. En el próximo número comenzaremos a ver las partes más importantes de la librería estándar de Java. Os espero todos el mes que viene.

 

Descargas

 

 

 

Cápitulos anteriores del curso:

 

 

lunes
nov302009

JavaHispano Podcast - 063 - Noticias Noviembre

domingo
nov292009

Los terminales móviles basados en Android de LG tendrán soporte para Java ME

El anuncio se ha hecho público la semana pasada; Myriad Group AG ha seleccionado a LG como partner para lanzar su nuevo terminal móvil basado en Android. La novedad de este terminales que contará con soporte para Java ME. 


Hasta la fecha, los terminales basados en Android se programan el lenguaje Java, pero las librerías que están disponibles al programador son un subconjunto de Java SE diferente de lo especificado por cualquiera de los perfiles de Java ME, mas un conjunto de librerías propietarias. Por tanto, la plataforma Android no puede considerarse como la implementación ni de Java ME ni de Java SE, ni los terminales basados en Android pueden ejecutar aplicaciones Java ME.


¿Que opinais vosotros a cerca de este movimiento?

jueves
nov262009

Carta del senado de USA a la unión Europea para apoyar la fusión Oracle - Sun

En un nuevo capítulo del culebrón en que se ha convertido la aprobación de la fusión Oracle - Sun por parte de la Unión Europea, me entero vía Fayerwayer que una gran parte del senado de los Estados Unidos, ha enviado una carta a la Unión Europea para conminarla a aprobar la unión.

Este movimiento está lidereado por el senador republicano Orin Hatch y por el senador demócrata y ex pre candidato a la presidencia John Kerry.

Traduce FayerWayer: " El atraso reiterado de la decisión de aprobación amenaza miles de empleos americanos (sic) por lo que nos sentimos empujados a pedir una resolución rápida. La Unión Europea tiene el derecho soberano a regular quien puede operar en su mercado, pero nuestro Departamento de Justicia ya demostró que la fusión no amenaza la libre competencia, por lo que es justo preguntar a la UE en qué se basa para demorar su decisión."

Por su parte, el senador Hatch expresó:

"Cada vez  me preocupa más la creciente evidencia de que agencias regulatorias extranjeras están usando injustamente sus procesos de revisión para impedir los negocios de las corporaciones americanas. Esta transacción ha sido minuciosamente revisada por el Departamento de Justicia de los Estados Unidos que ha decidido no tomar acción. Por ello, espero que la Comisión Europea pueda concluir rápidamente su investigación sobre la transacción "