Programación 3

Universidad de Alicante, 2023–2024

Práctica 3

Herencia de implementación

Peso relativo de está práctica en la nota de prácticas: 15%

Un sistema de entrada/salida

Proyecto base

Descarga el proyecto base para Eclipse de esta práctica, que ya contiene algunas clases y test unitarios. Impórtalo como proyecto Java en Eclipse

Introducción

En esta práctica introducimos la herencia de implementación, que hemos estudiado en la unidad 4.

En el paquete es.ua.dlsi.prog3.p3.lowlevel nos han proporcionado una librería de bajo nivel para comunicación entre dispositivos de entrada/salida no específicos (InputDevice y OutputDevice ) que se comunican a través de un canal (Channel). Sin embargo, queremos que el código cliente que nos proporcionan en el paquete de código fuente es.ua.dlsi.prog3.p3.client trabaje con objetos que representen dispositivos concretos como un teclado, una pantalla, un ratón, etc.. Para ello tenemos que diseñar e implementar las clases del paquete es.ua.dlsi.prog3.p3.highlevel, que son las que usa el código cliente para definir dispositivos de entrada/salida.

El proyecto Eclipse ya contiene algunas clases del paquete lowlevel y del paquete client mencionados, así como algunos test unitarios. Al principio tendrás errores de compilación en el paquete client, es normal, ya que depende del paquete highlevel; desaparecerán conforme vayas implementando este último.

Al realizar tu implementación, ten en cuenta los diferentes niveles de visibilidad de cada elemento (atributos y métodos) tal y como se representan en el diagrama UML. Esto es especialmente importante cuando se trabaja con herencia.

Aquí tienes el diagrama UML de estas clases (se omite el paquete es.ua.dlsi.prog3.p3.client):

Figura 1. Diagrama de clases UML. Figura 1. Diagrama de clases UML.
Figura 1. Diagrama de clases UML.

En este UML se representan sin relaciones de herencia. Debes diseñar las relaciones de herencia necesarias para que las clases del paquete es.ua.dlsi.prog3.p3.highlevel, junto a las clases InputDevice y OutputDevice del paquete es.ua.dlsi.prog3.p3.lowlevel formen en realidad parte de una única jerarquía de clases cuya raíz será, como se explica más abajo, IODevice.

La forma de comunicar dos dispositivos entre sí es crear un canal común a ambos mediante una instancia de la clase Channel:

Keyboard keyboard = new Keyboard();

LinePrinter printer = new LinePrinter();

Channel channel = new Channel(keyboard, printer);

y a partir de ahí, todo lo que enviemos al canal desde el dispositivo de entrada (keyboard) puede ser leído por el dispositivo de salida (printer):

keyboard.put((char)4);
for (char c='A'; c<'D'; c++) keyboard.put(c);

String line = printer.printLine(); // devuelve la cadena "ABCD"

Cada dispositivo de salida tiene un protocolo de comunicaciones muy sencillo que define qué se debe enviar al canal para que el dispositivo lo pueda leer. Esto se detalla más abajo, en la descripción de los dispositivos de salida del paquete es.ua.dlsi.prog3.p3.highlevel.

Paquete es.ua.dlsi.prog3.p3.lowlevel

Las clases Channel e IODevice de este paquete se dan ya implementadas. NO debes modificarlas. Mientras lees esta descripción, ve consultando el código fuente del paquete para entender mejor cómo funcionan internamente sus clases.

Como puedes ver en el diagrama UML, un Channel se crea a partir de dos dispositivos IODevice. Los dos argumentos del constructor de Channel son, por este orden, el dispositivo de entrada y el de salida. El canal se construye con un buffer donde se almacena la información enviada desde el dispositivo de entrada, que luego será leída por el dispositivo de salida. Estos dispositivos usarán los métodos input(byte) y output() de Channel para enviar y recibir datos del canal byte a byte.

Clase Channel

