Curso Practico en Java de Ingenieria Del Software Mit

251
Clase 1: Introducción 1.1 Sobre el curso 6.170 El presente curso engloba tres cursos en uno: Un curso intensivo de programación orientada a objetos. Un curso de diseño de software en el medio. Un curso sobre construcción de software en equipo. Énfasis en el diseño. El curso incluye conocimientos de programación, por tratarse de un requisito esencial; así como la realización de un proyecto, ya que la única forma realmente eficaz de aprender una idea es llevándola a la práctica. En este curso, usted aprenderá: Cómo diseñar software: mecanismos robustos de abstracción, patrones de diseño que han demostrado su eficacia en la práctica y métodos de representación de diseños que permitan comunicarlos y hacer críticas. Cómo implementar en Java Cómo diseñar e implementar adecuadamente para crear un software fiable y flexible. También aprenderá, sin recurrir a parches: A trabajar con la arquitectura del sistema, y no simplemente a escribir código de bajo nivel. Cómo no perder tiempo depurando un programa. 1.2 Administración y principios Presentación del personal del curso: Profesores: Daniel Jackson y Rob Miller. Ayudantes técnicos (TAs): los conocerá la próxima semana en las sesiones de revisión. Monitores de prácticas (LAs): los conocerá en los grupos de prácticas. Horario: consulte la página web. Los profesores no tienen horario fijo de consulta, pero se mostrarán encantados de poder atender a los estudiantes: tan sólo tendrá que enviarles un correo o pasarse por su despacho. Materiales: Libro de texto de Liskov; léalo siguiendo el programa del boletín de información general. Material de clase: normalmente se publica el mismo día de la clase. Se recomienda el libro de patrones de diseño Gang of Four. Otro libro recomendado es “Effective Java”, de Bloch. Tutorial de Java: consulte el boletín de información general para más información. 1

Transcript of Curso Practico en Java de Ingenieria Del Software Mit

Page 1: Curso Practico en Java de Ingenieria Del Software Mit

Clase 1: Introducción 1.1 Sobre el curso 6.170 El presente curso engloba tres cursos en uno:

• Un curso intensivo de programación orientada a objetos. • Un curso de diseño de software en el medio. • Un curso sobre construcción de software en equipo.

Énfasis en el diseño. El curso incluye conocimientos de programación, por tratarse de un requisito esencial; así como la realización de un proyecto, ya que la única forma realmente eficaz de aprender una idea es llevándola a la práctica.

En este curso, usted aprenderá: • Cómo diseñar software: mecanismos robustos de abstracción, patrones de diseño que

han demostrado su eficacia en la práctica y métodos de representación de diseños que permitan comunicarlos y hacer críticas.

• Cómo implementar en Java • Cómo diseñar e implementar adecuadamente para crear un software fiable y flexible.

También aprenderá, sin recurrir a parches:

• A trabajar con la arquitectura del sistema, y no simplemente a escribir código de bajo nivel.

• Cómo no perder tiempo depurando un programa. 1.2 Administración y principios Presentación del personal del curso:

• Profesores: Daniel Jackson y Rob Miller. • Ayudantes técnicos (TAs): los conocerá la próxima semana en las sesiones de

revisión. • Monitores de prácticas (LAs): los conocerá en los grupos de prácticas. • Horario: consulte la página web. Los profesores no tienen horario fijo de consulta,

pero se mostrarán encantados de poder atender a los estudiantes: tan sólo tendrá que enviarles un correo o pasarse por su despacho.

Materiales:

• Libro de texto de Liskov; léalo siguiendo el programa del boletín de información general.

• Material de clase: normalmente se publica el mismo día de la clase. • Se recomienda el libro de patrones de diseño Gang of Four. • Otro libro recomendado es “Effective Java”, de Bloch. • Tutorial de Java: consulte el boletín de información general para más información.

1

Page 2: Curso Practico en Java de Ingenieria Del Software Mit

Los libros de texto recomendados son excelentes; le proporcionarán buenas referencias y le ayudarán a convertirse rápidamente en un buen programador en breve. Si compra la oferta le harán un gran descuento. Adquiriendo el paquete completo obtendrá un interesante descuento. Organización del curso:

• Primera mitad del trimestre: clases, ejercicios semanales, revisiones, prueba. • Segunda mitad del trimestre: proyecto en equipo. Se dará más información sobre el

mismo más adelante. Hay diferencias en relación con trimestres anteriores: no tiene que preocuparse ahora de quién formará parte de su equipo. Prepárese para un cambio de ayudante técnico a mediados del trimestre. Revisiones:

• En las sesiones semanales con los ayudantes técnicos, se revisará el trabajo de los estudiantes.

• Al comienzo del curso, los ayudantes técnicos le pedirán fragmentos de su trabajo que tomarán como punto de referencia para la revisión.

• El grupo al completo debatirá los temas de manera constructiva y con la colaboración participación de todos.

• Un aspecto esencial del curso es que le ofrece la oportunidad de observar la aplicación práctica de los conceptos tratados en clase.

Iniciación a Java:

• El aprendizaje de Java lo realiza cada estudiante por sí mismo, pero cuenta para ello con nuestra ayuda.

• Utilice el tutorial de Java de Sun y haga los ejercicios. • Tendrá a su disposición un amplio equipo de ayudantes de prácticas dispuestos a

resolver sus dudas. Colaboración y política sobre protocolos de Internet:

• Consulte la información general. • En resumen: puede intercambiar ideas con sus compañeros, pero los trabajos escritos,

tales como especificaciones, diseños, código, pruebas, explicaciones, etc., debe realizarlos usted personalmente.

• Puede utilizar código de dominio público. • En el proyecto de equipo, puede colaborar en todo.

Pruebas:

• Dos pruebas centradas en el material explicado en las clases. Calificaciones:

• El 70% lo constituirá el trabajo individual = el 25% las pruebas y el 45% de los boletines de problemas.

• El proyecto final valdrá el 30% restante, puntuándose por igual a los miembros de un mismo grupo de trabajo.

• La participación en clase supondrá una puntuación adicional del 10%. • no se aceptará ningún trabajo entregado fuera de plazo.

1.3 ¿Por qué es importante la ingeniería de software?

2

Page 3: Curso Practico en Java de Ingenieria Del Software Mit

Aportación del software a la economía de EE.UU (datos del año 1996):

• Principal fuente de superávit por exportaciones en la balanza comercial. • 24.000 millones de dólares de ingresos por exportaciones de software y 4.000 millones

gastados en importaciones, arrojan un superávit anual de 20.000 millones de dólares. • Datos comparativos (también en millones de dólares): agricultura, 26-14-12; industria

aeroespacial, 11-3-8; industria química, 26-19-7; industria automovilística, 21-43-(22); productos manufacturados, 200-265-(64).

(Datos tomados de: Software Conspiracy, Mark Minasi, McGraw Hill, 2000).

Papel del software en la infraestructura: • no sólo tiene un papel importante en Internet; • también en sectores como transportes, energía, medicina y finanzas.

El software se halla cada vez más presente como elemento incorporado a otros mecanismos arraigados. Los automóviles modernos, por ejemplo, poseen entre 10 y 100 procesadores para dirigir todo tipo de funciones, desde el reproductor de música hasta el sistema de frenado. El coste del software:

• La relación entre la adquisición de software y hardware se aproxima a cero. • Coste total de la propiedad del software: 5 veces el coste del hardware. El grupo

Gartner calcula que el coste de mantenimiento de un PC durante 5 años asciende en la actualidad a 7 dólares por cada K de memoria del ordenador).

¿Qué calidad presenta nuestro software?

• Fallos en el desarrollo. • Accidentes. • Software de baja calidad.

1.3.1 Fallos en el desarrollo Estudio llevado a cabo por IBM en el año 1994:

• El 55% de los sistemas costaron más de lo previsto. • El 68% excedieron el tiempo previsto para su desarrollo. • El 88% se tuvo que volver a diseñar por completo.

Sistema de Automatización Avanzada (FAA, 1982-1994) • El promedio de producción industrial era de 100 dólares/línea, y se preveía pagar 500

dólares/línea. • Se terminó por pagar 700-900 dólares/línea. • Trabajos cancelados por valor de 6.000 millones de dólares.

Departamento de Estadística Laboral (1997) • 2 de cada seis nuevos sistemas que se ponen en funcionamiento sufren cancelaciones. • Los grandes sistemas tienen aproximadamente un 50% de probabilidad de ser

cancelados. • La media de tiempo empleado en un proyecto se excede en un 50% con respecto al

plazo previsto.

3

Page 4: Curso Practico en Java de Ingenieria Del Software Mit

• las ¾ partes de los sistemas se consideran “fracasos operativos”. 1.3.2 Accidentes La mayor parte de los expertos coinciden en señalar que “la manera más probable de destruir el mundo es por accidente”. Y aquí es donde entramos en juego nosotros, los profesionales de la informática: “nosotros somos los que provocamos los accidentes". Nathaniel Borenstein, creador de MIME en: Programming as if People Mattered: Friendly Programs, Software Engineering and Other Noble Delusions, Princeton University Press, Princeton, NJ, 1991. Caso Therac-25 (1985-87)

• Se trataba de un aparato de radioterapia dotado de controlador de software. • Se retiró el interbloqueo del hardware, pero el software no tenía dispositivo de

interbloqueo. • El software falló al mantener las constantes vitales: un flujo de electrones o bien un

flujo más intenso de radiación mediante placa para generar rayos X. • A consecuencia de ello se produjeron varias muertes por quemaduras. • El programador no tenía experiencia en programación concurrente. • Véase: http://sunnyday.mit.edu/therac-25.html

Cabría pensar que se aprendería de la experiencia y que un desastre de este tipo no volvería a suceder jamás. Sin embargo...

• La Agencia Internacional de Energía Atómica anunció una “emergencia radiológica” el 22 de Mayo del 2001 en Panamá.

• 28 pacientes sufrieron sobreexposición: 8 murieron, 3 de ellos como consecuencia directa de la sobreexposición; con la probabilidad de que ¾ de los 20 que sobrevivieron desarrollaran “serias complicaciones que en algunos casos, a la larga, podrían resultar mortales”

• Los expertos anunciaron que el equipo de radioterapia “funcionaba perfectamente”; la razón de la emergencia tuvo que ver con la entrada de datos.

• Si los datos se introdujeron en varios bloques protegidos dentro de un lote, la dosis se computó de manera incorrecta.

• Al menos, la FDA llegó a la conclusión de que “la interpretación de los datos del bloqueo de flujo por el software” fue uno de los factores causantes del desastre.

• Visite la web: http://www.fda.gov/cdrh/ocd/panamaradexp.html

Ariane-5 (Junio de 1996) • Agencia Espacial Europea. • Pérdida absoluta de misiles no tripulados poco después del despegue. • Causada por una excepción en el código de Ada. • Ni siquiera se precisó el código defectuoso después del despegue. • Debido a un cambio en el entorno físico: se infringieron supuestos no documentados. • Visite la web: http://www.esa.int/htdocs/tidc/Press/Press96/ariane5rep.html

En los desastres provocados por fallos de software, son más comunes los accidentes como el del Ariane, que los causados por aparatos de radioterapia. No es muy probable que los errores en el código sean la causa; normalmente, el problema se remonta al análisis de las

4

Page 5: Curso Practico en Java de Ingenieria Del Software Mit

necesidades; en este caso, a un error al articular y evaluar presunciones claves sobre el entorno. Servicio de Ambulancias de Londres (1992)

• Pérdida de llamadas, doble servicio por llamadas duplicadas. • Mala selección del programador: experiencia insuficiente. • Visite la web: http://www.cs.ucl.ac.uk/staff/A.Finkelstein/las.html

El desastre del Servicio de Ambulancias de Londres se debió en realidad a fallos de gestión. Los informáticos que desarrollaron el software pecaron de ingenuidad y aceptaron una oferta de una empresa desconocida que era bastante peor que las de otras compañías más acreditadas. Cometieron el terrible error de saltar a la red repentinamente, sin pararse a contrastar la ejecución del sistema nuevo y la del ya existente. A corto plazo, estos problemas se acentuarán debido al uso generalizado del software en nuestra infraestructura cívica. En el informe PITAC se hacía eco de esta realidad, y los argumentos que en él se exponían han servido para incrementar los fondos destinados a la investigación en software: "La demanda de software ha crecido mucho más rápido que nuestra capacidad para crearlo. Además, el país requiere un tipo de software más práctico, fiable y robusto que el que se está desarrollando hoy en día. Nos hemos hecho peligrosamente dependientes de los grandes sistemas de software, cuyo comportamiento no es del todo comprensible, y que a menudo fallan de forma imprevista". Investigación en tecnología de la información: Invirtiendo en nuestro futuro Comité Consultor en Tecnología de la Información del Presidente (PITAC) Informe al Presidente, 24 de febrero de 1999 Información disponible en http://www.ccic.gov/ac/report/ Foro de RIESGOS

• coteja informes de prensa sobre incidentes relacionados con la informática. • http://www.catless.ncl.ac.uk

1.3.3 Calidad del Software Sistema de medición de la calidad: errores/kloc

• La medición se realiza después de la entrega del software. • La media en la industria es de aproximadamente 10. • De alta calidad: 1 o menos.

Sistema Praxis CDIS (1993)

• Sistema de control de tráfico aéreo desde terminales, empleado en el Reino Unido. • Utilizaba un lenguaje de especificación concreto, muy parecido al de los modelos de

objeto que aprenderemos en el curso. • Sin aumento del coste de la red. • Tasa de error mucho menor: aproximadamente 0’75 fallos/kloc. • ¡Incluso se ofrecía garantía al cliente!

5

Page 6: Curso Practico en Java de Ingenieria Del Software Mit

Por supuesto, la calidad de un software no se mide únicamente por los errores. Podemos probar un software y depurarlo, eliminando la mayoría de los errores que pueden hacer que falle, pero al final nos encontraremos ante un programa que es imposible utilizar, y que la mayoría de las veces no logra hacer lo que espera porque presenta muchos casos especiales. Para solucionar este problema, es preciso crear calidad desde el principio. 1.4 Importancia del diseño “¿Sabe usted lo que hace falta para poder producir software de buena calidad? En los Estados Unidos, la calidad de los automóviles mejoró cuando Japón nos mostró otros métodos más eficaces de fabricación. Alguien tendrá que enseñar a la industria del software que éste también puede desarrollarse de un modo más eficiente.” John Murria, experto en control de calidad del software de FDA, citado en Software Conspiracy, Mark Minasi, McGraw Hill, 2000. ¡ Este es probablemente también su caso! El objetivo que perseguimos en el curso 6.170 es mostrarle que el código o la programación avanzada no lo es todo a la hora de crear software. De hecho, supone sólo una pequeña parte. No piense en el código como parte de la solución; normalmente forma parte del problema. Necesitamos palabras distintas a código para referirnos al software, palabras que sean menos aparatosas, más directas y menos vinculadas a la tecnología, ya que en breve se quedarán obsoletas. Función del diseño y de los diseñadores:

• Pensar a largo plazo nunca viene mal (¡y es barato!). • No se puede añadir calidad al final del proceso: hay que contrastar confiando en el

testeo; resulta más efectivo y mucho menos costoso. • Hacer posible la delegación de tareas y el trabajo en equipo. • Un diseño defectuoso perjudica al usuario: software difícil de utilizar, incoherente y

poco flexible. • Un diseño defectuoso también afecta al programador: interfaces pobres, proliferación

de errores y dificultades para añadir nueva funcionalidad. No deja de ser curioso que los estudiantes de informática suelan resistirse a considerar la creación de software como una labor de ingeniería. Quizás piensen que las técnicas de ingeniería le restarán lo místico a su trabajo o no se adecuarán a su don de innatos mañosos Quizás piensen que la aplicación de técnicas de ingeniería hace su trabajo menos excitante, o que éstas no se adecúan a sus dones innatos para la programación. Por otro lado, la técnicas que aprendas en el curso 6.170 permitirán que su talento goce de mayor eficacia. (Por el contrario, las técnicas que veremos en este curso ayudan al estudiante a poner en práctica sus condiciones naturales de un modo mucho más eficaz?). Incluso los programadores profesionales se engañan a sí mismos. Durante un experimento, 32 informáticos de la NASA pusieron en práctica 3 técnicas distintas para probar algunos programas de pequeño tamaño. Se les pedía que evaluaran la proporción de errores que esperaban hallar en cada método. Resultó que sus intuiciones eran erróneas. Creyeron que el testeo en modalidad de caja negra sobre las especificaciones era el más efectivo, pero en realidad la modalidad de lectura de código resultó ser más eficaz (a pesar de que el código no

6

Page 7: Curso Practico en Java de Ingenieria Del Software Mit

estaba comentado). Al aplicar el método de pruebas basado en la lectura de código, ¡encontraron los errores en la mitad de tiempo! Victor R. Basili y Richard W. Selby: Comparing the Effectiveness of Software Testing Strategies. IEEE Transactions on Software Engineering. Vol. SE-13, No. 12, diciembre de 1987, págs. 1278 – 1296. El diseño tiene mucha importancia para el software de infraestructura (como el de control de tráfico aéreo). Incluso en estos casos, muchos mandos altos y medios del sector parecen no darse cuenta de cuánta influencia puede tener lo que enseñamos en el curso 6.170. Échele un vistazo al artículo que John Chapin (un antiguo profesor del curso) y yo escribimos, en el cual explicamos como rediseñamos un componente de CTAS, un nuevo sistema de control de tráfico aéreo, a partir de conceptos que se exponen en el curso: Daniel Jackson y John Chapin. Redesigning Air-Traffic Control: An Exercise in Software Design. IEEE Software, mayo/junio 2000. Disponible en http:sdg.lcs.mit.edu/ dnj/publications. 1.4.1 La historia de Netscape Existe un mito acerca del software de los ordenadores personales, según el cual el diseño carece de importancia, ya que lo único que importa en realidad es el tiempo que se tarda en lanzar el producto al mercado. En este sentido, la desaparición de Netscape es una historia sobre la que merece la pena reflexionar. El originario equipo Mosaic del NCSA (National Center for Supercomputing Applications) de la Universidad de Illinois creó el primer navegador de uso generalizado, pero su trabajo fue rápido y ciertamente mejorable. El equipo fundó Netscape, y entre abril y diciembre del año 1994 se creó Navigator 1.0. Se ejecutó sobre 3 plataformas, y pronto se convirtió en el principal navegador para Windows, Unix y Mac. Microsoft empezó a desarrollar el navegador Internet Explorer 1.0 en octubre del año 1994, y lo lanzó al mercado con Windows 95 en agosto del año 1995. Durante el periodo de mayor expansión de Netscape, desde 1995 a 1997, los programadores trabajaron duro para lanzar al mercado nuevos productos con nueva funcionalidad, dedicando muy poco tiempo al diseño. La mayoría de las empresas dedicadas al negocio del empaquetado de software (aún) creen que el diseño de un producto puede ser postergado: que una vez conquistada una cuota de mercado y una serie de cualidades convicentes, se puede “volver a considerar” el código y obtener las ventajas de un diseño nítido. Netscape no fue la excepción, aún cuando sus ingenieros eran probablemente más competentes que los de la mayoría de las compañías rivales. Mientras tanto, Microsoft se había dado cuenta de la necesidad de añadir diseños más sólidos. Creó NT partiendo desde cero, y replanteó la suite de Office para utilizar aplicaciones compartidas. Se apresuró a lanzar al mercado IE (Internet Explorer) para ponerse al nivel de Netscape, pero les llevó bastante tiempo reestructurar IE 3.0. Microsoft considera ahora que este replanteamiento de IE, fue una decisión clave que les ayudó a reducir distancias con Netscape.

7

Page 8: Curso Practico en Java de Ingenieria Del Software Mit

El desarrollo de Netscape siguió avanzando. Eran 120 programadores (de 10 que había inicialmente) los que trabajaban en el desarrollo de Communicator 4.0, y habían desarrollado 3 millones de líneas de código 30 veces más que al principio. Michael Toy, director de ventas, afirmó: “Nos encontramos ante una situación verdaderamente mala... Tendríamos que haber dejado de sacar este código hace un año. Está acabado... Esto es como despertarse de golpe de un sueño... Estamos pagando el precio de las prisas.” Curiosamente, las razones que llevaron en 1997 a Netscape a pensar en un diseño modular surgieron del deseo de volver a trabajar con equipos pequeños para el desarrollo de productos. Pero si no se dispone de interfaces simples y claras resulta imposible dividir el trabajo en partes independientes unas de otras. Netscape dejó de lado el proyecto durante 2 meses para reestructurar el navegador, pero este tiempo no fue suficiente. Así que se decidió volver a empezar desde el principio con Communicator 6.0 . Pero la versión 6.0 nunca se acabó, y a sus programadores les volvieron a asignar trabajo en la versión 4.0. Mozilla, que era la versión 5.0, se encontraba disponible como versión de libre distribución, pero nadie quería trabajar en código espagueti. Al final Microsoft ganó la batalla de los navegadores, y AOL se hizo con Netscape. Por supuesto que esta no es la historia completa de cómo el navegador creado por Microsoft llegó a imponerse sobre el de Netscape. Las prácticas empresariales de Microsoft no beneficiaron a Netscape, y la independencia de la plataforma fue un tema crucial desde el principio; Navigator se ejecutó sobre Windows, Mac y Unix desde la versión 1.0, y Netscape se esforzó por mantener la máxima independencia de plataforma en su código. Incluso se pensó en crear una versión pura en Java (“Javagator”), y se crearon muchas herramientas propias en Java (porque en esa época las herramientas de Sun no estaban acabadas). Sin embargo, en 1998 Netscape arrojó la toalla. De todas formas, Communicator 4.0 aún contiene aproximadamente 1’2 millones de líneas de código en Java. He extraído esta sección de un excelente libro sobre Netscape, la empresa y sus estrategias técnicas. Puede leer la historia completa en: Michael A. Cusumano and David B. Yoffie. Competing on Internet Time: Lessons from Netscape and its Battle with Microsoft, Free Press, 1998. Lea especialmente el cápitulo 4, Estrategia de Diseño. A este respecto, tenga en cuenta que Netscape tardó más de dos años en reconocer la importancia del diseño. Así que no se extrañe si no queda convencido de sus ventajas al terminar el curso; hay cosas que sólo se adquieren mediante la experiencia. 1.5 Consejos Claves del curso

• No se quedes atrás: ¡el ritmo es rápido! • Asista a las clases: no todo el material está en los libros. • Piense por adelantado: no tenga prisa a la hora de codificar. • Concentre su atención en el diseño, más que en depurar el programa.

8

Page 9: Curso Practico en Java de Ingenieria Del Software Mit

Por mucho que se insista, siempre me parece poca la importancia que le doy a que empiece cuanto antes y a que piense por adelantado. Es obvio que no se espera que usted acabe el boletín de problemas el mismo día en que se le entregan pero, a la larga, ahorrará mucho tiempo y obtendrá resultados mucho mejores si empieza a trabajar desde el principio. En primer lugar, se beneficiará del tiempo que le haya dedicado: reflexionará sobre los problemas inconscientemente. En segundo lugar, sabrá qué otros recursos va a necesitar, y podrá hacerse con ellos con tiempo, cuando aún es fácil conseguirlos. En especial, acuda siempre que lo necesite al personal del curso --¡estamos aquí para prestarle ayuda! Tenemos programado un horario para las prácticas de laboratorio en grupos, y horas de tutorías con los ayudantes técnicos, considerando los plazos de entrega de trabajos, aunque puede contar con nuestra ayuda en cualquier momento, siempre que no sea la noche anterior a la entrega de los boletines de problemas, que es cuando todos ustedes suelen necesitar ayuda..... No se complique: “Me cansé de advertir sobre los riesgos de la ambigüedad, la complejidad y la ambición desmedida en los nuevos diseños, pero nadie escuchó mis consejos. He llegado a la conclusión de que existen dos formas de construir un diseño de software: simplificándolo hasta el punto que resulte obvio que no hay en él errores o complicándolo de tal forma que los errores que haya en él no sean obvios”. Tony Hoare, Turing Award Lecture, 1980 Refiriéndose al diseño de Ada, aunque su punto de vista se puede aplicar al diseño de programas en general. "Cómo evitar complicarse” (KISS), (keep it simple stupid).

• Evite pisar terreno resbaladizo: trate de no recurrir a soluciones para iniciados ni a estructuras de datos y algoritmos complejos.

• No emplee las propiedades más complejas de un lenguaje de programación • Muestre escepticismo ante la complejidad. • No sea excesivamente ambicioso: reconozca el “creeping featurism” (tendencia que

se basa en incorporar demasiado al programa en poco tiempo para satisfacer los requerimientos de un nuevo hardware/software) y el “síndrome del sistema sucesor” (tendencia que consiste en convertir un nuevo sistema, sucesor de otro más pequeño, en algo grandioso).

• Recuerde que es fácil crear algo complicado, pero que lo difícil consiste en desarrollar algo que resulte verdaderamente sencillo.

Regla de optimización

• No lo haga • Déjelo en manos de expertos: no lo haga aún.

(de Michael Jackson, Principles of Program Design, Academic Press, 1975). 1.6 Colofón Notas recordatorias:

• Mañana habrá clase normal, y no de repaso.

9

Page 10: Curso Practico en Java de Ingenieria Del Software Mit

• Rellene la solicitud de matrícula que se encuentra en línea antes de la medianoche de hoy.

• ¡ Iníciese en Java desde ya! • El plazo de entrega del Ejercicio 1 es el próximo martes.

Consulte esta dirección:

• http://www.170systems.com/about/our_name.html

10

Page 11: Curso Practico en Java de Ingenieria Del Software Mit

Clase 2: Desacoplamiento I

Uno de los aspectos básicos del diseño de software es cómo descomponer un programa en

partes. En esta clase introduciremos algunas nociones fundamentales que nos permitan hablar

sobre las partes y sobre el modo en que éstas se relacionan entre sí. Nos centraremos en el

análisis del problema del acoplamiento entre partes, y en mostrar métodos para simplificarlo.

En la próxima clase, veremos una serie de técnicas de Java pensadas específicamente para

soportar el desacoplamiento.

La idea clave que presentaremos hoy es la de especificación. Es erróneo pensar que las

especificaciones no son más que documentación tediosa. Por el contrario, resultan esenciales

para el desacoplamiento y, por lo tanto, para el diseño de alta calidad. Veremos que en

diseños más avanzados, las especificaciones se convierten por sí mismas en elementos de

diseño.

El libro de texto de la asignatura trata los términos usa y depende como sinónimos. En esta

clase, haremos una distinción entre los dos, y explicaremos cómo la noción depende resulta

más útil que la noción usa, que es un término más antiguo. El alumno aprenderá a construir y

analizar diagramas de dependencia; los diagramas de casos de uso se tratarán sólo de pasada.

2.1 Descomposición

Un programa se construye a partir de un conjunto de partes. El problema de la

descomposición consiste en descubrir qué partes integran ese conjunto y qué relación guardan

entre ellas.

2.11 ¿Por qué descomponer?

Dijkstra ha puesto de manifiesto que si un programa se compone de N partes, y cada una de

ellas tiene una probabilidad de exactitud de c –es decir, si existe una probabilidad de 1 – c de

1

Page 12: Curso Practico en Java de Ingenieria Del Software Mit

que el programador cometa un error –entonces la probabilidad de que toda la estructura

funcione es de cN. Si N tiene un valor alto, entonces, a menos que el valor de c se halle muy

cerca de uno, cN será un valor próximo a cero. Con este argumento, Dijkstra pretende mostrar

lo importante que es crear un programa correctamente, y que el grado de importancia es

proporcional al tamaño del programa. Si no se logra que cada parte sea prácticamente

perfecta, no se podrá esperar que el programa llegue a funcionar.

(Este razonamiento se halla en un texto ya clásico: Structured Programming, de Dahl,

Dijkstra y Hoare, Academic Press, 1972. Se trata de una argumentación atractiva y sugerente,

aunque tal vez un tanto falaz; ya que, en la práctica, la probabilidad de conseguir que todo el

programa resulte totalmente correcto es cero. Lo verdaderamente importante es asegurar que

se mantengan ciertas propiedades, limitadas pero cruciales, y puede que éstas no se hallen en

cada una de las partes. Volveremos sobre esta cuestión más adelante).

Sin embargo, el argumento de Dijkstra parece insinuar que no se debería descomponer un

programa en partes. Cuanto más pequeña sea N, mayor será la probabilidad de que el

programa funcione. Por supuesto, no hablo en serio –resulta más fácil conseguir que una parte

pequeña, y no una grande, funcione correctamente (por lo que el parámetro c no es

independiente de N). No obstante, merece la pena preguntarse qué ventajas se derivan de la

división de un programa en partes pequeñas. He aquí algunas:

• División del trabajo. Un programa no surge de la nada: tiene que construirse

gradualmente. Si se divide en partes, el proceso de construcción se agiliza, ya que ello

permite que varias personas se dediquen a trabajar en las distintas partes.

• Reusabilidad. Algunas veces es posible aislar las partes que son comunes a diferentes

programas, de modo que se puedan crear una sola vez y utilizar muchas veces.

• Análisis modular. Incluso si un programa haya sido construido por una única persona,

resulta conveniente construirlo por partes pequeñas. De este modo, cada vez que se

completa una parte puede analizarse para comprobar su exactitud (leyendo el código,

2

Page 13: Curso Practico en Java de Ingenieria Del Software Mit

testeándolo o mediante métodos más sofisticados de los cuales hablaremos más

adelante). Si funciona, otra parte podrá utilizarla sin necesidad de volver sobre ella.

Además de la satisfacción que supone poder progresar con rapidez, el análisis modular

proporciona una ventaja más sutil. Esto, aparte de dar una idea satisfactoria de

progreso, posee una ventaja más sutil. Analizar una parte que es doblemente extensa

supone el doble de esfuerzo, así que analizar un programa que está descompuesto en

partes pequeñas reduce drásticamente el coste global del análisis.

• Cambio localizado. Todo programa útil necesita de adaptaciones y ampliaciones a lo

largo de su existencia. La posibilidad de localizar un cambio en unas cuantas partes

permite que sólo haya que tener en cuenta una porción mucho más pequeña del total

del programa a la hora de llevar a cabo dicho cambio y validarlo.

En este sentido, resulta interesante el razonamiento propuesto por Herb Simon sobre por qué

las estructuras –ya sean de origen humano o naturales– tienden a construirse a partir de una

jerarquía formada por partes. Imaginemos dos relojeros; uno de los cuales fabrica relojes de

una sola vez, mediante ensamblajes únicos de grandes dimensiones, mientras que el otro

realiza combinaciones de pequeños ensamblajes que luego va uniendo. Cada vez que un

relojero interrumpe su labor (por ejemplo, para atender el teléfono), debe dejar el ensamblaje

en el que esté ocupado, lo que supone echarlo a perder. El relojero que hace los relojes de una

sola vez echa por tierra ensamblajes completos, debiendo empezar desde cero cada vez. Sin

embargo, el relojero que fabrica relojes de forma gradual no pierde el trabajo realizado en los

ensamblajes parciales ya terminados, con lo que De modo que tiende a perder menos trabajo

cada vez que se interrumpe y a fabricar relojes de manera más eficaz. ¿Cómo aplicaríamos

este razonamiento al software?

(Puede encontrar este razonamiento en el artículo de Simon titulado The Architecture of

Complexity.)

3

Page 14: Curso Practico en Java de Ingenieria Del Software Mit

2.1.2 ¿Cuáles son las partes?

¿Cuáles son las partes en las que se divide un programa? Por el momento, utilizaremos mejor

el término “parte” en vez de “módulo”, lo que nos permitirá mantenernos alejados de las

nociones específicas de un lenguaje de programación. (En la próxima clase, nos centraremos

en ver cómo la programación en Java, en particular, soporta la descomposición en partes). Por

ahora, nos basta con señalar que las partes de un programa son descripciones: de hecho, el

desarrollo de software se centra en realidad en producir, analizar y ejecutar descripciones.

Pronto veremos que las partes de un programa no son todas código ejecutable, por lo que es

conveniente que también pensemos en las especificaciones como partes.

2.1.3 Diseño descendente ("top down")

Supongamos que necesitamos una parte A y que deseamos descomponerla a su vez en otras

partes. ¿Cómo llevar a cabo correctamente esta descomposición? Gran parte de los temas que

veremos en esta asignatura giran en torno a esta cuestión. Imaginemos que descomponemos A

en B y C: debería ser posible, como mínimo, construir B y C y, a continuación, obtener A

uniendo B y C.

En la década de los años 70 existía un enfoque generalizado sobre el desarrollo del software,

llamado diseño descendente, o diseño "top down". La idea de la que parte este diseño consiste

simplemente en aplicar de manera recursiva el siguiente paso:

• Si la parte que se quiere construir se halla ya disponible (como, por ejemplo, las

instrucciones de un aparato), el proceso ya está terminado.

• Si la parte no está disponible, se divide en subpartes, que se desarrollan y combinan

entre sí.

La división en subpartes se llevaba a cabo mediante la “descomposición funcional”: se piensa

en la función que debe tener la parte y se desglosa esa función en pasos más pequeños. Si

4

Page 15: Curso Practico en Java de Ingenieria Del Software Mit

tomamos como ejemplo un navegador, que recoge los comandos del usuario, obtiene páginas

web y las muestra; podríamos dividir la parte Navegador en LeerComando, ObtenerPágina y

MostrarPágina.

La idea resultó atractiva en su momento, y tiene aún hoy en día sus defensores. Sin embargo,

se trata de un enfoque que fracasa rotundamente, por la razón siguiente: la primera

descomposición que se hace es la más decisiva, y aún así, no se descubre si se ha hecho

correctamente hasta que no se llega al nivel más bajo del árbol de descomposición. No es

posible hacer muchas evaluaciones durante el desarrollo del proceso, puesto que no se puede

testear una descomposición en dos partes que no se han implementado, y una vez que se ha

llegado al final, es demasiado tarde para actuar sobre las descomposiciones realizadas en

niveles superiores. Por lo tanto, desde el punto de vista del riesgo (es decir, tomar decisiones

sólo cuando se dispone de la información necesaria y minimizar la probabilidad de incurrir en

errores y el coste de los mismos), se trata de una estrategia totalmente inadecuada.

En la práctica, lo que normalmente ocurre es que la descomposición es imprecisa, y se

mantiene la esperanza de que las partes se vayan definiendo más claramente a medida que se

desciende por el árbol de descomposición. Es decir, que sólo se sabe qué problema es el que

se intenta resolver cuando se está ya estructurando la solución al mismo. A consecuencia de

ello, resulta que cuando se está llegando a los niveles más inferiores del árbol se hace

necesario recurrir a trucos de todo tipo para lograr que las partes encajen unas con otras y

puedan servir para la función deseada. Las partes quedan acopladas entre sí de forma muy

extensa, hasta tal punto que resulta imposible introducir la más mínima variación en una de

ellas sin cambiar las otras. Y, en el peor de los casos, las partes no encajarán en absoluto. Por

último, ninguna de las características del diseño top-down favorece la reusabilidad del código.

5

Page 16: Curso Practico en Java de Ingenieria Del Software Mit

(Puede consultar una exposición de los riesgos del diseño top-down en el artículo: Software

Requirements and Specifications: A Lexicon of Software Principles, Practices and Prejudices,

Michael Jackson, Addison Wesley, 1995.)

Esto no quiere decir, por supuesto, que examinar un sistema de forma jerárquica sea una mala

idea. Simplemente, no es posible desarrollarlo de ese modo.

2.1.4 Una estrategia mejor

Una estrategia mucho mejor consiste en desarrollar una estructura de sistema de múltiples

partes a partir de niveles similares de abstracción. Para ello se perfecciona la descripción de

cada parte de una sola vez y se analiza si las partes encajan y consiguen la funcionalidad

deseada antes de empezar a implementar cualquiera de ellas. Parece, asimismo, mucho más

conveniente organizar un sistema en torno a datos que en torno a funciones.

Quizás el factor más importante que hay que tener en cuenta a la hora de evaluar la

descomposición en partes sea el modo en que esas partes están acopladas unas con otras.

Queremos minimizar el acoplamiento –desacoplar las partes— para poder trabajar en cada

una de ellas con independencia de las demás. Este es el tema de nuestra clase de hoy; más

adelante, conforme avance la asignatura, veremos cómo podemos expresar las propiedades de

las partes y los detalles de cómo interactúan unas con otras.

2.2 Relaciones de dependencia

2.2.1 Diagrama de casos de uso

La noción más básica de relación entre partes es la relación de casos de uso. Se dice que una

parte A usa una parte B cuando A se refiere a B de tal manera que el significado de A depende

del significado de B. Cuando A y B son código ejecutable, el significado de A es su

6

Page 17: Curso Practico en Java de Ingenieria Del Software Mit

comportamiento cuando se ejecuta, de modo que A usa a B cuando el comportamiento de A

depende del comportamiento de B.

Supongamos, por ejemplo, que estamos diseñando un nuevo navegador. El diagrama muestra

una supuesta descomposición en partes:

La parte Main usa a la parte Protocol para tener acceso al protocolo HTTP, a la parte Parser

para analizar la página HTML recibida y a la parte Display para visualizarlo en pantalla.

Estas partes, a su vez, usan a otras partes. La parte Protocol usa a la parte Network para llevar

a cabo la conexión de red y para controlar la comunicación de bajo nivel, y a la parte Page

para almacenar la página HTML recibida.

Parser usa AST para crear un árbol de sintaxis abstracta (Abstract Sintax Tree) a partir de la

página HTML una estructura de datos que representa la página como una estructura lógica, en

vez de hacerlo como una secuencia de caracteres. Parser también usa a la parte Page, ya que

debe ser capaz de conseguir acceso a la secuencia de caracteres HTML sin procesar. Display

usa la parte Render para traducir en pantalla el árbol de sintaxis abstracta.

Pensemos qué tipo de forma puede adoptar un grafo de casos de uso:

7

Page 18: Curso Practico en Java de Ingenieria Del Software Mit

• Árboles. En primer lugar, hay que fijarse en que, visto como un grafo, el diagrama de

casos de uso no suele ser un árbol. La reusabilidad hace que una parte tenga múltiples

usuarios y, cuando una parte se descompone en dos, es probable que esas partes

compartan una parte común que les permita comunicarse. El AST, por ejemplo,

permite que Parser comunique sus resultados a Display.

• Capas. Las disposiciones en capas son frecuentes. Un diagrama de casos de uso de

nuestro navegador, más minucioso, puede tener varias partes en vez de cada una de

las partes, como hemos mostrado anteriormente. La parte Network, por ejemplo,

podría ser sustituida por Stream, Socket, etc. Algunas veces, es útil pensar en un

sistema como una secuencia de capas, donde cada una proporciona una visión

coherente de alguna infraestructura subyacente, en varios niveles de abstracción. La

capa Network facilita una visión de bajo nivel de la red; la capa Protocol se construye

por encima de ésta, y da una visión de la red como una infraestructura para procesar

consultas HTTP, y la capa superior proporciona la visión que tiene del sistema el

usuario de la aplicación, que convierte URLs en páginas webs visibles. Técnicamente,

en cualquier diagrama de usos podemos determinar una organización en capas

asociando cada parte del diagrama a una capa, de manera que ninguna flecha de usos

apunte desde una parte de una capa determinada a una parte de una capa superior. Sin

embargo, esto en realidad no genera un programa en capas, ya que las capas no

presentan coherencia conceptual.

8

Page 19: Curso Practico en Java de Ingenieria Del Software Mit

• Ciclos. La presencia de ciclos en diagramas de casos de uso es bastante habitual, lo

que no significa que tenga que haber recursividad en el programa. En nuestro proyecto

de navegador web, esto se podría presentar del modo siguiente. No hemos tenido en

cuenta cómo funcionará Display. Supongamos que tenemos una parte denominada

GUI (Graphical User Interface, o Interfaz Gráfica de Usuario) que facilita funciones

para escribir a un visualizador, y que gestiona datos de entrada haciendo llamadas

(cuando se pulsan los botones, etc.) a funciones que se hallan en otras partes. De este

modo, la parte Display puede usar a GUI para la salida de datos, y GUI puede usar a

Main para la entrada. En los diseños orientados a objetos, como veremos más

adelante, los ciclos normalmente surgen cuando existe una interacción muy marcada

entre objetos de distintas clases.

¿Qué podemos hacer con los diagramas de casos de uso?

• Argumentación. Supongamos que queremos determinar si una parte P es correcta.

Aparte de la propia P, ¿qué partes tenemos que examinar? La respuesta es: las partes

que P usa, las partes que usan a éstas y así sucesivamente; o, dicho de otro modo,

todas las partes que se puedan alcanzar desde P. En el ejemplo de nuestro navegador,

9

Page 20: Curso Practico en Java de Ingenieria Del Software Mit

para verificar que Display funciona, tendremos que comprobar también el

funcionamiento de Render y de AST.

Por el contrario; si hacemos un cambio en P, ¿qué partes se verían afectadas? La

respuesta es: todas las partes que usan a P, las partes que usan a éstas, y así

sucesivamente. Si cambiamos AST, por ejemplo, es posible que hubiera que cambiar

Display, Parser y Main. Esto se conoce con el nombre de análisis de impacto, y es

importante durante el mantenimiento de programas extensos, cuando queremos

asegurarnos de que las consecuencias de un cambio son perfectamente conocidas, y

queremos evitar volver a testear cada parte.

• Reutilidad. Para identificar un subsistema –un conjunto de partes—que se pueda

reutilizar, debemos verificar que ninguna de las partes del mismo use cualquier otra

parte que no pertenezca al subsistema. El mismo principio nos indica cómo encontrar

un subsistema mínimo para una implementación inicial. Por ejemplo, las partes

Display, Render y AST constituyen un conjunto sin dependencias de otras partes, y

podrían ser reutilizadas como una unidad.

10

Page 21: Curso Practico en Java de Ingenieria Del Software Mit

• Orden de construcción. El diagrama de casos de uso ayuda a determinar el orden en el

que construir las partes. Podríamos asociar dos conjuntos de partes a dos grupos

distintos, y hacer que funcionaran en paralelo. Al verificar de que ninguna parte de un

conjunto usa a una parte del otro conjunto, nos aseguramos de que ningún grupo

sufrirá retrasos por tener que esperar al otro. Podemos de este modo construir un

sistema de manera incremental, comenzando por los niveles inferiores del diagrama de

casos de uso, por las partes que no hacen uso de ninguna otra parte, y luego ir

subiendo, interconectando y testeando cada vez que tengamos un subsistema

consistente. Por ejemplo, las partes Display y Protocol podrían desarrollarse

independientemente con las partes que usan, pero nunca con Display y Parser.

Reflexionar sobre estas consideraciones puede arrojar luz sobre la calidad de un diseño. El

ciclo que anteriormente mencionamos, (Main-Display-GUI-Main), por ejemplo, hace que sea

imposible reutilizar la parte Display sin reutilizar también Main.

Sin embargo, existe un problema con respecto al diagrama de casos de uso. La mayoría de los

análisis que hemos tratado implican encontrar todas las partes que puedan alcanzarse o bien

alcanzar una parte. En un sistema extenso, esto puede suponer una proporción elevada de las

partes del mismo y, lo que es peor, a medida que el sistema va creciendo, el problema se

agrava, incluso para las partes ya existentes que no se refieren directamente a ninguna otra

parte distinta de las anteriores. Dicho de otro modo, la relación básica que sustenta a los usos

es transitiva: Si A se ve afectada por B, y B se ve afectada por C, entonces A se ve afectada

por C. Sería mucho mejor si argumentar sobre una parte, por ejemplo, exigiese examinar sólo

las partes a las que ésta se refiere.

La idea de la relación de usos y su papel en el problema de la estructuración del software,

fueron descritos por primera vez por David Parnas en Designing Software for Ease of

11

Page 22: Curso Practico en Java de Ingenieria Del Software Mit

Extension and Contraction, IEEE. Transactions on Software Engineering, Vol. SE-5, No 2,

1979.

2.2.2 Dependencias y especificaciones

La solución a este problema consiste en tener una noción de dependencia de las partes que se

limite a un paso en la estructura del diagrama del sistema. Para realizar el análisis de una parte

A, tendremos que tener en cuenta únicamente las partes de las que ésta depende. Para hacer

que esto sea posible, es necesario que cada parte de la que A depende esté terminada, en el

sentido de que su descripción caracterice totalmente su comportamiento. No puede depender

por si misma de otras partes. Esta descripción se conoce con el nombre de especificación.

Una especificación no se puede ejecutar, de modo que para cada parte de la especificación

necesitaremos al menos una parte de la implementación que se comporte según la

especificación. Nuestro diagrama, el diagrama de dependencias, por tanto, presenta dos tipos

de arcos. Una parte de la implementación puede depender de una parte de la especificación, y

puede que satisfaga o cumpla con una parte de la especificación.

En comparación con lo que teníamos anteriormente, hemos roto las relaciones de usos entre

dos partes A y B en dos relaciones separadas. Al introducir una parte de la especificación S,

podemos decir que A depende de S y B satisface a S, lo que puede verse en el diagrama de la

izquierda. Obsérvese que se han utilizado dos líneas dobles para distinguir las partes de la

especificación de las partes de la implementación.

Cada arco supone una obligación. El programador de la parte A deberá comprobar que

funcionará si se une con una parte que satisfaga la especificación S. Y el término “funcionar”

se define ahora a partir de la condición explícita de que se satisfagan las especificaciones: B

se podrá utilizar en A si funciona conforme a la especificación S, y se considerará que A

funciona si satisface cualquier especificación relativa a los usos previstos para A –a la que

12

Page 23: Curso Practico en Java de Ingenieria Del Software Mit

llamaremos T, tal y como muestra el diagrama de la derecha. Es la misma cadena "depends-

meets" (depende-satisface), centrada en una parte de la implementación en vez de en una

parte de la especificación.

Este esquema, o diagrama de dependencias, resulta mucho más útil y potente que el diagrama

de usos. La introducción de especificaciones conlleva muchas ventajas:

• Menos suposiciones. Cuando decimos que A usa a B, es poco probable que estemos

considerando en la afirmación todos los aspectos de B. El uso de especificaciones nos

permite decidir de manera explícita cuáles son los aspectos importantes. Al realizar

especificaciones mucho más pequeñas y simples que las implementaciones, podemos

facilitar muchísimo la verificación de que las partes son correctas. Una especificación

débil nos da más oportunidades para obtener una mejora del rendimiento.

• Evaluación de los cambios. La especificación S ayuda a limitar el alcance de un

cambio. Supongamos que queremos cambiar B. ¿Deberíamos cambiar también A?

Para esta cuestión no tenemos que fijarnos en A. Comenzamos por examinar S, que es

la especificación que A necesita de la parte que usa. Si la nueva B aún satisface a S,

entonces A no necesitará ningún cambio.

13

Page 24: Curso Practico en Java de Ingenieria Del Software Mit

• Comunicación. Si A y B van a ser construidas por personas distintas, éstas sólo tendrán

que ponerse de acuerdo con antelación en cuanto a S. A puede obviar los detalles de

los servicios que B proporciona, y B puede a su vez ignorar los detalles de las

necesidades de A.

• Implementaciones múltiples. Pueden existir varias partes de la implementación que

satisfagan una parte de la especificación. Esta característica hace posible que surja un

mercado de partes intercambiables, en el que las partes se comercializan a través de un

catálogo, dependiendo de las especificaciones que satisfagan, pudiendo un cliente

escoger cualquier parte que satisfaga la especificación que necesita. Un sistema único

puede proporcionar múltiples implementaciones de una parte. La elección puede

hacerse al configurar el sistema o, como veremos más adelante, durante la ejecución

del mismo.

Las especificaciones tienen tanta utilidad que asumiremos que en nuestro sistema existe una

parte de especificación que se corresponde con cada parte de implementación, lo que hace

posible fusionar las partes de especificación e implementación, permitiéndonos trazar

dependencias directamente de implementaciones a implementaciones. Dicho de otro modo, un

arco de dependencia de A a B significa que A depende de la especificación de B.

Por tanto, cuando tracemos un diagrama como el de nuestro navegador, que mostramos arriba,

lo interpretaremos como un diagrama de dependencia y no como un diagrama de casos de

uso. Por ejemplo, será posible contar con equipos que construyan Parser y Protocol en

paralelo, tan pronto como la especificación de Page esté acabada; su implementación puede

esperar.

14

Page 25: Curso Practico en Java de Ingenieria Del Software Mit

Sin embargo, algunas veces, las especificaciones son elementos de diseño por sí mismas y nos

interesará hacer explícita su presencia. Java ofrece varios mecanismos útiles para expresar el

desacoplamiento por medio de especificaciones, mecanismos que explicaremos más adelante.

Los patrones de diseño, que también estudiaremos más adelante a lo largo del curso, utilizan

ampliamente las especificaciones del modo que acabamos de ver.

2.2.3 Dependencias débiles

En algunas ocasiones, una parte es simplemente un canal. Se refiere a otra parte por el

nombre, pero no hace uso de ninguno de los servicios que ésta ofrece. La especificación de la

cual depende solamente necesita que la parte exista. En este caso, la dependencia se conoce

con el nombre de dependencia débil, y se traza como un arco de puntos.

En nuestro navegador, por ejemplo, el árbol de sintaxis abstracta en AST puede ser accesible

como un nombre global (utilizando el patrón Singleton que más adelante veremos). Sin

embargo, por varias razones –por ejemplo, que quizás más tarde decidamos que necesitamos

dos árboles de sintaxis—no resulta muy conveniente utilizar nombres globales de este modo.

Una alternativa consiste en que la parte Main pase la parte AST desde la parte Parse a la parte

15

Page 26: Curso Practico en Java de Ingenieria Del Software Mit

Display, lo que dará lugar a una dependencia débil de Main con respecto a AST. El mismo

razonamiento causaría una dependencia débil de Main con respecto a Page.

En una dependencia débil de A con relación a B, A normalmente depende del nombre de B. Es

decir, no sólo es necesario que haya alguna parte que satisfaga la especificación de B, sino

que también hace falta que ésta se llame B. En algunas ocasiones, no obstante, una

dependencia débil no restringe el nombre. En tal caso, A depende únicamente de la existencia

de alguna parte que cumpla con la especificación de B, y A se referirá a esa parte utilizando el

nombre de la especificación de B. Veremos cómo Java permite este tipo de dependencia. En

este caso, resulta útil mostrar la especificación de B como una parte independiente con su

propio nombre.

Por ejemplo, puede que la parte Display de nuestro navegador use una parte denominada UI

para la salida de datos, pero que no necesite conocer si esta parte UI es gráfica o está basada

en caracteres de texto. Esta parte puede ser una parte de la especificación, satisfecha por una

parte de la implementación GUI, de la que Main depende (ya que crea el objeto GUI real). En

este caso, Main, que pasa un objeto cuyo tipo se describe como UI a Display, debe tener

también una dependencia débil con respecto a la parte de la especificación UI.

2.3 Técnicas para el desacoplamiento

Hasta ahora, hemos visto cómo representar dependencias entre partes de un programa. Hemos

hablado también sobre algunos de los efectos que las dependencias tienen sobre diversas

actividades de desarrollo. En cualquier caso, una dependencia resulta ser una responsabilidad:

amplía el campo de lo que es necesario considerar. Así que una parte fundamental del diseño

se basa en intentar minimizar las dependencias: desacoplar unas partes de otras.

El desacoplamiento consiste en minimizar tanto la cantidad como la calidad de las

dependencias. La calidad de una dependencia de A a B se mide según la cantidad de

16

Page 27: Curso Practico en Java de Ingenieria Del Software Mit

información que haya en la especificación de B ( que, como hemos visto anteriormente, es de

hecho de la que A depende). Cuanta menos información haya, más débil será la dependencia.

En caso extremo, no existe información alguna en la dependencia y tenemos una dependencia

débil en la que A depende únicamente de la existencia de B.

El modo más eficaz de reducir el acoplamiento consiste en diseñar las partes de un modo

sencillo y bien delimitado, reunir aspectos del sistema que se compaginen y separar los que

no. Existen asimismo ciertas técnicas que se pueden aplicar cuando ya se tiene un candidato a

la descomposición: éstas suponen la introducción de partes nuevas y el cambio de

especificaciones. Veremos muchas de ellas a lo largo del curso. Por ahora, nos limitaremos a

mencionar brevemente algunas para ofrecer una idea de las posibilidades existentes.

2.3.1 Fachada

El patrón de diseño llamado fachada ("facade") implica interponer una parte de la

implementación nueva entre dos conjuntos de partes. La nueva parte funciona como una

especie de portero: todo uso de una parte del conjunto S por una parte del conjunto B, que

anteriormente se producía de forma directa, se hace ahora a través de la nueva parte. Esto

normalmente tiene sentido en un sistema organizado por capas, y sirve para desacoplar una

capa de otra.

En nuestro navegador, por ejemplo, puede que existan muchas dependencias entre las partes

de una capa de protocolo y entre las de una capa de red. A menos que las partes de red sean

independientes de la plataforma, portar el navegador a una nueva plataforma puede requerir la

sustitución de la capa de red. Todas las partes de la capa de protocolo que dependa de partes

de la capa de red tendrán que ser examinadas y, tal vez, también cambiadas.

Para evitar este problema, podríamos introducir una parte fachada que se sitúe entre las capas,

reúna todas las funcionalidades de red que necesite la capa de protocolo (y no más), y las

17

Page 28: Curso Practico en Java de Ingenieria Del Software Mit

presente a la capa protocolo con una interfaz de alto nivel. Esta interfaz es, lógicamente, una

nueva especificación, más débil que las especificaciones de las que las partes del protocolo

solían depender. Si se ha hecho de manera correcta, quizás sea posible cambiar las partes de la

capa de red dejando sin cambios la especificación de la fachada, para que así las partes del

protocolo no se vean afectadas.

2.3.2 Representación oculta

Una especificación puede evitar que sea necesario mostrar cómo están representados los

datos. De este modo, las partes que dependen de ella no podrán manipular los datos

directamente: el único modo de manipularlos es mediante operaciones que estén incluidas en

la especificación de la parte usada. Este tipo de debilitamiento en la especificación se conoce

como “abstracción de datos”, y hablaremos mucho de ello en las próximas semanas. Al

eliminar la dependencia que la parte A, que está siendo usada, tiene con respecto a la

representación de datos de la parte usada B, se facilita la comprensión del papel que B

desempeña en A. Esto hace posible que se cambie la representación de datos en B sin realizar

ningún tipo de cambio en A.

En nuestro navegador, por ejemplo, la parte de la especificación asociada a Page podría

indicar que una página web es una secuencia de caracteres, que esconde detalles de su

representación utilizando matrices para almacenamiento de datos ("arrays").

2.3.3 Polimorfismo

La parte C de un programa que proporciona objetos contenedores ("containers": objetos que

mantienen referencias de otros elementos, como listas) posee una dependencia con respecto a

la parte E del programa que facilita los elementos contenidos. Para algunos contenedores, esto

es una dependencia débil, pero no necesariamente: C puede usar E para comparar elementos

(ej: para verificar su igualdad o para ordenarlos). Algunas veces, C puede incluso usar

funciones de E que transforman los elementos.

18

Page 29: Curso Practico en Java de Ingenieria Del Software Mit

Para reducir el acoplamiento entre C y E, podemos convertir a C en polimórfica. El término

“polimórfico” significa “con muchas formas”, y se refiere al hecho de que C está escrito sin

hacer mención alguna a las propiedades especiales de E, de modo que los contenedores de

varias formas pueden producirse a partir de C y conforme al E del cual la parte de C haga uso.

En la práctica, el polimorfismo puro es poco común, y C dependerá al menos de las

comprobaciones de igualdad proporcionadas por E.

De nuevo, lo que está sucediendo es un debilitamiento de la especificación que conecta C con

E. En el caso monomórfico, C depende de la especificación de E; en el polimórfico, C

depende de una especificación S que indica únicamente que la parte debe proporcionar objetos

que hayan pasado por una comprobación de igualdad. En Java, esta especificación S es la

especificación de la clase Object.

En nuestro navegador, por ejemplo, la estructura de datos que se ha utilizado para el árbol de

sintaxis abstracta podría usar una especificación de parte genérica para nodo, que fuese

implementada por una parte HTMLNode, para la mayoría de su código. Un cambio en la

estructura del lenguaje de marcado afectaría entonces a menos código.

19

Page 30: Curso Practico en Java de Ingenieria Del Software Mit

2.3.4 Callbacks

Hemos mencionado anteriormente cómo, en nuestro navegador, una parte GUI puede

depender de la parte Main, ya que aquélla llama a un procedimiento de Main cuando, por

ejemplo, se pulsa un botón. Este acoplamiento resulta inconveniente, porque entrecruza la

estructura de la interfaz de usuario con la estructura del resto de la aplicación. Si alguna vez

quisiéramos cambiar la interfaz de usuario, resultaría complicado desentrelazarlo.

En vez de esto, la parte Main podría pasar a la parte GUI, en tiempo de ejecución, una

referencia a uno de sus procedimientos. Cuando este procedimiento fuese llamado por la parte

GUI, produciría el mismo efecto que hubiese tenido si el procedimiento se hubiera nombrado

en el texto de la parte GUI. No obstante, ya que la asociación se lleva a cabo sólo en tiempo

de ejecución, no hay dependencia de GUI con relación a Main. Existirá una dependencia de

GUI con respecto a una especificación (Listener, por ejemplo), que el procedimiento que ha

sido pasado debe satisfacer, pero ésta suele ser mínima: podría decirse simplemente, por

ejemplo, que el procedimiento regresa sin entrar en loop infinito o que no hace que los

procedimientos dentro del mismo GUI sean invocados. Esta disposición se denomina

"callback", ya que GUI “vuelve a llamar” a Main en el sentido contrario de la llamada

normal a un procedimiento. En Java, los procedimientos no pueden pasarse, pero se puede

obtener el mismo resultado pasando el objeto completo.

2.4 Acoplamiento por restricciones compartidas

Existe un tipo de acoplamiento distinto que no se muestra en el diagrama de dependencia de

módulos. Puede ser que dos partes no tengan dependencia explícita entre ellas, pero que, no

obstante, estén acopladas por ser ambas necesarias para cumplir una restricción.

Imaginemos, por ejemplo, que tenemos dos partes, Read, que lee archivos, y Write, que los

escribe. Si los archivos leídos por Read son los mismos ficheros escritos por Write, existirá

una restricción que exija que las dos partes estén de acuerdo con respecto al formato del

archivo. Si se cambia éste, habrá que cambiar también ambas partes.

20

Page 31: Curso Practico en Java de Ingenieria Del Software Mit

Para evitar este tipo de acoplamiento, se debe intentar localizar la funcionalidad asociada a

cualquier restricción de una única parte. Esto es lo que Matthias Felleisen llama “punto de

control individual”en su original introducción a la programación en Scheme (How to Design

Programs, An Introduction to Programming and Computing, Matthias Felleisen, Robert

Bruce Findler, Matthew Flatt, and Shriram Krishnamurthy, MIT Press, 2001).

David Parnas ha sugerido que esta idea debería constituir la base de la selección de partes. SE

debe comenzar por enumerar las decisiones claves del diseño (como la elección del formato

del archivo), y luego asignar cada una a una parte que mantenga la decisión “en secreto”. Esto

viene explicado minuciosamente mediante un buen ejemplo en su brillante artículo On the

Criteria to Be Used in Descomposing Systems into Modules, Communications of the ACM,

Vol. 15, No. 12, December 1972, pp. 1053-1058.

2.5 Volviendo a Dijkstra: Conclusión

El aviso de Dijkstra de que las posibilidades de que un programa funcione correctamente

descenderán a cero a medida de que el número de partes aumente parece preocupante. Pero si

podemos desacoplar las partes de manera que cada una de las propiedades que nos interesan

estén localizadas solamente en unas cuantas partes, podremos entonces demostrar su exactitud

de manera local y mantenernos inmunes a la suma de nuevas partes.

21

Page 32: Curso Practico en Java de Ingenieria Del Software Mit

22

Page 33: Curso Practico en Java de Ingenieria Del Software Mit

Clase 3: Desacoplamiento II

En la clase anterior, hablamos de la importancia de las dependencias en el diseño de un

programa. Un buen lenguaje de programación le permitirá expresar las dependencias entre las

partes y controlarlas, evitando que surjan dependencias no deseadas.

En esta clase, veremos cómo se pueden utilizar los elementos de Java para expresar y manejar

dependencias. Estudiaremos también una variedad de soluciones para un problema simple de

codificación, haciendo especial hincapié en el papel de las interfaces.

3.1 Repaso: Diagramas de dependencia de módulos

Comencemos dando un breve repaso a los diagramas de dependencia de módulos (MDD) que

vimos en la última clase. Un diagrama de dependencia de módulos (MDD) muestra dos tipos

de partes en un programa: partes de la implementación (clases de Java), que aparecen como

recuadros con una única raya adicional en la parte superior, y partes de la especificación, que

se presentan como recuadros con una raya tanto en la parte superior como en la inferior. Las

organizaciones de partes en grupos (como los paquetes de Java) pueden mostrarse como

contornos que contienen partes de un programa, siguiendo el estilo de un diagrama de Venn.

Una flecha simple con la punta abierta conecta la parte de la implementación A con la parte de

la especificación S, e indica que el significado de A depende del significado de S. Dado que la

especificación S no puede tener por sí misma un significado que dependa de otras partes, se

asegura que el significado de una parte se puede determinar desde esa misma parte y desde las

especificaciones de las que ésta depende, sin tener que recurrir a ningún otro dato. Una flecha

de puntos que vaya desde A hasta S es una dependencia débil; indica que A depende

únicamente de la existencia de una parte que satisfaga la especificación S, pero que en

1

Page 34: Curso Practico en Java de Ingenieria Del Software Mit

realidad no tiene dependencia de ninguno de los detalles de S. Una flecha de punta cerrada

que vaya desde una parte de la implementación A a una parte de la especificación S indica que

A satisface a S: su significado se ajusta al de S.

Dado que las especificaciones son tan imprescindibles, debemos asumir en todo momento que

están presentes. La mayoría de las veces, no dibujaremos partes de la especificación de forma

explícita y, de este modo, una flecha de dependencia entre dos partes de implementación A y

B deberá interpretarse como abreviatura de una dependencia de A para la especificación de B,

y como una flecha de conformidad de B para su especificación. Mostraremos las interfaces de

Java explícitamente como partes de la especificación.

3.2 Java Namespace (sistema de denominación)

Al igual que cualquier trabajo escrito de gran extensión, un programa también se beneficia del

hecho de estar organizado conforme a una estructura jerárquica. Cuando se intenta

comprender una gran estructura, suele resultar útil visualizarla de arriba abajo, comenzando

por los niveles más generales de la estructura y prosiguiendo hasta llegar a los detalles más

concretos. El namespace (sistema de denominación) de Java soporta esta estructura

jerárquica, lo que supone otra ventaja importante: diferentes componentes pueden utilizar los

mismos nombres para sus subcomponentes, con significados locales distintos. En el contexto

del sistema como un todo, los subcomponentes llevarán nombres que estén condicionados

por los componentes a los que pertenecen, de modo que no habrá confusión. Esto es

fundamental porque permite que los programadores trabajen de forma autónoma, sin

preocuparse por conflictos de denominación.

El sistema de denominación de Java funciona del modo que a continuación exponemos. Los

componentes considerados clave son clases e interfaces, y poseen denominaciones de

métodos y campos nombrados. Las variables locales (dentro de los métodos) y los argumentos

de un método también poseen su nombre. Cada nombre en un programa de Java tiene un

2

Page 35: Curso Practico en Java de Ingenieria Del Software Mit

alcance: una parte del texto del programa para la que el nombre es válido y se halla asociado

al componente. Los argumentos de un método, por ejemplo, poseen el mismo alcance del

método; los campos tienen el alcance de la clase y, en algunas ocasiones, un alcance aún

mayor. Se puede usar el mismo nombre para referirse a cosas distintas cuando no existe

ambigüedad. Por ejemplo, es posible utilizar el mismo nombre para un campo, un método y

una clase; consúltense las especificaciones del lenguaje Java para ver los ejemplos.

Un programa de Java se organiza por paquetes. Cada clase o interfaz posee su propio fichero

(haciendo caso omiso de las clases internas, que no trataremos). Los paquetes se hallan

reflejados en la estructura del directorio. Al igual que los directorios, los paquetes pueden

estar anidados en estructuras de profundidad arbitraria. Para organizar un código en paquetes,

deben hacerse dos cosas: indicar al comienzo de cada fichero a qué paquete pertenece la clase

o interfaz, y organizar los archivos físicamente dentro de la estructura de un directorio para

que se ajusten a la estructura del paquete. Por ejemplo, la clase djn.browser.Protocol estaría

en un fichero llamado Protocol.java en el directorio djn/browser.

Podemos mostrar esta estructura en nuestro diagrama de dependencia. Las clases e interfaces

constituyen las partes entre las que se muestran las dependencias. Los paquetes se presentan

como contornos que engloban estas clases e interfaces. A veces resulta conveniente ocultar las

dependencias exactas entre las partes de diferentes paquetes mostrando simplemente un arco

de dependencia al nivel del paquete. Una dependencia desde un paquete significa que alguna

clase o interfaz (o quizás varias) de ese paquete tiene una dependencia; una dependencia de

un paquete significa una dependencia de alguna clase o interfaz (o quizás varias) de ese

paquete.

3

Page 36: Curso Practico en Java de Ingenieria Del Software Mit

3.3 Control de acceso

Los mecanismos de Java para el control de acceso permiten dirigir las dependencias. En el

texto de una clase, se puede indicar qué otras clases pueden tener dependencias de ella, e

incluso controlar, hasta cierto punto, la naturaleza de las dependencias.

Una clase declarada pública puede ser referida por cualquier otra clase; si no, puede ser

referida sólo por clases del mismo paquete. Por tanto, al lanzar este modificador, podemos

evitar dependencias de clase de cualquier clase que no pertenezca al paquete.

Los miembros de una clase –es decir, sus campos y métodos—se pueden marcar como

públicos, privados o protegidos. Un miembro público puede accederse desde cualquier parte.

Un miembro privado puede accederse únicamente desde dentro de la clase en la que el campo

o el método se ha declarado. Un miembro protegido puede accederse bien desde dentro del

paquete o bien desde fuera por una subclase de la clase en la que el miembro es declarado,

creando así un resultado muy peculiar, que consiste en que al marcar un miembro como

protegido, éste no se hace menos accesible, sino más.

4

Page 37: Curso Practico en Java de Ingenieria Del Software Mit

No hay que olvidar que una dependencia de A sobre B indica en realidad una dependencia de

A sobre la especificación de B. Los modificadores de los miembros de B nos permiten

controlar la naturaleza de la dependencia al cambiar los miembros que pertenecen a la

especificación de B. Controlar el acceso a los campos de B ayuda a dar independencia de

representación, pero no siempre la garantiza (como veremos próximamente en este curso).

3.4 Lenguajes seguros

Una de las propiedades claves de un programa es que una parte únicamente debería depender

de otra si ésta la nombra. Esto puede parecer obvio, pero es de hecho una propiedad que sólo

se da en los programas escritos mediante los llamados “lenguajes seguros”. En un lenguaje

inseguro, el texto de una parte puede afectar al comportamiento de otra, sin que haya ningún

nombre compartido. Esto nos lleva a errores insidiosos que resultan muy difíciles de localizar,

y que pueden tener resultados desastrosos e imprevisibles.

Veamos cómo se producen estos errores. Piense en un programa escrito en C, en el que un

módulo (en C, sólo un fichero) actualiza un array. Un intento de fijar el valor de un elemento

del array más allá de los límites de éste no dará resultado en algunas ocasiones, ya que

provocará un fallo de memoria, yendo más allá del área de memoria asignada al proceso. Sin

embargo, y por desgracia, la mayoría de las veces el intento sí dará resultado, y éste consistirá

en que se sobrescribirá una parte arbitraria de memoria; arbitraria porque el programador no

sabe cómo el compilador dispuso de la memoria del programa, y no puede predecir qué otra

estructura de datos se ha visto perjudicada. A consecuencia de ello, una actualización del

array puede afectar al valor de una estructura de datos con el nombre d que se ha declarado en

un módulo diferente y no posee ni siquiera un tipo en común con a.

Los lenguajes seguros evitan estos efectos mediante la combinación de distintas técnicas. La

comprobación dinámica de los límites del array impide que se produzca este tipo de

actualización que acabamos de mencionar; en Java, se lanzaría una excepción. La

5

Page 38: Curso Practico en Java de Ingenieria Del Software Mit

administración automática de la memoria asegura que ésta no pueda ser reciclada y

posteriormente reutilizada de modo erróneo. Ambas técnicas parten de la idea básica de

paradigma de tipos fuertes, que asegura que un acceso que sea declarado a un valor de tipo t

en el texto del programa sea siempre un acceso a un valor de tipo t en tiempo de ejecución.

No existe riesgo de que el código diseñado para un array pueda ser aplicado por error a una

cadena o número entero.

Los lenguajes seguros han estado circulando desde 1960. Entre los más famosos se incluyen

Algol-60, Pascal, Modula, LISP, CLU, Ada, ML y ahora Java. Resulta interesante saber que

durante muchos años, la industria afirmaba que los costes de seguridad eran demasiado altos,

y que no era viable cambiar de lenguajes no seguros (como C++) a lenguajes seguros (como

Java). Java se vio pronto favorecido por numerosos despliegues de tipo publicitario relativos a

los applets, y ahora que está siendo utilizado en todo el mundo, muchas compañías han dado

el paso decisivo y están reconociendo las ventajas que supone un lenguaje de programación

seguro.

Algunos lenguajes seguros garantizan la corrección del tipo en tiempo de compilación por

medio de “tipos estáticos”. Otros, como Scheme y LISP, llevan a cabo la comprobación de su

tipo en tiempo de ejecución, y sus sistemas de tipos sólo reconocen tipos primitivos. En breve

veremos cómo un sistema de tipos más expresivo puede servir también para controlar las

dependencias.

Si lo que se desea potenciar es la fiabilidad, utilizar un lenguaje seguro es la opción más

adecuada. Sirva como ejemplo de ello la historia que conté en clase sobre el uso de elementos

de un lenguaje inseguro en un acelerador médico.

6

Page 39: Curso Practico en Java de Ingenieria Del Software Mit

3.5 Interfaces

En lenguajes de tipos estáticos se pueden controlar las dependencias mediante la elección de

tipos.

En líneas generales, una clase que hace referencia sólo a objetos de tipo T no puede tener una

dependencia de una clase que proporciona objetos de un tipo T’ distinto. Dicho de otro modo,

se puede deducir, a partir de los tipos referidos en una clase, de qué otras clases depende ésta.

Sin embargo, en lenguajes con subtipos, caben posibilidades interesantes. Supongamos que la

clase A hace referencia únicamente a la clase B. Esto no significa que ésta pueda solamente

llamar a métodos de objetos creados por la clase B. En Java, los objetos creados por una

subclase C de B se consideran también de tipo B, así que incluso aunque A no pueda crear

directamente objetos de clase C, puede acceder a ellos por intermedio de otra clase. El tipo C

se considera un subtipo del tipo B, ya que se puede usar un objeto C cuando se espera un

objeto B. Esto se conoce con el nombre de “sustitucionabilidad”; es decir, posibilidad de

sustituir.

En realidad, las subclases compaginan dos conceptos distintos. Uno es el subtipado: se

considera que los objetos de clase C deben tener tipos compatibles con B, por ejemplo. El otro

concepto es el de herencia: el código de la clase C puede reutilizar el código de B. Más

adelante, en el curso de la asignatura, trataremos algunas de las lamentables consecuencias de

combinar estos dos conceptos, y veremos cómo la sustitucionabilidad no funciona siempre

tan bien como cabría esperar.

Por ahora, nos centraremos exclusivamente en el mecanismo de subtipado, ya que es lo más

relevante con relación a lo que estamos viendo. Java proporciona una noción de interfaces

que da más flexibilidad al subtipado que las subclases. Una interfaz de Java es, siguiendo

nuestra terminología, una parte de especificación pura. No contiene código ejecutable y se

utiliza únicamente para facilitar el desacoplamiento.

7

Page 40: Curso Practico en Java de Ingenieria Del Software Mit

Analicemos su funcionamiento. En vez de tener una clase A que dependa de una clase B,

introducimos una interfaz I. A ahora hace referencia a I en vez de a B, y B es necesaria para

satisfacer la especificación de I. Ni que decir tiene que el compilador de Java no se ocupa de

las especificaciones de comportamiento de tipos: simplemente comprueba que los tipos de los

métodos de B sean compatibles con los tipos declarados en I. En tiempo de ejecución, cuando

A espera un objeto de tipo I, un objeto de tipo B es aceptable.

Por ejemplo, en la librería de Java hay una clase denominada java.util.LinkedList que

implementa listas enlazadas. Si estamos escribiendo código que únicamente necesite que un

objeto sea una lista, y que no tenga que ser necesariamente una lista enlazada, deberíamos

utilizar el tipo java.util.List en nuestro código, que es una interfaz implementada mediante

java.util.LinkedList. Existen otras clases, tales como ArrayList y Vector, que implementan

esta interfaz. En el momento en que nuestro código se refiera sólo a la interfaz, funcionará

con cualquiera de estas clases de implementación.

Varias clases pueden implementar la misma interfaz, y una clase puede implementar varias

interfaces. Por el contrario, puede que una clase sólo tenga a lo sumo como subclase a otra

clase. Debido a esto, mucha gente usa el término “herencia de especificación múltiple” para

describir el elemento de la interfaz de Java, en comparación con la verdadera herencia

múltiple en la cual se puede reutilizar el código de múltiples superclases.

Las interfaces presentan ante todo dos ventajas. En primer lugar, permiten al usuario expresar

partes de la especificación pura en código, con lo que éste puede asegurar que el uso de una

clase B por una clase A implica simplemente una dependencia de A con respecto a la

especificación S, y no con relación a otras características de B. En segundo lugar, las

interfaces pueden proporcionar varias partes de la implementación que satisfagan una única

8

Page 41: Curso Practico en Java de Ingenieria Del Software Mit

especificación, con una selección realizada en tiempo de compilación o en tiempo de

ejecución.

3.6 Ejemplo: Cómo instrumentar un programa en Java

En lo que queda de clase, estudiaremos algunos mecanismos de desacoplamiento en el

contexto de un ejemplo breve, pero que es representativo de una clase importante de

problemas.

Imaginemos que queremos dar parte de los pasos graduales de un programa cuando se

ejecuta, visualizando el progreso línea por línea. Por ejemplo, en un compilador con varias

fases, podríamos estar interesados en mostrar un mensaje al comienzo y al final de cada fase.

En un cliente de correo electrónico, podríamos visualizar cada uno de los pasos que se

producen durante la descarga de un mensaje de correo desde un servidor. Esta clase de

servicios de informe resulta útil cuando los pasos por separado podrían llevarnos mucho

tiempo o cuando tienen tendencia a fallar (de esta forma el usuario puede optar por cancelar el

comando que los provocó).

Las barras de progreso se usan normalmente en este contexto, pero presentan más

complicaciones (al señalar el comienzo y el fin de una actividad y al calcular el progreso

proporcional) que no tendremos en cuenta.

Como ejemplo específico, pensemos en un cliente de correo electrónico que tiene un paquete

central que contiene una clase Session, la cual presenta un código para establecer una sesión

de comunicación con un servidor y descargar mensajes, una clase Folder para los objetos que

modelan carpetas y sus contenidos, y una clase Compactor que contiene el código para

comprimir la representación de carpetas en el disco. Supongamos que hay llamadas desde

Session a Folder y desde Folder a Compactor, pero que las actividades intensivas de recursos

que queremos instrumentar tienen lugar únicamente en Session y en Compactor, pero no en

Folder.

9

Page 42: Curso Practico en Java de Ingenieria Del Software Mit

El diagrama de dependencia de módulo muestra que Session depende de Folder, la cual tiene

una dependencia mutua de Compactor.

Examinaremos una serie de métodos para implementar nuestro servicio de instrumentación, y

estudiaremos las ventajas y desventajas de cada uno de ellos. Comenzando por el diseño más

simple posible, podríamos entremezclar resultados tales como

System.out.println (“Comenzando descarga”);

por todo el programa.

3.6.1 Abstracción por parametrización

El problema de este plan es obvio. Cuando ejecutamos el programa en modo batch,

podríamos redirigir la salida estándar a un fichero. Entonces nos damos cuenta de que sería

útil grabar el tiempo de todos los mensajes, de modo que podamos ver más tarde, cuando

leamos los ficheros, cuánto tiempo llevaron los distintos pasos. Deseamos que nuestro

enunciado sea el siguiente:

System.out.println (“Comenzando descarga a:” + nueva Fecha() );

Esto debería ser fácil, pero no lo es. Tenemos que encontrar todos estos enunciados en nuestro

código (y diferenciarlos de otras llamadas a System.out.println que tienen objetivos distintos),

y modificar cada uno por separado.

10

Page 43: Curso Practico en Java de Ingenieria Del Software Mit

Por supuesto, lo que deberíamos haber hecho es definir un procedimiento para encapsular esta

funcionalidad. En Java, esto sería un método estático:

public class StandardOutReporter {

public static void report (String msg) {

System.out.println (msg);

}

}

Ahora el cambio puede realizarse en un único punto del código. Nos limitamos a modificar el

procedimiento:

public class StandardOutReporter {

public static void report (String msg) {

System.out.println (msg + “a” + nueva Fecha());

}

}

Matthias Felleisen llama a esto el principio del “punto de control individual”. En este caso, el

mecanismo nos resulta familiar: es lo que en el curso 6001 se denominó abstracción por

parametrización, porque cada llamada al procedimiento:

StandardOutReporter.report (“Comenzando descarga”);

es una instanciación de la descripción genérica, con el parámetro msg ligado a un valor

especial. Podemos ilustrar el único punto de control en un diagrama de dependencia de

módulos. Hemos introducido una clase única, de la cual dependen, las clases que usan la

función de instrumentación: StandardOutReporter. Hay que tener en cuenta que no existe

11

Page 44: Curso Practico en Java de Ingenieria Del Software Mit

dependencia de Folder con respecto a StandardOutReporter, ya que el código de Folder no

hace ninguna llamada a éste.

3.6.2 Desacoplamiento con interfaces

Este planteamiento está lejos de ser perfecto. Aunque reunir la funcionalidad en una única

clase es una buena idea, el código mantiene una dependencia de la noción de escribir a una

salida estándar (standard out). Si quisiéramos crear una nueva versión de nuestro sistema con

una interfaz gráfica de usuario, tendríamos que sustituir esta clase por una que contuviese el

código GUI adecuado; lo que supondría cambiar todas las referencias del paquete central para

que se refirieran a una clase distinta, o cambiar el código de la propia clase, y tener entonces

que controlar dos versiones incompatibles de la clase con el mismo nombre. Ninguna de las

dos opciones es válida.

De hecho, el problema es aún más grave. En un programa que usa una GUI, se escribe a ésta

invocando a un método de un objeto que represente parte de la GUI: un panel de texto o un

campo del mensaje. En Swing, el kit de herramientas de la interfaz de usuario de Java, las

subclases de JTextComponent poseen un método setText. Dado algún componente nombrado,

por ejemplo, por la variable outputArea, el enunciado de muestra podría ser:

OutputArea.setText (msg)

¿Cómo vamos a pasar la referencia al componente bajando al sitio de llamada? ¿Y cómo

vamos a hacerlo sin introducir código específico de Swing en la clase reporter?

Las interfaces de Java tienen la solución. Creamos una interfaz con un sólo método report

(informe) que será invocado para mostrar resultados.

public interface Reporter {

void report (String msg);

}

12

Page 45: Curso Practico en Java de Ingenieria Del Software Mit

Ahora añadimos a cada método de nuestro sistema un argumento de este tipo. La clase

Session, por ejemplo, puede tener un método download:

Void download (Reporter r, ...) {

r.report (“Comenzando descarga”);

...

}

Ahora definimos una clase que en realidad implementará el comportamiento del método

Report (informe). Utilicemos StandardOut (salida estándar) como ejemplo porque es más

sencillo:

public class StandardOutReporter implements Reporter {

public void report (String msg) {

System.out.println (msg + “a” + nueva Fecha () );

}

}

Esta clase no es igual a la anterior que también tenía este nombre. El método ya no es estático,

así que podemos crear un objeto de la clase y llamar al método a partir de ella. Asimismo,

hemos indicado que esta clase es una implementación de la interfaz Reporter. Por supuesto,

para la salida estándar esta solución presenta numerosas lagunas, y la creación del objeto

parece ser gratuita. No obstante, en el caso de la GUI, haremos algo más elaborado y

crearemos un objeto que esté unido a un mecanismo especial:

public class JtextComponentReporter implements Reporter {

JTextComponent comp.;

13

Page 46: Curso Practico en Java de Ingenieria Del Software Mit

public JtextComponentReporter (JTextComponent c) {comp. = c;}

public void report (String msg) {

comp.setText (msg + “a” + nueva Fecha () );

}

}

Al comienzo del programa, crearemos un objeto y lo pasaremos a:

Ahora hemos llegado a una solución interesante. La llamada a report (informe) ejecuta ahora,

en tiempo de ejecución, el código que implica a System.out (salida estándar). Sin embargo,

métodos como download sólo dependen de la interfaz Reporter, que no hace alusión alguna a

ningún mecanismo de salida concreto. Hemos desacoplado con éxito el mecanismo de salida

del programa, rompiendo la dependencia que el núcleo del programa tiene con respecto a su

I/O.

14

Page 47: Curso Practico en Java de Ingenieria Del Software Mit

Observemos el diagrama de dependencia de módulos, teniendo en cuenta que una flecha con

la punta cerrada desde A hasta B se lee como “A satisface a B”. B podría ser una clase o una

interfaz; la relación en Java puede ser de implementación o de extensión. Aquí, la clase

StandardOutReporter satisface la interfaz Reporter.

La característica clave de este planteamiento es que no existe ya dependencia de ningún tipo

del paquete core (núcleo) con respecto a una clase del paquete gui. Todas las dependencias

apuntan (¡al menos lógicamente!) desde gui a core. Para cambiar la salida de datos desde la

salida estándar al mecanismo GUI, simplemente sustituiríamos la clase StandardOutReporter

por la clase JtextComponentReporter, y modificaríamos el código de la clase principal del

paquete gui para llamar a su constructor a las clases que realmente contienen código

específico I/O. Este idioma constituye quizás el uso más generalizado de las interfaces, y

merece la pena llegar a dominarlo.

Recordemos que las flechas de puntos indican dependencias débiles. Una dependencia débil

desde A hasta B significa que A hace referencia al nombre de B, pero no al nombre de ninguno

de sus miembros. Dicho de otro modo, A sabe que la clase de la interfaz B existe, y hace

referencia a las variables de ese tipo, pero no invoca a métodos de B, y no accede a campos de

B.

La dependencia débil de Main en relación a Reporter indica simplemente que la clase Main

puede incluir código que controle a un informador genérico; lo que no supone ningún

problema. Sin embargo, la dependencia débil de Folder en relación a Reporter sí que lo es. Se

encuentra allí porque el objeto Reporter tiene que ser pasado a través de métodos de Folder a

métodos de Compactor. Cada método en la cadena de llamada que alcanza a un método que

está instrumentado debe tomar un Reporter como argumento. Se trata de una incomodidad

que hace que la optimización de este planteamiento resulte demasiado laboriosa.

15

Page 48: Curso Practico en Java de Ingenieria Del Software Mit

3.6.3 Interfaces y clases abstractas

Cabe preguntarnos si podríamos haber usado una clase en vez de una interfaz. Una clase

abstracta es aquella que no está totalmente implementada; no puede ser instanciada, pero debe

ser extendida por una subclase que la complete. Las clases abstractas resultan útiles cuando se

quiere reunir algún código común de varias clases. Imaginemos que quisiésemos mostrar un

mensaje que nos indicase el tiempo empleado en cada paso. Podríamos implementar una clase

Reporter cuyos objetos mantuviesen en su estado el tiempo de la última llamada a report

(informe), y luego considerar la diferencia entre éste y el tiempo actual de la salida. Al hacer

que esta clase sea una clase abstracta, podríamos reutilizar el código de cada una de las

subclases específicas StandardOutReporter, JtextComponentReporter, etc.

¿Por qué no hacer que el argumento de download tenga como tipo a esta clase abstracta en

vez de a una interfaz? Por dos razones. La primera es que queremos que la dependencia del

código de reporter (informe) sea lo más débil posible. La interfaz no tiene ningún tipo de

código; expresa la mínima especificación requerida. La segunda es que no existe la herencia

múltiple en Java: una clase sólo puede incluir como máximo a otra clase. Así que cuando

estemos diseñando el núcleo del programa, no nos interesa hacer uso de las subclases antes de

tiempo. Una clase puede implementar cualquier número de interfaces, así que al elegir una

interfaz, deja a la vista del diseñador de las clases reporter el modo en que se implementarán

éstas.

3.6.4 Campos estáticos

El mayor inconveniente del planteamiento que acabamos de exponer es que el objeto reporter

(informe) tiene que ser enhebrado a través de todo el núcleo del programa. Si toda la salida de

datos se muestra en un único componente de texto, resulta engorroso tener que pasarle una

referencia por alrededor. En términos de dependencia, cada método posee al menos una

dependencia débil con respecto a la interfaz Reporter.

16

Page 49: Curso Practico en Java de Ingenieria Del Software Mit

Las variables globales, campos estáticos en Java, facilitan la solución a este problema. Para

eliminar muchas de estas dependencias, podemos mantener al objeto reporter (informe) como

campo estático de una clase:

public class StaticReporter {

static Reporter r;

static void setReporter (Reporter r) {

this.r = r;

{

static void report (String msg) {

r.report (msg);

}

}

Lo que tenemos que hacer ahora es colocar el static reporter (informador estático) al

comienzo:

StaticReporter.setReporter (new StandardOutReporter ());

y podemos enviar llamadas a éste sin tener que hacer referencia a un objeto:

void download (...) {

StaticReporter.report (“Comenzando descarga”);

...

}

En el diagrama de dependencia de módulos, el resultado de este cambio consiste en que ahora

sólo las clases que realmente usan un reporter (informe) tienen dependencia de éste:

17

Page 50: Curso Practico en Java de Ingenieria Del Software Mit

Obsérvese cómo la dependencia débil de Folder ha desaparecido. Por supuesto, hemos visto

este concepto global antes, en nuestro segundo planteamiento, en el que el método del

StandardOutputReporter era estático. Este planteamiento combina el aspecto estático con el

desacoplamiento proporcionado por las interfaces.

Las referencias globales resultan prácticas porque permiten cambiar el comportamiento de los

métodos que se hallan en los niveles inferiores de la jerarquía de llamadas sin necesidad de

introducir cambios en sus invocadores. Sin embargo, las variables globales conllevan riesgos.

Pueden hacer que el código resulte terriblemente difícil de comprender. Por ejemplo, para

definir el resultado de una llamada a StaticReporter.report, es necesario saber cómo está

definido el campo estático r. Podría haber una llamada al método setReporter en algún lugar

del código, y para ver el resultado que tiene, habría que localizar ejecuciones para tratar de

ver cuándo se ha ejecutado cerca del código que nos interesa.

Otro problema de las variables globales es que sólo funcionan bien cuando hay un objeto que

realmente tenga una importancia constante. La salida de datos estándar es uno de estos casos.

No lo son, en cambio, los componentes del texto en una GUI. Podríamos estar interesados en

que las distintas partes del programa informaran de su progreso a distintos paneles de nuestro

GUI. En el planteamiento en el que los objetos reporter son pasados alrededor, podemos crear

18

Page 51: Curso Practico en Java de Ingenieria Del Software Mit

diferentes objetos y pasarlos a las distintas partes del código. En la versión estática, tendremos

que crear varios métodos, lo que amenazaría con degradar el funcionamiento del código.

La concurrencia también hace dudar acerca de la idea de usar un único objeto. Supongamos

que mejoramos a nuestro cliente de correo electrónico para descargar mensajes desde varios

servidores al mismo tiempo. No querríamos que el progreso de los mensajes desde todas las

sesiones de descarga se viera intercalado en una única salida de datos.

Una práctica que conviene seguir es no fiarse de las variables globales. Es necesario

preguntarse si realmente es posible utilizar un único objeto. Normalmente hallaremos

suficientes razones para tener más de un objeto alrededor. Este planteamiento recibe en la

literatura de los patrones de diseño el nombre de Singleton, porque la clase contiene un único

objeto.

19

Page 52: Curso Practico en Java de Ingenieria Del Software Mit

Tema 4: Especificaciones del procedimiento

4.1.Introducción En este tema nos centraremos en el papel que desempeñan las especificaciones de los métodos. Éstas constituyen el eje del trabajo en equipo. No es posible delegar responsabilidad para implementar un método si no existe una especificación. La especificación funciona como un contrato: el implementador es el responsable del cumplimiento del contrato, y un cliente que utilice el método podrá confiar en el contrato. De hecho, veremos que al igual que los contratos reales legales, las especificaciones requieren exigencias por ambas partes: cuando la especificación posee una precondición, el cliente tiene también que afrontar responsabilidades. Muchos de los peores errores de los programas surgen a causa de malentendidos en el comportamiento de las interfaces. Aunque cada programador tiene en mente las especificaciones, no todos ellos las escriben. Como resultado, cada programador de un equipo posee en mente una especificación distinta. Cuando el programa falla, resulta difícil determinar dónde está el error. Las especificaciones concretas en el código le permiten distribuir la responsabilidad (¡entre los fragmentos del código, no entre el personal!), así como ahorrarle la molestia de tener que darle vueltas a la cabeza para hallar dónde ubicar las correcciones. Otra de las ventajas de las especificaciones es que ahorran al cliente la tarea de tener que leer el código. Si no está convencido de que la lectura de una especificación es más sencilla que la lectura del código, fíjese en algunas de las especificaciones estándar de Java y compárelas con el código fuente que las implementa. Por ejemplo, Vector, en el paquete java.util, posee una especificación simple, pero su código no lo es en absoluto. Las especificaciones también benefician al implementador de un método porque le proporcionan libertad para cambiar la implementación sin avisar a los clientes. Las especificaciones pueden aligerar el código. En algunas ocasiones, una especificación débil hace que sea posible conseguir una implementación mucho más eficaz. En concreto, una precondición puede excluir ciertos estados en los que se podría haber invocado a un método y que podrían haber provocado un chequeo costoso que ya no es necesario. Este tema está relacionado con lo que debatimos en las dos lecciones anteriores sobre el desacoplamiento y las dependencias. En éstas, nos ocupábamos únicamente de si existía una dependencia. Aquí, trataremos de investigar la forma que la dependencia debería adoptar. Al presentar sólo la especificación de un procedimiento, sus clientes se hacen menos dependientes de él y, por tanto, es menos probable que necesiten modificarse cuando el procedimiento cambie.

4.2.Equivalencia de comportamientos

Piense en estos dos métodos. ¿Son iguales o distintos? static int findA (int [] a, int val) {

for (int i = 0; i < a.length; i++) { if (a[i] == val) return i; }

return a.length; }

static int findB (int [] a, int val) { for (int i = a.length -1 ; i > 0; i--) {

42

Page 53: Curso Practico en Java de Ingenieria Del Software Mit

if (a[i] == val) return i; }

return -1; }

Por supuesto que el código es diferente, de modo que en ese sentido son distintos. No obstante, nuestra pregunta es si sería posible sustituir una implementación por la otra. Estos métodos no sólo poseen un código distinto; tienen en realidad un comportamiento distinto. · cuando val no está presente, findA devuelve la longitud y findB devuelve -1; · cuando val aparece dos veces, findA devuelve el índice más bajo y findB devuelve el más alto. Pero cuando val se encuentra exactamente en un índice de la matriz, los dos métodos se comportan igual. Es posible que los clientes nunca confíen en el comportamiento en los otros casos. Por tanto, la noción de equivalencia se encuentra en depende de la persona que la utilice, es decir, el cliente. Para que sea posible sustituir una implementación por otra, y para saber cuando esto es aceptable, necesitamos una especificación que declare exactamente de qué depende el cliente. En este caso, nuestra especificación podría ser:

requires : effects:

val se da en a devuelve un resultado tal que a[result] = val

4.3.Estructura de la especificación La especificación de un método consta de varias cláusulas:

· una precondición, indicada por la palabra clave requires; · una poscondición, indicada por la palabra clave effects; · una condición estructural, indicada por la palabra clave modifies. A continuación explicaremos cada una de ellas. En cada caso, daremos el significado de la cláusula, y lo que implica la ausencia de una de ellas. Más adelante, trataremos algunas abreviaturas prácticas que permiten que determinadas palabras poco formales puedan definirse a través de tipos especiales de cláusulas. La precondición es una obligación que el cliente (es decir, el que llama a un método) debe satisfacer. Es una condición sobre el estado en el que se invoca al método. Si la precondición no se da, la implementación del método posee la libertad de hacer cualquier cosa entre ellas no terminar una ejecución, lanzar una excepción, devolver resultados arbitrarios, hacer modificaciones arbitrarias, etc. ). La poscondición es una obligación del implementador del método. Si la precondición se satisface en el momento de la invocación del método, se obliga a éste a obedecer la poscondición, devolviendo valores adecuados, lanzando excepciones especificadas, modificando o no objetos y demás. La condición estructural está relacionada con la poscondición. Permite especificaciones más concisas. Sin una condición estructural, sería necesario describir cómo todos los objetos accesibles pueden o no cambiar. No obstante, normalmente sólo se modifica alguna pequeña parte del estado. Esta condición identifica qué objetos pueden ser modificados. Si decimos modifies x, nos referimos a que el objeto x, que supuestamente es mutable, puede ser modificado; pero que ningún otro objeto puede serlo. Así que en realidad, esta condición estructural o cláusula modifies, como se denomina algunas veces, es en realidad una afirmación sobre los objetos que no se han mencionado. Para los que se han mencionado, es posible pero no necesaria una mutación; mientras que para los que no se han mencionado, la mutación no se da. Las cláusulas omitidas tienen interpretaciones especiales. Si se omite la precondición, se da el

43

Page 54: Curso Practico en Java de Ingenieria Del Software Mit

valor por defecto true (verdadero). Eso significa que cualquier estado del sistema durante una invocación, satisface la precondición. Por tanto, no hay obligación de realizar cualquier tipo de verificación por parte del que realiza la llamada. En este caso, se dice que el método es total. Si la precondición no es verdadera, se dice que el método es parcial, dado que sólo funciona en algunos estados. Cuando se omite la condición estructural, el valor por defecto es modifies nothing (no modifica nada). Dicho de otro modo, el método no realiza cambios en ningún objeto. La omisión de la poscondición no tiene sentido y es algo que nunca se hace.

4.4.Especificación declarativa En líneas generales, existen dos tipos de especificaciones. Las especificaciones operacionales proporcionan una serie de pasos que el método lleva a cabo; a esta categoría pertenecen las descripciones con pseudocódigo. Las especificaciones declarativas no dan detalles de los pasos intermedios. En vez de esto, simplemente facilitan las propiedades del resultado final, y de la relación de éste con el inicial. En la mayoría de los casos, las especificaciones declarativas son preferibles. Normalmente son más breves y fáciles de entender, y lo más importante de todo, es que no dejan al descubierto de forma involuntaria detalles de la implementación en los que un cliente puede confiar (y que luego no vuelven a encontrar cuando la implementación se cambia). Por ejemplo, si queremos permitir cualquier implementación del método find, nos interesa decir en la especificación que el método “baja por la matriz hasta que encuentra a val”, ya que a parte de ser bastante imprecisa, esta especificación aconseja que la búsqueda transcurra desde los índices más bajos hasta los más altos, y que el más bajo sea devuelto, lo que quizás no sea la intención del especificador. Aquí se muestran algunos ejemplos de especificación declarativa. La clase StringBuffer proporciona objetos que son como objetos String, pero mutables. Los métodos de StringBuffer modifican al objeto en vez de crear otros nuevos: son mutadores, mientras que los métodos de String son productores. El método reverse invierte una cadena. A continuación se muestra cómo queda especificado esto en la API de Java:

public StringBuffer reverse() // modifies: this //effects: Sea n la longitud de la secuencia del carácter anterior, la contenida en string buffer // antes de la ejecución del método reverse. Entonces, el carácter en el índice k de la nueva // secuencia del carácter equivale al carácter en el índice n-k-1 de la secuencia del carácter anterior.

Observe que la poscondición no facilita ninguna pista sobre cómo se ha realizado la inversión; simplemente proporciona una propiedad que tiene que ver con la secuencia del carácter anterior y posterior. (A propósito, hemos omitido parte de la especificación: el valor devuelto es simplemente el propio objeto StringBuffer). Formalmente podríamos escribir:

Effects : length (this.seq) = length (this.seq’)

para todo k: 0..length(this.seq)-1 | this.seq’[k] = this.seq[length(this.seq)-k-1]

Aquí he usado la notación this.seq’ para referirme al valor de la secuencia de caracteres en este objeto después de la ejecución. El libro de texto de la asignatura utiliza la palabra clave post como una abreviación, con el mismo fin. No hay precondición, así que el método debe funcionar cuando StringBuffer está también vacío; en este caso, el búfer quedará igual. Otro ejemplo, esta vez desde la clase String. El método startsWith prueba si un string (cadena) comienza con un substring (subcadena) especial.

44

Page 55: Curso Practico en Java de Ingenieria Del Software Mit

public boolean startsWith(String prefix) // prueba si este string comienza con el prefijo especificado. // effects: // si (prefix = null) throws NullPointerException // else returns true iff existe una secuencia s tal que (prefix.seq ^ s = this.seq)

He asumido que los objetos String, al igual que los objetos StringBuffer, poseen un campo de especificación que modela la secuencia de caracteres. El símbolo de acento circunflejo es el operador de concatenación, así que la poscondición dice que el método devuelve el valor verdadero si hay algún sufijo que cuando está concatenado al argumento, proporciona la secuencia del carácter de la cadena. La ausencia de una sentencia modifies indica que ningún objeto se ha transformado. Dado que String es un tipo inmutable, ninguno de sus métodos poseerán cláusulas modifies. Otro ejemplo de objeto String:

public String substring(int i) // effects: // si i < 0 or i > length (this.seq) throws IndexOutOfBoundsException // else return r tal que // para alguna secuencia s | length(s) = i && s ^ r.seq = this.seq

Esta especificación muestra cómo una poscondición bastante matemática puede algunas veces resultar más fácil de comprender que una descripción informal. En vez de hablar de si i es el índice inicial, de si viene justo antes del substring devuelto, etc., simplemente nos limitamos a descomponer el string en un prefijo de longitud i y en el string devuelto. Nuestro ejemplo final muestra cómo una especificación declarativa puede expresar lo que frecuentemente se conoce como no-determinismo, aunque es mejor llamarlo sub-determinismo. Al no dar suficientes detalles que permitan al cliente decidir el comportamiento de todas las clases, la especificación facilita la implementación. El término no-determinismo sugiere que la implementación debería mostrar todos los comportamientos posibles que satisfagan la especificación, lo cual no es el caso. Hay una clase BigInteger en el paquete java.math, cuyos objetos son números enteros de tamaño ilimitado. La clase posee un método similar a este:

public boolean maybePrime () // effects: if este BigInteger es compuesto, returns false

Si este método devuelve el valor falso, el cliente sabe que el número entero no es primo. Pero si devuelve el valor verdadero, el número entero puede ser primo o compuesto, lo que resulta útil mientras el método regrese el valor falso una proporción considerable de veces. En realidad, como afirma la API de Java: el método toma un argumento que es indicativo de la incertidumbre que el llamador está dispuesto a soportar. El tiempo de ejecución de este método es proporcional al valor de este parámetro. No nos ocuparemos de asuntos probabilísticos en este curso; mencionamos esta especificación simplemente para indicar que, pese a no depender del resultado, sigue siendo útil para los clientes. Aquí mostramos un ejemplo de una especificación genuinamente indeterminada. En el patrón Observer, un conjunto de objetos llamados “observadores” están informados de los cambios aplicados a un objeto conocido con el nombre de “sujeto”. El sujeto pertenecerá a una clase que posea la subclase java.util.Observable. En la especificación de Observable, existe un campo de especificación 1 llamado observadores, que sostiene al conjunto de objetos observadores. Esta clase proporciona métodos para añadir un observador

public void addObserver(Observer o) // modifies: this // effects: this.observers’ = this.observers + {o}

(utilizando + para referirse al conjunto de), y para informar a los observadores de un cambio de estado:

1 El concepto de campos de especificación se explicará en el próximo tema.

45

Page 56: Curso Practico en Java de Ingenieria Del Software Mit

public void notifyObservers() // modifies: a los objetos en this.observers // effects: llama a o.notify en cada observador o de this.observers

La especificación de notify no indica el orden en el que se notifica a los observadores. El orden que se haya escogido puede afectar al comportamiento global del programa; pero, al haber elegido el modelar a los observadores como un conjunto, no hay modo de precisar un orden.

4.5. Excepciones y precondiciones Un aspecto básico del diseño consiste en decidir si se usa una precondición, y en tal caso, si es conveniente comprobarla. Es esencial entender que una precondición no requiere que una comprobación se lleve a cabo, sino que, por el contrario, el uso más común de las precondiciones consiste en requerir una propiedad que sea precisa, porque la comprobación resultaría complicada o costosa. Como anteriormente se mencionó, una precondición no trivial hace que el método sea parcial, lo que resulta engorroso para los clientes, ya que éstos tienen que asegurar que no llaman al método en mal estado (lo cual violaría la precondición); si lo hacen, no existe un modo previsible de recuperarse del error. De modo que los usuarios de métodos no son partidarios de precondiciones y, por esta razón, los métodos de una librería serán normalmente globales. Este es el motivo por el cual las clases de la API de Java, por ejemplo, siempre lanzan excepciones cuando los argumentos no son los apropiados, lo que hace que los programas en los que éstos se utilizan sean más robustos. Sin embargo, algunas veces, una precondición sirve para escribir un código más eficaz y evitar problemas. Por ejemplo, en una implementación de un árbol binario, usted podría tener un método privado que equilibrara el árbol. ¿Podría controlar el caso en el que no se diera el orden invariante del árbol? Obviamente no, ya que resultaría costoso a la hora de hacer las comprobaciones. Dentro de la clase que implementa el árbol, es razonable suponer que el invariante se da. Generalizaremos esta noción cuando hablemos de los invariantes de representación en un tema próximo. La decisión sobre si debemos utilizar una precondición es un criterio de ingeniería. Los factores claves son el coste de la comprobación (al escribir y ejecutar código), y el alcance del método. Si solamente es llamado a nivel local dentro de una clase, la precondición puede dispararse al comprobar cuidadosamente todos las ubicaciones de llamadas del método. No obstante, si el método es público y ha sido utilizado por otros programadores, resultaría menos acertado utilizar una precondición. En ocasiones no resulta factible comprobar una condición, ya que ralentiza mucho al método; por lo que en estos casos suele ser necesario introducir una precondición. En la librería estándar de Java, por ejemplo, los métodos de búsqueda binaria de la clase Arrays exigen que la matriz determinada se ordene. El comprobar que la matriz está ordenada frustraría el fin último de la búsqueda binaria: obtener un resultado en tiempo logarítmico y no lineal. Incluso si decide usar una precondición, puede ser posible insertar prácticos controles que detectarán, al menos algunas veces, que la precondición se ha violado. Estas son las aserciones en tiempo de ejecución que tratamos en el tema sobre excepciones. A menudo, no comprobará la precondición explícitamente al comienzo, pero descubrirá el error durante la computación. Por ejemplo, al equilibrar el árbol binario, tendría la posibilidad de comprobar, al visitar un nodo, que sus hijos se hallan ordenados correctamente. Si se percibe que una precondición ha sido violada, usted debe lanzar una excepción unchecked (informando al respecto de que no se ha comprobado), dado que no se espera que el cliente manipule dicha excepción. El lanzamiento de la excepción no se mencionará en la especificación, aunque puede aparecer en los comentarios de la implementación que están debajo de ésta.

46

Page 57: Curso Practico en Java de Ingenieria Del Software Mit

4.6. Abreviaturas Existen algunas abreviaturas prácticas que facilitan la escritura de especificaciones. Cuando un método no modifica nada, especificamos el valor de retorno en una cláusula returns. Si se lanza una excepción, la condición y la excepción se dan en una cláusula throws. Por ejemplo, en vez de

public boolean startsWith(String prefix) // effects: // if (prefix = null) throws NullPointerException // else returns true iff existe una secuencia s tal que (prefix.seq ^ s = this.seq)

podemos escribir public boolean startsWith(String prefix) // throws: NullPointerException if (prefix = null) // returns: true iff existe una secuencia s tal que (prefix.seq ^ s = this.seq)

El uso de estas abreviaturas para expresar la especificación de un método implica que no se han introducido modificaciones. Las condiciones se evalúan mediante un orden implícito: todas las cláusulas throws son consideradas en el orden en el que aparecen, y luego las cláusulas return. Esto nos permite omitir la parte else del enunciado if-the-else. Nuestro generador JavaDoc html del curso 6.170 produce especificaciones siguiendo el estilo del formato de la API de Java. Admite las cláusulas que hemos tratado aquí, y que han sido el patrón en la comunidad de proyectos a lo largo de varias décadas, junto con las cláusulas throws y returns. No utilizaremos la cláusula de los parámetros de JavaDoc, puesto que ya se halla incluida en la poscondición y, además, suele resultar difícil de escribir.

4.7.Orden de la especificación Imagine que quiere sustituir un método por otro. ¿Cómo compararía las especificaciones? Una especificación A es al menos tan buena como una especificación B cuando: · la precondición de A no es más fuerte que la de B · la poscondición de A no es más débil que la de B para los estados que satisfacen la precondición de B. Estas dos reglas engloban varias ideas. Le indican que siempre puede debilitar la precondición; el plantear menos exigencias a un cliente nunca le afectará. Siempre cabe la posibilidad de hacer que la poscondición sea más fuerte, lo que implica prometer más. Por ejemplo, nuestro método maybePrime puede ser sustituido en cualquier contexto por un método isPrime que devuelve un valor verdadero si y sólo si el número entero es primo. Y en aquellos casos en los que la precondición sea falsa, usted puede escoger la opción que prefiera. Si la poscondición precisa el resultado para un estado que viola la precondición, usted puede ignorarla, ya que el resultado no está garantizado de ningún modo. Estas relaciones entre especificaciones resultarán importantes cuando nos centremos en las condiciones bajo las que la división de clases funciona correctamente (que veremos al tratar el tema sobre subtipos y división de clases).

47

Page 58: Curso Practico en Java de Ingenieria Del Software Mit

4.8.Cómo juzgar especificaciones ¿Qué es lo convierte a un método en bueno? Diseñar un método supone ante todo escribir una especificación. No existen reglas infalibles, pero hay algunas directrices prácticas: · La especificación debe ser coherente: debe contener un buen grupo de casos distintos. Las sentencias if anidadas en profundidad son indicio de problema, como también lo son los flags (banderas) booleanos presentados como argumentos.

· Los resultados de una llamada deben ser informativos. La clase HashMap de Java posee un método put que toma una clave y un valor y devuelve un valor anteriormente recibido si esa clave estaba ya mapeada, o si por el contrario era null. HashMaps permite que se almacenen las referencias para null, de modo que un resultado null es difícil de interpretar.

· La especificación debe ser lo bastante fuerte. No tiene sentido lanzar una excepción que ha sido comprobada por un argumento cuyo valor no satisface la precondición, sino que sufre alteraciones arbitrarias, dado que un cliente no será capaz de determinar las alteraciones realizadas en realidad.

· La especificación debe ser lo bastante débil. Es evidente que un método que toma una dirección URL y devuelve una conexión de red, no garantiza el éxito de la ejecución en todos los casos.

4.9.Resumen Una especificación actúa como un cortafuegos decisivo entre el implementador de un procedimiento y su cliente. Hace posible el desarrollo en paralelo: el cliente puede escribir con libertad el código que utiliza el procedimiento sin ver su código fuente, y el implementador tiene libertad para escribir el código que implementa al procedimiento sin saber cómo se utilizará éste. Las especificaciones declarativas son las más útiles en la práctica. Las precondiciones dificultan la vida al cliente pero, aplicadas juiciosamente, son una herramienta vital dentro del repertorio del diseñador de software.

48

Page 59: Curso Practico en Java de Ingenieria Del Software Mit

Tema 5: Tipos abstractos

5.1.Introducción

En este tema, nos centraremos en un tipo especial de dependencia, aquella observada entre un cliente de un tipo abstracto de dato, para con la representación de este tipo, y veremos el modo de evitar esta dependencia. Trataremos también brevemente el concepto de campos de especificación para la definición de tipos abstractos, la clasificación de las operaciones y los beneficios del uso de las representaciones.

5.2.Tipos definidos por el usuario A comienzos de la era informática, un lenguaje de programación venía con tipos (como integers (enteros), booleans (booleanos), strings (cadenas), etc.) y procedimientos incorporados; p. ej., para la entrada y salida de datos. Los usuarios podían definir sus propios procedimientos, y de este modo se construyeron programas de gran tamaño. La idea de tipos abstractos supuso un gran avance en el desarrollo de software. Según esta idea, se podría diseñar un lenguaje de programación que admitiese también tipos definidos por el usuario. Esta idea surgió del trabajo de muchos investigadores, en particular Dahl (creador del lenguaje Simula), Hoare (quién desarrolló muchas de las técnicas que se utilizan actualmente para trabajar con tipos abstractos), Parnas (que acuñó el concepto “ocultación de datos”, y que por primera vez articuló la idea de organizar los módulos de un programa de acuerdo con el contenido que encapsulaban), y, ya por último, Barbara Liskov y John Guttag, profesores de MIT, que realizaron un trabajo clave en relación con la especificación de tipos abstractos, y con el soporte de un lenguaje de programación para éstos (y que por cierto, desarrollaron el presente curso). La abstracción de datos parte de la idea de que lo que caracteriza a un tipo determinado son las operaciones que se pueden realizar en él. Un número es algo que se puede sumar y multiplicar; una string es algo que se puede concatenar y que puede tomar una substring (subcadena); un tipo booleano es algo que se puede negar, y así sucesivamente. En cierto modo, los usuarios podían ya definir sus propios tipos en los primeros lenguajes de programación: era posible crear un tipo date a través de un recurso de programación record; por ejemplo, con campos integer para el día, el mes y el año. No obstante, la originalidad de los tipos abstractos radicaba en el énfasis en las operaciones: el usuario del tipo no necesitaba preocuparse por cómo sus valores se almacenaban, del mismo modo en que un programador puede ignorar cómo el compilador guarda los integers. Lo que interesa aquí son las operaciones. En Java, como en muchos lenguajes de programación modernos, la separación entre tipos incorporados y tipos definidos es un tanto imprecisa. Las clases del paquete java.lang, como Integer y Boolean son incorporadas; la cuestión de si considerar o no que todas las colecciones de java.util sean incorporadas es un asunto menos claro (y no muy importante de todas formas). Java complica este tema al tener tipos primitivos que no son objetos. El conjunto de estos tipos, como int y boolean, no puede ser extendido por el usuario.

5.3. Clasificación de tipos y operaciones

50

Los tipos, ya sean incorporados o definidos por el usuario, pueden clasificarse como mutables o inmutables. Los objetos de un tipo mutable pueden ser alterados, es decir, facilitan operaciones que, cuando son ejecutadas, hacen que los resultados de otras operaciones sobre el mismo objeto provoquen resultados diferentes. Por tanto, Vector es mutable porque usted puede llamar a addElement y observar la alteración con la operación size, que provocará un resultado distinto en cada ejecución de addElement. Sin embargo, String es inmutable porque sus operaciones crean nuevos objetos String

Page 60: Curso Practico en Java de Ingenieria Del Software Mit

en vez de alterar los ya existentes. En algunas ocasiones, un tipo se facilitará de dos formas, una mutable y otra inmutable. StringBuffer, por ejemplo, es una versión mutable de String (aunque los dos no son, sin duda alguna, el mismo tipo dentro del lenguaje Java, y por tanto, no se pueden intercambiar). Generalmente, se trabaja mejor con tipos inmutables. El fenónemo llamado Aliasing1 no es un problema, ya que el reparto no puede ser observado. Algunas veces, la utilización de tipos inmutables es más eficiente, ya que podemos tener más reparto. Sin embargo, muchos problemas se expresan de forma más natural mediante el uso de tipos mutables, que resultan más eficaces cuando se trata de alteraciones locales en grandes estructuras. Las operaciones de un tipo abstracto se clasifican de la siguiente forma: · Constructores: crean nuevos objetos de un determinado tipo. Un constructor puede recibir un objeto

como argumento, pero no un objeto del tipo que está siendo construido. · Productores: crean nuevos objetos a partir de objetos ya existentes; los términos son sinónimos. El

método concat de una String, por ejemplo, es un productor: recibe dos strings y produce una nueva que represente la concatenación.

· Mutadores o modificadores: cambian el valor de los objetos. El método addElement de la clase Vector, por ejemplo, altera un vector al añadir un elemento al final del mismo.

· Observadores: reciben objetos de un determinado tipo abstracto y devuelven objetos de un tipo distinto.El método size de la clase Vector, por ejemplo, devuelve un entero.

Podemos resumir estas distinciones esquemáticamente de la siguiente forma: constructor: t -> T

productor: T, t -> T mutador: T, t -> void observador: T, t -> t

Este esquema muestra de modo informal el formato de las operaciones en las diversas clases. Cada T es un tipo abstracto por sí sólo; cada t representa a algún otro tipo. En general, cuando un tipo aparece en la parte izquierda, indica que puede darse más de una vez. Por ejemplo, un productor puede recibir dos valores de un determinado tipo abstracto, al igual que el método concat de String recibe dos strings. Las apariciones de t a la izquierda pueden omitirse también; los observadores no reciben ningún argumento que no sea de tipo abstracto (como size, por ejemplo), y otros pueden recibir varios. Esta clasificación proporciona una terminología bastante útil, pero no llega a ser perfecta. En tipos de datos complejos, por ejemplo, pueden existir operaciones que son a la vez productores y mutadores. Hay quién utiliza el término productor para enfatizar que no se da ninguna transformación de datos. Otro término que conviene conocer es iterator. Un iterator es normalmente un tipo de método especial (no disponible en Java) que devuelve una colección de objetos, devolviendo uno cada vez; como, por ejemplo, los elementos que están en un conjunto. En Java, un iterator es una clase que proporciona métodos que pueden usarse luego para obtener una colección de objetos, devolviendo uno cada vez. La mayoría de las clases de colecciones están provistas de un método con el nombre iterator, que devuelve un objeto de tipo java.util.Iterator, para que sus objetos sean extraídos por un iterador propiamente dicho.

1 El fenómeno Aliasing se explica detalladamente en la sección 9.7 del tema 9.

51

Page 61: Curso Practico en Java de Ingenieria Del Software Mit

5.4.Ejemplo: Lista Observemos un ejemplo de un tipo abstracto: la lista. Una lista, en Java, es como un array. Facilita métodos para extraer al elemento de un determinado índice y para sustituirlo en un determinado índice. Sin embargo, a diferencia del array, posee también métodos para insertar o quitar un elemento de un determinado índice. En Java, el tipo List es una interfaz con muchos métodos, pero por ahora, supongamos que es una clase simple que comprende los siguientes métodos:

public class List { public List (); public void add (int i, Object e); public void set (int i, Object e); public void remove (int i); public int size (); public Object get (int i);

} Los métodos add, set y remove son mutadores; los métodos size y get son observadores. Es normal que un tipo mutable no tenga productores (y que un tipo inmutable, sin duda, no pueda tener mutadores). Para especificar estos métodos, nos hará falta alguna expresión que nos permita explicar cómo es una lista. Utilizaremos para ello el concepto de campos de especificación. Puede pensar que un objeto de un determinado tipo posee estos campos, pero acuérdese de que éstos no tienen que ser necesariamente campos de la implementación, y que no hace falta que el valor de un campo de la especificación se pueda obtener por medio de algún método. En este caso, describiremos las listas con un único campo de especificación,

seq [Object] elems;

donde para una lista l, la expresión l.elems indicará la secuencia de objetos almacenados en ella, indexada desde cero. Veamos ahora algunos métodos especificados:

public void get (int i); // throws // IndexOutOfBoundsException if i < 0 or i > length (this.elems) // returns // this.elems [i] public void add (int i, Object e); // modifies this // effects // throws IndexOutOfBoundsException if i < 0 or i > length (this.elems) // else this.elems’ = this.elems [0..i-1] ^ <e> ^ this.elems [i..] public void set (int i, Object e); // modifies this // effects // throws IndexOutOfBoundsException if i < 0 or i >= length (this.elems) // else this.elems’ [i] = e y this.elems es inalterado en cualquier otro lugar

En la poscondición de add, he utilizado s[i..j] para referirme a la subsecuencia de s que va desde el índice i hasta j, y s[i..] para indicar la secuencia de elementos a partir del sufijo i. El acento circunflejo hace referencia a la concatenación de secuencias. Por tanto, la poscondición dice que, cuando el valor del índice pasado como argumento está dentro de los límites del array, el nuevo elemento se coloca junto al índice pasado como argumento.

52

Page 62: Curso Practico en Java de Ingenieria Del Software Mit

5.5. Cómo diseñar un tipo abstracto

El diseño de un tipo abstracto supone la elección de buenas operaciones y la definición de su comportamiento. Algunos consejos generales serían: · Es mejor tener unas cuantas operaciones simples que se puedan combinar para realizar funciones

más complejas, que tener un gran número de operaciones complicadas. · Cada operación debe tener un propósito bien definido y mostrar un comportamiento

coherente, y no un despliegue de casos especiales. · El conjunto de operaciones debe ser apropiado, y constar de un número de operaciones suficiente para realizar los tipos de cómputos que probablemente necesiten los clientes. Una buena prueba consiste en

comprobar que cada propiedad de un objeto de un determinado tipo puede extraerse. Por ejemplo, si no hubiese una operación get, no podríamos averiguar cuáles son los elementos de la lista. La obtención de información básica no debería ser una complicación para el cliente. El método size no es estrictamente necesario, ya que podríamos aplicar el método get sobre los valores incrementales del índice, pero esto resultaría ineficaz y poco práctico.

· El tipo puede ser genérico: una lista o conjunto, o un grafo, por ejemplo. Puede ser también específico del dominio: un mapa de calle, una base de datos de empleados, una guía telefónica, etc. Sin embargo, no se deberían mezclar características genéricas con aquellas específicas del dominio.

5.6. La elección de representaciones Hasta ahora, nos hemos centrado en la caracterización de tipos abstractos a través de sus operaciones. En el código, una clase que implemente a un tipo abstracto facilita una representación: la estructura de datos propiamente dicha que sustenta las operaciones. La representación consistirá en una colección de campos, cada uno de los cuales posee algún otro tipo de Java; en una implementación recursiva, puede que un campo tenga un tipo abstracto (una clase), pero esto rara vez se hace en Java. Por ejemplo, las listas encadenadas constituyen una representación común de (las) listas. El siguiente modelo de objeto muestra una implementación de una lista encadenada semejante (pero no idéntica) a la

List

Entry

Object

header

element

next prev

53

clase LinkedList que se encuentra en la librería estándar de Java: El objeto lista posee un campo header que hace referencia a un objeto Entry. Un objeto Entry es un registro con tres campos: next y prev, que pueden mantener referencias a otros objetos Entry (o pueden ser nulos), y element, que mantiene una referencia a un objeto que, de hecho, es un elemento almacenado en la lista. Los campos next y prev son enlaces que apuntan hacia delante o hacia atrás a lo largo de la lista. En la mitad de la lista, después de una llamada consecutiva a los métodos next y prev , el puntero de la lista apuntará al objeto apuntado al comienzo, antes de las llamadas a los métodos. Supongamos que la lista encadenada no almacena referencias nulas como elementos. Habrá siempre un elemento Entry auxiliar al comienzo de la lista, cuyo campo element es nulo, pero éste no se interpretará como un elemento.

Page 63: Curso Practico en Java de Ingenieria Del Software Mit

El siguiente diagrama de objetos muestra una lista con dos elementos:

( List )

Otra representación diferente de listas utiliza un array. El siguiente modelo de objeto muestra cómo se

next

header

elementprev

next ( Entry ) ( Entry )

( Object )

element prev

( Entry )

( Object )

List

Object[]

Object

elementData

elts[]

representan las listas en la clase ArrayList de la librería estándar de Java: aquí tenemos una lista con dos elementos en su representación.

54

Page 64: Curso Practico en Java de Ingenieria Del Software Mit

( Object ) elts[1]

( Object )

( Object[] ) Element Data

elts[0]

( List )

Estas representaciones poseen distintas ventajas. La representación de la lista encadenada será más eficaz cuando haya muchas inserciones en su parte delantera, ya que puede sumarse un nuevo elemento a la cadena simplemente modificando un par de punteros. La representación por array, durante una inserción, tiene que promover todos los elementos que se encuentren por encima del índice del elemento insertado hacia arriba, aunque si el array es demasiado pequeño, es posible que sea necesario asignar uno nuevo, mayor que el anterior, y copiar todas las referencias para la creación de una nueva lista. Sin embargo, si hay muchas operaciones get y set, la representación de la lista por array resulta más conveniente, dado que proporciona acceso aleatorio en tiempo constante, mientras que la lista encadenada tiene que realizar una búsqueda secuencial. Es posible que no sepamos qué operaciones predominarán cuándo estemos escribiendo código para la representación de una lista. La cuestión crucial es entonces, cómo podemos tener la certeza de que será fácil cambiar las representaciones posteriormente.

5.7. Independencia de representación La independencia de representación implica el asegurar que el uso de un determinado tipo abstracto es independiente de su representación, de modo que las alteraciones en ésta no causen efectos en el código exterior que utiliza el código del tipo abstracto. Examinemos qué es lo que falla si no hay independencia de representación, y luego centrémonos en algunos mecanismos del lenguaje que nos ayuden a garantizar la independencia. Imagine que sabemos que nuestra lista está implementada como un array de elementos. Estamos intentando utilizar un código que cree una secuencia de objetos pero, desafortunadamente, este código almacena una secuencia en un objeto Vector y no en un List. El tipo de datos List que estamos utilizando no ofrece un constructor capaz de recibir un objeto de tipo Vector y hacer la conversión automáticamente. Descubrimos que Vector posee un método llamado copyInto que copia los elementos del vector en un array. Escribimos por tanto, el siguiente código:

List l = new List (); v.copyInto (l.elementData); donde v. representa una instancia de un objeto Vector.

Se trata de un truco muy hábil pero que, como la mayoría de los trucos, sólo sirve para cosas puntuales. Suponga que el implementador de la clase List decide alterar la representación de la versión que utiliza array para una versión de lista encadenada. Ahora la lista l no tendrá un campo elementData, como había cuando usaba la representación de array. Además, el compilador rechazará el programa. Esto es un fallo de independencia de representación: tendremos que cambiar todos los lugares del código en los que hicimos esto, es decir, copiar un Vector para un List. El fallo en la compilación no es tan grave como parece. Sería mucho peor si funcionara y la alteración averiara el programa, lo que ocurriría del siguiente modo: En general, el tamaño de un array tendrá que ser mayor que el número de elementos de la lista, dado que de lo contrario, sería necesario crear un nuevo array cada vez que un elemento se añade o se quita. Por tanto, debe existir algún modo de marcar el final de un segmento del array que contenga los elementos. Imagine que el implementador de la lista lo ha diseñado asumiendo que el final de la lista está marcado por una única referencia nula, que una vez encontrada, se interpretará como el final de la lista de elementos, o por el final del array propiamente dicho, que se encontró primero. Afortunadamente (o en este caso, desafortunadamente), nuestro truco sólo funciona bajo estas circunstancias.

55

Page 65: Curso Practico en Java de Ingenieria Del Software Mit

Ahora, nuestro implementador se da cuenta de que esta era una decisión poco acertada, ya que para definir el tamaño de la lista es necesario realizar una búsqueda secuencial, y de este modo encontrar la primera referencia nula. Por lo tanto, añade un campo size y lo actualiza cada vez que una operación altera el contenido de la lista. Esta es una mejor solución, ya que encontrar ahora el tamaño de la lista puede realizarse en tiempo constante. Además, la lista podrá manipular de forma natural referencias nulas como elementos de la lista, motivo por el cual la implementación LinkedList de Java utiliza esta solución. En este caso, es probable que nuestro audaz truco produzca algún comportamiento erróneo cuya causa será difícil de encontrar. La lista que creamos tenía un campo size con valor cero, a pesar de los muchos elementos que había en la lista (ya que, durante la copia, actualizamos solamente el array y no el campo size). Las operaciones get y set aparentemente funcionan; sin embargo, la primera llamada al campo size fallará sin razón aparente. Aquí mostramos otro ejemplo. Imagine que tenemos la implementación de la lista encadenada, y que añadimos una operación que devuelve el objeto Entry correspondiente a un índice en concreto.

public Entry getEntry (int i) Nuestro fundamento se basa en que si existen muchas llamadas al método set en el mismo índice, esto evitará la búsqueda secuencial que consistía en obtener el elemento reiteradamente. En vez de

l.set (i, x); ... ; l.set (i, y) podemos escribir ahora

Entry e = l.getEntry (i); e.element = x; ... e.element = y; una alternativa basada en el conocimiento de la representación del dato abstracto que puede ofrecer

ventajas en función del rendimiento, ya que la búsqueda secuencial es relativamente costosa. No obstante, esta alternativa también viola la independencia de representación, ya que cuando haya una alteración en la implementación de List para la representación de array, no existirán más objetos Entry. Podemos ilustrar el problema con un diagrama de dependencia de módulos:

List

Entry

Object

Client

BAD

56

Page 66: Curso Practico en Java de Ingenieria Del Software Mit

Debería existir únicamente una dependencia del tipo Client sobre la clase List (y sobre la clase del tipo element, que es, en este caso, Object). La dependencia de Client sobre Entry es la causa de nuestros problemas. Volviendo a nuestro modelo de objeto para esta representación, queremos que la clase Entry y sus asociaciones sean internas a List. Podemos representar esto de manera informal, pintando de rojo las partes que deberían ser inaccesibles a la parte Client (si está leyendo una impresión en blanco y negro, esta parte correspondería a Entry, con todos sus arcos entrantes y salientes), y de amarillo, la parte denominada Entry, y añadiendo un campo de especificación elems que oculte la representación:

elems[] next

List

Entry

Object

header

element

prev

La representación queda explicada en el ejemplo de Entry. Una presentación más aceptable, y bastante común, surge de la implementación de un método que devuelve una colección. Cuando la representación contiene ya un objeto colección del tipo adecuado, resulta tentador devolverlo directamente. Por ejemplo, imagine que List posee un método toArray que devuelve un array de elementos correspondientes a los elementos de la lista. Si hubiésemos implementado la lista como un array, podríamos simplemente devolver el array propiamente dicho. Si el campo size estuviese basado en el índice en el que una referencia nula aparece por primera vez, una modificación en este array podría impedir el cálculo del tamaño del mismo.

a =l.toArray (); a[i] = null;

// presenta la representación //¡ops!

… l.get (i); // ahora tiene un comportamiento impredecible

Una vez que size se ha calculado erróneamente, todo puede fallar: las operaciones posteriores pueden tener un comportamiento imprevisible.

5.8.Mecanismos del lenguaje

Para evitar que se acceda a las representación, podemos definir los campos como privados, lo que impide poner en práctica el truco del array anteriormente explicado; por ejemplo, la sentencia

v.copyInto (l.elementData); sería rechazada por el compilador porque la expresión l.elementData estaría haciendo referencia, ilegalmente, a un campo privado desde un lugar externo a su clase. El problema del campo Entry no es tan fácil de resolver. No hay acceso directo a la representación, sino que la clase List devuelve un objeto Entry que pertenece a la representación. Esto se conoce como exposición de la representación, y no puede evitarse únicamente por mecanismos del lenguaje. Es necesario que comprobemos que las referencias a los componentes mutables internos de la representación no sean pasados a los clientes externos, y que la representación no se construya a partir de objetos mutables pasados como argumentos para una representación interna.

57

Page 67: Curso Practico en Java de Ingenieria Del Software Mit

En la representación por array, por ejemplo, no podemos permitir un constructor que reciba un array y lo atribuya al campo interno. Las interfaces están provistas de otro método para conseguir la independencia de representación. En la librería estándar de Java, las dos representaciones de listas que tratamos anteriormente son en realidad clases distintas: ArrayList y LinkedList. Ambas están declaradas como extensiones de la interfaz List. La interfaz rompe la dependencia entre el cliente y otra clase, en este caso la clase de representación:

List

ArrayList LinkedList

Este enfoque es bueno porque una interfaz no puede tener campos (no estáticos), por lo que nunca se plantea la cuestión de acceder a la representación. Sin embargo, debido a que las interfaces de Java no pueden tener constructores, puede ser incómodo utilizar este recurso en la práctica, ya que la información relativa al modo de invocar a los constructores compartidos entre las clases de la implementación que están en una misma interfaz, no puede expresarse a través de ésta. Además, dado que el código cliente debe, en algún punto, construir objetos, existirán dependencias sobre clases concretas (que obviamente trataremos de localizar) y no sólo sobre la interfaz, como supone la práctica del desacoplamiento. El patrón Factory, que iremos viendo a lo largo de esta asignatura, trata este problema en particular.

5.9.Resumen Los tipos abstractos se caracterizan por sus operaciones. La independencia de representación hace posible la alteración de la representación de un tipo, sin que sus clientes tengan que ser alterados también. En Java, los mecanismos de control de acceso y las interfaces pueden ayudar a garantizar la independencia de representación. No obstante, la exposición de la representación es más complicada, ya que puede forzar la utilización de un tipo abstracto, y es por tanto necesario, que sea manipulada dentro de una esmerada disciplina de programación.

58

Page 68: Curso Practico en Java de Ingenieria Del Software Mit

Clase 6: Invariantes de representación y funciones de abstracción

6.1 Introducción

En esta clase, vamos a describir dos herramientas utilizadas para la comprensión de tipos de datos abstractos: los invariantes de representación y la función de abstracción. El invariante de representación describe si una instancia de un determinado tipo está bien formada; la función de abstracción nos indica cómo debemos interpretarla. Los invariantes de representación pueden aumentar el poder de las pruebas. Resulta imposible codificar un tipo abstracto o modificarlo sin comprender la función de abstracción, al menos informalmente. Escribir tal función es útil, especialmente para el mantenimiento del software, y resulta crucial en situaciones complicadas.

6.2 ¿Qué es un invariante Rep?

Un invariante de representación, o invariante Rep, en forma abreviada, es una restricción que, desde el punto de vista de la representación, caracteriza si una instancia de un tipo de datos abstracto está o no bien formada. Matemáticamente, es una fórmula relacionada con la representación de una instancia; puede considerarla como una función que recibe objetos de un determinado tipo abstracto y devuelve los valores true o false dependiendo de si están o no bien formadas:

RI : Object -> Boolean Considere la implementación de lista encadenada que estudiamos en el tema anterior. Aquí le mostramos su modelo de objeto:

?

?

next

! ?

?

?

List

Entry

Object

header

element

?

prev

La clase LinkedList posee un campo, header, que mantiene una referencia a un objeto de la clase Entry. Este objeto posee a su vez tres campos: element, que mantiene una referencia a un elemento de la lista; prev, que apunta a la entrada anterior de la lista de datos; y next, que apunta al elemento posterior a lo largo de la lista.

Este modelo de objeto muestra la representación del tipo de datos abstracto. Como hemos mencionado

59

Page 69: Curso Practico en Java de Ingenieria Del Software Mit

antes, los modelos de objeto pueden diseñarse en varios niveles de abstracción. Desde el punto de vista del usuario de la lista, se podría elidir el recuadro que representa a Entry, y simplemente mostrar un campo de especificación de List para Object. Este diagrama muestra ese modelo de objeto en negro, con la representación en dorado (Entry y sus arcos entrantes y salientes) escondida:

?

?

next

?

?

?

List

Entry

Object

element

?

!

header

elems[] prev

El invariante de representación es una restricción válida para todas las instancias del tipo. Nuestro modelo de objeto nos ofrece algunas de sus propiedades: · Muestra, por ejemplo, que el campo header mantiene una referencia para un objeto de la clase

Entry. Esta propiedad es importante, pero no demasiado interesante, ya que el campo ya se ha declarado para poseer ese tipo; este tipo de propiedad es más interesante cuando se utiliza para expresar el contenido de contenedores polimórficos, como los vectores, cuyo tipo de elemento no puede expresarse en código fuente.

· El signo de multiplicidad ! al final de la flecha del campo header indica que el campo header no puede ser nulo. (El símbolo ! indica exactamente uno).

· El signo de multiplicidad ? al final de las flechas de los campos next y prev indica que cada flecha de next y prev apunta como máximo a una entrada. (El símbolo ? denota cero o uno).

· El signo de multiplicidad ? al inicio de las flechas de los campos next y prev indican que cada entrada (objeto Entry) es apuntada como máximo por otro campo next, y como máximo por otro campo prev. (El símbolo ? denota cero o uno).

· El símbolo de multiplicidad ? al final de la flecha del campo element indica que cada Entry apunta como máximo a un Object.

Algunas propiedades del modelo de objeto no son parte del invariante de representación. Por ejemplo, el hecho de que los objetos Entry no estén compartidos entre las listas (lo cual está indicado por la multiplicidad al inicio de la flecha del campo header) no es una propiedad de ninguna lista individual.

Existen propiedades del invariante de representación que no se muestran en el modelo gráfico de objeto: · Cuando hay dos entradas e1 y e2 en la lista, si e1.next = e2, entonces e2.prev =

e1.

60

Page 70: Curso Practico en Java de Ingenieria Del Software Mit

· La entrada opcional en cabeza de lista posee un campo element.

Existen también propiedades que no aparecen porque el modelo de objeto sólo muestra objetos y no valores primitivos. La representación de LinkedList posee un campo size que mantiene el tamaño de la lista. Una propiedad del invariante Rep es que el valor de size es igual al número de entradas de la representación de la lista menos uno (dado que la primera entrada es un auxiliar).

( Object ) ( Object )

En realidad, en la implementación de Java java.util.LinkedList, el modelo de objeto posee una restricción adicional, reflejada en el invariante Rep. Toda entrada, es decir, todo objeto Entry, posee campos no nulos como next y prev:

Observe los signos de multiplicidad en negrita en las flechas next y prev. Aquí puede observar una lista de ejemplo de dos elementos (y por tanto, tres entradas, si incluimos el elemento auxiliar):

prev

next

( List )

header

element prev

next ( Entry ) ( Entry )

element

prev

( Entry )

next

!

?

!

!

?

List

Entry

Object

element

prev next

!

!

header

61

Page 71: Curso Practico en Java de Ingenieria Del Software Mit

Cuando se examina un invariante de representación, es importante darse cuenta no sólo de qué restricciones están presentes, sino también de cuáles faltan. En este caso, no es necesario que el campo element sea non-null, ni que no se compartan los elementos. De este modo podemos esperar lo siguiente: una representación permite que una lista contenga referencias null, y que posea al mismo objeto en múltiples posiciones.

Resumamos nuestro invariante Rep informalmente: Para cada ejemplo de la clase LinkedList

el campo header es non-null el campo header posee un campo element con valor null existen (size + 1) entradas las entradas forman un ciclo que se inicia y acaba con la entrada header para cualquier entrada, tomar prev y luego next, le devuelve a la entrada, es decir, al mismo lugar

Podemos escribir esto también de un modo algo más formal:

para todo p: LinkedList | p.header != null && p.header.element = null && p.size + 1 = | p.header.*next |

&& p.header = p.header.next && para todo e en p.header.*next | e.prev.next = e

Para comprender esta fórmula, es necesario que sepa que: · para cualquier expresión que represente a un conjunto de objetos, y para cualquier campo f: e.f

p.size + 1

representa el conjunto de objetos que se alcanza cuando se sigue a f a partir de cada objeto en e; · e.*f representa el conjunto de objetos obtenidos al seguir f un número arbitrario de veces a partir de

cada uno de los objetos en e; · | e | es el número de objetos del conjunto representado por e.

Así que p.header.*next, por ejemplo, representa al conjunto de todas las entradas de la lista, ya que este conjunto se consigue a través de la lista p, siguiendo al campo header y luego al campo next cualquier número de veces.

Lo que se ve muy claro a través de esta fórmula, es que el invariante de representación hace referencia a una única lista encadenada p. Otro modo de escribir el invariante sería éste:

R(p) = p.header != null && p.header.element = null && p.size + 1 = | p.header.*next|

&& p.header = p.header.next && para todo e en p.header.*next | e.prev.next = e

en la que consideramos al invariante como una función booleana. Éste es el punto de vista que adoptaremos cuando convirtamos el invariante en código como una aserción en tiempo de ejecución.

p.size + 1

La elección del invariante puede tener un efecto importante tanto en la dificultad de escribir el código de la implementación del tipo abstracto, como en la evaluación del funcionamiento de dicho código. Imagine que reforzamos nuestro invariante, al ser necesario que el campo element de todas las entradas, a excepción de header, sea non-null. Esta alteración nos permitiría detectar la entrada header, al comparar su campo element con el valor null; con el invariante que actualmente estamos utilizando como ejemplo, las operaciones que requieran atravesar la lista, deben contener entradas en vez de comparar el campo element de header.

62

Page 72: Curso Practico en Java de Ingenieria Del Software Mit

Imagine, por el contrario, que debilitamos el invariante en los punteros next y prev y permitimos que prev al inicio y next al final, tengan cualquier valor. El resultado será la necesidad de un tratamiento especial para las entradas al inicio y al final, dando como resultado un código menos uniforme. Exigir que tanto prev al inicio como next al final, sean valores null, no sirve de mucha ayuda.

6.3 Razonamiento por inducción

El invariante de representación hace que el razonamiento modular sea posible. No es necesario verificar ningún otro método para comprobar si una operación se ha implementado correctamente. En su lugar, hacemos un llamamiento al principio de inducción. Garantizamos que cada constructor crea un objeto que satisface el invariante, y que cada método mutador y productor conserva al invariante, es decir: si se da un objeto que satisface estos requisitos, se produce otro que también los cumple. De este modo, podemos decir que cada objeto de un determinado tipo satisface el invariante Rep, dado que éste debe haber sido producido por un constructor o alguna secuencia de aplicaciones mutadoras o productoras.

Para ver cómo funciona esto, observemos algunos ejemplos de operaciones de nuestra clase LinkedList. En Java, la representación es declarada como se muestra a continuación:

public class LinkedList { Entry header; int size; class Entry {

Object element; Entry prev; Entry next; Entry (Object e, Entry p, Entry n) {element = e; prev = p; next = n;} }

... Aquí tenemos nuestro constructor:

public LinkedList () { size = 0; header = new Entry (null, null, null); header.prev = header.next = header; }

Observe que el constructor establece el invariante: crea el elemento auxiliar, forma el ciclo y define el tamaño (size) adecuadamente:

El método transformador (mutador) add recibe un elemento y lo añade al final de la lista: public void add (Object o) {

Entry e = new Entry (o, header.prev, header); e.prev.next = e; e.next.prev = e; size++; }

Para verificar este método, podemos asumir que el invariante se mantiene en la nueva entidad Entry que se ha creado. Nuestra labor consiste en mostrar que el invariante también se mantiene al final de la ejecución del método.

63

Page 73: Curso Practico en Java de Ingenieria Del Software Mit

El efecto del código del método add es la adición de un nuevo dato en una posición anterior al método header, o sea, esta nueva entrada se convierte en la última entrada en la cadena de objetos definida por el campo next, de modo que podemos observar que se mantiene la restricción de que los objetos Entry pueden formar un ciclo. Observe que una consecuencia de ser capaz de asumir el invariante en la nueva entidad Entry, es que no es necesario que comprobemos referencias nulas: podemos asumir que e.prev y e.next son non-null, por ejemplo, porque son entradas que existían en la lista a la entrada del método, y el invariante Rep nos indica que todas las instancias de la clase Entry poseen campos prev y next con valor non-null.

Finalmente, examinemos un observador. La operación getLast devuelve el último elemento de la lista o lanza una excepción si la lista está vacía:

public Object getLast () { if (size == 0) throw new NoSuchElementException (); return header.prev.element; }

Nuevamente, podemos asumir un invariante en una entidad Entry. Esto nos permite resolver la referencia header.prev, la cual, según nos indica el invariante Rep, no puede ser null. En este caso, verificar que el invariante se mantiene es esencial, ya que no hay modificaciones.

6.4 Cómo interpretar la representación

Piense nuevamente en el método transformador add, que recibe un elemento y lo añade al final de la lista:

public void add (Object o) { Entry e = new Entry (o, header.prev, header); e.prev.next = e; e.next.prev = e; size++; }

Verificamos que esta operación mantenía el invariante Rep al añadir correctamente una nueva entrada en la lista. Sin embargo, lo que no verificamos es si la adición de la nueva entrada se dio en una posición correcta. ¿Se insertó el nuevo elemento al inicio o al final de la lista? Aparentemente, fue al final, pero esto implica que el orden de las entradas corresponde al orden de los elementos. Sería muy posible (aunque quizás un poco perverso) que una lista p con los elementos o1, o2, o3 tuviese

p.header.next.element = o3; p.header.next.next.element = o2; p.header.next.next.element = o1;

Para solucionar este problema, es necesario que sepamos cómo se interpreta la representación: es decir, cómo considerar una instancia de una LinkedList como una secuencia abstracta de elementos. Esto es lo que hace exactamente la función de abstracción. La función de abstracción para nuestra implementación es:

A(p) = if p.size = 0 then

<> (la lista está vacía) else

64

Page 74: Curso Practico en Java de Ingenieria Del Software Mit

<p.header.next.element, p.header.next.next.element, ...> (la secuencia de elementos con índices 0.. p.size-1 cuyo I-ésimo elemento es p.nexti+1.element)

6.5 Objetos abstractos y objetos concretos

Cuando se piensa en un tipo abstracto, imaginar que los objetos se encuentran en dos dominios (realms) distintos, puede servir de ayuda. En el dominio concreto, tenemos los objetos reales de la implementación. En el dominio abstracto, tenemos una representación matemática de los objetos que se corresponden con el modo en el que la especificación del tipo abstracto describe sus valores.

Suponga que estamos construyendo un programa para manipular el registro de los cursos de una universidad. Para cada curso en concreto, es necesario que indiquemos en cuáles de las cuatro estaciones Fall (otoño), Winter (invierno), Spring (primavera) y Summer (verano), se imparte el curso. Siguiendo el buen estilo de MIT, llamamos a las estaciones F, W, S y U respectivamente. Lo que nos hace falta es un tipo SeasonSet cuyos valores son conjuntos de estaciones; asumiremos que ya tenemos un tipo Season. Esto nos permitirá escribir código de la siguiente forma.

if (course.seasons.contains (Season.S)) ... //donde seasons es una instancia de SeasonSet Existen muchas formas de representar nuestro tipo. Podríamos ser perezosos y utilizar java.util.ArrayList; esto nos permitirá escribir la mayoría de nuestros métodos como envoltorios (wrappers) simples. Los dominios abstractos y concretos podrían representarse como se indica a continuación:

Dominio abstracto

[F, W, S]

{ F, W } { F, W, S }

[W, F] [W, W, F, S] [W, F, S]

Dominio concreto A A A A

En la figura, el óvalo etiquetado debajo con el rótulo [F,W,S] representa un objeto concreto que contiene la lista almacenada en el array, cuyo primer elemento es F, el segundo es W y el tercero es S. El óvalo etiquetado encima con {F,W,S} representa un conjunto que contiene tres elementos F, W y S. Observe que puede haber múltiples representaciones del mismo conjunto abstracto: {F, W, S}, por ejemplo, puede estar representado también por [W,F, S], ya que el orden no tiene importancia, o por [W,W,F, S] si el invariante Rep permite duplicados. (Ni que decir tiene que existen muchos conjuntos abstractos y objetos concretos que no hemos mostrado; el diagrama únicamente da un ejemplo).

65

Page 75: Curso Practico en Java de Ingenieria Del Software Mit

La relación entre los dos dominios es una función, ya que cada objeto concreto es interpretado como máximo como un valor abstracto. La función puede ser parcial, dado que algunos objetos concretos, principalmente aquellos que violan el invariante Rep, no tienen interpretación. Esta función es la función de abstracción, y está representada por las flechas marcadas con una A en el diagrama.

Suponga que nuestra clase SeasonSet posee un campo eltlist (elements list o lista de elementos) que es del tipo ArrayList. Podemos entonces escribir la función de abstracción del siguiente modo:

A(s) = {s.eltlist.elts [i] | 0 <= i <= size(s.eltlist)} Es decir, el conjunto se compone de todos los elementos de la lista.

Las representaciones distintas poseen diferentes funciones de abstracción. Otro modo de representar nuestro SeasonSet consiste en utilizar un array de posiciones para 4 valores booleanos. Aquí, la función de abstracción puede por ejemplo, asociar

[true, false, true, false] con {F,S}, teniendo en cuenta el orden F, W, S, U para los elementos del array. Este orden es la información transmitida por la función de abstracción, que podría escribirse, asumiendo que el array se almacene en un campo llamado boolarr, de la siguiente manera:

A(s) =

(if s.boolarr[0] then {F} else {}) U

(if s.boolarr[1] then {W} else {}) U

(if s.boolarr[2] then {S} else {}) U (if s.boolarr[3] then {U} else {})

Podríamos haber elegido igualmente una función de abstracción distinta, que ordenase las estaciones de otra forma:

A(s) =

(if s.boolarr[0] then {S} else {}) U(if s.boolarr[1] then {U} else {}) U

(if s.boolarr[2] then {F} else {}) U (if s.boolarr[3] then {W} else {})

Una lección importante que proviene de este último ejemplo es que “elegir una representación” tiene más valor que nombrar algunos campos y seleccionar sus tipos. El mismo array de valores booleanos puede interpretarse de diferentes modos; una función de abstracción define cuál es esa interpretación. Asimismo, en nuestro ejemplo de lista encadenada, una función de abstracción nos dice cómo el orden de las entradas corresponde al orden de los elementos. Un típico error de principiante es imaginar que la función de abstracción es obvia, ya que siempre puede deducirla a partir de las declaraciones del código. Desafortunadamente, esto no es siempre verdad: es necesario, por ejemplo, hacer una lectura cuidadosa del código de la lista encadenada para descubrir que la primera instancia de Entry es sólo un objeto auxiliar.

6.6 Ejemplo: Fórmulas booleanas en CNF

Observemos un ejemplo de una representación simple con una función de abstracción complicada. Una fórmula booleana es una fórmula matemática construida a partir de proposiciones (símbolos a los que se pueden asignar los valores true y false) y operadores lógicos, por ejemplo, la siguiente fórmula

66

Page 76: Curso Practico en Java de Ingenieria Del Software Mit

CourseSix => sixOneSeventy

utiliza dos proposiciones, courseSix y sixOneSeventy y el operador lógico “implica”. La fórmula dice que si courseSix es true, sixOneSeventy es también true. Una fórmula booleana tiene posibilidades de ser satisfecha (es decir, que pueda satisfacerse), si existe algún conjunto de valores booleanos que, atribuidos a las proposiciones, hacen que la fórmula sea true. La fórmula de arriba es proclive a ser satisfecha, ya que podemos atribuir false a courseSix, o podemos asignar el valor true a ambas proposiciones. Un algoritmo que determina si una fórmula en concreto puede cumplirse, y si puede, devuelve valores que satisfacen las proposiciones, es conocido como solucionador SAT. Los solucionadores SAT poseen muchas aplicaciones y su tecnología ha avanzado espectacularmente en la última década. Se utilizan en herramientas de diseño para comprobar restricciones de diseño, en planeadores para encontrar planos, en herramientas de prueba para encontrar tests que presenten clases de errores especiales, etc. Un solucionador SAT puede utilizarse también para comprobar una prueba. Imagine que a partir de

CourseSix => sixOneSeventy y de

sixOneSeventy =>lateNights ¡obtenemos esto!:

courseSix => lateNights Esto es un razonamiento elemental que utiliza el modus ponens, por supuesto, pero veamos cómo se puede comprobar esta formulación con un solucionador SAT . Lo que hacemos simplemente es, unir las premisas a la negación de la conclusión (courseSix => sixOneSeventy) (sixOneSeventy => lateNights) ( ! (courseSix

=> lateNights))

y presentar esta fórmula al solucionador. El solucionador decidirá que la fórmula no se puede satisfacer, y habrá demostrado que es imposible que las premisas sean true sin que la conclusión también lo sea: dicho de otro modo, la prueba es válida.

La mayoría de los solucionadores SAT utilizan una representación de fórmulas booleanas que se conoce con el nombre de conjunctive normal form (CNF) o forma normal conjuntiva. Una fórmula CNF es un conjunto de cláusulas; cada cláusula es un conjunto de literales; un literal es una proposición o su negación. La fórmula se interpreta como una disyunción de sus literales (símbolos individuales). Un nombre más apropiado para CNF es producto de las sumas, que deja claro que el operador más externo es un producto (es decir, una conjunción).

Por ejemplo, la fórmula CNF {{a}{ !b,c}}

equivale a la fórmula convencional a ? (!b V c )

Nuestra fórmula de arriba se representaría en CNF como { {! courseSix,sixOneSeventy}, {! sixOneSeventy, lateNights}, {courseSix}, {! lateNights} }

Pensemos ahora cómo podríamos construir un tipo de datos abstracto que soportase fórmulas en CNF. Imagine que poseemos ya una clase Literal para representar literales.

67

Page 77: Curso Practico en Java de Ingenieria Del Software Mit

Aquí mostramos una representación aceptable que utiliza la clase ArrayList de la librería de Java: public class Formula {

private ArrayList clauses; ... }

El campo clauses es del tipo ArrayList, cuyos elementos son listas de literales del tipo ArrayLists.

Nuestro invariante de representación podría entonces representarse de la siguiente forma: R(f) =

f.clauses != null && para todo c: f.clauses.elts |

c instanceof ArrayList && c != null && para todo l: c.elts | c instanceof Literal && c != null

He utilizado el campo de especificación elts aquí para representar los elementos de un ArrayList. El invariante de representación determina que los elementos de las cláusulas de ArrayList son referencias non-null para objetos lista del tipo ArrayList, cada uno de los cuales contiene elementos non-null, que son del tipo Literal. Aquí finalmente, está la función de abstracción:

A(f) = true ? C (f.clauses.elts[0]) ? ... ? C(f.clauses.elts[(size(f.clauses) -1]) donde C(c) = false V c.elts[0] V ... V c.elts[0]

Observe cómo he introducido la función auxiliar C que extrae cláusulas para las fórmulas. Si prestamos atención a esta definición, podemos resolver el significado de los casos extremos. Imagine que f.clauses es un ArrayList vacío. Entonces, A(f) será siempre true, ya que los conjuntores que están en el lado derecho de la fórmula en la primera línea, desaparecen. Imagine que f.clauses contiene una única cláusula c, que por sí sola es un ArrayList vacío. Entonces C(c) será falso y A(f) también. Éstos son nuestros dos valores booleanos básicos: true está representado por el conjunto vacío de cláusulas, y false por el conjunto que contiene la cláusula vacía.

6.7 Efectos colaterales benevolentes

¿En qué consiste una operación de tipo observador? En nuestra lección de introducción sobre la independencia de representación y la abstracción de datos, la definimos como una operación que no altera al objeto. Ahora, podemos definirla con mayor libertad:

Una operación puede alterar un objeto de un determinado tipo, mientras que los campos de la representación que son alterados mantengan el mismo valor abstracto que representan. Podemos ilustrar este fenómeno con un diagrama:

68

Page 78: Curso Practico en Java de Ingenieria Del Software Mit

a

A A

op r1 r2

La ejecución de la operación op altera la representación de un objeto desde r1 a r2. Sin embargo, r1 y r2 están asociados mediante la función de abstracción A, al mismo valor abstracto a, por lo que el cliente del tipo de datos no puede observar cualquier alteración.

Por ejemplo, el método get de LinkedList puede guardar en caché el último elemento extraído de la lista, de modo que las llamadas consecutivas al método get para el mismo índice, se ejecutarán más rápidamente. Esta escritura en caché (en este caso sólo los dos campos), ciertamente cambia la representación, pero no tiene efectos en el valor del objeto, como puede observarse a través de las llamadas a sus métodos. El cliente no puede saber si la última operación de búsqueda se colocó en caché (a no ser que se dé cuenta de una mejora en el rendimiento).

En general, por tanto, podemos permitir que los observadores alteren la representación, mientras que se conserve el valor abstracto. Es necesario que garanticemos que el invariante de representación no se ha quebrado y si hemos codificado el invariante como un método checkRep, deberíamos insertarlo al comienzo y al final de la implementación de las operaciones que son de tipo observadores.

6.8 Resumen

¿Por qué se utilizan los invariantes de representación? Guardar el invariante le ahorrará trabajo: · Hace que el razonamiento modular sea posible. Sin una documentación del invariante Rep,

es posible que tenga que leer todos los métodos para comprender qué está sucediendo, antes de que usted pueda añadir un nuevo método con seguridad.

· Ayuda a encontrar errores. Al implementar el invariante como una aserción en tiempo de ejecución, puede encontrar errores difíciles de detectar mediante otros medios.

La función de abstracción especifica cómo la representación de un tipo de datos abstracto se interpreta como un valor abstracto. Junto con el invariante de representación, nos permite razonar de forma modular en relación a la exactitud de una operación del tipo.

En la práctica, las funciones de abstracción son más complicadas de escribir que los invariantes de representación. Escribir un invariante Rep siempre merece la pena, y es algo que siempre debería hacer. A menudo, resulta práctico escribir una función de abstracción, incluso si únicamente se hace de manera informal. Sin embargo, algunas veces, el dominio abstracto es de difícil caracterización, y el trabajo adicional de escribir una función de abstracción elaborada, no está recompensado. Es necesario que lo juzgue bajo su propio criterio.

69

Page 79: Curso Practico en Java de Ingenieria Del Software Mit

Clase 7: Abstracción de iteración e iteradores

7.1 Introducción

En este tema, describiremos la abstracción de iteración y los iteradores. Los iteradores son una generalización del mecanismo de iteración disponible en la mayoría de los lenguajes de programación. Éstos permiten a los usuarios iterar sobre los tipos de datos arbitrarios de un modo práctico y eficaz.

Por ejemplo, un claro uso de un conjunto de elementos consiste en llevar a cabo alguna operación para cada uno de sus elementos:

Para todos elementos del conjunto hacer acción

En este tema, discutiremos cómo se puede especificar e implementar la abstracción de iteración. También describiremos la exposición de representación relacionada con el uso de los iteradores.

7.2 Lecturas

Lea el capítulo 6 de Liskov y Guttag antes de continuar. La primera mitad de los apuntes de este tema está basada en el capítulo 6 de este libro, por lo que no se volverá a repetir aquí.

7.3 Exposición de representación en iteradores

Considere la implementación de un iterador para una clase denominada IntSet. La estructura general de la clase IntSet sería de la siguiente forma:

public class IntSet { private Vector els; // representación

private int size; // representación

// constructores, etc., van aquí, vea pág. 88 de Liskov …

public Iterator elems() { return new IntGen(this); }

// clase interna estática (static inner class ) private static class IntGen implements Iterator {

public boolean hasNext() { … } public Object next() throws NoSuchElementException { … } public void remove(Object o) { … }

} // fin def IntGen }

70

Page 80: Curso Practico en Java de Ingenieria Del Software Mit

Observe el método adicional remove() en la clase IntGen. No es necesario que se implemente, es opcional. El método permite que se extraiga un elemento de IntSet mientras se itera sobre los elementos del conjunto. ¡Debe implementarse muy cuidadosamente!

Observe que en Liskov no se permiten modificaciones en el objeto sobre el que se da la iteración (es decir, en nuestro ejemplo, IntSet). Sin embargo, la interfaz Iterator de Java, incluye el método opcional remove().

Ahora queremos implementar la clase IntGen. Nos damos cuenta de que la clase IntSet está representada por el objeto de tipo Vector els, y que la clase Vector posee un método que devuelve un iterador, así que podríamos, perfectamente, implementar nuestro método elems() de la siguiente forma:

public class IntSet {

… public Iterator elems() {

return els.iterator(); } }

El generador devuelto, els.iterator(), provee a los métodos next(), hasNext() y remove(). Esto nos ahorra mucho trabajo, pero desafortunadamente, provoca una sutil forma de exposición de representación.

Hemos tratado ya una forma simple de exposición de representación relacionada con los métodos remove() en IntSet y Vector. IntSet implementa un método remove() que puede afectar al método size(). El método remove() de la clase Vector no conoce el tamaño (size) de IntSet. Por tanto, si un cliente invoca al método remove() de Vector directamente, algunas cosas pueden fallar, p. ej. size se calculará incorrectamente.

Del mismo modo, en la clase Iterator, si el cliente utiliza directamente g.remove(), donde g = els.iterator(), dado que hay un estado compartido entre els.iterator() y el campo els de Vector, varios errores pueden tener lugar. Resumiremos el problema mediante el dibujo que se muestra a continuación:

71

Page 81: Curso Practico en Java de Ingenieria Del Software Mit

Clase

IntSet

Cliente

els

size() remove() elems()

Vector remove()

dependencia ERRÓNEA

elems()

els.iterator() remove()

next() hasNext()

estado compartido

¿Qué deberíamos hacer? Podríamos, obviamente, retirar el método remove() de la clase IntGen o incluso no llamarlo más, pero esto sería igual que abandonar el problema. Necesitamos que la implementación del método remove() de la clase IntGen sea similar a la implementación del método remove() de la clase IntSet, es decir, manteniendo del mismo modo la integridad del objeto IntSet. Éste sería pues, el único método accesible para el cliente. El método remove() de IntGen puede llamar a g.remove(), dondeg = els.iterator(), el cual manipula la representación subyacente mientras el iterador se está utilizando. Esto queda resumido en el dibujo siguiente:

Clase

size() IntSet

Clase interna del iterador

IntGen

els

els

remove() …

Vector remove()

remove()

els.iterator() remove()

next() hasNext() estado

compartido

72

Page 82: Curso Practico en Java de Ingenieria Del Software Mit

Observe que implementar el método remove() de la clase IntGen, invocando al método remove() del objeto de tipo Vector, es decir, els.remove(), no es tampoco una buena idea. Esto podría arruinar al iterador con respecto a los métodos next() o hasNext( ).

73

Page 83: Curso Practico en Java de Ingenieria Del Software Mit

Clase 8: Modelos de objetos e invariantes

En este tema se consolidan muchas de las ideas fundamentales sobre objetos, representaciones y abstracción, tratadas en temas anteriores. Explicaremos detalladamente la notación gráfica del modelado de objetos y repasaremos los invariantes de representación, las funciones de abstracción y la exposición de representación. Después de leer este tema, es posible que desee volver a los temas del principio para darles un repaso, ya que éstos incluyen más detalles en relación a los ejemplos tratados aquí.

8.1 Modelos de objetos Un modelo de objeto es una descripción de una colección de configuraciones. En este tema, nos centraremos en modelos de objeto en forma de código, en los que las configuraciones son estados de un programa. Sin embargo, a lo largo de la asignatura, veremos que se puede utilizar una misma notación, de forma más genérica, para describir cualquier tipo de configuración, como el formato de un sistema de archivos, una jerarquía de seguridad, una topología de red, etc. Las nociones básicas que yacen bajo los modelos de objeto son increíblemente simples: conjuntos de objetos y las relaciones entre ellos. Lo más complicado para los estudiantes es aprender a construir un modelo útil: cómo capturar las partes interesantes y complicadas de un programa y cómo no entusiasmarse con el modelado de las partes irrelevantes y acabar ante un modelo enorme y de difícil manejo, o por el contrario, decir muy poco, y verse ante un modelo que resulta inútil. Tanto los modelos de objeto como los diagramas de dependencia de módulos, poseen recuadros y flechas. He aquí la única similitud entre ellos. Bueno, de acuerdo, admito que existen algunas conexiones sutiles entre el modelo de objeto y el diagrama de dependencia de módulos de un programa. Sin embargo, a primera vista, es mejor pensar en ellos como si fuesen completamente diferentes. El diagrama de dependencia de módulos aborda la estructura sintáctica, es decir, las descripciones textuales que existen, y cómo éstas están relacionadas entre sí. El modelo de objeto se centra en la estructura semántica, es decir, qué configuraciones se crean en tiempo de ejecución, y qué propiedades poseen. 8.1.1 Clasificación

Un modelo de objeto expresa dos tipos de propiedades: la clasificación de objetos y las relaciones entre ellos. Para expresar la clasificación, dibujamos un recuadro para cada clase de objetos. En un modelo de objeto en forma de código, estos recuadros corresponderán a las clases e interfaces de Java; en una definición más general, simplemente representarán clasificaciones arbitrarias.

47

Page 84: Curso Practico en Java de Ingenieria Del Software Mit

List

ArrayList LinkedList

List

ArrayList LinkedList

Una flecha con la punta gruesa y cerrada, desde la clase A hasta la clase B, indica que A representa un subconjunto de B: es decir, todo A es también un B. Para demostrar que dos recuadros representan subconjuntos distintos, hacemos que éstos compartan la misma punta de flecha. En el diagrama de arriba, LinkedList y ArrayList son subconjuntos distintos de List. En Java, cada declaración implements y extends da como resultado una relación de subconjuntos en un modelo de objetos. Ésta es una propiedad del sistema de tipos: si un objeto o se crea con un constructor de una clase C, y C extiende a D, entonces se considera que o también posee al tipo D. El diagrama de arriba muestra el modelo de objetos a la izquierda. El de la derecha es un diagrama de dependencia de módulos. Sus recuadros representan descripciones textuales – el código de las clases. Sus flechas, como usted recordará, representan la relación “meet” (satisfacer). Por tanto, la flecha que parte de ArrayList hacia List indica que el código de ArrayList satisface la especificación List. Dicho de otro modo, los objetos de la clase ArrayList se comportan como listas abstractas. Ésta es una propiedad sutil que es verdadera debido a los detalles del código. Como veremos más adelante en el tema sobre subtipado (o derivación), es fácil engañarse con esta característica y crear una clase que extiende o implementa a otra sin que exista una relación “meet” entre ellas (en un diagrama de dependencia de módulos, el compartir la punta de la flecha no tiene ninguna importancia).

8.1.2 Campos

Una flecha con una punta abierta desde A hacia B indica que existe una relación entre los objetos de A y los de B. Dado que pueden existir muchas relaciones entre dos clases, le damos nombre a las relaciones y etiquetamos las flechas con los nombres. Un campo f en una clase A, cuyo tipo es B, da como resultado una flecha desde A hasta B etiquetada como f (el nombre del campo). Por ejemplo, el siguiente código produce estructuras que pueden ilustrarse a través del diagrama que se muestra a continuación (ignore por el momento las marcas al final de las flechas):

48

Page 85: Curso Practico en Java de Ingenieria Del Software Mit

List

?

header!! !

prev nextEntry

! !

element?

Object

class LinkedList implements List { Entry header; … }

class Entry { Entry next; Entry prev; Object elt; … }

8.1.3 Multiplicidad

Hasta ahora, hemos visto la clasificación de los objetos en clases y las relaciones que muestran que los objetos de una clase pueden estar relacionados con los objetos de otra. Una cuestión básica sobre la relación entre las clases es la multiplicidad: cuántos objetos de una clase pueden estar relacionados con un determinado objeto de otra clase.

49

Page 86: Curso Practico en Java de Ingenieria Del Software Mit

Los símbolos de multiplicidad son: · * (cero o más)

+ (uno o más) · ? (cero o más) ·

· ! (exactamente uno). Cuando se omite un símbolo, * es el símbolo que se asume por defecto (que no indica nada). La interpretación de estas marcas consiste en que cuando hay una marca n en el final B de una flecha de campo f que parte de la clase A hacia la clase B, existen n miembros de la clase B asociados por f con cada A. Esto también funciona al revés; si hay una marca m en el inicio A de una flecha de campo f que parte desde A hacia B, cada B es asociada por los m miembros de la clase A. En el final de la flecha, es decir, hacia donde mira la punta de la misma, la multiplicidad le indica cuántos objetos puede referenciar una variable. Hasta ahora, no hemos asignado ningún uso a las marcas * y +, pero veremos cómo éstas se utilizan con campos abstractos. La elección de ? o ! depende de si un campo puede o no ser null. Al inicio de la flecha, la multiplicidad señala cuántos objetos pueden apuntar a un determinado objeto. Dicho de otro modo, nos da información sobre el hecho de compartir. Observemos algunas de las flechas y veamos qué nos indican sus multiplicidades: . Para el campo header, el símbolo ! al final de la flecha, indica que cada objeto de la clase List está relacionado exactamente con un objeto de la clase Entry por el campo header. El símbolo ? al inicio de la flecha, indica que cada objeto Entry es el objeto header de un objeto List como máximo.

Para el campo element, el símbolo ? al final de la flecha indica que el campo element de un objeto Entry apunta a cero o a uno de los objetos de la clase Object. Dicho de otro modo, éste puede ser null: un objeto List puede almacenar referencias null. La ausencia de un símbolo al inicio de la flecha, indica que un objeto puede estar apuntado por el campo element de cualquier número de objetos Entry. Es decir, una List puede almacenar duplicados.

·

· Para el campo next, el símbolo ! al final y al inicio de la flecha indica que el campo next de todo objeto Entry

apunta a un objeto Entry, y todo objeto Entry queda apuntado por el campo next de un objeto Entry.

8.1.4 Mutabilidad

Hasta ahora, todas las características del modelo de objetos que hemos descrito restringen a los estados individuales. Las restricciones de mutabilidad describen cómo pueden alterarse los estados. Para mostrar que una restricción de multiplicidad se ha violado, es necesario exhibir un único estado, pero para exponer la violación de una restricción de mutabilidad, necesitamos mostrar dos estados que representen el estado anterior y posterior a la alteración global del estado. Las restricciones de mutabilidad se pueden aplicar a ambos conjuntos y relaciones, pero por ahora, tendremos en cuenta únicamente una forma limitada, en la cual una barra (figura de arriba) opcional se puede usar para marcar el final de una flecha de campo. Cuando está presente, esta marca indica que un objeto con el cual un determinado objeto está relacionado a través de un campo, debe ser siempre el mismo. En este caso, decimos que el campo es inmutable, estático, o más exactamente, target static (o estático al final de la flecha, dado que más tarde facilitaremos un significado para una barra situada al inicio de la flecha).

50

Page 87: Curso Practico en Java de Ingenieria Del Software Mit

En nuestro diagrama, por ejemplo, la barra al final de la relación header indica que un objeto List, una vez creado, siempre apunta a través de su campo header al mismo objeto Entry. Un objeto es inmutable si todos sus campos son inmutables. Se dice que una clase es inmutable si sus objetos son inmutables.

8.1.5 Diagramas de instancia

El significado de un modelo de objetos es una colección de configuraciones, es decir, todas las que satisfacen las restricciones del modelo. Éstas se pueden representar en diagramas de instancia o snapshots (un snapshot es una representación simplificada), que son simplemente grafos que se componen de objetos y referencias que los conectan. Cada objeto está etiquetado con la clase (la más específica) a la que pertenecen. Cada referencia está etiquetada con el campo que representa. La relación entre un snapshot y un modelo de objeto es igual que la relación entre una instancia de un objeto y una clase, o como la relación entre una sentencia y la gramática. La figura de abajo muestra un snapshot legal (que pertenece a la colección representada por el modelo de objeto de los ejemplos de arriba) y uno ilegal (que no pertenece a la colección). Existe, por supuesto, un número infinito de snapshots legales, ya que se puede elaborar una lista de cualquier longitud. Un ejercicio práctico para comprobar que usted comprende el significado del modelo de objetos, es analizar el snapshot ilegal y definir qué restricciones viola. Las restricciones son las de multiplicidad y las que están implícitas en la colocación de las flechas. Por ejemplo, ya que la flecha del campo header va desde List hasta Entry, un snapshot que contenga un campo etiquetado con una flecha de referencia, partiendo de Entry hasta Entry, debe ser erróneo. Observe que las restricciones de mutabilidad no son pertinentes aquí; le indican las transiciones permitidas.

8.2 Modelos de programas completos

Un modelo de objeto puede utilizarse para mostrar cualquier parte del estado de un programa. En el ejemplo List de arriba, nuestro modelo de objeto exhibía únicamente los objetos implicados en la representación del tipo abstracto List. Sin embargo, en realidad, los modelos de objeto resultan más útiles cuando incluyen objetos de muchos tipos, ya que capturan la interrelación entre éstos, que constituye a menudo la esencia de un diseño orientado a objetos. Suponga, por ejemplo, que estamos construyendo un programa para controlar los precios de las acciones de la bolsa. Podemos diseñar un tipo de datos llamado Portfolio que represente a una cartera de un determinado tipo de acciones de bolsa. Un Portfolio contiene una lista de objetos de tipo Position, cada uno de los cuales posee un símbolo Ticker para una determinada acción, el recuento del número de acciones de la cartera y el valor actual para cada acción. El objeto Portfolio también mantiene el valor total de todas las posiciones indicadas por los objetos Positions.

51

Page 88: Curso Practico en Java de Ingenieria Del Software Mit

(List)

nextheader

nextnext(Entry)(Entry) (Entry)

prevprev

prev elementelement

(Object)(Object)

(List)

headerheader

nextnext(Entry)(Entry) (Entry)

prev

element prevelement

(Object) (Object)

52

Page 89: Curso Practico en Java de Ingenieria Del Software Mit

Portfolio

positionList

Listtotalval

?

header!! !

prev Entry next

! !

element?

Position

?count, value ticker

! ! !

int Ticker

En el modelo de objeto de abajo se puede observar esto. Observe cómo los objetos Entry apuntan ahora a los objetos Position: pertenecen a una lista (objeto List) de objetos Position, que no es una lista cualquiera. Debemos permitir que haya varios recuadros en el mismo diagrama con la etiqueta List, que se correspondan con distintos tipos de List. Y consecuentemente, debemos ser un poco cuidadosos sobre cómo interpretamos las restricciones implícitas en una flecha correspondiente a un campo. La flecha marcada como element, que parte de Entry hacia Position en nuestro diagrama, por ejemplo, no significa que todo objeto Entry del programa, apunte a un objeto Position, sino que todo objeto Entry contenido en un objeto List, que está contenido a su vez en el objeto Portfolio, apunta a un objeto Position.

53

Page 90: Curso Practico en Java de Ingenieria Del Software Mit

Imagine que queremos implementar un conjunto de la forma de un tipo de dato abstracto. En algunas circunstancias, por ejemplo, cuando tenemos muchos conjuntos pequeños que representan un conjunto como una lista, es una opción aceptable. La figura anterior muestra tres modelos de objeto. Los dos primeros son dos versiones de un tipo llamado Set, uno representado con un LinkedList y otro con un ArrayList. (Pregunta para el lector astuto: ¿por qué es el campo header en la representación con LinkedList inmutable y, sin embargo, no sucede lo mismo con el campo elementData, en la representación con ArrayList?). Si lo que nos interesa es saber cómo se representa Set, sería posible que quisiéramos mostrar estos modelos de objeto. Pero si nuestro interés se centra en el papel que Set representa dentro de un programa mayor y no queremos preocuparnos por la elección de la representación, preferiríamos un modelo de objeto que ocultase la diferencia entre estas dos versiones. El tercer modelo de objeto, a mano derecha, es este mismo modelo, que sustituye todos los detalles de la representación de Set, con un único campo denominado elements, que conecta objetos Set directamente con sus elementos.

8.3 Puntos de vista concretos y abstractos

?

??

!!

elements

Set

Object

eltList

Set

eltList

Set

! !

! !

?

!

?

prev next

element

header

Object

Entry

LinkedList

!

elts[]

elementData

Object

Object []

ArrayList

54

Page 91: Curso Practico en Java de Ingenieria Del Software Mit

elems[]

?

??

? ! ?

prev next

element

header

Object

Entry

List

?

Este campo no corresponde a un campo declarado en Java, en la clase Set; se trata de un campo abstracto o de especificación. Por tanto, se pueden diseñar muchos modelos de objetos para el mismo programa. Usted goza de libertad para decidir cuánto modelará de un estado y, para esa parte del estado, qué nivel de abstracción tendrá su representación. Sin embargo, existe un nivel específico de abstracción que está establecido como norma. Éste sería el nivel presentado por los métodos en el código. Por ejemplo, si algún método de la clase Set devuelve un objeto de tipo LinkedList, no tendría apenas sentido realizar una abstracción de la clase LinkedList. Pero si, desde el punto de vista de un cliente de Set, resulta imposible saber si se está utilizando un LinkedList o un ArrayList, sería más lógico mostrar el campo abstracto elements. . Un tipo abstracto puede estar representado por muchos tipos de representación distintos. Asimismo, un tipo se puede utilizar para representar muchos tipos abstractos diferentes. Por ejemplo, una lista encadenada se puede usar para implementar una pila: a diferencia de la interfaz genérica List, LinkedList ofrece los métodos addLast y removeLast. Además, por cuestiones de diseño, LinkedList implementa directamente la interfaz List, que representa una secuencia abstracta de elementos. Podemos por tanto, observar a la clase LinkedList, de manera más abstracta con un campo elems[], escondiendo la estructura interna Entry, en la cual, el símbolo [] indica que el campo elems representa una secuencia indexada. La figura de abajo muestra estas relaciones: una flecha indica “puede utilizarse para representar”. Obviamente, no estamos ante una relación simétrica. Generalmente, el tipo concreto posee más información en su contenido: una lista puede representar un conjunto, pero un conjunto no puede representar una lista. La razón es que un

55

Page 92: Curso Practico en Java de Ingenieria Del Software Mit

A (0000) = 0 … A (0001) = 8 A (1001) = 9

Debido a una elección específica de un tipo abstracto y concreto, podemos mostrar cómo los valores del tipo concreto se interpretan como valores abstractos mediante el uso de una función de abstracción, como se explicó en un tema anterior. Recuerde que el mismo valor concreto puede interpretarse de modos distintos, por tanto, la función de abstracción no está determinada por la elección de tipos concretos y abstractos. Se trata de una decisión de diseño y determina cómo se escribe el código para métodos del tipo de dato abstracto. En un lenguaje de programación sin objetos mutables, en el que no tuviésemos que preocuparnos por el reparto, podríamos interpretar los “valores” abstractos y concretos como simplemente como eso: valores). La función de abstracción es claramente, por tanto, una función matemática. Piense, por ejemplo, en las varias formas a través de las cuales los enteros se representan como bitstrings. Cada una de estas representaciones pueden ser descritas como una función de abstracción desde bitstring hasta integer. Una codificación que coloque al menos significativo primero, por ejemplo, puede tener una función de asociación como:

8.3.1 Funciones de abstracción

implements

Set

StackList

LinkedListArrayList

conjunto no puede contener información relativa al orden, o permitir duplicados. Observe también que ningún tipo es inherentemente “abstracto” o “concreto”. Estas nociones son relativas. Una lista es abstracta con respecto a una lista encadenada, utilizada para representarla, pero es concreta con respecto a un conjunto que ésta represente.

56

Page 93: Curso Practico en Java de Ingenieria Del Software Mit

s.elements = s.list.header.*next.element, para expresar, que para cada objeto s de la clase, los objetos apuntados por el campo abstracto elements, son objetos obtenidos al seguir list (el objeto List), header (para el primer objeto Entry), luego cero o más transversales por el campo next (hasta los demás objetos Entry) y, para cada uno de estos, seguir el campo element una vez (hasta el objeto apuntado por Entry). Observe que esta regla es, por sí misma, una especie de modelo de objeto invariante: le indica donde está permitido colocar flechas etiquetadas con elements dentro de un snapshot. En general, un tipo abstracto puede tener cualquier número de campos abstractos, y la función de abstracción se especifica al dar una regla para cada uno de estos campos. En la práctica, a excepción de unos pocos tipos container, las funciones de abstracción, por lo general, son más problemáticas que útiles. Sin embargo, comprender la idea de función de abstracción es algo valioso, ya que le ayudará a interiorizar el concepto de abstracción de datos.

Sin embargo, en un programa orientado a objetos en el que tengamos que preocuparnos por cómo las alteraciones a un objeto a través de una ruta (un método, por ejemplo) pueden afectar a una visión del objeto a través de otra ruta, los “valores” son, de hecho, como subgrafos pequeños. El modo más claro de definir la función de abstracción en estas circunstancias consiste en facilitar una regla para cada campo abstracto, explicando cómo se obtiene a partir de campos concretos. Por ejemplo, para la representación LinkedList de Set, podemos escribir

! !

! !!

?

prev next

element

header

Object

Entry

LinkedList

?

57

Page 94: Curso Practico en Java de Ingenieria Del Software Mit

Un modelo de objeto es un tipo de invariante: una restricción válida durante toda la vida de un programa. Un invariante de representación o “invariante Rep”, como vimos en el tema 6, es un tipo específico de invariante que describe si la representación de un objeto abstracto está bien formada. Algunos aspectos de un invariante Rep pueden expresarse en un modelo de objeto. Sin embargo, existen otros que no se pueden expresar de forma gráfica. Además, no todas las restricciones de un modelo de objeto son invariantes rep. Un invariante Rep es una restricción que puede aplicarse a un único objeto de un tipo abstracto, y le indica si la representación es correcta. Por tanto, un invariante siempre implica exactamente un objeto del tipo abstracto en cuestión, y cualquiera de los objetos que puedan ser alcanzados por su representación. Podemos trazar un contorno alrededor de una parte del modelo de objeto para indicar que un invariante de representación en concreto se refiere a esta parte. Este contorno agrupa los objetos de una representación junto con su objeto abstracto. Por ejemplo, para el invariante Rep de LinkedList visto como un List (es decir, una secuencia abstracta de elementos), este contorno incluye los elementos Entry. Como era de esperar, las clases dentro del contorno, son las clases abstraídas por el campo elems[]. Del mismo modo, el invariante Rep para la clase ArrayList engloba al Array contenido. Los detalles de los invariantes rep se trataron en el tema 6: para LinkedList, por ejemplo, los invariantes incluyen restricciones como la necesidad de los objetos Entry de formar un ciclo, o de que header esté siempre presente y que tenga un campo element con valor null, etc. Recordemos por qué el invariante Rep es útil, por qué no es solamente un concepto teórico, sino una herramienta práctica: · El invariante de representación captura, en un determinado lugar, las reglas sobre cómo se forma un valor legal de la representación. Si usted está modificando el código de un ADT (tipo de datos abstracto) o escribiendo un método nuevo, es necesario que sepa qué invariantes deben restablecerse y en cuáles puede confiar. El invariante Rep le indica todo lo que necesita saber; esto es lo que se persigue en el razonamiento modular. Si no hay un registro explícito de un invariante Rep, ¡tendrá que leer el código de cada método! . El invariante Rep captura la esencia del diseño de la representación. La presencia de la entidad header y la forma cíclica de LinkedList de Java, por ejemplo, son buenas decisiones de diseño que hacen que los métodos sean más fáciles de codificar de modo uniforme.

8.3.2 Invariantes de representación

Además, debería estar preparado para escribir una función de abstracción si surge la necesidad. La fórmula booleana en CNF del tema 6, es un buen ejemplo de un tipo abstracto que realmente necesita una función de abstracción. En ese caso, sin una firme comprensión de la función de abstracción, es complicado conseguir un código correcto.

58

Page 95: Curso Practico en Java de Ingenieria Del Software Mit

public Object[] toArray() { return elementData; }

Observe cómo el array interno se ha copiado para que se produzca el resultado. Si, por el contrario, el array se hubiese devuelto inmediatamente, como en este caso

Object[] result = new Object[size]; System.arraycopy(elementData, 0, result, 0, size); return result; }

En la implementación de ArrayList,, el método se implementa como: private Object elementData[];

… public Object[] toArray() {

La especificación de este método dice: El array devuelto estará “seguro”, en cuanto a que este objeto Collection no mantendrá ninguna referencia al

array. (O que este método debe asignar un nuevo array incluso si este objeto Collection está respaldado por un array). El llamador tiene por tanto, libertad para modificar el array devuelto.

public Object [] toArray ()

El invariante Rep proporciona un razonamiento modular mientras que la representación sea modificada únicamente dentro de la clase del tipo de dato abstracto. Si existe la posibilidad de modificaciones a través de un código externo a la clase, hace falta examinar el programa entero para asegurarnos de que el invariante Rep se está manteniendo. Esta desagradable situación se conoce como exposición de representación. Hemos visto en temas anteriores algunos ejemplos claros y más sutiles. Un ejemplo sencillo se da cuando un tipo de dato abstracto proporciona acceso directo a uno de los objetos que está dentro del contorno del invariante Rep. Por ejemplo, cada implementación de la interfaz de List (en realidad, la interfaz Collection más general) debe proporcionar un método que devuelva la lista como un array de elementos.

8.3.3 Exposición de representación

· Como veremos en los próximos temas, el invariante Rep se puede utilizar para detectar errores en tiempo de ejecución en una especie de “programación defensiva”.

59

hubiésemos obtenido una exposición de representación. Las modificaciones posteriores al array desde fuera del tipo abstracto afectarían a la representación interna. (De hecho, en este caso, tenemos un invariante Rep tan débil, que un cambio al array no podría romperlo, y esto produciría un efecto tan extraño como ver que el valor de la lista abstracta cambia mientras se modifica el array.

Page 96: Curso Practico en Java de Ingenieria Del Software Mit

Sin embargo, podríamos imaginar una versión de ArrayList que no almacenase referencias nulas; en este caso, la asignación del valor null a un elemento del array arruinaría el invariante). Ahora presentamos un ejemplo mucho más sutil. Suponga que implementamos un tipo de dato abstracto para listas sin duplicados y que definimos el concepto de duplicación a través del método equals de los elementos. Ahora, nuestro invariante Rep indicará para la representación de una lista encadenada, por ejemplo, que no hay ningún par de objetos Entry distintos cuya prueba de igualdad devuelva el valor true. Si los elementos son mutables y el método equals examina los campos internos, es posible que la alteración de un elemento, haga que el elemento alterado sea igual que otro. Por tanto, el acceso a los elementos propiamente dichos constituirá una exposición de representación. Esto, en realidad, no es distinto al caso sencillo, dado que el problema consiste en acceder a un objeto dentro del contorno. El invariante en este caso, ya que depende del estado interno de los elementos, posee un contorno que incluye los elementos de tipo Object. La igualdad crea cuestiones especialmente complicadas; nos dedicaremos a ello en el tema de mañana.

60

Page 97: Curso Practico en Java de Ingenieria Del Software Mit

Clase 9: Igualdad, copia y vistas

9.1 El contrato de la clase Object

Toda clase extiende la clase Object, y por tanto hereda todos sus métodos. Dos de éstos son especialmente importantes y significativos en todos los programas: el método para la comprobación de igualdad y el de la generación de código hash.

public boolean equals (Object o)

public int hashCode () Como cualquier otro método de una superclase, estos métodos se pueden invalidar. Veremos en uno de los siguientes temas sobre el subtipado, que una subclase debería ser un subtype. Esto significa que debería comportarse según la especificación de la superclase, de manera que un objeto de la subclase pudiera colocarse en un contexto en el que se esperase un objeto de la superclase, y aún funcionase adecuadamente. La especificación de la clase Object es bastante abstracta y puede parecer algo recóndita. No obstante, la falta de conformidad con ésta puede ocasionar consecuencias nefastas y tiende a causar errores complejos y oscuros. Y lo que es peor aún, es que si usted no comprende esta especificación y sus ramificaciones, es probable que su código se vea impregnado de fallos con un efecto generalizado y difíciles de eliminar sin una compleja reorganización de todo. La especificación de la clase Object es tan importante que en muchas ocasiones se denomina “El contrato de Object”. Se puede encontrar el contrato en las especificaciones de los métodos equals y hashCode, en la documentación de la API de Java. Se establece que:

• equals debe definir una relación de equivalencia, es decir, una relación que sea reflexiva, simétrica y transitiva;

• equals debe ser coherente: las llamadas reiteradas al método deben producir el mismo resultado a menos que los argumentos sean modificados;

• para una referencia non-null x, x.equals (null) debería devolver el valor false; • hashCode debe producir el mismo resultado para dos objetos considerados igual por el método

equals;

61

Page 98: Curso Practico en Java de Ingenieria Del Software Mit

… }

¿Cómo debe ser el método equals de la clase ColourPoint? Podríamos heredar únicamente el método equals

super (x, y); this.colour = colour; }

private Colour colour; public ColourPoint (int x, int y, Colour colour) {

Suponga ahora que añadimos la noción de colour: public class ColourPoint extends Point {

… }

return false; Point p = (Point) o; return p.x == x && p.y == y; }

public boolean equals (Object o) { if (!(o instanceof Point))

this.x = x; this.y = y; }

public class Point { private final int x; private final int y; public Point (int x, int y) {

Centrémonos primero en las propiedades del método equals. Reflexividad significa que un objeto siempre se iguala a sí mismo; simetría significa que cuando a es igual a b, b es igual a -a; transitividad significa que cuando a es igual a b y b es igual a c, a también es igual a c. Puede parecer que estas propiedades son obvias, y de hecho lo son. Si no se cumpliesen, resultaría complicado imaginar el uso del método equals: tendría que preocuparse de escribir a.equals(b) o b.equals(a), si por ejemplo, no fuesen simétricos. Sin embargo, lo que es mucho menos obvio, es la facilidad para romper estas propiedades inadvertidamente. El siguiente ejemplo, (tomado del excelente libro de Joshua Bloch, Effective Java: Programming Language Guide, que es además uno de los libros aconsejados para la asignatura) muestra como la simetría y la transitividad pueden no cumplirse, cuando utilizamos la herencia. Considere una clase simple que implementa un punto bidimensional:

9.2 Propiedades de igualdad

62

Page 99: Curso Practico en Java de Ingenieria Del Software Mit

Las llamadas p1.equals(p2) y a p2.equals(p3) devolverán el valor true, excepto p1.equals(p3), que devolverá false.

ColourPoint p1 = new ColourPoint (1, 2, Colour.RED); Point p2 = new Point (1, 2); ColourPoint p2 = new ColourPoint (1, 2, Colour.BLUE);

Esto soluciona el problema de simetría, ¡pero ahora la igualdad no es transitiva! Para ver el porqué, tenga en cuenta la construcción de estos puntos:

return o.equals (this); ColourPoint cp = (ColourPoint) o; return super.equals (o) && cp.colour.equals (colour); }

return false; //si f o es un Point normal, haga una comparación sin tener en cuenta el color if (!(o instanceof ColourPoint))

Ahora, p.equals(cp) devolverá el valor true, pero ¡cp.equals(p) devolverá false! El problema es que estas dos expresiones utilizan métodos equals distintos: el primero usa el método de la clase Point, que obvia el color, y el segundo, el método de la clase ColourPoint. Podemos tratar de asegurar esto haciendo que el método equals de la clase ColourPoint haga caso omiso al color, cuando se dé una comparación con un punto que no sea de color: public boolean equals (Object o) {

if (!(o instanceof Point))

Este ejemplo aparentemente inofensivo, en realidad viola el requisito de la simetría. Para comprender la razón de esto, piense en un punto Point y en un punto de color ColourPoint:

Point p = new Point (1, 2); ColourPoint cp = new ColourPoint (1, 2, Colour.RED);

return false; ColourPoint cp = (ColourPoint) o; return super.equals (o) && cp.colour.equals(colour); }

public boolean equals (Object o) { if (!(o instanceof ColourPoint))

de Point, pero entonces, los dos objetos ColourPoints se considerarán igual, incluso si poseen colores diferentes. Podríamos invalidar esto de la siguiente forma:

63

Page 100: Curso Practico en Java de Ingenieria Del Software Mit

Para comprender la parte del contrato relacionada con el método hashCode, será necesario que tenga una idea de cómo funcionan las tablas hash. Las tablas hash son un invento fantástico, una de las mejores ideas dentro del mundo informático. Una tabla hash es una representación de una asociación: un tipo de datos abstracto que asocia claves con valores. Estas tablas ofrecen tiempo constante de búsqueda, de modo que tienden a funcionar mejor que los árboles o las listas. No hace falta que las claves se ordenen o que tengan cualquier propiedad especial, pero sí es necesario que ofrezcan los métodos equals y hashCode. Aquí le mostramos cómo funciona una tabla hash. Contiene un array que se inicializa con un tamaño correspondiente al número de elementos que esperamos que se inserten. Cuando se presentan una clave y un valor para ser insertados, calculamos el código hash de la clave y lo convertimos en un índice dentro del intervalo del array (ej.: a través de una divista de módulo). Entonces, se inserta el valor en ese índice. El invariante Rep de una tabla hash incluye la restricción fundamental de que las claves se encuentran en las posiciones, determinadas por sus códigos hash. Veremos más tarde por qué esto es importante. Los códigos hash están diseñados de modo que las claves se distribuyan uniformemente a lo largo de los índices. Sin embargo, de vez en cuando se da un conflicto y se colocan dos claves en el mismo índice. Así que, en vez de mantener un único valor en un índice, una tabla hash mantiene de hecho una lista de pares clave/valor (conocidos normalmente como “hash buckets”), que se implementan en Java como objetos de la clase con dos campos. Durante la inserción, usted añadirá un par a la lista, en la posición del array determinada por el código hash. Durante la operación de búsqueda, aplicará una función hash a la clave, encontrará la posición correcta del array, y luego examinará cada uno de los pares hasta que encuentre uno cuya clave se corresponda con la clave determinada. Ahora, debería haberle quedado claro por qué el contrato de la clase Object exige que objetos iguales posean la misma clave hash.

9.3 Hashing

public boolean equals (Point p)

Parece que no hay solución a este problema: es un problema fundamental de herencia. No se puede escribir un buen método equals para ColourPoint si esta clase hereda de Point. No obstante, el problema desaparecerá si usted implementa ColourPoint usando Point en su representación, de forma que un ColourPoint no se trate más como un Point. Para ver más detalles sobre esto, consulte el libro de Bloch. En este libro también se facilitan algunos consejos sobre cómo escribir un buen método equals y se observan algunos problemas típicos. Por ejemplo, ¿qué sucedería si usted escribiese algo como esto y utilizara otro tipo para Object en la declaración del método equals?

64

Page 101: Curso Practico en Java de Ingenieria Del Software Mit

Normalmente, surge la necesidad de hacer una copia de un objeto. Por ejemplo, es posible que quiera realizar un cálculo que requiera una modificación del objeto sin que ello afecte a los objetos que ya mantienen referencias a éste. También es posible que tenga un objeto “prototipo” a partir del cual usted pueda crear una colección de objetos que difieran sutilmente, y es conveniente hacer copias y luego modificarlas. Normalmente se habla de copias “superficiales” y “profundas”. Una copia superficial de un objeto se realiza al crear un nuevo objeto cuyos campos apuntan a los mismos objetos que el objeto original. Una copia profunda de un objeto se lleva a cabo al crear un nuevo objeto también para los objetos apuntados por los campos del objeto original, y tal vez para los objetos a los cuales éstos apuntan, y así sucesivamente.

9.4 Copia

(Éste es uno de los aforismos de Bloch. El libro al completo es una colección de aforismos como éste, bien explicados e ilustrados). El pasado año, un estudiante pasó horas intentando descubrir un error en un proyecto, en el que la palabra hashCode aparecía siempre escrita como hashcode. Esto creó un método que no invalidó al método hashCode en absoluto, y empezaron a ocurrir cosas extrañas. Otra razón más para evitar la herencia...

Invalide siempre el método hashCode cuando invalide el método equals.

Si dos objetos iguales poseen claves hash distintas, éstos podrían colocarse en posiciones diferentes. Así que si intenta buscar un valor usando una clave igual a la que utilizó cuando éste se insertó, es posible que la búsqueda falle. Una manera sencilla y notoria de asegurar que el método hashCode satisface el contrato del objeto, es hacer que este método devuelva siempre algún valor constante, de manera que el código hash de cada objeto sea el mismo. Esto satisface el contrato de Object, pero tendría un resultado de funcionamiento desastroso, ya que cada clave se almacenará en la misma posición y cada búsqueda degenerará en una búsqueda lineal a lo largo de una lista larga. El modo estándar para construir un código hash más razonable que aún satisfaga el contrato, consistiría en calcular un código hash para cada componente del objeto que se ha usado en la determinación de la igualdad (normalmente llamando al método hashCode de cada componente) y en combinar luego cada código conseguido, a través de unas operaciones aritméticas. Consulte el libro de Bloch para más detalles. Y lo más importante es que debe observar que, si usted no invalida el método hashCode, obtendrá el de la clase Object, que está basado en la dirección del objeto. Si usted ha invalidado el método equals, casi seguro que habrá violado el contrato. Por tanto, como regla general:

65

Page 102: Curso Practico en Java de Ingenieria Del Software Mit

La otra forma consiste en proporcionar constructores adicionales, generalmente conocidos como “constructores de copia”:

public static Point newPoint (Point p)

Object copy () Esto resulta incómodo, ya que usted tendrá que aplicar un proceso de downcast al resultado. No obstante, es práctico, y algunas veces, es la mejor opción. Existen otros dos modos de hacer copias. Uno consiste en utilizar un método estático llamado factory (de fabricación), ya que crea objetos nuevos:

Desafortunadamente, esto no es legal en Java. Usted no puede cambiar el tipo de retorno de un método cuando loinvalida en una subclase. Y la sobrecarga de los nombres del método sólo utiliza los tipos de los argumentos. De modo que tendría que declarar forzosamente ambos métodos de la siguiente forma:

} … }

Fíjese en el tipo de retorno (tipo de dato que devuelve un método): la copia de un Point debe dar como resultado un Point. Ahora, en una subclase, usted querría que el método copy le devolviese un objeto de la subclase:

class ColourPoint extends Point { ColourPoint copy () { …

} … }

class Point { Point copy () { …

¿Cómo debería realizarse la copia? Si usted ha estudiado concienzudamente la API de Java, es posible que suponga que debería usar el método clone de la clase Object, junto con la interfaz Cloneable. Esto resulta tentador porque Cloneable es un tipo especial de “interfaz de marcador” que añade funcionalidad a la clase de forma mágica. Sin embargo, desafortunadamente, el diseño de esta parte de Java no es bastante bueno, y es muy complicado utilizarla adecuadamente. Por tanto, le aconsejo que no la use bajo ningún concepto, a menos que tenga que hacerlo forzosamente (por ejemplo, porque el código que esté usando exija que su clase implemente Cloneable, o porque su gestor no participe en el curso 6.170). Consulte el libro de Bloch para observar una discusión a fondo sobre estos problemas. Usted podría pensar que es correcto declarar un método de la siguiente manera:

66

Page 103: Curso Practico en Java de Ingenieria Del Software Mit

Después de haber dedicado un tiempo considerable a las strings o secuencias de caracteres, pensemos ahora en las listas, que son secuencias de objetos arbitrarios. ¿Deberían tratarse del mismo modo, de forma que dos listas son iguales si contienen los mismos elementos en el mismo orden? Imagine que estoy planificando una fiesta en la cual mis amigos se sentarán en varias mesas distintas y he escrito un programa para que me ayude a crear el planteamiento de los asientos.

9.5.1 El problema

s1 == s2, que devolverá false cuando s1 y s2 representen objetos string distintos, que contengan las mismas secuencias de caracteres.

s1.equals (s2), y no

¿Cuándo dos objetos de tipo Container son iguales? Si son inmutables, deberían ser iguales si contienen los mismos elementos. Por ejemplo, dos strings (cadenas), deberían ser iguales si contienen los mismos caracteres (en el mismo orden). De lo contrario, si sólo mantuviésemos el método de igualdad por defecto de la clase Object, por ejemplo, una string insertada en el teclado, nunca se correspondería con una string situada en una lista o tabla, porque sería un nuevo objeto string, y por tanto, no el mismo objeto como cualquier otro. Y de hecho, aquí tiene exactamente cómo el método equals se implementa en la clase String de Java; si usted quiere comprobar si dos strings s1 y s2 contienen la misma secuencia de caracteres, debería escribir

9.5 Igualdad de elementos e igualdad de contenedores

new ArrayList (l)

Ambos funcionan adecuadamente, aunque no son perfectos. Usted no puede colocar métodos estáticos o constructores en una interfaz, ya que estas alternativas no funcionarán cuando intente proporcionar una funcionalidad genérica. El enfoque del constructor de copia se usa mucho en la API de Java. Una buena característica de este enfoque es que permite que el cliente escoja la clase del objeto que se va a crear. A menudo, se manifiesta que el argumento para el constructor de copia posee el tipo de una interfaz, de modo que usted puede pasarle al constructor cualquier tipo de objeto que implemente la interfaz. Todas las clases de la colección de Java, por ejemplo, proporcionan un constructor de copia que recibe un argumento de tipo Collection o Map. Si usted desea crear un ArrayList a partir de un LinkedList l, por ejemplo, sólo tendrá que llamar a

public Point (Point p)

67

Page 104: Curso Practico en Java de Ingenieria Del Software Mit

En nuestro libro de texto, el profesor Liskov presenta una solución sistemática para este problema. Usted facilita dos métodos distintos: equals, que devuelve true cuando dos objetos de una clase son equivalentes desde el punto de vista de su comportamiento, y similar, que devuelve true cuando los dos objetos son equivalentes, desde un punto de vista observacional.

9.5.2 La solución de Liskov

Set set = new HashSet (); String lower = “hello”; String upper = “HELLO”; set.add (lower.toUpperCase()); …

En algún momento más tarde, el programa añadirá amigos a las distintas listas; es posible que también cree listas nuevas y que sustituya las listas existentes en el conjunto por éstas. Por último, el programa realiza una iteración sobre los contenidos del conjunto, imprimiendo cada una de las listas. Este programa fallará porque las inserciones iniciales no tendrán el resultado que se espera. Incluso si las listas vacías representan conceptualmente planes de mesas distintos, éstos serán iguales según el método equals de LinkedList. Dado que Set utiliza al método equals sobre sus elementos con el fin de rechazar duplicados, todas las inserciones menos la primera, no tendrán efecto, porque todas las listas vacías se considerarán duplicados. ¿Cómo podemos solventar este problema? Podría pensar que Set debería haber usado = = para comprobar duplicados, en vez de equals, y de este modo, un objeto se consideraría un duplicado sólo si ese mismo objeto está ya en el conjunto. Sin embargo, esto no funcionaría con las strings; significaría que después, la prueba set.contains (upper) devolvería false, ya que el método toUpperCase crea una string nueva.

List t1 = new LinkedList (); List t2 = new LinkedList (); … Set s = new HashSet (); s.add (t1); s.add (t2); …

Represento cada mesa como una lista de amigos y la fiesta al completo como un conjunto de estas listas. El programa comienza por crear listas vacías para las mesas e insertarlas dentro del conjunto de éstas:

68

Page 105: Curso Practico en Java de Ingenieria Del Software Mit

Debido a razones como ésta, el diseñador de la API de las colecciones de Java no siguió este enfoque. No existe un método similar y el método equals se refiere a la equivalencia observacional.

9.5.3 El enfoque de Java

Aquí está la diferencia. Dos objetos son equivalentes desde el punto de vista del comportamiento si no existe una secuencia de operaciones que pueda distinguirlos. Por esta razón, las listas vacías t1 y t2 de arriba, no son equivalentes, ya que si usted inserta un elemento en una, podrá observar que la otra no cambia. No obstante, dos strings distintas que contienen la misma secuencia de caracteres son equivalentes, ya que usted no puede modificarlas ni, por tanto, descubrir que se trata de objetos diferentes. (Estamos asumiendo que usted no puede usar = = en este experimento). Dos objetos son equivalentes desde el punto de vista observacional si usted no puede ver la diferencia entre ellos, utilizando operaciones observadoras (y no mutadoras). Por esta razón, las listas vacías t1 y t2 de arriba son equivalentes, ya que tienen el mismo tamaño, contienen los mismos elementos, etc. Siguiendo esta línea, dos strings que contengan la misma secuencia de caracteres serán también equivalentes. Aquí le mostramos cómo puede codificar equals y similar. Para un tipo mutable, usted simplemente hereda el método equals de la clase Object, pero escribe un método similar que lleva a cabo una comparación campo a campo. Para un tipo inmutable, usted debe anular a equals por un método que realice una comparación campo a campo, y hacer que similar invoque a equals, de modo que ambos sean el mismo método. Esta solución, cuando se aplica uniformemente, es fácil de comprender y funciona bien. Sin embargo, no siempre resulta ideal. Imagine que quiere escribir un código que almacena objetos. Esto significa que se debe transformar una estructura de datos, de manera que las referencias a los objetos que son estructuralmente idénticos se conviertan en referencias al mismo objeto. Esto se utiliza muchas veces en compiladores; los objetos podrían ser variables del programa, por ejemplo, y usted querría que todas las referencias a una variable en concreto del árbol de sintaxis abstracta apuntasen al mismo objeto, de manera que toda la información que almacenase en relación a la variable (alterando el objeto) se propagara eficazmente a todos los lugares en los que ésta apareciese. Para realizar la inserción, podría tratar de usar una tabla hash. Cada vez que encuentre un objeto nuevo en la estructura de datos, debe buscar ese objeto en la tabla para comprobar si tiene un representante canónico. Si lo tiene, sustituya el objeto por su representante; si no, insértelo como clave y valor en la tabla hash. Bajo el enfoque de Liskov, esta estrategia fallaría, ya que la prueba de igualdad sobre las claves de la tabla nunca encontraría una correspondencia para los distintos objetos que son estructuralmente equivalentes, ya que el método equals de un objeto mutable, sólo devuelve true, cuando se trata exactamente del mismo objeto.

69

Page 106: Curso Practico en Java de Ingenieria Del Software Mit

9.6 Exposición de representación

En general, cuando tenga que incorporar una clase, cuyo método equals siga un enfoque diferente al programa entero, puede escribir un wrapper (envoltorio) para la clase, que sustituya al método equals por uno más apropiado. El libro de texto le muestra un ejemplo de cómo hacer esto.

·

Existen otras consecuencias, incluso más sutiles, del enfoque de Java con respecto a la exposición de representación, que se detallarán a continuación. Este hecho deja a su disposición dos opciones, que se aceptan en 6.170: · Puede optar por el enfoque de Java, en cuyo caso, usted obtendrá los beneficios de su comodidad, pero

deberá hacer frente a las complicaciones que puedan surgir. Si no, puede seguir el enfoque de Liskov, pero en este caso tendrá que resolver cómo va a incorporar en su código las clases de la colección de Java (como LinkedList y HashSet).

Nota: del mismo modo que se permite que las listas se contengan a sí mismas como elementos, se aconseja una precaución extrema: los métodos equals y hashCode no estarán bien definidos en este tipo de lista.

Por esta razón, encontrará advertencias en la documentación de la API de Java sobre la inserción de objetos containers en ellos mismos, como este comentario en la especificación de la clase List:

List l = new LinkedList (); l.add (l); int h = l.hashCode ();

Esto tiene algunas consecuencias prácticas. La tabla hash de almacenamiento funcionará, por ejemplo. No obstante, también tiene consecuencias lamentables. El programa del planteamiento de los asientos de la fiesta de la sección 9.5.1 se romperá, ya que dos listas vacías diferentes se considerarán iguales, como ya se apuntó en su momento. En la especificación de la clase List de Java, dos listas son iguales no sólo si contienen los mismos elementos en el mismo orden, sino si contienen también elementos iguales, según el método equal, en el mismo orden. Dicho de otro modo, el método equals se invoca de forma recurrente. Para mantener el contrato de Object, el método hashCode es llamado también recursivamente recurrentemente sobre los elementos. Esto da como resultado una sorpresa desagradable. El siguiente código, en el que una lista se inserta en sí misma, ¡fallará, ya que nunca terminará!

Hagamos un repaso del ejemplo de la exposición de representación, con el que cerramos la clase de ayer. Imaginamos una variante de LinkedList para representar secuencias sin duplicados. La operación add posee una nueva especificación que indica que el elemento se añade sólo si no es un duplicado y su código realiza esta comprobación:

70

Page 107: Curso Practico en Java de Ingenieria Del Software Mit

Después de esta secuencia de código, el invariante Rep de p se quiebra. El problema es que la mutación a x, la hace igual a y, ya que ambos son listas vacías. ¿Qué está sucediendo aquí? El contorno que trazamos alrededor de la representación incluye realmente la clase del elemento, ya que el invariante de representación depende de una propiedad del elemento (observe la figura). Fíjese en que este problema no hubiese surgido si la igualdad se hubiera determinado por el enfoque de Liskov, ya que dos elementos mutables serían iguales únicamente si fueran el mismo objeto en sí: el contorno se extiende sólo hasta la referencia del elemento y no hasta el elemento propiamente dicho.

List x = new LinkedList (); List y = new LinkedList (); Object o = new Object (); x.add (o); List p = new LinkedList (); p.add (x); p.add (y); x.remove (o);

Comprobamos que esto se mantiene, al garantizar que cada método que añade un elemento, primero realiza la comprobación de contención. Desafortunadamente, esto no es una buena idea. Observe qué sucede si hacemos una lista de listas y luego alteramos uno de los elementos de la lista:

La lista no contiene duplicados. Es decir, no existen entradas e1 y e2 tales que e1.element.equals (e2.element).

Debemos registrar el invariante Rep al principio del archivo, indicando que la lista no contiene duplicados:

}

// add the element …

return; else

void add (Object o) { if (contains (o))

71

Page 108: Curso Practico en Java de Ingenieria Del Software Mit

Set s = new HashSet (); List x = new LinkedList (); s.add (x); x.add (new Object ());

Un ejemplo más común e insidioso de este fenómeno se da con las claves hash. Si usted altera un objeto después de haber sido insertado como clave en una tabla hash, su código hash puede cambiar. Como resultado, el invariante Rep vital de la tabla hash, que decía que las claves se encuentran almacenadas en las posiciones definidas por sus respectivos códigos hash, se quiebra. Aquí le mostramos un ejemplo. Un conjunto hash es un conjunto implementado con una tabla hash: piense en él como si fuese una tabla hash con claves y sin valores. Si insertamos una lista vacía en un conjunto hash y luego añadimos a la lista un elemento como este,

! !

! !

?

!

?

prev next

element

header

Object

Entry

LinkedList

! !

! !

?

!

?

prev next

element

header

Object

Entry

LinkedList

9.6.1 Cómo alterar claves hash

es probable que una llamada posterior a s.contains(x) devuelva false. Si usted cree que esto es aceptable, piense en que puede que no haya ahora valor de x, para el que s.contains(x) devuelve true, incluso si s.size() devuelve el valor 1! De nuevo, el problema es la exposición de representación: el contorno alrededor de la tabla hash incluye las claves. La lección que aprendemos de esto es: usted puede seguir el enfoque de Liskov y usar un wrapper para invalidar el método equals de la lista de Java, o asegurarse de que nunca va a cambiar las claves hash, o puede permitir la mutación de cualquier elemento de un container que podría quebrar el invariante Rep del container en el que elemento está insertado.

72

Page 109: Curso Practico en Java de Ingenieria Del Software Mit

Un iterador puede considerarse una vista de la colección subyacente. Aquí le mostramos otros dos ejemplos de vistas de la API de las colecciones de Java.

Object o = i.next (); … i.remove (); // también muta x }

List x = new LinkedList (); … Iterator i = x.iterator (); while (i.hasNext ()) {

Las vistas son complicadas porque implican una forma sutil de aliasing en la que los dos objetos poseen tipos distintos. Hemos visto un ejemplo de esto con los iteradores, cuyo método remove de un iterador extrae, de la colección subyacente, al último elemento insertado:

List x = new LinkedList (); List y = x; y.add (o); // changes y also

Una práctica cada vez más común en la programación orientada a objetos consiste en tener objetos distintos que ofrezcan distintos tipos de acceso a la misma estructura de datos subyacente. Estos objetos se denominan vistas. A menudo, se piensa en un objeto como primario y en otro como secundario. El primario se conoce con el nombre de “subyacente” o “de refuerzo” y el secundario, recibe el nombre de “vista”. Estamos acostumbrados a usar aliasing cuando las referencias de dos objetos apuntan al mismo objeto, de manera que un cambio bajo un nombre aparece como un cambio bajo el otro:

9.7 Vistas

Nota: debe tenerse mucho cuidado si los objetos mutables se usan como conjuntos de elementos. El comportamiento de un conjunto no está definido si se cambia el valor de un objeto de un modo que afecte a las comparaciones de igualdades, mientras el objeto sea un elemento del conjunto. Un caso especial de esta prohibición es que no está permitido que un conjunto se contenga a sí mismo como elemento.

Ésta es la razón por la que verá comentarios de este tipo en la especificación de la API de Java:

73

Page 110: Curso Practico en Java de Ingenieria Del Software Mit

Es necesario que las implementaciones de la interfaz Map tengan un método keySet que devuelva el conjunto de claves del objeto Map. Este conjunto es una vista; como el objeto Map subyacente cambia, el conjunto cambiará en consecuencia. A diferencia de un iterador, esta vista, junto con el objeto Map subyacente, puede modificarse; la eliminación de una clave del conjunto hará que se borren del objeto Map, la clave y su valor. El conjunto no soporta una operación add, dado que no tendría sentido añadir una clave sin un valor. (A propósito, ésta es la razón por la que add y remove son métodos opcionales en la interfaz Set).

·

List posee un método subList que devuelve una vista de parte de una lista. Se puede usar para acceder a la lista con un desfase, eliminando la necesidad de operaciones explícitas de intervalos de índices. Cualquier operación que requiera una lista puede usarse como una operación de intervalo de índices, pasando una vista subList, en vez de una lista completa. Por ejemplo, el siguiente código extrae un intervalo de elementos de una lista:

·

list.subList(from, to).clear();

Idealmente, tanto una vista como su objeto subyacente deberían ser modificables, con los efectos propagados entre los dos, como es de esperar. Desafortunadamente, esto no resulta siempre viable y muchas vistas definen restricciones sobre los tipos de modificaciones que son posibles. Por ejemplo, un iterador carece de validez si la colección subyacente se modifica durante la iteración. Una sublista se invalida por ciertas modificaciones estructurales a la lista subyacente. Las cosas se llegan a complicar mucho más cuando hay varias vistas del mismo objeto. Por ejemplo, si usted tiene dos iteradores simultáneamente sobre la misma colección subyacente, una modificación a través de un iterador (mediante una llamada al método remove) invalidará al otro iterador (pero no a la colección).

9.8 Resumen

Cuestiones como la copia, las vistas y la igualdad entre objetos, muestran el poder de la programación orientada a objetos, pero también sus trampas. En cualquier programa que escriba, debe ser muy sistemático y uniforme en cuanto al tratamiento de la igualdad, durante la utilización del hashing y durante las operaciones de copia. Las vistas son un mecanismo muy útil, pero deben emplearse con cuidado. Construir un modelo de objeto de su programa resulta práctico, ya que esto le recordará dónde se dan las distribuciones y le obligará a analizar cada caso detenidamente.

74

Page 111: Curso Practico en Java de Ingenieria Del Software Mit

Clase 10. Análisis dinámico, 1ª parte

La mejor forma de garantizar la calidad del software que desarrolla es proyectarlo cuidadosamente desde el principio. Las partes encajarán mejor y la funcionalidad de cada parte será más sencilla, de modo que cometerá menos errores en la implementación. Sin embargo, es difícil que no se nos escape algún error durante la codificación y la forma más eficaz de hallarlos es mediante técnicas dinámicas: es decir, aquellas que suponen la ejecución del programa y la observación de su rendimiento. En contraste, las técnicas estáticas son las que se utilizan para garantizar la calidad antes de la ejecución: evaluando el diseño y el análisis del código (bien manualmente, o bien utilizando herramientas como un verificador de tipos). Algunos individuos, erróneamente, confían en las técnicas dinámicas, sin apenas detenerse en la fase de especificación y proyecto, confiando en que podrán arreglar las cosas más tarde. Este modo de actuar conlleva dos problemas. El primero es que los problemas que surgen en la fase del proyecto, con el tiempo, se mezclan con los problemas de implementación, con lo que es más difícil encontrarlos. El segundo es que el coste de arreglar un problema aumenta de manera espectacular cuanto más tarde se descubre dentro del proceso de desarrollo. En estudios recientes realizados en IBM y TRW, Barry Boehm descubrió que arreglar un error de especificación puede llegar a costar 1.000 veces más si no se descubre hasta la fase de implementación. Otros también se equivocan al pensar que sólo son necesarias las técnicas estáticas. Aunque se han realizado grandes progresos tecnológicos en el análisis estático, aún estamos lejos de descubrir todos los errores con esta técnica. Aunque disponga de pruebas matemáticas de que su programa es correcto, sería una tontería no probarlo. El problema fundamental que existe en la fase de prueba se expresa con un famoso aforismo de Dijkstra:

Las pruebas pueden revelar la presencia de errores pero nunca su ausencia.

La fase de prueba es, por su propia naturaleza, incompleta. Sea cauteloso a la hora de sacar conclusiones de un programa sólo porque haya superado una gran cantidad de pruebas. De hecho, el problema de determinar cuándo un programa informático es suficientemente fiable para que se entregue es uno de los dolores de cabeza de los responsables de gestión, para el que además existen pocos remedios. Por tanto, es mejor entender la fase de prueba no como un modo de tener la seguridad de que el programa es correcto, sino más bien como fórmula para hallar errores. La diferencia entre estos dos puntos de vista es sutil pero muy importante.

10.1 Programación defensiva Se trata de un método para incrementar la fiabilidad de un programa mediante la inserción de comprobaciones redundantes. Funciona de la siguiente forma: cuando escribe algún código, se imagina condiciones que deben ser validadas y mantenidas en determinados puntos del código; en otras palabras, invariantes. Entonces, en lugar de simplemente asumir que estas invariantes se mantienen, se someten a prueba explícitamente. Estas pruebas se denominan certificaciones en tiempo de ejecución. Si una certificación falla –esto es, el invariante no es validado– se informa del error y se abandona la ejecución.

10.1.1 Directrices ¿Cómo utilizar las certificaciones en tiempo de ejecución? En primer lugar, no deben utilizarse como parches de una mala codificación. A usted le interesa que el código esté libre de errores (bugs) de la manera más efectiva. La programación defensiva no significa escribir un código rematadamente malo y luego salpicarlo con certificaciones. Quizá ahora no lo sepa, pero a la larga descubrirá que es menos trabajo escribir un buen código desde el principio; a menudo, el código mal escrito supone tanto desorden que no se puede arreglar sin empezar todo desde el principio. ¿Cuándo es aconsejable emplear las certificaciones en tiempo de ejecución? A medida que escribe el código,

Page 112: Curso Practico en Java de Ingenieria Del Software Mit

no posteriormente. De cualquier modo, cuando escribe el código ya tiene invariantes en mente y escribirlos es una buena forma de documentación. Si lo pospone, tiene menos probabilidades de hacerlo. Las certificaciones en tiempo de ejecución tienen un coste. Pueden desordenar el código, por lo que debe utilizarlas con sabiduría. Desde luego, lo que usted quiere es escribir las certificaciones que tengan más probabilidad de detectar fallos. Los buenos programadores las utilizan tal y como se explica a continuación:

• Al inicio de un procedimiento, para comprobar que el estado del mismo es el adecuado –esto es, para verificar la precondición. Esto es sensato, ya que una gran proporción de errores tienen que ver con una mala compresión de interfaces entre procedimientos.

• Al final de un procedimiento complicado, para comprobar que el resultado es plausible –esto es, para verificar la poscondición. En un proceso que calcula raíces cuadradas, por ejemplo, tal vez podría escribir una certificación que calcula el cuadrado del resultado para comprobar que es (aproximadamente) igual al argumento. Este tipo de certificación se denomina a veces autocomprobación.

• Cuando se va a realizar una operación que conlleva efectos externos. Por ejemplo, en una máquina para radioterapia, sería razonable, antes de encenderla, comprobar que la intensidad del rayo está dentro de los límites adecuados.

Las certificaciones en tiempo de ejecución también pueden disminuir el rendimiento de la ejecución. Los programadores novatos se preocupan más de lo necesario por esta causa. La práctica de escribir certificaciones en tiempo de ejecución para probar el código y después deshabilitarlas para el lanzamiento de la versión oficial es como quitar los asientos de seguridad en un vehículo una vez que ha superado las pruebas de seguridad. Una buena regla de oro es que si considera que una certificación es necesaria, sólo debería preocuparse por el coste de ejecución cuando tenga pruebas de que es realmente importante. No obstante, no tiene sentido escribir certificaciones ridículamente caras. Supongamos, por ejemplo, que se le da un array y un índice en el cual se ha insertado un elemento. Sería razonable comprobar que el elemento se encuentra ahí. Pero no lo sería verificar que no está en otro lugar, buscando por todo el array de principio a fin: eso convertiría una operación que puede ejecutarse en tiempo constante en una que se necesita tiempo lineal (sobre el tamaño del array) para ser completada.

10.1.2 Interceptación de excepciones comunes Como Java es un lenguaje seguro, su entorno de ejecución –máquina virtual de Java (JVM)– ya incluye certificaciones en tiempo de ejecución para varias clases de errores importantes:

• Llamada de método en una referencia nula; • Acceso a un array más allá de sus límites; • Realización de una operación no válida de downcast.

Estos errores hacen que las excepciones no comprobadas sean lanzadas. Es más, las propias clases de las API de Java lanzan excepciones en condiciones de error. Es una buena práctica interceptar todas estas excepciones. Un modo sencillo de hacerlo es incluir un gestor en la parte superior del programa, el método main, que finaliza el programa como es debido (por ejemplo, con un mensaje de error dirigido al usuario intentando, a continuación, cerrar todos los archivos abiertos). Observe que la JVM lanza algunas excepciones que no es aconsejable gestionar. Los errores de desbordamiento por acumulación o de falta de memoria (stack overflows y out-of-memory), por ejemplo, indican que el programa se ha quedado sin recursos. En estas circunstancias, no tiene sentido empeorar las cosas intentando continuar la computación.

10.1.3 Comprobación del invariante Rep Una estrategia muy útil para hallar errores en un tipo abstracto con representación compleja es codificar el invariante Rep como una certificación en tiempo de ejecución. La mejor forma de hacerlo es escribir un método

public void checkRep ()

Page 113: Curso Practico en Java de Ingenieria Del Software Mit

que lanza una excepción no comprobada si el invariante no es válido en el momento de la llamada. Este método se puede insertar en el código de tipo abstracto, o puede ser llamado a partir de una parte del código externo dedicada a la verificación de los invariantes.

Verificar el invariante Rep es mucho más efectivo que comprobar la mayor parte de los otros invariantes, porque una representación quebrada muchas veces resulta en un problema que sólo se percibe mucho después de que la representación haya sido violada. Con el método checkRep, es probable que identifique e intercepte el error muy próximo a su fuente. Es una buena idea invocar checkRep al inicio y al final de cada método, en el caso de que exista una exposición de representación que cause la violación de la representación entre las llamadas de los métodos. No olvide instrumentar, con checkRep, los métodos de tipo observadores, ya que pueden alterar la representación (como efecto colateral benevolente). Aunque hay mucho material sobre certificaciones en tiempo de ejecución, es curioso comprobar como, al parecer, se conoce muy poco acerca de cómo utilizar el método repCheck en el sector. Por lo general, la comprobación del invariante Rep será excesivamente costosa comparada con el tipo de certificación en tiempo de ejecución que se debería dejar en el código de producción. Por lo tanto, más vale que utilice checkRep principalmente en la fase de prueba. Para comprobaciones del código de producción, debe tener en cuenta en qué puntos es posible que falle el código a consecuencia de la violación de un invariante de representación e insertar las comprobaciones adecuadas en esas ubicaciones.

10.1.4 Marco (framework) de certificaciones Las certificaciones en tiempo de ejecución pueden desordenar el código. Esto es negativo sobre todo cuando un lector no puede distinguir fácilmente qué partes del código son certificaciones y cuáles realizan la computación propiamente dicha. Por ello, y para que la escritura de certificaciones sea más sistemática y menos trabajosa, es aconsejable introducir un pequeño marco (framework) de certificaciones. Algunos lenguajes de programación, como Eiffel, vienen con mecanismos de certificación incorporados. Mecanismos como éstos constituyen la principal reivindicación de cambios en Java. Existen también herramientas de terceros y marcos para añadir certificaciones al código, permitiendo controlarlas. En la práctica, sin embargo, es fácil construir un pequeño marco. Un modo de abordarlo es implementar una clase, Assert por ejemplo, con un método

public static void assert (boolean b, String location)

que lanza una excepción no comprobada cuando el argumento es falso, recibiendo también una string que indica la ubicación del certificado fallido. Esta clase puede encapsular los registros de error y de registro ocurridos. Para utilizarla, sólo hay que escribir certificaciones como ésta:

Assert.assert (x != null, “MyClass.MyMethod”); También es posible utilizar los mecanismos de reflexión de Java para mitigar la necesidad de facilitar información de localización.

10.1.5 Certificaciones en subclases Cuando estudiemos derivación de clases, veremos como las precondiciones y poscondiciones de una subclase deberían estar relacionadas con las precondiciones y poscondiciones de su superclase. Veremos oportunidades para nuevas comprobaciones en tiempo de ejecución y también podremos conocer cómo volver a utilizar las certificaciones utilizadas en las superclases. Sorprendentemente, la mayoría de los métodos para comprobar las certificaciones en subclases fallan conceptualmente. El motivo es analizado en estos trabajos recientes que muestran cómo desarrollar un framework (marco) de certificación para subclases:

Page 114: Curso Practico en Java de Ingenieria Del Software Mit

• Robby Findler, Mario Latendresse y Matthias Felleisen. Behavioral Contracts and Behavioral Subtyping. Foundations of Software Engineering, 2001.

• Robby Findler y Matthias Felleisen. Contract Soundness for Object-Oriented Languages. Object-Oriented Programming, Systems, Languages, and Applications, 2001.

Consulte la siguiente dirección http: www.cs.rice.edu/~robby/publications/.

10.1.6 Respuesta a los fallos Es hora de responder a la pregunta de qué hacer cuando una certificación falla. Tal vez se sienta tentado a resolver el problema sobre la marcha, durante la ejecución, lo que casi siempre es erróneo. Complica aún más el código y suele introducir más fallos. Es poco probable que sea capaz de adivinar la causa del fallo; si puede, tal vez habría podido evitarlo desde el principio. Por otro lado, muchas veces es razonable ejecutar algunas acciones específicas al margen de la causa del fallo. Puede registrar el fallo en un archivo y notificar al usuario en pantalla, por ejemplo. En un sistema de seguridad crítico, decidir qué medidas se van a tomar cuando aparezcan fallos es difícil y muy importante; en el controlador de un reactor nuclear, por ejemplo, probablemente desee eliminar las varillas de combustible si detecta algo que no va bien. A veces, es mejor no abortar la ejecución en absoluto. Cuando nuestro compilador falla, tiene sentido abortar completamente. Pero piense en el caso de un procesador de textos. Si el usuario utiliza un comando erróneo, sería mucho mejor señalizar el fallo y abortar el comando, pero no cerrar el programa; de esta forma el usuario puede mitigar los efectos del fallo (por ejemplo, guardando el texto con un nombre diferente y después cerrando el programa).

Page 115: Curso Practico en Java de Ingenieria Del Software Mit

Clase 11. Análisis dinámico, 2ª parte.

Continuamos con el mismo tema de la clase anterior, pero esta vez nos ocuparemos principalmente de la fase de prueba. Nos detendremos brevemente en algunas de las nociones básicas que subyacen en las actividades de prueba y analizaremos las técnicas más extendidas. Por último, recopilaremos algunas directrices prácticas para ayudarle en sus propias tareas de prueba.

11.1 Fase de prueba Si adopta un enfoque sistemático, la fase de prueba resultará mucho más efectiva y mucho menos complicada. Antes de empezar, considere los siguientes puntos:

• qué propiedades desea probar; • qué módulos desea probar y en qué orden; • cómo va a generar los casos de prueba; • cómo va a comprobar los resultados; • cómo sabrá si ha terminado.

Para decidir qué propiedades desea probar es necesario conocer el dominio del problema, con el fin de saber qué clase de fallos son más importantes, así como el programa, para saber lo difícil que va a resultar descubrir los tipos de errores. La elección de módulos es más sencilla. Pruebe sobre todo los módulos que sean críticos, complejos o que estén escritos por el peor de sus programadores (o por el más aficionado a utilizar trucos brillantes dentro del código). O tal vez el módulo que se escribió a altas horas de la noche, o justo antes del lanzamiento… El diagrama de dependencia modular sirve para determinar el orden. Si el módulo depende de otro que aún no está implementado, tendrá que escribir un stub (o esqueleto de un módulo) que hará el papel del módulo que está fallando durante la fase de prueba. El stub proporciona el rendimiento necesario para la prueba. Es posible, por ejemplo, buscar respuestas en una tabla en lugar de realizar la computación verdadera. La comprobación de los resultados puede resultar complicada. Algunos programas –como el Foliotracker que va a construir en los ejercicios 5 y 6– ni siquiera tienen comportamiento repetitivo. En otros, los resultados son sólo la punta del iceberg y para comprobar que las cosas marchan bien, será necesario verificar las estructuras internas. Más adelante hablaremos de cómo generar casos de prueba y cómo saber cuando el trabajo está completo. 11.2 Pruebas de regresión Es muy importante ser capaz de volver a ejecutar las pruebas cuando se modifica el código.

Page 116: Curso Practico en Java de Ingenieria Del Software Mit

Por esta razón, no es buena idea realizar pruebas específicas que no pueden ser repetidas. Puede parecer un trabajo arduo, pero a largo plazo, resulta menos laborioso construir un conjunto práctico de pruebas que pueden ser reejecutadas a partir de un archivo. Es lo que se denomina pruebas de regresión. Un enfoque de la fase de prueba que recibe el nombre de test first programming, y que es parte de la nueva doctrina de desarrollo denominada extreme programming, apuesta por la construcción de pruebas de regresión antes incluso de que se haya escrito el código de aplicación. JUnit, el marco de pruebas que ha utilizado, fue concebido para esto. La construcción de pruebas de regresión para un sistema grande es una empresa importante. Es posible que sólo la ejecución de los scripts dure una semana. Por lo tanto un área de investigación que es muy interesante actualmente es intentar determinar qué pruebas de regresión pueden omitirse. Si sabe qué casos de prueba aplicar a las partes del código, podrá determinar que un cambio local en una parte del código no exige que todos los casos sean reejecutados.

11.3 Criterios Para entender cómo se generan y evalúan las pruebas, podemos pensar de manera abstracta sobre la finalidad y la naturaleza de la fase de prueba. Suponga que tenemos un programa P que debe cumplir una especificación S. Asumiremos, para que sea más sencillo, que P es una función que transforma las entradas de datos en salida de datos, y S es una función que recibe una entrada de datos y una salida de datos y devuelve un tipo booleano. Nuestro objetivo al realizar las pruebas es encontrar un caso de prueba t tal que:

S (t, P(t)) sea falso: esto es, P produce un resultado para la entrada t que no es permitido por S. Llamaremos a t un caso de prueba fallido, aunque en realidad es un caso de prueba con éxito, ya que nuestra finalidad es encontrar errores. Una suite de pruebas T es un conjunto de casos de prueba. Ahora nos hacemos la siguiente pregunta: ¿cuándo una suite puede considerarse suficientemente buena? En lugar de intentar evaluar cada suite de forma que dependa de la situación, podemos aplicar criterios generales. Puede pensar en un criterio como una función:

C: Suite, Program, Spec ~ Boolean que recibe una suite de pruebas, un programa y una especificación, y devuelve verdadero o falso de acuerdo con el hecho de que la suite sea suficientemente buena para el programa y la especificación dados, todo ello de forma sistemática. La mayoría de los criterios no incluyen ambos, el programa y la especificación. Si sólo se incluye el programa se denomina criterio basado en el programa. También se utilizan

Page 117: Curso Practico en Java de Ingenieria Del Software Mit

términos como ‘whitebox’, ‘clearbox’, ‘glassbox’, o pruebas estructurales para describir fases de prueba que utilizan criterios basados en programas. Un criterio que sólo incluye la especificación se denomina criterio basado en la especificación. El término ‘blackbox’ se utiliza en asociación con este criterio, para dar a entender que las pruebas se juzgan sin que se pueda analizar la parte interna del programa. También se utiliza el término pruebas funcionales.

11.4 Subdominios Los criterios prácticos se inclinan por una estructura y propiedades singulares. Por ejemplo, pueden aceptar una suite de casos T y sin embargo rechazar una suite T’ que es igual que T pero con algunos casos adicionales. También tienden a no ser sensibles en lo que se refiere a las combinaciones de los casos de prueba escogidas. Estas características no son, necesariamente, buenas propiedades; simplemente surgen del modo sencillo en que la mayoría de los criterios se definen. El dominio de los datos de entrada se divide en subregiones, algunas de las cuales se denominan subdominios, y cada una contiene un conjunto de datos de entrada. Los subdominios juntos engloban todos los dominios de los datos de entrada: esto es, toda entrada está en por lo menos un subdominio. Una división del dominio de datos de entrada en subdominios define un criterio implícito: que define que deba existir al menos un caso de prueba para cada subdominio. Por lo general los subdominios no son inconexos, por lo tanto, un único caso de prueba puede estar en todos los subdominios. La idea que subyace tras el subdominio tiene dos aspectos. En primer lugar, es fácil (al menos conceptualmente) determinar si una suite de pruebas es suficientemente buena. En segundo lugar, esperamos que al exigir un caso de prueba de cada subdominio haremos que las pruebas se orienten a regiones de datos más propensas a revelar fallos. De forma intuitiva, cada subdominio representa un conjunto de casos de prueba similares; deseamos maximizar el beneficio de la actividad de prueba escogiendo casos de prueba que no sean similares; es decir, casos de prueba que provengan de subdominios diferentes. En el mejor de los casos, un subdominio es revelador, lo que significa que cada caso de prueba que contiene hace que el programa falle o tenga éxito. Así, el subdominio agrupa casos verdaderamente equivalentes. Si todos los dominios son reveladores, una suite de pruebas que satisfaga el criterio será completa, ya que tendremos la garantía de que encontrará cualquier fallo. En la práctica, sin embargo, resulta bastante difícil obtener subdominios reveladores, pero escogiendo con cuidado los subdominios es posible tener al menos algún subdominio cuya tasa de error –la proporción de entradas que conducen a salidas de datos erróneas– sea mucho mayor que la tasa de error media del dominio de datos de entrada como un todo.

11.5 Criterios de subdominio El criterio ordinario y más ampliamente utilizado en las pruebas basadas en programa es la

Page 118: Curso Practico en Java de Ingenieria Del Software Mit

cobertura de sentencias: esto es, que cada sentencia o segmento de un programa deba ejecutarse al menos una vez. Por la definición, se entiende por qué se trata de un criterio de subdominio: defina para cada sentencia del programa el conjunto de entregas que hacen que se ejecute y escoja al menos un caso de prueba para cada subdominio. Desde luego, el subdominio nunca se construye explícitamente; es una noción conceptual. En vez de eso, lo que ocurre es que se ejecuta una versión instrumental del programa que registra cada sentencia ejecutada. Debe continuar añadiendo casos de prueba hasta que todas las sentencias sean ejecutadas. Existen más criterios aparte de la cobertura de sentencias. El denominado cobertura de decisión o de condición requiere que se ejecuten todas las aristas del gráfico de flujo de control del programa: es como exigir que todas las ramas de un programa sean ejecutadas. No está tan clara la razón por la que este enfoque está considerado más riguroso que la cobertura de sentencias. Piense en la posibilidad de aplicar este criterio a un procedimiento que devuelva el menor de dos valores:

static int minimum (int a, int b) { if (a ≤ b)

return a; else

return b; Para este código, la cobertura de sentencias requerirá entradas con a menor que b y viceversa. Sin embargo, para el código:

static int minimum (int a, int b) { int result = b; if (b ≤ a)

result = b; return result;

un único caso de prueba con b menor que a satisfará el criterio de la cobertura de sentencias, y el fallo se pasará por alto. La cobertura de decisión requeriría un caso en el que el comando if no sea ejecutado, exponiendo de esta forma el fallo. Hay muchas formas de cobertura de condición que exigen, de diversas formas, que las expresiones booleanas probadas como condición sean evaluadas tanto para verdadero (true) como para falso (false). Una forma específica de cobertura de condición, conocida como MCDC, es exigida por una norma denominada DoD específica para software de seguridad crítica, como los de aviación. Esta norma, DO-178B, clasifica los fallos en tres niveles y exige un diferente nivel de cobertura para cada uno:

Nivel C: el fallo reduce el margen de seguridad Ejemplo: link de datos vía radio Requiere: cobertura de sentencia Nivel B: el fallo reduce la capacidad de la nave o de la tripulación Ejemplo: GPS

Page 119: Curso Practico en Java de Ingenieria Del Software Mit

Requiere: cobertura de decisión

Nivel A: el fallo provoca la pérdida de la nave Ejemplo: sistema de gestión de vuelo Requiere: cobertura MCDC

Otra forma común de criterio de subdominio de tipo basada en programa es la que se utiliza en las pruebas de casos límite. Esto requiere la evaluación de los casos límite de cada condición. Por ejemplo, si su programa prueba x < n, serían necesarios casos de prueba que produjeran x = n, x = n-1, y x=n+1.

Los criterios basados en especificación también se presentan en términos de subdominios. Como las especificaciones son por lo general informales –esto es, no están escritas en ninguna notación precisa– los criterios tienden a ser más vagos. El planteamiento más común es definir los subdominios de acuerdo con la estructura de la especificación y los valores de los tipos de datos subyacentes. Por ejemplo, los subdominios para un método que inserte un elemento en un conjunto pueden ser:

• el conjunto está vacío • el conjunto no está vacío y el elemento no está en el conjunto • el conjunto no está vacío y el elemento está en el conjunto

También puede, en la especificación, utilizar cualquier estructura condicional para guiar la división en subdominios. Es más, en la práctica, los encargados de realizar las pruebas utilizan sus conocimientos al respecto de los tipos de error que muchas veces surgen en los códigos. Por ejemplo, si está probando un procedimiento que encuentra un elemento en un array, probablemente colocará el elemento al principio, en el medio y al final, simplemente porque estos casos son propensos a ser manipulados de forma diferente en el código.

11.6 Viabilidad La cobertura total es raramente posible. De hecho, incluso logrando una cobertura de sentencias del 100% es imposible alcanzar la cobertura total. Esta imposibilidad ocurre en razón del código de programación defensiva, código que, en gran parte, nunca se debería ejecutar. Las operaciones de un tipo abstracto de datos, que no tienen ningún cliente, no se ejecutarán mediante casos de prueba independientemente del rigor aplicado, aunque se pueden ejecutar por pruebas de unidad. Se dice que un criterio es factible si es posible satisfacerlo. En la práctica, los criterios no suelen ser factibles. En términos de subdominio, contienen subdominios vacíos. La cuestión práctica es determinar si un subdominio esta vacío o no; si está vacío, no hay razón para tratar de encontrar un caso de prueba que lo satisfaga. Por regla general, cuanto más elaborado sea el criterio, más difícil llega a ser su

Page 120: Curso Practico en Java de Ingenieria Del Software Mit

determinación. Por ejemplo, la cobertura de caminos requiere que todos los caminos del programa sean ejecutados. Suponga que tengamos el siguiente programa:

if C1 then S1;

if C2 then S2;

Entonces, para determinar si el camino S1;S2 es factible, necesitamos determinar si las condiciones C1 y C2 pueden ambas ser verdaderas. Para un programa complejo, no se trata de una tarea trivial y, en el peor de los casos, no es más fácil que determinar la corrección del programa mediante razonamiento.

A pesar de estos problemas, la idea de cobertura es muy importante en la práctica. Si existen partes importantes del programa que nunca han sido ejecutadas, ¡más vale que no confíe demasiado en su exactitud!

11.7 Directrices prácticas

Ha de quedar claro por qué ni los criterios basados en programas, ni los basados en especificaciones son, por sí solos, suficientes . Si sólo se fija en el programa, pasará por alto errores de omisión. Si sólo observa la especificación, no detectará errores que surgen de problemas de implementación, como, por ejemplo, cuando se alcanzan los límites de un recurso computacional, caso en que se necesita un procedimiento de compensación. En la implementación de la clase ArrayList de Java, por ejemplo, el array de la representación se sustituye cuando está lleno. Para probar este comportamiento, será necesario insertar elementos suficientes en la ArrayList para que el array quede lleno.

La experiencia sugiere que el mejor modo de realizar una suite de pruebas es utilizar el criterio basado en la especificación para guiar el desarrollo de la suite y, para evaluar la suite, es mejor que se utilicen los criterios basados en el programa. De este modo, podrá examinar la especificación y definir subdominios de entrada. Basándose en estas premisas, usted puede escribir los casos de prueba. A continuación, se ejecutan los casos y se mide la cobertura de las pruebas en relación con el código. Si la cobertura es inadecuada, bastaría con añadir nuevos casos de prueba. En un entorno profesional, se utilizaría una herramienta especial para medir la cobertura. En este curso, no exigiremos que aprenda a utilizar otra herramienta. En vez de eso, deberá escoger casos de prueba suficientemente elaborados para que pueda argumentar que ha alcanzado una cobertura considerable del código. Las certificaciones en tiempo de ejecución, sobre todo las que representan comprobaciones de invariante, aumentarán de manera espectacular la fuerza de sus pruebas, esto es, podrá hallar más fallos con menos casos y será capaz de solucionarlos más fácilmente.

Page 121: Curso Practico en Java de Ingenieria Del Software Mit

Patrones de diseño

Clases 12 a 14 del curso 6.170 2, 3 y 10 de octubre de 2001

1. Patrones de diseño Un patrón de diseño es:

• una solución estándar para un problema común de programación • una técnica para flexibilizar el código haciéndolo satisfacer ciertos criterios • un proyecto o estructura de implementación que logra una finalidad determinada • un lenguaje de programación de alto nivel • una manera más práctica de describir ciertos aspectos de la organización de un

programa • conexiones entre componentes de programas • la forma de un diagrama de objeto o de un modelo de objeto.

1.1 Ejemplos Les vamos a presentar algunos ejemplos de patrones de diseño que ya conocen. A cada diseño de proyecto le sigue el problema que trata de resolver, la solución que aporta y las posibles desventajas asociadas. Un desarrollador debe buscar un equilibrio entre las ventajas y las desventajas a la hora de decidir que patrón utilizar. Lo normal es, como observará a menudo en la ciencia computacional y en otros campos, buscar el balance entre flexibilidad y rendimiento. Encapsulación (ocultación de datos)

Problema: los campos externos pueden ser manipulados directamente a partir del código externo, lo que conduce a violaciones del invariante de representación o a dependencias indeseables que impiden modificaciones en la implementación.

Solución: esconda algunos componentes, permitiendo sólo accesos estilizados al objeto.

Desventajas: la interfaz no puede, eficientemente, facilitar todas las operaciones deseadas. El acceso indirecto puede reducir el rendimiento.

Subclase (herencia)

Problema: abstracciones similares poseen miembros similares (campos y métodos). Esta repetición es tediosa, propensa a errores y un quebradero de cabeza durante el mantenimiento.

Page 122: Curso Practico en Java de Ingenieria Del Software Mit

Solución: herede miembros por defecto de una superclase, seleccione la implementación correcta a través de resoluciones sobre qué implementación debe ser ejecutada.

Desventajas: el código para una clase está muy dividido, con lo que, potencialmente, se reduce la comprensión. La introducción de resoluciones en tiempo de ejecución introduce overhead (procesamiento extra).

Iteración

Problema: los clientes que desean acceder a todos los miembros de una colección deben realizar un transversal especializado para cada estructura de datos, lo que introduce dependencias indeseables que impiden la ampliación del código a otras colecciones.

Solución: las implementaciones, realizadas con conocimiento de la representación, realizan transversales y registran el proceso de iteración. El cliente recibe los resultados a través de una interfaz estándar.

Desventajas: la implementación fija la orden de iteración, esto es, no está controlada en absoluto por el cliente.

Excepciones

Problema: los problemas que ocurren en una parte del código normalmente han de ser manipulados en otro lugar. El código no debe desordenarse con rutinas de manipulación de error, ni con valores de retorno para identificación de errores.

Solución: introducir estructuras de lenguaje para arrojar e interceptar excepciones. Desventajas: es posible que el código pueda continuar aún desordenado. Puede ser

difícil saber dónde será gestionada una excepción. Tal vez induzca a los programadores a utilizar excepciones para controlar el flujo normal de ejecución, que es confuso y por lo general ineficaz.

Estos patrones de diseño en concreto son tan importantes que ya vienen incorporados en Java. Otros vienen incluidos en otros lenguajes, tal vez algunos nunca lleguen a estar incorporados a ningún lenguaje, pero continúan siendo útiles. 1.2 Cuando (no) utilizar patrones de diseño La primera regla de los patrones de diseño coincide con la primera regla de la optimización: retrasar. Del mismo modo que no es aconsejable optimizar prematuramente, no se deben utilizar patrones de diseño antes de tiempo. Seguramente sea mejor implementar algo primero y asegurarse de que funciona, para luego utilizar el patrón de diseño para mejorar las flaquezas; esto es cierto, sobre todo, cuando aún no ha identificado todos los detalles del proyecto (si comprende totalmente el dominio y el problema, tal vez sea razonable utilizar patrones desde el principio, de igual modo que tiene sentido utilizar los algoritmos más eficientes desde el comienzo en algunas aplicaciones). Los patrones de diseño pueden incrementar o disminuir la capacidad de comprensión de un diseño o de una implementación, disminuirla al añadir accesos indirectos o aumentar la cantidad de código, disminuirla al regular la modularidad, separar mejor los conceptos y simplificar la descripción. Una vez que aprenda el vocabulario de los patrones de diseño le será más fácil y más rápido comunicarse con otros individuos que también lo conozcan. Por

Page 123: Curso Practico en Java de Ingenieria Del Software Mit

ejemplo, es más fácil decir “ésta es una instancia del patrón Visitor” que “éste es un código que atraviesa una estructura y realiza llamadas de retorno, en tanto que algunos métodos deben estar presentes y son llamados de este modo y en este orden”. La mayoría de las personas utiliza patrones de diseño cuando perciben un problema en su proyecto —algo que debería resultar sencillo no lo es — o su implementación— como por ejemplo, el rendimiento. Examine un código o un proyecto de esa naturaleza. ¿Cuáles son sus problemas, cuáles son sus compromisos? ¿Qué le gustaría realizar que, en la actualidad, es muy difícil lograr? A continuación, compruebe una referencia de patrón de diseño y busque los patrones que abordan los temas que le preocupan. La referencia más utilizada en el tema de los patrones de diseño es el llamado libro de la “banda de los cuatro”, Design Patterns: Elements of Reusable Object-Oriented Software por Erich Gamma, Richard Helm, Ralph Johnson y John Vlissides, Addison-Wesley, 1995. Los patrones de diseño son muy populares en la actualidad, por lo que no dejan de aparecer nuevos libros. 1.3 ¿Por qué preocuparse? Si es usted un programador o un diseñador brillante, o dispone de mucho tiempo para acumular experiencia, tal vez pueda hallar o inventar muchos patrones de diseño. Sin embargo, esta no es una manera eficaz de utilizar su tiempo. Un patrón de diseño es el trabajo de una persona que ya se encontró con el problema anteriormente, intentó muchas soluciones posibles, y escogió y describió una de las mejores. Y esto es algo de lo que debería aprovecharse. Los patrones de proyecto pueden parecerle abstractos a primera vista o, tal vez, no tenga la seguridad de que se ocupan del problema que le interesa. Comenzará a apreciarlos a medida que construya y modifique sistemas más grandes; tal vez durante su trabajo en el proyecto final Gizmoball. 2. Patrones de creación 2.1 Fábricas Suponga que está escribiendo una clase para representar carreras de bicicletas. Una carrera se compone de muchas bicicletas (entre otros objetos, quizás).

class Race {

Race createRace() { Frame frame1 = new Frame(); Wheel frontWhee11 = new Wheel(); Wheel rearWhee11 = new Wheel(); Bicycle bike1 = new Bicycle(frame1, frontWhee11, rearWhee11); Frame frame2 = new Frame(); Wheel frontWhee12 = new Wheel(); Wheel rearWhee12 = new Wheel(); Bicycle bike2 = new Bicycle(frame2, frontWhee12, rearWhee12); ...

}

Page 124: Curso Practico en Java de Ingenieria Del Software Mit

... }

Puede especificar la clase Race para otras carreras de bicicleta. // carrera francesa class TourDeFrance extends Race {

Race createRace() { Frame frame1 = new RacingFrame(); Wheel frontWhee11 = new Whee1700c(); Wheel rearWhee11 = new Whee1700c(); Bicycle bike1 = new Bicycle(frame1, frontWhee11, rearWhee11); Frame frame2 = new RacingFrame(); Wheel frontWhee12 = new Whee1700c(); Wheel rearWhee12 = new Whee1700c(); Bicycle bike2 = new Bicycle(frame2, frontWhee12, rearWhee12); ...

} ...

} //carrera en tierra class Cyclocross extends Race {

Race createRace() { Frame frame1 = new MountainFrame(); Wheel frontWhee11 = new Whee127in(); Wheel rearWhee11 = new Whee127in(); Bicycle bike1 = new Bicycle(frame1, frontWhee11, rearWhee11); Frame frame2 = new MountainFrame(); Wheel frontWhee12 = new Whee127in(); Wheel rearWhee12 = new Whee127in(); Bicycle bike2 = new Bicycle(frame2, frontWhee12, rearWhee12); ...

} ...

} En las subclases, createRace devuelve un objeto Race porque el compilador Java impone que los métodos superpuestos tengan valores de retorno idénticos. Por economía de espacio, los fragmentos de código anteriores omiten muchos otros métodos relacionados con las carreras de bicicleta, algunos de los cuales aparecen en todas las clases, en tanto que otros aparecen sólo en ciertas clases. La repetición del código es tediosa y, en particular, no fuimos capaces de reutilizar el método Race.createRace. (Podemos observar la abstracción de creación de un único objeto Bicycle a través de una función; utilizaremos esta sin más discusión, como es obvio, por lo menos después de realizar el curso 6.001). Debe existir un método mejor. El patrón de diseño de fábrica nos proporciona la respuesta. 2.1.1 Método de fabrica

Page 125: Curso Practico en Java de Ingenieria Del Software Mit

Un método de fábrica es el que fabrica objetos de un tipo determinado. Podemos añadir métodos de fábrica a Race: class Race {

Frame createFrame() { return new Frame(); } Wheel createWheel() { return new Wheel(); } Bicycle createBicycle(Frame frame, Wheel front, Wheel rear) { return new Bicycle(frame, front, rear);

} // devuelve una bicicleta completa sin necesidad de ningún argumento Bicycle completeBicycle() {

Frame frame = createFrame(); Wheel frontWheel = createWheel(); Wheel rearWheel = createWheel(); return createBicycle(frame, frontWheel, rearWheel);

} Race createRace() {

Bicycle bike1 = completeBicycle(); Bicycle bike2 = completeBicycle(); ... }

} Ahora las subclases pueden reutilizar createRace e incluso completeBicycle sin ninguna alteración: //carrera francesa class TourDeFrance extends Race {

Frame createFrame() { return new RacingFrame(); } Wheel createWheel() { return new Whee1700c(); } Bicycle createBicycle(Frame frame, Wheel front, Wheel rear) {

return new RacingBicycle(frame, front, rear); }

} class Cyclocross extends Race {

Frame createFrame() { return new MountainFrame(); } Wheel createWheel() { return new Whee126inch(); } Bicycle createBicycle(Frame frame, Wheel front, Wheel rear) {

return new RacingBicycle(frame, front, rear); }

} Los métodos de creación se denominan Factory methods (métodos de fábrica). 2.1.2 Objeto de fábrica

Page 126: Curso Practico en Java de Ingenieria Del Software Mit

Si existen muchos objetos para construir, la inclusión de los métodos de fábrica puede inflar el código haciéndolo difícil de modificar. Las subclases no pueden compartir fácilmente el mismo método de fábrica. Un objeto de fábrica es un objeto que comprende métodos de fábrica. class BicycleFactory {

Frame createFrame() { return new Frame(); } Wheel createWheel() { return new Wheel(); } Bicycle createBicycle(Frame frame, Wheel front, Wheel rear){

return new Bicycle(frame, front, rear); } // devuelve una bicicleta completa sin necesidad de ningún argumento Bicycle completeBicycle() {

Frame frame = createFrame(); Wheel frontWheel = createWheel(); Wheel rearWheel = createWheel();

return createBicycle(frame, frontWheel, rearWheel); }

} class RacingBicycleFactory {

Frame createFrame() { return new RacingFrame(); } Wheel createWheel() { return new Whee1700c(); } Bicycle createBicycle(Frame frame, Wheel front, Wheel rear) {

return new RacingBicycle(frame, front, rear); }

} class MountainBicycleFactory {

Frame createFrame() { return new MountainFrame(); } Wheel createWheel() { return new Whee126inch(); } Bicycle createBicycle(Frame frame, Wheel front, Wheel rear) {

return new RacingBicycle(frame, front, rear); }

} Los métodos Race utilizan objetos fábrica. class Race { BicycleFactory bfactory; //constructor Race () {

bfactory = new BicycleFactory(); } Race createRace() {

Bicycle bike1 = bfactory.completeBicycle(); Bicycle bike2 = bfactory.completeBicycle();

Page 127: Curso Practico en Java de Ingenieria Del Software Mit

... }

} class TourDeFrance extends Race {

//constructor TourDeFrance() { bfactory = new RacingBicycleFactory(); }

} class Cyclocross extends Race {

//constructor Cyclocross () { bfactory = new MountainBicycleFactory(); }

} En esta versión del código, el tipo de bicicleta está codificado en cada variedad de carrera. Hay un método más flexible que requiere una alteración en la forma en que los clientes llaman al constructor. class Race { BicycleFactory bfactory; // constructor Race(BicycleFactory bfactory) {

this.bfactory = bfactory; } Race createRace() {

Bicycle bike1 = bfactory.completeBicycle(); Bicycle bike2 = bfactory.completeBicycle(); ...

} } class TourDeFrance extends Race {

//constructor TourDeFrance(BicycleFactory bfactory) { this.bfactory = bfactory;

} } class Cyclocross extends Race {

// constructor Cyclocross(BicycleFactory bfactory) { this.bfactory = bfactory;

} }

Page 128: Curso Practico en Java de Ingenieria Del Software Mit

Éste es el mecanismo más flexible de todos. Con él, un cliente puede controlar tanto el tipo de carrera como la bicicleta utilizada en ella, por ejemplo, por medio de una llamada del tipo

new TourDeFrance(new TricycleFactory()) Una razón por la que los métodos Factory son necesarios es la primera debilidad de los constructores de Java: siempre devuelven un objeto del tipo especificado. Ellos nunca pueden devolver un objeto de un subtipo, aunque exista una corrección de tipos (tanto en relación con el mecanismo de subtipos de Java como en relación con el verdadero comportamiento de la práctica de subtipo, tal y como se describirá en la clase 15). 2.1.3 El patrón prototipo El patrón prototipo ofrece otra manera de construir los objetos de los tipos arbitrarios. En lugar de pasar un objeto BicycleFactory, un objeto Bicycle es recibido como argumento. Su método clone es invocado para crear nuevos objetos Bicycle; estamos construyendo copias del objeto ofrecido. class Bicycle {

Object clone() { ... } } class Frame {

Object clone() { ... } } class Wheel {

Object clone() { ... } } class RacingBicycle {

Object clone() { ... } } class RacingFrame {

Object clone() { ... } } class Whee1700c {

Object clone() { ... } } class MountainBicycle {

Object clone() { ... } } class MountainFrame { Object clone() { ... } } class Whee126inch {

Object clone() { ... } } class Race { Bicycle bproto;

Page 129: Curso Practico en Java de Ingenieria Del Software Mit

//constructor Race(Bicycle bproto) {

this.bproto = bproto; } Race createRace() {

Bicycle bike1 = (Bicycle) bproto.clone(); Bicycle bike2 = (Bicycle) bproto.clone(); }

} class TourDeFrance extends Race { //constructor

TourDeFrance(Bicycle bproto) { this.bproto = bproto; }

} class Cyclocross extends Race {

//constructor Cyclocross(Bicycle bproto) { this.bproto = bproto; }

} Efectivamente, cada objeto es, en sí mismo, una fábrica especializada en construir objetos iguales a sí mismo. Los prototipos se utilizan de ordinario en lenguajes tipificados dinámicamente como Smalltalk, y menos frecuentemente en lenguajes tipificados estáticamente como C++ y Java. No obstante, esta técnica tiene un coste: el código para crear objetos de una clase particular debe estar en algún lugar. Los métodos de fábrica colocan el código en métodos de cliente; los objetos de fábrica colocan el código en métodos de un objeto de fábrica y los prototipos colocan el código en métodos clone. 2.2 El patrón Sharing Muchos otros patrones de diseño están relacionados con la creación de objetos en el sentido de que influyen sobre los constructores (y necesitan utilizar fábricas) y están relacionados con la estructura en el sentido de que especifican patrones sharing entre varios objetos. 2.2.1 El patrón singular El patrón singular garantiza que, en todo momento, sólo existe un objeto de una clase particular. Tal vez desee utilizarlo para su clase Gym del proyecto Gym Manager, ya que los métodos de este patrón (como listas de espera para una máquina específica) son los más acertados para la gestión de una única ubicación. Un programa que instancia múltiples copias, probablemente tenga un error, pero la utilización del patrón singular hace que tales errores sean inofensivos. class Gym {

Page 130: Curso Practico en Java de Ingenieria Del Software Mit

private static Gym theGym; //constructor private Gym() { ... } //método fábrica public static getGym() {

if (theGym == null) { theGym = new Gym();

} return theGym;

} } El patrón singular también es útil para objetos grandes y caros que no deben ser instanciados múltiples veces. La razón por la que debe utilizarse un método de fábrica, en vez de un constructor, es la segunda debilidad de los constructores de Java: siempre devuelven un objeto nuevo, nunca un objeto ya existente. 2.2.2 El patrón Interning El patrón de diseño Interning reutiliza objetos inexistentes en vez de crear nuevos. Si un cliente solicita un objeto que es igual a uno ya existente, se devuelve el objeto existente. Esta técnica sólo funciona para los objetos inmutables. Como ejemplo, la clase MapQuick representa una calle determinada mediante muchas clases StreetSegments. Los objetos StreetSegments tendrán el mismo nombre de calle y el mismo código postal. Aquí representamos un posible diagrama de objeto (captura) para una parte de la calle.

Esta representación es correcta (por ejemplo, todos los pares de nombres de calle son considerados iguales a través del método equals), sin embargo, esto requiere una pérdida innecesaria de espacio. Una mejor configuración del sistema sería:

Page 131: Curso Practico en Java de Ingenieria Del Software Mit

La diferencia en la utilización del espacio es substancial – tanto que es improbable que usted pueda leer, aunque se trate de una pequeña base de datos, en un objeto MapQuick en el que no aparezca este sharing. Por lo tanto, la implementación del objeto StreetSegReader que se le facilitó, realiza esta operación. El patrón Interning posibilita la reutilización de objetos inmutables: en vez de crear un nuevo objeto, se reutiliza una representación canónica. Este patrón requiere una tabla de todos los objetos que han sido creados; si esta tabla contiene objetos iguales al objeto deseado, se devuelve esa versión del objeto existente. Por razones de rendimiento se utiliza una tabla hash (de dislocación), que asigna el contenido con los objetos (ya que la igualdad depende sólo de los contenidos). Aquí se representa un fragmento de código que realiza la operación de Interning en strings (cadenas) que denominan nombres de segmentos:

HashMap segnames = new HashMap(); canonicalName(String n) {

if (segnames.containsKey(n)) { return segnames.get(n);

} else { segnames.put(n, n); return n;

}

Page 132: Curso Practico en Java de Ingenieria Del Software Mit

} Las cadenas son un caso especial, pues la mejor representación de una secuencia de caracteres (el contenido) es la propia cadena; y terminamos con una tabla que asigna cadenas a cadenas. Esta estrategia es correcta en general: el código construye una representación no canónica, esta representación no canónica se asigna a la representación canónica y se devuelve esta última representación. No obstante, dependiendo de la cantidad de trabajo realizada por el constructor, puede ser más eficiente no construir la representación no canónica si no es necesario, en cuyo caso la tabla podría realizar la asignación del contenido (no del objeto) con la representación canónica. Por ejemplo, si estuviésemos realizando una operación de Interning sobre objetos de una clase denominada GeoPoints, indexaríamos la tabla utilizando los parámetros de latitud y longitud. El código de ejemplo anterior utiliza el mapa de las cadenas con las propias cadenas, pero no puede utilizar un objeto Set en lugar de un objeto Map. La razón de esto es que la clase Set no posee una operación get, sólo una operación contains. La operación contains utiliza equals para realizar comparaciones. Por tanto, incluso si myset.contains(mystring), esto no significa que mystring sea un miembro, idéntico, de myset, y no hay modo conveniente de acceder al elemento de myset que corresponda (equals) a mystring. La noción de tener sólo una versión de una cadena dada es tan importante que está incorporada en Java; String.intern devuelve la versión canónica de una cadena. El texto de Liskov habla del patrón Interning en la sección 15.2.1, pero denomina la técnica 'flyweight' (o 'peso-mosca'), que es un término diferente de la terminología estándar en este campo. 2.2.3 Flyweight El patrón Flyweight es una generalización del patrón Interning. (En el texto de Liskov, en la sección 15.2.1, titulada "Flyweight", habla del patrón Interning y dice que es un caso especial de Flyweight). El patrón Interning es aplicable sólo cuando un objeto es completamente inmutable. La forma más general del patrón Flyweight se puede utilizar cuando la mayor parte (no necesariamente todo) del objeto es inmutable. Examine el caso de los radios (spoke) de la rueda de una bicicleta.

class Wheel { ... FullSpoke[] spokes; ... } //más adelante definiremos una versión simplificada //de esta clase, denominada "Spoke"

class FullSpoke { int length; int diameter; boolean tapered; Metal material;

Page 133: Curso Practico en Java de Ingenieria Del Software Mit

float weight; float threading; boolean crimped; int location; //localización en la cual el radio se encaja en el cubo y

en el aro de la rueda } Normalmente hay de 32 a 36 radios por rueda (hasta 48 en una bicicleta tipo tándem). Sin embargo, existen apenas tres variedades diferentes de radio por bicicleta: uno para la rueda delantera y dos para la rueda trasera (ya que el cubo de la rueda de atrás no está centrado, de forma que son necesarios radios de longitudes diferentes). Preferiríamos asignar sólo tres objetos Spoke (o FullSpoke) diferentes, en vez de uno por radio de bicicleta. No es aceptable tener un único objeto Spoke en la clase Wheel en vez de un array, no sólo por la falta de simetría de la rueda trasera, sino también porque podría sustituir un radio (después de una rotura, por ejemplo) por otro que tenga la misma longitud pero difiera en otras características. El patrón Interning no se puede utilizar, ya que los objetos no son idénticos: difieren en el campo location. En una carrera de bicicletas, con 10.000 bicicletas, es posible que apenas existan unas centenas de radios diferentes, pero millones de instancias de ellos; sería desastroso asignar millones de objetos Spoke. Los objetos Spoke podrían ser compartidos entre las bicicletas diferentes (dos amigos con bicicletas idénticas podrían compartir el mismo radio número 22 de la rueda delantera), aun así no tendríamos una repartición representativa y, en cualquier evento, es más posible que existan radios semejantes en una bicicleta de los que hay entre varias bicicletas. El primer paso para la utilización del patrón Flyweight es separar los estados intrínsecos de los estados extrínsecos. Los estados intrínsecos se mantienen en el objeto; los estados extrínsecos se mantienen fuera del objeto. Para que el patrón Interning sea posible, los estados intrínsecos deben ser inmutables y similares en los objetos. Creemos una clase Spoke no dependiente de la propiedad de location para los estados intrínsecos:

class Spoke { int length; int diameter; boolean tapered; Metal material; float weight; float threading; boolean crimped;

} Para añadir los estados extrínsecos, no es posible hacer lo siguiente:

class InstalledSpokeFull extends Spoke { int location;

}

Page 134: Curso Practico en Java de Ingenieria Del Software Mit

porque esto es sólo una forma simplificada de la clase FullSpoke; la clase InstalledSpokeFull consume la misma cantidad de memoria que FullSpoke ya que tienen los mismos campos. Otra posibilidad es:

class InstalledSpokeWrapper { Spoke s; int location;

} Este es un ejemplo de un wrapper (del que trataremos en breve) que ahorra una buena cantidad de espacio porque los objetos Spoke se pueden compartir entre objetos InstalledSpokeWrapper. No obstante, hay una solución que construye menos memoria. Observe que la propiedad location de un radio dado es igual al valor del índice del objeto Spoke que representa este radio en el array Wheel.spokes:

class Wheel { ... Spoke[] spokes; ...

} No es necesario en absoluto almacenar esta información (extrínseca). No obstante, algunas partes del código de cliente (en Wheel) deben cambiarse, ya que los métodos de FullSpoke que utilizaban el campo location deben tener acceso a esta información. Dada esta versión utilizando la clase FullSpoke:

class FullSpoke { // tense el radio girando el engrasador el número // especificado de veces (turns) void tighten(int turns){ ... location... }

} class Wheel {

FullSpoke[] spokes;

//el método debería tener el nombre "true", //pero este nombre de identificador no es bueno void align() {

while (la rueda está mal alineada) { ... spokes[i].tighten(numturns) ...

} }

} La versión correspondiente con el patrón Flyweight es:

Page 135: Curso Practico en Java de Ingenieria Del Software Mit

class Spoke { void tighten(int turns, int location) {

... location... }

} class Wheel {

FullSpoke[] spokes;

void align() { while (la rueda está mal alineada) {

... spokes[i].tighten(numturns, i) ... }

} }

La referencia a una clase Spoke como patrón Interning es mucho menos costosa para el sistema si se compara con una clase Spoke sin la utilización de ese patrón, se puede decir que esta nueva versión de la clase es más leve, pudiendo ser considerada peso-mosca (flyweight) en contraposición a su versión más pesada; el mismo principio se puede aplicar a la clase InstalledSpokeWrapper, aunque su consumo extra de memoria es como mínimo tres veces mayor (o posiblemente más). La misma técnica funciona en la clase FullSpoke si contiene un campo ‘rueda’ (wheel) que se refiere a la rueda en la que el radio representado por la clase está instalado; los métodos de la clase Wheel pueden, fácilmente, pasar el propio objeto Wheel para los métodos de la clase Spoke. El mismo truco funciona si FullSpoke contiene un campo wheel referido a la rueda en que se instala; los métodos wheels pueden convertir esto al método Spoke. Si la clase FullSpoke también contiene un campo booleano denominado 'broken' (quebrado), ¿cómo podría representarse? Se trata de otra información extrínseca, que no aparece en el programa explícitamente, de la misma forma que lo hacen las propiedades location y wheel. Esta información debe estar explícitamente almacenada en la clase Wheel, probablemente como un array booleano, paralelo al array de objetos Spoke. Esto es un tanto inadecuado —el código está comenzado a ponerse feo— pero es aceptable si la necesidad de ahorro de espacio es crítica. Sin embargo, si hay muchos campos semejantes al campo broken, el proyecto debe reconsiderarse. Recuerde que el patrón Flyweight debe utilizarse únicamente después de que un análisis de sistema determine que la economía de espacio de la memoria es crítica para el rendimiento, esto es, la memoria es un cuello de botella (bottleneck) del programa. Al introducir estas construcciones en un programa, estamos complicando su código y aumentando las posibilidades de aparición de errores. El patrón Interning debe ponerse en práctica sólo en circunstancias muy limitadas.

Page 136: Curso Practico en Java de Ingenieria Del Software Mit

Clase 13. Patrones de diseño, 2ª parte. 3 Patrones de comportamiento 3.1 Comunicación multi-modos Es bastante fácil para un único cliente utilizar una única abstracción. (Ya hemos visto patrones para facilitar la tarea de modificar las abstracciones que están siendo utilizadas, lo que es una tarea común). Sin embargo, en ocasiones, es posible que un cliente necesite utilizar múltiples abstracciones; además de eso, tal vez el cliente no sepa con antelación cuántas o qué abstracciones serán utilizadas. Los patrones observador (observer), cuadro negro (blackboard) y mediador (mediator) permiten esta comunicación. 3.1.1 Observador Suponga que existe una base de datos con todas las notas de los estudiantes del MIT, y el personal docente del curso 6.170 desea consultar las notas de ese curso. Podrían escribir una clase SpreadsheetView que mostrara la información de la base de datos. (Asumiremos que el visor almacena en la caché los datos sobre los estudiantes del 6.170 – tal vez necesite esa información para una nueva exhibición– aunque esto no es parte importante de este debate). La visualización deberá tener un aspecto similar a esto:

Suponga que el código para comunicarse entre la base de datos que contiene las notas y la visión de la base de datos utiliza la siguiente interfaz:

interface GradeDBViewer{ void update(String course, String name, String assignment, int grade); }

Cuando una nueva información sobre las notas está disponible (esto es, una nueva tarea es calificada e introducida en la base de datos, o se vuelve a calificar una tarea cambiando la nota antigua), la base de datos que contiene esas notas debe comunicar esa información al visor. Vamos a suponer que Ben Bitdiddle solicitó una revisión del boletín de ejercicios 1, y que esta revisión puso de manifiesto errores en las notas: la puntuación de Ben debería haber sido 30. El código de la base de datos debe realizar llamadas a SpreadsheetView.update. Suponga que lo hace de la siguiente manera:

SpreadsheetView ssv = new SpreadsheetView(); ... ssv.update(“6.170”, “B. Bitdiddle”, “PS1”, 30);

(Para simplificar, esta parte de código representa valores literales en vez de variables para los argumentos de update.)

Page 137: Curso Practico en Java de Ingenieria Del Software Mit

Entonces el aspecto de la plantilla electrónica sería la siguiente:

Tal vez el personal docente del curso decida más tarde que también le gustaría ver las medias de las notas en un gráfico de barras e implementen el siguiente dispositivo visualizador:

Para mantener esta visualización conjuntamente con la visualización de la plantilla electrónica es necesario modificar el código de la base de datos:

SpreadsheetView ssv = new SpreadsheetView(); BargraphView bgv = new BargraphView(); ... ssv.update(“6.170”, “B. Bitdiddle”, “PS1”, 30); bgv.update(“6.170”, “B. Bitdiddle”, “PS1”, 30);

De la misma manera, añadir la visualización de un gráfico de tarta, o eliminar alguna visualización, necesitaría de más modificaciones en el código de la base de datos. La programación orientada a objetos (por no mencionar la buena práctica de la programación) debe facilitar la realización de estas modificaciones: el código debe ser reutilizable sin necesidad de editar o compilar de nuevo el cliente o la implementación. El patrón observador logra el objetivo en este caso. En lugar de codificar de modo inmutable qué visualizaciones actualizar, la base de datos puede mantener una lista de observadores que sean notificados cuando su estado se altere.

Vector observers = new Vector(); ... for (int i=0; i<observers.size(); i++){ GradeDBViewer v = (GradeDBViewer) observers[i];

Page 138: Curso Practico en Java de Ingenieria Del Software Mit

v.update(“6.170”, “B. Bitdiddle”, “PS1”, 30); }

Para inicializar los vectores de los observadores, la base de datos facilitará dos métodos adicionales, register para añadir un observador y remove para eliminar un observador.

void register(GradeDBViewer observer){ observers.add(observer);

} void remove(GradeDBViewer observer){ return observers.remove(observer);

} El patrón observador permite que el código de cliente (que gestiona la base de datos y los dispositivos visualizadores) seleccione qué observador está activo, y los observadores pueden incluso añadirse o eliminarse en tiempo de ejecución. En este debate se han pasado por alto varios detalles. Por ejemplo, el cliente podría almacenar toda la información que le interese (todas las notas del curso 6.170, o sólo las notas de algunos estudiantes, o bien sólo el número de actualizaciones de la base de datos para DatabaseActivityViewer), duplicando partes de la base de datos, o podría realizar una lectura de la base de datos cuando sea necesario. Una decisión de diseño relacionada es si la base de datos envía todos los datos potencialmente importantes para el cliente cuando tiene lugar una actualización (éste es el modelo push), o la base de datos siempre informa al cliente, “se ha realizado una actualización” (este es el modelo pull). El modelo push obliga al cliente a solicitar información, lo que puede dar lugar a un mayor número de mensajes, pero en general se transmite una menor cantidad de datos. 3.1.2 Blackboard El patrón blackboard generaliza el patrón observador para permitir múltiples fuentes de datos y múltiples visualizadores. También realiza un completo desacoplamiento de los productores y consumidores de información. Blackboard es un almacén de mensajes que puede ser leído y editado por todos los procesos. Siempre que ocurra algo que puede ser de interés para otra parte, el proceso responsable o informado sobre el evento añade al blackboard una notificación del evento. Otros procesos pueden leer el cuadro negro. En un caso típico, ignoran la mayoría de su contenido, que no les es de interés, sin embargo, tal vez reaccionen a otros eventos. Un proceso que coloca una notificación en el blackboard no sabe si cero, uno, o varios otros procesos están prestando atención a sus modificaciones. Estos patrones, por lo general, no imponen una estructura determinada a sus anuncios, pero es necesario un formato de mensaje bien comprendido para que los procesos puedan operar entre sí. Algunos presentan servicios de filtro para que los clientes no vean todas las modificaciones, sino sólo las de un tipo determinado; otros envían notificaciones automáticamente para los clientes que tengan interés registrado (ésta es una técnica pull). Un boletín de noticias ordinario (tanto físico como electrónico) es un ejemplo de un sistema que emplea un blackboard. Otro ejemplo que emplea este patrón en el MIT es el servicio de mensajes zephyr.

Page 139: Curso Practico en Java de Ingenieria Del Software Mit

El texto de Liskov llama a este patrón “white board” en lugar de “blackboard”. El primer nombre tiene una apariencia más moderna, pertenece a la terminología de computación estándar que lleva utilizándose durante décadas y se reconocerá mejor fuera del curso 6.170. El primer gran sistema que empleó el patrón blackboard fue el sistema de reconocimiento del habla Hearsay-II, implementado entre 1971 y 1976. 3.1.3 Mediador El patrón mediador es un patrón intermediario entre observador y blackboard. Desacopla las informaciones de los productores y de los consumidores, pero no desacopla el control. Mientras que la comunicación del patrón blackboard es asíncrona, en el patrón mediador es síncrona: no devuelve el control a los productores antes de pasar la información a todos los consumidores. 3.2 Conexión de compuestos Consultar sección 4.2 sobre el patrón de diseño composite. Esta sección trata de cómo conectar compuestos o realizar otras operaciones en todas las subpartes de un compuesto. Nuestro objetivo es que sea capaz de funcionar en varias operaciones diferentes, y ser capaz de realizarlas en varias subpartes de un objeto compuesto. Como tanto la operación a realizar como el tipo de objeto compuesto en el que la operación será realizada afectan a la implementación, decidir cómo dividir el problema en partes puede ser difícil. Piense en el ejemplo de un árbol de sintaxis abstracta, o AST, que es una representación de (la sintaxis de) un programa de computación. Por ejemplo, el operador de adicción binaria + puede estar representado por objetos:

PlusOp: class PlusOp extends Expression { Expression leftExp; Expression rightExp;

... }

Las referencias variables, las operaciones de distribución (a=b), y las expresiones condicionales (a?b:c) son otro tipo de expresiones:

class VarRef extends Expression { String varname; ... } class AssignOp extends Expression { VarRef lvalue; // lado izquierdo; “a” en “a=b” Expression rvalue; // lado derecho; “b” en “a=b”

... } class CondExpr extends Expression { Expression condition; Expression thenExpr; Expression elseExpr;

... }

Una representación completa tendría también muchos otros tipos de nodos AST, como AssignOp para atribuciones, para expresiones, etc.

Page 140: Curso Practico en Java de Ingenieria Del Software Mit

Un uso particular de +, como a + b, sería representado en tiempo de ejecución por

Un compilador u otra herramienta de análisis de programas crea un AST analizando sintácticamente el programa destino; tras el análisis, la herramienta realiza operaciones, como la verificación de tipos (typecheck), estilo de impresión (pretty-printing), la optimización o la generación de código, en el AST. Cada operación difiere de las otras, pero cada nodo AST es también distinto de los otros. Cada célula de esta tabla se rellenará con un código diferente:

Objetos Operaciones La cuestión es si organizar el código de manera que se reúna todo el código de verificación de tipo (y necesariamente ampliar el código relativo a CondExprs por la implementación) o agrupar todo el código que se ocupa de un tipo particular de expresión, pero separar el código que versa sobre una operación particular. (Un asunto parecido es cómo seleccionar y ejecutar el bloque de código adecuado, independientemente del lugar en el que pueda estar almacenado. El mecanismo de lanzamiento de métodos de Java selecciona qué versión de un método sobrecargado llamar basándose en el tipo de tiempo de ejecución del receptor. De este modo es posible realizar el lanzamiento basándose tanto en las operaciones como en los objetos, pero no en ambos a la vez). Los patrones intérprete y procedimiento (y visitante – visitor – que es una mejora del patrón procedimiento) permiten la expresión de operaciones sobre objetos compuestos como AST. El patrón intérprete reúne todos los objetos similares y distribuye separadamente las operaciones similares. El padrón procedimiento reúne las operaciones similares y distribuye separadamente los objetos similares. Esto significa que: El patrón intérprete facilita la suma de objetos, y dificulta la de operaciones. El patrón procedimiento facilita la suma de operaciones, y dificulta la de objetos. Cuando decimos “facilitar” y “dificultar” nos referimos a cuántas clases diferentes han de ser modificadas. Cuando se utiliza la clase intérprete, la suma de un nuevo objeto requiere que se escriba una única clase nueva, pero la de una nueva operación requiere la modificación de todas las clases existentes. Lo contrario es cierto para el patrón procedimiento. Ambos poseen clases para todos los objetos que capturan aquellas peculiaridades de los objetos, como se puso de manifiesto en los ejemplos de código para CondExpr y AssignOp más arriba; la

Page 141: Curso Practico en Java de Ingenieria Del Software Mit

cuestión es dónde colocar las implementaciones de operaciones que existen para todos los objetos. Los ejemplos que mostraremos más adelante ayudarán a clarificar esta cuestión. La estrategia que hay que elaborar para diseñar un sistema de software depende de dos factores. Primero, ¿ve el sistema centrado en la operación o centrado en el operando? ¿Son los algoritmos fundamentales o lo son los objetos? (En un sistema orientado a objeto, generalmente son los objetos.) Segundo, ¿qué aspectos del sistema tienen más probabilidad de sufrir modificaciones? (La sintaxis del lenguaje de programación raramente cambia para añadir nuevos tipos de expresiones, pero un analizador de programas, como un compilador, se suele ampliar con nuevas funcionalidades.) Estas alteraciones deben ser facilitadas por su elección de patrón de diseño. 3.2.1 Intérprete El patrón intérprete agrupa todas las operaciones para una variedad determinada de objeto. Utiliza las clases preexistentes para objetos y añade a cada clase un método para cada operación compatible. Por ejemplo,

class Expression { ... Type typecheck(); String prettyPrint();

} ... class AssignOp extends Expression {

... Type typecheck() { ... } String prettyPrint() { ... }

} class CondExpr extends Expression {

... Type typecheck() { ... } String prettyPrint() { ... }

} 3.2.2 Procedimiento El patrón procedimiento agrupa todo el código que implementa una operación determinada. Crea una clase para cada operación; ya que cada clase tiene un método separado para cada tipo de operando. Por ejemplo, el código de verificación de tipo tendría el siguiente aspecto:

class Typecheck { ... // verifica el tipo de “a?b:c” Type tcCondExpr(CondExpr e){ Type codeType = tcExpression(e.condition); // tipo de “a” Type thenType = tcExpression(e.thenExpr); // tipo de “b” Type elseType = tcExpression(e.elseExpr); // tipo de “c” // El tipo booleano BoolType se define en otro lugar if ((condType = = BoolType) && (thenType = = elseType)) { // Esta expresión está “bien tecleada”, pues la condición es de tipo

Page 142: Curso Practico en Java de Ingenieria Del Software Mit

// booleano y las bifurcaciones then y else tienen el mismo tipo. // El tipo de la expresión entera es el tipo de las bifurcaciones. return thenType; } else { return ErrorType; // O tipo ErrorType se define en otro lugar } } // verifica el tipo de “a=b” Type tcAssignOp(AssignOp e) { ...

} }

El patrón procedimiento funciona bastante bien, pero hay una parte que no es buena: la definición de tcExpression. Necesita llamar tcCondExpr o tcAssignOp o tcVarRef u otra función, dependiendo del tipo de tiempo de ejecución de los subcomponentes de una expresión.

class Typecheck { ...

Type tcExpression(Expression e) { if (e instanceof PlusOp) {

return tcPlusOp((PlusOp)e); } else if (e instanceof VarRef) {

return tcVarRef((VarRef)e); } else if (e instanceof AssignOp) {

return tcAssignOp((AssignOp)e); } else if (e instanceof CondExpr) {

return tcCondExpr((CondExpr)e); } else ... ...

} }

Mantener este código hace el programa tedioso y propenso a errores, y es probable que la larga cascada de pruebas if sea de ejecución lenta. Además, aunque este código sería indeseable aunque sólo ocurriese una vez, de hecho ocurre de nuevo en la clase PrettyPrint y en todas las otras clases de operación. La repetición sistemática en el código es generalmente una señal de que hay que realizar el diseño nuevamente, posiblemente utilizando un patrón de diseño. Nosotros conocemos un constructor de Java que automáticamente escoge qué código ejecutar basándose en una prueba de tipo: el lanzamiento de método. Realiza el mismo tipo de comparación y selección que la cascada de pruebas if, pero no amontona el código y es seguramente más eficiente. El patrón visitante se aprovecha de esto. 3.2.3 Visitante El patrón visitante codifica una busca con detenimiento (o alternativamente, alguna otra variedad de búsqueda) sobre una estructura de datos jerárquica como la resultante del patrón compuesto. El patrón visitante depende de dos operaciones: los nodos (objetos) aceptan a los

Page 143: Curso Practico en Java de Ingenieria Del Software Mit

visitantes, y los visitantes visitan los nodos (objetos). Conceptualmente, la estructura del código es la siguiente:

class Node { ... void accept(Visitor v) { for each child of this node { child.accept(v);

} v.visit(this);

} } class Visitor { ... void visit(Node n) { perform work on n

} }

Los métodos accept y visit trabajan en conjunto de manera que n.accept(v) realiza una búsqueda con detenimiento en la estructura con raíz en n, con la operación representada por v siendo ejecutada en cada componente de la estructura a medida que se recorren. Considere una composición con la siguiente estructura:

La secuencia de llamadas resultante de a.accept(v) para algún visitante v es: a.accept(v) b.accept(v) d.accept(v) v.visit(d) e.accept(v) v.visit(e) v.visit(b) c.accept(v) f.accept(v) v.visit(f) v.visit(c) v.visit(a) La secuencia de llamadas a visit, que es quien realmente ejecuta el trabajo, es d, e, b, f, c, a; esta es una búsqueda con detalle. El método visit puede contar el número de nodos o ejecutar una verificación de tipo u otra operación.

Page 144: Curso Practico en Java de Ingenieria Del Software Mit

El patrón visitante requiere la adición de los métodos visit y accept; consulte el libro de texto de Liskov para ver un ejemplo. Al igual que el patrón procedimiento, el patrón visitante facilita la adición de operaciones (visitantes), pero dificulta la de nodos (que requiere la modificación de cada visitante existente). El visitante es como un objeto iterador: esencialmente, cada elemento de estructura de datos se presenta al método visit a medida que se recorren. Esto da la oportunidad para más cosas, mientras tanto: un visitante puede acumular estados que serían imposibles de determinar a partir de una única secuencia de nodos. Desgraciadamente, la estructura de implementación descrita anteriormente no ofrece modo alguno de hacer que una llamada de visit se comunique con otra. He aquí dos posibles soluciones a este problema. El libro de texto propone guardar la información en una estructura de datos separada (por ejemplo, una pila) que pueda ser leída y escrita. Esto deja limpios a los visitantes y a los aceptantes, pero puede ser difícil ver cómo los datos fluyen entre las llamadas. Una solución alternativa es desplazar parte del trabajo para el propio visitante:

class Node { ... void accept(Visitor v) {

v.visit(this); }

} class Visitor { ... void visit(Node n) {

for each child of this node { child.accept(v); }

perform work on n }

} Esta solución presenta varios problemas. Primero, existen muchos visitantes, por lo que el código de búsqueda se repite varias veces en lugar de aparecer sólo una vez (ya que sólo hay un aceptante). Segundo, el aceptante no hace ya nada más. El visitante está haciendo esencialmente una búsqueda con detalle por sí sólo. Esta solución tiene el mérito de hacer que el flujo de información fluya más claro, en el caso común de que un visitante de un nodo dependa de los resultados de la visita de los hijos. 3.3 Estado No trataremos el patrón estado (state) en detalle, pero tal vez desee tenerlo en cuenta para la implementación de StreetNumberSet.

Page 145: Curso Practico en Java de Ingenieria Del Software Mit

4 Patrones estructurales 4.1 Wrappers (Envoltorios) Los envoltorios modifican el comportamiento de otra clase; a menudo funcionan como una fina capa sobre la clase encapsulada, que realiza el trabajo real. El envoltorio puede modificar a la interfaz, extender el comportamiento o restringir el acceso. La función de un envoltorio consiste en hacer de intermediario entre dos interfaces incompatibles, traduciendo las llamadas entre éstas. Esto permite que dos piezas de código, que no se diseñaron o escribieron al mismo tiempo y que, por tanto, son ligeramente incompatibles, puedan utilizarse juntas en cualquier situación. A continuación se nombran tres tipos de envoltorios: adaptadores, decoradores y proxies.

Patrón Funcionalidad Interfaz Adaptador Igual Diferente Decorador Diferente Igual Proxy Igual Igual

Las funcionalidades y las interfaces que se han comparado arriba son aquellas de dentro y fuera del envoltorio; es decir, la vista del cliente del objeto envuelto se compara con la vista del cliente del envoltorio. A lo largo de este tema, se tratarán los tres tipos de envoltorios, para luego pasar a analizar los pros y contras de dos estrategias de implementación: la herencia de clases y la delegación. 4.1.1 Adaptador Los adaptadores modifican la interfaz de una clase sin alterar su funcionalidad básica. Por ejemplo, pueden permitir la interoperabilidad entre un paquete geométrico que requiera que los ángulos se especifiquen en radianes y un cliente que espere que los ángulos se pasen a grados. Aquí tiene otros dos ejemplos: Ejemplo: Rectángulo Suponga que usted ha escrito un código que funciona sobre objetos Rectangle y que llama a su método scale. Interface Rectangle{ // aumenta o disminuye esto por el factor dado void scale(float factor); // otras operaciones float area(); float circumference(); ... } class myClass{ void myMethod(Rectangle r){ ... r.scale(2); ... } }

25

Page 146: Curso Practico en Java de Ingenieria Del Software Mit

Suponga que existe otra clase NonScaleableRectangle, que no tiene el método scale, pero tiene los otros métodos de Rectangle, junto con los métodos adicionales setWidth y setHeigh. class NonScaleableRectangle{ void setWidth(float width){ ... } void setHeight(float height){ ... } ... } Es posible que desee cambiar a esta variedad de rectángulo, o al menos permitir su uso, tal vez porque tenga características que le convengan, como un mejor funcionamiento, o quizás porque se utilice en otro lugar, como en un sistema con el que usted tiene que interaccionar. No puede utilizar NonScaleableRectangle directamente, debido a la incompatibilidad de la interfaz. Sin embargo, puede escribir un adaptador que permita su utilización. Existen dos maneras de hacer esto: a través de la herencia de clases (subclases) o por medio de la delegación. La solución basada en la herencia de clases le resultará familiar: class ScaleableRectangle1 extends NonScaleableRectangle implements Rectangle { void scale(float factor){ setWidth(factor *getWidth()); setHeight(factor *getHeight()); } } La delegación es una técnica que consiste en “escurrir el bulto”, enviando una petición para que un objeto distinto realice el trabajo solicitado. class ScaleableRectangle2 implements Rectangle{ NonScaleableRectangle r; ScaleableRectangle2(NonScaleableRectangle r){ this.r = r; } void scale(float factor){ setWidth(factor * getWidth()); setHeight(factor * getHeight()); } float area() { return r.area(); } float circumference() { return r.circunference(); } ... } Ejemplo: Paleta Imagine que el profesor Jackson llama al profesor Ernst a media noche porque alguien ha descubierto que hay un problema relacionado con el boletín de ejercicios: éste debe ser capaz de soportar bicicletas que se puedan volver a pintar (para cambiar su color). Los profesores dividen el trabajo: el profesor Jackson escribirá la clase ColorPalette con un método que, dado un nombre como “rojo ”, “azul” o “ceniza”, devuelva un array con tres valores RGB, y el profesor Devadas escribirá un código que utilice esta clase. Los profesores hacen esto, pasan los tests al trabajo realizado, se van de fin de semana, y dejan los archivos .class para que los monitores de prácticas los integren. Estos se dan cuenta de que el profesor Devadas ha escrito un código que depende de: interface ColorPalette{ // devuelve valores RGB int[] getColor(String name); } Sin embargo, el profesor Jackson implementa una clase que se adhiere a:

25

Page 147: Curso Practico en Java de Ingenieria Del Software Mit

interface ColourPalette{ // devuelve valores RGB int[] getColour(String name); } ¿Qué es lo que los monitores tienen que hacer? Ellos no tienen acceso a la fuente, y no disponen de tiempo para volver a implementar y volver a pasar las pruebas. Su solución consiste en escribir un adaptador para ColourPalette que cambie el nombre de la operación. Pueden implementar el adaptador utilizando la herencia de clases (subclases) o la delegación. 4.1.2 Decorador Mientras que un adaptador modifica la interfaz sin añadir funciones nuevas, un decorador amplía la funcionalidad al tiempo que mantiene la misma interfaz. Generalmente, un decorador no altera la funcionalidad existente, sólo añade más funciones, de modo que los objetos de la clase resultante se comporten exactamente como los originales, pero que también realicen algo adicional. Esto se parece a la herencia de clases, pero no toda instancia de una subclase es una decoración. En primer lugar, la implementación de una operación puede ser completamente distinta, o bien puede estar reimplementada en una subclase; lo que generalmente no ocurre con un decorador, que posee relativamente menos funcionalidad y reutiliza el código de la superclase. En segundo lugar, las subclases pueden introducir nuevas operaciones, mientras que los envoltorios (incluidos los decoradores), no pueden. Un ejemplo de decoración es una interfaz Window (para un gestor de ventana) y una interfaz BordereWindow. La interfaz BordereWindow se comporta exactamente como la Window, excepto en que también traza un borde alrededor del lado exterior. Suponga que Window se implementa de la siguiente forma: interface Window { // rectángulo que limita con la ventana Rectangle bounds(); // dibuja este objeto en la pantalla especificada void draw(Screen s); ... } class WindowImpl implements Window{ ... } La implementación que utiliza el concepto de la herencia de clases, sería así: class BorderedWindow1 extends WindowImpl { void draw(Screens) { super.draw(s); bounds().draw(s); } } La implementación que utiliza el concepto de delegación tendría el siguiente aspecto: class BorderedWindow2 implements Window { Window innerWindow; BoreredWindow2(Window innerWindow){ This.innerWindow = innerWindow; } void draw(Screen s){ innerWindow.draw(s); innerWindow.bounds().draw(s);

25

Page 148: Curso Practico en Java de Ingenieria Del Software Mit

} } 4.1.3 Proxy Un proxy es un envoltorio que posee la misma interfaz y la misma funcionalidad que la clase a la que encapsula. Esto no parece muy práctico a primera vista. Sin embargo, los proxies cumplen una finalidad importante al controlar el acceso a otros objetos, lo que es especialmente útil en los casos en los que haya que acceder a ellos de una forma estilizada o complicada. Por ejemplo, si un objeto está en una máquina remota, entonces, para lograr acceder a él, es necesario utilizar varios recursos de red. En este caso, es más fácil crear un proxy local que comprenda a la red y que realice las operaciones necesarias, devolviendo luego el resultado. Esto simplifica al cliente, localizando el código específico de red en otro lugar. Como ejemplo distinto, un objeto puede requerir un bloqueo o traba en caso de que múltiples clientes puedan acceder a él. Esta traba representa el derecho a leer o actualizar un objeto; sin una traba, las actualizaciones concurrentes pueden dejar al objeto en un estado irregular, o una lectura en medio de una secuencia de actualizaciones podría causar la observación de un estado irregular . Un proxy puede responsabilizarse del bloqueo de un objeto antes de una operación o secuencia de operaciones y puede desbloquearlo después. Esto es menos propenso a error que requerir que los clientes implementen correctamente el protocolo de bloqueo. Otra variedad de proxy es el de seguridad. Éste podría operar correctamente si el invocador tuviese las credenciales apropiadas (como un certificado Kerberos válido), pero lanzaría un error si un usuario no autorizado intentase realizar operaciones. Un último ejemplo de proxy es el virtual. Si crear un objeto es costoso (debido al cálculo de la latencia en red), entonces, éste se puede representar mejor mediante un proxy. Este proxy podría empezar inmediatamente a crear el objeto, como una tarea de fondo, con la esperanza de que éste esté preparado para el momento en que se invocase la primera operación, o podría retardar la creación de un objeto hasta que se invocase la operación. En el primer caso, el resto del sistema podría continuar sin tener que esperar; en el último caso, la tarea de creación del objeto no tendría nunca que realizarse si éste nunca fuese utilizado. En los dos casos, las operaciones se retrasan hasta que el objeto esté preparado. Un ejemplo de un proxy virtual o para un objeto no existente, es la funcionalidad de autocarga de Emacs. Por ejemplo, yo tengo un archivo util-mde.el que define varias funciones útliles. Sin embargo, no quiero ralentizar Emacs por tener que cargar esto, cada vez que lo inicio. En vez de esto, mi archivo .emacs contiene el código que se muestra a continuación: (autoload ´looking-back-at “util-mde”) (autoload ´in-buffer “util-mde”) (autoload ´in-window “util-mde”) La forma (autoload ´función “archivo”)es básicamente equivalente a (en sintaxis Scheme; Lisp de Emacs utiliza defun) (define function() (load “file”) ;; redefine una función (function) ;; llama a la nueva versión ) Emacs autocarga la mayor parte de su propia funcionalidad, desde lecturas de correo electrónico y noticias hasta el modo edición de Java. Quienes se quejan porque el arranque de Emacs es demasiado lento, lo hacen porque a menudo se colocan formas de carga indiscriminada en sus archivos .emacs; esto es lo mismo que utilizar una implementación ineficaz y quejarse luego de que el compilador es pobre porque el programa resultante se ejecuta muy lentamente. La funcionalidad de un proxy resulta especialmente útil cuando los clientes no tienen conocimiento de si el objeto que están manipulando posee propiedades especiales (como estar situado en una máquina remota que requiere bloqueo o seguridad, o que no se le cargue). Es mejor aislar al cliente de tales asuntos y colocarlos en un envoltorio proxy.

25

Page 149: Curso Practico en Java de Ingenieria Del Software Mit

4.1.4 Herencia versus delegación La especialización o herencia de clases y la delegación, son dos estrategias para la implementación de envoltorios. La utilización de subclases ya le es familiar; la delegación almacena objetos en un campo, pasando mientras mensajes al objeto. La especialización de clases facilita automáticamente a los clientes acceso a todos los métodos de la superclase. La delegación fuerza la creación de varios métodos pequeños como void area() {return r.area();} Por otro lado, con el fin de prevenir el acceso a ciertos métodos del padre la subclase debe sobreescribirlos para lanzar un error; sería más inteligente no tenerlos en la interfaz, hecho que la delegación permite fácilmente. Otra ventaja potencial de la especialización de clases es que ésta se construye dentro del lenguaje; es probable que resulte bastante fácil de comprender y que su implementación sea bastante eficaz. La delegación es generalmente la técnica preferida para los envoltorios (y para muchos patrones de diseño). Los envoltorios se pueden añadir o extraer de forma dinámica. Por ejemplo, a una ventana se le marca el borde cuando está activa y se le desmarca el mismo, en caso contrario. Otra ventaja es que los objetos de clases concretas arbitrarias pueden ser encapsulados. La creación de una subclase especifica el tipo exacto de objeto que está siendo encapsulado. Por el contrario, el envoltorio puede encapsular un objeto de cualquier subclase del tipo declarado del objeto contenido. Otro beneficio de la delegación (que guarda relación con lo anterior), es la habilidad de utilizar múltiples adaptadores. (Por ejemplo, piense en cómo crearía una ventana con doble borde).

25

Page 150: Curso Practico en Java de Ingenieria Del Software Mit

Las implementaciones de las tres variedades de envoltorios poseen la misma estructura basta; sin mirar al cuerpo del método para ver qué trabajo se está realizando, no está nada claro si un envoltorio es un decorador o un proxy. (Un adaptador posee una interfaz distinta a la de la clase con la cual se comunica). Algunos envoltorios pueden incluso poseer aspectos de más de una variedad, aunque en este caso, quedaría más claro si en la documentación se especificase, por ejemplo, “Este es tanto un adaptador como un decorador”. 4.2 Compuesto El patrón de diseño compuesto permite que un cliente manipule tanto una unidad atómica como una colección de unidades exactamente de la misma forma. No es necesario que el cliente cree un código especial en el caso en el que se le proporcione un objeto de alto nivel con una estructura, a diferencia de si se le facilitara un objeto básico; en ambos casos funcionan las dos operaciones. El patrón compuesto es bueno para objetos con relaciones del todo por la parte y el cliente no debería preocuparse de si su argumento es atómico o está compuesto de partes. Por ejemplo, una bicicleta se puede descomponer en las siguientes partes: Bicicleta Rueda horquilla eje radio tuercas del radio de las ruedas cámara tubo llanta armazón manillar ...

25

Page 151: Curso Practico en Java de Ingenieria Del Software Mit

Dado un componente de la bicicleta podría querer determinar su peso o su coste sin considerar que pueda ser descompuesta en subcomponentes. Un cliente que recibe un componente de la bicicleta no debería tratarlo de forma distinta si es una rueda, en vez de un reflector o un sillín. La solución a este problema es hacer que todos los componentes de la bicicleta satisfagan una misma interfaz: class BicycleComponent{ int weight(); float cost(); } La implementación de Wheel.weight podría por sí misma llamar a weight en sus subpartes, pero esto no es importante para el cliente (y el cliente no debería preocuparse por esto). Una alternativa para la utilización de una interfaz común, es tener una superclase común; de cualquier modo, todos los componentes de la bicicleta en todos los niveles, proporcionan los mismos métodos y pueden utilizarse de forma intercambiable. Como otro ejemplo, los elementos de una biblioteca de préstamo, deben organizarse en niveles, como se indica a continuación: Biblioteca Sección (para un determinado género) Estante Volumen Página Columna Palabra Letra Si todo esto satisface la interfaz Interface Text{ String getText(); } entonces un cliente puede (decir) contar el número de palabras o ejecutar otras operaciones en una parte mayor o menor de los elementos de la biblioteca, como desee. El libro de texto presenta otro ejemplo, la sintaxis de programas informáticos. Observe que existen dos estructuras arbóreas no relacionadas por completo ilustradas en las figuras 15.12 y 15.13 de la página 392. Una es un árbol de sintaxis abstracta que subdivide la sintaxis de un enunciado en concreto de la lengua como un bloque particular de código. La otra es la jerarquía de clases, que expresa subtipos y herencia. (En Java, cuando hay herencia, se utilizan siempre los subtipos, ya que toda subclase es un subtipo). La última organización permite que Node pueda tener métodos como typeCheck o prettyPrint, sin reparar en sobre qué Node en concreto se está operando. Los métodos, clases y paquetes, podrían también ser Nodes en esta representación.

25

Page 152: Curso Practico en Java de Ingenieria Del Software Mit

Subtipado

Clase 15 del curso 6.170 15 de octubre de 2001

Sumario 1 Subtipos 2 Ejemplo: bicicletas 3 Ejemplo: cuadrado y rectángulo 4 Principio de sustitución 5 Subclases y subtipos Java 6 Interfaces Java Lecturas necesarias: capítulo 7 del libro Program Development in Java de Bárbara Liskov. Consulte su libro de texto de Java para obtener detalles sobre este lenguaje como, por ejemplo, tipos abstractos (no todos los métodos se implementan y ningún objeto se puede instanciar) y detalles sobre interfaces y modificadores de acceso (public, private, protected, default). Estos temas no se tratarán en esta clase. 1 Subtipos Decimos que A es B si todo objeto A es también un objeto B. Por ejemplo, todo automóvil es un vehículo y toda bicicleta es un vehículo, incluso unos zancos son un vehículo: todo vehículo es un medio de transporte, así como todo animal de carga. Representamos esta relación de subconjuntos en un diagrama de dependencia modular:

Esta relación de subconjunto es condición necesaria, pero no suficiente, para una relación de subtipificación. El tipo A es un subtipo del tipo B cuando la especificación de A implica la especificación de B. Esto es, cualquier objeto (o clase) que satisfaga la especificación de A también satisfará la especificación de B, ya que la especificación de B es más débil. Otra manera de explicar esto es que en cualquier lugar del código, si se espera un objeto B, es admisible un objeto A. Se garantiza que el código escrito para funcionar con los objetos B (y para depender de sus propiedades) continua funcionando si se suministran objetos A en su lugar; además, el comportamiento será el mismo, si se consideran sólo los aspectos del comportamiento de A que también están incluidos en el comportamiento de B. (Es posible que A introduzca nuevos comportamientos que B no tenga, pero esto sólo puede

Page 153: Curso Practico en Java de Ingenieria Del Software Mit

modificar los comportamientos existentes de B en ciertas maneras; que veremos enseguida). 2 Ejemplo: bicicletas Suponga que tenemos una clase para representar bicicletas. He aquí una implementación parcial de esa clase:

class Bicycle{ private int framesize; private int chainringGears; private int freewheelGears; ...

// devuelve el número de marchas de la bicicleta public int gears() { return chainringGears * freewheelGears; } // devuelve el precio de la bicicleta public float cost() { ... } // devuelve el impuesto de venta que incide sobre la bicicleta public float salesTax() { return cost() * .0825; } // ejecución: transporta al ciclista del trabajo a casa public void goHome() { ... } ... }

Una nueva clase que representa bicicletas con luces delanteras para poderse adaptar a la falta de luz.

class LightedBicycle{

private int framesize; private int chainringGears; private int freewheelGears; private BatteryType battery; ... // devuelve el número de marchas de la bicicleta public int gears() { return chainringGears * freewheelGears; } // devuelve el precio de la bicicleta float cost() { ... } // devuelve el impuesto de venta que incide sobre la bicicleta public float salesTax() { return cost() * .0825; } // ejecución: transporta al ciclista del trabajo a casa public void goHome() { ... } // ejecución: sustituye la pila existente por el argumento b public void changeBattery(BatteryType b); ... }

Page 154: Curso Practico en Java de Ingenieria Del Software Mit

Copiar todo el código resulta trabajoso y aumenta la posibilidad de incurrir en errores. (El error puede provenir de un fallo en la copia o en la realización de una modificación requerida). Además, si se encuentra un error en una versión, es fácil olvidarse de extender el arreglo a todas las versiones del código. Por último, es muy difícil comprender la distinción de las dos clases observando únicamente las diferencias en un cúmulo de similitudes. Java y otros lenguajes de programación utilizan el concepto de subclase para superar esas dificultades. Este concepto permite reutilizar las implementaciones y sobrescribir los métodos. A continuación presentamos una implementación mejor de la clase LightedBicycle:

class LightedBicycle extends Bicycle{ private BatteryType battery; ... // devuelve el precio de la bicicleta float cost() { return super.cost() + battery.cost(); } // ejecución: transporta al ciclista del trabajo a casa public void goHome() { ... } // ejecución: sustituye la pila existente con el argumento b public void changeBattery(BatteryType b); ... }

LightedBicycle no necesita implementar métodos y campos que aparecen en su superclase Bicycle; las versiones de Bicycle son automáticamente utilizadas por Java cuando no son sobrescritas en la subclase. Considere la siguiente implementación del método goHome (junto con especificaciones más completas). Si éstos son los únicos cambios, ¿son las clases LightedBicycle y RacingBicycle subtipos de Bicycle? (De momento trataremos el concepto de subtipos; más tarde volveremos a las diferencias entre las subclases Java, los subtipos Java, y los verdaderos subtipos). class Bicycle{

... // requiere: velocidad_del_viento < 20mph && luz_del_dia // ejecución: transporta al ciclista del trabajo a casa public void goHome() { ... } }

class LightedBicycle{ ... // requiere: velocidad_del_viento < 20mph

Page 155: Curso Practico en Java de Ingenieria Del Software Mit

// ejecución: transporta al ciclista del trabajo a casa void goHome() { ... } }

class RacingBicycle{ ... // requiere: velocidad_del_viento < 20mph && luz_del_dia // ejecución: transporta al ciclista del trabajo a casa // en un período de tiempo < 10 minutos // && hace al ciclista sudar void goHome() { ... } }

Para responder a esa pregunta, recuerde la definición de subtipificación: ¿puede un objeto del subtipo ser sustituido en cualquier lugar donde el código espera un objeto del supertipo? Si es así, la relación de subtipificación es válida. En este caso, tanto LightedBicycle como RacingBicycle son subtipos de Bicycle. En el primer caso, las condiciones son relajadas; en el segundo caso, la ejecución se refuerza de una manera que aún satisface la ejecución de la superclase. El método cost de LightedBicycle muestra otra capacidad de especialización de clases en Java. Los métodos pueden ser sobrescritos para facilitar una nueva implementación en una subclase. Esto permite una mayor reutilización del código; en particular, LightedBicycle puede reutilizar el método salesTax de Bicycle. Cuando se llama a salesTax en una LightedBicycle, la versión de Bicycle es la que se utiliza. Entonces, la llamada de cost dentro de salesTax llama a la versión basada en el tipo de tiempo de ejecución del objeto (LightedBicycle), con lo que se utiliza la versión LightedBicycle. Independientemente del tipo declarado de un objeto, la implementación de un método con muchas implementaciones (de la misma firma) siempre se selecciona basándose en el tipo de tiempo de ejecución. De hecho, un cliente externo no tiene manera de llamar a la versión de un método, especificado por el tipo declarado o cualquier otro tipo, que no sea el tipo de tiempo de ejecución. Ésta es una propiedad atractiva y muy importante de Java (y de otros lenguajes orientados a objetos). Suponga que la subclase mantiene algunos campos adicionales que se mantienen sincronizados con los campos de la superclase. Si los métodos de la superclase pudieran llamarse directamente, posiblemente modificando campos de la superclase sin que los campos de la subclase sean alterados también, entonces se violaría el invariante de representación de la subclase. De cualquier modo, una subclase puede llamar métodos de sus padres mediante la utilización de super. A veces es útil cuando el método de la subclase necesita hacer un poco más de trabajo; recuerde la implementación de LightedBicycle para cost:

class LightedBicycle extends Bicycle{ // devuelve el precio de la bicicleta

Page 156: Curso Practico en Java de Ingenieria Del Software Mit

float cost() { return super.cost() + battery.cost(); }

} Suponga que la clase Rider modela las personas que montan en bicicleta. En ausencia de especializaciones de clase y de subtipos, el diagrama de dependencia modular tendría el siguiente aspecto:

El código para Rider también tendría que probar que tipo de objeto se ha pasado, lo que resultaría feo, ampuloso y propenso a errores. Con la subtipificación, las dependencias de MDD se parecerían a esto:

Las diversas dependencias se han reducido a una. Cuando se añaden las fechas de subtipo, el diagrama resulta apenas un poco más complicado:

Page 157: Curso Practico en Java de Ingenieria Del Software Mit

Aunque existan varias flechas, este diagrama es más sencillo que el original: las restricciones de dependencia complican el diseño y la implementación más que otros tipos de restricciones. 3 Ejemplo: cuadrado y rectángulo Desde la escuela primaria sabemos que todo cuadrado es un rectángulo. Suponga que queremos hacer del cuadrado Square un subtipo de Rectangle que incluya un método setSize:

class Rectangle{ ... // ejecución: define la anchura width y la altura height como los valores // especificados (esto es, this.width’ = w && this.height’ = h) void setSize(int w, int h); } class Square extends Rectangle{ ... }

¿Cuál de los siguientes métodos es adecuado para Square?

// requiere: w = h void setSize(int w, int h); void setSize(int edgeLenght); // arroja la excepción BadSizeException si w != h void setSize(int w, int h) throws BadSizeException;

El primero no resulta acertado porque el método de subclase exige más que el método de superclase. Así, los objetos de subclase no se pueden sustituir por objetos de superclase, ya que puede existir alguna parte del código que llame al método setSize con argumentos diferentes.

Page 158: Curso Practico en Java de Ingenieria Del Software Mit

El segundo no está en lo cierto (completamente), ya que la subclase aún debe especificar un comportamiento para setSize(int, int); ésta es una definición de un método diferente (cuyo nombre es el mismo pero cuya firma es diferente). El tercero no es correcto porque arroja una excepción que la superclase no menciona. Así, de nuevo, posee un comportamiento diferente y de esta manera Square no puede ser sustituido por Rectangle. (Si la excepción BadSizeException es una excepción no verificada, entonces Java permitirá la compilación del tercer método; pero, de nuevo, también permitirá la compilación del primer método. La noción de Java de subtipo es más débil que la propia noción de subtipo del curso 6.170. Sin ninguna arrogancia, llamaremos a estos últimos “subtipos verdaderos” para distinguirlos de los subtipos de Java). No hay forma de salir de este dilema sin modificar el supertipo. Algunas veces los subtipos no están de acuerdo con nuestra intuición. O bien nuestra intuición sobre lo que es un buen subtipo es errónea. Una solución plausible sería modificar Rectangle.setSize para especificar que él arroja la excepción; esta claro que, en la práctica, solamente Square.size lo haría. Otra solución sería eliminar setSize y en su lugar tener el método void scale(double scaleFactor); que disminuye o aumenta una figura. Otras soluciones también son posibles. 4 Principio de sustitución El principio de sustitución es la base teórica de los subtipos: ofrece una definición precisa de cuándo dos tipos son subtipos. Informalmente, afirma que los subtipos deben ser sustituibles por supertipos. Esto garantiza que el comportamiento del sistema no se verá afectado cuando el código dependa de (cualquier aspecto de) un supertipo, pero habiéndose sustituido un objeto de un subtipo. (El compilador de Java también requiere que las cláusulas extends o implements nombren al padre para que los subtipos se utilicen en lugar de los supertipos). Los métodos de un subtipo deben mantener ciertas relaciones con los métodos del supertipo, y el subtipo debe garantizar que las propiedades del supertipo (como los invariantes de representación o las restricciones de especificación) no sean violadas por el subtipo. Métodos. Existen dos propiedades necesarias:

1. El subtipo debe tener un método correspondiente para cada método del supertipo. (Esta permitido que el subtipo introduzca nuevos métodos adicionales que no aparezcan en el supertipo). 2. Cada método del subtipo que corresponde a un método del supertipo:

• requiere menos (tiene una condición previa más débil)

Page 159: Curso Practico en Java de Ingenieria Del Software Mit

- existen menos cláusulas “requires” y cada una de ellas es menos rigurosa que la del método del supertipo. - los tipos de argumentos pueden ser uno de los supertipos del supertipo. Esto se llama contravarianza, y puede dar la impresión de ser un paso hacia atrás, ya que los argumentos del método subtipo son supertipos de los argumentos de los métodos del supertipo. Sin embargo, esto tiene sentido, porque así se garantiza que cualquier argumento pasado al método del supertipo es un argumento válido para el método del subtipo.

• garantiza más (tiene una condición posterior más fuerte) - no existen más excepciones - existen menos variables modificadas - en la descripción del resultado o en el estado resultante, existen más cláusulas, y éstas describen propiedades más fuertes. - el tipo de resultado debe ser uno de los subtipos del supertipo. Esto se llama covarianza: el tipo de retorno del método del subtipo es un subtipo del tipo de retorno del método del supertipo.

(Todas las descripciones anteriores deberían permitir la uniformidad; por ejemplo, “requerir menos” debería ser “no requerir más”, y “menos rigurosa” debería ser “no más rigurosa”. Se han expresado de esta forma para facilitar su lectura). El método de subtipo no debe comprometerse a ofrecer mayores resultados o resultados diferentes; sólo debe comprometerse a hacer lo que ha hecho el método del supertipo, garantizando también las propiedades adicionales. Por ejemplo, si un método de un supertipo devuelve un número mayor que su argumento, un método de subtipo devolvería un número primo mayor que su argumento. Como ejemplo de las restricciones de tipo, si A es un subtipo de B, entonces la siguiente redefinición (que es lo mismo que sobrescribir) sería válida:

Bicycle B.f(Bicycle arg); RacingBicycle A.f(Vehicle arg);

El método B toma una bicicleta Bicycle como su argumento, pero A.f puede aceptar cualquier vehículo (lo que incluye todas las bicicletas). El método B.f devuelve una bicicleta Bicycle como resultado, pero A.f devuelve una bicicleta de carreras RacingBicycle (que es una bicicleta propiamente dicha).

Propiedades Toda propiedad garantizada por un supertipo, como las restricciones sobre los

valores que aparezcan en los campos de especificación, debe estar también garantizada por el subtipo. (Está permitido que el subtipo refuerce esas restricciones).

Como un ejemplo sencillo del libro de texto, considere FatSet, que siempre está no vacío.

class FatSet{

Page 160: Curso Practico en Java de Ingenieria Del Software Mit

// restricciones de especificación: el objeto this debe // contener siempre por lo menos un elemento ... // ejecución: si el objeto this contiene x y this.size > 1, // elimina x de this void remove(int x); }

El tipo SuperFatSet con un método adicional

// ejecución: elimina x del objeto this void reallyRemove(int x)

no es un subtipo de FatSet. Aunque no hay ningún problema con los métodos de FatSet –reallyRemove es un método nuevo, por lo que las reglas sobre métodos correspondientes no se aplican: se trata de un método que viola la restricción. Si el objeto del subtipo se considera meramente como un objeto del supertipo (esto es, sólo se consultan los métodos y campos del supertipo), entonces el resultado debería ser el mismo que si se hubiese gestionado en su lugar un objeto del supertipo. En la sección 7.9, el libro de texto describe el principio de sustitución como la colocación de restricciones en

• firmas: se trata esencialmente de las reglas de contravarianza y de covarianza explicadas arriba. (La firma de un procedimiento se compone de su nombre, tipos de argumentos, tipos de devolución y excepciones).

• métodos: se trata de restricciones del comportamiento, o de todos los aspectos de una especificación que no se pueden explicar en una firma.

• propiedades: como arriba. 5 Subclases y subtipos Java Los tipos de Java son clases, interfaces o primitivas. Java posee su propia noción de subtipo (que comprende sólo las clases y las interfaces). Se trata de una noción más débil que la de subtipos verdaderos anteriormente descrita: los subtipos Java no satisfacen necesariamente el principio de substitución. Además, es posible que una definición de subtipo que satisfaga el principio de sustitución no se permita en Java, por lo que no compilará. Para que un tipo sea un subtipo de Java de otro tipo, la relación debe declararse (mediante la sintaxis Java extends o implements), y los métodos deben satisfacer dos propiedades similares, aunque más débiles, a las de los subtipos verdaderos:

1. El subtipo debe tener un método correspondiente para cada método del supertipo. (Está permitido que el subtipo introduzca nuevos métodos adicionales que no aparezcan en el supertipo).

2. Para cada método del subtipo que corresponda a un método del supertipo: • los argumentos deben tener los mismos tipos

Page 161: Curso Practico en Java de Ingenieria Del Software Mit

• el resultado debe tener el mismo tipo • no deben existir más declaraciones de excepciones.

Java no posee ninguna noción de especificación de conducta, por lo tanto no realiza verificaciones y no puede dar ninguna garantía en cuanto al comportamiento. La exigencia de uniformidad de tipos para los argumentos y los resultados es más fuerte que lo estrictamente necesario para garantizar la protección del tipo. Esto prohíbe algunas partes de código que nos gustaría escribir; lo que, sin embargo, simplifica la sintaxis y la semántica del lenguaje Java. La especialización de clases posee varias ventajas, todas ellas provenientes de la reutilización:

• las implementaciones de subclases no precisan repetir los campos y métodos no alterados, pero pueden utilizar los de la superclase

• los clientes (aquellos que ejecutan las llamadas) no precisan modificar el código cuando se añaden nuevos subtipos, pero pueden reutilizar el código existente (la parte que no menciona los subtipos, sólo el supertipo)

• el diseño resultante posee una modularidad mejorada y una complejidad reducida, porque los diseñadores, los programadores y los usuarios solamente tienen que entender el supertipo, no cada subtipo: esto se llama reutilización de la especificación.

Un mecanismo clave que permite obtener estas ventajas es la redefinición, que especializa el comportamiento para algunos métodos. En ausencia de redefinición, cualquier alteración del comportamiento (aunque se trate de una alteración compatible) podría forzar una reimplementación completa. La redefinición permite que parte de una implementación se altere sin modificar otras partes que dependen de ella, haciendo posible una mayor reutilización del código y de la especificación por parte de la implementación y del cliente. Una posible desventaja de la especialización de clases es que suponen un riesgo de reutilización inapropiada. Es posible que las subclases y las superclases dependan unas de otras (explícitamente por el nombre del tipo o implícitamente por el conocimiento de la implementación), especialmente porque las subclases tienen acceso a las partes protegidas de la implementación de la superclase. Estas dependencias extras complican el MDD, el diseño y la implementación, haciendo que sea más difícil codificar, entender y modificar. 6 Interfaces Java Algunas veces el usuario desea tener garantía sobre el comportamiento sin tener que compartir el código. Por ejemplo, tal vez necesite ordenar los elementos de un contenedor específico o que éstos acepten una determinada operación sin facilitar una implementación por defecto (porque toda relación de orden posee una implementación diferente). Java ofrece interfaces que permiten resolver estas necesidades y garantizar que no se reutilizará el código. Otra de sus ventajas es que una clase puede implementar múltiples interfaces y una interfaz puede ampliar otras muchas. En oposición a esto, una clase sólo puede ampliar

Page 162: Curso Practico en Java de Ingenieria Del Software Mit

una clase. En la práctica, la implementación de múltiples interfaces y la extensión de una superclase única proporcionan la mayoría de los beneficios de la herencia arbitraria, pero con una semántica y una implementación más simples. Una desventaja de las interfaces es que no facilitan el modo de especificar la firma (o el comportamiento) de un constructor.

Page 163: Curso Practico en Java de Ingenieria Del Software Mit

Clase 16. Prácticas: colecciones de la API de Java No se puede ser un programador de Java competente sin entender las partes esenciales de la biblioteca Java. Los tipos básicos están todos en java.lang, y son parte del lenguaje propiamente dicho. El paquete java.util ofrece colecciones -conjuntos, listas y mapas- y es necesario conocerlo muy bien. El paquete java.io también es importante y no basta con tener de él un conocimiento básico, hace falta profundizar. En esta clase analizaremos el diseño del paquete java.util, que suele recibir el nombre de ‘API de colecciones’. Merece la pena estudiarlo no sólo porque las clases de colecciones resulten extremadamente útiles, sino también porque la API es un ejemplo óptimo de código bien diseñado. La API es bastante fácil de comprender y existe mucha documentación al respecto. Fue diseñada y escrita por Joshua Bloch, autor del libro Effective Java que hemos recomendado al inicio del curso. Al mismo tiempo, en la API aparecen casi todas las complejidades de la programación orientada a objetos, por lo que si la estudia con detenimiento obtendrá una amplia comprensión de asuntos de programación que, probablemente, no había tenido en cuenta en su propio código. De hecho, no sería exagerado decir que si llega a comprender enteramente tan sólo una de las clases, ArrayList por ejemplo, dominará todos los conceptos de Java. Hoy no tendremos tiempo de analizar todos los códigos pero sí que nos ocuparemos de muchos de ellos. Algunos, como la serialización y la sincronización, quedan fuera del alcance de este curso. 16.1 Jerarquía de tipos Vista de un modo general, la API presenta tres tipos de colecciones: conjuntos, listas y mapas. Un conjunto es una colección de elementos que no mantiene un orden en el recuento de los elementos: cada elemento o está en el conjunto o no lo está. Una lista es una secuencia de elementos y, por tanto, mantiene el orden y el recuento. Un mapa es una asociación entre llaves y valores: mantiene un conjunto de llaves y asigna cada llave a un único valor. La API organiza sus clases mediante una jerarquía de interfaces –las especificaciones de los diversos tipos– y una jerarquía separada de clases de implementación. El siguiente diagrama muestra algunas clases de interfaces seleccionadas para ilustrar la organización jerárquica. La interfaz Collection captura las propiedades comunes de listas y conjuntos, pero no de los mapas, aunque de todas formas utilizaremos el término informal “colecciones” para referirnos también a los mapas. SortedMap y SortedSet son interfaces utilizadas por los mapas y los conjuntos que facilitan operaciones adicionales para recuperar los elementos en un orden.

Page 164: Curso Practico en Java de Ingenieria Del Software Mit

Las implementaciones concretas de clases, como LinkedList, están construidas en la parte superior del esqueleto de las implementaciones (por ejemplo AbstractList, de la cual LinkedList desciende). Esta estructura paralela de interfaces es un idioma importante que merece la pena estudiar. Muchos programadores inexpertos están tentados a utilizar clases abstractas cuando les sería más conveniente utilizar interfaces; pero, por regla general, es mejor para usted elegir las interfaces en vez de las clases abstractas. No es fácil aplicar un retrofit a una clase existente para extender una clase abstracta (porque una clase puede tener como máximo una superclase), pero no suele resultar difícil hacer que la clase implemente una nueva interfaz. Bloch demuestra (en el capítulo 16 de su libro: ‘Prefer interfaces to abstract classes’) cómo combinar las ventajas de ambas, utilizando una implementación de clases organizada en forma de esqueleto de jerarquías, como hace aquí en la API de colecciones. De esta forma se obtienen las ventajas de las interfaces para lograr el desacoplamiento basado en especificaciones y las ventajas de las clases abstractas para fabricar código compartido entre implementaciones relacionadas.

Page 165: Curso Practico en Java de Ingenieria Del Software Mit

Cada interfaz de Java viene con una especificación informal en la documentación de API Java, lo que resulta bastante útil, ya que informa sobre el comportamiento de la interfaz al usuario de una clase que implementa ésta. Si se implementa una clase y se quiere que ésta satisfaga la especificación List, por ejemplo, se deberá garantizar que cumple también con la especificación informal, pues de lo contrario no se comportará con arreglo a lo previsto por los programadores. Estas especificaciones, al igual que ocurre con muchas otras , han quedado incompletas de forma intencionada. Las clases concretas también poseen especificaciones que completan los detalles de las especificaciones de la interfaz. La interfaz List, por ejemplo, no especifica si los elementos nulos pueden ser almacenados, pero las clases ArrayList y LinkedList informan explícitamente que los elementos nulos están permitidos. La clase HashMap admite tanto valores nulos como llaves nulas, al contrario que Hashtable, que no permite ni éstas ni aquellos. Cuando escriba código que utiliza clase de API de colecciones, deberá referirse a un objeto mediante la interfaz o la clase más genérica posible. Por ejemplo,

List p = new LinkedList (); es un estilo mejor que

LinkedList p = new LinkedList (); Si su código compila con la primera versión del ejemplo anterior, podrá migrar fácilmente a una lista diferente en una implementación posterior:

List p = new ArrayList (); ya que todo el código subsiguiente se basaba en el hecho de que p era del tipo List. Pero si utiliza la segunda versión del ejemplo anterior, seguramente descubrirá que puede hacer la alteración, porque algunas partes de su programa realizan operaciones sobre x que sólo la clase LinkedList ofrece: una operación que, de hecho, podría no ser necesaria. Esto está explicado más detalladamente en la sección 34 del libro de Bloch ('Refer to objects by their interfaces'). Veremos un ejemplo más complejo de este tipo de ocurrencia en el caso práctico Tagger en la próxima clase, donde parte del código requiere acceso a las llaves de HashMap. En lugar de pasar todo el mapa, sólo pasaremos una visión del tipo Set:

Set keys = map.keySet (); Ahora el código que utiliza keys ni siquiera sabe que este conjunto es un conjunto de llaves de un mapa. 16.2 Métodos opcionales La API de colecciones permite que una clase implemente una interfaz de colecciones sin implementar todos sus métodos. Por ejemplo, todos los métodos de tipo modificadores de la

Page 166: Curso Practico en Java de Ingenieria Del Software Mit

interfaz List están especificados como opcionales, (optional). Esto significa que usted puede implementar una clase que satisfaga la especificación de List, pero que arroja una excepción UnsupportedOperationException cada vez que desea llamar a un método de tipo modificador (mutator) como, por ejemplo, el método add. Esta debilidad intencional de la especificación de la interfaz List es problemática, porque significa que cuando usted está escribiendo un código que recibe una lista, no puede saber, en ausencia de información adicional sobre la lista, si será compatible con el método add. Pero sin esta noción de operaciones opcionales, tendría que declarar una interfaz separada denominada ImmutableList. Estas interfaces proliferarían. A veces nos interesan algunos métodos modificadores y otros no. Por ejemplo, el método keySet de la clase HashMap devuelve un conjunto (un objeto Set) que contiene las llaves del mapa. El conjunto es una visión: al eliminar una llave del conjunto, una llave y su valor asociado desaparecen del mapa. Por lo tanto, es posible utilizar el método remove, aunque no el método add, ya que no se puede añadir una llave a un mapa sin un valor asociado a ella. Por consiguiente, la utilización de operaciones opcionales es un buen cálculo de ingeniería. Implica menos comprobaciones en tiempo de compilación, aunque reduce el número de interfaces. 16.3 Polimorfismo Todos estos contenedores –conjuntos, listas y mapas– reciben elementos de tipo Object. Se les considera polimórficos, lo que significa 'muchas formas', porque permiten construir muchas clases diferentes de contenedores: listas de enteros, listas de URL, listas de listas, etc. Este tipo de polimorfismo se denomina polimorfismo de subtipo, ya que se basa en la jerarquía de tipos. Una forma diferente de polimorfismo, denominada polimorfismo paramétrico, permite definir contenedores a través de parámetros que indican el tipo, de manera que un cliente pueda indicar qué tipo de elemento contendrá un contenedor específico: List[URL] bookmarks; // ilegal en Java Java no admite este tipo de polimorfismo, aunque han existido muchas propuestas para incorporarlo. El polimorfismo paramétrico tiene la gran ventaja de que el programador puede decir al compilador cuáles son los tipos de los elementos. Así, el compilador es capaz de interceptar errores en los que se inserta un elemento del tipo equivocado, o cuando un elemento que se extrae se trata como un tipo diferente. A través del polimorfismo de subtipo, usted deberá moldear explícitamente los elementos durante la recuperación, a través de la operación de cast. Considere el código:

List bookmarks = new LinkedList (); URL u = …; bookmarks.add (u);

Page 167: Curso Practico en Java de Ingenieria Del Software Mit

… URL x = bookmarks.get (0); // el compilador rechazará esta sentencia

La sentencia que añade u es correcta, pues el método add espera un objeto, y URL es una subclase de Object. La sentencia que recupera x, no obstante, es errónea; ya que el tipo devuelto por la sentencia del lado derecho del operador = devuelve un Object, y no se puede atribuir un Object a una variable del tipo URL, ya que no podría basarse en aquella variable como si fuera una URL. Por tanto, es precisa una operación de downcast, para lo que hay que escribir el siguiente código:

URL x = (URL) bookmarks.get (0); El efecto de la operación de downcast es realizar una verificación en tiempo de ejecución. Si tiene éxito, y el resultado de la llamada del método es del tipo URL, la ejecución continuará normalmente. En el caso de que falle, porque el tipo devuelto no es el correcto, se lanzará una excepción ClassCastException y no se realizará la atribución. Asegúrese de que entiende este concepto, y no se confunda (como suelen hacer los estudiantes), pensando que la operación de cast, de alguna forma, realiza una mutación del objeto devuelto por el método. Los objetos llevan su tipo en tiempo de ejecución, y si un objeto se creó con un constructor de la clase URL, siempre tendrá ese tipo y no hay razón para modificarlo y darle otro tipo. Las operaciones de downcast pueden ser incómodas y, ocasionalmente, vale la pena escribir una clase wrapper para automatizar el proceso. En un navegador, probablemente usted emplearía un tipo abstracto de datos para representar una lista de favoritos (compatibles con otras funciones además de las ofrecidas por el tipo URL). Haciéndolo así, realizaría la operación de cast dentro del código de tipo abstracto, y sus clientes verían códigos como el siguiente: URL getURL (int i); que no exigirían la operación de cast en sus contextos de invocación, limitando así el ámbito en el cual los errores de cast podrían suceder. El polimorfismo de subtipo ofrece cierta flexibilidad de la que carece el polimorfismo paramétrico. Puede crear contenedores heterogéneos que contengan diferentes tipos de elementos. También puede colocar contenedores dentro de sí mismos –intente averiguar cómo expresar esto como un tipo polimórfico– aunque no suele ser aconsejable hacerlo. De hecho, como mencionamos en nuestra clase anterior al respecto de la igualdad, la API de clases Java se degenerará si lo hace de esta forma. Definir el tipo de elemento que un contenedor posee es muchas veces la parte más importante de una invariante Rep de tipo abstracto. Debería acostumbrarse a escribir un comentario cada vez que declare un contenedor, utilizando para ello una declaración del tipo pseudoparamétrica: List bookmarks; // List [URL] o como una parte de la invariante Rep propiamente dicha:

Page 168: Curso Practico en Java de Ingenieria Del Software Mit

IR: bookmarks.elems in URL 16.4 Implementaciones sobre esqueletos de jerarquías Las implementaciones concretas de las colecciones se hallan construidas sobre esqueletos de jerarquías. Estas implementaciones utilizan un patrón de diseño denominado Template Method (consulte Gamma et al, páginas 325-330). Una clase variable no tiene instancias de sí misma, pero define métodos denominados templates (plantillas) que invocan otros métodos denominados hooks (ganchos) que son declarados como abstractos y no poseen código. En la subclase, los métodos hook están superpuestos, y los métodos template se heredan sin sufrir alteraciones. La clase AbstractList, por ejemplo, hace de iterator un método template que devuelve un iterador implementado con el método get como un hook. El método equals se implementa como otro template de la misma forma que iterator. Una subclase, como ArrayList, ofrece entonces una representación (un array de elementos, por ejemplo) y una implementación para el método get (por ejemplo, el método debe devolver el i-ésimo elemento del array), pudiendo heredar los métodos iterator y equals. Algunas clases concretas sustituyen las implementaciones abstractas. LinkedList, por ejemplo, sustituye la funcionalidad del iterador ya que, al utilizar la representación de las entradas como objetos de tipo Entry directamente, es posible escribir un rendimiento mejor que si se utiliza el método get, que es un hook, y realizar una búsqueda secuencial en cada operación. 16.5 Capacidad, distribución y garbage collector Una implementación que utiliza un array para su representación –como ArrayList y HashMap– debe definir un tamaño para el array cuando se distribuye. La elección de un tamaño adecuado puede ser importante con vistas al rendimiento. Si el tamaño del array es demasiado pequeño,se deberá sustituir éste por uno nuevo, siendo necesario cargar con los costes de asignar un nuevo array y de liberarse del antiguo. Si es demasiado grande, tendremos pérdida de espacio, lo que supondrá un problema, especialmente cuando existen muchas instancias del tipo de la colección que estamos utilizando. Tales implementaciones, por tanto, ofrecen constructores en los que el cliente puede definir la capacidad inicial, a partir de la cual se puede determinar el tamaño de la distribución. ArrayList, por ejemplo, tiene el constructor: public ArrayList(int initialCapacity)

Construye una lista con una capacidad inicial especificada. Parámetros:

initialCapacity – la capacidad inicial de la lista. Lanza:

IllegalArgumentException – si la capacidad inicial es un valor negativo.

Page 169: Curso Practico en Java de Ingenieria Del Software Mit

Existen también métodos que ajustan la distribución: trimToSize, que define la capacidad del contenedor de forma que sea lo suficientemente grande para los elementos actualmente almacenados, y ensureCapacity, que garantiza la capacidad hasta una determinada cuantía. Utilizar las facilidades para la gestión de la capacidad puede resultar problemático. Si no conoce exactamente la magnitud de las colecciones que necesitará la aplicación, le convendrá tratar de ejecutar una estimativa. Observe que este concepto de capacidad transforma un problema de comportamiento en uno de rendimiento: un cambio muy deseable. Los recursos de muchos programas antiguos son limitados y el programa falla cuando éstos se alcanzan. Con la propuesta de gestión de la capacidad, el programa se vuelve más lento. Es una buena idea diseñar un programa que funcione eficientemente la mayor parte del tiempo aunque ocasionalmente se produzcan problemas de rendimiento. Si estudia la implementación del método remove de ArrayList, verá este código: public Object remove(int index) { … elementData[-size] = null; // deje que el gc (garbage collector) haga su trabajo … ¿Qué ocurre? ¿El garbage collector no funciona automáticamente? Estamos ante un error común en programadores inexpertos. Si tiene un array en su representación con una variable de instancia distinta que contiene un índice para señalar qué elementos del array deben ser considerados como parte de la colección abstracta, resulta tentador pensar que basta con decrementar ese índice para eliminar los elementos. Realizar un análisis partiendo de la función de abstracción no servirá para eliminar la confusión: los elementos que se encuentran por encima del índice no son considerados parte de la colección abstracta, y sus valores son irrelevantes. No obstante, hay un problema. Si no garantiza la atribución del valor null para las posiciones no utilizadas, los elementos cuyas referencias están en esas posiciones no serán tratados por el garbage collector, aunque no existan otras referencias de estos elementos en cualquier otra parte del programa. El garbage collector no puede interpretar la función abstracta, por lo que no sabe que no es posible alcanzar esos elementos a través de la colección, aunque sí sea posible alcanzarlos a través de la representación. Si se olvida de atribuir null a estas posiciones, el rendimiento del programa puede verse gravemente afectado. 16.6 Copias, conversiones, wrappers, etc. Todas las clases de colecciones concretas ofrecen constructores que reciben colecciones como argumentos. Esto le permitirá copiar colecciones y convertir un tipo de colección en otro. Por ejemplo, la clase, LinkedList tiene:

public LinkedList(Collection c)

Page 170: Curso Practico en Java de Ingenieria Del Software Mit

Construye una lista con los elementos de la colección especificada, en el orden en que son devueltos por el iterador de la colección. Parámetros:

c – la colección cuyos elementos deben ser colocados en esta lista. que se puede utilizar para copiar:

List p = new LinkedList () … List pCopy = new LinkedList (p)

o para que se cree una lista encadenada a partir de otro tipo de colección:

Set s = new HashSet () … List p = new LinkedList (s)

Como no podemos declarar constructores en interfaces, la especificación List no establece que todas sus implementaciones deban tener tales constructores, a pesar de que los tienen. Existe una clase especial denominada java.util.Collections que contiene un grupo de métodos estáticos que realizan operaciones sobre las colecciones o que devuelven colecciones como resultado. Algunos de estos métodos son algoritmos genéricos (por ejemplo, para clasificación) y otros son wrappers. Por ejemplo, el método unmodifiableList recibe una lista y devuelve una lista con los mismos elementos, pero inmutable:

public static List unmodifiableList(List list) Devuelve una visión no modificable de la lista especificada. Este método permite a

los módulos ofrecer a los usuarios un acceso de sólo lectura a sus listas internas. Las operaciones de consulta sobre la lista realizan la consulta en la lista original. Las tentativas de modificar la lista devuelta, directamente o a través de un iterador, resultan en una excepción UnsupportedOperationException.

La lista retornada será serializable en el caso de que la lista original también lo sea. Parámetros:

list – la lista por la que se devuelve una visión no modificable. Devuelve:

Una visión no modificable de la lista especificada. La lista devuelta no es exactamente inmutable, ya que su valor puede cambiar a causa de las alteraciones de la lista subyacente (vea la sección 16.8 más abajo), pero no puede modificarse directamente. Existen métodos semejantes que reciben colecciones y devuelven visiones que se sincronizan con la lista original a través de métodos wrappers. 16.7 Colecciones ordenadas Una colección ordenada debe tener alguna forma de compararse con los elementos para determinar su orden. La API de colecciones ofrece dos propuestas. Puede utilizar la 'ordenación

Page 171: Curso Practico en Java de Ingenieria Del Software Mit

natural', que se determina con el método compareTo del tipo de los elementos almacenados, que deben implementar la interfaz java.lang.Comparable: public int compareTo(Object o) que devuelve un entero negativo, cero, o un entero positivo en el caso de que el o objeto (this) sea menor, igual, o mayor que el objeto dado o. Cuando se añade un elemento a una colección ordenada que está utilizando la ordenación natural, el elemento deberá ser una instancia de una clase que implemente la interfaz Comparable. El método add realiza la operación de downcast para el tipo Comparable sobre el elemento añadido de forma que sea posible compararlo con los elementos ya existentes en la colección, en el caso de que no sea posible el downcast, se arrojará una excepción de moldeo de clase. La otra propuesta consiste en utilizar una clasificación independiente de los elementos, a través de un elemento que implemente la interfaz java.util.Comparator, que tiene el método public int compare(Object o1, Object o2) semejante al método compareTo, pero que recibe como argumento los dos elementos que se van a comparar. Esta es una instancia del patrón Strategy, en la que un algoritmo se desacopla del código que lo utiliza (consulte Gamma, págs. 315-323). Se elegirá una propuesta u otra dependiendo del constructor que usted emplee para crear la colección de objetos. Si emplea el constructor que recibe un Comparator como argumento, éste será utilizado para determinar el orden, mientras que si emplea el constructor sin argumentos, se utilizará la ordenación natural. La actividad de comparación se ve expuesta a los mismos problemas que la de igualdad (de la que se habló en la clase 9). Una colección ordenada tiene una invariante Rep que determina que los elementos de la representación deben estar ordenados. Si el orden de dos elementos se puede alterar a través de una invocación a un método público, tendremos una exposición de representación. 16.8 Visiones Presentamos el concepto de visiones en la clase 9. Las visiones son un mecanismo complejo y muy útil, aunque peligroso. Violan muchas de nuestras concepciones sobre qué tipos de comportamientos pueden ocurrir en un programa orientado a objetos bien formado. Se pueden citar tres tipos de visiones, según cuál sea su propósito:

• Ampliación de la funcionalidad. Algunas visiones se utilizan para ampliar la funcionalidad de un objeto sin que sea necesario añadir nuevos métodos a su clase. Los iteradores caen dentro de esta categoría. Sería posible, por el contrario, colocar los métodos next y hasNext en la propia clase de la colección. Pero esto complicaría la API de la clase. Sería difícil también soportar múltiples iteraciones sobre la misma colección.

Page 172: Curso Practico en Java de Ingenieria Del Software Mit

Podríamos añadir un método reset a la clase que sería invocado para reiniciar una iteración, aunque esto sólo permitiría una iteración cada vez. Tal método podría conducir a errores en los que el programador se olvide de reiniciar la iteración.

• Desacoplamiento. Algunas visiones ofrecen un subconjunto de las funcionalidades de la

colección subyacente. El método keySet de la interfaz Map, por ejemplo, devuelve un conjunto que consiste en llaves del mapa. El método permite, por tanto, que la parte del código relacionada con las llaves (pero no la relacionada con los valores) se desacople del resto de la especificación de Map.

• Transformación coordinada. La visión ofrecida por el método subList de la interfaz List

da una especie de transformación coordinada. Las alteraciones en la visión producen alteraciones en la lista subyacente, pero permiten el acceso a la lista a través de un índice que es un offset pasado a través del parámetro del método subList.

Las visiones son peligrosas por dos motivos. Primero, las alteraciones se producen de modo subyacente: si se invoca remove a partir de un iterador, la colección subyacente se modificará; mientras que si se invoca remove en un mapa se alterará una determinada visión del conjunto de llaves (y viceversa). Esto es un fenómeno de aliasing abstracto en el cual una alteración introducida en un objeto hace que se modifique otro objeto de un tipo diferente. Los dos objetos ni siquiera tienen que estar en el mismo ámbito léxico. Observe que el significado de la cláusula 'modifies' utilizada en las especificaciones debe perfeccionarse: si se define 'modifies c' y c tiene una visión v, ¿quiere ello decir que también se podrá modificar v? En segundo lugar, la especificación de un método que devuelve una visión limita muchas veces los tipos de alteraciones que se permiten. Para tener la certeza de que el código funciona, usted tendrá que entender la especificación de ese método. Y como es lógico, estas especificaciones resultan a menudo confusas. La cláusula 'post-requires' del texto de Liskov es una forma de ampliar nuestro concepto de especificación para manipular algunas de las complicaciones. Algunas visiones sólo permiten que se altere la colección subyacente. Otras sólo permiten que se altere la visión, por ejemplo los iteradores. Algunas permiten alteraciones para ambas, la visión y la colección subyacente, pero determinan implicaciones complejas en función de las alteraciones. La API de colecciones, sin ir más lejos, determina que cuando una visión en forma de sublist se crea a partir de una lista, la lista subyacente no debe sufrir modificaciones estructurales o, como se explica en la documentación: Las modificaciones estructurales son las que alteran el tamaño de la lista, o la perturban de tal modo que las iteraciones en curso pueden presentar resultados incorrectos. No está muy claro lo que esto significa. Mi sugerencia sería que se evite cualquier modificación de la lista subyacente. La situación se complica más debido a la posibilidad de que existan varias visiones sobre la misma colección subyacente. Así, por ejemplo, usted puede tener múltiples iteradores sobre la misma lista. En tal caso, deberá tener también en cuenta las iteraciones entre visiones. Si

Page 173: Curso Practico en Java de Ingenieria Del Software Mit

modifica la lista a través de uno de sus iteradores, los otros iteradores serán invalidados y no deberán utilizarse posteriormente. Existen algunas estrategias prácticas que simplifican la complejidad de las visiones. Cuando utilice una visión, considere detenidamente los siguientes consejos:

• · Puede determinar el ámbito dentro del cual la visión es accesible. Por ejemplo, utilizando un bucle de tipo for en lugar de una sentencia while para realizar la iteración. De esta forma, estará limitando el ámbito del iterador para el ámbito del propio bucle. Esta práctica ayuda a garantizar que no se produzcan interacciones no previstas durante la iteración. Esto no siempre es posible; el programa Tagger, del que hablaremos más adelante en el curso, altera un iterador a partir de un local a muchas invocaciones de métodos de distancia y en una clase diferente, a partir del local de su creación.

• · Puede evitar la alteración de una visión o de un objeto subyacente a través del recurso de wrapping mediante métodos de la clase Collection. Por ejemplo, si crea una visión a partir del método keySet de un mapa y no pretende modificarla, puede hacer el conjunto inmutable:

Set s = map.keySey (); Set safe_s = Collections.unmodifiableSet (s);

Page 174: Curso Practico en Java de Ingenieria Del Software Mit

Clase 17. Prácticas: JUnit

El marco Junit, que usted ha utilizado en este curso para probar su propio código, merece ser objeto de estudio por su importancia. Fue desarrollado por Kent Beck y Erich Gamma. Beck es muy conocido por su trabajo con patrones y con la programación XP (Extreme Programming); mientras que Gamma es coautor de un conocido libro sobre patrones de diseño. Al ser JUnit código abierto, usted podrá estudiar el código fuente por su cuenta. Hay también un buen artículo aclaratorio en la distribución de JUnit, titulado ‘A Cook’s Tour’, que explica el diseño de JUnit desde la perspectiva de los patrones de diseño y del cual se ha extraido la mayor parte del material para esta clase. JUnit ha tenido un gran éxito. Martin Fowler, un autor lúcido y con un marcado sentido práctico, defensor de los patrones de diseño y de XP (y también autor de un excelente libro sobre modelos de objeto llamado Analysis Patterns), dice sobre JUnit:

Jamás, en el campo del desarrollo de software, tantas personas han debido tanto a tan pocas líneas de código.

Sin duda, la popularidad de JUnit se debe en gran parte a su facilidad de uso. Cabría pensar que, ya que se trata de un marco que no hace gran cosa –simplemente ejecuta un grupo de pruebas e informa de sus resultados– JUnit debería ser muy sencillo. Pero, en realidad, el código es bastante complicado. La razón principal de su complejidad radica en que ha sido ideado como un marco, para ampliarse de diversas formas no previstas, por lo que está lleno de patrones complejos y generalizaciones diseñadas con el fin de permitir a los implementadores anular algunas partes del marco y preservar otras. Otra influencia que añade complejidad al asunto es el deseo de que las pruebas resulten fáciles de escribir. Para ello se utilizó una especie de truco técnico (hack), basado en la técnica de reflexión, que convierte métodos de una clase en instancias individuales del tipo Test. También se utilizó otra técnica que, en principio, parece excesiva. La clase abstracta TestCase hereda de la clase Assert, que contiene unos cuantos métodos estáticos de certificación, simplemente para que la invocación del método assert quede escrita sólo como un comando assert (…), en lugar de Assert.assert (…). Está claro que de ninguna manera TestCase es un subtipo de Assert, por lo que esta estructuración no tiene en realidad mucho sentido, aunque en el fondo permite escribir de manera más sucinta el código perteneciente a TestCase. Y, como todos los casos de prueba que el usuario escribe son métodos de la clase TestCase, la técnica resulta bastante valiosa. El uso de patrones es una actividad que requiere mucha pericia y motivación. Los patrones clave que vamos a analizar son: Template Method, el patrón clave de la programación del marco; Command, Composite, y Observer. Todos ellos se explican con detenimiento en Gamma et al, y, con la excepción de Command, ya se han visto en el presente curso. Mi opinión personal es que el propio JUnit, la joya de la programación XP, desdice el

Page 175: Curso Practico en Java de Ingenieria Del Software Mit

mensaje fundamental del movimiento XP: el código por sí mismo es suficiente para su comprensión. JUnit es un ejemplo perfecto de programa que sería prácticamente incomprensible sin que algunas representaciones globales del diseño se expliquen de manera que se pueda entender el modo en que encajan. No resulta de gran ayuda el hecho de que el código sea escaso en comentarios y que, cuando éstos existen, tiendan a ser bastante oscuros. En este sentido, el artículo ‘Cook’s Tour’ es fundamental: sin él, llevaría horas comprender las sutilezas de lo que sucede en el código. También sería de gran utilidad tener más representaciones de diseño. El artículo presenta una visión simplificada, y yo mismo tuve que construir un modelo de objeto que explica, por ejemplo, como funciona el esquema de listeners. Si es usted uno de esos estudiantes que no cree en las representaciones de diseño y que piensa que el código es lo más importante, le recomiendo dejar de leer en este instante y sentarse cómodamente en un sillón dispuesto a pasar toda la tarde con el código fuente de JUnit. Quién sabe, tal vez cambie de idea…

Puede descargar el código fuente y documentación sobre JUnit de:

http://www.junit.org/.

Hay un almacén de código libre en la dirección:

http://sourceforge.net/projects/junit/

donde pueden verse (y añadirse) informes sobre fallos.

17.1 Resumen general JUnit tiene diversos paquetes: framework como paquete básico de marcos, runner para algunas clases abstractas y para la ejecución de pruebas, textui y swingui para interfaces de usuario y extensions para algunas contribuciones prácticas al marco. Vamos a ocuparnos principalmente del paquete de framework. Los diagramas siguientes muestran el modelo de objeto y el diagrama de dependencia modular. Es aconsejable que siga los diagramas a medida que lee el contenido de esta clase. Ambos diagramas incluyen sólo los módulos del marco, aunque he incluido TestRunner en el modelo objeto para demostrar cómo se conectan los listeners; sus relaciones, suite y result, son variables locales de su método doRun.

Page 176: Curso Practico en Java de Ingenieria Del Software Mit

Observe que el diagrama de dependencia modular está conectado casi por completo. No es sorprendente si tenemos en cuenta que se trata de un marco, ya que los módulos no están pensados para trabajar de forma independiente. 17.2 El patrón Command El patrón Command encapsula una función como un objeto. De esta forma se implementa un cierre –¿recuerdan el curso 6.001?– en un lenguaje orientado a objetos. La clase command suele poseer un método único denominado do, run o perform. Se crea una instancia de la subclase que anula este método, encapsulando también, normalmente, algún estado de la clase (en el lenguaje del curso 6.001 llamábamos a esto el entorno del cierre). El comando entonces puede pasar por un objeto y ‘ejecutarse’ invocando el método. En JUnit, los casos de prueba se representan a través de objetos de comando que implementan la interfaz Test:

public interface Test { public void run();

}

Page 177: Curso Practico en Java de Ingenieria Del Software Mit

Casos de prueba verdaderos son instancias de una subclase de una clase concreta TestCase:

public abstract class TestCase implements Test { private String fName; public TestCase(String name) {

fName= name; }

public void run() {

}

}

En realidad, el código actual no es así, pero empezar a partir de esta versión simplificada nos permitirá explicar los patrones básicos más fácilmente. Observe que el constructor asocia un nombre con el caso de prueba, lo que resultará útil al informar de los resultados. De hecho, todas las clases que implementan Test tienen esta propiedad, lo que quizás hubiera sido buena idea añadir el método

public String getName () a la interfaz Test. Observe también que los autores de JUnit utilizan la convención de que los identificadores que comienzan por una f minúscula son campos de una clase (esto es, variables de instancia). Veremos un ejemplo más elaborado del patrón command más adelante, cuando estudiemos el programa Tagger. 17.3 Método Template Puede determinarse que run sea un método abstracto, que exige, por tanto, que todas las subclases lo superpongan. Pero la mayoría de los casos de prueba tienen tres fases: determinación del contexto, ejecución de la prueba y desmontaje del contexto. Podemos automatizar la utilización de esta estructura haciendo que run sea un método template:

public void run() { setUp(); runTest(); tearDown();

}

Las implementaciones por defecto de los métodos hook no realizan ningún procesamiento: protected void runTest() { } protected void setUp() { } protected void tearDown() { }

Están declaradas como protected, de forma que sean accesibles a partir de las subclases (y,

Page 178: Curso Practico en Java de Ingenieria Del Software Mit

por consiguiente, se puedan superponer) pero no desde fuera del paquete. Estaría bien poder restringir el acceso excepto a partir de las subclases, pero Java no ofrece dicho modo. Una subclase puede superponer esos métodos arbitrariamente; si sólo superpone runTest, por ejemplo, no habrá ningún comportamiento especial de los métodos setUp o tearDown. Observamos el mismo patrón en la última clase, en las implementaciones organizadas en jerarquías de esqueleto de la API de colecciones de Java. A este patrón a veces se le conoce con el nombre, bastante cursi, de Hollywood Principle. Una API tradicional ofrece métodos que son invocados por el cliente; un marco, por el contrario, hace llamadas a los métodos de su cliente: ‘no nos llame, le llamaremos nosotros’.. El uso cada vez más extendido de las plantillas es la esencia de la programación de marcos. Resulta sencillo y llama mucho la atención escribir programas que sean completamente incomprensibles, pues las implementaciones de métodos realizan llamadas en todos los niveles de la jerarquía de herencia. Puede ser difícil saber qué esperar de una subclase en un marco. No se ha desarrollado una analogía de las condiciones previas y subsiguientes, y la tecnología actual aún no está muy desarrollada. Por lo general, es preciso leer el código fuente del marco para poder usarlo eficazmente. La API de colecciones de Java es mejor que la mayoría de los marcos, ya que incluye en las especificaciones de los métodos de plantilla descripciones exhaustivas sobre su implementación. Esto se puede interpretar como una afrenta a la idea de especificación abstracta, pero es inevitable en el contexto de un marco.

17.4 El patrón Composite

Como vimos en la clase 11, los casos de prueba se agrupan en suites. Pero lo que se hace con una suite de pruebas es básicamente lo mismo que se hace con una prueba: ejecutarla e informar del resultado. Esto nos sugiere la utilización del patrón Composite, en el que un objeto compuesto comparte una interfaz con sus componentes elementales. Aquí, la interfaz es Test, el objeto compuesto es TestSuite y los componentes elementales son miembros de TestCase. TestSuite es una clase concreta que implementa Test, pero cuyo método run, al contrario que el método run de TestCase, invoca el método run de cada una de las pruebas de la suite. Las instancias de los TestCase se añaden a la instancia TestSuite con el método addTest; hay también un constructor que crea una TestSuite con un grupo de casos de prueba, como veremos más adelante. En el ejemplo Composite del libro de Gamma la interfaz incluye todas las operaciones del objeto compuesto. Siguiendo este enfoque, Test debería incluir métodos como addTest, que se aplican solamente a objetos TestSuite. La sección de implementación de la descripción del patrón explica que se da una compensación entre transparencia –haciendo que los objetos compuesto y hoja se muestren de la misma forma– y seguridad –impidiendo las invocaciones a operaciones no apropiadas. En los términos de nuestro debate en la clase sobre subtipos, la cuestión es si la interfaz debería ser un verdadero supertipo. En mi opinión sí debería serlo, pues los beneficios de la seguridad son mayores que los de la

Page 179: Curso Practico en Java de Ingenieria Del Software Mit

transparencia, y, es más, la inclusión de operaciones compuestas en la interfaz crea confusión. El proyecto JUnit adopta este enfoque, y no incluye addTest en la interfaz Test.

17.5 El patrón del parámetro collecting El método run de Test posee en realidad esta firma:

public void run(TestResult result); Recibe un único argumento que se altera para registrar el resultado del código ejecutado. Beck llama a esta técnica parámetro collecting y lo considera como un patrón de diseño propiamente dicho. Una prueba puede fracasar de dos modos. O bien porque produce un resultado erróneo (lo que puede incluir no lanzar la excepción esperada), o bien porque lanza una excepción inesperada (como IndexOutOfBoundsException). JUnit llama a los primero fallos (failure) y a los segundos errores (error). Una instancia de TestResult contiene una secuencia de fallos y una secuencia de errores, representándose cada fallo o error como una instancia de clase TestFailure, que contiene una referencia a un Test y una referencia al objeto de excepción generado por el fallo o error. (Los fallos siempre producen excepciones, ya que incluso cuando un resultado inesperado se produce sin una excepción, el método assert utilizado en la prueba convierte el fallo en una excepción). El método run en TestSuite permanece inalterado; apenas pasa un objeto TestResult cuando invoca el método run de cada una de sus pruebas. El método run en TestCase tiene el siguiente aspecto:

public void run (TestResult result) { setUp (); try { runTest (); } catch

(AssertionFailedError e) { result.addFailure (test, e); }

(Throwable e) { result.addError (test, e); }

tearDown (); }

De hecho, el flujo de control del método template run es más complicado de lo que hemos sugerido. Más abajo hay unos fragmentos de pseudocódigo que muestran lo que ocurre. Ignora las actividades setUp y tearDown, y considera una utilización de TestSuite dentro de una interfaz de usuario de texto:

junit.textui.TestRunner.doRun (TestSuite suite) { result = new TestResult (); result.addListener (this); suite.run (result);

Page 180: Curso Practico en Java de Ingenieria Del Software Mit

print (result); }

junit.framework.TestSuite.run (TestResult result) {

forall test: suite.tests test.run (result);

}

junit.framework.TestCase.run (TestResult result) { result.run (this); }

junit.framework.TestResult.run (Test test) { try { test.runBare (); } catch (AssertionFailedError e) {

addFailure (test, e); } catch (Throwable e) {

addError (test, e); } }

junit.framework.TestCase.runBare (TestResult result) {

setUp(); try { runTest(); }

finally { tearDown(); } }

TestRunner es una clase de interfaz de usuario que invoca al marco y muestra los resultados. Hay una versión con interfaz gráfica de usuario junit.swingui y una versión simple con terminal de texto junit.textui, de la que hemos mostrado un fragmento. Presentaremos el sistema listener más adelante, por ahora lo pasaremos por alto. He aquí cómo funciona. El objeto TestRunner crea un nuevo TestResult para almacenar los resultados de la prueba, ejecuta la suite de pruebas e imprime los resultados. El método run de TestSuite invoca el método run de cada una de sus pruebas constituyentes, que pueden ellas mismas ser objetos TestSuite, por lo que el método puede ser llamado recurrentemente. Este es un ejemplo óptimo de la simplicidad de Composite. Al final, como hay una invariante que determina que TestSuite no se puede contener a sí mismo –que, en verdad, no está especificado ni definido por el código de TestSuite– el método acabará por invocar los métodos run de objetos de tipo TestCase.

Ahora, en el método run de TestCase, el objeto receptor TestCase le cambia el sitio al objeto TestResult e invoca el método run de TestResult con TestCase como argumento. (¿Por qué?). A continuación, el método run de TestResult invoca el método runBare de TestCase, que es el método template real que ejecuta la prueba. En caso de error en la prueba, se arroja una excepción, que es interceptada por el método run de TestResult, el

Page 181: Curso Practico en Java de Ingenieria Del Software Mit

cual a continuación empaqueta la prueba y la excepción como un fallo o error de TestResult.

17.6 El patrón Observer Queremos mostrar, para una interfaz de usuario alternativa, los resultados de la prueba de modo incremental, mientras ésta tiene lugar. Para lograrlo, JUnit utiliza el patrón Observer. La clase TestRunner implementa una interfaz TestListener que tiene métodos addFailure y addError propios. La interfaz hace el papel de Observer (observador). La clase TestResult hace el papel de Subject (sujeto observado). TestResult ofrece un método

public void addListener(TestListener listener) que añade un observador. Cuando se invoca el método addFailure de TestResult, además de actualizar su lista de fallos, llama al método addFailure en cada uno de sus observadores:

public synchronized void addFailure(Test test, AssertionFailedError e) { fFailures.addElement(new TestFailure(test, e));

for (Enumeration e= cloneListeners().elements(); e.hasMoreElements(); ) { ((TestListener)e.nextElement()).addFailure(test, e); }

}

En la interfaz de usuario textual, el método addFailure de TestRunner muestra simplemente un carácter F en la pantalla. En la interfaz gráfica de usuario, este método añade el fallo a una lista de exhibición y cambia el color de la barra de progreso a rojo.

17.7 La técnica de reflexión Hay que recordar que un caso de prueba es una instancia de la clase TestCase. Para crear una suite de pruebas en Java puro y simple, el usuario tendría que crear una nueva subclase de TestCase para cada caso de prueba e instanciarla. Una forma elegante de hacerlo es por medio de clases internas anónimas, creando el caso de prueba como una instancia de una subclase que no tiene nombre. Este método no deja de ser muy trabajoso, por lo que JUnit utiliza un hack o truco técnico denominado reflexión. El usuario ofrece una clase para cada suite de pruebas –denominada, digamos, MySuite– que es una subclase de TestCase, y que contiene muchos métodos de prueba, cada uno de los cuales tiene un nombre iniciado con la string ‘test’. Estas clases se tratan como casos de prueba individuales.

public class MySuite extends TestCase { void testFoo () {

int x = MyClass.add (1, 2); assertEquals (x, 3); }

void testBar () {

Page 182: Curso Practico en Java de Ingenieria Del Software Mit

} }

La propia clase objeto MySuite se pasa al constructor de TestSuite. Mediante la técnica de reflexión, el código de TestSuite instancia MySuite para cada uno de los métodos que comienzan con ‘test’, pasando los nombres de los métodos como un argumento para el constructor. Como resultado, para cada método de prueba se crea un nuevo objeto TestCase, con su nombre vinculado al nombre del método de prueba. El método runTest de TestCase invoca, de nuevo a través de la reflexión, el método cuyo nombre corresponde al nombre del propio objeto TestCase, más o menos así:

void runTest () { Method m = getMethod (fName); m.invoke (); }

Este esquema es oscuro y presenta sus riesgos; no es el tipo de cosa que usted debe imitar en su código. En este caso se justifica porque se limita a una pequeña parte del código JUnit y aporta una gran ventaja al usuario de éste.

17.8 Cuestiones para el estudio Estas cuestiones surgieron cuando construí el modelo de objeto para JUnit. No todas tienen una única respuesta.

• · ¿Por qué los listeners forman parte de TestResult? ¿No es TestResult una especie de listener por sí mismo?

• · ¿Es posible que un TestSuite no contenga ninguna prueba? ¿Se puede contener a sí mismo? ¿Son únicos los nombres de los Test?

• · ¿El campo fFailedTest de TestFailure apunta siempre a un TestCase?

Page 183: Curso Practico en Java de Ingenieria Del Software Mit

Clase 18. Prácticas: Tagger

18.1 Descripción general

En esta clase explicaremos el diseño de Tagger, un pequeño programa que escribí en el verano de 2001 y que he utilizado para redactar y editar mis trabajos durante los últimos meses, así como para el presente documento (curso 6.170) y muchos otros desde junio de 2001 (véase http://sdg.lcs.mit.edu/~dnj/publications). He escogido Tagger para esta tercera clase práctica por una serie de razones. En primer lugar, porque lo conozco mejor que otros programas, ya que lo he escrito yo mismo. En segundo lugar, porque ofrece demostraciones de varios de los patrones y lenguajes estudiados a lo largo del curso y muestra una utilización muy interesante de la API de colecciones de Java (el primer caso práctico visto en el curso). Y, por último, porque no se trata de una tarea tan pulida como las de los dos casos prácticos anteriores, por lo que posiblemente se adecua mejor a lo que se espera que el estudiante presente en su trabajo de fin de curso. El diseño del programa me ocupó varios días, más una semana que empleé en construirlo. Esta clase práctica puede también consultarse en forma de presentación a base de transparencias.

18.2 Objetivo Tagger es una pequeña aplicación de procesamiento de texto diseñada para servir de ayuda en la producción de artículos y libros de contenido técnico. Se utiliza como una aplicación de usuario final para programas de diseño tipo WYSIWYG, como QuarkXpress y Adobe Indesign, que combina las ventajas de éstos con algunas de las ventajas de herramientas de texto basadas en compilaciones, como TeX. Las herramientas del tipo TeX son interesantes, ya que permiten al usuario editar documentos en un editor de texto potente e intercambiarlos fácilmente por correo electrónico. Al hallarse el formato indicado por medio de etiquetas de texto (tags), se puede variar mediante los mismos mecanismos (como Buscar y Reemplazar) que se utilizan para modificar el propio texto. Asimismo, los símbolos de operadores matemáticos se pueden referenciar simbólicamente (por ejemplo, escribiendo \alpha para indicar el símbolo α), lo que normalmente permite agilizar el proceso de mecanografiado al no tener que seleccionar caracteres especiales, así como desacoplar el documento de una fuente matemática específica. Las referencias cruzadas se expresan de modo sencillo mediante la asignación de nombres simbólicos a párrafos y utilizando a continuación éstos en las citaciones. Pero, por otra parte, las herramientas como TeX presentan también graves inconvenientes.

Page 184: Curso Practico en Java de Ingenieria Del Software Mit

Requieren un considerable trabajo de adaptación por parte del usuario para poder reconocer la amplia gama de fuentes de postcript actualmente disponibles. Además, los ajustes de diseño no resultan por lo general fáciles: cualquier modificación, por simple que sea (como cambiar los márgenes o el espaciado de los títulos), exige normalmente que la persona que la realiza tenga los conocimientos adecuados. Y la calidad tipográfica de los documentos es inferior a la que se obtiene con las modernas herramientas de diseño. Tanto Indesign como Quark, sin ir más lejos, permiten definir una "baseline grid" (cuadrícula de base) para alinear las líneas de texto en páginas con columnas enfrentadas, y los algoritmos de guiones que utilizan parecen funcionar mejor. Indesign da acceso a todas las funciones de las fuentes OpentType, ofreciendo además alineamiento óptico. El funcionamiento de Tagger es sumamente sencillo. El usuario escribe un documento en un lenguaje de marcado simple. Este tipo de lenguaje no ofrece prácticamente ningún control directo sobre el formato, aparte de comandos para poner el texto en negrita, cursiva, etc. Por el contrario, los párrafos se etiquetan con los nombres de los estilos de párrafo (paragraph styles). Tagger convierte el documento en un archivo en el formato de importación de un programa de diseño como Quark. Dentro de Quark, el usuario define una hoja de estilo (stylesheet) que asigna a cada párrafo sus características tipográficas. De esta forma, al importarse los párrafos se formatean según el estilo apropiado de la hoja de estilo. Naturalmente, cabe también la posibilidad de escribir el archivo de importación para el programa de diseño. Pero ocurre que cada programa de diseño tiene un formato de importación distinto. Aunque actualmente Tagger sólo genera archivos para Quark, no sería difícil añadir soporte para Indesign y otros programas similares. Asimismo, los formatos de importación tienden a ser de bajo nivel y resultan mucho más complicados de escribir que nuestro lenguaje de marcado. Curiosamente, el formato de importación para Indesign no se puede ni siquiera preparar en un editor de texto, ya que se halla basado en la distinción entre saltos de línea y retornos de carro. Tagger también traduce nombres simbólicos de caracteres matemáticos a información de fuentes e índices: en el formato de importación, en vez escribir cosas como \alpha para mostrar el carácter α, habría que indicar el nombre de la fuente matemática y el índice correspondientes.

18.3 Características Tagger presenta las siguientes características:

• Marcado de párrafos con nombres de estilo. Una hoja de estilo específica determina para cada estilo un estilo por defecto, por lo que en muchos casos no es necesario marcar un párrafo explícitamente.

• Numeración automática de párrafos. La hoja de estilo determina qué estilos deben numerarse, la jerarquía de la numeración (sección, subsección, etc.), en qué estilo se generarán los números (alfabético, arábigo, latino, etc.) y cómo deben estar compuestas las cadenas de numeración, con los encabezados, colas y separadores propios de cada estilo.

• Denominación simbólica de caracteres especiales. Los archivos de asignación traducen los nombres simbólicos a pares nombre de fuente/índice. Tagger incluye

Page 185: Curso Practico en Java de Ingenieria Del Software Mit

algunos archivos de este tipo que facilitan la escritura de nuevos archivos que hagan accesibles los caracteres de otras fuentes.

• Modo math. El programa trata el texto escrito entre dos signos de dólar ($) como texto matemático. Los caracteres alfabéticos aparecen en cursiva, pero los números, los signos de puntuación y los símbolos permanecen invariables.

• Referencias cruzadas. Cuando se marca un párrafo con una etiqueta, una cita que utilice dicha etiqueta en cualquier parte del texto generará una cadena que refiere al párrafo marcado. Por defecto, esta cadena será la cadena de numeración creada por la función de numeración automática, aunque el usuario puede especificar otra cadena explícitamente. Esta característica, combinada con la numeración automática, simplifica el manejo de referencias bibliográficas.

• Formato básico de caracteres. Se puede poner el texto en cursiva, insertar subíndices, etc.

• Espacios en blanco. Los espacios en blanco se mantienen en su mayoría, por lo que resulta sencillo sangrar el texto con tabulaciones o espacios.

• Atajos. Tagger ofrece varios de los atajos más comunes: por ejemplo, tres puntos equivalen a puntos suspensivos, dos guiones a un guión de cierre, etc. El texto entre guiones bajos pasa a cursiva. En cuanto a las comillas, el programa les da la forma adecuada según el contexto; como por ejemplo en la frase: "Está bien trabajar como ‘ingeniero de software’ en el año '01".

18.4 Diseño: descripción general La organización básica del programa es muy sencilla. La clase principal, Tagger, utiliza la clase SourceParser para analizar el texto de entrada y transformarlo en una cadena de objetos Token, cada uno de los cuales tiene un TokenType. El Token se pasa a un objeto Engine, que asocia el TokenType a una lista de objetos Action, lo que hace que cada uno de éstos se ejecute. Un efecto típico de una Action es generar una salida de texto a través de una interfaz Generator que oculta a la Action la opción de formato de importación, es decir, el formato de salida). Actualmente sólo existe una clase que implemente esta interfaz, llamada QuarkGenerator, que produce texto para ser importado por QuarkXpress.

Algunos objetos Action hacen que se lean los archivos: por ejemplo, el texto \loadstyles{foo.txt} hace que el archivo foo.txt sea procesado como un archivo de estilo. Los archivos de estilo y los mapas de caracteres comparten una misma sintaxis: una secuencia de líneas, cada una de ellas formadas por una lista de propiedades, cada una de las cuales, a su vez, consiste en un par formado por un nombre de propiedad y un valor. Los contenidos de ambos tipos de archivos se representan como objetos PropertyMap. Cada objeto de este tipo contiene una asignación de los nombres de propiedad a las listas de propiedad, siendo éstas listas de objetos Property, y estando formadas cada una de ellas por un nombre de propiedad y un valor. La clase PropertyParser lleva a cabo el análisis de los archivos de propiedades. La clase Numbering crea cadenas de numeración. Se genera una instancia de la clase para cada archivo de estilo, ya que cada uno de éstos contiene directrices de numeración.

Page 186: Curso Practico en Java de Ingenieria Del Software Mit

El diagrama de dependencia de módulos (véase el archivo tagger-mdd.doc) muestra los módulos del programa Tagger y sus dependencias entre sí. El contorno de puntos agrupa los módulos que comparten dependencias, lo que nos permite evitar el tener que trazar una flecha de dependencia desde la clase Tagger a prácticamente todas las demás clases. Los números que figuran junto a los bordes de las dependencias señalan comentarios a la lista de dependencias no previstas, que explican por qué aparece una dependencia con cuya presencia no se contaba. Así, por ejemplo, la nota en la dependencia que va desde StandardEngine a Property nos indica que el código StandardEngine (en realidad, el código de sus clases internas anónimas, que son subtipos de Action) genera un archivo de índices de datos de referencias cruzadas. Para ello es preciso que la clase tenga acceso a Property. Todos los demás usos de Property, para la lectura y el procesamiento de archivos de estilo y mapas de caracteres, son gestionados por clases inferiores como PropertyMap.

18.5 Diseño: características En este apartado presentamos las principales características del diseño del programa (véase el archivo tagger-mdd.doc). Algunas de ellas están ilustradas con modelos de objeto, algunas veces del código y otras veces de las estructuras conceptuales subyacentes. 18.5.1 Interfaz Generator Los objetos Action interactúan con el generador que les da soporte a través de la interfaz Generator, lo que garantiza que no sean dependientes de las funciones de ningún generador en concreto. Mientras las propiedades compartidas de diferentes generadores puedan ser captadas por la interfaz, no debería haber problema en adaptar la aplicación para que genere datos de salida para una variedad de herramientas de diseño (como Indesign y PageMaker, además de Quark). Ello, a su vez, permitiría escribir fácilmente, por ejemplo, un generador de texto puro que produjera texto simple ASCII adecuado para mensajes de correo electrónico. El modelo de objeto de Tagger (archivo tagger-mdd.doc ) muestra la relación entre las acciones, los generadores concretos y la interfaz Generator.

18.5.2 Numeración Cada estilo puede estar numerado o no estarlo. En el primer caso, pertenece a una serie. La serie tiene estilo de raíz, existiendo una cadena de estilos que parten de la raíz: decimos entonces que, cuando la cadena va del estilo s al estilo t; s es padre de t y t es hijo de s. Cuando en la serie sólo existe un estilo, ese estilo es la raíz y no tiene ningún hijo. Esta estructura se especifica en el archivo de estilo indicando simplemente cuál es el padre de cada estilo, en caso de que lo hubiera, y asignándole una propiedad de contador si hubiera necesidad de numeración.

Page 187: Curso Practico en Java de Ingenieria Del Software Mit

El algoritmo de numeración funciona del siguiente modo: se asocia un contador (counter) a cada estilo numerado (numbered style), inicializándose con un valor que sea inferior en una unidad al primer valor que se va a generar. Cuando se encuentra un párrafo de un determinado estilo numerado, su contador se incrementa y se reinician los contadores de todos sus descendientes. A continuación se construye una cadena de numeración concatenando su encabezado, los valores actuales del contador de cada uno de sus antecesores desde la raíz hasta él separados por los separadores correspondientes, y su cola. El encabezado, el separador y la cola de cada estilo vienen dados en el archivo de estilo. El modelo de objeto ilustrado más arriba muestra las relaciones mantenidas por el objeto Numbering. A él se aplican las siguientes constantes:

• Si s es el padre de t, t será el hijo de s, y viceversa. • Todo estilo apunta a un estilo raíz, que puede ser él mismo (en el caso de que no

tenga padre), o el primer antecesor que carezca de padre. • Las series son disjuntas: ningún estilo puede pertenecer a dos series. Por lo tanto,

dos estilos distintos no pueden compartir un mismo padre o un mismo hijo. • Ningún estilo es su propio padre ni su propio hijo. • Si un estilo es numerado, sus antecesores deben serlo también.

18.5.3 Archivos de índices El problema de manejar referencias ascendentes se trata del mismo modo que ya vimos en LaTeX. Durante una ejecución se genera un archivo de índices que asocia etiquetas (tags) de citación con las cadenas de citación que se van a insertar en su lugar. Las referencias ascendentes no se resuelven, pero quedan recogidas al ejecutar la herramienta por segunda

Page 188: Curso Practico en Java de Ingenieria Del Software Mit

vez. Las nuevas asociaciones generadas durante la ejecución no son cotejadas con las que ya se encontraban en el archivo de índices, por lo que cabe la posibilidad de que una ejecución produzca referencias cruzadas erróneas después de haberse editado el texto. No obstante, ejecutar la herramienta dos veces seguidas dará siempre resultados correctos tras la segunda ejecución. Este planteamiento se podría aplicar sin problemas también a referencias en múltiples archivos de origen. El modelo de objeto del problema muestra las relaciones conceptuales implicadas en la generación de referencias cruzadas. Cada párrafo puede tener una etiqueta y una cadena de numeración: una de las dos se utiliza para mostrar las citaciones correspondientes al párrafo, teniendo la etiqueta prioridad sobre la cadena de numeración. Asimismo, un párrafo puede tener una etiqueta para citaciones en otros párrafos, que a su vez pueden también citarlo. Un párrafo p referencia a otro párrafo q cuando p cita a t y t es la etiqueta de q. Obsérvese que las etiquetas no pueden ser compartidas por más de un párrafo: son identificadores únicos. 18.5.4 Mapas de propiedades Las hojas de estilo y los mapas de caracteres tienen una misma sintaxis y se representan internamente mediante un mismo tipo de datos abstractos, PropertyMap, lo que permite utilizar para ambos un mismo analizador. Los archivos de índices generados por el mecanismo de referencias cruzadas utilizan también la misma sintaxis y la misma representación. La clase Numbering aumenta la representación interna de las hojas de estilo al añadir propiedades nuevas y redundantes que facilitan la generación de cadenas de numeración. Se trata de una especie de parche. 18.6 Estilos: visión de conjunto El SourceParser debe ser capaz de distinguir nombres de estilos de párrafos de otros

comandos, puesto que la sintaxis no requiere que estén marcados de una manera especial. El SourceParser, por consiguiente, se construye con una referencia para un conjunto (un objeto de tipo Set) de nombres de estilos. Al principio, sin embargo, los nombres de estilos son desconocidos. Cuando se carga una hoja de estilos, la Action que lee el archivo produce el PropertyMap y ocurre lo siguiente: inicialmente, el Engine recibe un PropertyMap vacío y el objeto Set pasado al SourceParser es una vista del mapa definido por el objeto PropertyMap. Cuando se ha cargado la hoja de estilos, el PropertyMap se ve alterado por la adición de nuevos estilos, y la vista varía también del mismo modo. Se trata de un mecanismo algo complicado, pero que nos permite desacoplar el SourceParser del PropertyMap.

El siguiente modelo de objeto muestra cómo la vista conecta el SourceParser y el StandardEngine en el código.

Page 189: Curso Practico en Java de Ingenieria Del Software Mit

18.6.1 Motores (Engines) múltiples Cabría esperar que la clase Engine fuera un objeto singleton, pero no lo es. Existe un objeto Engine para manipular el archivo fuente, y un segundo Engine, que realiza un menor número de operaciones, para procesar cadenas de numeración. Al dar directrices de numeración en el archivo de estilo podíamos utilizar el lenguaje de marcado. Así, por ejemplo, los puntos centrados al inicio de los párrafos para señalar listas se generan porque se han marcado los párrafos con el estilo point, y la directriz de numeración del archivo de estilos determina que, aunque point no esté numerado, la cadena de numeración deberá incluir un punto centrado en su inicio, así como una tabulación. La referencia al punto centrado viene indicada por el nombre simbólico \periodcentered. El objeto Numbering genera una cadena que contiene dicho nombre como una subcadena, que debe analizarse y procesarse de la misma forma que las categorías del propio archivo de origen.

Page 190: Curso Practico en Java de Ingenieria Del Software Mit

El modelo de objeto muestra que algunas acciones (objetos Action) contienen una referencia a un objeto Numbering. La cadena resultante, que no aparece en el diagrama, es pasada a una NumberingEngine diferente, con sus propias acciones. La flecha punteada indica, informalmente, la relación entre un motor y sus acciones registradas.

18.6.2 Modificación sincronizada La implementación de Engine utiliza un iterador para barrer la lista de objetos Action asociados con el TokenType disponible. A continuación se ejecuta el método perform de cada uno de estos objetos que, como hemos mencionado anteriormente, sirve para registrarlos y para eliminarlos del registro. Si se hiciera esto en el TokenType disponible, la lista en la que se está produciendo la iteración quedaría modificada, violándose la prohibición de Java de realizar alteraciones concurrentes en una colección mientras su iterador se halla activo. El único caso en el que parece necesario llevar a cabo esta operación sería para los objetos Action que se eliminan del registro inmediatamente después de producirse. Para manipular estas acciones, Engine pasa el iterador como un argumento para el método perform de Action, que a continuación invoca el método remove del iterador para eliminar el objeto Action de la lista subyacente. Se trata de un uso infrecuente y poco conocido del método remove puesto que el sitio de llamada a remove se halla alejado del sitio del bucle. El modelo de objeto muestra cómo una acción perteneciente a una lista de acciones de un motor (engine) tiene acceso a la misma de forma indirecta por medio de un iterador.

Page 191: Curso Practico en Java de Ingenieria Del Software Mit

18.6.3 Registro dinámico Un objeto Action puede hacer que otros sean registrados o eliminados del registro. Así, por ejemplo, cuando el programa encuentra por primera vez un signo de dólar ($), ejecuta un Action que registra una acción de cambio a cursiva en TokenType para secuencias de caracteres alfabéticos. Al procesar el siguiente signo de dólar, la acción anterior se elimina del registro. A consecuencia de ello, los caracteres alfabéticos pasan a cursiva cuando se hallan entre signos de dólar.

18.6.4 Clases internas anónimas La mayor parte de los objetos Action se implementan por medio de clases internas anónimas. Para comprender esta técnica es importante tener en cuenta que los métodos de clases internas tienen acceso a las variables del alcance dentro del que se insertan esas clases. Java no permite realizar asignaciones a estas variables, ya que el entorno no es el adecuado. Por esta razón, las variables que representan el estado del procesamiento de un párrafo se hallan encapsuladas en un objeto de clase ParaSettings. Observe que existen muy pocas variables de este tipo, lo que representa una de las principales ventajas de la organización basada en acciones. 18.6.5 Enumeraciones seguras con respecto a los tipos (type-safe) Diversas enumeraciones, como Format y TokenType se implementan de forma segura (type-safe), de acuerdo con el idioma descrito por Bloch en el apartado 21 de su texto Effective Java. A diferencia de la práctica común de representar enumeraciones con variables estáticas vinculadas a valores de números enteros, este método garantiza la seguridad de los tipos. Y, sin embargo, al contrario de lo que ocurre con los tipos de datos algebraicos de lenguajes como ML, no permite que el compilador compruebe que existe una sentencia de comando case manipulando todos los valores de la enumeración.

Page 192: Curso Practico en Java de Ingenieria Del Software Mit

18.7 Otras opciones de diseño A continuación indico otros diseños distintos que he preferido no utilizar.

• Un diseño que utilice un lenguaje de script basado en líneas, como Perl, sed o awk. El problema es que se trata de lenguajes que no se adaptan bien a aplicaciones como Tagger en las que las líneas no tienen relevancia y en las que el contexto (por ejemplo, la utilización del formato en cursiva) debe continuar siendo válido tras los saltos de línea. Tampoco he tenido la suficiente paciencia para depurar un script complejo en Perl y he preferido utilizar un lenguaje con seguridad en los tipos.

• Un típico diseño orientado a objetos en el que cada tipo de categoría se halle representado como su propia subclase de una clase denominada Token, y las acciones sean métodos de estas subclases, dependiendo la selección de las acciones de un mecanismo dinámico. Se trata de un enfoque sencillo, pero que crea un número demasiado amplio de clases y, lo que es peor, dispersa las funcionalidades entre muchas clases: el modo math, por ejemplo, no se codificaría en un único sitio, sino en todas las categorías representadas. Este inconveniente fue el que motivó la creación del patrón Visitor. Este diseño no permite realizar cambios en comportamientos de un modo tan dinámico como el diseño que yo propongo.

• Un diseño en el que la elección de comportamiento en función del tipo de categoría venga determinada por una sentencia de comando case de gran tamaño o por métodos de un patrón Visitor. Esta opción crearía una masa de variables globales, haciendo que funciones como las del modo math vicien la totalidad de la sentencia de comando case. En el diseño basado en acciones, por el contrario, estas funciones se hallan encapsuladas en su mayor parte.

• Una organización de tipo estándar como las utilizadas en compiladores, que utilice un árbol de sintaxis abstracta intermedia en vez de un flujo de categorías. Este diseño ofrece mucha mayor flexibilidad y un mejor control de errores, pero también exige mayor trabajo para su implementación.

18.8 Defectos de diseño Algunos de los defectos conocidos de Tagger son los siguientes:

• Dado que Tagger no ve el diseño final de las páginas, no puede manipular características de éstas, como las notas al pie y los encabezados, algo que sería posible utilizando un lenguaje de importación más expresivo (por ejemplo, algunos de los formatos comercializados por terceros para el programa Quark). La inserción de gráficos se ve afectada por el mismo problema: en la actualidad sólo es posible insertarlos manualmente en el programa de diseño.

• No ofrece facilidades para el diseño de tablas. • Tampoco ofrece las funciones de TeX para la edición de fórmulas matemáticas; por

ejemplo, no permite trabajar con fórmulas con varias líneas para sumatorios e integrales.

• En un principio, tenía la intención de incluir soporte para LaTeX y HTML, pero gran parte de esta funcionalidad se halla incorporada en las propias acciones. El soporte para estos lenguajes puede implementarse fácilmente creando soportes para otros programas de diseño como Indesign y Pagemaker.

Page 193: Curso Practico en Java de Ingenieria Del Software Mit

• Los archivos de estilos no están totalmente comprobados por el momento. Se producen errores en las relaciones de numeración (por ejemplo, el programa indica que el estilo es padre de sí mismo) que pueden hacer que Tagger no funcione bien o se bloquee sin dar los avisos pertinentes.

• El sistema de informe de errores no es siempre fiable. Por ejemplo, los errores detectados al parsear archivos de propiedades no incluyen datos sobre los números de filas en las que se producen aquellos.

• La sintaxis de los archivos de propiedades se halla representada en dos sitios distintos del código: en el método dump de PropertyMap y en los métodos de análisis de PropertyParser, lo que supone un acoplamiento nada deseable.

• Los mapas de caracteres y las hojas de estilo pueden anularse entre sí sin que el programa avise de ello. Cuando dos mapas de caracteres definen un mismo nombre de carácter simbólico se utiliza la última definición. Asimismo, un nombre de estilo puede anular un nombre de carácter. Por ejemplo, el nombre de estilo "section" anula el carácter del mismo nombre, evitando que el sistema muestre el símbolo de éste.

• El mecanismo que informa del progreso del procesamiento deja bastante que desear. A medida que se van generando cadenas de numeración, éstas pasan a un motor especial pensado para la presentación de datos que elimina los caracteres que no van a aparecer en la consola. Este mecanismo aplica la suposición de que mostrar los números de los párrafos es un modo apropiado de indicar el progreso del procesamiento; y lo es a menudo, pero puede no serlo cuando la numeración se utiliza para ítems menores, como referencias bibliográficas o líneas de código. Si bien muchas de las acciones se pueden comprender por separado, existen interacciones más sutiles entre algunas de ellas. El comportamiento asociado al inicio de un nuevo párrafo, por ejemplo, comprende varias acciones, registros dinámicos y eliminaciones de registros, así como los estados que persisten entre las acciones, encapsulados en el objeto ParaSettings. Esto refleja la naturaleza contextual del problema: en este caso, que el inicio de un párrafo generaría por defecto una directriz de estilo, a no ser que hubiera un comando explícito de estilo de párrafo.

• Algunos caracteres (por ejemplo, >) no pueden utilizarse en el texto de origen, ya que Quark los interpreta como caracteres de control. Tagger no los reconoce ni encapsula del modo adecuado, por lo que el usuario se ve obligado a referirse a ellos mediante nombres simbólicos (en este caso, \less).

• El programa no reconoce por el momento estilos de caracteres, aunque esta función se añadirá próximamente.

• El mecanismo de resolución de ambigüedades al utilizar los signos de interrogación no funciona correctamente en todos los casos.

18.9 Proceso de desarrollo

Tagger se escribió inicialmente como un script en Perl a fin de experimentar con la idea de generar entrada de datos para Adobe Indesign. Había estado tratando de escribir texto en el formato de entrada de datos Indesign, pero lo encontraba demasiado laborioso, especialmente porque se trata de un formato que distingue entre saltos de línea y retornos

Page 194: Curso Practico en Java de Ingenieria Del Software Mit

de carro, por lo que no es posible prepararlo en un editor de texto. El experimento tuvo éxito y me animó a continuar y añadir otras funciones como la de numeración automática. Pero como el script de Perl era frágil y difícil de mantener, decidí escribir una versión en Java. Pasé unos días diseñando el lenguaje fuente. A medida que desarrollaba la implementación, lo fui perfeccionando y descubriendo qué partes eran fáciles de analizar y cuáles eran fáciles de escribir. Como detesto escribir analizadores, comencé por implementar el analizador del archivo fuente y quitármelo de encima cuanto antes. El diseño inicial estaba representado por una serie de modelos de objeto y diagramas de dependencia de módulos. No realicé ninguna prueba de unidades, ya que las principales complicaciones se hallaban en clases (como StandardEngine) que no era fácil probar sin los demás módulos. Posiblemente debería haber escrito pruebas de unidades para tipos de datos pequeños como Counter. La prueba del programa completo la realicé manualmente, analizando la salida de datos y la forma en la que Quark los procesaba. El programa mejoró incrementalmente durante los siguientes meses a medida que lo utilizaba para escribir. Hasta el momento he detectado cuatro errores en el código (en 3.000 líneas, incluyendo los comentarios), aunque sospecho que hay muchos más errores que aún no han aparecido, ni siquiera al utilizar el programa en toda su extensión, ya que los casos patológicos raramente salen a la luz. Dado que se trata de un programa para uso personal, me alegra poder ponerlo a prueba con el uso cotidiano; aunque, naturalmente, en caso de que fuese a distribuir el programa ampliamente sería necesario realizar una comprobación adecuada. Hasta el momento he introducido unos veinte arreglos en el código para adaptarlo a cambios realizados en el lenguaje fuente. Al preparar el programa para su distribución he añadido especificaciones para métodos públicos. Como era el único programador, hasta ahora me he preocupado de escribir especificaciones sólo para los procedimientos más complicados. También he rehecho el código en algunos lugares, por ejemplo, introduciendo el patrón de enumeración segura para tipos. A fin de reducir el riesgo de introducir nuevos errores, he escrito un marco de test de regresión un tanto rudimentario (con la clase principal Tagger como método runTest), que sirve para comparar cada archivo que se genera con otro generado previamente. 18.10 Guía del usuario A continuación muestro una guía del usuario, si bien muy básica y pendiente de finalización. 18.10.1 Argumentos de línea de comando La aplicación Tagger se invoca con un argumento (el nombre del archivo que se va a procesar) y con un segundo argumento opcional (un nombre de trayecto por el que se pueden interpretar los archivos a los que se refiere el archivo fuente). El nombre del archivo fuente aparece sin extensión, y se presume que ésta es .txt. El archivo generado recibe el

Page 195: Curso Practico en Java de Ingenieria Del Software Mit

sufijo .tag.txt. Por defecto, Tagger intenta abrir los archivos mencionados en el archivo fuente en la ubicación especificada para ellos, y sólo en caso de que no se abra utiliza el nombre opcional. 18.10.2 Estructura general Antes de utilizar un símbolo de carácter es necesario cargar un archivo que lo defina mediante el comando \loadchars. Del mismo modo, para utilizar un nombre de estilo se debe cargar previamente una hoja de estilos que lo defina mediante el comando \loadstyles. Es conveniente cargar los mapas de caracteres y las hojas de estilo en un preámbulo en la parte superior del archivo. 18.10.3 Léxico El archivo fuente se analiza en párrafos. El primer texto de impresión del archivo comienza en un párrafo. Los párrafos se hallan separados por una línea en blanco (es decir, que esté vacía o que contenga un espacio en blanco de tabulación o espaciado) o por el símbolo especial \p, utilizado para párrafos cortos (como las líneas de código que se van a numerar) que el usuario no desea separar con líneas en blanco. Los comandos están precedidos por una barra invertida (\), mientras que dos barras (//) indican un salto de línea manual. Los comandos se clasifican en comandos de impresión (p.ej., \alpha, que hace que se genere α) y comandos de no impresión (p.ej., un comando de estilo de párrafo como \section, que introduce cambios de formato pero no genera datos de salida que aparezcan como texto en el documento final). Por lo general, los espacios en blanco se mantienen, salvo cuando van detrás de comandos de no impresión. Esto permite al usuario marcar un párrafo con un nombre de estilo dado en una línea anterior, ignorando así el salto de línea. Las tabulaciones producen el sangrado que se haya especificado en la hoja de estilos del programa de diseño. Como un comando no puede estar seguido inmediatamente por texto, sino que requiere que vaya detrás un espacio en blanco; existe un comando especial (\eat) que consume el espacio en blanco que sigue al comando. Los guiones y los puntos se traducen a guiones largos y puntos suspensivos, respectivamente. Por ejemplo, un guión normal se trata como un guión, dos como un guión de longitud n y tres como un guión de longitud m. Los signos de interrogación se interpretan según el contexto. Para insertar un carácter que tenga un significado especial como un carácter normal, debe ir precedido por una barra invertida: por ejemplo, la cadena \eat se genera escribiendo \\eat. 18.10.4 Comandos

• Estilo de párrafo. Cuando style es el nombre de un estilo definido en una hoja de

Page 196: Curso Practico en Java de Ingenieria Del Software Mit

estilos cargada anteriormente, el comando \style detrás de un salto de párrafo o al comienzo de un archivo indica que el párrafo iniciado por ese comando se debe configurar en el estilo denominado style. Si se añade un asterisco detrás (\style*) la numeración queda suprimida: no se genera ninguna cadena de numeración y los contadores no se incrementan. El estilo de párrafo por defecto (body) se utiliza para párrafos que no se hallan marcados con un estilo determinado y que no adopten un estilo especificado por un comando next style definido en la hoja de estilos.

• Símbolo de caracteres. Cuando char es el nombre de un carácter definido en un mapa de caracteres previamente cargado, el comando \char hace que el programa inserte ese carácter.

• Modo cursiva. El texto entre guiones bajos pasa a cursiva. • Modo math. El texto entre símbolos de dólar pasa a modo math: todas las cadenas

alfabéticas y numéricas pasan a cursiva, pero los demás caracteres (puntos, símbolos matemáticos, etc.) no varían.

• Nuevo comando. Los comandos \new{column} y \new{line} inician una nueva columna y una nueva línea, respectivamente. \new{line} equivale a //.

• Comandos de formato. La cadena \format<text> convierte el texto en text en el formato especificado por el comando de formato format. El programa admite los comandos de formato sub y super, para subíndices y superíndices; así como bold, roman e italic para letra negrita, redonda y cursiva.

• Referencias cruzadas. El comando \tag{t} marca un párrafo con el nombre t, que se puede utilizar para referirse al párrafo. El comando \label{l} asocia la cadena de etiqueta l a dicho párrafo, y el comando \cite{t} genera una referencia cruzada al párrafo marcado con t. Cuando el párrafo se ha marcado explícitamente con un comando \label, se utiliza la etiqueta como referencia cruzada; de lo contrario se utiliza la cadena de numeración generada para el párrafo.

18.10.5 Formato de hojas de estilo Una página de estilo consiste en una secuencia de líneas, cada una de las cuales especifica las propiedades de un estilo. La primera propiedad determina el nombre del estilo. Entre las demás propiedades pueden estar:

• next. Indica el estilo que toma el párrafo por defecto. • counter. Cuando aparece, indica que los párrafos del estilo se deben numerar

automáticamente. El valor de la propiedad indica tanto el valor inicial del contador como el estilo del mismo: 0, 1, 2, etc. si se trata de numeración arábiga; a, b, o A, B, etc. si la numeración es alfabética. Así, por ejemplo, <counter:B> significa que el contador debe utilizar letras mayúsculas, comenzando por B, C, …

• trailer. Texto fuente que se debe insertar después de la cadena de numeración y antes del texto del párrafo. Puede incluir distintos comandos: caracteres especiales, nueva columna, etc.

• leader. Texto fuente que se debe insertar antes de la cadena de numeración y antes del texto del párrafo. También puede incluir comandos como caracteres especiales, nueva columna, etc.

• separator. Texto fuente que se debe insertar a continuación del contador del

Page 197: Curso Practico en Java de Ingenieria Del Software Mit

estilo, en cadenas de numeración de párrafos del estilo hijo. Esta propiedad sirve, entre otras cosas, para insertar puntos entre contadores.

• parent. En la numeración automática, los estilos deben estar organizados en una jerarquía. Para ello se agrupan en series de numeración, cada una de las cuales tiene un estilo raíz y una cadena de estilos hijos. La serie de numeración se indica únicamente asignando un padre a cada uno de los estilos numerados, excepto al estilo raíz, que carece de padre. Por ejemplo, para numerar capítulos, secciones y subsecciones según el método estándar haríamos que el capítulo fuera el padre de la sección (asignándole la propiedad de padre en su lista de propiedades) y que la sección a su vez fuera el padre de la subsección.

El siguiente ejemplo muestra un archivo de estilos completo que numera las secciones 1,2, etc.; numera las subsecciones 1.1, 1.2, etc.; separa los números de sus párrafos mediante una tabulación y sitúa un punto centrado antes de cada párrafo de punto de estilo:

<style:section><next:noindent><counter:1><separator:.><trailer: > <style:subsection><next:noindent><parent:section><counter:1><separa-tor:.><trailer: > <style:point><next:body><leader:\periodcentered >

18.10.6 Formato del mapa de caracteres

Cada una de las líneas de un mapa de caracteres tiene la forma:

<char:myname><font:myfont><index:myindex>

en la que el símbolo de carácter myname aparece en myfont en la posición myindex. La propiedad fuente puede omitirse cuando el carácter aparece en la fuente estándar.

Page 198: Curso Practico en Java de Ingenieria Del Software Mit

Dan

iel J

acks

on61

70. C

urso

prá

ctic

o en

Inge

nier

ía d

e So

ftwar

eC

lase

18:

22

de o

ctub

re d

e 20

01

Page 199: Curso Practico en Java de Ingenieria Del Software Mit

Tem

as d

e la

cla

se d

e ho

y

rQué

es T

agge

r y p

or q

ué lo

esc

ribí

rOpc

ione

s de

dise

ñorD

iseñ

o ba

sado

en

acci

ones

rDis

eño:

vis

ión

gene

ral

rAsp

ecto

s con

cret

os d

el d

iseñ

o:O

bjet

os A

ctio

nR

efer

enci

as c

ruza

das

Map

as d

e pr

opie

dade

sN

umer

ació

n au

tom

átic

aV

isió

n de

con

junt

o de

est

ilos

Enum

erac

ione

s seg

uras

con

resp

ecto

a lo

s tip

os (t

ype-

safe

)rP

roce

sorC

oncl

usio

nes

Page 200: Curso Practico en Java de Ingenieria Del Software Mit

Qué

es

y po

r qu

é er

a ne

cesa

rio

¿Qué

es?

rUn

prep

roce

sado

r de

text

o pa

ra la

pre

para

ción

de

docu

men

tos

rTex

to fu

ente

esc

rito

en m

i pro

pio

leng

uaje

de

mar

cado

rSal

ida

de d

atos

en

el fo

rmat

o de

impo

rtaci

ón d

e Q

uark

Xpr

ess

¿Por

qué

?rD

esco

nten

to c

on lo

s sis

tem

as d

e pr

epar

ació

n de

text

os e

xist

ente

s W

ord:

poc

o fia

ble

Fram

emak

er: l

ento

y a

ntic

uado

Tex:

poc

o fle

xibl

e (y

no

se p

uede

n ut

iliza

r det

erm

inad

as fu

ente

s)

Page 201: Curso Practico en Java de Ingenieria Del Software Mit

Obj

etiv

os

Mod

elo

de c

ompi

laci

ón d

e Te

xrP

repa

raci

ón d

e do

cum

ento

s en

un e

dito

r de

text

orN

ombr

es si

mbó

licos

par

a ca

ract

eres

esp

ecia

les

rNum

erac

ión

auto

mát

ica

y re

fere

ncia

s cru

zada

s

Mod

elo 8

:4*8

:(�d

el Q

uark

rAlta

cal

idad

tipo

gráf

ica

rFác

il aj

uste

de

fuen

tes,

espa

ciad

o, d

iseñ

o de

pág

ina,

etc

.

Page 202: Curso Practico en Java de Ingenieria Del Software Mit

Solu

ción

: Tag

ger

Com

bina

:rL

a ca

lidad

tipo

gráf

ica

y la

flex

ibili

dad

del Q

uark

Xpr

ess

rcon

las f

unci

ones

de

entra

da d

e da

tos e

n te

xto

de T

ex

5BHHFS

2VBSL

Tex

to

Etiq

ueta

do

cara

cter

es

Tex

tofu

ente

Salid

a de

type

set

Hoj

a de

estil

o M

apa

de

Page 203: Curso Practico en Java de Ingenieria Del Software Mit

Ejem

plo

de in

trod

ucci

ón d

e da

tos

>NQCFEJCTU]OCRU>UVCPFCTF�OCR�VZV_

>NQCFEJCTU]OCRU>NWEOCVJCTT�OCR�VZV_

>NQCFUV[NGU]GZCORNG�UV[NGU�VZV_

\title Ejemplo de Tagger

Daniel Jackson//

Clase practica de informatica del MIT

\section Introduccion

\subsection Aspectos interesantes

Estas son algunas de las cosas

=>EKVG]FPL_?�que se pueden hacer:

\point Crear documentos con una _buena_

\point Escribir formulas $(a +

b) = c$ y utilizar simbolos como

\arrowdblse.

\section Referencias

>TGH>VCI]FPL_

,CEMUQP �&CPKGN��6CIIKPI�HQT�

2TQHKV�CPF�2NGCUWTG�������

presentación.

Page 204: Curso Practico en Java de Ingenieria Del Software Mit

Ejem

plo

de h

oja

de e

stilo

�UV[NG�VKVNG �PGZV�CWVJQT

�UV[NG�CWVJQT �PGZV�UGEVKQP

�UV[NG�UGEVKQP �PGZV�DQF[ �EQWPVGT�� �UGRCTCVQT�� �VTCKNGT��

�UV[NG�DQF[ �PGZV�DQF[

�UV[NG�UWDUGEVKQP �PGZV�DQF[ �RCTGPV�UGEVKQP

�EQWPVGT�C �UGRCTCVQT�� �VTCKNGT��

�UV[NG�RQKPV �PGZV�PQKPFGPV �NGCFGT�>RGTKQFEGPVGTGF

�UV[NG�TGH �PGZV�TGH �EQWPVGT��� �NGCFGT�= �VTCKNGT�?�

Page 205: Curso Practico en Java de Ingenieria Del Software Mit

Ejem

plo

de m

apa

de c

arac

tere

s (r

esum

en)

�EJCT�CTTQYFDNPG �HQPV�.WEKF0GY/CV#TT6 �KPFGZ���

�EJCT�CTTQYFDNUY �HQPV�.WEKF0GY/CV#TT6 �KPFGZ����

�EJCT�CTTQYFDNUG �HQPV�.WEKF0GY/CV#TT6 �KPFGZ����

�EJCT�CTTQYFDNNGHVPGI �HQPV�.WEKF0GY/CV#TT6 �KPFGZ����

Page 206: Curso Practico en Java de Ingenieria Del Software Mit

Ejem

plo

de s

alid

a de

dat

os e

n Ta

gger

"VKVNG�Ejemplo de Tagger

"CWVJQT�Daniel Jackson<\n>Clase practica de informatica del MIT

"UGEVKQP�1 Introduccion

"UWDUGEVKQP�1.a Aspectos interesantes

"DQF[�Estas son algunas de las cosas [1] que se pueden hacer:

"RQKPV�<\#183> Crear documentos con una <I>buena<I> presentacion.

"RQKPV�<\#183> Escribir formulas (<I>a<I> + <I>b<I>) = <I>c<I> y

utilizar simbolos como <f"LucidNewMatArrT"><\#101><f$>.

"UGEVKQP�2 Referencias

"TGH�[1] Jackson, Daniel. Tagging for Profit and Pleasure.

�����

Page 207: Curso Practico en Java de Ingenieria Del Software Mit

��

Ejem

plo

de h

oja

de e

stilo

en

Qua

rk

Page 208: Curso Practico en Java de Ingenieria Del Software Mit

��

Ejem

plo

de s

alid

a de

typ

eset

Page 209: Curso Practico en Java de Ingenieria Del Software Mit

��

Opc

ión

de d

iseñ

o 1:

sub

clas

e To

ken

Prob

lem

as:

rExc

eso

de su

bcla

ses

rLos

mod

os (p

. ej.

el m

odo

mat

h) q

ueda

n di

vidi

dos e

ntre

las c

lase

s

CDUVTCEV�ENCUU�6QMGP�]_

ENCUU�2CTCITCRJ%QOOCPF�GZVGPFU�6QMGP�]

XQKF�IGPGTCVG1WVRWV��]�_

_

ENCUU�#NRJCDGVKE5GSWGPEG�GZVGPFU�6QMGP�]

XQKF�IGPGTCVG1WVRWV��]�_

_

Page 210: Curso Practico en Java de Ingenieria Del Software Mit

��

Opc

ión

de d

iseñ

o 2:

sen

tenc

ia c

ase

Prob

lem

as:

rFra

gmen

to d

e có

digo

bas

tant

e ex

tens

orC

on m

ucha

s var

iabl

es g

loba

les e

inte

racc

ione

srta

mbi

én se

pod

ría u

tiliz

ar e

l pat

rón

Vis

itor p

ara

impl

emen

tarlo

ENCUU�6QMGP�]KPV�V[RG���_

ENCUU�6QMGP6[RG�]UVCVKE�KPV�HKPCN�2#4#%/&�������_

XQKF�IGPGTCVG1WVRWV�6QMGP�V�]

KH�V�V[RG����6QMGP6[RG�2#4#%/&�VJGP

GNUG�KH�V�V[RG����6QMGP6[RG�#.2*#5'3�VJGP

Page 211: Curso Practico en Java de Ingenieria Del Software Mit

��

Dis

eño

basa

do e

n ac

cion

es

rEl a

rchi

vo fu

ente

se c

onvi

erte

en

un fl

ujo

de T

oken

srC

ada

Toke

n es

con

sum

ido

por e

l mot

orrE

l mot

or e

jecu

ta to

das l

as a

ccio

nes r

egis

trada

s par

a el

tipo

de

Toke

nrL

as a

ccio

nes p

uede

n de

term

inar

la le

ctur

a y

escr

itura

de

arch

ivos

y

Mot

or

Tip

o 1

B�

B�

Tip

o 2

B�

B�

B�

Con

vers

orT

estig

os

regi

stra

r/elim

inar

acc

ione

s del

regi

stro

de te

stigo

s

Page 212: Curso Practico en Java de Ingenieria Del Software Mit

��

¿Qué

apa

rien

cia

tien

e el

cód

igo?

#EVKQP�RNCKPVGZV#EVKQP���PGY�#EVKQP��]

RWDNKE�XQKF�RGTHQTO�6QMGP�V�]

IGPGTCVQT�RNCKPVGZV�V�CTI�

_

_�

TGIKUVGT$[6[RG�RNCKPVGZV#EVKQP �6QMGP6[RG�#.2*#$'6+%�

TGIKUVGT$[6[RG�RNCKPVGZV#EVKQP �6QMGP6[RG�07/'4+%�

TGIKUVGT$[6[RG�RNCKPVGZV#EVKQP �6QMGP6[RG�9*+6'52#%'�

TGIKUVGT$[6[RG�PGY�#EVKQP��]

RWDNKE�XQKF�RGTHQTO�6QMGP�V�]

GTTQT5VTGCO�RTKPVNP������FQPG��

__

6QMGP6[RG�'0&1(564'#/�

Page 213: Curso Practico en Java de Ingenieria Del Software Mit

��

¿Por

qué

las

acci

ones

son

una

bue

na o

pció

n?

rCom

porta

mie

ntos

flex

ible

s y se

nsib

les a

l con

text

orS

in u

n fr

agm

ento

de

códi

go d

emas

iado

ext

enso

rPer

mite

n ac

tivar

y d

esac

tivar

fáci

lmen

te lo

s com

porta

mie

ntos

rPer

mite

n in

clui

r mot

ores

secu

ndar

ios:

p. e

j., p

ara

num

erar

cad

enas

Ejem

plo:

mod

o m

ath

rEl s

ímbo

lo $

inic

ia y

fina

liza

el 'm

odo

mat

h': p

asa

toda

s las

letra

s a c

ursiv

arI

mpl

emen

tado

com

o do

s acc

ione

s:Pa

ra e

l Tok

en $

:�EPMMBS

@BDU

JPO

Para

el T

oken

cad

ena

alfa

bétic

a:�JUB

MJDJ[F

@BDU

JPO

rEPMMBS

@BDU

JPO

Enca

psul

amie

nto

del m

odo

Reg

istro

/elim

inac

ión

del r

egis

tro d

e�JUB

MJDJ[F

@BDU

JPO

Page 214: Curso Practico en Java de Ingenieria Del Software Mit

��

Códi

go d

el m

odo

mat

h

TGIKUVGT$[6[RG�PGY�#EVKQP��]

DQQNGCP�OCVJ/QFG1P���HCNUG�

RWDNKE�XQKF�RGTHQTO�6QMGP�V�]

KH�OCVJ/QFG1P�]

OCVJ/QFG1P���HCNUG�

TGIKUVGT$[6[RG�CRQUVTQRJG#EVKQP 6QMGP6[RG�#2156412*'�

WPTGIKUVGT$[6[RG�RTKOG#EVKQP 6QMGP6[RG�#2156412*'�

WPTGIKUVGT$[6[RG�RWUJ+VCNKEU#EVKQP 6QMGP6[RG�#.2*#$'6+%�

WPTGIKUVGT$[6[RG�RQR+VCNKEU#EVKQP 6QMGP6[RG�#.2*#$'6+%�

_�GNUG�]

OCVJ/QFG1P���VTWG�

__ �6QMGP6[RG�&1..#4�

Page 215: Curso Practico en Java de Ingenieria Del Software Mit

��

Códi

go d

el m

otor

RTKXCVG�.KPMGF.KUV�=?�VCDNG�

� RWDNKE�XQKF�TGIKUVGT�#EVKQP�CEVKQP �KPV�V[RG�]

CEVKQPU=V[RG?�CFF�CEVKQP�

_

RWDNKE�XQKF�EQPUWOGAVQMGP�6QMGP�VQMGP�]

+VGTCVQT�K���VCDNG=VQMGP�V[RG?�KVGTCVQT��

YJKNG�K�JCU0GZV��]

#EVKQP�C���#EVKQP�K�PGZV��

C�RGTHQTO�VQMGP�

_

_

Page 216: Curso Practico en Java de Ingenieria Del Software Mit

��

¡Fal

lo!

Mot

orL

ista

Iter

ador

Acc

ión

cons

umo

J���M�JUFSBUPS�

B���J�OFYU

B�QFSGPSN

F�SFHJTUFSy

B���J�OFYU�A

rray

M���UBC

MF<U�UZQ

F>�

Page 217: Curso Practico en Java de Ingenieria Del Software Mit

��

Asp

ecto

del

dis

eño

1: o

bjet

os A

ctio

n

����

���

���

��

List

aAc

ción

��

����

�(A

nóni

ma)

��

�M

otor

������

����

Itera

dor

vist

as

��

� �

Cóm

o fu

ncio

na:

rLa

acci

ón se

elim

ina

por s

í mis

ma

de la

list

a de

acc

ione

s rI

nvoc

ando

al m

étod

o re

mov

e en

el i

tera

dor p

asad

o a

Act

ion.

perf

orm

Page 218: Curso Practico en Java de Ingenieria Del Software Mit

��

Códi

go d

el m

otor

y d

e lo

s ob

jeto

s A

ctio

nRWDNKE�XQKF�EQPUWOGAVQMGP�6QMGP�VQMGP�]

+VGTCVQT�K���VCDNG=VQMGP�V[RG?�KVGTCVQT��

YJKNG�K�JCU0GZV��]

#EVKQP�C���#EVKQP�K�PGZV��

C�RGTHQTO�VQMGP �K�

_

_

HKPCN�#EVKQP�RCTCITCRJ#EVKQP���PGY�#EVKQP��]

RWDNKE�XQKF�RGTHQTO�6QMGP�V �+VGTCVQT�KVGT�]

KH�V�V[RG����6QMGP6[RG�2#4#56;.'%1//#0&�]

IGPGTCVQT�PGY2CTC�EWTTGPV2CTC�UV[NG0COG�

5VTKPI�PWODGTU��PWODGTKPI�IGV0WODGTKPI5VTKPI����

� KVGT�TGOQXG��

__�

Page 219: Curso Practico en Java de Ingenieria Del Software Mit

��

Asp

ecto

del

dis

eño

2: r

efer

enci

as c

ruza

das

Cade

na d

eci

taci

ón

Párr

afo

Cade

na d

ere

fere

ncia

mar

ca

cita

s

etiq

ueta

num

erac

ión

� �

!

!

!!

Cóm

o fu

ncio

na:

rEl u

suar

io p

uede

mar

car c

ada

ítem

con

un

nom

bre

sim

bólic

ory

hac

er la

llam

ada

desd

e cu

alqu

ier o

tro lu

gar m

edia

nte

ese

nom

bre

rLa

cade

na d

e lla

mad

a es

una

cad

ena

de n

umer

ació

n o

una

etiq

ueta

Page 220: Curso Practico en Java de Ingenieria Del Software Mit

��

Asp

ecto

del

dis

eño

3: m

apas

de

prop

ieda

des

Las h

ojas

de

estil

o, lo

s map

as d

e ca

ract

eres

y lo

s arc

hivo

s de

refe

renc

ias c

ruza

das:

rTod

os u

tiliz

an u

na m

ism

a si

ntax

isrU

n ún

ico

Pars

er (P

rope

rtyPa

rser

)rU

na ú

nica

repr

esen

taci

ón in

tern

a (P

rope

rtyM

ap)

Page 221: Curso Practico en Java de Ingenieria Del Software Mit

��

Asp

ecto

del

dis

eño

4: n

umer

ació

n au

tom

átic

a

Estil

oN

umer

ado

Cont

ador

cont

ador

raíz

Padr

e �$

hijo

)

!!�

Cóm

o fu

ncio

na:

rLa

hoja

de

estil

o de

be p

ropo

rcio

nar ú

nica

men

te:

Una

pro

pied

ad p

adre

: que

com

pren

de e

l est

ilo e

n la

jera

rquí

a

Una

pro

pied

ad c

onta

dor:

si e

l cas

o es

tá n

umer

ado

y có

mo

rLa

clas

e N

umer

ació

n (N

umbe

ring

) man

tiene

el e

stad

oy

aum

enta

el m

apa

de p

ropi

edad

es c

on la

s pro

pied

ades

hijo

y ra

íz

Page 222: Curso Practico en Java de Ingenieria Del Software Mit

��

Asp

ecto

del

dis

eño

5: v

isió

n de

l con

junt

o de

est

ilos

%��

��

�����

���

��

����

���

���

��

���

��

��

��

����

���

�����

����

�����

��&

��

��

���

����

��

����

&��

����

���

��

������

����

��

����

&��

���

���

��

��

Cóm

o fu

ncio

na:

rTag

ger c

rea

un m

apa

de

prop

ieda

des (

Pro

pert

yMap

) vac

íorE

nvía

el m

apa

al m

otor

está

ndar

(Sta

ndar

dEng

ine)

rEnv

ía la

vis

ta d

e co

njun

to a

Sour

ceP

arse

r

rSou

rceP

arse

r ve

rific

a la

s cad

enas

del c

onju

nto

en b

usca

de

estil

os d

e pá

rraf

o rS

tand

ardE

ngin

e ac

tual

iza

el

map

a un

a ve

z ca

rgad

a la

hoj

a de

est

ilo

Page 223: Curso Practico en Java de Ingenieria Del Software Mit

��

Asp

ecto

del

dis

eño

6: e

num

erac

ione

s ty

pe-s

afe

(1)

// m

aner

a in

corr

ecta

RWDNKE�HKPCN�ENCUU�(QTOCV�]

RWDNKE�HKPCN�UVCVKE�KPV�41/#0�����

RWDNKE�HKPCN�UVCVKE�KPV�+6#.+%5�����

RWDNKE�HKPCN�UVCVKE�KPV�57$5%4+26�����

RWDNKE�HKPCN�UVCVKE�KPV�572'45%4+26�����

_ Prob

lem

a:rL

os fo

rmat

os se

dec

lara

n co

mo

int e

n el

cód

igo

clie

nte

rLos

form

atos

no

prev

isto

s (p.

ej.

-1) n

o se

iden

tific

an d

uran

te

la c

ompi

laci

ón

Page 224: Curso Practico en Java de Ingenieria Del Software Mit

��

Asp

ecto

del

dis

eño

6: e

num

erac

ione

s ty

pe-s

afe

(2)

RWDNKE�HKPCN�ENCUU�(QTOCV�]

RWDNKE�UVCVKE�(QTOCV�41/#0���PGY�(QTOCV��4QOCP��

RWDNKE�UVCVKE�(QTOCV�+6#.+%5���PGY�(QTOCV��+VCNKEU��

RWDNKE�UVCVKE�(QTOCV�$1.&���PGY�(QTOCV��$QNF��

RWDNKE�UVCVKE�(QTOCV�57$5%4+26���PGY�(QTOCV��5WDUETKRV��

RWDNKE�UVCVKE�(QTOCV�572'45%4+26��

PGY�(QTOCV��5WRGTUETKRV��

RTKXCVG�HKPCN�5VTKPI�PCOG�

RTKXCVG�(QTOCV�5VTKPI�PCOG�]VJKU�PCOG���PCOG�_

RWDNKE�5VTKPI�VQ5VTKPI��]TGVWTP�PCOG�_

_

Page 225: Curso Practico en Java de Ingenieria Del Software Mit

��

Proc

eso:

cóm

o co

nstr

uí T

agge

r

Prim

eros

pro

totip

os:

rEsc

ribí l

os sc

ripts

en

Perl

y ex

perim

enté

con

el f

orm

ato

de

Des

arro

llo e

n Ja

va:

rDed

iqué

alg

unos

día

s al d

iseñ

o: e

sque

ma

de M

O y

de

MD

DrD

esar

rollé

el c

ódig

o en

el l

engu

aje

rNo

se re

aliz

aron

pru

ebas

sist

emát

icas

; se

prob

ó só

lo d

uran

te su

Ree

stru

ctur

ació

n:rM

ejor

é la

est

ruct

ura

(p. e

j., e

num

erac

ione

s typ

e-sa

fe)

rCom

plet

é la

s esp

ecifi

caci

ones

par

a to

dos l

os m

étod

os p

úblic

osrE

scrib

í una

serie

de

prue

bas d

e re

gres

ión

(sim

ples

) par

a lo

grar

impo

rtaci

ón d

e Q

uark

utili

zaci

ón

prot

ecci

ón c

ontra

nue

vos e

rror

es (b

ugs)

Page 226: Curso Practico en Java de Ingenieria Del Software Mit

��

Conc

lusi

ones

Las n

eces

idad

es d

e ca

lidad

var

ían

segú

n el

sist

ema:

rEn

este

cas

o, si

rven

las p

rueb

as a

d ho

crS

ería

n ne

cesa

rias p

rueb

as si

stem

átic

as p

ara

el la

nzam

ient

o co

mer

cial

Leng

uaje

bas

ado

en a

ccio

nes:

rCas

i sie

mpr

e cl

aro

y po

tent

erA

ctua

lmen

te e

stoy

real

izan

do a

nális

is d

e m

odel

os d

e ob

jeto

s

Util

izac

ión

de p

atro

nes m

últip

les:

rMás

com

ún q

ue e

l JU

nit e

n es

te a

spec

torE

sfue

rzos

real

izad

os, p

rinci

palm

ente

, en

tipos

e id

iom

as d

efin

idos

para

máq

uina

s bas

adas

en

acci

ones

por e

l usu

ario

Page 227: Curso Practico en Java de Ingenieria Del Software Mit

Clase 19. Modelos conceptuales

La misma notación de modelado de objetos que hemos utilizado para describir la estructura de la pila en programas que se están ejecutando –es decir, qué objetos hay y cómo se hallan relacionados por campos– puede emplearse de un modo más abstracto para describir el estado espacial de un sistema o del entorno en el que éste opera. Denominaremos a estos modelos "modelos conceptuales", aunque el libro de texto se refiere a ellos como "modelos de datos". Ya hemos construido algunos de estos modelos en los ejemplos preparatorios del ejercicio 4, y también al describir mediante la notación de modelado de objetos la estructura del sistema de metro de Boston. La notación en sí es sumamente sencilla, y los modelos se interpretan fácilmente una vez dejemos de lado el enfoque orientado a la implementación y sustituyamos los objetos de Java por entidades reales, los campos por relaciones, etc. Tras esta clase, el estudiante estará en disposición de leer modelos conceptuales sin ningún tipo de problema. Escribirlos, en cambio, requiere una mayor práctica, ya que implica crear las abstracciones apropiadas, al igual que cuando se diseña la interfaz de un tipo de datos abstractos. Construir correctamente las abstracciones es una tarea difícil, aunque la dificultad no tiene que ver con el modelado de objetos en particular, sino con el hecho de que siempre resulta complicado captar la esencia de un problema y articularlo de un modo conciso. Una vez hayamos superado este obstáculo y construido un modelo conceptual, nos hallaremos ya encaminados hacia la solución del problema. Como se suele decir, describir un problema con exactitud es el primer paso para solucionarlo, y en el campo del desarrollo de software una descripción exacta supone haber recorrido más de la mitad del camino. No espere ser capaz de crear modelos conceptuales sin la práctica necesaria. Poner en práctica sus habilidades de modelado le resultará muy entretenido y a medida que las perfeccione descubrirá que se va convirtiendo en un diseñador más competente. Al ir ganando claridad en sus estructuras conceptuales, las estructuras de su código se harán a su vez más simples y más nítidas, y la codificación resultará más productiva. En la clase propiamente dicha trataremos de dar una idea de cómo los modelos se crean incrementalmente. En estas notas de clase, en cambio, los modelos se muestran en su forma final.

19.1 Átomos, conjuntos y relaciones

Las estructuras de los modelos se pueden construir a partir de conjuntos, relaciones y átomos. Un átomo es una partícula elemental que se caracteriza por ser: · indivisible: no se puede descomponer en partes más pequeñas;

Page 228: Curso Practico en Java de Ingenieria Del Software Mit

· invariable: sus propiedades no se alteran con el paso del tiempo; y · no interpretable: carece de propiedades internas, al contrario de lo que, por ejemplo, ocurre con los números. Aparte de estas partículas elementales, muy pocas cosas en el mundo físico tienen estructura atómica. Sin embargo, nada nos impide modelarlas de este modo; de hecho, el modelado que proponemos no incluye ninguna idea de composición. Así, para modelar una parte x formada por las partes y y z, consideraremos que tanto x como y y z son atómicas y representaremos las restricciones mediante una relación explícita entre ellas. Un conjunto es una suma de átomos no sujeta a las nociones de orden o cuenta de repetición. Por su parte, una relación es una estructura que crea correspondencias entre los átomos. Desde el punto de vista matemático, consiste en un conjunto de pares, cada uno de ellos formado por dos átomos y dispuestos en un orden determinado. Podemos imaginar una relación como una tabla de dos columnas en la que cada entrada es un átomo. El orden en el que se hallan dispuestas las columnas es importante, mientras que el orden de las filas no lo es. Cada fila debe tener una entrada en cada columna. Resulta conveniente definir algunos de los operadores de conjuntos y relaciones. En principio, nos servirán para explicar los modelos gráficos, aunque también pueden utilizarse para escribir reglas más expresivas. Dados dos conjuntos s y t, podemos tomar su suma (s+t), su intersección (s&t) o su diferencia (s-t). Así, escribiremos no s para decir que una expresión indica un conjunto vacío, y some s si lo que indica es un conjunto no vacío. Si lo que deseamos decir es que cada miembro de s es a la vez miembro de t, escribiremos s in t; y s = t cuando cada elemento de s sea un elemento de t y viceversa. Dados un conjunto s y una relación r, escribiremos s.r para la imagen de s vinculada a la relación r: el conjunto de elementos al que r asocia los elementos de s, lo que podemos definir formalmente como:

s.r = {y | some x: s | (x,y) in r}

Dada una relación r, escribiremos ~r para indicar el intercambio de r: la relación de imagen invertida, definida como:

~r = {(y,x) | (x,y) in r}

Por último, escribiremos +r para indicar el cierre transitivo de r: se trata de la relación que asocia x a y, cuando existe alguna secuencia finita de átomos z1, z2, …, zn tal que

(x,z1) in r (z1,z2) in r (z2,z3) in r

(zn,y) in r

y *r para indicar el cierre transitivo reflexivo de r, un cierre básicamente igual que el transitivo, pero que además relaciona cada átomo consigo mismo. El cierre transitivo

Page 229: Curso Practico en Java de Ingenieria Del Software Mit

tomaría la imagen a partir de una, dos, tres o más aplicaciones de la relación, mientras que el cierre transitivo reflexivo no incluiría ninguna aplicación.

Veamos algunos ejemplos. Supongamos que tenemos un conjunto de gente llamado Person que existe actualmente o existió en un momento dado, los conjuntos Man y Woman de personas de sexo masculino o femenino, la relación parents que asocia a una persona con sus padres, y la relación spouse que asocia a una persona con su cónyuge.

Interprete cada una de las siguientes afirmaciones. ¿Cuáles son válidas en el mundo real?

no (Man & Woman) Man + Woman = Person all p: Person | some p.spouse => p.spouse.spouse = p all p: Person | some p.spouse all p: Person | some p.parents no p: Person | p.spouse = p all p: Person | p.sisters = {q: Woman | p.parents = q.parents} all p: Person | p.siblings = p.parents.~parents

Hasta aquí, hemos mostrado observaciones elementales relativas al mundo real y a definiciones de términos. Las que planteamos a continuación, en cambio, se adentran en terrenos más problemáticos:

no p: Person | some (p.parents & p.spouse.parents) Man.spouse in Woman some adam: Person | all p: Person | adam in p.*parents all p: Man | no q, r: p.spouse | q != r

Suponiendo que el estudiante es capaz de comprender la notación lógica básica, hemos introducido la definición de hermana (sister)

¿Cómo se escribirían las siguientes frases según esta notación?:

Todo el mundo tiene una madre Nadie tiene dos madres Los primos son personas que tienen un abuelo en común

La primera afirmación ilustra un punto interesante e importante: es muy cómodo dar por supuesto que el significado de un término es siempre el más obvio, pero resulta muy peligroso cuando hablamos de desarrollo de software. En este campo, la ambigüedad y la indefinición en el significado de los términos son una fuente constante de problemas. Si los desarrolladores entienden los requisitos de maneras distintas, acabarán implementando módulos incompatibles entre sí, o que no sirvan para satisfacer las necesidades del cliente.

Page 230: Curso Practico en Java de Ingenieria Del Software Mit

Por lo tanto, es necesario tener cuidado a la hora de expresar lo que significa cada conjunto y cada relación. En este caso concreto, se debe precisar el significado del término mother. ¿Se trata de la madre legal o de la madre biológica? ¿O el término se refiere a algo distinto? Al construir un modelo conceptual en el que se emplean términos que no se hallan definidos en el contexto en el que estamos trabajando, se debe elaborar un glosario. Así, en este ejemplo, escribiríamos en el glosario:

mother: (p,q) in mother significa que la persona q es la madre biológica de la persona p.

19.2 Notación gráfica No es necesario volver a explicar detalladamente la notación gráfica, que ya vimos en la clase de modelado de objetos. En esta clase nos limitaremos a reinterpretar la notación de un modo más abstracto. Fijémonos en el modelo de objeto correspondiente al árbol de familia. Cada cuadro indica un conjunto de átomos, no un conjunto de objetos de un programa de Java ni de una clase. Las flechas con la punta abierta indican la relación entre un conjunto y otro. Se trata de una asociación abstracta, no de un campo ni de una variable de instancia. Obviamente, las consecuencias semánticas son distintas dependiendo de cuál sea el sentido de la flecha: es muy diferente decir que p es el padre de q o decir lo contrario. Sin embargo, en cualquier relación podemos utilizar por igual una relación diferente que sea su inversa: hijos (children) en vez de padres (parents), por ejemplo. No existe una noción de navegabilidad, ni tampoco la idea de que una relación pertenezca a un conjunto del mismo modo que una variable de instancia pertenece a una clase.

Page 231: Curso Practico en Java de Ingenieria Del Software Mit

La flecha de trazo grueso con la punta cerrada indica un subconjunto. Dos conjuntos que comparten una flecha son disjuntos. Podemos rellenar la punta de la flecha para indicar que los subconjuntos son también exhaustivos; es decir, que cada elemento que forma el superconjunto forma parte de al menos uno de los subconjuntos. En el presente ejemplo, hemos expresado que cada persona (Person) es un hombre (Man) o una mujer (Woman).

Los conjuntos que no tienen superconjuntos se conocen como dominios (domains), y se supone que son disjuntos. Por ejemplo, no hay átomos que sean a la vez una persona y un nombre. No insistiremos en esta clase sobre los indicadores de multiplicidad y mutabilidad, ya que se hallan ampliamente explicados en el libro de texto del curso.

19.3 Relaciones ternarias

Ocurre en ocasiones que deseamos describir relaciones que comprendan no dos conjuntos, sino tres. Supongamos, por ejemplo, que deseamos registrar el hecho de que una persona recibe un salario por trabajar en una empresa. Como las personas pueden trabajar para varias empresas y obtener un salario distinto en cada una de ellas, no nos basta con asociar el salario a la persona. La forma más sencilla de resolver esta cuestión suele ser crear un nuevo dominio. En este caso, podríamos introducir Job (trabajo) y diseñar un modelo de objetos que muestre la relación jobs entre Person y Job, una relación salary (salario) que vaya de Job a Salary, y una relación company (empresa) desde Job a Company. Esta solución funciona cuando el dominio introducido se corresponde a un conjunto natural de átomos; se trata de una noción ya comprendida en el dominio del problema. Otra posibilidad consiste en introducir una relación indexada. Si marcamos la flecha que va

Page 232: Curso Practico en Java de Ingenieria Del Software Mit

de A a B con la etiqueta r[Index], estamos diciendo que existe una relación r[i] desde A a B para cada átomo i del conjunto Index. Así, por ejemplo, para modelar nombres en un sistema de archivos podemos tener una relación indexada obj[Dir] desde Name hasta FileSystemObject (FSO), ya que conceptualmente existe una relación de nombres separada para cada directorio del sistema de archivos.

Por último, podemos diseñar el modelo de objetos diciendo que es una proyección: muestra las relaciones correspondientes a un átomo determinado en un dominio. Por ejemplo, al diseñar un procesador de texto, habría una relación ternaria format (formato) que asocie un StyleName (nombre de estilo) a un Format en una Stylesheet (hoja de estilo) determinada. Nos puede interesar diseñar un modelo que únicamente tenga en cuenta una sola hoja de estilo, de modo que la relación pase a ser binaria.

19.4 Tres ejemplos

Veamos a continuación tres ejemplos de modelos conceptuales, todos ellos sencillos pero no triviales. Confiamos en que sirvan para demostrar lo útil que resulta la creación de modelos, por muy pequeños que sean. Cuando esté usted trabajando en su proyecto de fin de curso, o en posteriores desarrollos que desee realizar, deberá ser capaz de crear modelos conceptuales según los vaya necesitando. No es preciso que tenga un único modelo comprensivo; resulta más conveniente diseñar varios modelos de pequeño tamaño y a continuación decidir cuáles sería necesario integrar. Hasta que adquiera la suficiente experiencia en la creación de modelos conceptuales, pensará probablemente que una noción conceptual es obvia y no descubrirá que no lo es hasta que haya profundizado en el código. Por ello le interesa experimentar con otros modelos que crea que le van a ser útiles al principio y, en caso de toparse con dificultades a la hora de codificar, volver atrás y diseñar algunos modelos.

19.4.1 Tipos de Java Nuestro primer modelo muestra las relaciones entre los objetos y las variables y sus tipos en Java. Entender este modelo es esencial para poder comprender el lanzamiento dinámico y las conversiones forzosas (casts) de tipos. Existen tres dominios:

· Object: conjuntos de objetos de instancia existentes en la pila en tiempo de ejecución. · Var: conjunto de variables que reúne objetos según su valor y que comprende variables de instancia, argumentos de métodos, variables estáticas y variables locales. Type: conjunto de tipos de objetos definidos por clases e interfaces.

Page 233: Curso Practico en Java de Ingenieria Del Software Mit

Dejaremos de lado referencias nulas y tipos primitivos como int. El dominio Type se halla clasificado por clases, clases abstractas e interfaces. El conjunto ObjectClass es de un solo objeto (singleton) y se halla formado únicamente por la clase llamada Object.

Hay cuatro relaciones:

· holds: asocia una variable al objeto con el que mantiene una referencia; · otype: asocia un objeto a su tipo; el tipo adquirido por el hecho de haber sido creado por el

constructor de una clase.; · vtype: asocia una variable a su tipo declarado; · subs: asocia un tipo a sus subtipos inmediatos. Los subtipos de una clase son las clases

que la extienden, mientras que los subtipos de una interfaz son las interfaces que la extienden y las clases que la implementan.

Las siguientes son algunas restricciones que no se pueden expresar gráficamente:

En primer lugar, la propiedad de seguridad de los tipos esenciales: el tipo de un objeto mantenido en una variable se halla en el conjunto de subtipos directos o indirectos del tipo de la variable.:

all v: Var | v.holds.otype in v.vtype.*sub

Algunas propiedades de la jerarquía de tipos en Java: que cada tipo es un subtipo directo o indirecto de la clase Object; que una clase puede ser el subtipo de otra clase como máximo; y que ningún tipo puede directa o indirectamente ser subtipo de sí mismo:

Page 234: Curso Practico en Java de Ingenieria Del Software Mit

Type in ObjectClass.*sub

all c: Class | no c1, c2: Class | c1 != c2 && c in c1.sub && c in c2.sub no t: Type | t in t.+sub

• Que las interfaces y las clases abstractas no pueden ser instanciadas: no o: Object | o.otype in (AbstractClass + Interface)

19.4.2 Meta-modelo

Nuestro siguiente modelo es un meta-modelo de notación de modelado gráfico de objetos. Este meta-modelo deberá ser auto explicativo. Una restricción será, por ejemplo:

• Un conjunto box no puede tener un subconjunto arrow para sí mismo:

no a: SubsetArrow | a.parent in a.children

Existen muy pocas restricciones aparte de las que exigen que la jerarquía de subconjuntos sea un árbol; lo que dota de flexibilidad a la notación. Las restricciones suelen ser útiles a la hora de definir nuevos conjuntos y nuevas relaciones. Supongamos, por ejemplo, que queremos clasificar aquellos arcos de relación que representen relaciones homogéneas: relaciones que vinculan objetos en un único dominio. ¿Podríamos definir esta noción como un nuevo conjunto? (Pista: posiblemente será más sencillo comenzar definiendo una

Page 235: Curso Practico en Java de Ingenieria Del Software Mit

relación super de SetBox a SetBox, a continuación un conjunto DomainBox, y posteriormente definir el conjunto HomoArrow en función de ello).

19.4.3 Numeración

Nuestro tercer modelo describe parte de la aplicación Tagger, sobre la que trató la clase anterior. Esta aplicación muestra la información que se almacena en la stylesheet para numerar párrafos, pero no nos dice cómo funciona la asignación de numeración a los mismos párrafos. Esta función puede también añadirse, aunque ello resulta un tanto más complicado. Tenemos los siguientes dominios:

· Style: conjunto de nombres de estilo de párrafo; · CounterType: conjunto de tipos de contadores (p.ej., arábigo, alfabético, latino). · CounterValue: conjunto de valores que puede tener un contador (como 1, 2, 3; ó a, b, c). Y tenemos las siguientes relaciones:

type: asocia un nombre de estilo con su tipo de contador (CounterType) declarado; ·initial: asocia un nombre de estilo con su valor de contador (CounterValue) inicial declarado; values: asocia un nombre de estilo con su valor de contador (CounterValue) inicial declarado; follows: asocia un valor de contador (CounterValue) con el valor que le sigue; parent: asocia un nombre de estilo con su estilo padre; section, por ejemplo, podría ser el padre de subsection. Téngase en cuenta que un estilo no puede tener más de un padre. Sí se permite que dos estilos tengan un mismo padre, ya que ello hace posible numerar de forma independiente, por ejemplo, cifras y subsecciones dentro de una sección. Veamos algunas restricciones:

• El valor inicial de un contador de estilo debe hallarse en el conjunto dado por su

Page 236: Curso Practico en Java de Ingenieria Del Software Mit

CounterType. En la aplicación Tagger, esta regla viene reforzada por la sintaxis: la declaración (p.ej., <counter:a> determina ambos al mismo tiempo).

all s: Style | s.initial in s.type.values

• Un estilo no puede ser su propio padre

no s: Style | s = s.parent

Y veamos también algunas definiciones:

• Los hijos de un estilo son aquellos estilos de los que éste es padre:

all s: Style | s.children = s.~parent

• La raíz de un estilo es el antecesor que no tiene padre:

all s: Style | s.root = {r:s.*parent | no r.parent} 19.5 Conclusión El libro de texto del curso ofrece una descripción más detallada de la notación gráfica. También resulta útil el apartado Material de Clase del curso 6.170 del año pasado, hay en él un archivo PDF con una lista de contenidos que puede consultarse en la Web:

http://sdg.lcs.mit.edu/~dnj/publications.html#fall00-lectures

En esta dirección encontrará una clase sobre modelado conceptual con una mayor variedad de casos prácticos. La notación textual se conoce con el nombre de Alloy y ha sido diseñada por el Software Design Group del MIT. Hemos creado también un analizador automático para Alloy capaz de realizar simulaciones y comprobaciones. Si desea saber más sobre él, diríjase a la página de publicaciones mencionada: en ella encontrará material que describe el lenguaje utilizado, ilustrándolo con ejemplos prácticos.

Page 237: Curso Practico en Java de Ingenieria Del Software Mit

Clase 20. Estrategia de diseño La presente clase reúne varias de las ideas ya vistas en clases anteriores: modelos de objeto de problemas y código, diagramas de dependencia de módulos y patrones de diseño. El objetivo que en ella se persigue es ofrecer algunos consejos de carácter general sobre cómo enfrentarse al proceso de diseño de software. 20.1 Descripción general del proceso y pruebas Los principales pasos del proceso de desarrollo son los siguientes:

• Análisis del problema: da como resultado un modelo de objeto y una lista de operaciones.

• Diseño: da como resultado un modelo de objeto de código, un diagrama de dependencia de módulos y especificaciones de módulos.

• Implementación: da como resultado un código ejecutable.

Lo ideal sería que las pruebas se llevaran a cabo a medida que se realiza el proceso de desarrollo, de modo que los errores aparezcan lo antes posible. En un conocido estudio sobre proyectos desarrollados por TRW e IBM, Barry Boehm llegó a la conclusión de que el coste de corregir un error puede llegar a multiplicarse hasta por 1.000 cuando se detecta tardíamente. Hemos empleado el término "pruebas" exclusivamente para describir la evaluación de códigos, pero se pueden aplicar técnicas parecidas a descripciones de problemas y diseños cuando se han registrado en una notación que tenga asociada una semántica. (En mi grupo de trabajo hemos desarrollado una técnica de análisis para modelos de objetos). En este curso, el estudiante deberá basar su trabajo en la meticulosidad de las revisiones y en el uso de escenarios manuales para evaluar las descripciones y los diseños del problema.

Por lo que a probar las implementaciones se refiere, el estudiante debe marcarse como objetivo que las pruebas se realicen tan pronto como sea posible. La programación extrema (XP), una metodología de desarrollo muy extendida en la actualidad, aboga por escribir las pruebas antes incluso de haber escrito el código al que se van a aplicar éstas. Se trata de una idea muy interesante, en primer lugar porque significa que es menos probable que una selección de pruebas quede expuesta a sufrir los mismos errores conceptuales que esas pruebas tratan precisamente de detectar. Además, estimula al usuario a pensar en las especificaciones por adelantado. Es un enfoque ambicioso, aunque no siempre factible. En vez de probar el código de una manera ad hoc, es más conveniente crear una base sistemática de pruebas que no requieran la interacción del usuario para su ejecución y validación. Este sistema reporta numerosos beneficios: por ejemplo, permite, cuando se introducen cambios en un código, detectar rápidamente los nuevos errores que se han producido al volver a ejecutar estas "pruebas de regresión". Es aconsejable hacer un uso liberal de las certificaciones en tiempo de ejecución y comprobar las constantes de representación.

Page 238: Curso Practico en Java de Ingenieria Del Software Mit

20.2 Análisis del problema El resultado principal del análisis de un problema es un modelo de objeto que describe las entidades fundamentales del mismo y sus relaciones con otro problema. (El libro de texto del curso utiliza el término "modelo de datos" para referirse a esto). Conviene escribir descripciones breves para cada uno de los conjuntos y cada una de las relaciones del modelo de objeto, explicando lo que significan. Aunque nos parezcan evidentes en el momento de escribirlas, es fácil olvidar más tarde el significado de algún término. Además, muchas veces una descripción que nos parecía clara resulta no serlo tanto cuando la vemos por escrito. Por ejemplo, en mi grupo de trabajo estamos diseñando un nuevo componente de control de tráfico aéreo, y hemos descubierto que en nuestro modelo de objeto el término Flight resulta bastante confuso y que es importante describirlo con claridad. También resulta útil escribir una lista de las operaciones primarias que el sistema proporciona. Con ello se aprende a controlar la funcionalidad global y se puede comprobar que el modelo de objeto es capaz de soportar las operaciones. Por ejemplo, un programa pensado para llevar un seguimiento de precios de valores en Bolsa puede incluir operaciones para crear y suprimir carteras, añadir acciones a carteras, actualizar el precio de un valor, etc. 20.3 Propiedades del diseño La fase de diseño produce como resultado principal un modelo de objeto de código que muestra la forma en la que se implementa el estado del sistema, y un diagrama de dependencia de módulos que representa la división del sistema en módulos y el modo en que éstos se relacionan entre sí. En el caso de módulos que presenten complicaciones, resulta también conveniente disponer de un esquema con las especificaciones del módulo antes de comenzar la codificación. ¿En qué se basa un buen diseño? Obviamente, no hay un modo sencillo y objetivo de decidir si un diseño es mejor o peor que otro, pero sí que hay ciertas propiedades clave que permiten evaluar su calidad. Lo ideal sería un diseño que funcionara bien en todos los aspectos, pero en la práctica normalmente es necesario sacrificar algún aspecto a cambio de otro.

Estas propiedades clave son: • Extensibilidad. El diseño debe ser capaz de soportar nuevas funciones. Aunque sea

perfecto en todos los demás aspectos, un sistema que no muestre disposición a integrar el más ligero cambio o perfeccionamiento resulta inservible. Quizás no haya necesidad de añadir nuevas funciones, pero siempre es posible que se produzcan alteraciones en el dominio del problema que exijan introducir cambios en el programa.

• Fiabilidad. El sistema debe tener un comportamiento fiable, lo que no significa solamente que no se bloquee ni corrompa los datos; debe además realizar todas sus funciones correctamente y de la forma prevista por el usuario. (Lo que, por cierto, quiere decir que tampoco basta con que el sistema satisfaga una especificación confusa; debe satisfacer una que el usuario comprenda fácilmente, de forma que

Page 239: Curso Practico en Java de Ingenieria Del Software Mit

éste pueda predecir su comportamiento). La disponibilidad es una característica importante si el sistema es distribuido, mientras que en los sistemas en tiempo real tiene más importancia el tiempo: no tanto que el sistema sea rápido, sino que complete las tareas en el tiempo previsto. La forma de contemplar la fiabilidad varía mucho de un sistema a otro. Así, la falta de precisión a la hora de presentar imágenes es menos grave en un navegador que en un programa de edición electrónica. Los conmutadores telefónicos, por su parte, deben cumplir estándares de disponibilidad extraordinariamente altos, si bien ello ocasiona de vez en cuando errores de desvío de llamadas. Los pequeños retrasos quizás no importen mucho cuando se trata de un cliente de correo electrónico, pero son inaceptables en el caso del controlador de un reactor nuclear.

• Eficiencia. El consumo de recursos por parte del sistema debe ser racional, lo que una vez más depende del contexto. Una aplicación que se ejecuta en un teléfono móvil no puede asumir la misma disponibilidad de memoria que la que se ejecuta en un ordenador de consola. Los recursos más específicos son el tiempo y el espacio que consume el programa que se ejecuta. Pero no hay que olvidar que, como ha demostrado Microsoft, el tiempo empleado en el desarrollo del programa puede tener idéntica importancia, al igual que otro recurso que no se debe pasar por alto: el dinero. Un diseño que se implemente de modo económico puede ser preferible a otro que funcione mejor conforme a otros parámetros pero que resulte más caro.

20.4 Estrategia: visión general ¿Cómo se obtienen estas propiedades?

20.4.1 Extensibilidad • Suficiencia del modelo de objeto. El modelo de objeto del problema debe ser capaz

de describir éste de un modo suficientemente fiel. Uno de los obstáculos habituales a la hora de extender un sistema es la falta de espacio para añadir una nueva función, debido a que sus nociones no se hallan expresadas en el código. Microsoft Word nos muestra un ejemplo de este tipo de problemas. Word se diseñó asumiendo que los párrafos eran la noción clave de la estructura de los documentos, sin que se incluyera la noción de flujos de texto (los espacios físicos del documento a través de los que se hilvana el texto) ni ningún tipo de estructura jerárquica. A consecuencia de ello, Word no admite fácilmente la división en secciones y no es capaz de ubicar figuras. Es importante tener mucho cuidado de no optimizar el modelo de objeto del problema y eliminar subestructuras que no parezcan necesarias a primera vista. No se debe introducir una abstracción para sustituir nociones más concretas a menos que se esté totalmente seguro de que se halla bien fundada. Como se suele decir, toda generalizaciones suele conllevar errores.

• Localización y desacoplamiento. Aunque el código consiga reunir suficientes nociones que permitan la adición de nuevas funcionalidades, puede resultar complicado realizar el cambio que se desea introducir sin alterar el código en todo el sistema. Para evitar esto, el diseño debe proporcionar localización: cuestiones distintas deben estar separadas, en la medida de lo posible, en distintas regiones

Page 240: Curso Practico en Java de Ingenieria Del Software Mit

del código. Asimismo, los módulos deben hallarse desacoplados todo lo posible unos de otros, de modo que un cambio no provoque alteraciones en cascada. Ya hemos visto ejemplos de desacoplamiento en la clase sobre espacios de nombres y, más recientemente, en las clases sobre patrones de diseño (por ejemplo, al hablar de Observer). Estas propiedades se ven con mayor claridad en el diagrama de dependencia de módulos, razón por la que construimos éste. Las especificaciones del módulo son también importantes para obtener localización; en este sentido, una especificación debe ser coherente, con una colección de comportamientos claramente definida (sin características ad hoc especiales) y una división terminante entre los métodos, de modo que éstos sean ampliamente independientes unos de otros.

20.4.2 Fiabilidad

• Esmero en el modelado. No es fácil dotar de fiabilidad a un sistema ya existente. La clave para lograr que un software sea fiable radica en desarrollarlo con el mayor cuidado, manteniendo ese esmero durante todo el proceso de modelado. Los problemas más graves en sistemas críticos no provienen de errores de código, sino de fallos en el análisis del problema; normalmente porque el analista no ha tenido en cuenta alguna propiedad del entorno en el que el sistema se halla insertado. Un ejemplo de ello es el fallo mecánico del Airbus en el aeropuerto de Varsovia.

• Revisión, análisis y prueba. Por mucho cuidado que se ponga, es inevitable cometer errores. Por ello, en todo proceso de desarrollo es preciso decidir por adelantado cómo se van a solucionar éstos. En la práctica, uno de los métodos más eficaces desde el punto de vista del coste a la hora de detectar errores de software, sea cual sea el modelo, la especificación o el código utilizados, es el de la revisión por pares. Hasta ahora, usted solamente habrá podido explorar este método con el profesor auxiliar y en las clases de laboratorio; en el proyecto de final de curso le conviene aprovechar la oportunidad de trabajar en equipo para analizar el trabajo de sus compañeros. De esta forma, tanto usted como ellos ahorrarán tiempo a largo plazo.

Análisis y pruebas más específicos permiten hallar aquellos errores que hayan pasado inadvertidos en el análisis de pares. Un análisis útil y fácil de realizar consiste simplemente en comprobar la coherencia de los modelos. ¿Soporta el modelo de objeto del código todos los estados del modelo de objeto del problema? ¿Se combinan adecuadamente las multiplicidades y mutabilidades? ¿Tiene en cuenta el diagrama de dependencia de módulos todas las restricciones del modelo de objeto? Otra posibilidad es comprobar el código con los modelos. La herramienta Womble, que se puede descargar desde el sitio http://sdg.lcs.mit.edu, construye automáticamente modelos de objetos a partir de Codi-bait (Byte-code). Hemos descubierto numerosos errores en nuestro código examinando modelos extraídos y comparándolos con los modelos planeados. Es conveniente, por tanto, que usted compruebe las propiedades esenciales de su modelo de objeto preguntándose si está seguro de que mantiene las propiedades. Suponga, por ejemplo, que su modelo dice que un vector nunca es compartido por dos objetos de cuenta bancaria. En tal caso, usted debería ser capaz de explicar por qué el código garantiza esta afirmación. Siempre que su modelo de objeto contenga una restricción que no se haya podido expresar gráficamente es especialmente aconsejable verificarla, ya que

Page 241: Curso Practico en Java de Ingenieria Del Software Mit

es probable que comprenda relaciones que sobrepasen los límites del objeto 20.4.3 Eficiencia

• Modelo de objeto. La elección del modelo de objeto del código es esencial, ya que una vez elegido resulta muy difícil de cambiar. Por ello es conveniente considerar los objetivos de rendimiento crítico al comenzar el diseño. Más adelante veremos algunos ejemplos de transformaciones que se pueden aplicar al modelo de objeto para mejorar su eficiencia.

• Evitar el sesgo. Al desarrollar el modelo de objeto del problema deben dejarse de lado las cuestiones relativas a la implementación. Cuando un modelo de problema contiene detalles sobre su implementación se dice que está sesgado, ya que favorece un tipo de implementación en perjuicio de otro. Ello supone reducir prematuramente el espacio a posibles implementaciones, entre las que podría hallarse la más eficiente.

• Optimización. La palabra "optimización" es engañosa: invariablemente significa una mejora del rendimiento, pero en detrimento de otras cualidades (como la nitidez de la estructura). Y si no se tiene cuidado con la optimización se corre el riesgo de que el sistema acabe siendo peor en todos los aspectos. Antes de introducir un cambio para mejorar el rendimiento, asegúrese de que tiene suficientes pruebas de que las alteraciones tendrán probablemente un efecto muy positivo. En general es aconsejable resistir la tentación de optimizar y concentrarse en lograr que el diseño sea sencillo y claro. En cualquier caso, los diseños que cumplen estas premisas suelen ser los que muestran mayor eficiencia y, en caso de que no la muestren, siempre son los más fáciles de modificar.

• Elección de representaciones. En vez de perder el tiempo en lograr pequeñas mejoras del rendimiento, es mejor centrarse en las clases de mejora positiva que se pueden obtener eligiendo una representación diferente para un tipo abstracto, por ejemplo, capaz de cambiar una operación de tiempo lineal a tiempo constante. Muchos de ustedes lo habrán podido comprobar en su proyecto de MapQuick: cuando se elige una representación para grafos que requiere un tiempo proporcional al tamaño de todo el grafo para obtener vecinos de un nodo, la búsqueda resulta totalmente impracticable. Asimismo, no se debe olvidar que compartir objetos puede tener efectos positivos, por lo que hay que considerar la posibilidad de utilizar tipos invariables y hacer que los objetos compartan una estructura. Por ejemplo, en MapQuick, Route es un tipo invariable; si se implementa compartiendo estructura, cada extensión de la ruta por un nodo durante la búsqueda exige solamente situar un único nodo en vez de una copia entera de la ruta.

Ante todo, recuerde que lo más importante es la sencillez. Piense en lo fácil que resulta terminar embrollado en una masa de complejidades, incapaz de alcanzar ninguna de estas propiedades. Lo más sensato es diseñar y construir primero un sistema mínimo, lo más sencillo posible, y sólo entonces comenzar a añadir recursos. 20.5 Transformaciones del modelo de objeto En modelos de objetos de códigos y problemas, hemos visto dos usos distintos de la misma

Page 242: Curso Practico en Java de Ingenieria Del Software Mit

notación. ¿Cómo puede un modelo de objeto describir un problema a la vez que describe una implementación? Para responder a esta pregunta resulta útil pensar en la interpretación de un modelo de objeto como un proceso en dos fases. En la primera, se interpreta el modelo en función de conjuntos y relaciones abstractas. En la segunda fase, se asocian estos conjuntos y relaciones, bien a las entidades y relaciones del problema, o bien a los objetos y campos de la implementación. Supongamos, por ejemplo, que tenemos un modelo de objeto con una relación employs (contrata) que asocia Company (empresa) a Employee (empleado).

Matemáticamente, vemos esto como una expresión de dos conjuntos y de una relación entre ambos. La restricción de multiplicidad nos dice que cada employee se asocia, conforme a la relación employs, a una company como máximo. A la hora de interpretarlo como un modelo de objeto de problema, contemplaremos el conjunto Company como un conjunto de empresas del mundo real (real world), y Employee como un conjunto de personas que son contratadas por las empresas. La relación employs relaciona c con e cuando la compañía c contrata a la persona e. Si lo interpretamos como un modelo de objeto de código, en cambio, contemplaremos el conjunto Company como un conjunto de objetos situados en una pila (heap) de la clase Company, y Employee como un conjunto de objetos situados en una pila de la clase Employee. Aquí, la relación employs pasa a ser un campo de especificación que asocia c y e cuando el objeto c mantiene una referencia a una colección (oculto en la representación de Company) que contenga la referencia e. Nuestra estrategia consiste en partir de un modelo de objeto de problema y transformarlo en uno de código. Por lo general, uno y otro serán considerablemente distintos, dado que un modelo que proporciona una descripción clara del problema no suele ofrecer una buena implementación.

Page 243: Curso Practico en Java de Ingenieria Del Software Mit

¿Cómo se obtiene esta implementación? Un método de trabajo bastante apropiado consiste en realizar una sesión de brainstorming y, a partir de ella, jugar con diferentes fragmentos de modelos de código hasta que encajen. Es necesario comprobar que el modelo de objeto de código se corresponde fielmente al modelo de objeto del problema. Debe ser capaz de representar al menos toda la información sobre los estados del modelo de problema, de forma que sea posible, por ejemplo, añadir una relación, pero que no sea posible eliminarla. Otra forma de llevar a cabo la transformación es mediante la aplicación sistemática de una serie de pequeñas transformaciones. Cada una de ellas se elige de entre un repertorio de transformaciones que preservan el contenido de los datos del modelo. De esta forma, como cada paso mantiene el modelo intacto, toda la serie se mantendrá también invariable. Hasta el momento, nadie ha propuesto un repertorio completo de tales transformaciones (lo que representa un problema de investigación), pero sí que hay varias que se pueden identificar como las más útiles. Antes de seguir adelante, veamos un ejemplo. 20.6 Ejemplo de Folio Tracker Supongamos que queremos diseñar un programa para realizar el seguimiento de una cartera de valores. El modelo de objeto nos da la descripción de los elementos del problema. Folio es el conjunto de carteras, cada una de ellas con un Name (nombre), que contiene un conjunto de posiciones Pos. Cada posición corresponde a un Stock (valor) concreto, del que se mantiene algún número. Un stock puede tener un valor (cuando se haya obtenido recientemente una cotización), y tiene asignado un símbolo de registro de cotización que permanece invariable. Estos símbolos identifican únicamente a los valores. Se puede situar un observador (Watch) en una cartera, lo que hace que el sistema muestre la información relativa a la cartera cuando se produzcan determinados cambios en ésta.

Page 244: Curso Practico en Java de Ingenieria Del Software Mit

20.7 Catalogo de transformaciones 20.7.1 Introducción de una generalización Si A y B son conjuntos con relaciones p y q, de la misma multiplicidad y mutabilidad, al conjunto C, podemos introducir una generalización AB y sustituir p y q por una única relación pq de AB a C. La relación pq puede no tener la misma multiplicidad fuente que p y q.

20.7.2 Inserción de una colección Si una relación r de A a B tiene una multiplicidad objetivo que permite más de un elemento, podemos interponer una colección, como un vector o un conjunto, entre A y B, y sustituir r por una relación a dos relaciones, una desde A a la colección y otra desde la colección a B.

Page 245: Curso Practico en Java de Ingenieria Del Software Mit

En nuestro ejemplo de Folio Tracker, podríamos sustituir/interponer un vector en la relación posns entre Folio y Pos. Obsérvense las marcas de mutabilidad; la colección es generalmente construida y reorganizada con su contenedor.

20.7.3 Inversión de una relación Dado que la dirección de una relación no implica su capacidad para recorrerla en esa dirección, siempre cabe la posibilidad de invertirla. Al final, naturalmente, interpretaremos las relaciones como campos, por lo que es habitual invertir relaciones para orientarlas en la dirección en que se espera que sean recorridas. En nuestro ejemplo, podríamos invertir la relación name, ya que posiblemente querremos recorrerla desde nombres (names) a carteras (folios), obteniendo una relación folio, por ejemplo. 20.7.4 Traslado de una relación A veces es posible trasladar el objetivo o la fuente de una relación sin que ello suponga pérdida de datos. Por ejemplo, una relación de A a C se puede sustituir por una relación de B a C si A y B se hallan en una correspondencia de uno a uno.

Page 246: Curso Practico en Java de Ingenieria Del Software Mit

En nuestro ejemplo, podemos sustituir la relación val entre Stock y Dollar por una relación entre Ticker y Dollar. Resulta conveniente utilizar un mismo nombre para la nueva relación, aunque técnicamente se trate de una relación diferente.

20.7.5 Relación a una tabla Una relación de A a B que tenga una multiplicidad objetivo igual a exactamente uno o cero-o-uno, puede sustituirse por una tabla. Dado que solamente se necesita una tabla, puede utilizarse el patrón de instancia única (singleton), de manera que la tabla se pueda referenciar por un nombre global. Si la multiplicidad objetivo de la relación es cero o uno, la tabla debe ser capaz de admitir correlaciones a valores nulos.

Page 247: Curso Practico en Java de Ingenieria Del Software Mit

En FolioTracker, por ejemplo, podríamos convertir la relación folio a una tabla que permitiera hallar carteras mediante una operación de verificación constante. Así, tendríamos:

Sería interesante convertir también en una tabla la relación val que vincula Ticker a Dollar, ya que ello haría posible que la búsqueda de valores para símbolos de registro de cotización se encapsulara en un objeto distinto de la cartera de valores. En este caso, debido a la multiplicidad cero-o-uno, necesitaremos una tabla capaz de almacenar valores nulos.

Page 248: Curso Practico en Java de Ingenieria Del Software Mit

20.7.6 Adición de estados redundantes Suele ser útil añadir componentes de estado redundantes a un modelo de objeto. Dos casos comunes de ello son la adición del traslado de una relación y la adición de la composición de dos relaciones. Si p asocia A a B, podemos añadir el traslado q de B a A. Si p asocia A a B, y q asocia B a C, podemos añadir la composición pq de A a C. 20.7.7 Descomposición de relaciones mutables Supongamos que un conjunto A tiene relaciones de salida p, q y r, de las cuales p y q son estáticas. Si se implementa directamente, la presencia de r hará que A sea mutable. Por tanto, sería conveniente descomponer la relación r utilizando, por ejemplo, la transformación Relación a Tabla, e implementar a continuación A como un tipo de datos inmutable.

Volviendo a nuestro ejemplo, la descomposición de la relación val encaja en este patrón, ya que hace inmutable a la relación Stock. La misma idea subyace en el patrón de diseño Flyweight. 20.7.8 Interpolación de una interfaz Esta transformación sustituye el objetivo de una relación R entre un conjunto A y un conjunto B por un superconjunto X de B. Por regla general, A y B pasarán a ser clases y X se convertirá en una clase o interfaz abstracta. Gracias a ello, la relación R se podrá ampliar para asociar elementos de A a elementos de un nuevo conjunto C, implementando C como una subclase de X. Dado que X descompone las propiedades compartidas de sus subclases, tendrá una especificación más simple que B; la dependencia de A en X es, por lo tanto, menos rígida que su dependencia anterior en B. Para compensar la pérdida de comunicación entre A y B, se puede añadir (mediante una nueva transformación) una relación adicional desde B de regreso a A.

Page 249: Curso Practico en Java de Ingenieria Del Software Mit

El patrón de diseño Observer (observador)es un ejemplo del resultado de esta transformación. En nuestro ejemplo, podríamos convertir los objetos Watch en observadores de los objetos Folio:

Page 250: Curso Practico en Java de Ingenieria Del Software Mit

20.7.9 Eliminación de conjuntos dinámicos No es posible implementar como subclase un subconjunto que no sea estático: los objetos no pueden migrar entre clases en tiempo de ejecución, por lo que es necesario transformarlos. Una clasificación en subconjuntos puede transformarse en una relación del subconjunto a un conjunto de valores de clasificación.

Cuando solamente hay uno o dos subconjuntos dinámicos, los valores de clasificación pueden ser valores booleanos primarios. La clasificación se puede también transformar en varios conjuntos únicos, uno para cada subconjunto. 20.8 Modelo de objeto final El siguiente gráfico muestra el resultado en el ejemplo de Folio Tracker de la secuencia de transformaciones que hemos comentado. Llegados a este punto, debemos comprobar que nuestro modelo es capaz de soportar las operaciones que el sistema debe realizar, y utilizar los escenarios de estas operaciones para construir un diagrama de dependencia del modelo que nos permita verificar la viabilidad del diseño. Tendremos que añadir módulos para la interfaz de usuario y para cualquier otro dispositivo que haya que utilizar para obtener las cotizaciones de las acciones. Asimismo, nos conviene añadir un mecanismo para almacenar carteras en disco de modo permanente. Para algunas de estas tareas, necesitaremos volver sobre nuestros pasos y construir un modelo de objeto del problema, pero para otras partes habrá que trabajar en el nivel de implementación. Así, por ejemplo, si queremos que los usuarios puedan dar nombre a los archivos para almacenar carteras en ellos, es prácticamente seguro que necesitaremos un modelo de objeto del problema. Sin embargo, la construcción de un modelo no resultará posiblemente eficaz para resolver cuestiones relativas al modo de analizar una página Web para obtener cotizaciones de acciones.

Page 251: Curso Practico en Java de Ingenieria Del Software Mit

20.9 Lenguaje unificado de modelado (UML) y métodos Existen varios métodos que describen detalladamente estrategias para el desarrollo orientado a objetos, indicando qué modelos hay que crear y en qué orden. En un escenario industrial, establecer un método como estándar puede servir de ayuda a la hora de coordinar el trabajo de diversos equipos. Aunque en este curso no se enseña ningún método en concreto, las nociones que usted ha asimilado son la base de la mayoría de ellos, por lo que no debería tener problema en aprender cualquier método específico. Casi todos los métodos utilizan modelos de objeto, y algunos utilizan también diagramas de dependencia de módulos. Si desea conocer más sobre la materia, le recomiendo introducir en Google una búsqueda con los términos "Catalysis", "Fusion" y "Syntropy"; que le dirigirá a libros y materiales online. En los últimos años se han producido diversos intentos de estandarización de las notaciones. El Object Management Group (grupo de administración de objetos) ha adoptado como notación estándar el lenguaje unificado de modelado (UML), que es en realidad una amplia colección de notaciones diversas, en la que se incluye una notación de modelado de objetos similar a la nuestra (aunque mucho más compleja).