Ultima actualización: 22 de abril de 2010, 13:00.
Fundamentos de Programación
Estudio del uso de Java como lenguaje de la asignatura
Indice General
1.Conceptos básicos de datos y control
1.1.Tipos primitivos, asignación, declaración de variables
1.2.Operadores y conversión entre tipos
1.3.Estructuras básicas de control
1.4.Modularidad
2.Entrada/Salida
2.1.Consola
2.2.Ficheros de texto
2.3.Ficheros binarios unifornes
2.4.Ficheros binarios genéricos
2.5.Ficheros de acceso aleatorio
2.6.Directorios
3.Datos estructurados
3.1.Arrays (estáticos)
3.2.Arrays (dinámicos)
3.3.Strings (inmutables)
3.4.Strings (mutables)
3.5.Otros tipos
4.Estructuras enlazadas
5.Apéndices
5.1.Librería SimpleIO
5.2.Examen 09/10: Agenda
5.3.Caso de estudio: Agenda
5.4.Practica 09/10: Blackjack
5.5.Seminario: Blackjack como Applet
5.6.Seminario: Blackjack para teléfono movil

Introducción

 

En este documento os presento los resultados de un modesto estudio acerca de las posibilidades de usar Java como lenguaje base de la asignatura Fundamentos de Programación, y en que medida se pueden esconder aquellas características poco deseables en un primer lenguaje. Ya sabeis mi opinión de que la orientación a objeto (un pequeño subconjunto de ella) no debería ser una de esas características, pero aún así he intentado ocultarla lo más posible.

Para intentar conseguir los objetivos anteriores he definido varias clases, siendo la principal SimpleIO, que los alumnos deberían importar estaticamente en sus programas. Las otras tres clases tienen que ver con el tratamiento de ficheros y son TextFile, BinaryFile y GeneralFile.

Este documento debe considerarse como un borrador, con partes que no han sido completadas y otras que están mal escritas. Las clases que proporciono no se han probado adecuadamente. Por todo ello pido disculpas anticipadas por los errores que puedan existir, y solicito vuestra colaboración para corregirlos.

1. Conceptos básicos de datos y control

1.1. Tipos primitivos, asignación, declaración de variables

De los tipos primitivos de Java propongo usar el subconjunto formado por boolean byte char int double.

Respecto a Pascal las principales diferencias son que no existen tipos cuyo rango dependa del compilador, y que no existen subrangos ni enumerados (en realidad si, los han añadidos en la última versión, pero no me parece excesivamente importante su inclusión).

La asignación se realiza como en C, con el operador =, lo cual es muy desafortunado debido a su confusión con el operador de igualdad. Por suerte, y gracias a que Java dispone del tipo booleano y a la ausencia de conversiones automáticas a entero, en la mayoría de los casos (aunque no en todos) esta confusión provoca un error de compilación.

En Java es posible declarar variables en cualquier parte del código, no sólo al principio. También es posible declarar variables cuyo ámbito sea el de una estructura de control, no el del subprograma (se usa mucho en los bucles for: for(int i = 0; i < n; i++) {...}). Habría que decidir si esto se permite o no (yo por mi parte no veo ningún problema).

1.2. Operadores y conversión entre tipos

Las diferencias relevantes entre Pascal y Java respecto a los operadores son:

  • Sólo existe un operador de división, /, que funciona como división entera (cociente, div en Pascal) cuando ambos operandos son enteros, y como división real cuando algún operando es real. Esto supone que si queremos dividir dos enteros y obtener el valor real debemos hacer o un casting a double en algún operando o multiplicarlo por 1.0 previamente.
  • El operador de resto, %, tambien se aplica a argumentos de tipo real.
  • El tipo char, además de representar caracteres es un tipo entero sin signo, y pueden realizarse operaciones aritmeticas sobre él.
  • La precedencia de los operadores de comparación es menor que la de los operadores lógicos, por lo que no es necesario encerrar entre paréntesis como sucede en Pascal. La expresión (a > 0) and (a < 10) se puede escribir como a > 0 && a < 10
  • Las funciones de cálculo habituales (potencia, exponencial, logaritmo, redondeo, etc.) se encuentran asociadas a la clase estática Math. Para usarlas se debería usar dot notation (x = r*Math.sin(2*Math.PI*ang)), aunque es posible importar estaticamente la clase Math (añadiendo la linea import static java.lang.Math.*;) al principio del programa.

Como un argumento extra a favor de usar dot notation, en cualquier entorno avanzado como por ejemplo NetBeans, al escribir el punto se muestra una lista de los métodos disponibles junto con una descripción de su uso:

La principal diferencia entre C y Java es la no existencia (al igual que en Pascal) de conversiones numéricas automáticas en las que se pueda perder información.

  • Conversión de real a entero: Para conversión con truncamiento, lo normal es un typecasting (int i = (int) 3.4). Para conversión con redondeo, existe la función round de la clase Math, pero es necesario también un typecasting ya que ésta función devuelve un entero largo.
  • Conversión de número a string: Todos los tipos y clases de Java se convierten automáticamente en string si aparecen en cualquier expresión que requiera un valor de tipo String. Por lo tanto, una tecnica de uso general en Java es emplear el operador de concatenación de cadenas (+) para efectuar automáticamente la conversión:
    int i = 5;
    double d = 3.14;
    String cad1,cad2,cad3;
    cad1 = i;     // * ERROR DE COMPILACION: NO ES UNA EXPRESION.
    cad2 = ""+i;  // Correcta
    cad3 = "i = "+i+", d = "+d; // Correcta
    Si no se desea enseñar esta técnica he definido en la clase SimpleIO las funciones int2Cad y double2Cad.
  • Conversión de string a número: He definido en la clase SimpleIO las funciones cad2Int y cad2Double.
  • Conversión con formato a string: He definido en la clase SimpleIO la función strFmt. Para una explicación detallada consultar el apartado de Entrada/Salida, ya que es equivalente a writeFmt.

Si el uso de typecasting no parece adecuado, se pueden definir sin problemas nuevas funciones estáticas en la clase SimpleIO para llevar a cabo esas operaciones.

Utilidades definidas en SimpleIO:

