SESIÓN 18:
INTRODUCCIÓN A LOS PRINCIPIOS SOLID
EN JAVA
1. Introducción al Diseño Orientado a Objetos.
1.1. Concepto de diseño orientado a objetos. DOO
El reciente aumento de aplicaciones en donde se utiliza la computadora ha sido
posible debido a un hardware de bajo costo, por lo cual la demanda de software ha
crecido de forma exponencial. Esto implica que son necesarias técnicas y tecnología
eficientes de Ingeniería de Software para resolver los múltiples problemas que se
derivan de las aplicaciones en donde se desarrollan sistemas software de gran
tamaño.
La Ingeniería de Software implica seguir en cualquier proyecto de software una
metodología de desarrollo y la utilización de distintas técnicas y herramientas. Los
diferentes procedimientos a seguir en cualquier proyecto de Ingeniería de software
son:
- Definición de requerimientos.
- Análisis.
- Diseño.
- Verificación y Validación (Pruebas de Calidad del Software)
- Pruebas
- Mantenimiento.
El presente documento intenta dar a conocer y describir los conceptos y aspectos
fundamentales del diseño orientado a objetos (DOO) dentro del desarrollo de un
producto de software, así como las técnicas, metodologías y herramientas actuales
de dicho paradigma en la Ingeniería de software.
Así pues, definimos Diseño de Software como la acción de construir soluciones
que satisfagan los requerimientos del cliente. Existen varias etapas en el proceso
de diseño de software, a saber son:
✓ Entendimiento del problema
✓ Identificar una o mas soluciones
✓ Describir abstracciones de la solución
✓ Repetir el proceso para cada abstracción identificada hasta que el diseño
este expresado en términos sencillos
Cualquier diseño debe ser modelado como una gráfica dirigida hecha de entidades
con atributos los cuales participan en relaciones. El sistema debe estar descrito a
distintos niveles de abstracción y el diseño ocurre en etapas que se traslapan.
La primera idea que se tiene al construir una solución de un determinado problema
es un modelo mental que constituye el primer intento de diseño llamado
comúnmente diseño informal. Este diseño a medida que se va describiendo en
papel utilizando técnicas y procedimientos esquemáticos y metódicos va
adquiriendo forma hasta constituirse en un diseño formal equivalente.
La siguiente figura ejemplifica este hecho:
Pues bien, dentro del paradigma de la orientación a objetos, el diseño OO es con
mucho; más complejo que el diseño estructurado clásico, ya que lo que se busca
es crear un diseño genérico y abierto y no cerrado y concreto.
El Diseño Orientado a Objetos se define como un diseño de sistemas que utiliza
objetos auto-contenidos y clases de objetos.
1.2. Características principales del Diseño Orientado a Objetos:
✓ Los objetos son abstracciones del mundo real o entidades del sistema que
se administran entre ellas mismas
✓ Los objetos son independientes y encapsulan el estado y la representación
de información.
✓ La funcionalidad del sistema se expresa en términos de servicios de los
objetos.
✓ Las áreas de datos compartidas son eliminadas. Los objetos se comunican
mediante paso de parámetros.
✓ Los objetos pueden estar distribuidos y pueden ejecutarse en forma
secuencial o en paralelo.
1.3. Ventajas del Diseño Orientado a Objetos:
✓ Fácil de mantener, los objetos representan entidades auto-contenidas.
✓ Los objetos son componentes reutilizables.
✓ Para algunos sistemas, puede haber un mapeo obvio entre las entidades del
mundo real y los objetos del sistema.
1.4. Desarrollo Orientado a Objetos:
✓ El análisis, diseño y programación orientada a objetos están
relacionados pero son diferentes.
✓ El análisis orientado a objetos concierne al desarrollo del modelo de
objetos del dominio de la aplicación.
✓ El Diseño Orientado a Objetos trata del desarrollo del modelo del
sistema orientado a objetos para implementar los requerimientos.
✓ La programación orientada a objetos trata de la realización del
Diseño Orientado a Objetos utilizando algún lenguaje de
programación orientada a objetos como Java
1.5. Métodos de Diseño Orientado a Objetos
✓ Algunos métodos que fueron originalmente basados en funciones (método
de Yourdon) han sido adaptadas al diseño orientado a objetos. Otros
métodos como el método de Booch han sido específicamente desarrolladas
específicamente para el Diseño Orientado a Objetos
✓ El Diseño Orientado a Objetos es un método de diseño desarrollado para
soportar la programación en Ada.
✓ JSD (Jackson system development) tiene una cierta orientación a objetos
pero no contiene información sobre estados entidad
1.6. Componentes del Diseño Orientado a Objetos
✓ La identificación de objetos, sus atributos y servicios
✓ La organización de objetos dentro de una jerarquía
✓ La construcción de descripciones dinámicas de objetos que muestran
como se usan los servicios
✓ La especificación de interfaces de objetos
2. Diseño De Sistemas Orientados A objetos:
El diseño Orientado a Objetos (DOO) difiere considerablemente del diseño
estructurado ya que en DOO no se realiza un problema en términos de tareas
(subrutinas) ni en términos de datos, sino (como ya se vio en la introducción) se
analiza el problema como un sistema de objetos que interactúan entre sí. Un
problema desarrollado con técnicas orientadas a objetos requiere, en primer
lugar saber cuáles son los objetos del programa. Como tales objetos son instancias
de clases, la primera etapa en el desarrollo orientado a objetos requiere de la
identificación de dichas clases (atributos y comportamiento), así como las relaciones
entre éstas y su posterior implementación en un lenguaje de programación. Existen
numerosos métodos de diseño orientado a objetos: Booch, Yourdon-Coad, Martín,
Shlaer & Mellor, Rumbaugh, por citar algunos. Pero en general como ocurre en
cualquier proyecto estructurado, un proyecto software OO se compone de las
siguientes etapas:
✓ Análisis Orientado a Objetos (AOO)
✓ Diseño Orientado a Objetos (DOO)
✓ Programación Orientada a Objetos (POO)
Aunque no siempre están bien delimitadas las etapas de análisis y diseño en la OO,
se pueden sintetizar de alguna forma las ideas claves de las distintas tecnologías
existentes dentro del desarrollo orientado a objetos al que denominaremos diseño.
El método de Booch considera que las etapas del proceso en un desarrollo
orientado a objetos son:
1. Identificar las claves y objetos en un nivel dado de abstracción
2. Identificar la semántica de estas clases y objetos
3. Identificar las relaciones entre clases y objetos
4. Especificar la interfaz y la implementación de estas clases y objetos
Estas etapas suelen seguirse por la mayoría de los métodos de diseño OO
existentes. De hecho, para los sistemas orientados a objetos se define el siguiente
diseño en pirámide que contempla el método de Booch.
- La capa del subsistema.- Contiene una representación de cada uno de los
subsistemas que le permiten al software conseguir los requisitos definidos por el
cliente e implementar la infraestructura técnica que los soporta.
- La capa de clases y Objetos.- Contiene las jerarquías de clase que permiten crear
el sistema usando generalizaciones y especializaciones mejor definidas. Esta capa
también contiene representaciones de diseño para cada objeto.
- La capa de mensajes.- Contiene los detalles que le permiten a cada objeto
comunicarse con sus colaboradores. Esta capa establece las interfaces externas e
internas para el sistema.
- La capa de responsabilidades.- Contiene las estructuras de datos y el diseño
algorítmico para todo los atributos y operaciones de cada objeto.
Esta pirámide de diseño se centra entonces en el diseño de un producto o sistema
específico.
3. El Enfoque Convencional Y El Enfoque OO
Los enfoques convencionales para el diseño del software aplican notaciones y
heurísticas diferentes para establecer correspondencias entre el modelo de análisis
y el diseño. Si recordamos la ingeniería de software clásica (imperativa), veremos
que cada elemento del modelo de análisis convencional tiene correspondencia con
una o más capas del modelo de diseño tal como lo ilustra la siguiente figura:
Al igual que el diseño de software convencional, el DOO aplica diseño de datos
(cuando se representan atributos), diseño de interfaces (cuando se presenta el
intercambio de mensajes) y diseño procedimental (en el diseño de operaciones), no
obstante, el diseño arquitectónico es diferente.
La arquitectura de diseño OO se centra más en las colaboraciones entre los objetos
que con el flujo de control de datos. De esta manera las capas de la pirámide se
renombran para reflejar de forma más exacta la naturaleza del DOO. La siguiente
figura muestra ahora la correspondencia entre el AOO con las correspondientes
capas de la pirámide de diseño OO.
4. EL DISEÑO:
Bertrand Meyer sugiere los siguientes criterios para poder juzgar la capacidad que
posee un método de diseño en poder lograr ciertos elementos importantes tales
como la modularidad:
- Descomponibilidad.- Facilidad con la cual un método de diseño ayuda al
diseñadora descomponer un gran problema en subproblemas más sencillos
de resolver.
- Componibilidad.- Grado con el cual un método de diseño asegura que los
componentes de un programa (módulos), una vez diseñados y construidos,
pueden reusarse para crear otros sistemas.
- Comprensibilidad.- Facilidad de comprensión de un componente de
programa sin referencia a otra información o módulos.
- Continuidad.- Facilidad de hacer pequeños cambios en un programa y hacer
que estos se manifiesten por sí mismos en cambios correspondientes
solamente en no o unos pocos módulos más.
- Protección.- Característica arquitectónica que reducirá la propagación de
efectos colaterales si ocurre un error en un módulo dado.
Estos criterios y principios de diseño presentados por Meyer pueden aplicarse a
cualquier método de diseño (incluyendo diseño estructurado), no obstante el
método de diseño orientado a objetos alcanza cada uno de los principios de manera
más eficiente que otros enfoques y el resultado final es una arquitectura modular
que permite cumplir con todos los principios de modularidad de una manera más
eficiente.
5. Conceptos de Cohesión y Acoplamiento
Uno de los objetivos más importantes del diseño orientado a objetos es conseguir
una alta cohesión entre clases y un bajo acoplamiento.
5.1.
5.2. ¿Qué es la cohesión?
La medida que indica si una clase tiene una función bien definida dentro del sistema.
El objetivo es enfocar de la forma más precisa posible el propósito de la clase.
Cuanto más enfoquemos el propósito de la clase, mayor será su cohesión.
Una prueba fácil de cohesión consiste en examinar una clase y decidir si todo su
contenido está directamente relacionado con el nombre de la clase y descrito por el
mismo.
Una alta cohesión hace más fácil:
✓ Entender qué hace una clase o método.
✓ Usar nombres descriptivos.
✓ Reutilizar clases o métodos.
5.3. ¿Qué es el acoplamiento?
El acoplamiento entre clases es una medida de la interconexión o dependencia
entre esas clases.
El acoplamiento fuerte significa que las clases relacionadas necesitan saber detalles
internos unas de otras, los cambios se propagan por el sistema y el sistema es
posiblemente más difícil de entender.
Por ello deberemos siempre intentar que nuestras clases tengan un acoplamiento
bajo. Cuantas menos cosas conozca la clase A sobre la clase B, menor será su
acoplamiento.
Lo ideal es conseguir que la clase A sólo conozca de la clase B lo necesario para
que pueda hacer uso de los métodos de la clase B, pero no conozca nada acerca
de cómo estos métodos o sus atributos están implementados.
Los atributos de una clase deberán ser privados y la única forma de acceder a
ellos debe ser a través de los métodos getter y setter.
Un bajo acoplamiento permite:
✓ Entender una clase sin leer otras.
✓ Cambiar una clase sin afectar a otras.
✓ Mejora la mantenibilidad del código.
6. Principios SOLID
POO (Programación Orientada a Objetos) nos permite agrupar entidades con
funcionalidades parecidas o relacionadas entre sí, pero esto no implica que los
programas no se vuelvan confusos o difíciles de mantener.
De hecho, muchos programas acaban volviéndose un monstruo al que se va
alimentando según se añaden nuevas funcionalidades, se realiza mantenimiento,
etc…
Viendo este problema, Robert C. Martin estableció cinco directrices o principios para
facilitarnos a los desarrolladores la labor de crear programas legibles y mantenibles.
Estos principios se llamaron S.O.L.I.D. por sus siglas en inglés:
• S: Single responsibility principle o Principio de responsabilidad única
• O: Open/closed principle o Principio de abierto/cerrado
• L: Liskov substitution principle o Principio de sustitución de Liskov
• I: Interface segregation principle o Principio de segregación de la interfaz
• D: Dependency inversion principle o Principio de inversión de
dependencia
Aplicar estos principios facilitará mucho el trabajo, tanto propio como ajeno (es muy
probable que tu código lo acabe leyendo muchos otros desarrolladores a lo largo de
su ciclo de vida). Algunas de las ventajas de aplicarlo son:
• Mantenimiento del código más fácil y rápido
• Permite añadir nuevas funcionalidades de forma más sencilla
• Favorece una mayor reusabilidad y calidad del código, así como la
encapsulación
Vamos a ver en detalle cada uno de estos principios, junto a ejemplos básicos, que,
a pesar de no ser aplicables en el mundo real, espero que aporten la suficiente
claridad para que seas capaz de entender y aplicar estos principios en tus
desarrollos.
6.1. S: Principio de responsabilidad única
Como su propio nombre indica, establece que una clase, componente o
microservicio debe ser responsable de una sola cosa (el tan aclamado término
“decoupled” en inglés). Si por el contrario, una clase tiene varias responsabilidades,
esto implica que el cambio en una responsabilidad provocará la modificación en otra
responsabilidad.
Considera este ejemplo:
¿Por qué este código viola el principio de responsabilidad única? Para un minuto y
piensa un poco ;)
Como podemos observar, la clase Coche permite tanto el acceso a las propiedades
de la clase como a realizar operaciones sobre la BBDD, por lo que la clase ya tiene
más de una responsabilidad.
Supongamos que debemos realizar cambios en los métodos que realizan las
operaciones a la BBDD. En este caso, además de estos cambios, probablemente
tendríamos que tocar los nombres o tipos de las propiedades, métodos, etc, cosa
que no parece muy eficiente porque solo estamos modificando cosas que tienen
que ver con la BBDD, ¿verdad?
Para evitar esto, debemos separar las responsabilidades de la clase, por lo que
podemos crear otra clase que se encargue de las operaciones a la BBDD:
Nuestro programa será mucho más cohesivo y estará más encapsulado aplicando
este principio.
6.2. O: Principio abierto/cerrado
Establece que las entidades software (clases, módulos y funciones) deberían estar
abiertos para su extensión, pero cerrados para su modificación.
Si seguimos con la clase Coche:
Si quisiéramos iterar a través de una lista de coches e imprimir sus marcas por
pantalla:
Esto no cumpliría el principio abierto/cerrado, ya que si decidimos añadir un nuevo
coche de otra marca:
También tendríamos que modificar el método que hemos creado anteriormente:
Como podemos ver, para cada nuevo coche habría que añadir nueva lógica al
método precioMedioCoche(). Esto es un ejemplo sencillo, pero imagina que tu
aplicación crece y crece… ¿cuántas modificaciones tendríamos que hacer? Mejor
evitarnos esta pérdida de tiempo y dolor de cabeza, ¿verdad?
Para que cumpla con este principio podríamos hacer lo siguiente:
Cada coche extiende la clase abstracta Coche e implementa el método abstracto
precioMedioCoche().
Así, cada coche tiene su propia implementación del método precioMedioCoche(),
por lo que el método imprimirPrecioMedioCoche() itera el array de coches y solo
llama al método precioMedioCoche().
Ahora, si añadimos un nuevo coche, precioMedioCoche() no tendrá que ser
modificado. Solo tendremos que añadir el nuevo coche al array, cumpliendo así el
principio abierto/cerrado.
6.3. L: Principio de substitución de Liskov
Declara que una subclase debe ser sustituible por su superclase, y si al hacer esto,
el programa falla, estaremos violando este principio.
Cumpliendo con este principio se confirmará que nuestro programa tiene una
jerarquía de clases fácil de entender y un código reutilizable.
Veamos un ejemplo:
Esto viola tanto el principio de substitución de Liskov como el de abierto/cerrado. El
programa debe conocer cada tipo de Coche y llamar a su método numAsientos()
asociado.
Así, si añadimos un nuevo coche, el método debe modificarse para aceptarlo.
Para que este método cumpla con el principio, seguiremos estos principios:
• Si la superclase (Coche) tiene un método que acepta un parámetro del tipo
de la superclase (Coche), entonces su subclase (Renault) debería aceptar
como argumento un tipo de la superclase (Coche) o un tipo de la subclase
(Renault).
• Si la superclase devuelve un tipo de ella misma (Coche), entonces su
subclase (Renault) debería devolver un tipo de la superclase (Coche) o un
tipo de la subclase (Renault).
Si volvemos a implementar el método anterior:
Ahora al método no le importa el tipo de la clase, simplemente llama al método
numAsientos() de la superclase. Solo sabe que el parámetro es de tipo coche, ya
sea Coche o alguna de las subclases.
Para esto, ahora la clase Coche debe definir el nuevo método:
Y las subclases deben implementar dicho método:
Como podemos ver, ahora el método imprimirNumAsientos() no necesita saber con
qué tipo de coche va a realizar su lógica, simplemente llama al método
numAsientos() del tipo Coche, ya que por contrato, una subclase de Coche debe
implementar dicho método.
6.4. I: Principio de segregación de interfaz
Este principio establece que los clientes no deberían verse forzados a depender de
interfaces que no usan.
Dicho de otra manera, cuando un cliente depende de una clase que implementa una
interfaz cuya funcionalidad este cliente no usa, pero que otros clientes sí usan, este
cliente estará siendo afectado por los cambios que fuercen otros clientes en dicha
interfaz.
Imaginemos que queremos definir las clases necesarias para albergar algunos tipos
de aves. Por ejemplo, tendríamos loros, tucanes y halcones:
Hasta aquí todo bien. Pero ahora imaginemos que queremos añadir a los pingüinos.
Estos son aves, pero además tienen la habilidad de nadar. Podríamos hacer esto:
El problema es que el loro no nada, y el pingüino no vuela, por lo que tendríamos
que añadir una excepción o aviso si se intenta llamar a estos métodos. Además, si
quisiéramos añadir otro método a la interfaz IAve, tendríamos que recorrer cada una
de las clases que la implementa e ir añadiendo la implementación de dicho método
en todas ellas. Esto viola el principio de segregación de interfaz, ya que estas clases
(los clientes) no tienen por qué depender de métodos que no usan.
Lo más correcto sería segregar más las interfaces, tanto como sea necesario. En
este caso podríamos hacer lo siguiente:
Así, cada clase implementa las interfaces de la que realmente necesita implementar
sus métodos. A la hora de añadir nuevas funcionalidades, esto nos ahorrará
bastante tiempo, y además, cumplimos con el primer principio (Responsabilidad
Única).
6.5. D: Principio de inversión de dependencias
Establece que las dependencias deben estar en las abstracciones, no en las
concreciones. Es decir:
• Los módulos de alto nivel no deberían depender de módulos de bajo nivel.
Ambos deberían depender de abstracciones.
• Las abstracciones no deberían depender de detalles. Los detalles deberían
depender de abstracciones.
En algún momento nuestro programa o aplicación llegará a estar formado por
muchos módulos. Cuando esto pase, es cuando debemos usar inyección de
dependencias, lo que nos permitirá controlar las funcionalidades desde un sitio
concreto en vez de tenerlas esparcidas por todo el programa. Además, este
aislamiento nos permitirá realizar testing mucho más fácilmente.
Supongamos que tenemos una clase para realizar el acceso a datos, y lo hacemos
a través de una BBDD:
Imaginemos que en el futuro queremos cambiar el servicio de BBDD por un servicio
que conecta con una API. Para un minuto a pensar qué habría que hacer... ¿Ves el
problema? Tendríamos que ir modificando todas las instancias de la clase
AccesoADatos, una por una.
Esto es debido a que nuestro módulo de alto nivel (AccesoADatos) depende de un
módulo de más bajo nivel (DatabaseService), violando así el principio de inversión
de dependencias. El módulo de alto nivel debería depender de abstracciones.
Para arreglar esto, podemos hacer que el módulo AccesoADatos dependa de una
abstracción más genérica:
Así, sin importar el tipo de conexión que se le pase al módulo AccesoADatos, ni este
ni sus instancias tendrán que cambiar, por lo que nos ahorraremos mucho trabajo.
Ahora, cada servicio que queramos pasar a AccesoADatos deberá implementar la
interfaz Conexion:
Así, tanto el módulo de alto nivel como el de bajo nivel dependen de abstracciones,
por lo que cumplimos el principio de inversión de dependencias. Además, esto nos
forzará a cumplir el principio de Liskov, ya que los tipos derivados de Conexion
(DatabaseService y APIService) son sustituibles por su abstracción (interfaz
Conexion).