Disponemos de un constructor y tres métodos públicos que podemos usar para consultar el estado del canal:

  • Channel(IODevice, IODevice) Crea un canal y asocia los dispositivos pasados como parámetros a este canal. El primer argumento es el dispositivo de entrada y el segundo el de salida. Este constructor reserva memoria para un buffer del tamaño especificado por el dispositivo de salida, por lo cual siempre que nos refiramos al buffer de un dispositivo de salida, en realidad nos estamos refiriendo al buffer del canal asociado al dispositivo.

  • isFull() : indica si el canal está lleno (y por tanto no se puede escribir más en él)

  • hasData() : indica que hay bytes almacenados en el canal pendientes de leer.

  • getBufferSize() : devuelve la capacidad máxima del canal (el tamaño de su buffer), independientemente de que contenga información disponible para leer o no.

Además, Channeldispone de otros tres métodos que los dispositivos pueden usar:

  • input(byte) : envía un byte al canal. Lanzará la excepción BufferOverflowException si el buffer del canal ya está lleno.
  • output() : lee un byte del canal. Lanzará la excepción BufferUnderflowException si el buffer del canal no contiene datos para leer.
  • resetBuffer(int bufferSize) : inicializa el buffer del canal al tamaño especificado por bufferSize. Los datos que puediera contener anteriormente el buffer del canal se perderán. Lanza IllegalArgumentException si el argumento no es mayor que cero.

Clase IODevice

Los dispositivos IODevice son conectados a un canal por el constructor de Channel. Para crear un dispositivo disponemos de dos constructores: el constructor por defecto se utiliza para crear un dispositivo de entrada, mientras que el constructor sobrecargado se utiliza para crear dispositivos de salida. Este último necesita como parámetro el tamaño del buffer que deberá tener el canal mediante el cual se le enviará información al dispositivo. El constructor de Channelse encargará de consultar esta información para crear el canal.

Los métodos de IODevice que podemos utilizar para implementar los dispositivos de alto nivel son:

  • getChannel() : devuelve el canal asociado a este dispositivo. Lanza IllegalStateException si el dispositivo no tiene asociado ningún canal.
  • setChannel(Channel) : Asocia el dispositivo al canal pasado como argumento. Lanza NullPointerException si el argumento es null.
  • getBufferSize() : obtiene el tamaño del buffer del canal asociado a este dispositivo.

Clases InputDevice y OutputDevice

Estas clases, que tienes que implementar, representan, respectivamente, un dispositivo de entrada y un dispositivo de salida no específicos.

  • InputDevice

    • InputDevice() crea un nuevo dispositivo de entrada.
    • sendToChannel(byte) : enviar un byte al canal asociado.
    • put(byte[]) permite enviar un array de bytes al canal asociado.

    Tanto sendToChannel() como put() lanzan las excepciones estándar IllegalStateException si el dispositivo no tiene canal asociado y BufferOverflowException si el canal ya está lleno o se llena al ir enviando los datos.

  • OutputDevice

    • OutputDevice(int) : Crea un dispositivo de salida. El tamaño del buffer del dispositivo se indica en el parámetro del constructor.

    • receiveFromChannel() : lee un byte del canal asociado. Lanza la excepción estándar IllegalStateException si no hay canal asociado a este dispositivo y BufferUnderflowException si no hay datos en el canal.

    • get(int num_bytes) : lee como máximo num_bytes del canal asociado. Devuelve un array de bytes de tamaño igual al número de bytes leídos (que puede ser cero si el canal no contiene datos).

      • Lanza la excepción estándar IllegalStateException si el dispositivo no tiene canal asociado.
      • Si el parámetro num_bytes no es mayor que cero y menor o igual al tamaño del buffer de este dispositivo, lanza la excepción estándar IllegalArgumentException.
    • readStoredString() (Este método se da ya implementado; ver código fuente en el proyecto base). Lee una cadena de caracteres enviada al canal y la devuelve como un objeto de tipo String. El método supone que en el buffer del canal se encuentra una cadena de caracteres con el siguiente formato: el primer byte indica el número de bytes que hay a continuación en el buffer, los cuales representan caracteres ASCII imprimibles. Por ejemplo, si en el canal se encuentra la cadena “PROG3”, estará almacenada así en su buffer: | 5 | 80 | 82 | 79 | 71 | 51 |.

      • Devuelve la cadena leída, o bien la cadena vacía si no hay datos en el canal
      • Lanza la excepción estándar IllegalStateException si el dispositivo no tiene canal asociado.
      • Lanza BufferUnderflowException si el canal se vacía antes de poder leer toda la cadena, es decir, si los datos en el canal no tienen el formato correcto.