Conversión
chartoUpperCase(char letra)Conversión a mayúsculas
chartoLowerCase(char letra)Conversión a minúsculas
StringtoUpperCase(char letra)Conversión a mayúsculas
StringtoLowerCase(char letra)Conversión a minúsculas
intcad2Int(String cad)Conversión de cadena a entero
doublecad2Double(String cad)Conversión de cadena a real
Stringint2Cad(int valor)Traducción de entero a cadena
Stringdouble2Cad(double valor)Traducción de real a cadena
StringstrFmt(String cad, Object... args)
Importación estática de la clase Math
doublerandom()Genera valor aleatorio entre 0 y 1.
.........

1.3. Estructuras básicas de control

Las estructuras basicas de control de Java son las mismas que las de C, y respecto a Pascal la única diferencia reseñable reside en la distinta filosofía de la alternativa múltiple (case vs. switch).

Un punto que merece consideración es el de las directivas break, continue y return usadas para terminar abruptamente la ejecución de un bucle. Hace años pensaba que, siguiendo las directrices de la programación estructurada, no se debian enseñar a los alumnos (Pascal también dispone de ellas), pero ultimamente me estoy decantando por la posibilidad de legalizar su uso, ya que realmente permiten escribir un código más claro y cercano al algoritmo que se desea implementar. Voy a poner como ejemplo un clásico, el cálculo de si un número es o no primo:

boolean esPrimo(int n) {
  boolean flag;
  int d;
  if(n < 1) {
    flag = false;
  } else if(n < 3) {
    flag = true;
  } else {
    d = 2;
    while(d < n && n%d != 0)
      d++;
    flag = d >= n
  }
  return(flag);
}
boolean esPrimo(int n) {
  boolean flag;
  int d;
  if(n < 1) {
    flag = false;
  } else {
    flag = true;
    for(d = 2; d < n; d++)
      if(n%d == 0) 
        flag = false;
  }
  return(flag);
}
boolean esPrimo(int n) {
  boolean flag;
  int d;
  if(n < 1) {
    flag = false;
  } else if(n < 3) {
    flag = true;
  } else {
    for(d = 2; d < n; d++)
      if(n%d == 0) break;
    flag = d >= n
  }
  return(flag);
}
boolean esPrimo(int n) {
  if(n < 1) return(false);
  for(int d = 2; d < n; d++)
    if(n%d == 0) return(false);
  return(true);
}

Dado el problema de detectar si un número es primo, considerando falso valores nulos o negativos, primos el 1 (criterio de los físicos) y el 2, y usando la definición clásica (no divisible por 2..n-1), muestro arriba cuatro posibles implementaciones (he llamado flag a la variable logica porque así lo hacen muchos alumnos).

  • La primera es la que enseñamos actualmente: Bucle con condición compuesta, y comprobación posterior del resultado. Problemas: La comprobación final no es inmediata (si es primo, d termina valiendo n, por lo que el test n%d != 0 ya no es significativo). Este tipo de comprobación provoca también que el 1 y el 2 sean casos especiales, de ahí la necesidad de crear una escalera de ifs. Por otro lado, si se cambian ligeramente las condiciones del problema para detenerse al superar la raiz cuadrada de n, la comprobación final pasaría a ser n%d != 0. Todas estas complicaciones provocan confusión en el alumno, ya que el problema en realidad es sencillo y no parece que la solución debiera ser tan compleja.
  • La segunda es la que suelen implementar los alumnos: Recorrer todos los divisores aunque ya se sepa que es un número compuesto. Esto elimina los casos especiales del 1 y el 2, la comprobación final, y simplifica la condición del bucle. Sin embargo, cuestiones de optimización aparte, no me parece una enseñanza correcta el seguir dentro de un bucle cuando no es necesario.
  • La tercera muestra el uso de break para salirse del bucle. Debido a la comprobación final siguen siendo casos especiales el 1 y el 2.
  • La cuarta es la más aberrante desde el punto de vista de la programación estructurada y sin embargo la más fácil de escribir, entender y modificar: Cuando se detectan los casos especiales se termina la ejecución, y cuando dentro del bucle se detecta que es compuesto también, eliminando la necesidad de comprobación final.

1.4. Modularidad

En este punto es donde existen divergencias importantes entre Pascal y Java:

  • No existen procedimientos: En Java (al igual que en C) sólo existen funciones. Si no devuelven valor debe indicarse con el tipo nulo void.
  • No existe paso por variable, sólo por valor: Para tipos primitivos esto significa que sólo se puede computar un valor por función (se examinará esta situación más adelante). En C es posible simular el paso por variable mediante punteros y operadores de referencia y dereferencia. En Java no.
  • Los tipos no primitivos (y esto incluye arrays) se pasan por referencia: Las variables de tipo no primitivo almacenan referencias, no valores. Esto tiene como consecuencia que si se pasa por ejemplo un array a una función, es posible (y legal) modificarlo en la función y esos cambios se mantienen tras la llamada a la función (hay que señalar que en C la situación es la misma).
  • .

