Diseño orientado a objetos y software estadístico: patrones de diseño, UML y composición vs. herencia Jordi Ocaña Rebull Alexandre Sánchez Pla Departament d’Estadística. Universitat de Barcelona RESUMEN En este artículo se analizan algunos aspectos del diseño de programas estadísticos. La idea central es que bastantes de los “patrones de diseño” comúnmente empleados en otras áreas del desarrollo informático, son aplicables en la creación de programas estadísticos, y podrían contribuir a mejorar algunas de sus características, como la claridad y la facilidad de mantenimiento y de ampliación. En concreto, se describen y se discuten con cierto detalle los patrones denominados “Estrategia”, “Objeto función” y “Visitante”, junto con el concepto de multimétodo, y se apunta la posible utilidad de otros patrones. La exposición se basa en parte en la utilización de diagramas UML, y en parte en ilustraciones de código (similar a) Java. Palabras clave: Generación de variables aleatorias, Framework, Computación estadística, Estrategia, Factoría, Multimétodo, Visitante, Objeto función ABSTRACT In this paper, we discuss some questions with respect to the design of statistical software. The central point is that many “design patterns” are relevant in the development of statistical software, and may contribute to improve some of its characteristics, like the readability, the maintainability and the extensibility. More precisely, we describe and discuss to some extent the “Strategy”, “Function object” and “Visitor” patterns, jointly with the concept of “multimethod”. We also suggest the possible applicability of other patterns. We illustrate the concepts using UML diagrams and also some Java (alike) code. Keywords: Random variate generation, Framework, Statistical computing, Strategy, Factory, Multimethod, Visitor, Function object 1. INTRODUCCIÓN Hoy en día, la Estadística aplicada no se concibe sin la Informática, y no solamente en el caso evidente del usuario de una técnica estadística. También en el desarrollo de nuevos métodos estadísticos surge la necesidad de plasmarlos en forma de programas, o de realizar estudios de simulación. En parte, un metodólogo estadístico es también un desarrollador informático. Alguien que repite tareas elementales con un programa puede querer automatizarlas, un primer paso hacia la programación. Puede surgir la necesidad de “ampliar” un método ya programado, como añadir un nuevo índice a los inicialmente previstos para determinado método de clasificación jerárquica. Chambers (2000) propone cinco condiciones deseables en todo entorno informático en Estadística: 1. Especificación fácil de tareas sencillas; 2. capacidad de refinamiento gradual de las tareas; 3. posibilidades ilimitadas de extensión mediante programación; 4. desarrollo de programas de alta calidad; y 5. posibilidad de integrar los resultados de los puntos 2 a 4 como nuevas herramientas informáticas. Una consecuencia casi obligada de los puntos anteriores es la necesidad de un entorno de trabajo “orientado a objetos”. Pero este paradigma informático no proporciona una garantía segura de que el material desarrollado vaya a ser “extensible” o de “calidad”. Frecuentemente, los programas desarrollados por los estadísticos, aún utilizando lenguajes orientados a objetos como S o Java, reflejan una de dos estrategias de desarrollo típicas: o bien prescindir de la orientación a objetos, o bien abusar de las características más llamativas de la misma, en especial de la herencia. En el primer caso, el resultado es una librería de funciones como la que hace unas décadas se habría desarrollado, por ejemplo, en FORTRAN –salvo que las funciones están agrupadas en unos contenedores llamados “clases”, que más bien molestan. En el segundo caso todo tiene que acabar encajando en grandes y complejas clases, derivadas por herencia de otras complejas clases generales. En este trabajo discutiremos aspectos generales del diseño de programas estadísticos, obviando consideraciones sobre herramientas concretas y sobre eficiencia, para ver que algunos “patrones de diseño” (Gamma y col., 1994, Losilla, 2003, y también http://www.mindview.net/Books/TIPatterns/) son de directa utilidad en la correcta realización de esta tarea. 2. ¿QUÉ ALGORITMO UTILIZAMOS? EL PATRÓN “ESTRATEGIA” La elección del algoritmo más adecuado para resolver determinado problema es crucial en Estadística. En ocasiones, conceptualmente la solución obtenida es la misma al margen del algoritmo concreto, cuyas diferencias repercuten en la velocidad, o en la estabilidad numérica. Para una distribución dada podemos disponer de varios algoritmos exactos de generación aleatoria, pero teóricamente las propiedades estadísticas de los valores generados son las mismas. Otras veces la solución no es la misma conceptualmente, si bien es una de las aceptables. Tal es el caso de la elección del método de estimación para ajustar los parámetros de un modelo. Lo más común es que no exista “el mejor” algoritmo. Un algoritmo de generación aleatoria puede ser rápido sólo para determinado rango de parámetros, o siempre más veloz pero inadecuado para utilizarlo junto con determinada técnica de reducción de varianza. Considérese el diseño de un entorno de generación variables aleatorias. Será necesario decidir qué métodos de generación programar y en general cómo organizar todos los conceptos implicados, como distribuciones o métodos de generación. En el lenguaje S (o R), y en la mayoría de los paquetes estadísticos, existe una lista fija de distribuciones. Mediante llamada a una función o un menú se pueden generar valores según la distribución elegida, sin control del método de generación empleado ni información al respecto. Así son fáciles las cosas básicas, no una utilización más avanzada. Una librería de clases parece una opción mejor. Lo más común es organizar todos los conceptos en una jerarquía de clases que represente las distribuciones de probabilidad. Supongamos que la raíz de dicha jerarquía es una clase general, ProbabilityDistribution. De ella descenderán clases como UnivariateDistribution, que se especializará en clases como UnivariateDiscrete o UnivariateAbsolutelyContinuous, de las cuales descenderán clases más concretas como Poisson o Normal. Las clases más generales serán abstractas. No se instanciarán nunca (no se crearán objetos de aquella clase) pero permitirán definir partes comunes de la interfaz, y posiblemente de la implementación. Aparte de otros métodos o funciones miembro1 (como cdf, para la función de distribución o pdf para la densidad) todas las clases contarán con una función como nextRand, encargada de generar valores aleatorios. Para un objeto de clase Normal está claro el significado de una llamada a cdf o a nextRand, que tendrá un efecto distinto a la llamada a las mismas funciones para un objeto de clase Binomial, etc. La implementación de los posibles algoritmos de generación para una misma distribución se puede llevar a cabo especializando por herencia otras clases. Por ejemplo, para implementar el algoritmo de generación de 1 A partir de ahora, al describir clases procuraremos emplear el término “función miembro” que, aunque tal vez menos general, no inducirá a confusión con la palabra “método”, que se reservará para designar conceptos más de carácter estadístico. De todas maneras, sí que se hablará de “multimétodo”, “sobrecarga de métodos”, etc. puesto que estos términos tienen un sentido muy preciso. Ahrens y Dieter(1988), aplicable a la distribución normal, una posibilidad es crear una nueva clase, NormalAhrensDieter1988, especializando una clase general Normal, sobrescribiendo su método nextRand –pero aprovechando (heredando) las otras partes de la misma. Posiblemente Normal implementará un método sencillo, como el de Box y Muller(1958), o simplemente será abstracta y sin ninguna implementación de nextRand. El enfoque anterior deriva en una jerarquía de clases intrincada, con árboles de gran profundidad, difícil de entender y de mantener. La Figura 1 trata de reflejar este hecho, empleando notación UML (véase Jiménez y col., 2003). Alternativamente, en cada clase distribución se pueden definir funciones miembro distintas para cada algoritmo de generación. Por ejemplo, Normal tendrá las funciones boxMullerNextRand y ahrensDieter1988NextRand. Esto conduce a clases con interfaces complicadas y distintas de una clase (distribución) a otra. Un principio de diseño elemental es el de crear interfaces cuanto más cortas y claras mejor. En última instancia, se tiene una librería de funciones, agrupadas en clases relativamente inútiles. La discusión anterior refleja un problema de diseño común en Estadística y en otras áreas. Una solución general la proporciona el patrón de diseño denominado “Estrategia” (Strategy en Gamma y col., 1994). En él, los posibles algoritmos alternativos se implementan en una jerarquía de clases independiente de la jerarquía de clases de posibles clientes de estos algoritmos. Todos los algoritmos deben ser capaces de responder al mensaje “ejecuta el algoritmo”, implementado en una función miembro adecuada. Las clases que utilizarán estos algoritmos deben definir un atributo que sea una referencia al objeto algoritmo activo en un momento dado. Para realizar la operación que depende del algoritmo, delegarán esta tarea en el objeto algoritmo. En la Figura 2 se ilustra el patrón Estrategia adaptado a la situación planteada anteriormente, la elección dinámica del algoritmo de generación. Cuando un objeto distribución precisa generar valores aleatorios, delega esta función en el objeto algoritmo que tiene asociado en aquel momento. Otro claro ejemplo del patrón Estrategia se halla en Hitz y Hudec (1994) para la estimación del elipsoide de volumen mínimo en la determinación de la localización y la forma multivariante. La Figura 2 ilustra algunos aspectos adicionales, presentes en general en la realización concreta de este patrón. Gracias a la herencia, basta con que una superclase general de la jerarquía de clases cliente (UnivariateDistribution en el ejemplo) tenga definida la referencia (genMethod en la Figura 2) a los objetos que implementan algoritmos de generación, y tenga definido la función miembro, nextRand, que activa el algoritmo. Nótese que esta referencia es genérica, se define como de tipo GenerationMethod, lo cual no impide que los objetos algoritmo concretos a los que hace referencia sean en la práctica de clases más especializadas (AhrensDieter1988, BucketsInversion,…). La validez del sistema queda asegurada por el hecho de que todas estas clases concretas implementan la interfaz especificada por GenerationMethod, son capaces de responder al mensaje adecuado, en este caso nextRandom. De la misma manera que las distribuciones tienen una referencia al algoritmo de generación empleado, los algoritmos de generación tienen una referencia a la distribución concreta (al objeto) que están generando, por ejemplo para poder consultar los valores de sus parámetros. A diferencia de la referencia distribución Æ algoritmo que es genérica (genMethod es de tipo GenerationMethod), la referencia algoritmo Æ distribución es más específica, para controlar posibles errores de empleo de algoritmos de generación inadecuados para determinada distribución. Así se especifica que el algoritmo AhrensDieter1988 solamente es válido para la clase Normal (o descendientes) mientras que BucketsInversion es válido para cualquier descendiente de FiniteUnivariate. La principal ventaja de este patrón de diseño es la mayor modularidad que proporciona. Las clases correspondientes a las distribuciones (o a otros conceptos estadísticos) y las clases correspondientes a los algoritmos concretos de generación (o de estimación, de búsqueda en un árbol…) se pueden mantener y crecer de forma independiente. Su principal inconveniente (como en la mayoría de los patrones) es que implica un nivel de direccionamiento adicional, que añade una cierta carga de computación. Es común que al mismo problema o situación sea aplicable más de un patrón de diseño, bien por que son posibles soluciones alternativas, bien por que intervienen en partes distintas de la solución. En este sentido los objetos distribución y los objetos algoritmo de generación del ejemplo deben estar siempre sincronizados. Si a un objeto que representa una distribución se le modifican los valores de los parámetros, este cambio también debe afectar al objeto algoritmo de generación –que posiblemente almacena internamente valores que será preciso actualizar en función de los parámetros de la distribución. Una solución completa a esta necesidad la proporciona el patrón “Observador” (Observer), descrito y utilizado en Jiménez y col.(2003). En realidad, dado que en la relación entre una distribución y el algoritmo de generación asociado solamente intervienen dos objetos, basta con que estén fuertemente vinculados: solamente se accederá al algoritmo de generación desde el objeto distribución. Esta restricción incluye la creación flexible (y destrucción) de objetos algoritmo, que no pueden existir “libremente”, no asociados a una distribución. Esto último sugiere alguna de las variantes del patrón Factoría (Factory), que independiza la creación de objetos de la necesidad de referencias explícitas a su clase concreta. El package Java Montecarlo, cuya versión preliminar se puede obtener del primer autor, implementa la “Factoría polimórfica” descrita en http://www.mindview.net/Books/TIPatterns/. 3. ¿TODO EN LA INTERFAZ? OBJETO FUNCIÓN Y MULTIMÉTODOS Un error de diseño común es que las clases incluyan funciones miembro para manejar todos los conceptos de la correspondiente área de aplicación. Por ejemplo, en una librería de clases de distribuciones de probabilidad, que las superclases iniciales tengan previsto el cálculo de la función de densidad, de la función de distribución, la media, la varianza, y en general todo lo que se le pueda ocurrir al desarrollador o a un posible usuario. De esta forma todos los objetos responderán a una misma interfaz, muy general y amplia. Este diseño contradice el principio de máxima modularidad y de concisión: toda clase debería tener unas atribuciones muy concretas, asociadas a una interfaz lo más concisa posible. En cualquier caso, la implementación de tal librería puede ser una tarea ardua y casi seguro que siempre inacabada. Un enfoque alternativo parte de unas superclases muy escuetas y va ampliando su interfaz mediante el uso de la herencia. El resultado final son interfaces muy amplias pero no comunes a todas las clases, lo cual no facilita la “programación genérica”, asunto del que trataremos con más detalle en los párrafos que siguen. Ahora no es posible referirse, en determinados lugares de un programa, a un objeto de una clase muy general (como ProbabilityDistribution o UnivariateDistribution) contando con que aquel código funcionará para objetos de clases mucho más específicas, Poisson, Gamma, etc, que incluso pueden no existir en el momento de la programación del código “genérico”, el que hacía referencia a clases generales, como UnivariateDistribution. Ninguno de los enfoques anteriores permite una extensión fácil. La Figura 3 trata de reflejar la multiplicación de clases asociada a la inclusión de una función miembro como interQuartilicRange, omitida en la jerarquía de clases original. Una solución alternativa se basa en crear, independientemente de la jerarquía de clases inicial, nuevas clases que representen las nuevas funciones a incorporar. Esta es la idea básica del patrón “Objeto función” o “Comando”. La clase denominada InterquartilicRange ilustra esta idea. Dispone de una función miembro eval con un argumento de una clase general como UnivariateDistribution. En Java se definiría: public class InterquartilicRange { public double eval (UnivariateDistribution distri) { // cálculos para determinar el recorrido // intercuartílico } }. Donde eval sería suficientemente general como para evaluar el recorrido intercuartílico para todas las distribuciones univariantes. Pero para la mayoría de las distribuciones específicas el mismo cálculo podría ser mucho más eficiente y simple. Parecería muy razonable ampliar la interfaz de la clase InterquartilicRange añadiendo funciones para manejar el caso de distribuciones más específicas. Seguramente la clase InterquartilicRange tendría que descender de una clase general, como StatFunction, con la función de “firma” public double eval (UnivariateDistribution distri) en su interfaz: public class InterquartilicRange extends StatFunction{ public double eval (UnivariateDistribution distri) { // cálculos para determinar el recorrido // intercuartílico en general } public double eval (Normal distri) { // cálculos para determinar el recorrido // intercuartílico para la distribución // normal } } Concretando más, consideremos el siguiente fragmento de código genérico: StatFunction operator; UnivariateDistribution distr; double valor = operator.eval(distr); La última línea podría corresponder al cálculo del valor de un parámetro cualquiera, para cualquier distribución. Sintácticamente, lo anterior sería correcto. Pero no haría lo que se podría pensar en principio. Si la auténtica clase de operator fuese InterquartilicRange y la de distr, Normal, se podría pensar que la tercera línea del ejemplo ejecutaría la función miembro, más eficiente y adecuada en este caso, de “firma” public double eval (Normal distri). La realidad es que en la mayoría de los lenguajes ciertamente se ejecutaría eval de InterquartilicRange, pero en su versión más general, no la propia de la distribución normal. Esto se debe a que la clase concreta para la que se ejecuta una función miembro virtual (en Java todas las funciones miembro son virtuales) se decide en tiempo de ejecución. Por lo tanto, correctamente, se buscan las disponibles en la clase InterquartilicRange. Pero en la mayoría de lenguajes, como C++ y Java, la sobrecarga de métodos se resuelve en el momento de la compilación, no durante la ejecución del programa. Cuando está analizando las tres líneas de código anteriores, el compilador desconoce la clase concreta del objeto, distr, que eval recibirá como parámetro, lo único seguro es que será de clase UnivariateDistribution o de alguna de sus descendientes, desconocida. La función miembro public double eval (UnivariateDistribution distri) de StatFunction es correcta sintácticamente y es la que se adapta mejor a la información que tiene el compilador. En la discusión previa se ilustra un concepto importante, tanto en general como en computación estadística. Se trata del concepto de “método múltiple” o “multimétodo” (véase Saar, 2000). La mayoría de los lenguajes orientados a objetos (incluyendo Smalltalk, Java y C++) implementan un “mecanismo de resolución” simple. Los métodos o funciones miembro son “despachados” o “resueltos” (el sistema decide ejecutar una u otra versión de los mismos) en función de la clase del objeto que los activa –la clase real, no necesariamente la declarada en el código donde interviene, que puede ser una superclase de la primera. Un multimétodo, en cambio, tiene un mecanismo de “resolución múltiple” (multiple dispatch): se decide qué método ejecutar en función no solamente de la clase del objeto que activa la función miembro sino también de la clase real de los restantes argumentos –o de parte de ellos. Este útil concepto ha sido incorporado (Chambers, 1998) en el lenguaje S, a partir de su versión 4. En un lenguaje como Java, mediante el mecanismo de reflexión, o por otras vías, es posible simular el concepto de método múltiple (Boyland y Castagna, 1997). Si solamente es necesario implementar “resolución doble” (funciones miembro que se ejecutan en función del objeto que los activa y de uno solo de sus parámetros), el patrón de diseño “Visitante” (Visitor) proporciona una solución limitada pero sencilla. La Figura 4 trata de esquematizar este patrón de diseño para el caso de la jerarquía de distribuciones y la de funciones objeto que realizan cálculos específicos sobre ella. En Visitante, la necesidad de resolución doble se implementa mediante una doble llamada a funciones de resolución simple, la única disponible. Todas las clases distribución deben implementar una función, denominada evaluate en la Figura 4, que haga que los correspondientes objetos “acepten” ser visitados por objetos de clases que implementen la interfaz StatFunction. Estas últimas clases son las que realmente realizan los cálculos adicionales, en el ejemplo mediante eval. El código de evaluate siempre sería algo como: return func.eval (this); La llamada a eval se realiza dentro de la función evaluate, definida en la clase distribución “visitada”. En este lugar está perfectamente claro para el compilador cuál es la clase del objeto distribución que activa evaluate, que es el mismo objeto que recibe eval como parámetro -la referencia this en Java. Por lo tanto, el mecanismo de sobrecarga de métodos escoge la versión de eval más específica, durante la compilación. Como consecuencia de la aplicación del patrón Visitante, con la incorporación de un único método, evaluate, en todas las clases distribución, la interfaz de todas ellas se puede ampliar con un número ilimitado de nuevas funciones, funciones encapsuladas en clases que implementen una interfaz dada, StatFunction en el ejemplo anterior. El punto más débil de este patrón es el hecho de que la interfaz “visitante”, StatFunction, debe incluir funciones miembro para todas las clases de la jerarquía de distribuciones que tengan que ser “visitables” directamente, es decir, para las cuales se vaya a definir una función miembro evaluate específica. Si se amplía la jerarquía de distribuciones y se desea crear funciones miembro eval cuya firma incluya las nuevas clases, será necesario ampliar y recompilar la interfaz StatFunction y las clases que la implementan. Ésto no es tan grave si la jerarquía “visitable”, las distribuciones, es mucho más estable que la “visitante”, los cálculos sobre distribuciones. Esta última estará en mayor medida bajo control del usuario, como vía de ampliación de la primera. La Figura 4 ilustra el patrón Visitante para ampliar las funciones ejecutables sobre una jerarquía de distribuciones que sólo incluye cuatro distribuciones “concretas”, Binomial, Poisson, Normal y Exponential, y tres superclases generales, UnivariateDiscrete, UnivariateAbsolutelyContinuous y UnivariateDistribution. La jerarquía con raíz en la clase UnivariateDistribution se amplía con una nueva función estadística, representada por la clase InterquartilicRange, que implementa la interfaz StatFunction. Ahora, si la auténtica clase del objeto distr es Normal y la de operator es InterquartilicRange, la última línea de StatFunction operator; UnivariateDistribution distr; double valor = operator.eval(distr); se traducirá en una llamada de la función eval(Normal …) de InterquartilicRange. UnivariateDiscrete, UnivariateAbsolutelyContinuous y UnivariateDistribution constan en la interfaz StatFunction ya que, posiblemente, para ellas se crearán versiones generales de eval, adecuadas para el caso discreto en general, para el absolutamente continuo, etc., aplicables a nuevas clases derivadas que no hayan sido explícitamente incluidas en el patrón. Por ejemplo, supóngase que se ha creado una nueva clase, NegativeBinomial, descendiente de UnivariateDiscrete. Para ella no se crea una eval específica en la interfaz StatFunction y en la clase InterquartilicRange y similares. Si distr es un objeto de clase NegativeBinomial y operator sigue siendo un objeto de clase InterquartilicRange, en la última línea del código anterior el cálculo correspondiente será finalmente realizado por la función miembro eval(UnivariateDiscrete …) de la clase InterquartilicRange, tal como ocurriría también en el caso de la extensión por herencia. 4. CONCLUSIONES La orientación a objetos en computación estadística es deseable, pero no necesariamente una garantía de calidad del código producido. Sin ser soluciones mágicas, determinados patrones de diseño proporcionan soluciones aprovechables en el ámbito de la computación estadística. Los considerados en este trabajo, y posiblemente otros, pueden hacer que el software estadístico sea más fácil de mantener y de extender. Como contrapartida pueden implicar cierta pérdida de eficiencia. Se usen o no patrones, la realización de un cierto análisis y diseño previo, con la ayuda de herramientas como la notación UML, no es una perdida de tiempo, siempre que no se incurra en una “parálisis del análisis”. Lo anterior corresponde al extremo contrario de la costumbre, tan extendida en Estadística y áreas afines, de realizar el análisis y el diseño en segundo plano (mental) mientras se teclea el programa. REFERENCIAS Ahrens, J.H., Dieter, U. Efficient table-free sampling methods for the exponential, Cauchy and normal distributions. Computing, Commun. ACM, 31, 1330-1337, 1988. Box, G.E.P., Muller, M.E. A note on the generation of random normal deviates. Ann. Math. Statist. 29, 610-611, 1958. Boyland, J., Castagna, G. Parasitic methods: An implementation of multi-methods for Java. OOPSLA ’97, Proceedings, Atlanta. SIGPLAN Notices, 32, 66-76, 1997. Chambers, J.M. Programming with Data. A Guide to the S Language. Springer, 1998. Chambers, J.M. Users, programmers, and statistical software. Journal of Computational and Graphical Statistics, 9, 403-422, 2000. Eckel, B. Thinking in Java. Prentice Hall, 2002. Gamma, E., Helm, R., Johnson, R., Vlissides, J. Dessign Patterns: Elements of Reusable Object-Oriented Software. Addisson-Wesley, 1994. Hitz, M., Hudec, M. Applying the Object Oriented paradigm to statistical computing. Proceedings in Computational Statistics, COMPSTAT 94, 389-394, 1994. Jiménez, R, Losilla, J.M., Vives, J. Modelado orientado a objetos de variables de recuento. Trabajo Presentado al VIII Congreso de Metodología de las Ciencias Sociales y de la Salud. Valencia. Programa y Libro de Resúmenes, 58-59, 2003. Losilla, J.M. El proceso unificado de desarrollo de software estadístico. Trabajo Presentado al VIII Congreso de Metodología de las Ciencias Sociales y de la Salud.Valencia. Programa y Libro de Resúmenes, 55-56, 2003. Saar, R. Extensions of software components using multimethods. The Journal of Object-Oriented Programming, 13, 12-16, 2000. Figura 1. Elección del método de generación basada, exclusivamente, en la herencia Figura 2. El patrón Estrategia en la generación de variables aleatorias Figura 3. Multiplicación de clases al extender por herencia Figura 4. El patrón Visitante en una jerarquía de distribuciones de probabilidad