Sie sind auf Seite 1von 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
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
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
• 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
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
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
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
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
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
• 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
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
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
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
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
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
(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
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
• Á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
• 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
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
• 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
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
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
• 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
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
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
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
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
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
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
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
22
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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 : val se da en a
effects: 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
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
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
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
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
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
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


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
50
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
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,

elems;
seq [Object]
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
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

header

next Entry prev

element

Object

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.
53
El siguiente diagrama de objetos muestra una lista con dos elementos:

( List )

header

next next
( Entry ) ( Entry ) ( Entry )

prev prev
element element

( Object ) ( Object )

Otra representación diferente de listas utiliza un array. El siguiente modelo de objeto muestra cómo se

List

elementData

Object[]

elts[]

Object

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
( Object )
elts[0]

( List ) Element ( Object[] )


Data

elts[1] ( Object )

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
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:

Client

BAD
List

Entry

Object

56
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:

List

header

elems[] Entry
next prev

element

Object

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 (); // presenta la representación


a[i] = null; //¡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
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
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:

List

?
header

!
? ?

next Entry prev

? ?
element

?
Object

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
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:

List

?
header

!
? ?
elems[]
next Entry prev

? ?
element

?
Object

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
· 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).

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:
List

?
header

!
! !

next Entry prev

! !
element

?
Object

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):

( List )

header next

next next
( Entry ) ( Entry ) ( Entry )

prev
prev
element
element
prev

( Object ) ( Object )

61
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.size + 1
&& 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
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.size + 1
&& 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.

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
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
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
<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 }

A
A A A
Dominio concreto

[F, W, S] [W, F, S] [W, W, F, S] [W, F]

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
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
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
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
a

A A

r1 op 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
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
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
Clase

IntSet
size() elems()
remove()
elems()

els.iterator()
els remove()
next()
hasNext()
Vector
estado
remove()
compartido

dependencia
ERRÓNEA

Cliente

¿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
Clase interna del
iterador
IntSet
size() IntGen
remove() remove()

els

els
els.iterator()
Vector remove()
remove() next()
estado
hasNext()
compartido

72
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
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
List List

ArrayList LinkedList 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
List

?
header
! ! !

prev Entry next

! !
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
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
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
(List)

header next

next next
(Entry) (Entry) (Entry)
prev prev

element prev element

(Object) (Object)

(List)

header header

next next
(Entry) (Entry) (Entry)
prev

element element prev

(Object) (Object)

52
Portfolio

positionList

List
totalval
?

header
! ! !

prev Entry next

! !

element
?

Position

?
count,
ticker
value
! ! !

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
Set Set Set

? ?
eltList eltList
! !

LinkedList ArrayList

? ?

header elementData elements


! ! ! !

prev Entry next Object []

! !

element elts[]
?

Object Object Object

8.3 Puntos de vista concretos y abstractos


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.

54
List

header
? ! ?

elems[]
prev Entry next

? ?

element
?

Object

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
Set

implements

List Stack

ArrayList LinkedList

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.

8.3.1 Funciones de abstracción

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:

A (0000) = 0

A (0001) = 8
A (1001) = 9

56
LinkedList

header
! ! !

prev Entry next

! !

element
?

Object

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

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.

57
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.

8.3.2 Invariantes de representación

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.

58
· 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”.

8.3.3 Exposición de representación

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.

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.

En la implementación de ArrayList,, el método se implementa como:


private Object elementData[];

public Object[] toArray() {

Object[] result = new Object[size];


System.arraycopy(elementData, 0, result, 0, size);
return result;
}

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

public Object[] toArray() {


return elementData;
}

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.
59
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
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
9.2 Propiedades de igualdad
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:

public class Point {


private final int x; private
final int y; public Point
(int x, int y) {
this.x = x; this.y = y;
}
public boolean equals (Object o) {
if (!(o instanceof Point))
return false;
Point p = (Point) o;
return p.x == x && p.y == y;
}
…}

Suponga ahora que añadimos la noción de colour:


public class ColourPoint extends Point {

private Colour colour;


public ColourPoint (int x, int y, Colour colour) {
super (x, y);
this.colour = colour;
}

}
¿Cómo debe ser el método equals de la clase ColourPoint? Podríamos heredar únicamente el método equals

62
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:

public boolean equals (Object o) {


if (!(o instanceof ColourPoint))
return false;
ColourPoint cp = (ColourPoint) o;
return super.equals (o) && cp.colour.equals(colour);
}

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);

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))
return false;
//si f o es un Point normal, haga una comparación sin tener en cuenta el color
if (!(o instanceof ColourPoint))
return o.equals (this);
ColourPoint cp = (ColourPoint) o;
return super.equals (o) && cp.colour.equals (colour);
}

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:

ColourPoint p1 = new ColourPoint (1, 2, Colour.RED);


Point p2 = new Point (1, 2);
ColourPoint p2 = new ColourPoint (1, 2, Colour.BLUE);

Las llamadas p1.equals(p2) y a p2.equals(p3) devolverán el valor true, excepto


p1.equals(p3), que devolverá false.

63
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?

public boolean equals (Point p)

9.3 Hashing
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.

64
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:

Invalide siempre el método hashCode cuando invalide el método equals.

(É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...

9.4 Copia
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.

65
¿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:

class Point {
Point copy () {

}

}

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 () {

}

}

Desafortunadamente, esto no es legal en Java. Usted no puede cambiar el tipo de retorno de un método cuando lo
invalida 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:

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:

public static Point newPoint (Point p)

La otra forma consiste en proporcionar constructores adicionales, generalmente conocidos como


“constructores de copia”:

66
public Point (Point p)

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

new ArrayList (l)

9.5 Igualdad de elementos e igualdad de contenedores


¿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

s1.equals (s2),
y no

s1 == s2,
que devolverá false cuando s1 y s2 representen objetos string distintos, que contengan las mismas secuencias
de caracteres.

9.5.1 El problema

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.

67
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:

List t1 = new LinkedList ();


List t2 = new LinkedList ();

Set s = new HashSet ();
s.add (t1);
s.add (t2);

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.

Set set = new HashSet ();


String lower = “hello”; String
upper = “HELLO”; set.add
(lower.toUpperCase()); …

9.5.2 La solución de Liskov

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.

68
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.

9.5.3 El enfoque de Java

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.

69
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á!

List l = new LinkedList ();


l.add (l);
int h = l.hashCode ();

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:

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.

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).

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.

9.6 Exposición de representación


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
void add (Object o) {
if (contains (o))
return;
else
// add the element

}

Debemos registrar el invariante Rep al principio del archivo, indicando que la lista no contiene duplicados:

La lista no contiene duplicados. Es decir, no existen entradas e1 y e2 tales que


e1.element.equals (e2.element).

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:

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);

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.

71
LinkedList LinkedList

? ?

header header
! ! ! ! ! !

prev Entry next prev Entry next

! ! ! !

element element
? ?

Object Object

9.6.1 Cómo alterar claves hash

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,

Set s = new HashSet ();


List x = new LinkedList ();
s.add (x);
x.add (new Object ());

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
Ésta es la razón por la que verá comentarios de este tipo en la especificación de la API
de Java:

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.

9.7 Vistas
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:

List x = new LinkedList ();


List y = x;
y.add (o); // changes y also

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 ();



Iterator i = x.iterator ();
while (i.hasNext ()) {
Object o = i.next ();

i.remove (); // también muta
x}

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.

73
· 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
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,
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 ()


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:
• 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).
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.
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
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
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
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
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.
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.
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
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);
...
}
...
}

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


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


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();
...
}
}

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;
}
}
É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;
//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 {
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:
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;
}
}

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;
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;
}
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:


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.
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.)
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];
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.
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.
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
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
// 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
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.
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.
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
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
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
}
}
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
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
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
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
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
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);
...
}
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
// 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
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:
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.
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)
- 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{
// 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
• 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
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.
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.
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.
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
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);

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:


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.
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)
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
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.
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
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);
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


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.
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();
}
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,
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
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);
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
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 () {
}
}

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?
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.
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
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.
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.
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
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.
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.
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.
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.
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.
• 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
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
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


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
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.
Daniel Jackson
6170. Curso práctico en Ingeniería de Software
Clase 18: 22 de octubre de 2001
Temas de la clase de hoy

r Qué es Tagger y por qué lo escribí


r Opciones de diseño

r Diseño basado en acciones

r Diseño: visión general

r Aspectos concretos del diseño:

Objetos Action
Referencias cruzadas
Mapas de propiedades
Numeración automática
Visión de conjunto de estilos
Enumeraciones seguras con respecto a los tipos (type-safe)
r Proceso

r Conclusiones


Qué es y por qué era necesario

¿Qué es?
r Un preprocesador de texto para la preparación de documentos

r Texto fuente escrito en mi propio lenguaje de marcado

r Salida de datos en el formato de importación de Quark Xpress