Paquete es.ua.dlsi.prog3.p3.highlevel

En este paquete necesitamos crear una jerarquía de clases que permita definir dispositivos de entrada y salida específicos, basados en InputDevice y OutputDevice. Debe ser posible utilizarlos como en el ejemplo de código cliente de la introducción, o el que puedes encontrar en el paquete es.ua.dlsi.prog3.p3.client.

Debes diseñar la jerarquía de herencia que forman las clases de este paquete junto a InputDevice y OutputDevice. Todas ellas son subclases de es.ua.dlsi.prog3.p3.lowlevel.IODevice, directa o indirectamente. Organiza las clases en base a los conceptos que representan. Los conceptos más específicos heredarán de aquellos más generales. Si tu jerarquía de clases no está bien diseñada, las pruebas del oráculo no compilarán correctamente.

IMPORTANTE: Que un método lance una excepción no implica que en él deban hacerse las comprobaciones pertinentes, puede que use otros métodos que ya las hacen.

Dispositivos de entrada

Las clases Keyboard y Mouse representan dispositivo de entrada específicos. Simulan un teclado y un ratón, respectivamente. Keyboard permite enviar caracteres uno a uno, mientras que Mouse simula enviar la posición actual del cursor de un ratón en la pantalla.

  • Keyboard :
    • Keyboard() crea un teclado para enviar caracteres.
    • put(char) envía un caracter al canal asociado. Lanza las excepciones estándar IllegalStateException si el dispositivo no tiene canal asociado y BufferOverflowException si el canal ya está lleno.
  • Mouse :
    • Mouse() crea un ratón que permite enviar coordenadas 2D de la posición del cursor en pantalla.
    • put(byte x, byte y) envía dos bytes que representan una coordenada 2D: el byte ‘x’ en primer lugar, seguido del byte ‘y’. Lanza las excepciones estándar IllegalStateException si el dispositivo no tiene canal asociado y BufferOverflowException si el canal ya está lleno o se llena tras enviar el primer byte.

Dispositivos de salida