Es legítimo preguntarse si la ausencia del paso por variable, en el caso de tipos primitivos, supone una limitación seria cuando se quieran computar varios valores en una misma función. Las técnicas habituales para enfrentarse a este problema son:

  • División de operaciones: Separar los resultados en distintas funciones, una por cada resultado. Ejemplo: Problema de obtener las coordenadas rectangulares a partir de coordenadas polares:
    procedure Rect2Polar(r,a: real; var x,y: real);
    begin
      x := r*cos(a);
      y := r*sin(a);
    end;
    static double rectX(double r, double a) {
      return(r*cos(a));
    }
    
    static double rectY(double r, double a) {
      return(r*sin(a));
    }
  • Empaquetamiento de los resultados: Codificar varios resultados en uno solo. Ejemplo: Problema de la búsqueda de la posición del mínimo en un matriz bidimensional (Nota: En la versión de Java estoy usando el wrapper de SimpleIO para la longitud del array)
    const
      N = ...; { Número de filas }
      M = ...; { Número de columnas }
    type
      TMatriz = array[1..N,1..M] of integer;
      
    procedure posmin(mat: TMatriz; var fil,col: integer);
    var i,j : integer;
    begin
      fil := 1; col := 1;
      for i := 1 to N do
        for j := 1 to M do
          if mat[i,j] < mat[fil,col] then
          begin
            fil := i; col := j
          end
    end;
    static int posmin(int[][] mat) {
      int n = length(mat);
      int m = length(mat[0]);
      int fil = 1, col = 1;
      for(int i = 1; i < n; i++) 
        for(int j = 1; j < m; j++)
          if(mat[i][j] < mat[fil][col]) {
            fil = i; col = j;
          }
      return(fil*n+col);
    }
    
    public static void main(...) {
      int[][] m = {{2,3,1},{5,0,4}};
      int res = posmin(m);
      int fil = res/length(m);
      int col = res%length(m);
      ...
    }
    Off-topic: Quisiera hacer notar que la versión Java de este problema funciona para cualquier matriz de enteros, mientras que en Pascal se encuentra ligada al tipo de datos concreto. En C se necesitaría enviar las dimensiones declare la matriz (y representar una matriz es poco menos que problemático).
  • Encapsular en una clase: Si tienes que devolver un resultado complejo, o modificar los propios parámetros, la POO sugiere que se represente el resultado como una clase o bien que la función pase a ser un método que altere el estado interno de un objeto. Ejemplo: Girar respecto al origen unas coordenadas 2D:  
    type
      TCoord = record
        X,Y: real
      end;
      
    procedure girar(var C: TCoord;
                    ang: real);
    var tmp : real;
    begin
      tmp := C.x;
      C.x := C.x*cos(ang) + C.y*sin(ang);
      C.y := tmp*sin(ang) - C.y*cos(ang)
    end;
    // WARNING:
    // ORIENTACION A OBJETO INCORRECTA
    
    public class Ejemplo {
      // Clase interna "simulando"
      // ser un registro
      class Coord { double x,y; }
      
      void girar(Coord c, double ang) {
        double tmp = c.x;
        c.x = c.x*cos(ang) + c.y*sin(ang);
        c.y = tmp*sin(ang) - c.y*cos(ang);
      }
      
      ...
    }
    // Clase "de verdad"
    public class Coord {
      private double x,y;
      
      public Coord(double x,
                   double y) {
        this.x = x;
        this.y = y;
      }
      
      void girar(double ang) {
        double t = x;
        x = x*cos(ang) + y*sin(ang);
        y = t*sin(ang) - y*cos(ang);
      }
      ...
    }
    
    // Clase que utiliza a la anterior
    public class Ejemplo {
      public static void main(...) {
        Coord c = new Coord(2.0,-1.0);
        c.girar(PI/2);
        ...
      }
    }
    Disclaimer: El ejemplo central se considera una mala práctica desde el punto de vista de la orientación a objeto: Sólo la propia clase (sus métodos) pueden modificar sus atributos, cualquier cambio de estado interno de un objeto debe producirse desde el exterior mediante una llamada (petición, mensaje, en terminología OO) a sus métodos.

En la mayoría de las situaciones prácticas (me baso en la hoja de problemas de Prog. I) las dos primeras técnicas permiten sortear la ausencia de paso por variable. En el resto de casos habría que plantearse la estrategia más adecuada (yo me inclino por no plantear problemas que requieran inevitablemente el encapsulamiento).

2. Entrada/Salida

La entrada/salida en Java plantea problemas porque este lenguaje tiene entre sus objetivos fundamentales la compatibilidad entre plataformas y dispositivos. Esto tiene como consecuencia que:

  • La librería java.io.* disponga de un gran número de clases..
  • Con un nivel de abstracción muy alto (flujos de datos, ortogonalidad entre fuentes de datos y métodos de acceso, etc.)
  • Basada ampliamente en patrones (como decorador)
  • Con problemas puntuales, como la detección de fin de fichero (que en la práctica se resuelve capturando excepciones)

Por ejemplo, para leer un entero por teclado se necesita el siguiente código:

import java.io.*;

public class Ejemplo {

  public static void main(String[] args) {
    BufferedReadear br = new BufferedReader(new InputStreamReader(System.in));
    try {
      String lin = br.readLine();
      int n = Integer.parseInt(lin);
    } catch(IOException ex) {
      ...
    }
  }
}

Lo que puede asustar al más templado. Afortunadamente todo esto se puede encapsular de forma que (casi) toda esta complejidad quede oculta para el alumno. Para ello he creado una conjunto de funciones en la clase SimpleIO, y las clases representando tipos de ficheros: TextFile, BinaryFile y GeneralFile diseñadas con los siguientes objetivos (perfectamente cuestionables):

  • La consola es un caso especial de fichero de texto, abierto para lectura y escritura y siempre disponible. Las mismas funciones utilizadas para E/S por consola se utilizan en los ficheros de texto.
  • Se ha elegido un diseño orientado a ficheros físicos, identificados por un String. No se han hecho esfuerzos para facilitar el tratamiento de otros posibles flujos de datos, como conexiones de red.
  • Los ficheros se abren para lectura o escritura, no para ambas cosas a la vez (salvo los de acceso aleatorio).
  • Se hace distinción entre ficheros de texto y binarios.
  • Los ficheros binarios se dividen en uniformes (almacenan ristras del mismo tipo de datos) y generales (pueden almacenar valores de tipos distintos)
  • Para ficheros uniformes debería existir un único método de lectura y escritura, independiente del tipo del dato almacenado (que se debe proporcionar en la declaración del fichero). Esto se consigue mediante el uso de genericidad.
  • Para ficheros no uniformes (de texto o generales) existen sin embargo distintos métodos de lectura, uno por cada posible tipo de dato, ya que Java no dispone de paso por variable, y las funciones no se pueden sobrecargar en base al tipo de retorno.
  • Los ficheros de texto se han diseñado suponiendo un tratamiento orientado a lineas, las cuales contienen uno o varios datos (análogo al existente en Pascal). Se ha intentando que la traducción a enteros y reales sea permisiva y no requiera de separadores estrictos (como el espacio en blanco en Pascal) para que se realice correctamente.
  • El problema de la detección de fin de fichero se ha resuelto realizando una lectura adelantada interna (invisible para el usuario). Esto ha condicionado alguna de las características del diseño.
  • Por último, respecto al resbaladizo tema del control de errores se ha seguido una estrategia mixta: Los errores cuya responsabilidad es del programador (intento de escritura en fichero abierto para lectura o cerrado, por ejemplo) generan un error (que no tiene porque ser capturada por el código) que hace que el programa termine. Sin embargo los errores cuya responsabilidad es del usuario (introducir letras cuando se espera un entero, fichero no existente o bloqueado, etc.) no causan interrupción en la ejecución (en las lecturas se devuelven valores predefinidos) pero pueden ser detectados consultando a una determinada función.

