Principios de diseño
Principios y estrategias de diseño de software
La fase de diseño es la más importante del ciclo de vida del desarrollo de software. Un buen diseño permite añadir nuevas funcionalidades y manejar el cambio de requisitos de una forma más sencilla.
Los principios de diseño facilitan la creación de software fácilmente mantenible y extensible. Los patrones de diseño utilizan un conjunto de principios de diseño.
Algunas características comunes de los principios de diseño son:
- Algún tipo de modularización.
- Hay una separación de cometidos para cada modulo:
- cada módulo conoce lo que hace otro modulo, pero no sabe cómo lo hace,
- esfuerzo por conseguir alta cohesión y bajo acoplamiento.
- Identifican los aspectos de la aplicación que varían y los separan de los que son inmutables.
- Se prefiere la abstracción sobre la implementación.
- No contienen duplicación.
- Para ello se usa la abstracción,
- si se tiene un bloque de código en más de dos sitios habría que hacer un método separado,
- cuando se utiliza un valor más de una vez se debería convertir en una constante pública final.
- El diseño de las clases/métodos/módulos tiene un único propósito y una única responsabilidad.
- Minimiza la regresión1.
- Encapsulación.
- Encapsular el código que se espera o sospecha que cambie en el futuro,
- es más sencillo probar y mantener código apropiadamente encapsulado.
- Las variables y los métodos deben ser privados por defecto.
- el acceso se incrementa paso a paso (de privado a protegido, no a público).
Las estrategias comunes que utilizan los patrones de diseño son:
- Los patrones se han diseñado para utilizar interfaces.
- Favorecen la agregación y la composición sobre la herencia.
- Usan encapsulación para la variación.
- Favorecen alternativas a grandes jerarquías de herencia.
- Diseño cerrado abierto.
- Sustitución de Liskov.
- Segregación de interfaces.
- Inyección de dependencia (inversión).
Principales principios de diseño
- Programar para una interfaz.
- Composición sobre herencia.
- Principios de delegación.
- Única responsabilidad.
Características de un mal diseño
- Rigidez
- dificultad para hacer cambios,
- un único cambio conlleva una secuencia de cambios en cascada en módulos dependientes,
- cuantos más módulos hay que cambiar más rígido es el sistema.
- Fragilidad
- tendencia del software a fallar en varios sitios cuando se hace un pequeño cambio,
- a menudo ocurren problemas en lugares que no tienen una relación obvia con el área que se ha cambiado,
- según se incrementa la fragilidad de un código crece la probabilidad de que un cambio conlleve problemas inesperados.
- Inmovilidad
- contiene partes que podrían ser útiles en otros sistemas, pero el esfuerzo y riesgo de separarlas del sistema original son muy grandes.
- Viscosidad
- es más difícil usar un cambio que preserve el diseño que hacer una ñapa2
- ejecutar pruebas unitarias y hacer nuevas compilaciones lleva demasiado tiempo, lo que lleva a los desarrolladores a hacer ñapas para hacerlo más rápido
- Repeticiones innecesarias
- código con copipastes por todo el sistema,
- ocurre cuando no se han hecho las abstracciones necesarias.
- Opacidad
- tendencia de un módulo a ser difícil de entender,
- cuando el código no está escrito de una forma clara y expresiva se dice que es opaco,
- el código que evoluciona con el tiempo tiende a ir siendo más difícil de entender.
- Complejidad innecesaria
- una de las características más importantes de un mal diseño,
- en un intento de evitar los otros problemas los desarrolladores introducen demasiadas abstracciones para intentar prever todos los posibles cambios en el futuro,
- no hay que sobre-diseñar
Características de un mal diseño en Java
- Muchas clases duplican el 90% de otras
- habría que usar herencia para eliminar la duplicación.
- Demasiados clases, miembros y métodos públicos
- viola la encapsulación.
- Clases demasiado grandes
- combinando cada concepto de la aplicación en una única clase enorme
- El código es:
- difícil de leer,
- casca a menudo,
- difícil de mantener.
Principios de Diseño
Programación para interfaz
Programar para interfaz significa programar para un supertipo.
- El tipo declarado de las variables debe ser un supertipo, normalmente una clase abstracta o una interfaz.
- Los objetos asignados a esas variables pueden ser cualquier implementación concreta del supertipo.
- La clase donde se declaran no tiene porqué conocer los tipos verdaderos de los objetos.
- No declarar variables para ser instancias de una clase concreta particular. En lugar de eso comprometerse solo a una interfaz definida por una clase abstracta (que en java puede ser una clase abstracta o una interfaz).
- Siempre se programa para la interfaz, no para la implementación. Esto conlleva código flexible que pueda trabajar con cualquier implementación de la interfaz.
- Programar para la interfaz es algo común en los patrones de diseño.
- Manipular los objetos sólo en términos de la interfaz es beneficioso para los clientes.
- Los clientes no tienen que conocer los tipos específicos de los objetos que usan.
- Los clientes no necesitan conocer las clases que implementan esos objetos.
- La programación para interfaz reduce mucho las dependencias de implementación entre subsistemas.
- Podemos usar tipos de interfaz en las variables, devolver tipos de métodos o tipos de parámetros en un método.
- La cuestión es explotar el polimorfismo programando para un supertipo, de forma que el objeto real en ejecución no está bloqueado en el código.
Ejemplo
tab: Programando para la implementación
```java
Perro p = new Perro();
p.ladra();
```
tab: Programando para la interfaz
```java
Animal animal = new Perro();
animal.hazSonido();
```
tab: E incluso mejor sería:
```java
a = getAnimal():
a.hazSonido();
```
Clases abstractas vs Interfaces en Java
Ninguna de las dos (clase abstracta o interfaz) es superior a la otra. Su utilización depende de cada caso concreto.
- Utilizar clases abstractas para establecer una relación entre objetos interrelacionados.
- Usar interfaces para establecer una relación entre clases no relacionadas.
- Las interfaces Comparable y Cloneable es implementan en muchas clases no relacionadas.
- También se utilizan interfaces cuando se quiere especificar el comportamiento de un tipo de datos particular, pero no nos preocupa quién implementa su comportamiento.
- Utilizar interfaces cuando se quiera herencia múltiple.
- También se puede crear una interfaz y tener una clase abstracta que implemente dicha interfaz.
Composición sobre herencia
La herencia es la forma más obvia y sencilla de reutilizar código entre clases. Por desgracia, el uso la herencia presenta algunos problemas:
- Una subclase no puede reducir al interfaz de la superclase. Es necesario implementar todos los métodos abstractos de la clase madre aunque no se utilicen.
- Cuando se sobreescriben métodos hay que asegurarse de que el nuevo comportamiento es compatible con el original.
- La herencia rompe la encapsulación de la superclase, porque los detalles internos de la clase madre están disponibles para la subclase.
- Las subclases están estrechamente acopladas a las superclases.
- Intentar reutilizar código a través de la herencia puede conducir a crear jerarquías de herencia paralelas.
Una alternativa a la herencia es la composición. Mientras que la herencia se refiere a una relación "es-una" (un coche es medio de transporte), la composición se refiere a una relación "tiene-una" en la que un objeto contiene (o es dueño de) otro objeto como una variable miembro de su clase (un coche tiene un motor).
- La composición implica una relación donde el hijo no puede existir independientemente del padre; por ejemplo las alas de un avión, las habitaciones de una casa, o las células de un cuerpo.
- La composición se usa en muchos patrones de diseño.
La agregación es una variante más relajada de la composición, donde un objeto puede tener una referencia a otro, pero sin manejar su ciclo de vida. Por ejemplo: un coche tiene un conductor.
- La agregación implica una relación en la que los hijos pueden existir independientemente del padre; por ejemplo, aviones en un aeropuerto, estudiantes en una clase, o neumáticos en un coche.
- La agregación y la composición son prácticamente idénticas, salvo que la composición se usa cuando la vida del hijo es completamente controlada por el padre.
- Esta distinción pierde importancia en lenguajes que tienen garbage collection, como Java; ya que no es necesario preocuparse por la vida de los objetos.
Preferir la composición de objetos sobre la herencia de clases ayuda a mantener cada clase encapsulada y enfocada en una tarea.
- La herencia rompe la encapsulación porque las subclases son dependientes del comportamiento de la clase base.
- La herencia es estrechamente acoplada, mientras la composición lo es débilmente.
- Cuando el comportamiento de la superclase cambia, la funcionalidad de las subclases se puede estropear.
Java no permite la herencia múltiple, una solución alterna para conseguirla es la composición.
La composición simplifica la posibilidad de probar las clases respecto al uso de la herencia.
- Con la composición se puede proveer de forma sencilla la implementación de un mock.
Tanto la composición como la herencia fomentan la reutilización de código.
- Los diseñadores tienden a sobreutilizar la herencia como técnica de reutilización.
- Es más sencillo utilizar la composición de objetos para conseguir la reutilización de código, ya que la composición es más flexible y más extensible.
Ejemplo
Veamos este modelo de un constructor de automóviles, que hace tanto coches como camiones; pueden ser eléctricos o de gasolina; y pueden ser manuales o de conducción autónoma.
Si se hace mediante herencia, se tendría:
classDiagram
Automóvil <|-- Camión
Automóvil <|-- Coche
Camión <|-- Camión Eléctrico
Camión <|-- Camión Gasolina
Camión Eléctrico <|-- Camión Eléctrico Autónomo
Camión Gasolina <|-- Camión Gasolina Autónomo
Coche <|-- Coche Eléctrico
Coche <|-- Coche Gasolina
Coche Eléctrico <|-- Coche Eléctrico Autónomo
Coche Gasolina <|-- Coche Gasolina Autónomo
Donde cada parámetro adicional resulta en multiplicar el número de subclases, teniendo mucho código duplicado.
Modelando mediante composición se tendría:
classDiagram
class Automóvil {
- motor
- conductor
+ entregar(destino, cargo)
}
class Motor {
<<interface>>
+ mover()
}
class Conductor {
<<interface>>
+ navegar()
}
Automóvil *--> Motor
Automóvil o--> Conductor
Motor <|-- Motor Combustión
Motor <|-- Motor Eléctrico
Conductor <|-- Robot
Conductor <|-- Humano
Delegación
La delegación el concepto de que una clase "delegue" su comportamiento en otra clase.
- No hacer todo por uno mismo, delegar a su clase respectiva.
- Cuando se delega lo único que se hace es llamar a algunas clases que sepan qué es lo que se tiene que hacer; no importa cómo lo hagan, solo importa que la clase a la que se llame sepa qué es lo que se necesita.
La delegación se puede ver como una relación entre objetos donde un objeto reenvía ciertas llamadas a métodos a otro objeto, llamado su delegado.
La delegación es un ejemplo extremo de composición de objetos.
- Muestra que siempre se puede reemplazar la herencia por composición de objetos como mecanismo de reutilización de código.
- La delegación quiere decir que se usa un objeto de otra clase como una variable de instancia, y redirige mensajes a la instancia.
La delegación es mejor que la herencia en la mayoría de los casos.
- Hace pensar en cada mensaje que se reenvía
- La instancia es de una clase conocida, en lugar de una nueva clase.
- No fuerza a aceptar todos los métodos de la superclase.
- Se pueden proveer solo los métodos que realmente tengan sentido.
Ventajas
- La principal ventaja de la delegación es la flexibilidad en tiempo de ejecución.
- Facilita componer comportamientos en tiempo de ejecución y cambiar la forma en que se componen.
- La delegación es una buena elección de diseño solo cuando simplifica más de lo que complica.
- Cómo sea de efectiva depende del contexto y de la experiencia que se tenga.
- La delegación funciona mejor cuando se usa en patrones de diseño.
- Muchos patrones de diseño usan delegación.
- State
- Strategy
- Visitor
Ejemplos
- Si tu clase es B y la clase delegada A, se puede usar delegación para mejorar A si A es final.
- Los métodos Java
equals()
yhasCode()
son un ejemplo clásico de delegación.- Para comparar dos objetos para ver si son iguales, pedimos a la misma clase que haga la comparación, en lugar de utilizar una clase cliente.
- Otro ejemplo es la delegación de eventos.
- Un evento se delega a los controladores para su manejo.
Principios SOLID
SOLID es un acrónimo que designa los cinco principios destinados a hacer los diseños de software más entendibles, flexibles y mantenibles.
Estos principios son:
- S ingle Responsibility Principle
- O pen/Closed Principle
- L iskov Substitution Principle
- I nterface Segregation Principle
- D ependency Inversion Principle
Principio de Responsabilidad Única
Un objeto solo debería tener una razón para cambiar.
El principio de responsabilidad única establece que cada clase debe ser responsable sólo de una única parte de la funcionalidad que provee el software.
- La responsabilidad debe estar enteramente encapsulada por la clase.
- Todos sus métodos deben estar estrechamente alineados con esa responsabilidad.
- Cada clase debe realizar un único trabajo.
Una clase debe tener una única responsabilidad, donde una responsabilidad no es nada más que una razón para cambiar.
Debemos estar seguros de que una clase como máximo es responsable de hacer una tarea o funcionalidad de todas las responsabilidades que se tienen.
- Solo cuando se necesita un cambio en esa tarea o funcionalidad específica debe cambiar la clase.
El principio de responsabilidad única está fuertemente relacionado con los conceptos de acoplamiento y cohesión.
-
Acoplamiento es el grado de interdependencia entre las clases o métodos.
- Mide cómo de estrechamente están relacionadas dos clases o dos métodos.
- Da la fuerza de las relaciones entre las clases.
- Un acoplamiento débil significa pequeñas dependencias entre clases/métodos.
- Es más fácil cambiar el código sin introducir errores en otras clases o métodos.
- Un acoplamiento fuerte significa que dos clases/métodos están estrechamente conectados.
- Un cambio en un módulo afecta al otro módulo.
-
La cohesión se refiere a lo que la clase (o el método) puede hacer.
- Una baja cohesión significa que la clase hace una gran variedad de acciones.
- Es genérica, sin enfocarse en lo que tiene que hacer.
- La alta cohesión indica que la clase se centra en lo que tiene que hacer.
- Contiene solo métodos relativos a la intención de la clase.
- Una baja cohesión significa que la clase hace una gran variedad de acciones.
El principio de responsabilidad única trata de limitar el impacto del cambio diseñando clases con acoplamiento débil y una gran cohesión.
Ejemplos de responsabilidades
Algunos ejemplos de responsabilidades que deberían sacarse fuera de la clase son:
- Persistencia
- Validación
- Notificación
- Manejo de errores
- Logs
- Selección de clase / instanciación
- Formateo
- Parseo
- Mapeo
Ejemplo
La clase Empleado puede cambiar por varias razones. Una razón puede ser por cambios relacionados con la principal tarea de la clase: gestionar los datos del empleado; pero también puede cambiar porque se modifica el formato del horario, haciendo necesario modificar la clase.
classDiagram
class Empleado {
- nombre
+ obtenerNombre()
+ imprimirHorario()
}
Se puede solucionar el problema moviendo el comportamiento relacionado con imprimir los horarios de los trabajadores en una clase separada:
classDiagram
class Empleado {
- nombre
+ obtenerNombre()
}
class Horario {
+ imprimirHorario()
}
Horario ..> Empleado
Principio Abierto–Cerrado
El principio abierto–cerrado dice que las clases deben estar abiertas para la extensión (nuevas funcionalidades) y cerradas para la modificación.
Una clase debería ser fácilmente extensible sin modificar la clase en sí.
La idea principal de este principio es mantener el código existente sin estropearlo cuando se implementan nuevas funcionalidades.
Se dice que un módulo está abierto si todavía permite la extensión: es posible añadir campos a las estructuras de datos que contiene, o nuevos elementos a su conjunto de funciones.
En algunos lenguajes, como Java, se pueden restringir nuevas extensiones de la clase con la palabra final
. Después de esto, la clase ya no está abierta.
Se dice que un módulo está cerrado (o completo) si está 100% preparado y disponible para usarse por otros módulos.
- Se asume que el módulo ha recibido una descripción estable y bien definida.
- La interfaz en el sentido de ocultar la información (no un interfaz java)
Los términos abierto y cerrado no son exclusivos, una clase puede estar a la vez abierta (para la extensión) y cerrada (para la modificación).
La idea general de este principio es que escribas tu propio código para añadir nueva funcionalidad, sin modificar el código existente.
- Previene las situaciones en las que el cambio de una clase también requiere adaptar las clases dependientes.
- Reduce el acoplamiento
Para lograr este principio se puede utilizar herencia (lo que no da buenos resultados) o interfaces (en este caso se llama principio polimórfico abierto–cerrado).
El principio polimórfico abierto–cerrado usa interfaces en lugar de superclases para admitir diferentes implementaciones.
- Las interfaces se pueden reutilizar a través de la herencia, pero no así la implementación.
- Se pueden modificar fácilmente sin cambiar el código que las utiliza
- Se pueden crear múltiples implementaciones y sustituirse polimórficamente por otras.
Las interfaces están cerradas para las modificaciones.
- Se puede proveer nuevas implementaciones para extender la funcionalidad del software.
- Las nuevas implementaciones debe implementar la interfaz.
Las interfaces introducen un nivel adicional de abstracción que permite bajo acoplamiento. Normalmente las interfaces son independientes unas de otras y no necesitan compartir código.
Ejemplo
En una aplicación se tiene una clase Pedido
que calcula los costes de envío y todos los métodos de envío están codificados dentro de la clase. Si se necesita añadir un nuevo método de envío hay que cambiar el código de la clase Pedido
con el riesgo de estropearla.
classDiagram
class Pedido {
- artículos
- envio
+ getTotal()
+ getPesoTotal()
+ setTipoEnvio(String)
+ getCosteEnvio()
+ getFechaEnvio()
}
if (envio == "terrestre") {
//Envío terrestre gratuito en pedidos grandes
if (getTotal > 100) {
return 0;
}
//1.5€ por kilogramo, pero como mínimo 10€
return max(10, getPesoTotal() * 1.5);
}
if (envio == "aereo") {
//3€ por kilogramo, pero como mínimo 20€
return max(20, getPesoTotal() * 3);
}
Se puede resolver el problema extrayendo los métodos de envío en clases separadas con un interfaz común (patrón Strategy).
classDiagram
class Pedido {
- artículos
- envio: Envio
+ getTotal()
+ getPesoTotal()
+ setTipoEnvio(envio)
+ getCosteEnvio()
+ getFechaEnvio()
}
class Envio {
<<interface>>
+ getCoste(pedido)
+ getFecha(pedido)
}
Pedido o--> Envio
Envio <|.. Terrestre
Envio <|.. Aereo
Terrestre: + getCoste(pedido)
Terrestre: + getFecha(pedido)
Aereo: + getCoste(pedido)
Aereo: + getFecha(pedido)
return envio.getCoste(this)
//Envío terrestre gratuito en pedidos grandes
if (getTotal > 100) {
return 0;
}
//1.5€ por kilogramo, pero como mínimo 10€
return max(10, getPesoTotal() * 1.5);
Ahora si se quiere implementar un nuevo método de envío, se puede derivar una nueva clase de la interfaz Envio sin tocar el código de la clase Pedido.
Además, se podría mover el cálculo del tiempo de entrega a otras clases más relevantes, según el principio de responsabilidad única.
Principio de sustitución de Liskov
El principio de sustitución de Liskov define que los objetos de una superclase se pueden reemplazar con objetos de sus subclases sin romper la aplicación.
- Requiere que los objetos de las subclases se comporten de la misma forma que los objetos de su superclase.
- Los métodos que usan un tipo de una superclase deben ser capaces de trabajar con las subclases sin ningún problema.
Todo método sobrecargado de una subclase necesita aceptar los mismos parámetros de entrada que el método de la superclase.
- No implementar ninguna validación estricta en los parámetros de entrada que ya esté implementada en la clase padre.
El valor de retorno de un métodos de la subclase tiene que cumplir las mismas reglas que el valor de retorno del método de la superclase.
Para seguir el principio de sustitución de Liskov la subclase debe ampliar funcionalidad, pero no reducir funcionalidad.
El mecanismo de herencia de Java sigue el principio de sustitución de Liskov.
El LSP 3 está muy relacionado con los principios de responsabilidad única y de segregación. También extiende el principio abierto–cerrado.
El principio de sustitución tiene un conjunto de requisitos formales para las subclases y sus métodos:
-
Los tipos de parámetros en un método de una subclase deben coincidir o ser más abstractos que los tipos de parámetros en el método de la superclase.
Ejemplo. Tenemos una clase con un método para alimentar gatos:
alimenta(Gato g)
. El código cliente siempre pasa objetos gato a este método.- Correcto: se crea una subclase que sobreescribe el método de forma que se pueda alimentar cualquier animal (una superclase de gatos):
alimenta(Animal a)
. Ahora si se pasara un objeto de esta subclase en lugar de un objeto de la superclase al código cliente, todo seguiría funcionando. El método puede alimentar a todos los animales, así que también puede alimentar a cualquier gato pasado por el cliente. - Incorrecto: se crea otra subclase y se restringe el método para que solo acepte gatos de angora:
alimenta(GatoAngora g)
. Como el método solo alimenta a una raza específica de gatos, no serviría para cualquier gato genérico pasado por el cliente, estropeando toda la funcionalidad relacionada.
- Correcto: se crea una subclase que sobreescribe el método de forma que se pueda alimentar cualquier animal (una superclase de gatos):
-
El tipo devuelto por un método de una subclase debe coincidir o ser un subtipo del tipo devuelto por el método de la superclase.
Ejemplo. Tenemos una clase con un método
compraGato(): Gato
. El código cliente espera recibir un gato como resultado de ejecutar este método.- Correcto: una subclase sobrescribe el método de la forma:
compraGato(): GatoAngora
. El cliente obtiene un gato de angora, que sigue siendo un gato, así que todo está bien. - Incorrecto: una subclase sobrescribe el método de la forma:
compraGato(): Animal
. Ahora el código cliente falla ya que recibe un animal genérico, que no encaja en la estructura definida para un gato.
- Correcto: una subclase sobrescribe el método de la forma:
-
Un método de una subclase no debería arrojar excepciones de un tipo que no pueda arrojar el método base.
-
Una subclase no debería fortalecer las precondiciones.
Por ejemplo, si el método base tiene un parámetro de tipo
int
, y una subclase sobreescribe este método requiriendo que el valor pasado al método tenga que ser positivo, esto restringe la precondición. El código cliente que funcionaba bien cuando pasaba enteros negativos al método, ahora falla si trabaja con un objeto de esta subclase. -
Una subclase no debería debilitar las postcondiciones.
Por ejemplo, tenemos una clase con un método que funciona con una base de datos, el método cierra todas las conexiones abiertas al devolver un valor. Ahora se crea una subclase y se dejan las conexiones abiertas, porque se quieren reutilizar. Esto puede llevar a tener conexiones fantasmas si se termina el programa después de llamar a estos métodos.
-
Los invariantes de una superclase se deben preservar. Los invariantes son condiciones que tienen sentido para un objeto; por ejemplo, los invariantes de un gato son tener cuatro patas, una cola, etc.
La regla de los invariantes es la más fácil de violar porque puede que no conozcamos o nos demos cuenta de todos los invariantes de una clase compleja.
-
Una subclase no debería cambiar valores de campos privados de la superclase.
Ejemplo
El siguiente ejemplo de jerarquía de clases de documentos viola el principio de sustitución.
classDiagram
class Documento {
- datos
- nombreFichero
+ abrir()
+ guardar()
}
class Proyecto {
- documentos
+ abrirTodos()
+ guardarTodos()
}
class DocumentoSoloLectura {
+ guardar()
}
Proyecto *--> Documento
Documento <|-- DocumentoSoloLectura
foreach (doc in documentos) {
doc.abrir()
}
foreach (doc in documentos) {
if (!doc instanceOf DocumentoSoloLectura) {
doc.guardar()
}
}
throw new Exception("No se puede guardar un documento de solo lectura")
El método guardar
de la subclase DocumentoSoloLectura
lanza una excepción si alguien lo llama. El método base no tiene esta restricción, lo que implica que el código cliente fallará si no comprobamos el tipo del documento antes de guardarlo.
Este código también viola el principio abierto/cerrado, ya que el código del cliente se vuelve dependiente de la clase concreta de los documentos. Si se introdujera una nueva subclase de documentos, habría que cambiar el código cliente para soportarla.
Solución. El problema se soluciona haciendo la clase DocumentoSoloLectura
la clase básica de la jerarquía.
classDiagram
class Documento {
- datos
- nombreFichero
+ abrir()
}
class Proyecto {
- documentosTodos
- documentosModificables
+ abrirTodos()
+ guardarTodos()
}
class DocumentModificable {
+ guardar()
}
Proyecto *--> Documento
Documento <|-- DocumentModificable
foreach (doc in documentosTodos) {
doc.abrir()
}
foreach (doc in documentosModificables) {
doc.guardar()
}
Ahora documento modificable es una subclase que extiende la clase básica añadiendo el comportamiento de guardado.
Principio de Segregación de Interfaz
El Principio de Segregación de Interfaz dice que los clientes no deberían depender de interfaces que no usen.
- Los clientes no deberían implementar interfaces, si no usan todos los métodos de dicha interfaz.
- Suele pasar que una interfaz contenga más de una funcionalidad y el cliente necesite una y no la otra.
El objetivo de este principio es reducir los efectos y la frecuencia de los cambios dividiendo el software en múltiples partes independientes.
El diseño de las interfaces es muy complicado porque una vez que se libera la interfaz no se puede modificar sin romper toda la implementación.
Hay que intentar hacer las interfaces suficientemente finas para que las clases cliente no tengan que implementar comportamientos que no necesitan.
El principio de segregación de interfaz evita los inconvenientes asociados con las "inferfaces gordas" refactorizando la interfaz gorda en múltiples interfaces segregadas. Cada interfaz segregada es una "interfaz delgada" que solo contiene métodos que requiere un cliente específico.
Ejemplo
public interface RestaurantInterface {
public void acceptOnlineOrder();
public void takeTelephoneOrder();
public void payOnline();
public void walkInCustomerOrder();
public void payInPerson();
}
class OnlineClientImpl implements RestaurantInterface
{
@Override
public void acceptOnlineOrder() {
//logic for placing online order
}
@Override
public void takeTelephoneOrder() {
//Not Applicable for Online Order
throw new UnsupportedOperationException();
}
@Override
public void payOnline() {
//logic for paying online
}
@Override
public void walkInCustomerOrder() {
//Not Applicable for Online Order
throw new UnsupportedOperationException();
}
@Override
public void payInPerson() {
//Not Applicable for Online Order
throw new UnsupportedOperationException();
}
}
Esta interfaz está rompiendo el Principio de Segregación de Interfaz, ya que en este caso de los cinco métodos que tiene que implementar la clase, tres no tienen sentido.
También está rompiendo el Principio de Responsabilidad Única, ya que la interfaz tiene responsabilidades tanto sobre el pago como sobre los pedidos.
Principio de Inversión de Dependencia
Las clases de alto nivel no deberían depender de las clases da bajo nivel. Ambas deberían depender de abstracciones. Las abstracciones no debería depender de los detalles. Los detalles deberían depender de las abstracciones.
El Principio de Inversión de Dependencia establece que las entidades deben depender de abstracciones y no de concreciones. El objetivo es reducir las dependencias en clases concretas.
Las abstracciones no deben depender de los detalles; son los detalles los que deben depender de las abstracciones.
Las clases de alto nivel no deben depender de las clases de bajo nivel.
- Las clases tanto de alto como de bajo nivel deben depender de las abstracciones.
- La implementación de las clases de bajo nivel es accesible por las clases de alto nivel mediante una interfaz abstracta.
- Las implementación de las clases de bajo nivel puede variar.
Este principio "invierte" la forma de pensar clásica en el diseño orientado a objetos.
- Se invierte la dependencia de arriba abajo, con las clases de alto y bajo nivel dependiendo de la abstracción.
La inversión de dependencia es un principio universal subyacente en el uso de los patrones de diseño.
Habitualmente cuando se diseña software se pueden distinguir dos tipos de clases:
- Clases de bajo nivel, que implementan operaciones básicas como trabajo con un disco, transferencia de datos por la red, conexión a base de datos, etc.
- Clases de alto nivel, que contienen lógica de negocio compleja que manda a las clases de bajo nivel haacer algo.
A menudo se diseñan primero las clases de bajo nivel y después se empieza a trabajar en las de alto nivel. Con esta forma de trabajar las clases de lógica de negocio tienden a volverse dependientes de clases primitivas de bajo nivel.
El principio de inversión de dependencia sugiere cambiar la dirección de esta dependencia.
Ventajas
- Elimina el estrecho acoplamiento que conlleva el diseño mediante una aproximación top-down, en el que cada clase de alto nivel está estrechamente acoplada con su clase concreta de bajo nivel.
- Introduce una capa de abstracción entre cada clase de alto nivel y su clase concreta de bajo nivel.
- Las clases de alto nivel dependen solo de una abstracción común.
- Las clases de bajo nivel se pueden modificar o extender sin miedo de perjudicar a las clases de alto nivel.
Buenas prácticas
- Ninguna variable debe mantener referencias a una clase concreta.
- Para conseguirlo se utiliza el patrón de diseño factory.
- Ninguna clase debe ser subclase de una clase concreta.
- Ningún método debe sobreescribir un método implementado por alguna de sus clases base.
Ejemplo
En este ejemplo la clase de alto nivel de informe presupuestario usa una clase de bajo nivel de base de datos para leer y persistir sus datos. Esto quiere decir que cualquier cambio en la clase de bajo nivel, como una nueva versión de la base de datos, afectará a la clase de alto nivel.
classDiagram
class InformePresupuesto {
<<AltoNivel>>
- baseDeDatos
+ abrir(datos)
+ guardar()
}
class MiBaseDeDatosMySQL {
<<BajoNivel>>
+ insert()
+ update()
+ delete()
}
InformePresupuesto --> MiBaseDeDatosMySQL
Este problema se puede solucionar creando un interfaz de alto nivel que describa las operaciones de lectura/escritura, de forma que la clase de informe use dicha interfaz en lugar de la clase de bajo nivel. Ahora se puede modificar o ampliar la clase de bajo nivel, implementando la nueva interfaz de lectura/escritura declarada por la lógica de negocio.
classDiagram
class InformePresupuesto {
<<AltoNivel>>
- baseDeDatos
+ abrir(datos)
+ guardar()
}
class MySQL {
<<BajoNivel>>
+ insert()
+ update()
+ delete()
}
class MongoDB {
<<BajoNivel>>
+ insert()
+ update()
+ delete()
}
class BaseDatos {
<<AltoNivel>>
<<Abstracción>>
<<interface>>
+ insert()
+ update()
+ delete()
}
InformePresupuesto --> BaseDatos
BaseDatos <|.. MySQL
BaseDatos <|.. MongoDB
La dirección de la dependencia original se ha invertido: las clases de bajo nivel ahora dependen de las abstracciones de alto nivel.
Principio de Inyección de Dependencia
Este principio está muy relacionado con el anterior (inversión de dependencia) y con el principio de programación para interfaz.
Dependencias
Una clase Java tiene dependencia de otra clase si usa una instancia de dicha clase.
Las clases java deberían ser tan independientes como fuera posible de otras clases java.
Si una clase java crea una instancia de otra clase vía el operador new
no se puede utilizar (ni testear) independientemente de esta clase. Esto se llama dependencia dura.
La inyección de dependencia resuelve estas dependencias duras.
Inyección de dependencia
La inyección de dependencia es una técnica por la cual un objeto proporciona las dependencias de otro objeto. Permite reemplazar dependencias sin cambiar la clase que las usa.
Una dependencia es un objeto que se puede usar (un servicio). Una inyección es pasar la dependencia a un objeto dependiente (un cliente) que la usaría.
La inyección de dependencia es una forma de la técnica más amplia de inversión de dependencia.
El cliente delega la responsabilidad de proveer sus dependencias a código externo (el inyector).
Los cuatro roles de la inyección de dependencia
Si queremos usar la inyección de dependencia se necesitan clases que cumplan cuatro roles básicos:
- El servicio que se quiere usar.
- El cliente que usa el servicio.
- Una interfaz que es usada por el cliente e implementada por el servicio.
- El inyector que crea una instancia del servicio y la inyecta en el cliente.
Con el principio de inversión de dependencia se tienen los tres primeros roles.
Tipos de inyección
- Inyección por constructor.
- Inyección por setter.
- Inyección por interfaz.
Ejemplo
public interface Service {
void write(String message);
}
class ServiceA implements Service {
@Override
public void write(String message)
{
System.out.println("Hello World");
}
}
class Client {
private Service myService;
// injects via the constructor
public Client (Service service)
{
this.myService = service;
}
public void doSomething() {
myService.write("This is a message");
}
public void setService(Service service) {
this.myService = service;
}
public static void main(String [] args) {
Service service = new ServiceA(); // the injector
Client client = new Client(service); // injects via the constructor
client.doSomething();
client.setService(service); // injects via a setter injection
}
}
1. Una regresión se refiere a cuando algo que estaba funcionando correctamente empieza a fallar debido a un cambio en otra parte, que en principio no tiene que ver con lo que empieza a fallar.
2. he traducido hack como ñapa.
3. Liskov Substitution Principle.
Referencias
Libros
- Patrones de diseño, Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides
- Dive into Design Patterns, Alexander Shvets
- Head First Design Patterns
- Applying UML and patterns, Craig Lanman
- UMl y patrones, Craig Lanman
- AA Roger S. Pressman: Ingeniería del Software. Un enfoque práctico. McGraw Hill, 1993
- Eric J. Braude: Ingeniería de Software. Una perspectiva orientada a objetos. Ra-Ma, 2003
- Software Requirements, Karl Wiegers, Joy Beatty
Cursos
- The Java Design Patterns Course (Udemy), Jason Fedin
- GoF Design Patterns (Udemy), Andrii Piatakha
Online
- http://rmtoo.florath.net/