Subtipado Clase 15 del curso 6.170 15 de octubre de 2001 Sumario 1 Subtipos 2 Ejemplo: bicicletas 3 Ejemplo: cuadrado y rectángulo 4 Principio de sustitución 5 Subclases y subtipos Java 6 Interfaces Java Lecturas necesarias: capítulo 7 del libro Program Development in Java de Bárbara Liskov. Consulte su libro de texto de Java para obtener detalles sobre este lenguaje como, por ejemplo, tipos abstractos (no todos los métodos se implementan y ningún objeto se puede instanciar) y detalles sobre interfaces y modificadores de acceso (public, private, protected, default). Estos temas no se tratarán en esta clase. 1 Subtipos Decimos que A es B si todo objeto A es también un objeto B. Por ejemplo, todo automóvil es un vehículo y toda bicicleta es un vehículo, incluso unos zancos son un vehículo: todo vehículo es un medio de transporte, así como todo animal de carga. Representamos esta relación de subconjuntos en un diagrama de dependencia modular: Esta relación de subconjunto es condición necesaria, pero no suficiente, para una relación de subtipificación. El tipo A es un subtipo del tipo B cuando la especificación de A implica la especificación de B. Esto es, cualquier objeto (o clase) que satisfaga la especificación de A también satisfará la especificación de B, ya que la especificación de B es más débil. Otra manera de explicar esto es que en cualquier lugar del código, si se espera un objeto B, es admisible un objeto A. Se garantiza que el código escrito para funcionar con los objetos B (y para depender de sus propiedades) continua funcionando si se suministran objetos A en su lugar; además, el comportamiento será el mismo, si se consideran sólo los aspectos del comportamiento de A que también están incluidos en el comportamiento de B. (Es posible que A introduzca nuevos comportamientos que B no tenga, pero esto sólo puede modificar los comportamientos existentes de B en ciertas maneras; que veremos enseguida). 2 Ejemplo: bicicletas Suponga que tenemos una clase para representar bicicletas. He aquí una implementación parcial de esa clase: class Bicycle{ private int framesize; private int chainringGears; private int freewheelGears; ... // devuelve el número de marchas de la bicicleta public int gears() { return chainringGears * freewheelGears; } // devuelve el precio de la bicicleta public float cost() { ... } // devuelve el impuesto de venta que incide sobre la bicicleta public float salesTax() { return cost() * .0825; } // ejecución: transporta al ciclista del trabajo a casa public void goHome() { ... } ... } Una nueva clase que representa bicicletas con luces delanteras para poderse adaptar a la falta de luz. class LightedBicycle{ private int framesize; private int chainringGears; private int freewheelGears; private BatteryType battery; ... // devuelve el número de marchas de la bicicleta public int gears() { return chainringGears * freewheelGears; } // devuelve el precio de la bicicleta float cost() { ... } // devuelve el impuesto de venta que incide sobre la bicicleta public float salesTax() { return cost() * .0825; } // ejecución: transporta al ciclista del trabajo a casa public void goHome() { ... } // ejecución: sustituye la pila existente por el argumento b public void changeBattery(BatteryType b); ... } Copiar todo el código resulta trabajoso y aumenta la posibilidad de incurrir en errores. (El error puede provenir de un fallo en la copia o en la realización de una modificación requerida). Además, si se encuentra un error en una versión, es fácil olvidarse de extender el arreglo a todas las versiones del código. Por último, es muy difícil comprender la distinción de las dos clases observando únicamente las diferencias en un cúmulo de similitudes. Java y otros lenguajes de programación utilizan el concepto de subclase para superar esas dificultades. Este concepto permite reutilizar las implementaciones y sobrescribir los métodos. A continuación presentamos una implementación mejor de la clase LightedBicycle: class LightedBicycle extends Bicycle{ private BatteryType battery; ... // devuelve el precio de la bicicleta float cost() { return super.cost() + battery.cost(); } // ejecución: transporta al ciclista del trabajo a casa public void goHome() { ... } // ejecución: sustituye la pila existente con el argumento b public void changeBattery(BatteryType b); ... } LightedBicycle no necesita implementar métodos y campos que aparecen en su superclase Bicycle; las versiones de Bicycle son automáticamente utilizadas por Java cuando no son sobrescritas en la subclase. Considere la siguiente implementación del método goHome (junto con especificaciones más completas). Si éstos son los únicos cambios, ¿son las clases LightedBicycle y RacingBicycle subtipos de Bicycle? (De momento trataremos el concepto de subtipos; más tarde volveremos a las diferencias entre las subclases Java, los subtipos Java, y los verdaderos subtipos). class Bicycle{ ... // requiere: velocidad_del_viento < 20mph && luz_del_dia // ejecución: transporta al ciclista del trabajo a casa public void goHome() { ... } } class LightedBicycle{ ... // requiere: velocidad_del_viento < 20mph // ejecución: transporta al ciclista del trabajo a casa void goHome() { ... } } class RacingBicycle{ ... // requiere: velocidad_del_viento < 20mph && luz_del_dia // ejecución: transporta al ciclista del trabajo a casa // en un período de tiempo < 10 minutos // && hace al ciclista sudar void goHome() { ... } } Para responder a esa pregunta, recuerde la definición de subtipificación: ¿puede un objeto del subtipo ser sustituido en cualquier lugar donde el código espera un objeto del supertipo? Si es así, la relación de subtipificación es válida. En este caso, tanto LightedBicycle como RacingBicycle son subtipos de Bicycle. En el primer caso, las condiciones son relajadas; en el segundo caso, la ejecución se refuerza de una manera que aún satisface la ejecución de la superclase. El método cost de LightedBicycle muestra otra capacidad de especialización de clases en Java. Los métodos pueden ser sobrescritos para facilitar una nueva implementación en una subclase. Esto permite una mayor reutilización del código; en particular, LightedBicycle puede reutilizar el método salesTax de Bicycle. Cuando se llama a salesTax en una LightedBicycle, la versión de Bicycle es la que se utiliza. Entonces, la llamada de cost dentro de salesTax llama a la versión basada en el tipo de tiempo de ejecución del objeto (LightedBicycle), con lo que se utiliza la versión LightedBicycle. Independientemente del tipo declarado de un objeto, la implementación de un método con muchas implementaciones (de la misma firma) siempre se selecciona basándose en el tipo de tiempo de ejecución. De hecho, un cliente externo no tiene manera de llamar a la versión de un método, especificado por el tipo declarado o cualquier otro tipo, que no sea el tipo de tiempo de ejecución. Ésta es una propiedad atractiva y muy importante de Java (y de otros lenguajes orientados a objetos). Suponga que la subclase mantiene algunos campos adicionales que se mantienen sincronizados con los campos de la superclase. Si los métodos de la superclase pudieran llamarse directamente, posiblemente modificando campos de la superclase sin que los campos de la subclase sean alterados también, entonces se violaría el invariante de representación de la subclase. De cualquier modo, una subclase puede llamar métodos de sus padres mediante la utilización de super. A veces es útil cuando el método de la subclase necesita hacer un poco más de trabajo; recuerde la implementación de LightedBicycle para cost: class LightedBicycle extends Bicycle{ // devuelve el precio de la bicicleta float cost() { return super.cost() + battery.cost(); } } Suponga que la clase Rider modela las personas que montan en bicicleta. En ausencia de especializaciones de clase y de subtipos, el diagrama de dependencia modular tendría el siguiente aspecto: El código para Rider también tendría que probar que tipo de objeto se ha pasado, lo que resultaría feo, ampuloso y propenso a errores. Con la subtipificación, las dependencias de MDD se parecerían a esto: Las diversas dependencias se han reducido a una. Cuando se añaden las fechas de subtipo, el diagrama resulta apenas un poco más complicado: Aunque existan varias flechas, este diagrama es más sencillo que el original: las restricciones de dependencia complican el diseño y la implementación más que otros tipos de restricciones. 3 Ejemplo: cuadrado y rectángulo Desde la escuela primaria sabemos que todo cuadrado es un rectángulo. Suponga que queremos hacer del cuadrado Square un subtipo de Rectangle que incluya un método setSize: class Rectangle{ ... // ejecución: define la anchura width y la altura height como los valores // especificados (esto es, this.width’ = w && this.height’ = h) void setSize(int w, int h); } class Square extends Rectangle{ ... } ¿Cuál de los siguientes métodos es adecuado para Square? // requiere: w = h void setSize(int w, int h); void setSize(int edgeLenght); // arroja la excepción BadSizeException si w != h void setSize(int w, int h) throws BadSizeException; El primero no resulta acertado porque el método de subclase exige más que el método de superclase. Así, los objetos de subclase no se pueden sustituir por objetos de superclase, ya que puede existir alguna parte del código que llame al método setSize con argumentos diferentes. El segundo no está en lo cierto (completamente), ya que la subclase aún debe especificar un comportamiento para setSize(int, int); ésta es una definición de un método diferente (cuyo nombre es el mismo pero cuya firma es diferente). El tercero no es correcto porque arroja una excepción que la superclase no menciona. Así, de nuevo, posee un comportamiento diferente y de esta manera Square no puede ser sustituido por Rectangle. (Si la excepción BadSizeException es una excepción no verificada, entonces Java permitirá la compilación del tercer método; pero, de nuevo, también permitirá la compilación del primer método. La noción de Java de subtipo es más débil que la propia noción de subtipo del curso 6.170. Sin ninguna arrogancia, llamaremos a estos últimos “subtipos verdaderos” para distinguirlos de los subtipos de Java). No hay forma de salir de este dilema sin modificar el supertipo. Algunas veces los subtipos no están de acuerdo con nuestra intuición. O bien nuestra intuición sobre lo que es un buen subtipo es errónea. Una solución plausible sería modificar Rectangle.setSize para especificar que él arroja la excepción; esta claro que, en la práctica, solamente Square.size lo haría. Otra solución sería eliminar setSize y en su lugar tener el método void scale(double scaleFactor); que disminuye o aumenta una figura. Otras soluciones también son posibles. 4 Principio de sustitución El principio de sustitución es la base teórica de los subtipos: ofrece una definición precisa de cuándo dos tipos son subtipos. Informalmente, afirma que los subtipos deben ser sustituibles por supertipos. Esto garantiza que el comportamiento del sistema no se verá afectado cuando el código dependa de (cualquier aspecto de) un supertipo, pero habiéndose sustituido un objeto de un subtipo. (El compilador de Java también requiere que las cláusulas extends o implements nombren al padre para que los subtipos se utilicen en lugar de los supertipos). Los métodos de un subtipo deben mantener ciertas relaciones con los métodos del supertipo, y el subtipo debe garantizar que las propiedades del supertipo (como los invariantes de representación o las restricciones de especificación) no sean violadas por el subtipo. Métodos. Existen dos propiedades necesarias: 1. El subtipo debe tener un método correspondiente para cada método del supertipo. (Esta permitido que el subtipo introduzca nuevos métodos adicionales que no aparezcan en el supertipo). 2. Cada método del subtipo que corresponde a un método del supertipo: • requiere menos (tiene una condición previa más débil) - existen menos cláusulas “requires” y cada una de ellas es menos rigurosa que la del método del supertipo. - los tipos de argumentos pueden ser uno de los supertipos del supertipo. Esto se llama contravarianza, y puede dar la impresión de ser un paso hacia atrás, ya que los argumentos del método subtipo son supertipos de los argumentos de los métodos del supertipo. Sin embargo, esto tiene sentido, porque así se garantiza que cualquier argumento pasado al método del supertipo es un argumento válido para el método del subtipo. • garantiza más (tiene una condición posterior más fuerte) - no existen más excepciones - existen menos variables modificadas - en la descripción del resultado o en el estado resultante, existen más cláusulas, y éstas describen propiedades más fuertes. - el tipo de resultado debe ser uno de los subtipos del supertipo. Esto se llama covarianza: el tipo de retorno del método del subtipo es un subtipo del tipo de retorno del método del supertipo. (Todas las descripciones anteriores deberían permitir la uniformidad; por ejemplo, “requerir menos” debería ser “no requerir más”, y “menos rigurosa” debería ser “no más rigurosa”. Se han expresado de esta forma para facilitar su lectura). El método de subtipo no debe comprometerse a ofrecer mayores resultados o resultados diferentes; sólo debe comprometerse a hacer lo que ha hecho el método del supertipo, garantizando también las propiedades adicionales. Por ejemplo, si un método de un supertipo devuelve un número mayor que su argumento, un método de subtipo devolvería un número primo mayor que su argumento. Como ejemplo de las restricciones de tipo, si A es un subtipo de B, entonces la siguiente redefinición (que es lo mismo que sobrescribir) sería válida: Bicycle B.f(Bicycle arg); RacingBicycle A.f(Vehicle arg); El método B toma una bicicleta Bicycle como su argumento, pero A.f puede aceptar cualquier vehículo (lo que incluye todas las bicicletas). El método B.f devuelve una bicicleta Bicycle como resultado, pero A.f devuelve una bicicleta de carreras RacingBicycle (que es una bicicleta propiamente dicha). Propiedades Toda propiedad garantizada por un supertipo, como las restricciones sobre los valores que aparezcan en los campos de especificación, debe estar también garantizada por el subtipo. (Está permitido que el subtipo refuerce esas restricciones). Como un ejemplo sencillo del libro de texto, considere FatSet, que siempre está no vacío. class FatSet{ // restricciones de especificación: el objeto this debe // contener siempre por lo menos un elemento ... // ejecución: si el objeto this contiene x y this.size > 1, // elimina x de this void remove(int x); } El tipo SuperFatSet con un método adicional // ejecución: elimina x del objeto this void reallyRemove(int x) no es un subtipo de FatSet. Aunque no hay ningún problema con los métodos de FatSet –reallyRemove es un método nuevo, por lo que las reglas sobre métodos correspondientes no se aplican: se trata de un método que viola la restricción. Si el objeto del subtipo se considera meramente como un objeto del supertipo (esto es, sólo se consultan los métodos y campos del supertipo), entonces el resultado debería ser el mismo que si se hubiese gestionado en su lugar un objeto del supertipo. En la sección 7.9, el libro de texto describe el principio de sustitución como la colocación de restricciones en • firmas: se trata esencialmente de las reglas de contravarianza y de covarianza explicadas arriba. (La firma de un procedimiento se compone de su nombre, tipos de argumentos, tipos de devolución y excepciones). • métodos: se trata de restricciones del comportamiento, o de todos los aspectos de una especificación que no se pueden explicar en una firma. • propiedades: como arriba. 5 Subclases y subtipos Java Los tipos de Java son clases, interfaces o primitivas. Java posee su propia noción de subtipo (que comprende sólo las clases y las interfaces). Se trata de una noción más débil que la de subtipos verdaderos anteriormente descrita: los subtipos Java no satisfacen necesariamente el principio de substitución. Además, es posible que una definición de subtipo que satisfaga el principio de sustitución no se permita en Java, por lo que no compilará. Para que un tipo sea un subtipo de Java de otro tipo, la relación debe declararse (mediante la sintaxis Java extends o implements), y los métodos deben satisfacer dos propiedades similares, aunque más débiles, a las de los subtipos verdaderos: 1. El subtipo debe tener un método correspondiente para cada método del supertipo. (Está permitido que el subtipo introduzca nuevos métodos adicionales que no aparezcan en el supertipo). 2. Para cada método del subtipo que corresponda a un método del supertipo: • los argumentos deben tener los mismos tipos • • el resultado debe tener el mismo tipo no deben existir más declaraciones de excepciones. Java no posee ninguna noción de especificación de conducta, por lo tanto no realiza verificaciones y no puede dar ninguna garantía en cuanto al comportamiento. La exigencia de uniformidad de tipos para los argumentos y los resultados es más fuerte que lo estrictamente necesario para garantizar la protección del tipo. Esto prohíbe algunas partes de código que nos gustaría escribir; lo que, sin embargo, simplifica la sintaxis y la semántica del lenguaje Java. La especialización de clases posee varias ventajas, todas ellas provenientes de la reutilización: • • • las implementaciones de subclases no precisan repetir los campos y métodos no alterados, pero pueden utilizar los de la superclase los clientes (aquellos que ejecutan las llamadas) no precisan modificar el código cuando se añaden nuevos subtipos, pero pueden reutilizar el código existente (la parte que no menciona los subtipos, sólo el supertipo) el diseño resultante posee una modularidad mejorada y una complejidad reducida, porque los diseñadores, los programadores y los usuarios solamente tienen que entender el supertipo, no cada subtipo: esto se llama reutilización de la especificación. Un mecanismo clave que permite obtener estas ventajas es la redefinición, que especializa el comportamiento para algunos métodos. En ausencia de redefinición, cualquier alteración del comportamiento (aunque se trate de una alteración compatible) podría forzar una reimplementación completa. La redefinición permite que parte de una implementación se altere sin modificar otras partes que dependen de ella, haciendo posible una mayor reutilización del código y de la especificación por parte de la implementación y del cliente. Una posible desventaja de la especialización de clases es que suponen un riesgo de reutilización inapropiada. Es posible que las subclases y las superclases dependan unas de otras (explícitamente por el nombre del tipo o implícitamente por el conocimiento de la implementación), especialmente porque las subclases tienen acceso a las partes protegidas de la implementación de la superclase. Estas dependencias extras complican el MDD, el diseño y la implementación, haciendo que sea más difícil codificar, entender y modificar. 6 Interfaces Java Algunas veces el usuario desea tener garantía sobre el comportamiento sin tener que compartir el código. Por ejemplo, tal vez necesite ordenar los elementos de un contenedor específico o que éstos acepten una determinada operación sin facilitar una implementación por defecto (porque toda relación de orden posee una implementación diferente). Java ofrece interfaces que permiten resolver estas necesidades y garantizar que no se reutilizará el código. Otra de sus ventajas es que una clase puede implementar múltiples interfaces y una interfaz puede ampliar otras muchas. En oposición a esto, una clase sólo puede ampliar una clase. En la práctica, la implementación de múltiples interfaces y la extensión de una superclase única proporcionan la mayoría de los beneficios de la herencia arbitraria, pero con una semántica y una implementación más simples. Una desventaja de las interfaces es que no facilitan el modo de especificar la firma (o el comportamiento) de un constructor.