¿Por qué?
r Descontento con los sistemas de preparación de textos existentes

Word: poco fiable


Framemaker: lento y anticuado
Tex: poco flexible (y no se pueden utilizar determinadas fuentes)


Objetivos

Modelo de compilación de Tex


r Preparación de documentos en un editor de texto

r Nombres simbólicos para caracteres especiales

r Numeración automática y referencias cruzadas

Modelo 8:4*8:(del Quark


r Alta calidad tipográfica

r Fácil ajuste de fuentes, espaciado, diseño de página, etc.


Solución: Tagger

Combina:
r La calidad tipográfica y la flexibilidad del Quark Xpress

r con las funciones de entrada de datos en texto de Tex

Mapa de Hoja de
caracteres estilo

Texto Texto
fuente
5BHHFS Etiquetado 2VBSL

Salida de
typeset

Ejemplo de introducción de datos
>NQCFEJCTU]OCRU>UVCPFCTFOCRVZV_ Estas son algunas de las cosas
>NQCFEJCTU]OCRU>NWEOCVJCTTOCRVZV_ =>EKVG]FPL_?que se pueden hacer:
>NQCFUV[NGU]GZCORNGUV[NGUVZV_
\point Crear documentos con una _buena_
\title Ejemplo de Tagger presentación.
\point Escribir formulas $(a +
Daniel Jackson// b) = c$ y utilizar simbolos como
Clase practica de informatica del MIT \arrowdblse.

\section Introduccion \section Referencias

\subsection Aspectos interesantes >TGH>VCI]FPL_


,CEMUQP &CPKGN6CIIKPIHQT
2TQHKVCPF2NGCUWTG


Ejemplo de hoja de estilo

UV[NGVKVNG PGZVCWVJQT
UV[NGCWVJQT PGZVUGEVKQP
UV[NGUGEVKQP PGZVDQF[ EQWPVGT UGRCTCVQT VTCKNGT
UV[NGDQF[ PGZVDQF[
UV[NGUWDUGEVKQP PGZVDQF[ RCTGPVUGEVKQP
EQWPVGTC UGRCTCVQT VTCKNGT
UV[NGRQKPV PGZVPQKPFGPV NGCFGT>RGTKQFEGPVGTGF
UV[NGTGH PGZVTGH EQWPVGT NGCFGT= VTCKNGT?


Ejemplo de mapa de caracteres (resumen)

EJCTCTTQYFDNPG HQPV.WEKF0GY/CV#TT6 KPFGZ

EJCTCTTQYFDNUY HQPV.WEKF0GY/CV#TT6 KPFGZ

EJCTCTTQYFDNUG HQPV.WEKF0GY/CV#TT6 KPFGZ

EJCTCTTQYFDNNGHVPGI HQPV.WEKF0GY/CV#TT6 KPFGZ


Ejemplo de salida de datos en Tagger

"VKVNGEjemplo de Tagger
"CWVJQTDaniel Jackson<\n>Clase practica de informatica del MIT
"UGEVKQP1 Introduccion
"UWDUGEVKQP1.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$>.
"UGEVKQP2 Referencias
"TGH[1] Jackson, Daniel. Tagging for Profit and Pleasure.



Ejemplo de hoja de estilo en Quark


Ejemplo de salida de typeset


Opción de diseño 1: subclase Token

CDUVTCEVENCUU6QMGP]_

ENCUU2CTCITCRJ%QOOCPFGZVGPFU6QMGP]
XQKFIGPGTCVG1WVRWV
]š_
_

ENCUU#NRJCDGVKE5GSWGPEGGZVGPFU6QMGP]
XQKFIGPGTCVG1WVRWV
]š_
_

Problemas:
r Exceso de subclases

r Los modos (p. ej. el modo math) quedan divididos entre las clases


Opción de diseño 2: sentencia case

ENCUU6QMGP]KPVV[RGš_
ENCUU6QMGP6[RG]UVCVKEKPVHKPCN2#4#%/&š_

XQKFIGPGTCVG1WVRWV 6QMGPV
]
KH VV[RG6QMGP6[RG2#4#%/&
VJGP
š
GNUGKH VV[RG6QMGP6[RG#.2*#5'3VJGP
š

Problemas:
r Fragmento de código bastante extenso

r Con muchas variables globales e interacciones

r también se podría utilizar el patrón Visitor para implementarlo


Diseño basado en acciones

r El archivo fuente se convierte en un flujo de Tokens


r Cada Token es consumido por el motor

r El motor ejecuta todas las acciones registradas para el tipo de Token

r Las acciones pueden determinar la lectura y escritura de archivos y


registrar/eliminar acciones del registro
Motor

Conversor Testigos
Tipo 1 B B
de testigos
Tipo 2 B B B


¿Qué apariencia tiene el código?

#EVKQPRNCKPVGZV#EVKQPPGY#EVKQP
]
RWDNKEXQKFRGTHQTO 6QMGPV
]
IGPGTCVQTRNCKPVGZV VCTI

_
_
TGIKUVGT$[6[RG RNCKPVGZV#EVKQP 6QMGP6[RG#.2*#$'6+%

TGIKUVGT$[6[RG RNCKPVGZV#EVKQP 6QMGP6[RG07/'4+%

TGIKUVGT$[6[RG RNCKPVGZV#EVKQP 6QMGP6[RG9*+6'52#%'


TGIKUVGT$[6[RG PGY#EVKQP
]
RWDNKEXQKFRGTHQTO 6QMGPV
]
GTTQT5VTGCORTKPVNP FQPG

__
6QMGP6[RG'0&1(564'#/


¿Por qué las acciones son una buena opción?

r Comportamientos flexibles y sensibles al contexto


r Sin un fragmento de código demasiado extenso

r Permiten activar y desactivar fácilmente los comportamientos

r Permiten incluir motores secundarios: p. ej., para numerar cadenas

Ejemplo: modo math


r El símbolo $ inicia y finaliza el 'modo math': pasa todas las letras a cursiva
r Implementado como dos acciones:
Para el Token $:EPMMBS@BDUJPO
Para el Token cadena alfabética:JUBMJDJ[F@BDUJPO
r EPMMBS@BDUJPO

Encapsulamiento del modo


Registro/eliminación del registro deJUBMJDJ[F@BDUJPO


Código del modo math

TGIKUVGT$[6[RG PGY#EVKQP
]
DQQNGCPOCVJ/QFG1PHCNUG
RWDNKEXQKFRGTHQTO 6QMGPV
]
KH OCVJ/QFG1P
]
OCVJ/QFG1PHCNUG
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/QFG1PVTWG
š
__ 6QMGP6[RG&1..#4



Código del motor

RTKXCVG.KPMGF.KUV=?VCDNG
š
RWDNKEXQKFTGIKUVGT #EVKQPCEVKQP KPVV[RG
]
CEVKQPU=V[RG?CFF CEVKQP

_

RWDNKEXQKFEQPUWOGAVQMGP 6QMGPVQMGP
]
+VGTCVQTKVCDNG=VQMGPV[RG?KVGTCVQT

YJKNG KJCU0GZV

]
#EVKQPC #EVKQP
KPGZV

CRGTHQTO VQMGP

_
_


¡Fallo!
Motor Array Lista Iterador Acción
consumo
MUBCMF<UUZQF>

JMJUFSBUPS


BJOFYU

BQFSGPSN

FSFHJTUFS y

BJOFYU



Aspecto del diseño 1: objetos Action

Motor DFWLRQ/LVWV>@ Lista HOHPV Acción


vistas


6WDQGDUG  $FWLRQ6XE
Iterador LWHU
(QJLQH (Anónima)

Cómo funciona:
r La acción se elimina por sí misma de la lista de acciones

r Invocando al método remove en el iterador pasado a Action.perform


Código del motor y de los objetos Action
RWDNKEXQKFEQPUWOGAVQMGP 6QMGPVQMGP
]
+VGTCVQTKVCDNG=VQMGPV[RG?KVGTCVQT

YJKNG KJCU0GZV

]
#EVKQPC #EVKQP
KPGZV

CRGTHQTO VQMGP K

_
_
HKPCN#EVKQPRCTCITCRJ#EVKQPPGY#EVKQP
]
RWDNKEXQKFRGTHQTO 6QMGPV +VGTCVQTKVGT
]
KH VV[RG6QMGP6[RG2#4#56;.'%1//#0&
]
IGPGTCVQTPGY2CTC EWTTGPV2CTCUV[NG0COG

5VTKPIPWODGTUPWODGTKPIIGV0WODGTKPI5VTKPI š

š
KVGTTGOQXG

__

Aspecto del diseño 2: referencias cruzadas

"
marca
Cadena de
" citas referencia

UHIV Párrafo
"
etiqueta
Cadena de
numeración citación
"

Cómo funciona:
r El usuario puede marcar cada ítem con un nombre simbólico

r y hacer la llamada desde cualquier otro lugar mediante ese nombre

r La cadena de llamada es una cadena de numeración o una etiqueta


Aspecto del diseño 3: mapas de propiedades

Las hojas de estilo, los mapas de caracteres y los archivos de referencias cruzadas:
r Todos utilizan una misma sintaxis
r Un único Parser (PropertyParser)

r Una única representación interna (PropertyMap)


Aspecto del diseño 4: numeración automática
raíz

Estilo 
contador Contador
Numerado
"
"

Padre ahijo)

Cómo funciona:
r La hoja de estilo debe proporcionar únicamente:

Una propiedad padre: que comprende el estilo en la jerarquía


Una propiedad contador: si el caso está numerado y cómo
r La clase Numeración (Numbering) mantiene el estado

y aumenta el mapa de propiedades con las propiedades hijo y raíz



Aspecto del diseño 5: visión del conjunto de estilos
7DJJHU Cómo funciona:
r Tagger crea un mapa de
SDUVHU HQJLQH
 
propiedades (PropertyMap) vacío
r Envía el mapa al motor estándar
6RXUFH3DUVHU 6WDQGDUG(QJLQH
(StandardEngine)
r Envía la vista de conjunto a
SDUD6W\OHV

DFWLRQ/LVWV>@
SourceParser
VW\OH0DS
/LVW r SourceParser verifica las cadenas
6HW
del conjunto en busca de estilos
HOHPV
 de párrafo

YLHZV r StandardEngine actualiza el
3URSHUW\0DS $FWLRQ
NH\V
mapa una vez cargada la hoja

de estilo
$FWLRQ6XE
VW\OH0DS
DQRQ\PRXV


Aspecto del diseño 6: enumeraciones type-safe (1)

// manera incorrecta
RWDNKEHKPCNENCUU(QTOCV]
RWDNKEHKPCNUVCVKEKPV41/#0
RWDNKEHKPCNUVCVKEKPV+6#.+%5
RWDNKEHKPCNUVCVKEKPV57$5%4+26
RWDNKEHKPCNUVCVKEKPV572'45%4+26
_

Problema:
r Los formatos se declaran como int en el código cliente

r Los formatos no previstos (p. ej. -1) no se identifican durante

la compilación


Aspecto del diseño 6: enumeraciones type-safe (2)

RWDNKEHKPCNENCUU(QTOCV]
RWDNKEUVCVKE(QTOCV41/#0PGY(QTOCV 4QOCP

RWDNKEUVCVKE(QTOCV+6#.+%5PGY(QTOCV +VCNKEU

RWDNKEUVCVKE(QTOCV$1.&PGY(QTOCV $QNF

RWDNKEUVCVKE(QTOCV57$5%4+26PGY(QTOCV 5WDUETKRV

RWDNKEUVCVKE(QTOCV572'45%4+26
PGY(QTOCV 5WRGTUETKRV


RTKXCVGHKPCN5VTKPIPCOG
RTKXCVG(QTOCV 5VTKPIPCOG
]VJKUPCOGPCOG_
RWDNKE5VTKPIVQ5VTKPI
]TGVWTPPCOG_
_


Proceso: cómo construí Tagger

Primeros prototipos:
r Escribí los scripts en Perl y experimenté con el formato de

importación de Quark
Desarrollo en Java:
r Dediqué algunos días al diseño: esquema de MO y de MDD

r Desarrollé el código en el lenguaje

r No se realizaron pruebas sistemáticas; se probó sólo durante su

utilización
Reestructuración:
r Mejoré la estructura (p. ej., enumeraciones type-safe)

r Completé las especificaciones para todos los métodos públicos

r Escribí una serie de pruebas de regresión (simples) para lograr

protección contra nuevos errores (bugs)


Conclusiones

Las necesidades de calidad varían según el sistema:


r En este caso, sirven las pruebas ad hoc

r Serían necesarias pruebas sistemáticas para el lanzamiento comercial

Lenguaje basado en acciones:


r Casi siempre claro y potente

r Actualmente estoy realizando análisis de modelos de objetos

para máquinas basadas en acciones


Utilización de patrones múltiples:
r Más común que el JUnit en este aspecto

r Esfuerzos realizados, principalmente, en tipos e idiomas definidos

por el usuario


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;


· 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
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.
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.
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
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.
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:
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
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
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.
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.
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
é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
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
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
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.
¿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.
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.
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.
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.
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.
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.
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:
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.
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).

Das könnte Ihnen auch gefallen