Usando esta librería, ahora la lectura de un entero se realizaría con el siguiente código:

import static uva.infor.soft.SimpleIO.*;

public class Ejemplo {

  public static void main(String[] args) {
    int n = readInt();
  }
}

2.1. Consola

La clase SimpleIO define un objecto estático, Consola, de tipo TextFile, que contiene funciones para leer y escribir tipos primitivos y lineas de texto por pantalla y teclado. Para usar este objeto se debe utilizar dot-notation, pero he definido wrappers para evitar su uso si esa es la estrategia seguida. A continuación se enumeran las funciones definidas (tienen el mismo nombre en Consola que en los wrappers). Para una descripción detallada remito al JavaDoc de la clase.

Lectura de datos
booleanreadBoolean()Lee un valor booleano
charreadChar()Lee un carácter
intreadInt()Lee un valor entero
doublereadDouble()Lee un valor real
StringreadLine()Lee una linea completa
StringreadKeyword()Lee una "palabra"
Lectura omitida
voidskipChar()Descarta el siguiente carácter
voidskipLine()Pasa a la linea siguiente
Fin de linea
booleanisEndOfLine()Comprueba si se ha llegado al final de la linea
Escritura de datos
voidwrite(dato)Escribe la traducción a texto del dato
voidwriteln(dato)Igual que la anterior, pasando a siguiente linea
voidwriteFmt(cadena,argumentos)Escritura con formato
Control de errores
intstatus()Tipo de error de la última operación
StringmsgError()Mensaje explicativo del error

Detalles sobre algunas operaciones:

  • El fin de linea no "bloquea" la lectura, se considera un separador más. Es posible, sin embargo, tratar la entrada teniendolo en cuenta usando la función isEndOfLine().
  • Para ReadKeyword, una palabra es cualquier sucesión de letras o dígitos (incluyendo acentos, @ y _), o bien cualquier texto encerrado entre comillas dobles o simples.
  • Las funciones write y writeln están sobrecargadas, aceptando cualquier dato de los tipos boolean char int double y String.
  • La función writeFmt se basa en la función format de java.io.PrintStream, y es muy parecida a printf de C.
  • Se han definido unas constantes para identificar los valores que puede devolver status(): NO_ERROR, ERROR_APERTURA (en ficheros), ERROR_CONVERSION, ERROR_FIN_FICHERO y ERROR TIPOS (en ficheros binarios).

A continuación pongo un ejemplo bastante rebuscado:

import static uva.infor.soft.SimpleIO.*;

public class Ejemplo {

  public static void main(String[] args) {
    int dia,mes,año;
    double peso;
    String nombre;
    boolean zurdo;
    char sep;

    writeln("Introduzca fecha de nacimiento (dd/mm/aaaa),"+
            "nombre entre comillas, peso y si es zurdo o no:");
    dia = readInt();
    sep = readChar();
    mes = readInt();
    skipChar();
    año = readInt();
    nombre = readKeyword();
    peso = readDouble();
    // Ejemplo de comprobación
    if(status() != NO_ERROR) {
      write("Error al leer el peso: ");
      writeln(msgError());
    }
    zurdo = readBoolean();
    write("Ha usado el separador: ");
    writeln(sep);
    // Escritura formateada
    writeFmt("Hola, %s.\nNació el: %d-%d-%d\nSu peso es %.3f kg.\nEs zurdo: %b\n",
             nombre,dia,mes,año,peso,zurdo);
    // Conversión formateada a String
    String fmt = strFmt("Hola, %s. Nació el: %d-%d-%d. Su peso es %f. Es zurdo: %b",
                        nombre,dia,mes,año,peso,zurdo);
    writeln(fmt);
  }
}

Si se desea usar dot-notation basta añadir el prefijo Consola. antes de cada operación

Si ejecutais este programa vereis que el usuario puede introducir los datos separándolos por espacios, comas, retornos de linea, etc. sin problemas, y que como valor booleano acepta cualquier palabra (interpreta "yes", "true", "si", "ok" y "vale" como cierto, y cualquier otra cosa como falso).

Detalle: Java acepta carácteres unicode como parte de identificadores, por eso podemos escribir "año" como nombre de variable.

2.2. Ficheros de texto

Los ficheros de texto se representan por la clase TextFile, y se han incorporado wrappers a SimpleIO para que no sea necesario instanciarla directamente ni usar dot-notation. Además de las funciones descritas en el apartado anterior (para las que existen también wrappers en SimpleIO), existen las siguientes:

Creación, apertura y cierre de fichero
TextFileTextFile(nomfich,modo)Constructor de TextFile. Se indica la ruta y el modo de apertura.
TextFileopenTextFile(nomfich)Wrapper en SimpleIO para apertura modo lectura.
TextFilecreateTextFile(nomfich)Wrapper en SimpleIO para apertura modo escritura.
voidclose()Cierre del fichero.
voidclose(TextFile fich)Wrapper en SimpleIO para cierre de fichero.
Detección de fin de fichero
booleanisEndOfFile()Comprueba final de fichero.
booleanisEndOfFile(TextFile fich)Wrapper en SimpleIO para comprobación de fin de fichero.

Ejemplo: En el siguiente código se crea un fichero de texto con 10 lineas, cada una conteniendo tres números, y a continuación se lee el fichero mostrándolo por pantalla en el formato original.

import static uva.infor.soft.SimpleIO.*;
import uva.infor.soft.TextFile;

public class Ejemplo {

