Buscar
Social
Ofertas laborales ES
« Videotutorial sobre cómo crear una aplicación CRUD con JSF 2.0 | Main | Curso de programación Java IV - Abraham Otero »
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:

 

 

Reader Comments

There are no comments for this journal entry. To create a new comment, use the form below.
Comentarios deshabilitados
Comentarios deshabilitados en esta noticia.