Beruflich Dokumente
Kultur Dokumente
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.
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.
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.
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).
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.
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".
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
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.
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".
Foro de RIESGOS
• coteja informes de prensa sobre incidentes relacionados con la informática.
• http://www.catless.ncl.ac.uk
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.
“¿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.
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?).
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!
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
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:
Regla de optimización
• No lo haga
• Déjelo en manos de expertos: no lo haga aún.
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.
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ñ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.
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
proporcional al tamaño del programa. Si no se logra que cada parte sea prácticamente
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
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).
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
• 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
permite que sólo haya que tener en cuenta una porción mucho más pequeña del total
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
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
ahora, nos basta con señalar que las partes de un programa son descripciones: de hecho, el
Pronto veremos que las partes de un programa no son todas código ejecutable, por lo que es
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
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
• Si la parte que se quiere construir se halla ya disponible (como, por ejemplo, las
entre sí.
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
MostrarPágina.
La idea resultó atractiva en su momento, y tiene aún hoy en día sus defensores. Sin embargo,
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
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
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
Esto no quiere decir, por supuesto, que examinar un sistema de forma jerárquica sea una mala
Una estrategia mucho mejor consiste en desarrollar una estructura de sistema de múltiples
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
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
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
6
comportamiento cuando se ejecuta, de modo que A usa a B cuando el comportamiento de A
Supongamos, por ejemplo, que estamos diseñando un nuevo navegador. El diagrama muestra
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
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
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,
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
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
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
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
(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
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
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
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
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
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
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
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
por C. Sería mucho mejor si argumentar sobre una parte, por ejemplo, exigiese examinar sólo
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.
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
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
especificación. Nuestro diagrama, el diagrama de dependencias, por tanto, presenta dos tipos
En comparación con lo que teníamos anteriormente, hemos roto las relaciones de usos entre
izquierda. Obsérvese que se han utilizado dos líneas dobles para distinguir las partes de la
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”
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-
parte de la especificación.
Este esquema, o diagrama de dependencias, resulta mucho más útil y potente que el diagrama
• Menos suposiciones. Cuando decimos que A usa a B, es poco probable que estemos
permite decidir de manera explícita cuáles son los aspectos importantes. Al realizar
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.
Para esta cuestión no tenemos que fijarnos en A. Comenzamos por examinar S, que es
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.
satisfagan una parte de la especificación. Esta característica hace posible que surja un
escoger cualquier parte que satisfaga la especificación que necesita. Un sistema único
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
Por tanto, cuando tracemos un diagrama como el de nuestro navegador, que mostramos arriba,
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
Los patrones de diseño, que también estudiaremos más adelante a lo largo del curso, utilizan
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
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
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
de alguna parte que cumpla con la especificación de B, y A se referirá a esa parte utilizando el
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
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
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.
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
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
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
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
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
eliminar la dependencia que la parte A, que está siendo usada, tiene con respecto a la
desempeña en A. Esto hace posible que se cambie la representación de datos en B sin realizar
indicar que una página web es una secuencia de caracteres, que esconde detalles de su
2.3.3 Polimorfismo
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
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.
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
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
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
estructura de la interfaz de usuario con la estructura del resto de la aplicación. Si alguna vez
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
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
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
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
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
21
22
Clase 3: Desacoplamiento II
programa. Un buen lenguaje de programación le permitirá expresar las dependencias entre las
En esta clase, veremos cómo se pueden utilizar los elementos de Java para expresar y manejar
vimos en la última clase. Un diagrama de dependencia de módulos (MDD) muestra dos tipos
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
Una flecha simple con la punta abierta conecta la parte de la implementación A con la parte de
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
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
Dado que las especificaciones son tan imprescindibles, debemos asumir en todo momento que
explícita y, de este modo, una flecha de dependencia entre dos partes de implementación A y
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
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
El sistema de denominación de Java funciona del modo que a continuación exponemos. Los
métodos y campos nombrados. Las variables locales (dentro de los métodos) y los argumentos
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
deben hacerse dos cosas: indicar al comienzo de cada fichero a qué paquete pertenece la clase
que se ajusten a la estructura del paquete. Por ejemplo, la clase djn.browser.Protocol estaría
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
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
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
4
No hay que olvidar que una dependencia de A sobre B indica en realidad una dependencia de
de otra si ésta la nombra. Esto puede parecer obvio, pero es de hecho una propiedad que sólo
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,
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á
sabe cómo el compilador dispuso de la memoria del programa, y no puede predecir qué otra
array puede afectar al valor de una estructura de datos con el nombre d que se ha declarado en
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
5
administración automática de la memoria asegura que ésta no pueda ser reciclada y
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
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.
adecuada. Sirva como ejemplo de ello la historia que conté en clase sobre el uso de elementos
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
sustituir.
considera que los objetos de clase C deben tener tipos compatibles con B, por ejemplo. El otro
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
7
Analicemos su funcionamiento. En vez de tener una clase A que dependa de una clase B,
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
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á
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
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
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.
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
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
Las barras de progreso se usan normalmente en este contexto, pero presentan más
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
Session a Folder y desde Folder a Compactor, pero que las actividades intensivas de recursos
Folder.
9
El diagrama de dependencia de módulo muestra que Session depende de Folder, la cual tiene
estudiaremos las ventajas y desventajas de cada uno de ellos. Comenzando por el diseño más
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
Esto debería ser fácil, pero no lo es. Tenemos que encontrar todos estos enunciados en nuestro
10
Por supuesto, lo que deberíamos haber hecho es definir un procedimiento para encapsular esta
System.out.println (msg);
Ahora el cambio puede realizarse en un único punto del código. Nos limitamos a modificar el
procedimiento:
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
módulos. Hemos introducido una clase única, de la cual dependen, las clases que usan la
11
dependencia de Folder con respecto a StandardOutReporter, ya que el código de Folder no
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
De hecho, el problema es aún más grave. En un programa que usa una GUI, se escribe a ésta
campo del mensaje. En Swing, el kit de herramientas de la interfaz de usuario de Java, las
OutputArea.setText (msg)
Las interfaces de Java tienen la solución. Creamos una interfaz con un sólo método report
12
Ahora añadimos a cada método de nuestro sistema un argumento de este tipo. La clase
...
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:
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
JTextComponent comp.;
13
public JtextComponentReporter (JTextComponent c) {comp. = c;}
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
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
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
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
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
(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
¿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
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.
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
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
static Reporter r;
this.r = r;
r.report (msg);
Lo que tenemos que hacer ahora es colocar el static reporter (informador estático) al
comienzo:
y podemos enviar llamadas a éste sin tener que hacer referencia a un objeto:
...
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
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
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
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
Una práctica que conviene seguir es no fiarse de las variables globales. Es necesario
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
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:
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 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.
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:
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:
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
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.
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
4.7.Orden de la especificación
Imagine que quiere sustituir un método por otro. ¿Cómo compararía las especificaciones?
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.
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.
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.
· 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
List
header
element
Object
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
54
( Object )
elts[0]
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.
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.
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.
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:
List
?
header
!
? ?
? ?
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).
?
header
!
! !
! !
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.
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.
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:
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.
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)
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
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.
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
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.
¿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
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.
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í.
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:
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 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.
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í.
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
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
! ! !
! !
element
?
Object
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.
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.
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
(Object) (Object)
(List)
header header
next next
(Entry) (Entry) (Entry)
prev
(Object) (Object)
52
Portfolio
positionList
List
totalval
?
header
! ! !
! !
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
? ?
! !
element elts[]
?
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.
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
! ! !
! !
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.
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”.
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.
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
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
• 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:
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:
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?
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:
(É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:
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
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:
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.
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.
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á!
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.
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:
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:
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
! ! ! ! ! !
! ! ! !
element element
? ?
Object Object
Un ejemplo más común e insidioso de este fenómeno se da con las claves hash. Si usted altera un objeto
después de haber sido insertado como clave en una tabla hash, su código hash puede cambiar. Como resultado,
el invariante Rep vital de la tabla hash, que decía que las claves se encuentran almacenadas en las posiciones
definidas por sus respectivos códigos hash, se quiebra.
Aquí le mostramos un ejemplo. Un conjunto hash es un conjunto implementado con una tabla hash: piense en
él como si fuese una tabla hash con claves y sin valores. Si insertamos una lista vacía en un conjunto hash y
luego añadimos a la lista un elemento como este,
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:
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:
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:
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.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.
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.
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.
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
También es posible utilizar los mecanismos de reflexión de Java para mitigar la necesidad de facilitar
información de localización.
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/.
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.
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…
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.
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.
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.
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.
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
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.
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.
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.
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.
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
1. Patrones de diseño
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.
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)
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.
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 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);
...
}
...
}
// 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.
class Race {
Ahora las subclases pueden reutilizar createRace e incluso completeBicycle sin ninguna
alteración:
//carrera francesa
class TourDeFrance extends Race {
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);
}
}
class Race {
BicycleFactory bfactory;
//constructor
Race () {
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();
...
}
}
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).
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;
}
}
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.
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.
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.
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.
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.
class Wheel {
...
FullSpoke[] spokes;
...
}
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;
}
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;
...
}
class FullSpoke {
// tense el radio girando el engrasador el número
// especificado de veces (turns)
void tighten(int turns){
... location...
}
}
class Wheel {
FullSpoke[] spokes;
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.
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.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:
(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:
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.
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).
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.
PlusOp:
class PlusOp extends Expression {
Expression leftExp;
Expression rightExp;
...
}
Operaciones
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:
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() { ... }
}
...
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
}
}
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)
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
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.
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.
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);
}
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{
...
}
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:
(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
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;
...
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.
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() { ... }
}
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:
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.
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{
...
// requiere: w = h
void setSize(int w, int h);
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.
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.
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.
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);
}
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.
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.
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.
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.
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,
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:
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:
Ahora el código que utiliza keys ni siquiera sabe que este conjunto es un conjunto de llaves de un
mapa.
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.
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.
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.
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:
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:
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:
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.
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:
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.
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:
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.
¿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.
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.
o para que se cree una lista encadenada a partir de otro tipo de colección:
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:
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.
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
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.
16.8 Visiones
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:
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.
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.
}
}
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.
Las implementaciones por defecto de los métodos hook no realizan ningún procesamiento:
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.
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.
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.
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);
}
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.
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 () {
}
}
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.
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.
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".
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.
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.
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.
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:
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.
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.
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.
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.
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.
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.
A continuación muestro una guía del usuario, si bien muy básica y pendiente de
finalización.
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.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.
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
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 >
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
¿Por qué?
r Descontento con los sistemas de preparación de textos existentes
Objetivos
Solución: Tagger
Combina:
r La calidad tipográfica y la flexibilidad del Quark Xpress
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.
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)
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
Diseño basado en acciones
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?
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
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
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
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)
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:
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
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
utilización
Reestructuración:
r Mejoré la estructura (p. ej., enumeraciones type-safe)
Conclusiones
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.
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.
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.
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
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.
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).
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.
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.
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.
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.
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.
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
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:
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.
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.
• Los hijos de un estilo son aquellos estilos de los que éste es padre:
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.
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.
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.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.
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.
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
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.
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.
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.
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.
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.
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.