  public static void main(String[] args) {
    // Variable "fichero lógico"
    TextFile fich; 
    int i;
    
    // Apertura para escritura
    fich = createTextFile("prueba.txt");
    // Ejemplo de comprobación de errores
    if(status(fich) != NO_ERROR) {
      writeln(msgError(fich));
      return;
    }
    // Escritura de los datos
    for(i = 1; i < 11; i++)
      writeFmt(fich,"%5d %5d %5d\n",i,i*i,i*i*i);
    close(fich);

    // Apertura para lectura 
    fich = openTextFile("prueba.txt");
    // Lectura por lineas
    while(!isEndOfFile(fich)) {
      while(!isEndOfLine(fich)) {
        i = readInt(fich);
        write(i);
        write(" ");
      }
      // Salto a la linea siguiente
      skipLine(fich);
      writeln("");
    }
    close(fich);
  }
}
import static uva.infor.soft.SimpleIO.*;
import uva.infor.soft.TextFile;

public class Ejemplo {

  public static void main(String[] args) {
    // Variable "fichero lógico"
    TextFile fich; 
    int i;
    
    // Apertura para escritura
    fich = new TextFile("prueba.txt",MODO_ESCRITURA);
    // Ejemplo de comprobación de errores
    if(fich.status() != NO_ERROR) {
      writeln(fich.msgError());
      return;
    }
    // Escritura de los datos
    for(i = 1; i < 11; i++)
      fich.writeFmt("%5d %5d %5d\n",i,i*i,i*i*i);
    fich.close();

    // Apertura para lectura 
    fich = new TextFile("prueba.txt",MODO_LECTURA);
    // Lectura por lineas
    while(!fich.isEndOfFile()) {
      while(!fich.isEndOfLine()) {
        i = fich.readInt();
        Consola.write(i);
        Consola.write(" ");
      }
      // Salto a la linea siguiente
      fich.skipLine();
      Consola.writeln("");
    }
    fich.close();
  }
}

Ambos códigos hacen lo mismo. El de la izquierda está wrappeado y el de la derecha no.

2.3. Ficheros binarios uniformes

La clase BinaryFile representa a un fichero binario que almacena una secuencia de datos del mismo tipo (equivalente a la construcción en Pascal file of tipo). Para lograr un acceso uniforme ha sido necesario usar genericidad, y por lo tanto al definir una variable de este tipo se debe añadir al identificador BinaryFile, entre corchetes angulares, el tipo de datos almacenado, teniendo en cuenta que si es un tipo primitivo se debe usar la clase asociada (booleanBoolean, charCharacter, intInteger, doubleDouble). A la hora de leer y escribir datos, sin embargo, se pueden usar directamente valores primitivos gracias al autoboxing. A continuación se muestran ejemplos de la definición de un fichero de enteros, de arrays de enteros y de arrays bidimensionales de strings:

  • BinaryFile<Integer> fich
  • BinaryFile<int[]> fich
  • BinaryFile<String[][]> fich

El uso de genericidad dificulta el definir wrappers estáticos, y por lo tanto para utilizar esta clase se debe usar dot-notation. Como alternativa se pueden usar ficheros genéricos.

He definido un iterador sobre esta clase para poder usar bucles for-each sobre ella.

Funcionalidad de la clase:

Creación, apertura y cierre de fichero
BinaryFile<tipo>BinaryFile(nomfich,modo)Constructor de BinaryFile. Se indica la ruta y el modo de apertura.
voidclose()Cierre del fichero.
Detección de fin de fichero
booleanisEndOfFile()Comprueba final de fichero.
Lectura y escritura de datos
tiporead()Lee un dato del fichero (del tipo asociado)
voidwrite(tipo dato)Escribe un dato del tipo asociado en el fichero

Ejemplo: En el siguiente ejemplo se crea un fichero binario de números reales generados al azar y a continuación se abre para lectura y se calcula la media de los valores.

import static uva.infor.soft.SimpleIO.*;
import uva.infor.soft.BinaryFile;
import static java.lang.Math.*;

public class Ejemplo {

  public static void main(String[] args) {
    BinaryFile fich;
    double valor, suma;
    int n;
    
    // Creación y escritura del fichero
    fich = new BinaryFile("test.dat",MODO_ESCRITURA);
    for(n = 1; n < 101; n++)
      fich.write(random());
    fich.close();
    
    // Lectura del fichero (normal)
    fich = new BinaryFile("test.dat",MODO_LECTURA);
    n = 0; suma = 0.0;
    while(!fich.isEndOfFile()) {
      valor = fich.read();
      suma += valor;
      n++;
    }
    fich.close();
 
    writeFmt("Media = %.5f\n",suma/n);
  }
}
import static uva.infor.soft.SimpleIO.*;
import uva.infor.soft.BinaryFile;
import static java.lang.Math.*;

public class Ejemplo {

  public static void main(String[] args) {
    BinaryFile fich;
    double valor, suma;
    int n;
    
    // Creación y escritura del fichero
    fich = new BinaryFile("test.dat",MODO_ESCRITURA);
    for(n = 1; n < 101; n++)
      fich.write(random(1000));
    fich.close();
    
    // Lectura del fichero (bucle for-each)
    fich = new BinaryFile("test.dat",MODO_LECTURA);
    n = 0; suma = 0.0;
    for(double v : fich) { suma += v; n++; }
    fich.close();
    
    writeFmt("Media = %.5f",suma/n);
  }
}

En el código de la derecha se muestra el uso de un bucle for-each para recorrer el contenido del fichero. En este apartado existe otro ejemplo de uso de ficheros binarios.

Nota: La función random está definida en java.lang.Math.

2.4. Ficheros binarios genéricos

La clase GeneralFile representa a un fichero binario que almacena una secuencia de datos que pueden ser de tipos distintos. He considerado 3 posibles diseños para esta clase:

  • Crear una función de lectura distinta para cada posible tipo de datos que se prevea vaya a ser utilizado (incluyendo arrays de distintos tipos)
  • Definir funciones de lectura distintas para los tipos primitivos y una de tipo general para el resto (este es el enfoque que sigue la clase java.io.DataOutputStream)
  • Definir una única función de lectura que devuelva un valor de tipo Object el cual se convierte por typecasting al tipo deseado

