Entrada/Salida Parte 2
Entrada/Salida formateada
Hasta ahora nos hemos dedicado simplemente a escribir bytes en un flujo de datos. Aunque con únicamente estas técnicas podríamos conseguir leer y escribir cualquier cosa en un archivo, esta forma de trabajar es relativamente pesada, ya que cada vez que quisiéramos escribir, por ejemplo, un entero de 32 bits en un archivo, tendríamos que dividir los 32 bits en cuatro paquetes de 8 bits cada uno, e ir transmitiéndolos a través de flujo de datos. Para leer ese dato, el proceso sería el inverso: leer cuatro bytes del flujo y combinarlos para obtener el número de 32 bits.
Como veremos, el paquete java.io nos proporciona varias herramientas para facilitarnos este trabajo.
La clase DataOutputStream
La clase DataOutputStream, heredera indirecta de OutputStream, añade a ésta última la posibilidad de escribir datos "complejos" en un flujo de salida. Cuando hablamos de datos "complejos", en realidad nos referimos a tipo de datos primitivos, pero no restringidos únicamente a bytes y a matrices de bytes, como en el caso de OutputStream.
Mediante la clase DataOutputStream podemos escribir datos de tipo int, float, double, char, etc. Incluso podemos escribir algunos objetos, como datos de tipo String, en una gran cantidad de formatos.
La forma general de trabajar con objetos de tipo DataOutputStream será la siguiente: obtenemos un objeto OutputStream (cuyo origen puede ser cualquiera: un archivo, un socket, una matriz en memoria, la salida standard, etc) y lo "envolvemos" en un objeto DataOutputStream, de forma que podamos usar la interfaz que nos proporciona este último. Para crear este DataOutputStream, le
pasamos como parámetro el OutputStream a su constructor.
Cada vez que escribamos un datos "complejo" en un objeto DataOutputStream, éste lo traducirá a bytes individuales, y los escribirá en el OutputStream subyacente, sin que nosotros tengamos que preocuparnos de la forma en que lo hace.
Veamos algunos ejemplos:
OutputStream os = ...;
DataOutputStream dos = new DataOutputStream(os);
int a = 3;
float b = 3.56754;
double c = -456.876345;
dos.writeInt(a); // Escribimos un entero en el stream (4 bytes)
dos.writeFloat(b); // Escribimos un nº de precisión simple (4 bytes)
dos.writeDouble(c); // Escribimos un nº de precisión doble (8 bytes)
dos.close();
os.close();
Los nombres de los métodos usados son bastante descriptivos por sí mismos. En la documentación del JDK podemos ver que existen otros métodos, uno para cada tipo de dato primitivo: writeBoolean(), writeByte(), writeChar(), writeLong() y writeShort().
Si seguimos investigando en el JDK, descubrimos algunos métodos de los que aún no hemos hablado: writeBytes(), writeChars() y writeUTF(). Estos tres métodos reciben como parámetros un objeto de tipo String, y lo escriben en el OutputStream subyacente usando diferentes formatos.
writeBytes() descompone la cadena de texto en bytes individuales (obtiene su código ASCII) y los escribe en el flujo de salida. Si la cadena consta de n letras, escribe n bytes, sin añadir ningún delimitador ni de principio ni de fin de cadena.
writeChars() descompone la cadena de texto en chars individuales (obtiene su código Unicode) y los escribe en el flujo de salida. Si la cadena consta de n letras, escribe n chars, sin añadir ningún delimitador ni de principio ni de fin de cadena.
writeUTF() escribe la cadena en un formato conocido como UTF-8. Este formato incluye información sobre la longitud exacta de la cadena.
La conclusión importante que debemos extraer de estos tres últimos métodos es que solo el último nos permite recuperar la cadena con facilidad. Los otros dos métodos no incluyen información sobre la longitud de la cadena, por lo que si otro programa necesita leer los datos que nosotros hemos escrito, es necesario conocer "a priori" la longitud de la cadena. Si no, es imposible saber el número de bytes o de chars que debemos leer.
Un ejemplo de utilización:
...
String cad1 = "Me voy a convertir en bytes";
String cad2 = "Me voy a convertir en chars";
String cad3 = "Me voy a convertir en formato UTF";
dos.writeBytes(cad1);
dos.writeChars(cad2);
dos.writeUTF(cad3);
La clase DataInputStream
Escribir datos formateados no vale de nada si luego no podemos leerlos cómodamente. Para esta función disponemos de la clase DataInputStream.
La clase DataInputStream está preparada para leer datos generados por un objeto DataOutputStream. La especificación garantiza que cualquier archivo escrito por un DataOutputStream, sobre cualquier plataforma y sistema operativo, será legible correctamente por un DataInputStream, sin que nos tengamos que preocupar de si las máquinas son "little-endian" o "big-endian".
Supongamos que estamos intentando leer los datos escritos por el programa de ejemplo anterior (donde escribíamos un int, un float y un double en un flujo de salida):
InputStream is = ...;
DataInputStream dis = new DataInputStream(is);
int x;
float y;
double z;
x = dis.readInt();
y = dis.readFloat();
z = dis.readDouble();
dis.close();
is.close();
¿Qué pasa si queremos leer las cadenas de texto que hemos escrito?. Es inmediato leer la cadena escrita en formato UTF. En cambio, leer las otras dos cadenas nos va a costar más trabajo, ya que debemos leer los bytes o los chars individuales, juntarlos de forma adecuada y construir la cadena resultante.
En este caso podemos hacer trampa, ya que sabemos que las cadenas cad1 y cad2 tienen una longitud de 27 letras exactamente. En una situación normal, puede que no tengamos esta información.
int tam = 27; // Hacemos trampas...
InputStream is = ...;
DataInputStream dis = new DataInputStream(is);
byte miNuevoArray[] = new byte[tam];
dis.readFully(miNuevoArray); /* Este método es nuevo */
String cadenaConvertida = new String(miNuevoArray,0);
// Ahora tenemos que leer un montón de chars (16 bits)
// que forman el siguiente String escrito.
// Hay que hacer un bucle para ir leyendo los chars uno a uno
char otroArrayMas[] = new char[tam];
for(int n=0;n<tam;n++)
otroArrayMas[n]=dis.readChar();
String otraCadenaConvertida = new String(otroArrayMas);
// Queda leer el String en formato UTF-8
// Basta con llamar a readUTF(), ya que la longitud
// del String está indicada en el propio archivo.
String ultimaCadena = dis.readUTF();
En el ejemplo anterior hemos usado un método nuevo, DataInputStream.readFully(byte[]), que es básicamente equivalente a InputStream.read(byte[]), con la única diferencia de que no retorna hasta que hayan sido leídos exactamente el número de bytes que caben en la matriz.
Es fácil apreciar las ventajas de usar los métodos writeUTF() y readUTF().
Entrada/Salida en memoria
Existen ocasiones en que nos puede interesar acceder a cierta información en memoria como si estuviéramos leyendo desde una flujo de datos. Por ejemplo, podemos tener un objeto capaz de leer un archivo MPEG (vídeo comprimido) y mostrarlo posteriormente en pantalla. Supongamos que ese objeto está diseñado para leer los datos desde un InputStream, pero nosotros queremos que lea los datos desde una array de bytes que tenemos en memoria, cuyo contenido hemos generado nosotros de alguna forma.
Una forma de resolver el problema sería escribir el array de bytes en un archivo, y hacer que nuestro objeto reproductor de MPEG leyera el contenido.
Otra forma, más elegante y más eficiente que la anterior, sería crear un flujo que extrajera sus datos directamente desde nuestro array en memoria. Cualquier objeto que leyera datos de este flujo, en realidad estaría sacando los datos secuencialmente de nuestro array.
La clase que nos permite hacer esto es DataArrayInputStream. Este clase tiene una constructor al que se le pasa como parámetro la matriz de bytes de la que debe leer:
int tam = 20;
byte[] buffer = new byte[tam];
for(int n=0;n<tam;n++)
buffer[n] = n;
ByteArrayInputStream bais = new ByteArrayInputStream(buffer);
...
int c;
while((c=bais.read())!=-1) /* Mientras leamos algo */
System.out.println("Hemos leído el valor " + c);
...
Existe otra clase de este tipo muy útil: ByteArrayOutputStream. Mediante esta clase podemos escribir datos en un flujo, sabiendo que estos datos se almacenan internamente en una matriz de bytes. Esta matriz crece dinámicamente a medida que escribimos datos en ella.
Una vez escritos los datos, podemos acceder a la matriz de bytes mediante el método toByteArray(), que nos devuelve una copia de la matriz original.
Veamos un ejemplo:
ByteArrayOutputStream baos = new ByteArrayOutpuStream();
boolean condicion = false;
while(!condicion){
int dato = ...;
baos.write(dato);
condicion = ...;
}
byte[] bufferSalida = baos.toByteArray();
Una vez ejecutado el código anterior obtenemos una matriz de bytes con todos los datos que hemos ido introduciendo a lo largo del bucle.