Las clases Display y LinePrinter representan diferentes tipos de dispositivo de salida específicos. Simulan, respectivamente, una pantalla y una impresora que imprime caracteres línea a línea.

  • LinePrinter Este dispositivo espera que en su buffer se encuentre una cadena de caracteres con el siguiente formato: el primer byte indica el número de bytes que hay a continuación en el buffer, los cuales representan caracteres ASCII imprimibles (ver método OutputDevice.readStoredString()).

    • LinePrinter() crea una impresora con un tamaño de buffer indicado por la constante LinePrinter.MAX_LINE_LENGTH más uno.
    • printLine() devuelve un String que contiene la cadena leída del buffer.
      • Lanza la excepción estándar IllegalStateException si el dispositivo no tiene canal asociado.
      • Lanza la excepción de usuario verificada NoLineForPrintingException si el buffer está vacío. Esta excepción se definirá en el subpaquete es.ua.dlsi.prog3.p3.highlevel.exceptions y no recibirá ningún parámetro en su constructor.
      • Lanza la excepción estándar BufferUnderflowException si el canal se vacía antes de poder leer toda la cadena, es decir, si los datos en el canal no tienen el formato correcto.
  • Display simula una pantalla cuadrada de NxN píxeles en blanco y negro, representada por una matriz de NxN bytes. El tamaño N está almacenado en el atributo pixel_rows, que indica el número de filas (y columnas) de la matriz. Un cero en la matriz representa un píxel ‘blanco’ o no activado. Cualquier otro número representa un píxel ‘negro’ o activado. Ten en cuenta que la matriz de bytes representa el contenido de una pantalla gráfica donde el eje X es el eje horizontal, y el eje Y es el vertical, por lo que una coordenada (x,y) corresponde al pixel en la columna ‘x’ y la fila ‘y’ de la matriz. Las cuatro esquinas de una pantalla de tamaño NxN corresponden por tanto a estas coordenadas: - esquina superior izquierda: (0,0) - esquina superior derecha: (N-1,0) - esquina inferior izquierda: (0,N-1) - esquina inferior derecha: (N-1,N-1)

    • Display(int N) crea una pantalla con N filas x N columnas de píxeles. El canal asociado a este dispositivo tendrá un buffer de tamaño NxN*2, ya que se necesitan dos bytes para cada pixel y queremos que en el buffer quepan eventualmente las coordenadas de todos los píxeles.
    • getDisplaySize() : devuelve el número de filas (o columnas) de esta pantalla.
    • refresh() Este método espera encontrar en el canal coordenadas de pixeles que deben activarse en la pantalla. Por tanto debe leer éstas desde el canal mientras queden datos en él y activar los píxeles correspondientes a ellas, poniendo a un valor distinto de cero las posiciones correspondientes de la matriz display. El canal contendrá por tanto pares de bytes (x,y) que representen coordenadas para píxeles de esta pantalla. Ten en cuenta que no tiene por qué contener tantas coordenadas como pixels tiene la pantalla, puede haber menos o incluso ninguna. Si el canal no contiene datos, este método no modificará la matriz display.
      • El método retorna una copia defensiva de la propiedad display.
      • Si el dispositivo no tiene canal asociado, se lanzará la excepción estándar IllegalStateException.
      • Si al intentar leer una coordenada desde el canal, no hay suficientes bytes en el (dos al menos), se lanza la excepción estándar BufferUnderflowException.
      • Las coordenadas (x,y) leídas del buffer deben corresponder a algún pixel de la pantalla, es decir: (0 <= x < N) y (0 <= y < N). En caso contrario, se lanzará la excepción estándar IndexOutOfBoundsException con un mensaje apropiado.
    • clear() borra por completo la pantalla, desactivando todos sus píxeles.

Paquete es.ua.dlsi.prog3.p3.client

La clase InputOutputClient, que ya se da implementada, usa los dispositivos definidos en el paquete es.ua.dlsi.prog3.p3.highlevel para demostrar su funcionamiento. En concreto, conecta un Mouse con un Display, siguiendo el movimiento del ratón durante 20 segundos y dibujando el contenido del Display en una pantalla ASCII en la consola. Luego conecta un Keyboard con un LinePrinter.

Para poder ejecutar este programa, es necesario hacerlo desde un terminal y en una máquina que tenga una única pantalla (si no dará errores). Abre un terminal y sitúate en el directorio raíz del proyecto Eclipse de la práctica. Allí, ejecuta el programa así:

$ java -cp bin es.ua.dlsi.prog3.p3.client.InputOutputClient

Aquí abajo puedes ver un ejemplo de salida de este programa:

Figura 2. Ejemplo de salida de InputOutputClient.
Figura 2. Ejemplo de salida de InputOutputClient.

Y aquí hay un pequeño vídeo que muestra el funcionamiento del programa.

Pruebas unitarias

Proporcionamos pruebas unitarias en la carpeta test/ del proyecto base que comprueban el adecuado comportamiento de las clases. Es importante que entiendas qué se prueba y cómo se hace.

Documentación

Este apartado no se realizará en el control

Debes incluir en los ficheros fuente todos los comentarios necesarios en formato javadoc. Estos comentarios deben definirse para:  

  • Ficheros: debe incluir nombre y dni de los autores usando la anotación @author
  • Clases: propósito de la clase: mínimo 3 líneas
  • Operaciones/métodos: 1 línea para funciones triviales, y un mímimo de 2 líneas, parámetros de entrada, parámetros de salida y funciones dependientes para operaciones más complejas.
  • Atributos: propósito de cada uno de ellos: como mínimo 1 línea  

Puedes usar un comentario no javadoc cuando sea necesario.