Se ha elegido la segunda opción, para evitar el uso de typecasting en tipos primitivos. De momento sólo se han implementado métodos para los tipos primitivos numéricos.

Funcionalidad de la clase:

Creación, apertura y cierre de fichero
GeneralFileGeneralFile(nomfich,modo)Constructor de GeneralFile. Se indica la ruta y el modo de apertura.
GeneralFileopenGeneralFile(nomfich)Wrapper en SimpleIO para apertura modo lectura.
GeneralFilecreateGeneralFile(nomfich)Wrapper en SimpleIO para apertura modo escritura.
voidclose()Cierre del fichero.
voidclose(GeneralFile fich)Wrapper en SimpleIO para cierre de fichero.
Detección de fin de fichero
booleanisEndOfFile()Comprueba final de fichero.
booleanisEndOfFile(GeneralFile fich)Wrapper en SimpleIO para comprobación de fin de fichero.
Lectura y escritura de datos
intreadInt()Lectura de un entero.
realreadDouble()Lectura de un real.
ObjectreadOther()Lectura de un tipo no primitivo.
voidwriteInt(int valor)Escritura de un entero.
voidwriteDouble(double valor)Escritura de un real.
voidwriteOther(Object valor)Escritura de un tipo no primitivo.
Wrappers de lectura y escritura de datos
intreadInt(GeneralFile fich)Lectura de un entero.
realreadDouble(GeneralFile fich)Lectura de un real.
ObjectreadOther(GeneralFile fich)Lectura de un tipo no primitivo.
voidwriteInt(GeneralFile fich, int valor)Escritura de un entero.
voidwriteDouble(GeneralFile fich, double valor)Escritura de un real.
voidwriteOther(GeneralFile fich, Object valor)Escritura de un tipo no primitivo.

Ejemplos: En la parte izquierda el mismo problema del apartado anterior utilizando ficheros generales y wrappers (el fichero físico generado en el problema anterior sería válido para este problema). En la parte derecha un ejemplo de generación y lectura de un fichero con varios tipos de datos distintos.

import static uva.infor.soft.SimpleIO.*;
import uva.infor.soft.GeneralFile;
import static java.lang.Math.*;

public class Ejemplo {

  public static void main(String[] args) {
    GeneralFile fich;
    int i;
    double valor, suma;
 
    // Creación y apertura del fichero
    fich = createGeneralFile("datos.dat");
    for(i = 0; i < 100; i++)
      writeDouble(fich,random());
    close(fich);

    // Cálculo de la media del fichero
    fich = openGeneralFile("datos.dat");
    i = 0; suma = 0.0;
    while(!isEndOfFile(fich)) {
      valor = readInt(fich);
      suma += valor;
      i++;
    }
    close(fich);
 
    writeFmt("Media = %.5f",suma/i);
  }
}
import static uva.infor.soft.SimpleIO.*;
import uva.infor.soft.GeneralFile;
import static java.lang.Math.*;

public class Ejemplo {

  public static void main(String[] args) {
    GeneralFile fich;
    int i, j, n;
    int[][] matriz = {{1,2},{3,4},{5,6}};
    int[][] otra;
    String fecha;
          
    // Creación del fichero
    fich = createGeneralFile("multiple.dat");
    // Escritura de datos diversos
    writeOther(fich,"29/3/2010");
    n = length(matriz);
    writeInt(fich,n);
    for(i = 0; i < n; i++)
      writeOther(fich,matriz[i]);
    close(fich);

    // Lectura de los datos
    fich = openGeneralFile("multiple.dat");
    fecha = (String) readOther(fich);
    n = readInt(fich);
    otra = new int[n][];
    for(i = 0; i < n; i++)
      otra[i] = (int[]) readOther(fich);
    close(fich);
  }
}

En el ejemplo de la derecha se muestra un fichero donde se almacenan un String, un entero y las filas de una matriz de enteros (la matriz se podría haber escrito directamente), y luego se leen los datos reconstruyendo la matriz (he aprovechado para mostrar la construcción parcial de una matriz). Para tipos no primitivos es necesario typecasting.

En este apartado existe otro ejemplo de uso de ficheros generales.

2.5. Ficheros de acceso aleatorio

La clase java.io.RandomAccessFile define métodos para trabajar con ficheros de forma que se pueden realizar las siguientes acciones, no permitidas en los tipos de ficheros anteriores:

  • Leer y escribir simultaneamente en un fichero
  • Regresar al principio del fichero o a una posición registrada previamente

Esto es posible porque esta clase esta restringida a tratar con ficheros físicos, no con un flujo de datos de tipo general.

Si se decide que es conveniente que los alumnos conozcan estos tipos de accesos, sería relativamente sencillo crear una clase del estilo de GeneralFile y los wrappers apropiados en SimpleIO.

2.6. Directorios

Sería interesante el considerar si se incluye en el temario algún apartado de gestión de sistemas de archivos. Java dispone de la clase java.io.File que incluye practicamente todas las operaciones relevantes (obtener un array con el listado de archivos de un directorio, obtención de información sobre ficheros, creación, borrado y modificación de ficheros y directorios, etc.) y sería muy sencillo crear wrappers en SimpleIO para acceder a estas funciones.

3. Datos estructurados

La diferencia más importante entre Pascal/C y Java, considerando su uso en la asignatura, radica en la distinta filosofía que subyace al tratamiento de los tipos de datos "estructurados". Mientras que Pascal/C se basan en la composición explícita de representaciones internas de los datos (arrays de registros, arrays de arrays de strings, etc.), Java (como todos los lenguajes OO) usa un enfoque distinto: Los tipos "estructurados" son clases o interfaces que presentan funcionalidad, escondiendo la representación interna y relacionandose entre sí de otras formas (herencia, implementación de interfaces, etc.).

Afortunadamente Java tiene un tratamiento especial para los arrays, manteniendo la sintaxis habitual, pero siguen existiendo cambios en la forma de trabajar con tipos no primitivos, que paso a enumerar:

 