No es necesario generar en HTML la documentación javadoc.

Requisitos mínimos para evaluar la práctica

  • La práctica debe poder ejecutarse sin errores de compilación.
  • Ninguna operación debe emitir ningún tipo de comentario o mensaje por salida estándar, a menos que se indique lo contrario. Evita también los mensajes por la salida de error.
  • Se debe respetar de manera estricta el formato del nombre de todas las propiedades (públicas, protegidas y privadas) de las clases, tanto en cuanto a ámbito de visibilidad como en cuanto a tipo y forma de escritura. En particular se debe respetar escrupulosamente la distinción entre atributos de clase y de instancia, así como las mayúsculas y minúsculas en los identificadores.
  • La práctica debe estar suficientemente documentada, de manera que el contenido de la documentación que se genere mediante la herramienta javadoc sea significativo.

Entrega de la práctica

La práctica se entrega en el servidor de prácticas del DLSI.

Debes subir allí un archivo comprimido con tu código fuente (sólo archivos .java). En un terminal, sitúate en el directorio ‘src’ de tu proyecto Eclipse e introduce la orden

tar czvf prog3-p3.tgz *

Esto comprimirá todo el código que hay en src/, incluyendo el de aquellas clases que ya se daban implementadas. Esto es correcto y debes entregarlo así.

Sube este fichero prog3-p3.tgz al servidor de prácticas. Sigue las instrucciones de la página para entrar como usuario y subir tu trabajo.

Esta entrega sólo sirve para que se evalue la parte de documentación y obtener el resultado del oráculo.

Evaluación 

La corrección de la práctica es automática. Esto significa que se deben respetar estrictamente los formatos de entrada y salida especificados en los enunciados, así como la interfaz pública de las clases, tanto en la signatura de los métodos (nombre del método, número, tipo y orden de los argumentos de entrada y el tipo devuelto) como en el funcionamiento de éstos. Así, por ejemplo, el método Clase(int,int) debe tener exactamente dos argumentos de tipo int.

Tienes más información sobre el sistema de evaluación de prácticas en la ficha de la asignatura.

Además de la corrección automática, se va a utilizar una aplicación detectora de plagios.

Se indica a continuación la normativa aplicable de la Escuela Politécnica Superior de la Universidad de Alicante en caso de plagio:


“Los trabajos teórico/prácticos realizados han de ser originales. La detección de copia o plagio supondrá la calificación de”0” en la prueba correspondiente. Se informará la dirección de Departamento y de la EPS sobre esta incidencia. La reiteración en la conducta en esta u otra asignatura conllevará la notificación al vicerrectorado correspondiente de las faltas cometidas para que estudien el caso y sancionen según la legislación vigente”.


Aclaraciones

  • Aunque no se recomienda, se pueden añadir los atributos y métodos privados que se considere oportuno a las clases. No obstante, eso no exime de implementar TODOS los métodos presentes en el enunciado, ni de asegurarse de que funcionan tal y como se espera, incluso si no se utilizan nunca en la implementación de la práctica. 
  • Cualquier aclaración adicional aparecerá en este enunciado.

  • Te aconsejamos que implementes las clases en el orden que aparecen en este enunciado.
  • Cuando se envían datos al canal de tipo int, char… hay que hacer casting a (byte).
  • Conforme vayas implementando clases deberías ir probando que los test unitarios de cada una funcionan.
  • Recuerda usar el análisis de cobertura de Eclipse para detectar aquellas partes de tu código que los test no están comprobando.
  • Display, las coordenadas (x,y) que se leen del canal indican eje horizontal y eje vertical de la ‘pantalla’, respectivamente, es decir, con una coordenada (x,y) leída del canal, nos estamos refiriendo a las componentes de la matriz display así: display[y][x].
  • Dado que el objetivo de la práctica es aprender a usar la herencia, a la hora de implementar subclases debes aprovechar el comportamiento, tanto normal como de gestión de errores, que ya se encuentra implementado en las superclases. Si implementas correctamente la herencia entre clases, podrás implementar muchos de los métodos de las subclases en una o dos líneas.