Las variables de tipo no primitivo almacenan referencias al contenido, no el propio contenido:

  • En el caso de los arrays, el "problema" puede aparecer con el operador de asignación. Cuando se ejecuta la sentencia v = w, donde v y w son variables de tipo array, no se tienen dos arrays independientes, sino uno sólo que ahora se puede acceder y modificar por ambas variables. Esto sólo tiene relevancia en el caso de Pascal, ya que C tiene el mismo comportamiento.
  • Al pasar por parámetro un array (en los strings no se da el caso ya que son inmutables) es posible cambiar su contenido, ya que se pasa una referencia (en C el comportamiento es idéntico).
  • Respecto a los strings, el problema proviene por el operador de igualdad y desigualdad: Cuando se escribe if(cad1 == cad2) {...} donde cad1 y cad2 son strings, se estan comparando referencias , no el contenido de las cadenas. Me permito indicar que lo mismo sucede en C, con la salvedad de que en Java el resto de operadores relaciones (menor, mayor, etc.) no tienen ese "problema" (Java no permite esas operaciones sobre referencias).

 

Los datos de tipo no primitivo deben crearse en tiempo de ejecución:

  • Con los strings no existe ese "problema" ya que al ser inmutables nunca se crean explicitamente, sino implicitamente (texto entre comillas) o son devueltos, ya creados, por funciones.
  • En el caso de arrays deben crearse en tiempo de ejecución, con el operador new. En el siguiente apartado indico los argumentos por los que considero que esta caracteristica no debería ocultarse a los alumnos (creando wrappers, por ejemplo).
  • En el caso de los ficheros no tiene importancia ya que todos los lenguajes usan algún tipo de intermediario (ficheros lógicos en Pascal, handles en C, etc.) para tratar con ellos y siempre se requiere alguna inicialización

 

El uso de dot-notation

  • Tanto para arrays, strings como ficheros he definido wrappers para que no sea necesario usar dot-notation en las principales operaciones que se realizan sobre ellos.
  • En el caso de los strings la situación es un poco más complicada por ser inmutables. Para ellos he tenido que definir funciones que devuelven otro string modificado de la manera descrita

 

No existencia del tipo registro

  • No existen registros en Java. El motivo es que su funcionalidad se asume y extiende por el concepto de clase.
  • Es posible usar clases internas para simular el tipo registro (ver ejemplo)..
  • ..pero es algo aberrante desde el punto de vista O.O., y no soy partidario de ello.
  • En mi opinión no sería demasiado traumatico enseñarles el concepto de clase y a crear clases sencillas y bien comportadas desde el punto de vista de la O.O. En caso contrario la alternativa sería no proporcionarles ningún equivalente.

3.1. Arrays (estáticos)

En la clase SimpleIO se ha definido la función length(array) como un wrapper de la función del mismo nombre asociada a la clase array.

Respecto a la creación de arrays en Java en tiempo de ejecución en vez de estar ya definidos con longitud fija en tiempo de compilación, considero que debería respetarse la sintaxis Java, sin crear wrappers por las razones siguientes:

  • Siempre hemos tenido problemas en Programación I con el hecho de que los arrays deban tener el tamaño ya definido en tiempo de compilación. En la mayoría de los problemas interesantes el tamaño (o su límite superior) se conoce en ejecución, y los alumnos siempre intentan trucos para alterar el tamaño en ejecución. Considero mucho más natural que el lenguaje permita realizarlo directamente. El propio Wirth dice que es la caracteristica más desafortunada de Pascal.
  • De lo anterior se sigue que no es necesario explicar el proceso de creación del array como algo relacionado con la orientación a objeto: Simplemente es una consecuencia de poder definir el tamaño en tiempo de ejecución.

Nota: Tanto en C como en Pascal/Delphi se pueden crear arrays con tamaño dado en tiempo de ejecución: En C usando punteros y reserva de memoria y en Pascal/Delphi con arrays dinámicos y setLength.

3.2. Arrays (dinámicos)

Considero arrays dinámicos aquellos que pueden alterar su tamaño en tiempo de ejecución. En Pascal/Delphi existen (no se los damos en Programación I pero si en Estructuras de Datos). En Java existen dos posibilidades para usarlos:

  • Crear otro array con el nuevo tamaño y copiar los datos del anterior con la función arraycopy de la clase System (disponible por tanto directamente en todo programa):
    int[] vec;
    vec = new int[100];
    // ... se trabaja con vec hasta que es necesario ampliarlo ...
    int[] tmp = new int[200];
    // Copia de los datos de vec a tmp
    arraycopy(vec,0,tmp,0,vec.length());
    // Asignación de referencia (el vector original se descarta)
    vec = tmp;
    // Ahora vec tiene los datos originales y 100 posiciones extra
    ...
    
  • Utilizar la clase java.util.ListArray o crear wrappers sobre ella.

3.3. Strings (inmutables)

Las diferencias básicas entre los tipos string entre Pascal/C y Java son:

  • En Java un string no es un array de caracteres (no se puede usar la notación de corchetes).
  • En Java los string son inmutables (no se puede cambiar su contenido).
  • Los operadores relacionales no tienen sentido con strings.

He definido las siguientes funciones en SimpleIO para evitar el uso de dot-notation con strings:

 

Wrappers sobre strings (inmutabilidad)
intlength(String cad)Longitud del string.
charcharAt(String cad, int i)Carácter en índice.
Stringsubstring(String cad, int i0)Obtener subcadena.
Stringsubstring(String cad, int i0, int n)Obtener subcadena.
booleanstrCompare(String cad1, String cad2)Comparación de strings.
intstrIndexOf(String cad, char ch)Búsqueda de carácter.
intstrIndexOf(String cad, int i0, char ch)Búsqueda de carácter.
intstrIndexOf(String cad, String subcad)Búsqueda de subcadena.
intstrIndexOf(String cad, int i0, String subcad)Búsqueda de subcadena.

Ejemplo: En el siguiente ejemplo se resuelve un problema del último examen de Programación I (ver enunciado, tercer problema). El objetivo del problema es detectar si dos cadenas de texto son anagramas. El algoritmo usado aquí consiste en crear vectores que indiquen el número de veces que aparece cada letra en cada cadena y comparar si son o no iguales (es mas un ejercicio de modularidad de arrays que de cadenas).

static boolean esLetra(char ch) {
  return(ch >= 'A' && ch <= 'Z');
}

static void recuento(String cad, int[] vec) {
  int i;
  char ch;
  for(i = 0; i < length(cad); i++) {
    ch = charAt(cad,i);
    if(esLetra(ch)) vec[ch-'A']++;
  }
}

static boolean vectoresIguales(int[] vec1, int[] vec2) {
  int i;
  if(length(vec1) != length(vec2)) return false;
  for(i = 0; i < length(vec1); i++)
    if(vec1[i] != vec2[i]) return false;
  return true;
}

static boolean esAnagrama(String cad1, String cad2) {
  // Número de caracteres considerados letras
  final int NUM_LETRAS = 'Z'-'A'+1;
  // Vectores de recuento
  int[] vec1 = new int[NUM_LETRAS];
  int[] vec2 = new int[NUM_LETRAS];
  // Contar apariciones de letras
  recuento(cad1,vec1);
  recuento(cad2,vec2);
  // Comprobar si son iguales
  return vectoresIguales(vec1,vec2);
}

3.4. Strings (mutables)

Si deseamos tener strings cuyo contenido se pueda cambiar, considero que existen tres estrategias:

  • Usar la clase String para cadenas inmutables y StringBuilder para cadenas mutables.
  • Usar unicamente StringBuilder (plantea el problema de que StringBuilder cad = "Hola" es una operación incorrecta: Se debe usar explicitamente el constructor. Además, el operador de concatenación no se aplica a esta clase).
  • Definir funciones que trabajen reciban el string original y devuelvan el string modificado.

He definido en la clase SimpleIO las siguientes funciones que usan la tercera estrategia:

 

Wrappers sobre strings (mutabilidad)
StringsetCharAt(String cad, int i, char ch)Cambiar carácter.
StringstrInsert(String cad, int i0, String subcad)Insertar subcadena.
StringstrInsert(String cad, int i0, char ch)Insertar subcadena.
StringstrDelete(String cad, int i0, int n)Borrar subcadena.

Ejemplo: El mismo problema del apartado anterior pero usando un algoritmo distinto: En este caso primero se eliminan los caracteres no letras de la segunda cadena, y a continuación se recorre la primera cadena, comprobando si cada letra de ella aparece en la segunda cadena (si no ya se sabe que no son anagramas) y eliminandola de la segunda cadena. Si la segunda cadena queda vacía, son anagramas.

Versión con StringBuilder, sin wrappers:

static boolean esLetra(char ch) {
  return(ch >= 'A' && ch <= 'Z');
}

static boolean esAnagrama(String cad1, String cad2) {
  // La segunda cadena se va a modificar, por lo que creamos un
  // StringBuilder para trabajar sobre él.
  StringBuilder cad = new StringBuilder(cad2);
  // 1. Eliminamos todo lo que no sea letra de cad2
  int i = 0;
  while(i < cad.length())
    if(esLetra(cad.charAt(i))) i++; else cad.deleteCharAt(i);
  // 2. Recorremos cad1 y por cada letra buscamos su aparición en
  // cad y la borramos
  for(i = 0; i < cad1.length(); i++) {
    char ch = cad1.charAt(i);
    if(esLetra(ch)) {
      int k = cad.indexOf(ch+"");
      if(k > -1) cad.deleteCharAt(k); else return(false);
    }
  }
  // 3. Comprobar si cad está vacía
  return(cad.length() == 0);
}

Versión con wrappers:

static boolean esLetra(char ch) {
  return(ch >= 'A' && ch <= 'Z');
}

static boolean esAnagrama3(String cad1, String cad2) {
  char ch;
  int i = 0;
  // 1. Eliminamos todo lo que no sea letra de cad2
  while(i < length(cad2))
    if(esLetra(charAt(cad2,i))) {
      i++;
    } else {
      cad2 = strDelete(cad2,i,1);
    }
  // 2. Recorremos cad1 y por cada letra buscamos
  // su aparición en cad2 y la borramos
  for(i = 0; i < length(cad1); i++) {
    ch = charAt(cad1,i);
    if(esLetra(ch)) {
      int k = strIndexOf(cad2,ch);
      if(k > -1) {
        cad2 = strDelete(cad2,k,1);
      } else {
        return(false);
      }
    }
  }
  // 3. Comprobar si cad2 está vacía
  return(length(cad2) == 0);
}

3.5. Otros tipos

4. Estructuras enlazadas

La forma natural de implementar el concepto de variable dinámica de Pascal es mediante la definición de clases. El problema que aparece aquí (caso identico al de los registros) es la regla OO de no acceder directamente a los atributos de un objeto. Si se enseña a crear constructores de clase (se les puede mentir y decir que son inicializadores) es relativamente facil crear ejemplos sencillos que ilustren el concepto. Por ejemplo, se puede mostrar una pila de una forma muy sencilla:

public class Ejemplo {
  
  // Clase interna
  static class Nodo {
     int val;
     Nodo sig;
     // Constructor
     public Nodo(int v, Nodo n) { val = v; sig = n; }
  }

  public static void main(String[] args) {
    Nodo pila = null;
    Nodo p;
    int i;
    // Creación de la pila
    for(i = 1; i < 11; i++) pila = new Nodo(i,pila);
    // Recorrido de valores
    p = pila;
    while(p != null) {
      writeln(p.val);
      p = p.sig;
    }
}

5. Apéndices

5.1. Librería SimpleIO

Ficheros y documentación de la librería

5.2. Examen 09/10: Agenda

En construcción

5.3. Caso de estudio: Agenda

En construcción

5.4. Practica 09/10: Blackjack

En este apartado se examinarán distintos enfoques para la resolución de la última práctica de la asignatura Programación I (ver enunciado).

En construcción

5.5. Seminario: Blackjack como Applet

En construcción

5.6. Seminario: Blackjack para teléfono movil

Reutilizando la clase Mazo y Partida del apartado anterior, es posible dar un mini-curso guiado donde se reconvierta el programa en una versión utilizable en un teléfono movil.

Descarga de ficheros:

La forma más cómoda de instalarlo es enviarlo via Bluetooth al telefono movil desde un portatil.

 

César Vaca Rodríguez

2010 Departamento de Informática, Universidad de Valladolid.